Compare commits

..
Author SHA1 Message Date
GitHub Actions
644cbe6564 Automatic update of packages
Some checks failed
Python / detect-packages (push) Has been cancelled
TypeScript / detect-packages (push) Has been cancelled
Python / Test (push) Has been cancelled
Python / Build (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
2026-08-18 16:04:55 +00:00
40 changed files with 240 additions and 1576 deletions

View file

@ -1,10 +1,7 @@
name: README PR Check name: README PR Check
on: on:
# pull_request_target, not pull_request: fork PRs get a read-only token under pull_request:
# pull_request, so the label and comment calls fail with 403. This job only
# calls the API and never checks out PR code, so the write token is safe.
pull_request_target:
types: [opened] types: [opened]
paths: paths:
- 'README.md' - 'README.md'
@ -13,7 +10,7 @@ on:
jobs: jobs:
check-readme-only: check-readme-only:
if: github.event_name == 'pull_request_target' if: github.event_name == 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read

View file

@ -39,7 +39,7 @@ These servers aim to demonstrate MCP features and the official SDKs.
The following reference servers are now archived and can be found at [servers-archived](https://github.com/modelcontextprotocol/servers-archived). The following reference servers are now archived and can be found at [servers-archived](https://github.com/modelcontextprotocol/servers-archived).
- **[AWS KB Retrieval](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/aws-kb-retrieval-server)** - Retrieval from AWS Knowledge Base using Bedrock Agent Runtime. - **[AWS KB Retrieval](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/aws-kb-retrieval-server)** - Retrieval from AWS Knowledge Base using Bedrock Agent Runtime.
- **[Brave Search](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/brave-search)** - Web and local search using Brave's Search API. Has been replaced by the [official server](https://github.com/brave/brave-search-mcp-server) ([`@brave/brave-search-mcp-server`](https://www.npmjs.com/package/@brave/brave-search-mcp-server)). - **[Brave Search](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/brave-search)** - Web and local search using Brave's Search API. Has been replaced by the [official server](https://github.com/brave/brave-search-mcp-server).
- **[EverArt](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/everart)** - AI image generation using various models. - **[EverArt](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/everart)** - AI image generation using various models.
- **[GitHub](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/github)** - Repository management, file operations, and GitHub API integration. - **[GitHub](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/github)** - Repository management, file operations, and GitHub API integration.
- **[GitLab](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/gitlab)** - GitLab API, enabling project management. - **[GitLab](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/gitlab)** - GitLab API, enabling project management.

9
package-lock.json generated
View file

@ -3866,8 +3866,7 @@
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0",
"diff": "^8.0.3", "diff": "^8.0.3",
"glob": "^13.0.6", "glob": "^13.0.6",
"minimatch": "^10.0.1", "minimatch": "^10.0.1"
"zod": "^4.0.0"
}, },
"bin": { "bin": {
"mcp-server-filesystem": "dist/index.js" "mcp-server-filesystem": "dist/index.js"
@ -3887,8 +3886,7 @@
"version": "0.6.3", "version": "0.6.3",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0"
"zod": "^4.0.0"
}, },
"bin": { "bin": {
"mcp-server-memory": "dist/index.js" "mcp-server-memory": "dist/index.js"
@ -3908,8 +3906,7 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"yargs": "^17.7.2", "yargs": "^17.7.2"
"zod": "^4.0.0"
}, },
"bin": { "bin": {
"mcp-server-sequential-thinking": "dist/index.js" "mcp-server-sequential-thinking": "dist/index.js"

View file

@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/server-everything", "name": "@modelcontextprotocol/server-everything",
"version": "2.0.0", "version": "2026.8.18",
"description": "MCP server that exercises all the features of the MCP protocol", "description": "MCP server that exercises all the features of the MCP protocol",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"mcpName": "io.github.modelcontextprotocol/server-everything", "mcpName": "io.github.modelcontextprotocol/server-everything",

View file

@ -99,9 +99,10 @@ export const setSubscriptionHandlers = (server: McpServer) => {
/** /**
* Sends simulated resource update notifications to the subscribed client. * Sends simulated resource update notifications to the subscribed client.
* *
* Iterates the URIs in `subscriptions` and emits a * This function iterates through all resource URIs stored in the subscriptions
* `notifications/resources/updated` notification for each URI the given * and checks if the specified session ID is subscribed to them. If so, it sends
* sessionId is subscribed to. * a notification through the provided server. If the session ID is no longer valid
* (disconnected), it removes the session ID from the list of subscribers.
* *
* @param {McpServer} server - The server instance used to send notifications. * @param {McpServer} server - The server instance used to send notifications.
* @param {string | undefined} sessionId - The session ID of the client to check for subscriptions. * @param {string | undefined} sessionId - The session ID of the client to check for subscriptions.
@ -121,6 +122,8 @@ const sendSimulatedResourceUpdates = async (
method: "notifications/resources/updated", method: "notifications/resources/updated",
params: { uri }, params: { uri },
}); });
} else {
subscribers.delete(sessionId); // subscriber has disconnected
} }
} }
}; };

View file

@ -127,26 +127,31 @@ export const blobResourceUri = (resourceId: number) =>
new URL(`${blobUriBase}/${resourceId}`); new URL(`${blobUriBase}/${resourceId}`);
/** /**
* Parses the resource identifier from the provided variables and validates * Parses the resource identifier from the provided URI and validates it
* that it is a positive integer. * against the given variables. Throws an error if the URI corresponds
* to an unknown resource or if the resource identifier is invalid.
* *
* The SDK only routes URIs that match the registered template to the * @param {URL} uri - The URI of the resource to be parsed.
* resource handler, so by the time we get here the URI prefix is already * @param {Record<string, unknown>} variables - A record containing context-specific variables that include the resourceId.
* known to be one of `textUriBase` / `blobUriBase`. Only the resourceId * @returns {number} The parsed and validated resource identifier as an integer.
* variable still needs validating. * @throws {Error} Throws an error if the URI matches unsupported base URIs or if the resourceId is invalid.
*
* @param {URL} uri - The URI of the resource (used in the error message).
* @param {Record<string, unknown>} variables - Context variables including resourceId.
* @returns {number} The parsed and validated resource identifier as a positive integer.
* @throws {Error} If the resourceId is not a finite positive integer.
*/ */
const parseResourceId = (uri: URL, variables: Record<string, unknown>) => { const parseResourceId = (uri: URL, variables: Record<string, unknown>) => {
const idxStr = String((variables as any).resourceId ?? ""); const uriError = `Unknown resource: ${uri.toString()}`;
const idx = Number(idxStr); if (
if (Number.isFinite(idx) && Number.isInteger(idx) && idx > 0) { uri.toString().startsWith(textUriBase) &&
return idx; uri.toString().startsWith(blobUriBase)
) {
throw new Error(uriError);
} else {
const idxStr = String((variables as any).resourceId ?? "");
const idx = Number(idxStr);
if (Number.isFinite(idx) && Number.isInteger(idx) && idx > 0) {
return idx;
} else {
throw new Error(uriError);
}
} }
throw new Error(`Unknown resource: ${uri.toString()}`);
}; };
/** /**

View file

@ -11,8 +11,9 @@ ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy ENV UV_LINK_MODE=copy
# Install the project's dependencies using the lockfile and settings # Install the project's dependencies using the lockfile and settings
COPY uv.lock pyproject.toml ./
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev --no-editable uv sync --locked --no-install-project --no-dev --no-editable
# Then, add the rest of the project source code and install it # Then, add the rest of the project source code and install it
@ -26,10 +27,6 @@ FROM python:3.12-slim-bookworm
WORKDIR /app WORKDIR /app
COPY --from=uv /root/.local /root/.local COPY --from=uv /root/.local /root/.local
RUN if ! id -u app >/dev/null 2>&1; then \
useradd -rUM -s /usr/sbin/nologin app; \
fi
COPY --from=uv --chown=app:app /app/.venv /app/.venv COPY --from=uv --chown=app:app /app/.venv /app/.venv
# Place executables in the environment at the front of the path # Place executables in the environment at the front of the path

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mcp-server-fetch" name = "mcp-server-fetch"
version = "0.6.3" version = "2026.8.18"
description = "A Model Context Protocol server providing tools to fetch and convert web content for usage by LLMs" description = "A Model Context Protocol server providing tools to fetch and convert web content for usage by LLMs"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

2
src/fetch/uv.lock generated
View file

@ -564,7 +564,7 @@ wheels = [
[[package]] [[package]]
name = "mcp-server-fetch" name = "mcp-server-fetch"
version = "0.6.3" version = "2026.8.18"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },

View file

@ -1,57 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
describe('directory_tree MCP SDK regression', () => {
let client: Client;
let transport: StdioClientTransport;
let testDir: string;
beforeEach(async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-tree-'));
testDir = await fs.realpath(tmp);
await fs.writeFile(path.join(testDir, 'root.txt'), 'root');
await fs.mkdir(path.join(testDir, 'nested'));
await fs.writeFile(path.join(testDir, 'nested', 'child.txt'), 'child');
const serverPath = path.resolve(__dirname, '../dist/index.js');
transport = new StdioClientTransport({
command: 'node',
args: [serverPath, testDir],
});
client = new Client(
{ name: 'directory-tree-regression-test', version: '1.0.0' },
{ capabilities: {} },
);
await client.connect(transport);
});
afterEach(async () => {
await client?.close();
await fs.rm(testDir, { recursive: true, force: true });
});
it('returns structuredContent.content as a string (not an array) when called via MCP SDK', async () => {
const result = await client.callTool({
name: 'directory_tree',
arguments: { path: testDir },
});
// Regression test for issues where structuredContent was returned as an array,
// which causes MCP SDK validation to throw -32602 (invalid structured content).
const structured = result.structuredContent as { content: unknown };
expect(structured).toBeDefined();
expect(typeof structured.content).toBe('string');
expect(Array.isArray(structured.content)).toBe(false);
const parsed = JSON.parse(structured.content as string);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBeGreaterThan(0);
});
});

View file

@ -14,7 +14,6 @@ import {
getFileStats, getFileStats,
readFileContent, readFileContent,
writeFileContent, writeFileContent,
moveFile,
// Search & filtering functions // Search & filtering functions
searchFilesWithValidation, searchFilesWithValidation,
// File editing functions // File editing functions
@ -27,23 +26,6 @@ import {
vi.mock('fs/promises'); vi.mock('fs/promises');
const mockFs = fs as any; const mockFs = fs as any;
function createMockFileHandle(content: Buffer) {
return {
read: vi.fn(
async (buffer: Buffer, offset: number, length: number, position: number) => {
const bytesRead = content.copy(
buffer,
offset,
position,
Math.min(position + length, content.length),
);
return { bytesRead, buffer };
},
),
close: vi.fn().mockResolvedValue(undefined),
};
}
describe('Lib Functions', () => { describe('Lib Functions', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -83,10 +65,8 @@ describe('Lib Functions', () => {
}); });
it('handles negative numbers', () => { it('handles negative numbers', () => {
// Negative numbers should return '0 B' as file sizes cannot be negative // Negative numbers will result in NaN for the log calculation
expect(formatSize(-1)).toBe('0 B'); expect(formatSize(-1024)).toContain('NaN');
expect(formatSize(-1024)).toBe('0 B');
expect(formatSize(-1000000)).toBe('0 B');
expect(formatSize(-0)).toBe('0 B'); expect(formatSize(-0)).toBe('0 B');
}); });
@ -173,13 +153,6 @@ describe('Lib Functions', () => {
describe('Security & Validation Functions', () => { describe('Security & Validation Functions', () => {
describe('validatePath', () => { describe('validatePath', () => {
it('rejects Windows drive paths on POSIX hosts', async () => {
if (process.platform === 'win32') return;
await expect(validatePath('C:\\Users\\me\\notes\\file.md'))
.rejects.toThrow('Windows-style path received on a POSIX host');
});
// Use Windows-compatible paths for testing // Use Windows-compatible paths for testing
const allowedDirs = process.platform === 'win32' ? ['C:\\Users\\test', 'C:\\temp'] : ['/home/user', '/tmp']; const allowedDirs = process.platform === 'win32' ? ['C:\\Users\\test', 'C:\\temp'] : ['/home/user', '/tmp'];
@ -215,33 +188,18 @@ describe('Lib Functions', () => {
expect(result).toBe(path.resolve(newFilePath)); expect(result).toBe(path.resolve(newFilePath));
}); });
it('walks past multiple missing ancestors to an existing allowed directory', async () => { it('rejects when parent directory does not exist', async () => {
// e.g. create_directory('/home/user/nonexistent/nested/newfile.txt') when
// neither 'nonexistent' nor 'nonexistent/nested' exist yet, but '/home/user'
// (an allowed directory) does. Regression test for #4629.
const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\nonexistent\\nested\\newfile.txt' : '/home/user/nonexistent/nested/newfile.txt';
const enoentError = new Error('ENOENT') as NodeJS.ErrnoException;
enoentError.code = 'ENOENT';
// The path itself is missing. The resolver then realpaths the allowed
// directory ('/home/user'), which falls through to the beforeEach base
// implementation and echoes the path back as-is. readdir is mocked to
// undefined, so every component below it counts as missing.
mockFs.realpath.mockRejectedValueOnce(enoentError);
const result = await validatePath(newFilePath);
expect(result).toBe(path.resolve(newFilePath));
});
it('rejects when no ancestor directory exists at all', async () => {
const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\nonexistent\\newfile.txt' : '/home/user/nonexistent/newfile.txt'; const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\nonexistent\\newfile.txt' : '/home/user/nonexistent/newfile.txt';
const enoentError = new Error('ENOENT') as NodeJS.ErrnoException; // Create errors with the ENOENT code
enoentError.code = 'ENOENT'; const enoentError1 = new Error('ENOENT') as NodeJS.ErrnoException;
enoentError1.code = 'ENOENT';
const enoentError2 = new Error('ENOENT') as NodeJS.ErrnoException;
enoentError2.code = 'ENOENT';
// Every ancestor, all the way up to the filesystem root, is missing. mockFs.realpath
mockFs.realpath.mockRejectedValue(enoentError); .mockRejectedValueOnce(enoentError1)
.mockRejectedValueOnce(enoentError2);
await expect(validatePath(newFilePath)) await expect(validatePath(newFilePath))
.rejects.toThrow('Parent directory does not exist'); .rejects.toThrow('Parent directory does not exist');
@ -350,56 +308,6 @@ describe('Lib Functions', () => {
expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file.txt', 'new content', { encoding: "utf-8", flag: 'wx' }); expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file.txt', 'new content', { encoding: "utf-8", flag: 'wx' });
}); });
it('preserves file permissions when overwriting existing file', async () => {
// First writeFile call with 'wx' flag fails because file exists
mockFs.writeFile.mockRejectedValueOnce(Object.assign(new Error('EEXIST'), { code: 'EEXIST' }));
// stat returns executable permissions
mockFs.stat.mockResolvedValueOnce({ mode: 0o100755 });
// Second writeFile (to temp) succeeds
mockFs.writeFile.mockResolvedValueOnce(undefined);
mockFs.rename.mockResolvedValueOnce(undefined);
mockFs.chmod.mockResolvedValueOnce(undefined);
await writeFileContent('/test/script.sh', 'new content');
expect(mockFs.stat).toHaveBeenCalledWith('/test/script.sh');
expect(mockFs.chmod).toHaveBeenCalledWith('/test/script.sh', 0o755);
});
it('does not fail the write when chmod fails', async () => {
mockFs.writeFile.mockRejectedValueOnce(Object.assign(new Error('EEXIST'), { code: 'EEXIST' }));
mockFs.stat.mockResolvedValueOnce({ mode: 0o100755 });
mockFs.writeFile.mockResolvedValueOnce(undefined);
mockFs.rename.mockResolvedValueOnce(undefined);
mockFs.chmod.mockRejectedValueOnce(Object.assign(new Error('EPERM'), { code: 'EPERM' }));
await expect(writeFileContent('/test/script.sh', 'new content')).resolves.toBeUndefined();
expect(mockFs.unlink).not.toHaveBeenCalled();
});
});
describe('moveFile', () => {
it('moves the file when the destination does not exist', async () => {
const enoent = Object.assign(new Error('not found'), { code: 'ENOENT' });
mockFs.lstat.mockRejectedValueOnce(enoent);
mockFs.rename.mockResolvedValueOnce(undefined);
await moveFile('/test/source.txt', '/test/dest.txt');
expect(mockFs.rename).toHaveBeenCalledWith('/test/source.txt', '/test/dest.txt');
});
it('fails without overwriting when the destination already exists', async () => {
// lstat resolving means the destination is occupied.
mockFs.lstat.mockResolvedValueOnce({} as any);
await expect(moveFile('/test/source.txt', '/test/dest.txt')).rejects.toThrow(
'Destination already exists'
);
expect(mockFs.rename).not.toHaveBeenCalled();
});
}); });
}); });
@ -508,8 +416,6 @@ describe('Lib Functions', () => {
beforeEach(() => { beforeEach(() => {
mockFs.readFile.mockResolvedValue('line1\nline2\nline3\n'); mockFs.readFile.mockResolvedValue('line1\nline2\nline3\n');
mockFs.writeFile.mockResolvedValue(undefined); mockFs.writeFile.mockResolvedValue(undefined);
mockFs.stat.mockResolvedValue({ mode: 0o100644 });
mockFs.chmod.mockResolvedValue(undefined);
}); });
it('applies simple text replacement', async () => { it('applies simple text replacement', async () => {
@ -604,30 +510,6 @@ describe('Lib Functions', () => {
); );
}); });
it('preserves file permissions after applying edits', async () => {
mockFs.stat.mockResolvedValue({ mode: 0o100755 });
const edits = [
{ oldText: 'line2', newText: 'modified line2' }
];
mockFs.rename.mockResolvedValueOnce(undefined);
await applyFileEdits('/test/script.sh', edits, false);
expect(mockFs.stat).toHaveBeenCalledWith('/test/script.sh');
expect(mockFs.chmod).toHaveBeenCalledWith('/test/script.sh', 0o755);
});
it('does not restore permissions in dry run mode', async () => {
const edits = [
{ oldText: 'line2', newText: 'modified line2' }
];
await applyFileEdits('/test/file.txt', edits, true);
expect(mockFs.chmod).not.toHaveBeenCalled();
});
it('throws error for non-matching edits', async () => { it('throws error for non-matching edits', async () => {
const edits = [ const edits = [
{ oldText: 'nonexistent line', newText: 'replacement' } { oldText: 'nonexistent line', newText: 'replacement' }
@ -761,23 +643,6 @@ describe('Lib Functions', () => {
expect(mockFileHandle.close).toHaveBeenCalled(); expect(mockFileHandle.close).toHaveBeenCalled();
}); });
it('preserves UTF-8 characters split across chunk boundaries', async () => {
const content = Buffer.concat([
Buffer.from('discard\n'),
Buffer.from('界'),
Buffer.alloc(1017, 'a'),
Buffer.from('\nlast'),
]);
const mockFileHandle = createMockFileHandle(content);
mockFs.stat.mockResolvedValue({ size: content.length } as any);
mockFs.open.mockResolvedValue(mockFileHandle);
const result = await tailFile('/test/file.txt', 2);
expect(result).toBe(`界${'a'.repeat(1017)}\nlast`);
});
it('handles read errors gracefully', async () => { it('handles read errors gracefully', async () => {
mockFs.stat.mockResolvedValue({ size: 100 } as any); mockFs.stat.mockResolvedValue({ size: 100 } as any);
@ -834,20 +699,6 @@ describe('Lib Functions', () => {
expect(mockFileHandle.close).toHaveBeenCalled(); expect(mockFileHandle.close).toHaveBeenCalled();
}); });
it('preserves UTF-8 characters split across chunk boundaries', async () => {
const content = Buffer.concat([
Buffer.alloc(1023, 'a'),
Buffer.from('界\nsecond'),
]);
const mockFileHandle = createMockFileHandle(content);
mockFs.open.mockResolvedValue(mockFileHandle);
const result = await headFile('/test/file.txt', 1);
expect(result).toBe(`${'a'.repeat(1023)}界`);
});
it('handles files with leftover content', async () => { it('handles files with leftover content', async () => {
const mockFileHandle = { const mockFileHandle = {
read: vi.fn(), read: vi.fn(),

View file

@ -1,35 +0,0 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { setAllowedDirectories, validatePath } from '../lib.js';
// Regression coverage for #4629: validatePath must accept a path whose
// ancestors are missing several levels deep, so create_directory can mkdir -p.
describe('validatePath with multiple missing ancestors', () => {
let allowedDir: string;
let outsideDir: string;
beforeEach(async () => {
allowedDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-nested-allowed-')));
outsideDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-nested-outside-')));
setAllowedDirectories([allowedDir]);
});
afterEach(async () => {
setAllowedDirectories([]);
await fs.rm(allowedDir, { recursive: true, force: true });
await fs.rm(outsideDir, { recursive: true, force: true });
});
it('returns the full path when several ancestors do not exist', async () => {
const requested = path.join(allowedDir, 'a', 'b', 'c');
await expect(validatePath(requested)).resolves.toBe(requested);
});
it('rejects when the nearest existing ancestor is a symlink out of the allowed tree', async () => {
await fs.symlink(outsideDir, path.join(allowedDir, 'link'), 'junction');
await expect(validatePath(path.join(allowedDir, 'link', 'a', 'b')))
.rejects.toThrow('Access denied');
});
});

View file

@ -22,9 +22,7 @@ describe('structuredContent schema compliance', () => {
beforeEach(async () => { beforeEach(async () => {
// Create a temp directory for testing // Create a temp directory for testing
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-test-')); testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-test-'));
// macOS temp dirs may be symlinked (/var -> /private/var); resolve for server allowlist checks.
testDir = await fs.realpath(tmpDir);
// Create test files // Create test files
await fs.writeFile(path.join(testDir, 'test.txt'), 'test content'); await fs.writeFile(path.join(testDir, 'test.txt'), 'test content');

View file

@ -1,49 +0,0 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { setAllowedDirectories, validatePath } from '../lib.js';
describe('Unicode-equivalent filesystem paths', () => {
let testDirectory: string;
beforeEach(async () => {
testDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-unicode-paths-'));
setAllowedDirectories([testDirectory]);
});
afterEach(async () => {
setAllowedDirectories([]);
await fs.rm(testDirectory, { recursive: true, force: true });
});
it('resolves an existing decomposed path from a composed request', async () => {
const onDiskDirectory = 'de\u0301marche';
const onDiskFile = 're\u0301sume\u0301.txt';
await fs.mkdir(path.join(testDirectory, onDiskDirectory));
await fs.writeFile(path.join(testDirectory, onDiskDirectory, onDiskFile), 'content');
const resolved = await validatePath(path.join(testDirectory, 'd\u00e9marche', 'r\u00e9sum\u00e9.txt'));
expect(resolved).toBe(await fs.realpath(path.join(testDirectory, onDiskDirectory, onDiskFile)));
});
it('preserves a new basename after resolving a Unicode-equivalent parent', async () => {
const onDiskDirectory = 'de\u0301marche';
await fs.mkdir(path.join(testDirectory, onDiskDirectory));
const resolved = await validatePath(path.join(testDirectory, 'd\u00e9marche', 'new.txt'));
expect(resolved).toBe(path.join(await fs.realpath(path.join(testDirectory, onDiskDirectory)), 'new.txt'));
});
it('rejects ambiguous canonically equivalent entries', async () => {
const composed = 'caf\u00e9';
const decomposed = 'cafe\u0301';
await fs.mkdir(path.join(testDirectory, composed));
await fs.mkdir(path.join(testDirectory, decomposed));
await expect(validatePath(path.join(testDirectory, 'cafe\u0341', 'file.txt')))
.rejects.toThrow('Ambiguous Unicode path component');
});
});

View file

@ -21,7 +21,6 @@ import {
getFileStats, getFileStats,
readFileContent, readFileContent,
writeFileContent, writeFileContent,
moveFile,
searchFilesWithValidation, searchFilesWithValidation,
applyFileEdits, applyFileEdits,
tailFile, tailFile,
@ -632,7 +631,7 @@ server.registerTool(
async (args: z.infer<typeof MoveFileArgsSchema>) => { async (args: z.infer<typeof MoveFileArgsSchema>) => {
const validSourcePath = await validatePath(args.source); const validSourcePath = await validatePath(args.source);
const validDestPath = await validatePath(args.destination); const validDestPath = await validatePath(args.destination);
await moveFile(validSourcePath, validDestPath); await fs.rename(validSourcePath, validDestPath);
const text = `Successfully moved ${args.source} to ${args.destination}`; const text = `Successfully moved ${args.source} to ${args.destination}`;
const contentBlock = { type: "text" as const, text }; const contentBlock = { type: "text" as const, text };
return { return {

View file

@ -2,7 +2,6 @@ import fs from "fs/promises";
import path from "path"; import path from "path";
import os from 'os'; import os from 'os';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { StringDecoder } from 'string_decoder';
import { diffLines, createTwoFilesPatch } from 'diff'; import { diffLines, createTwoFilesPatch } from 'diff';
import { minimatch } from 'minimatch'; import { minimatch } from 'minimatch';
import { normalizePath, expandHome } from './path-utils.js'; import { normalizePath, expandHome } from './path-utils.js';
@ -44,7 +43,7 @@ export interface SearchResult {
// Pure Utility Functions // Pure Utility Functions
export function formatSize(bytes: number): string { export function formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB']; const units = ['B', 'KB', 'MB', 'GB', 'TB'];
if (bytes <= 0) return '0 B'; if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024)); const i = Math.floor(Math.log(bytes) / Math.log(1024));
@ -97,54 +96,8 @@ function resolveRelativePathAgainstAllowedDirectories(relativePath: string): str
} }
// Security & Validation Functions // Security & Validation Functions
async function resolveUnicodeEquivalentPath(absolutePath: string): Promise<string> {
const allowedDirectory = [...allowedDirectories]
.sort((left, right) => right.length - left.length)
.find(directory => isPathWithinAllowedDirectories(normalizePath(absolutePath), [directory]));
if (!allowedDirectory) {
return absolutePath;
}
let currentPath = await fs.realpath(allowedDirectory);
const relativeParts = path.relative(allowedDirectory, absolutePath).split(path.sep).filter(Boolean);
for (let index = 0; index < relativeParts.length; index++) {
const requestedPart = relativeParts[index];
const entries = (await fs.readdir(currentPath)) ?? [];
const exactMatch = entries.find(entry => entry === requestedPart);
const equivalentMatches = exactMatch
? [exactMatch]
: entries.filter(entry => entry.normalize('NFC') === requestedPart.normalize('NFC'));
if (equivalentMatches.length > 1) {
throw new Error(`Ambiguous Unicode path component: ${requestedPart}`);
}
if (equivalentMatches.length === 0) {
// Nothing below this point exists yet, so there are no symlinks left to
// resolve. currentPath is already realpath'd and inside an allowed
// directory; append the missing tail so create_directory can mkdir -p it.
return path.join(currentPath, ...relativeParts.slice(index));
}
currentPath = await fs.realpath(path.join(currentPath, equivalentMatches[0]));
if (!isPathWithinAllowedDirectories(normalizePath(currentPath), allowedDirectories)) {
throw new Error(`Access denied - symlink target outside allowed directories: ${currentPath} not in ${allowedDirectories.join(', ')}`);
}
}
return currentPath;
}
export async function validatePath(requestedPath: string): Promise<string> { export async function validatePath(requestedPath: string): Promise<string> {
const expandedPath = expandHome(requestedPath); const expandedPath = expandHome(requestedPath);
// Do not silently reinterpret a Windows drive path as a relative POSIX path.
// This would create a literal filename such as `C:\\Users\\...` inside the
// allowed root and report success for the wrong location.
if (process.platform !== 'win32' && /^(?:[A-Za-z]:)(?:[\\/]|$)/.test(expandedPath)) {
throw new Error(`Access denied - Windows-style path received on a POSIX host: ${requestedPath}`);
}
const absolute = path.isAbsolute(expandedPath) const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath) ? path.resolve(expandedPath)
: resolveRelativePathAgainstAllowedDirectories(expandedPath); : resolveRelativePathAgainstAllowedDirectories(expandedPath);
@ -170,13 +123,16 @@ export async function validatePath(requestedPath: string): Promise<string> {
// Security: For new files that don't exist yet, verify parent directory // Security: For new files that don't exist yet, verify parent directory
// This ensures we can't create files in unauthorized locations // This ensures we can't create files in unauthorized locations
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
const parentDir = path.dirname(absolute);
try { try {
return await resolveUnicodeEquivalentPath(absolute); const realParentPath = await fs.realpath(parentDir);
} catch (resolutionError) { const normalizedParent = normalizePath(realParentPath);
if ((resolutionError as NodeJS.ErrnoException).code === 'ENOENT') { if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirectories)) {
throw new Error(`Parent directory does not exist: ${path.dirname(absolute)}`); throw new Error(`Access denied - parent directory outside allowed directories: ${realParentPath} not in ${allowedDirectories.join(', ')}`);
} }
throw resolutionError; return absolute;
} catch {
throw new Error(`Parent directory does not exist: ${parentDir}`);
} }
} }
throw error; throw error;
@ -212,7 +168,6 @@ export async function writeFileContent(filePath: string, content: string): Promi
// Security: Use atomic rename to prevent race conditions where symlinks // Security: Use atomic rename to prevent race conditions where symlinks
// could be created between validation and write. Rename operations // could be created between validation and write. Rename operations
// replace the target file atomically and don't follow symlinks. // replace the target file atomically and don't follow symlinks.
const origStats = await fs.stat(filePath);
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`; const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
try { try {
await fs.writeFile(tempPath, content, 'utf-8'); await fs.writeFile(tempPath, content, 'utf-8');
@ -223,13 +178,6 @@ export async function writeFileContent(filePath: string, content: string): Promi
} catch {} } catch {}
throw renameError; throw renameError;
} }
// Restore original permission bits since the atomic rename replaces the
// inode and the temp file has default (0644) permissions. Mask off the
// file-type bits; POSIX leaves them unspecified for chmod. A chmod
// failure must not fail the write, which has already succeeded.
try {
await fs.chmod(filePath, origStats.mode & 0o777);
} catch {}
} else { } else {
throw error; throw error;
} }
@ -237,25 +185,6 @@ export async function writeFileContent(filePath: string, content: string): Promi
} }
export async function moveFile(sourcePath: string, destinationPath: string): Promise<void> {
// The move_file tool contract (and README) state the operation fails if the
// destination already exists. fs.rename would silently overwrite it, which is
// a data-loss bug, so reject up front when anything - file, directory, or
// symlink - occupies the target. lstat is used so an existing symlink at the
// destination is detected rather than followed.
try {
await fs.lstat(destinationPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
await fs.rename(sourcePath, destinationPath);
return;
}
throw error;
}
throw new Error(`Destination already exists: ${destinationPath}`);
}
// File Editing Functions // File Editing Functions
interface FileEdit { interface FileEdit {
oldText: string; oldText: string;
@ -337,7 +266,6 @@ export async function applyFileEdits(
// Security: Use atomic rename to prevent race conditions where symlinks // Security: Use atomic rename to prevent race conditions where symlinks
// could be created between validation and write. Rename operations // could be created between validation and write. Rename operations
// replace the target file atomically and don't follow symlinks. // replace the target file atomically and don't follow symlinks.
const origStats = await fs.stat(filePath);
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`; const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
try { try {
await fs.writeFile(tempPath, modifiedContent, 'utf-8'); await fs.writeFile(tempPath, modifiedContent, 'utf-8');
@ -348,13 +276,6 @@ export async function applyFileEdits(
} catch {} } catch {}
throw error; throw error;
} }
// Restore original permission bits since the atomic rename replaces the
// inode and the temp file has default (0644) permissions. Mask off the
// file-type bits; POSIX leaves them unspecified for chmod. A chmod
// failure must not fail the write, which has already succeeded.
try {
await fs.chmod(filePath, origStats.mode & 0o777);
} catch {}
} }
return formattedDiff; return formattedDiff;
@ -371,28 +292,42 @@ export async function tailFile(filePath: string, numLines: number): Promise<stri
// Open file for reading // Open file for reading
const fileHandle = await fs.open(filePath, 'r'); const fileHandle = await fs.open(filePath, 'r');
try { try {
const chunks: Buffer[] = []; const lines: string[] = [];
let position = fileSize; let position = fileSize;
const chunk = Buffer.alloc(CHUNK_SIZE); let chunk = Buffer.alloc(CHUNK_SIZE);
let newlinesFound = 0; let linesFound = 0;
let remainingText = '';
// Read chunks from the end of the file until we have enough lines // Read chunks from the end of the file until we have enough lines
while (position > 0 && newlinesFound < numLines) { while (position > 0 && linesFound < numLines) {
const size = Math.min(CHUNK_SIZE, position); const size = Math.min(CHUNK_SIZE, position);
position -= size; position -= size;
const { bytesRead } = await fileHandle.read(chunk, 0, size, position); const { bytesRead } = await fileHandle.read(chunk, 0, size, position);
if (!bytesRead) break; if (!bytesRead) break;
const readData = Buffer.from(chunk.subarray(0, bytesRead)); // Get the chunk as a string and prepend any remaining text from previous iteration
chunks.unshift(readData); const readData = chunk.slice(0, bytesRead).toString('utf-8');
for (const byte of readData) { const chunkText = readData + remainingText;
if (byte === 0x0a) newlinesFound++;
// Split by newlines and count
const chunkLines = normalizeLineEndings(chunkText).split('\n');
// If this isn't the end of the file, the first line is likely incomplete
// Save it to prepend to the next chunk
if (position > 0) {
remainingText = chunkLines[0];
chunkLines.shift(); // Remove the first (incomplete) line
}
// Add lines to our result (up to the number we need)
for (let i = chunkLines.length - 1; i >= 0 && linesFound < numLines; i--) {
lines.unshift(chunkLines[i]);
linesFound++;
} }
} }
const text = normalizeLineEndings(Buffer.concat(chunks).toString('utf-8')); return lines.join('\n');
return text.split('\n').slice(-numLines).join('\n');
} finally { } finally {
await fileHandle.close(); await fileHandle.close();
} }
@ -406,14 +341,13 @@ export async function headFile(filePath: string, numLines: number): Promise<stri
let buffer = ''; let buffer = '';
let bytesRead = 0; let bytesRead = 0;
const chunk = Buffer.alloc(1024); // 1KB buffer const chunk = Buffer.alloc(1024); // 1KB buffer
const decoder = new StringDecoder('utf-8');
// Read chunks and count lines until we have enough or reach EOF // Read chunks and count lines until we have enough or reach EOF
while (lines.length < numLines) { while (lines.length < numLines) {
const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead); const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
if (result.bytesRead === 0) break; // End of file if (result.bytesRead === 0) break; // End of file
bytesRead += result.bytesRead; bytesRead += result.bytesRead;
buffer += decoder.write(chunk.subarray(0, result.bytesRead)); buffer += chunk.slice(0, result.bytesRead).toString('utf-8');
const newLineIndex = buffer.lastIndexOf('\n'); const newLineIndex = buffer.lastIndexOf('\n');
if (newLineIndex !== -1) { if (newLineIndex !== -1) {
@ -426,8 +360,6 @@ export async function headFile(filePath: string, numLines: number): Promise<stri
} }
} }
buffer += decoder.end();
// If there is leftover content and we still need lines, add it // If there is leftover content and we still need lines, add it
if (buffer.length > 0 && lines.length < numLines) { if (buffer.length > 0 && lines.length < numLines) {
lines.push(buffer); lines.push(buffer);

View file

@ -28,8 +28,7 @@
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0",
"diff": "^8.0.3", "diff": "^8.0.3",
"glob": "^13.0.6", "glob": "^13.0.6",
"minimatch": "^10.0.1", "minimatch": "^10.0.1"
"zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/diff": "^5.0.9", "@types/diff": "^5.0.9",

View file

@ -11,8 +11,9 @@ ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy ENV UV_LINK_MODE=copy
# Install the project's dependencies using the lockfile and settings # Install the project's dependencies using the lockfile and settings
COPY uv.lock pyproject.toml ./
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev --no-editable uv sync --locked --no-install-project --no-dev --no-editable
# Then, add the rest of the project source code and install it # Then, add the rest of the project source code and install it
@ -29,10 +30,6 @@ RUN apt-get update && apt-get install -y git git-lfs && rm -rf /var/lib/apt/list
WORKDIR /app WORKDIR /app
COPY --from=uv /root/.local /root/.local COPY --from=uv /root/.local /root/.local
RUN if ! id -u app >/dev/null 2>&1; then \
useradd -rUM -s /usr/sbin/nologin app; \
fi
COPY --from=uv --chown=app:app /app/.venv /app/.venv COPY --from=uv --chown=app:app /app/.venv /app/.venv
# Place executables in the environment at the front of the path # Place executables in the environment at the front of the path

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mcp-server-git" name = "mcp-server-git"
version = "0.6.2" version = "2026.8.18"
description = "A Model Context Protocol server providing tools to read, search, and manipulate Git repositories programmatically via LLMs" description = "A Model Context Protocol server providing tools to read, search, and manipulate Git repositories programmatically via LLMs"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View file

@ -1,6 +1,6 @@
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Sequence from typing import Sequence, Optional
from mcp.server import Server from mcp.server import Server
from mcp.server.session import ServerSession from mcp.server.session import ServerSession
from mcp.server.stdio import stdio_server from mcp.server.stdio import stdio_server
@ -157,28 +157,45 @@ def git_reset(repo: git.Repo) -> str:
return "All staged changes reset" return "All staged changes reset"
def git_log(repo: git.Repo, max_count: int = 10, start_timestamp: Optional[str] = None, end_timestamp: Optional[str] = None) -> list[str]: def git_log(repo: git.Repo, max_count: int = 10, start_timestamp: Optional[str] = None, end_timestamp: Optional[str] = None) -> list[str]:
# Defense in depth: reject timestamps starting with '-' to prevent flag injection if start_timestamp or end_timestamp:
if start_timestamp and start_timestamp.startswith("-"): # Defense in depth: reject timestamps starting with '-' to prevent flag injection
raise ValueError(f"Invalid start_timestamp: '{start_timestamp}' - cannot start with '-'") if start_timestamp and start_timestamp.startswith("-"):
if end_timestamp and end_timestamp.startswith("-"): raise ValueError(f"Invalid start_timestamp: '{start_timestamp}' - cannot start with '-'")
raise ValueError(f"Invalid end_timestamp: '{end_timestamp}' - cannot start with '-'") if end_timestamp and end_timestamp.startswith("-"):
raise ValueError(f"Invalid end_timestamp: '{end_timestamp}' - cannot start with '-'")
# Use git log command with date filtering
args = []
if start_timestamp:
args.extend(['--since', start_timestamp])
if end_timestamp:
args.extend(['--until', end_timestamp])
args.extend(['--format=%H%n%an%n%ad%n%s%n'])
kwargs: dict[str, Any] = {"max_count": max_count} log_output = repo.git.log(*args).split('\n')
if start_timestamp:
kwargs["since"] = start_timestamp
if end_timestamp:
kwargs["until"] = end_timestamp
commits = list(repo.iter_commits(**kwargs)) log = []
log = [] # Process commits in groups of 4 (hash, author, date, message)
for commit in commits: for i in range(0, len(log_output), 4):
log.append( if i + 3 < len(log_output) and len(log) < max_count:
f"Commit: {commit.hexsha}\n" log.append(
f"Author: {commit.author}\n" f"Commit: {log_output[i]}\n"
f"Date: {commit.authored_datetime}\n" f"Author: {log_output[i+1]}\n"
f"Message: {commit.message}\n" f"Date: {log_output[i+2]}\n"
) f"Message: {log_output[i+3]}\n"
return log )
return log
else:
# Use existing logic for simple log without date filtering
commits = list(repo.iter_commits(max_count=max_count))
log = []
for commit in commits:
log.append(
f"Commit: {commit.hexsha!r}\n"
f"Author: {commit.author!r}\n"
f"Date: {commit.authored_datetime}\n"
f"Message: {commit.message!r}\n"
)
return log
def git_create_branch(repo: git.Repo, branch_name: str, base_branch: str | None = None) -> str: def git_create_branch(repo: git.Repo, branch_name: str, base_branch: str | None = None) -> str:
# Defense in depth: reject names starting with '-' to prevent flag injection # Defense in depth: reject names starting with '-' to prevent flag injection
@ -582,4 +599,4 @@ async def serve(repository: Path | None) -> None:
options = server.create_initialization_options() options = server.create_initialization_options()
async with stdio_server() as (read_stream, write_stream): async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, options) await server.run(read_stream, write_stream, options, raise_exceptions=True)

Some files were not shown because too many files have changed in this diff Show more