Compare commits

...
Author SHA1 Message Date
Nandini-Inuguru
d73f99efbf
fix(memory): serialize graph mutations to prevent concurrent write race (#4555)
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
createEntities, createRelations, addObservations, deleteEntities,
deleteObservations, and deleteRelations each independently did
load -> mutate -> save with no synchronization. Concurrent tool
calls (e.g. multiple mutations dispatched from one LLM turn) could
race: both read the same starting state, both write back their own
copy, and whichever write landed last silently discarded the other's
changes. Interleaved writes could also corrupt the file outright.

Adds an in-process async mutex (KnowledgeGraphManager.withLock) that
serializes all six mutation methods through a single queue. Read-only
methods (readGraph, searchNodes, openNodes) are unaffected.

Verified: reverting the fix and re-running the new concurrency tests
reproduces the bug exactly (lost entities, lost relations, malformed
JSONL lines). With the fix, all 39 tests pass.

Fixes #1819

Co-authored-by: olaservo <olahungerford@gmail.com>
2026-09-02 18:42:26 -07:00
JSap0914
1ec570c27d
fix(memory): skip duplicate entities and relations within a single batch (#4383)
create_entities and create_relations only de-duplicated against the existing
graph, so passing the same entity name (or identical relation) twice in one
call persisted duplicate records. This contradicts the documented behavior
("Ignores entities with existing names" / "Skips duplicate relations") and
breaks the implicit name-uniqueness invariant the rest of the manager relies
on (e.g. addObservations/deleteEntities key on name).

De-duplicate within the input batch as well, keeping the first occurrence.

Co-authored-by: JSap0914 <JSap0914@users.noreply.github.com>
Co-authored-by: olaservo <olahungerford@gmail.com>
2026-09-02 18:38:22 -07:00
fei
f41b666edf
fix(memory): constrain search_nodes query length (#4662)
The search_nodes tool accepted an unbounded query string (per
modelcontextprotocol/servers#3537, official servers should constrain
string parameters). An oversized query costs an O(graph) scan per
call with no value. Cap it at 2048 chars via an exported
SearchNodesQuerySchema, and add vitest coverage for the boundary
(at-limit accepted, over-limit rejected, non-string still rejected).

Signed-off-by: fei <204683769+feiiiiii5@users.noreply.github.com>
2026-09-02 18:35:03 -07:00
Yiheng Zhao
c3d8e43d1f
fix(memory): validate knowledge graph entries when loading from disk (#4717)
loadGraph() trusted the persisted memory file and pushed entities and
relations without validating their fields. A corrupted or legacy entry
(e.g. an entity missing entityType, or an observation that is not a string)
would reach searchNodes and crash with "Cannot read properties of undefined
(reading 'toLowerCase')".

Validate each line against the existing EntitySchema/RelationSchema and skip
malformed entries with a warning, so the in-memory graph only ever contains
well-formed data. Malformed JSON lines are skipped as well.

Fixes #2044
2026-09-02 18:34:58 -07:00
Connor Moss
abbbddb2e3
fix(memory): stop reporting deletions that did not happen (#4738)
delete_entities, delete_observations and delete_relations returned
success: true with a hardcoded "deleted successfully" message regardless
of what matched. An agent that mistypes an entity name is told its memory
is clean while the data is still on disk, and nothing in the response
contradicts that. addObservations throws for the same condition ten lines
above, so the file already disagreed with itself.

Staying quiet is deliberate and documented, so nothing throws and the
output schema is unchanged. The three manager methods now return what
they matched, and the handlers say so. A delete where everything is found
returns the same message it always did.

README updated: the three "Silent operation" bullets described the
absence of an error, which is still true, but read as if the response
said nothing either.
2026-09-02 18:34:53 -07:00
Amey Pawar
649af5856e
fix(memory): expand leading ~ in MEMORY_FILE_PATH to the home directory (#4447)
* fix(memory): expand leading ~ in MEMORY_FILE_PATH to the home directory

MCP clients pass MEMORY_FILE_PATH from JSON config, where no shell
expands a leading "~". Because path.isAbsolute("~/memory.jsonl") is
false, the value was joined onto the package directory, so the server
persisted to a literal "~" folder inside the install instead of the
user's intended location.

Add an expandHome() helper that mirrors the one already used by the
filesystem server (src/filesystem/path-utils.ts) and apply it in
ensureMemoryFilePath() before the existing absolute/relative resolution.
Absolute paths, relative paths, and a "~" not at the start are
unaffected. Adds unit tests for expandHome plus an end-to-end test
through ensureMemoryFilePath.

Addresses #1600

* test(memory): use a literal ~/ path in the tilde expansion test

path.join('~', ...) produces a backslash on Windows, which expandHome does
not match, so the test failed there.

---------

Co-authored-by: olaservo <olahungerford@gmail.com>
2026-09-02 18:15:44 -07:00
Nick Veenhof
242751e8ee
fix(memory): add trailing newline to JSONL output (#3653)
Co-authored-by: Nick Veenhof <nveenhof@gitlab.com>
Co-authored-by: olaservo <olahungerford@gmail.com>
2026-09-02 18:15:35 -07:00
Yufeng He
ba167cef5b
fix: declare zod runtime dependencies (#4289)
Co-authored-by: olaservo <olahungerford@gmail.com>
2026-09-02 18:15:26 -07:00
King Star
48723c95dc
fix(memory): reject dangling relations (#4477)
Signed-off-by: King Star <mcxin.y@gmail.com>
2026-09-02 08:04:34 -07:00
石岳峰
96c49c7364
fix(memory): read serverInfo.version from package.json (#4407)
Fixes #4406. The memory server previously hardcoded serverInfo.version as
"0.6.3", which no longer matched published calendar versions. Resolve
package.json from both source and dist layouts so Docker and npm installs
report the installed package version.

Co-authored-by: syf2211 <syf2211@users.noreply.github.com>
2026-09-02 08:04:28 -07:00
Yusufhan Sacak
cce08de417
fix(memory): resolve Vitest false positive for expected rejection (#3893)
Wrap the addObservations call in an async closure before asserting with
.rejects.toThrow(). This prevents Vitest's global unhandled rejection
handler from capturing the expected error before the assertion can
intercept it, eliminating the false-positive "Unhandled Errors" warning
that was failing CI.

Fixes #3073
2026-09-02 08:03:51 -07:00
Ola Hungerford
2e3e4c7ab7
ci: run readme-pr-check on pull_request_target so fork PRs can be labeled (#4739)
Observed on #4714 and #4726: addLabels returned 403 "Resource not
accessible by integration" because pull_request gives fork PRs a
read-only token. The job only calls the GitHub API and never checks
out PR code, so pull_request_target is safe here.


Claude-Session: https://claude.ai/code/session_01CK8vvB229h6orvkokG8YxU

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:01:29 -07:00
Abhinav Prakash
3aa19f5574
fix(git): unify git_log output schema and remove raise_exceptions from server.run (#4658) 2026-09-01 19:25:31 -07:00
Abhinav Prakash
579c3903f3
docs(brave-search): point to official @brave/brave-search-mcp-server package (#4714)
Fixes #4694
2026-08-31 09:22:36 -07:00
Yohanes
a6cdbf4dea
fix(filesystem): reject Windows paths on POSIX (#4704)
Co-authored-by: Yohanes <CryoThrust@users.noreply.github.com>
2026-08-30 14:43:38 -07:00
Trent Apple
ebaccfd623
fix: Containers - Bind Mounts - Security and Compatibility (Docker / Podman / SELinux) for Fetch, Git, and Time Servers (#2205)
* Update Dockerfile for fetch

Initial patch for podman and SELinux contexts.

* Update Dockerfile for time

podman and SELinux

* Update Dockerfile

* Also include app user / group (securely) such that the build succeeds

* Container app user optional add (#1)

* Update Dockerfile git

* Update Dockerfile fetch

* Update Dockerfile time

* Update Dockerfile

* Update Dockerfile

* Update Dockerfile

---------

Co-authored-by: Ola Hungerford <olahungerford@gmail.com>
2026-08-30 13:07:02 -07:00
Riley
cda92bdaac
fix(filesystem): create_directory doesn't create parent directories (#4631)
validatePath()'s ENOENT fallback checked only the immediate parent
directory before allowing a new path through. Creating a path with
more than one missing level (e.g. a/b/c when none of a, b, c exist)
made the immediate parent check fail with "Parent directory does not
exist", even though fs.mkdir was already called with recursive: true
one level up in the create_directory handler and an existing allowed
ancestor was available further up the tree.

Walk up through missing ancestors until an existing one is found (or
the filesystem root is reached), verifying that ancestor still
resolves inside an allowed directory via realpath. The top-level
lexical containment check already ran against the full target path,
so this only extends how far the existence/symlink check climbs.

Fixes #4629

Co-authored-by: Riley <313779891+rileybuilds@users.noreply.github.com>
Co-authored-by: olaservo <olahungerford@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 10:58:19 -07:00
Amir Fathi
d6402efee6
fix(sequentialthinking): restore nextThoughtNeeded in the advertised inputSchema required array (#4652)
* fix(sequentialthinking): restore nextThoughtNeeded in the advertised inputSchema required array

commit 1cdf806d (#3533) wrapped nextThoughtNeeded in a z.preprocess-based
coercedBoolean to fix a real footgun (string "false" coercing to true).
zod's toJSONSchema(..., { io: "input" }) treats a z.preprocess()-wrapped
field's input type as unknown, so it silently drops that field from the
emitted required array, even though it carries no .optional(). A client
that builds its call arguments from the advertised schema then omits
nextThoughtNeeded and gets a -32602 Invalid params from the runtime
validator, which still requires it.

Rebuild coercedBoolean as a transform on an explicit z.union([z.boolean(),
z.string()]) instead of z.preprocess. The union gives toJSONSchema a
concrete input type to report, so nextThoughtNeeded stays in required,
while parse behavior (including the case-insensitive string coercion
#3533 added) is unchanged.

Fixes #4651

* test(sequentialthinking): pin nextThoughtNeeded in required and string coercion

Runs against the built server so it checks the schema the SDK emits.
Skips when dist/ is absent, matching server-version.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Wo28CHPXyM3DHoLWvKnCK

---------

Co-authored-by: olaservo <olahungerford@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 10:57:34 -07:00
karl4c
40a8de87ed
fix(sequentialthinking): read server version from package.json (#4576)
* fix(sequentialthinking): read server version from package.json

Replace the hardcoded 0.2.0 so initialize serverInfo tracks the
package version (0.6.2 in-tree; CalVer after release), matching the
approach used for memory server version sync.

Skip the dist-layout assertion when dist/ is absent so CI's
test-before-build job (and local runs without prepare) still pass.

* test(sequentialthinking): smoke stdio initialize serverInfo.version

Spawn the built server over stdio and assert getServerVersion() matches
package.json (and is not the old hardcoded 0.2.0). Skip when dist/ is
absent so test-before-build CI still passes.
2026-08-28 10:01:24 -07:00
Excelius
11845d51cb
fix(filesystem): preserve UTF-8 across head and tail chunks (#4667) 2026-08-28 10:01:16 -07:00
KXH
cf05b37694
fix(filesystem): resolve Unicode-equivalent paths (#4638)
Signed-off-by: KXH <shepherdlaurie238@gmail.com>
2026-08-28 10:01:08 -07:00
Teddy100x
642a9112e7
fix(memory): write the knowledge graph atomically (#4642)
* fix(memory): write the knowledge graph atomically

`saveGraph()` wrote the graph straight to the memory file:

    await fs.writeFile(this.memoryFilePath, lines.join("\n"));

`fs.writeFile` opens the target with `'w'`, which truncates it before any
new bytes are written. If the process is interrupted between truncation
and completion — SIGKILL, container or host stop, OOM kill, power loss —
the memory file is left empty or half-written.

That file is the only persistence layer for the knowledge graph, so the
window is small but the loss is total and unrecoverable.

The fix writes to a temporary file in the same directory and renames it
over the target. `rename(2)` is atomic on POSIX filesystems: a reader
sees either the complete old file or the complete new one, never an
intermediate state. Keeping the temp file in the same directory ensures
the rename stays on one filesystem, since cross-device renames fail with
EXDEV. On failure the temp file is removed so no strays accumulate.

Tests added in `__tests__/atomic-save.test.ts`:

- the live memory file is never opened for truncating writes
- a committed graph survives a write that fails midway
- no temporary files remain after a successful write
- the temporary file is cleaned up when the rename fails
- graph contents still round-trip correctly across reloads

The first and fourth fail against the previous implementation.

Full suite: 55 passed (50 existing, 5 new). `tsc --noEmit` clean.

Fixes #4614

* fix(memory): use randomBytes for temp filename to avoid concurrent collision

---------

Co-authored-by: teddiesloco <teddiesloco@users.noreply.github.com>
2026-08-28 10:00:33 -07:00
eeshsaxena
18ce197639
fix(filesystem): make move_file fail instead of overwriting the destination (#4630)
The move_file tool description and README both state the operation fails if
the destination already exists, but the handler called fs.rename directly,
which silently overwrites the destination. Since this server ships no delete
tool, move_file effectively provided unadvertised, irreversible file
deletion: moving any file onto an existing target destroyed the target.

Add a moveFile helper in lib.ts that rejects when the destination already
exists (checked with lstat, so an existing symlink is detected rather than
followed) and only renames when the target is free, and use it from the
move_file handler. Behaviour now matches the documented contract.

Fixes #4628
2026-08-28 09:58:51 -07:00
drudiger
562feeb281
fix(filesystem): preserve file permissions during write and edit operations (#4115)
* fix(filesystem): preserve file permissions during write and edit operations

The atomic write pattern (write temp file + rename) replaces the original
inode, causing the new file to have default 0644 permissions regardless of
what the original file had. This breaks executable scripts and other files
with non-default permissions.

Fix: capture stat.mode before writing and restore it with chmod after rename.

Fixes both writeFileContent() and applyFileEdits().

* fix(filesystem): mask chmod mode and keep chmod failure from failing the write

Pass origStats.mode & 0o777 to chmod instead of the full st_mode. Move
the chmod out of the try whose catch unlinks the temp file and rethrows,
since rename has already succeeded by then. Add a test that an EPERM
from chmod does not reject the write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Wo28CHPXyM3DHoLWvKnCK

---------

Co-authored-by: Dustin Rudiger <drudiger@apple.com>
Co-authored-by: olaservo <olahungerford@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 19:36:45 -07:00
Ryan Lopopolo
71e3cfbd1e
test(filesystem): add MCP SDK regression coverage for directory_tree (#3245)
* test(filesystem): add directory_tree MCP SDK regression coverage

* docs(filesystem): drop troubleshooting note from README
2026-08-27 19:17:32 -07:00
1060996408
781a93db14
fix(everything): remove dead code in template URI validation and subscription cleanup (#4104)
resources/templates.ts:
- `parseResourceId` had a guard that compared the URI against both
  `textUriBase` and `blobUriBase` with `&&`. Those prefixes are mutually
  exclusive, so the condition is always false and the branch is dead.
  Drop it; the SDK's template-based routing already guarantees the URI
  prefix is one of the two before the handler runs. The remaining
  positive-integer check on `resourceId` is preserved.

resources/subscriptions.ts:
- `sendSimulatedResourceUpdates` had an `else` branch that called
  `subscribers.delete(sessionId)` whenever the session wasn't in a URI's
  subscriber set, with a comment claiming the session had disconnected.
  That conclusion doesn't follow — a session not subscribed to URI A
  can still be subscribed to URI B — and the delete is a no-op when
  the element is absent anyway. Remove the branch.

No behavioral change. Existing 95 / 95 tests in `__tests__` still pass.

Co-authored-by: Jia Xuan <1060996408+jiaxuan@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-27 19:17:27 -07:00
nulone
8102fbe27a
fix(filesystem): handle negative values in formatSize() (#3231)
Return '0 B' for negative byte values instead of 'NaN B'.
File sizes cannot be negative, so this is a safe default.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-08-27 19:17:22 -07:00
33 changed files with 1568 additions and 232 deletions

View file

@ -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

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).
- **[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
View file

@ -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"

View file

@ -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
}
}
};

View file

@ -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()}`);
};
/**

View file

@ -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

View 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);
});
});

View file

@ -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(),

View 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');
});
});

View file

@ -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');

View 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');
});
});

View file

@ -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 {

View file

@ -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) {

View file

@ -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",

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View 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' },
]);
});
});

View 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