Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d73f99efbf |
||
|
|
1ec570c27d |
||
|
|
f41b666edf |
||
|
|
c3d8e43d1f |
||
|
|
abbbddb2e3 |
||
|
|
649af5856e |
||
|
|
242751e8ee |
||
|
|
ba167cef5b |
||
|
|
48723c95dc |
||
|
|
96c49c7364 |
||
|
|
cce08de417 |
||
|
|
2e3e4c7ab7 |
||
|
|
3aa19f5574 |
||
|
|
579c3903f3 |
||
|
|
a6cdbf4dea |
||
|
|
ebaccfd623 |
||
|
|
cda92bdaac |
||
|
|
d6402efee6 |
||
|
|
40a8de87ed |
||
|
|
11845d51cb |
||
|
|
cf05b37694 |
||
|
|
642a9112e7 |
||
|
|
18ce197639 |
||
|
|
562feeb281 |
||
|
|
71e3cfbd1e |
||
|
|
781a93db14 |
||
|
|
8102fbe27a |
33 changed files with 1568 additions and 232 deletions
7
.github/workflows/readme-pr-check.yml
vendored
7
.github/workflows/readme-pr-check.yml
vendored
|
|
@ -1,7 +1,10 @@
|
|||
name: README PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# pull_request_target, not pull_request: fork PRs get a read-only token under
|
||||
# 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]
|
||||
paths:
|
||||
- 'README.md'
|
||||
|
|
@ -10,7 +13,7 @@ on:
|
|||
|
||||
jobs:
|
||||
check-readme-only:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
- **[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 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)).
|
||||
- **[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.
|
||||
- **[GitLab](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/gitlab)** - GitLab API, enabling project management.
|
||||
|
|
|
|||
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -3866,7 +3866,8 @@
|
|||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"diff": "^8.0.3",
|
||||
"glob": "^13.0.6",
|
||||
"minimatch": "^10.0.1"
|
||||
"minimatch": "^10.0.1",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mcp-server-filesystem": "dist/index.js"
|
||||
|
|
@ -3886,7 +3887,8 @@
|
|||
"version": "0.6.3",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0"
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mcp-server-memory": "dist/index.js"
|
||||
|
|
@ -3906,7 +3908,8 @@
|
|||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"chalk": "^5.3.0",
|
||||
"yargs": "^17.7.2"
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mcp-server-sequential-thinking": "dist/index.js"
|
||||
|
|
|
|||
|
|
@ -99,10 +99,9 @@ export const setSubscriptionHandlers = (server: McpServer) => {
|
|||
/**
|
||||
* Sends simulated resource update notifications to the subscribed client.
|
||||
*
|
||||
* This function iterates through all resource URIs stored in the subscriptions
|
||||
* and checks if the specified session ID is subscribed to them. If so, it sends
|
||||
* 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.
|
||||
* Iterates the URIs in `subscriptions` and emits a
|
||||
* `notifications/resources/updated` notification for each URI the given
|
||||
* sessionId is subscribed to.
|
||||
*
|
||||
* @param {McpServer} server - The server instance used to send notifications.
|
||||
* @param {string | undefined} sessionId - The session ID of the client to check for subscriptions.
|
||||
|
|
@ -122,8 +121,6 @@ const sendSimulatedResourceUpdates = async (
|
|||
method: "notifications/resources/updated",
|
||||
params: { uri },
|
||||
});
|
||||
} else {
|
||||
subscribers.delete(sessionId); // subscriber has disconnected
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -127,31 +127,26 @@ export const blobResourceUri = (resourceId: number) =>
|
|||
new URL(`${blobUriBase}/${resourceId}`);
|
||||
|
||||
/**
|
||||
* Parses the resource identifier from the provided URI and validates it
|
||||
* against the given variables. Throws an error if the URI corresponds
|
||||
* to an unknown resource or if the resource identifier is invalid.
|
||||
* Parses the resource identifier from the provided variables and validates
|
||||
* that it is a positive integer.
|
||||
*
|
||||
* @param {URL} uri - The URI of the resource to be parsed.
|
||||
* @param {Record<string, unknown>} variables - A record containing context-specific variables that include the resourceId.
|
||||
* @returns {number} The parsed and validated resource identifier as an integer.
|
||||
* @throws {Error} Throws an error if the URI matches unsupported base URIs or if the resourceId is invalid.
|
||||
* The SDK only routes URIs that match the registered template to the
|
||||
* resource handler, so by the time we get here the URI prefix is already
|
||||
* known to be one of `textUriBase` / `blobUriBase`. Only the resourceId
|
||||
* variable still needs validating.
|
||||
*
|
||||
* @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 uriError = `Unknown resource: ${uri.toString()}`;
|
||||
if (
|
||||
uri.toString().startsWith(textUriBase) &&
|
||||
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);
|
||||
}
|
||||
const idxStr = String((variables as any).resourceId ?? "");
|
||||
const idx = Number(idxStr);
|
||||
if (Number.isFinite(idx) && Number.isInteger(idx) && idx > 0) {
|
||||
return idx;
|
||||
}
|
||||
throw new Error(`Unknown resource: ${uri.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -11,9 +11,8 @@ ENV UV_COMPILE_BYTECODE=1
|
|||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# Install the project's dependencies using the lockfile and settings
|
||||
COPY uv.lock pyproject.toml ./
|
||||
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
|
||||
|
||||
# Then, add the rest of the project source code and install it
|
||||
|
|
@ -27,6 +26,10 @@ FROM python:3.12-slim-bookworm
|
|||
WORKDIR /app
|
||||
|
||||
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
|
||||
|
||||
# Place executables in the environment at the front of the path
|
||||
|
|
|
|||
57
src/filesystem/__tests__/directory-tree.mcp-sdk.test.ts
Normal file
57
src/filesystem/__tests__/directory-tree.mcp-sdk.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
getFileStats,
|
||||
readFileContent,
|
||||
writeFileContent,
|
||||
moveFile,
|
||||
// Search & filtering functions
|
||||
searchFilesWithValidation,
|
||||
// File editing functions
|
||||
|
|
@ -26,6 +27,23 @@ import {
|
|||
vi.mock('fs/promises');
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -65,8 +83,10 @@ describe('Lib Functions', () => {
|
|||
});
|
||||
|
||||
it('handles negative numbers', () => {
|
||||
// Negative numbers will result in NaN for the log calculation
|
||||
expect(formatSize(-1024)).toContain('NaN');
|
||||
// Negative numbers should return '0 B' as file sizes cannot be negative
|
||||
expect(formatSize(-1)).toBe('0 B');
|
||||
expect(formatSize(-1024)).toBe('0 B');
|
||||
expect(formatSize(-1000000)).toBe('0 B');
|
||||
expect(formatSize(-0)).toBe('0 B');
|
||||
});
|
||||
|
||||
|
|
@ -153,6 +173,13 @@ describe('Lib Functions', () => {
|
|||
|
||||
describe('Security & Validation Functions', () => {
|
||||
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
|
||||
const allowedDirs = process.platform === 'win32' ? ['C:\\Users\\test', 'C:\\temp'] : ['/home/user', '/tmp'];
|
||||
|
||||
|
|
@ -188,19 +215,34 @@ describe('Lib Functions', () => {
|
|||
expect(result).toBe(path.resolve(newFilePath));
|
||||
});
|
||||
|
||||
it('rejects when parent directory does not exist', async () => {
|
||||
it('walks past multiple missing ancestors to an existing allowed directory', 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';
|
||||
|
||||
// Create errors with the ENOENT code
|
||||
const enoentError1 = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
enoentError1.code = 'ENOENT';
|
||||
const enoentError2 = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
enoentError2.code = 'ENOENT';
|
||||
|
||||
mockFs.realpath
|
||||
.mockRejectedValueOnce(enoentError1)
|
||||
.mockRejectedValueOnce(enoentError2);
|
||||
|
||||
|
||||
const enoentError = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
enoentError.code = 'ENOENT';
|
||||
|
||||
// Every ancestor, all the way up to the filesystem root, is missing.
|
||||
mockFs.realpath.mockRejectedValue(enoentError);
|
||||
|
||||
await expect(validatePath(newFilePath))
|
||||
.rejects.toThrow('Parent directory does not exist');
|
||||
});
|
||||
|
|
@ -308,6 +350,56 @@ describe('Lib Functions', () => {
|
|||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -416,6 +508,8 @@ describe('Lib Functions', () => {
|
|||
beforeEach(() => {
|
||||
mockFs.readFile.mockResolvedValue('line1\nline2\nline3\n');
|
||||
mockFs.writeFile.mockResolvedValue(undefined);
|
||||
mockFs.stat.mockResolvedValue({ mode: 0o100644 });
|
||||
mockFs.chmod.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('applies simple text replacement', async () => {
|
||||
|
|
@ -510,6 +604,30 @@ 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 () => {
|
||||
const edits = [
|
||||
{ oldText: 'nonexistent line', newText: 'replacement' }
|
||||
|
|
@ -643,6 +761,23 @@ describe('Lib Functions', () => {
|
|||
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 () => {
|
||||
mockFs.stat.mockResolvedValue({ size: 100 } as any);
|
||||
|
||||
|
|
@ -699,6 +834,20 @@ describe('Lib Functions', () => {
|
|||
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 () => {
|
||||
const mockFileHandle = {
|
||||
read: vi.fn(),
|
||||
|
|
|
|||
35
src/filesystem/__tests__/nested-parents.test.ts
Normal file
35
src/filesystem/__tests__/nested-parents.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -22,7 +22,9 @@ describe('structuredContent schema compliance', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
// Create a temp directory for testing
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-test-'));
|
||||
const tmpDir = 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
|
||||
await fs.writeFile(path.join(testDir, 'test.txt'), 'test content');
|
||||
|
|
|
|||
49
src/filesystem/__tests__/unicode-paths.test.ts
Normal file
49
src/filesystem/__tests__/unicode-paths.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
getFileStats,
|
||||
readFileContent,
|
||||
writeFileContent,
|
||||
moveFile,
|
||||
searchFilesWithValidation,
|
||||
applyFileEdits,
|
||||
tailFile,
|
||||
|
|
@ -631,7 +632,7 @@ server.registerTool(
|
|||
async (args: z.infer<typeof MoveFileArgsSchema>) => {
|
||||
const validSourcePath = await validatePath(args.source);
|
||||
const validDestPath = await validatePath(args.destination);
|
||||
await fs.rename(validSourcePath, validDestPath);
|
||||
await moveFile(validSourcePath, validDestPath);
|
||||
const text = `Successfully moved ${args.source} to ${args.destination}`;
|
||||
const contentBlock = { type: "text" as const, text };
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from "fs/promises";
|
|||
import path from "path";
|
||||
import os from 'os';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { StringDecoder } from 'string_decoder';
|
||||
import { diffLines, createTwoFilesPatch } from 'diff';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { normalizePath, expandHome } from './path-utils.js';
|
||||
|
|
@ -43,7 +44,7 @@ export interface SearchResult {
|
|||
// Pure Utility Functions
|
||||
export function formatSize(bytes: number): string {
|
||||
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));
|
||||
|
||||
|
|
@ -96,8 +97,54 @@ function resolveRelativePathAgainstAllowedDirectories(relativePath: string): str
|
|||
}
|
||||
|
||||
// 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> {
|
||||
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)
|
||||
? path.resolve(expandedPath)
|
||||
: resolveRelativePathAgainstAllowedDirectories(expandedPath);
|
||||
|
|
@ -123,16 +170,13 @@ export async function validatePath(requestedPath: string): Promise<string> {
|
|||
// Security: For new files that don't exist yet, verify parent directory
|
||||
// This ensures we can't create files in unauthorized locations
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
const parentDir = path.dirname(absolute);
|
||||
try {
|
||||
const realParentPath = await fs.realpath(parentDir);
|
||||
const normalizedParent = normalizePath(realParentPath);
|
||||
if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirectories)) {
|
||||
throw new Error(`Access denied - parent directory outside allowed directories: ${realParentPath} not in ${allowedDirectories.join(', ')}`);
|
||||
return await resolveUnicodeEquivalentPath(absolute);
|
||||
} catch (resolutionError) {
|
||||
if ((resolutionError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new Error(`Parent directory does not exist: ${path.dirname(absolute)}`);
|
||||
}
|
||||
return absolute;
|
||||
} catch {
|
||||
throw new Error(`Parent directory does not exist: ${parentDir}`);
|
||||
throw resolutionError;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
|
|
@ -168,6 +212,7 @@ export async function writeFileContent(filePath: string, content: string): Promi
|
|||
// Security: Use atomic rename to prevent race conditions where symlinks
|
||||
// could be created between validation and write. Rename operations
|
||||
// replace the target file atomically and don't follow symlinks.
|
||||
const origStats = await fs.stat(filePath);
|
||||
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tempPath, content, 'utf-8');
|
||||
|
|
@ -178,6 +223,13 @@ export async function writeFileContent(filePath: string, content: string): Promi
|
|||
} catch {}
|
||||
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 {
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -185,6 +237,25 @@ 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
|
||||
interface FileEdit {
|
||||
oldText: string;
|
||||
|
|
@ -266,6 +337,7 @@ export async function applyFileEdits(
|
|||
// Security: Use atomic rename to prevent race conditions where symlinks
|
||||
// could be created between validation and write. Rename operations
|
||||
// replace the target file atomically and don't follow symlinks.
|
||||
const origStats = await fs.stat(filePath);
|
||||
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tempPath, modifiedContent, 'utf-8');
|
||||
|
|
@ -276,6 +348,13 @@ export async function applyFileEdits(
|
|||
} catch {}
|
||||
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;
|
||||
|
|
@ -292,42 +371,28 @@ export async function tailFile(filePath: string, numLines: number): Promise<stri
|
|||
// Open file for reading
|
||||
const fileHandle = await fs.open(filePath, 'r');
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
const chunks: Buffer[] = [];
|
||||
let position = fileSize;
|
||||
let chunk = Buffer.alloc(CHUNK_SIZE);
|
||||
let linesFound = 0;
|
||||
let remainingText = '';
|
||||
const chunk = Buffer.alloc(CHUNK_SIZE);
|
||||
let newlinesFound = 0;
|
||||
|
||||
// Read chunks from the end of the file until we have enough lines
|
||||
while (position > 0 && linesFound < numLines) {
|
||||
while (position > 0 && newlinesFound < numLines) {
|
||||
const size = Math.min(CHUNK_SIZE, position);
|
||||
position -= size;
|
||||
|
||||
const { bytesRead } = await fileHandle.read(chunk, 0, size, position);
|
||||
if (!bytesRead) break;
|
||||
|
||||
// Get the chunk as a string and prepend any remaining text from previous iteration
|
||||
const readData = chunk.slice(0, bytesRead).toString('utf-8');
|
||||
const chunkText = readData + remainingText;
|
||||
|
||||
// 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 readData = Buffer.from(chunk.subarray(0, bytesRead));
|
||||
chunks.unshift(readData);
|
||||
for (const byte of readData) {
|
||||
if (byte === 0x0a) newlinesFound++;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
|
||||
const text = normalizeLineEndings(Buffer.concat(chunks).toString('utf-8'));
|
||||
return text.split('\n').slice(-numLines).join('\n');
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
|
|
@ -341,13 +406,14 @@ export async function headFile(filePath: string, numLines: number): Promise<stri
|
|||
let buffer = '';
|
||||
let bytesRead = 0;
|
||||
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
|
||||
while (lines.length < numLines) {
|
||||
const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
|
||||
if (result.bytesRead === 0) break; // End of file
|
||||
bytesRead += result.bytesRead;
|
||||
buffer += chunk.slice(0, result.bytesRead).toString('utf-8');
|
||||
buffer += decoder.write(chunk.subarray(0, result.bytesRead));
|
||||
|
||||
const newLineIndex = buffer.lastIndexOf('\n');
|
||||
if (newLineIndex !== -1) {
|
||||
|
|
@ -359,6 +425,8 @@ 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 (buffer.length > 0 && lines.length < numLines) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@
|
|||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"diff": "^8.0.3",
|
||||
"glob": "^13.0.6",
|
||||
"minimatch": "^10.0.1"
|
||||
"minimatch": "^10.0.1",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/diff": "^5.0.9",
|
||||
|
|
|
|||
|
|
@ -11,9 +11,8 @@ ENV UV_COMPILE_BYTECODE=1
|
|||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# Install the project's dependencies using the lockfile and settings
|
||||
COPY uv.lock pyproject.toml ./
|
||||
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
|
||||
|
||||
# Then, add the rest of the project source code and install it
|
||||
|
|
@ -30,6 +29,10 @@ RUN apt-get update && apt-get install -y git git-lfs && rm -rf /var/lib/apt/list
|
|||
WORKDIR /app
|
||||
|
||||
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
|
||||
|
||||
# Place executables in the environment at the front of the path
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Sequence, Optional
|
||||
from typing import Any, Optional, Sequence
|
||||
from mcp.server import Server
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
|
@ -157,45 +157,28 @@ def git_reset(repo: git.Repo) -> str:
|
|||
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]:
|
||||
if start_timestamp or end_timestamp:
|
||||
# Defense in depth: reject timestamps starting with '-' to prevent flag injection
|
||||
if start_timestamp and start_timestamp.startswith("-"):
|
||||
raise ValueError(f"Invalid start_timestamp: '{start_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'])
|
||||
# Defense in depth: reject timestamps starting with '-' to prevent flag injection
|
||||
if start_timestamp and start_timestamp.startswith("-"):
|
||||
raise ValueError(f"Invalid start_timestamp: '{start_timestamp}' - cannot start with '-'")
|
||||
if end_timestamp and end_timestamp.startswith("-"):
|
||||
raise ValueError(f"Invalid end_timestamp: '{end_timestamp}' - cannot start with '-'")
|
||||
|
||||
log_output = repo.git.log(*args).split('\n')
|
||||
kwargs: dict[str, Any] = {"max_count": max_count}
|
||||
if start_timestamp:
|
||||
kwargs["since"] = start_timestamp
|
||||
if end_timestamp:
|
||||
kwargs["until"] = end_timestamp
|
||||
|
||||
log = []
|
||||
# Process commits in groups of 4 (hash, author, date, message)
|
||||
for i in range(0, len(log_output), 4):
|
||||
if i + 3 < len(log_output) and len(log) < max_count:
|
||||
log.append(
|
||||
f"Commit: {log_output[i]}\n"
|
||||
f"Author: {log_output[i+1]}\n"
|
||||
f"Date: {log_output[i+2]}\n"
|
||||
f"Message: {log_output[i+3]}\n"
|
||||
)
|
||||
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
|
||||
commits = list(repo.iter_commits(**kwargs))
|
||||
log = []
|
||||
for commit in commits:
|
||||
log.append(
|
||||
f"Commit: {commit.hexsha}\n"
|
||||
f"Author: {commit.author}\n"
|
||||
f"Date: {commit.authored_datetime}\n"
|
||||
f"Message: {commit.message}\n"
|
||||
)
|
||||
return log
|
||||
|
||||
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
|
||||
|
|
@ -599,4 +582,4 @@ async def serve(repository: Path | None) -> None:
|
|||
|
||||
options = server.create_initialization_options()
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, options, raise_exceptions=True)
|
||||
await server.run(read_stream, write_stream, options)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ from mcp_server_git.server import (
|
|||
git_create_branch,
|
||||
git_show,
|
||||
validate_repo_path,
|
||||
serve,
|
||||
)
|
||||
import shutil
|
||||
import unittest.mock as mock
|
||||
|
||||
@pytest.fixture
|
||||
def test_repository(tmp_path: Path):
|
||||
|
|
@ -508,3 +510,81 @@ def test_git_branch_rejects_contains_flag_injection(test_repository):
|
|||
|
||||
with pytest.raises(BadName):
|
||||
git_branch(test_repository, "local", not_contains="--exec=evil")
|
||||
|
||||
|
||||
def test_git_log_formatting_no_repr(test_repository):
|
||||
"""Test that git_log does not use !r formatting (no Python object repr or quotes)."""
|
||||
file_path = Path(test_repository.working_dir) / "multiline.txt"
|
||||
file_path.write_text("multiline test")
|
||||
test_repository.index.add(["multiline.txt"])
|
||||
test_repository.index.commit("Subject line\n\nDetailed body line 1\nDetailed body line 2")
|
||||
|
||||
result = git_log(test_repository, max_count=1)
|
||||
entry = result[0]
|
||||
|
||||
# Verify no python repr quotes around commit hash or message
|
||||
assert "Commit: '" not in entry
|
||||
assert 'Commit: "' not in entry
|
||||
assert "<git.Actor" not in entry
|
||||
assert "Message: '" not in entry
|
||||
assert 'Message: "' not in entry
|
||||
|
||||
# Verify full message with body is included
|
||||
assert "Subject line\n\nDetailed body line 1\nDetailed body line 2" in entry
|
||||
|
||||
|
||||
def test_git_log_filtered_unfiltered_parity(test_repository):
|
||||
"""Test that filtered and unfiltered git_log produce identical schema and preserve commit body."""
|
||||
file_path = Path(test_repository.working_dir) / "parity_test.txt"
|
||||
file_path.write_text("parity test")
|
||||
test_repository.index.add(["parity_test.txt"])
|
||||
test_repository.index.commit("Parity subject\n\nParity body line 1\nParity body line 2")
|
||||
|
||||
unfiltered = git_log(test_repository, max_count=1)
|
||||
filtered_since = git_log(test_repository, max_count=1, start_timestamp="yesterday")
|
||||
filtered_until = git_log(test_repository, max_count=1, end_timestamp="2099-01-01")
|
||||
|
||||
# Output schemas and contents must be identical
|
||||
assert unfiltered == filtered_since
|
||||
assert unfiltered == filtered_until
|
||||
|
||||
# Multi-line commit message preserved in filtered results
|
||||
assert "Parity subject\n\nParity body line 1\nParity body line 2" in filtered_since[0]
|
||||
|
||||
|
||||
def test_git_log_date_filtering(test_repository):
|
||||
"""Test date filtering logic in git_log."""
|
||||
# Future start_timestamp should return no commits
|
||||
future_result = git_log(test_repository, start_timestamp="2099-01-01")
|
||||
assert future_result == []
|
||||
|
||||
# Past end_timestamp should return no commits
|
||||
past_result = git_log(test_repository, end_timestamp="2000-01-01")
|
||||
assert past_result == []
|
||||
|
||||
# Valid range with max_count
|
||||
valid_result = git_log(test_repository, max_count=1, start_timestamp="2000-01-01")
|
||||
assert len(valid_result) == 1
|
||||
|
||||
|
||||
def test_serve_run_does_not_raise_exceptions(tmp_path: Path):
|
||||
"""Verify that serve() runs server.run without raise_exceptions=True."""
|
||||
import anyio
|
||||
|
||||
repo_path = tmp_path / "serve_test_repo"
|
||||
git.Repo.init(repo_path)
|
||||
|
||||
async def _run():
|
||||
with mock.patch("mcp_server_git.server.stdio_server") as mock_stdio:
|
||||
mock_read = mock.AsyncMock()
|
||||
mock_write = mock.AsyncMock()
|
||||
mock_stdio.return_value.__aenter__.return_value = (mock_read, mock_write)
|
||||
mock_stdio.return_value.__aexit__.return_value = None
|
||||
|
||||
with mock.patch("mcp_server_git.server.Server.run", new_callable=mock.AsyncMock) as mock_run:
|
||||
await serve(repo_path)
|
||||
mock_run.assert_awaited_once()
|
||||
_, kwargs = mock_run.call_args
|
||||
assert kwargs.get("raise_exceptions") is not True
|
||||
|
||||
anyio.run(_run)
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ Example:
|
|||
- `to` (string): Target entity name
|
||||
- `relationType` (string): Relationship type in active voice
|
||||
- Skips duplicate relations
|
||||
- Fails if either the source or target entity doesn't exist
|
||||
|
||||
- **add_observations**
|
||||
- Add new observations to existing entities
|
||||
|
|
@ -86,7 +87,7 @@ Example:
|
|||
- Remove entities and their relations
|
||||
- Input: `entityNames` (string[])
|
||||
- Cascading deletion of associated relations
|
||||
- Silent operation if entity doesn't exist
|
||||
- No error if an entity doesn't exist; the response reports which names were not found
|
||||
|
||||
- **delete_observations**
|
||||
- Remove specific observations from entities
|
||||
|
|
@ -94,7 +95,7 @@ Example:
|
|||
- Each object contains:
|
||||
- `entityName` (string): Target entity
|
||||
- `observations` (string[]): Observations to remove
|
||||
- Silent operation if observation doesn't exist
|
||||
- No error if an observation doesn't exist; the response reports how many were deleted
|
||||
|
||||
- **delete_relations**
|
||||
- Remove specific relations from the graph
|
||||
|
|
@ -103,7 +104,7 @@ Example:
|
|||
- `from` (string): Source entity name
|
||||
- `to` (string): Target entity name
|
||||
- `relationType` (string): Relationship type
|
||||
- Silent operation if relation doesn't exist
|
||||
- No error if a relation doesn't exist; the response reports how many were deleted
|
||||
|
||||
- **read_graph**
|
||||
- Read the entire knowledge graph
|
||||
|
|
|
|||
133
src/memory/__tests__/atomic-save.test.ts
Normal file
133
src/memory/__tests__/atomic-save.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { KnowledgeGraphManager, Entity } from '../index.js';
|
||||
|
||||
/**
|
||||
* Regression tests for durable persistence of the knowledge graph.
|
||||
*
|
||||
* saveGraph() previously called fs.writeFile() directly on the memory file.
|
||||
* fs.writeFile opens the target with 'w', which truncates it before any new
|
||||
* bytes are written. If the process dies between truncation and completion
|
||||
* (SIGKILL, container stop, OOM kill, power loss) the memory file — the sole
|
||||
* persistence layer for the graph — is left empty or half-written, and the
|
||||
* accumulated memory is unrecoverable.
|
||||
*
|
||||
* The fix writes to a temporary file in the same directory and then renames
|
||||
* it over the target. rename(2) is atomic on POSIX filesystems: a reader
|
||||
* either sees the complete old file or the complete new one, never a
|
||||
* truncated intermediate state.
|
||||
*/
|
||||
describe('KnowledgeGraphManager persistence durability', () => {
|
||||
let testDir: string;
|
||||
let testFilePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-memory-atomic-'));
|
||||
testFilePath = path.join(testDir, 'memory.jsonl');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('never truncates the live memory file in place', async () => {
|
||||
const manager = new KnowledgeGraphManager(testFilePath);
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
|
||||
]);
|
||||
|
||||
// Any write that targets the live file directly is destructive: it
|
||||
// truncates committed data before the replacement is durable.
|
||||
const writeFileSpy = vi.spyOn(fs, 'writeFile');
|
||||
const openSpy = vi.spyOn(fs, 'open');
|
||||
|
||||
await manager.createEntities([
|
||||
{ name: 'Bob', entityType: 'person', observations: ['likes programming'] },
|
||||
]);
|
||||
|
||||
const destructiveTargets = [
|
||||
...writeFileSpy.mock.calls.map(call => call[0]),
|
||||
...openSpy.mock.calls.map(call => call[0]),
|
||||
].filter(target => target === testFilePath);
|
||||
|
||||
expect(destructiveTargets).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves the committed graph intact when the write fails midway', async () => {
|
||||
const manager = new KnowledgeGraphManager(testFilePath);
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
|
||||
]);
|
||||
|
||||
const before = await fs.readFile(testFilePath, 'utf-8');
|
||||
expect(before).toContain('Alice');
|
||||
|
||||
// Simulate the process being interrupted while persisting the next write.
|
||||
vi.spyOn(fs, 'writeFile').mockImplementationOnce(async () => {
|
||||
throw new Error('ENOSPC: simulated interruption');
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.createEntities([
|
||||
{ name: 'Bob', entityType: 'person', observations: ['likes programming'] },
|
||||
])
|
||||
).rejects.toThrow();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// The previously committed graph must survive untouched.
|
||||
expect(await fs.readFile(testFilePath, 'utf-8')).toBe(before);
|
||||
|
||||
const graph = await new KnowledgeGraphManager(testFilePath).readGraph();
|
||||
expect(graph.entities.map(e => e.name)).toEqual(['Alice']);
|
||||
});
|
||||
|
||||
it('does not leave temporary files behind after a successful write', async () => {
|
||||
const manager = new KnowledgeGraphManager(testFilePath);
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
|
||||
]);
|
||||
|
||||
expect(await fs.readdir(testDir)).toEqual(['memory.jsonl']);
|
||||
});
|
||||
|
||||
it('cleans up the temporary file when the write fails', async () => {
|
||||
const manager = new KnowledgeGraphManager(testFilePath);
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
|
||||
]);
|
||||
|
||||
vi.spyOn(fs, 'rename').mockImplementationOnce(async () => {
|
||||
throw new Error('EXDEV: simulated rename failure');
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.createEntities([
|
||||
{ name: 'Bob', entityType: 'person', observations: ['likes programming'] },
|
||||
])
|
||||
).rejects.toThrow();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
expect(await fs.readdir(testDir)).toEqual(['memory.jsonl']);
|
||||
});
|
||||
|
||||
it('still persists graph contents correctly across reloads', async () => {
|
||||
const manager = new KnowledgeGraphManager(testFilePath);
|
||||
await manager.createEntities([
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
|
||||
{ name: 'Bob', entityType: 'person', observations: ['likes programming'] },
|
||||
]);
|
||||
await manager.createRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
]);
|
||||
|
||||
const reloaded = await new KnowledgeGraphManager(testFilePath).readGraph();
|
||||
expect(reloaded.entities.map(e => e.name).sort()).toEqual(['Alice', 'Bob']);
|
||||
expect(reloaded.relations).toEqual([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
99
src/memory/__tests__/delete-reporting.test.ts
Normal file
99
src/memory/__tests__/delete-reporting.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { KnowledgeGraphManager, Entity, Relation } from '../index.js';
|
||||
|
||||
/**
|
||||
* The delete tools stay silent when a target is absent, which the README
|
||||
* documents. What they must not do is report a deletion that did not happen:
|
||||
* an agent that mistypes a name is told its memory is clean while the data is
|
||||
* still on disk, and nothing in the response contradicts that.
|
||||
*/
|
||||
describe('delete reporting', () => {
|
||||
let manager: KnowledgeGraphManager;
|
||||
let testFilePath: string;
|
||||
|
||||
const entities: Entity[] = [
|
||||
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp', 'likes tea'] },
|
||||
{ name: 'Bob', entityType: 'person', observations: ['likes programming'] },
|
||||
];
|
||||
const relations: Relation[] = [{ from: 'Alice', to: 'Bob', relationType: 'works_with' }];
|
||||
|
||||
beforeEach(async () => {
|
||||
testFilePath = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
`test-delete-reporting-${Date.now()}-${Math.random().toString(16).slice(2)}.jsonl`
|
||||
);
|
||||
manager = new KnowledgeGraphManager(testFilePath);
|
||||
await manager.createEntities(entities);
|
||||
await manager.createRelations(relations);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.unlink(testFilePath);
|
||||
} catch {
|
||||
// the file is gone already
|
||||
}
|
||||
});
|
||||
|
||||
describe('deleteEntities', () => {
|
||||
it('reports which names matched and which did not', async () => {
|
||||
const result = await manager.deleteEntities(['Alice', 'Alise']);
|
||||
expect(result).toEqual({ deleted: ['Alice'], notFound: ['Alise'] });
|
||||
});
|
||||
|
||||
it('reports nothing deleted when no name matches', async () => {
|
||||
const result = await manager.deleteEntities(['Nobody']);
|
||||
expect(result).toEqual({ deleted: [], notFound: ['Nobody'] });
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('still deletes the entity and its relations', async () => {
|
||||
await manager.deleteEntities(['Alice']);
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.entities.map(e => e.name)).toEqual(['Bob']);
|
||||
expect(graph.relations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteObservations', () => {
|
||||
it('counts only the observations that were present', async () => {
|
||||
const result = await manager.deleteObservations([
|
||||
{ entityName: 'Alice', observations: ['likes tea', 'never said this'] },
|
||||
]);
|
||||
expect(result).toEqual({ deletedCount: 1, missingEntities: [] });
|
||||
});
|
||||
|
||||
it('names an entity that does not exist', async () => {
|
||||
const result = await manager.deleteObservations([
|
||||
{ entityName: 'Carol', observations: ['anything'] },
|
||||
]);
|
||||
expect(result).toEqual({ deletedCount: 0, missingEntities: ['Carol'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRelations', () => {
|
||||
it('counts only the relations that matched', async () => {
|
||||
const result = await manager.deleteRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'works_with' },
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'never_existed' },
|
||||
]);
|
||||
expect(result).toEqual({ deletedCount: 1 });
|
||||
});
|
||||
|
||||
it('reports nothing deleted when the relation type is wrong', async () => {
|
||||
const result = await manager.deleteRelations([
|
||||
{ from: 'Alice', to: 'Bob', relationType: 'manages' },
|
||||
]);
|
||||
expect(result).toEqual({ deletedCount: 0 });
|
||||
|
||||
const graph = await manager.readGraph();
|
||||
expect(graph.relations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue