diff --git a/.github/workflows/readme-pr-check.yml b/.github/workflows/readme-pr-check.yml index ec5a0a2..66cb3a3 100644 --- a/.github/workflows/readme-pr-check.yml +++ b/.github/workflows/readme-pr-check.yml @@ -1,10 +1,7 @@ name: README PR Check on: - # 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: + pull_request: types: [opened] paths: - 'README.md' @@ -13,7 +10,7 @@ on: jobs: check-readme-only: - if: github.event_name == 'pull_request_target' + if: github.event_name == 'pull_request' runs-on: ubuntu-latest permissions: contents: read diff --git a/package-lock.json b/package-lock.json index 632d340..1845571 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3866,8 +3866,7 @@ "@modelcontextprotocol/sdk": "^1.30.0", "diff": "^8.0.3", "glob": "^13.0.6", - "minimatch": "^10.0.1", - "zod": "^4.0.0" + "minimatch": "^10.0.1" }, "bin": { "mcp-server-filesystem": "dist/index.js" @@ -3887,8 +3886,7 @@ "version": "0.6.3", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", - "zod": "^4.0.0" + "@modelcontextprotocol/sdk": "^1.30.0" }, "bin": { "mcp-server-memory": "dist/index.js" @@ -3908,8 +3906,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "chalk": "^5.3.0", - "yargs": "^17.7.2", - "zod": "^4.0.0" + "yargs": "^17.7.2" }, "bin": { "mcp-server-sequential-thinking": "dist/index.js" diff --git a/src/everything/package.json b/src/everything/package.json index 745d1b1..0105c3d 100644 --- a/src/everything/package.json +++ b/src/everything/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server-everything", - "version": "2.0.0", + "version": "2026.8.31", "description": "MCP server that exercises all the features of the MCP protocol", "license": "SEE LICENSE IN LICENSE", "mcpName": "io.github.modelcontextprotocol/server-everything", @@ -45,4 +45,4 @@ "typescript": "^5.6.2", "vitest": "^4.1.8" } -} +} \ No newline at end of file diff --git a/src/filesystem/package.json b/src/filesystem/package.json index 3c288a2..c8fa04f 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server-filesystem", - "version": "0.6.3", + "version": "2026.8.31", "description": "MCP server for filesystem access", "license": "SEE LICENSE IN LICENSE", "mcpName": "io.github.modelcontextprotocol/server-filesystem", @@ -28,8 +28,7 @@ "@modelcontextprotocol/sdk": "^1.30.0", "diff": "^8.0.3", "glob": "^13.0.6", - "minimatch": "^10.0.1", - "zod": "^4.0.0" + "minimatch": "^10.0.1" }, "devDependencies": { "@types/diff": "^5.0.9", @@ -40,4 +39,4 @@ "typescript": "^5.8.2", "vitest": "^4.1.8" } -} +} \ No newline at end of file diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index b94af84..84188d8 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -1,6 +1,6 @@ import logging from pathlib import Path -from typing import Any, Optional, Sequence +from typing import Sequence, Optional from mcp.server import Server from mcp.server.session import ServerSession from mcp.server.stdio import stdio_server @@ -157,28 +157,45 @@ 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]: - # 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 '-'") + 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']) - kwargs: dict[str, Any] = {"max_count": max_count} - if start_timestamp: - kwargs["since"] = start_timestamp - if end_timestamp: - kwargs["until"] = end_timestamp + log_output = repo.git.log(*args).split('\n') - 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 + 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 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 @@ -582,4 +599,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) + await server.run(read_stream, write_stream, options, raise_exceptions=True) diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index 05d5931..893195d 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -16,10 +16,8 @@ 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): @@ -510,81 +508,3 @@ 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 " { - 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); - }); - }); -}); diff --git a/src/memory/__tests__/file-path.test.ts b/src/memory/__tests__/file-path.test.ts index fe5fdeb..d1a16e4 100644 --- a/src/memory/__tests__/file-path.test.ts +++ b/src/memory/__tests__/file-path.test.ts @@ -1,9 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; -import os from 'os'; import { fileURLToPath } from 'url'; -import { ensureMemoryFilePath, defaultMemoryPath, expandHome } from '../index.js'; +import { ensureMemoryFilePath, defaultMemoryPath } from '../index.js'; describe('ensureMemoryFilePath', () => { const testDir = path.dirname(fileURLToPath(import.meta.url)); @@ -73,15 +72,6 @@ describe('ensureMemoryFilePath', () => { expect(path.isAbsolute(result)).toBe(true); } }); - - it('should expand a leading "~/" to the home directory', async () => { - process.env.MEMORY_FILE_PATH = '~/custom-memory.jsonl'; - - const result = await ensureMemoryFilePath(); - - expect(result).toBe(path.join(os.homedir(), 'custom-memory.jsonl')); - expect(path.isAbsolute(result)).toBe(true); - }); }); describe('without MEMORY_FILE_PATH environment variable', () => { @@ -164,29 +154,3 @@ describe('ensureMemoryFilePath', () => { }); }); }); - -describe('expandHome', () => { - it('expands a bare "~" to the home directory', () => { - expect(expandHome('~')).toBe(os.homedir()); - }); - - it('expands a leading "~/" to the home directory', () => { - expect(expandHome('~/notes/memory.jsonl')).toBe( - path.join(os.homedir(), 'notes/memory.jsonl') - ); - }); - - it('leaves a "~" not followed by a separator unchanged', () => { - expect(expandHome('~backup.jsonl')).toBe('~backup.jsonl'); - }); - - it('leaves absolute paths unchanged', () => { - expect(expandHome('/var/data/memory.jsonl')).toBe('/var/data/memory.jsonl'); - }); - - it('leaves relative paths unchanged', () => { - expect(expandHome(path.join('data', 'memory.jsonl'))).toBe( - path.join('data', 'memory.jsonl') - ); - }); -}); diff --git a/src/memory/__tests__/knowledge-graph.test.ts b/src/memory/__tests__/knowledge-graph.test.ts index 7d05a0c..2362424 100644 --- a/src/memory/__tests__/knowledge-graph.test.ts +++ b/src/memory/__tests__/knowledge-graph.test.ts @@ -59,20 +59,6 @@ describe('KnowledgeGraphManager', () => { const newEntities = await manager.createEntities([]); expect(newEntities).toHaveLength(0); }); - - it('should ignore duplicate entity names within a single batch', async () => { - const entities: Entity[] = [ - { name: 'Alice', entityType: 'person', observations: ['first'] }, - { name: 'Alice', entityType: 'person', observations: ['second'] }, - ]; - - const newEntities = await manager.createEntities(entities); - expect(newEntities).toHaveLength(1); - - const graph = await manager.readGraph(); - expect(graph.entities).toHaveLength(1); - expect(graph.entities[0].name).toBe('Alice'); - }); }); describe('createRelations', () => { @@ -113,60 +99,10 @@ describe('KnowledgeGraphManager', () => { expect(graph.relations).toHaveLength(1); }); - it('should reject relations from non-existent entities', async () => { - await manager.createEntities([ - { name: 'Alice', entityType: 'person', observations: [] }, - ]); - - await expect( - manager.createRelations([ - { from: 'Ghost', to: 'Alice', relationType: 'knows' }, - ]) - ).rejects.toThrow('Entity with name Ghost not found'); - - const graph = await manager.readGraph(); - expect(graph.relations).toHaveLength(0); - }); - - it('should reject relation batches that reference non-existent target entities', async () => { - await manager.createEntities([ - { name: 'Alice', entityType: 'person', observations: [] }, - { name: 'Bob', entityType: 'person', observations: [] }, - ]); - - await expect( - manager.createRelations([ - { from: 'Alice', to: 'Bob', relationType: 'knows' }, - { from: 'Alice', to: 'Ghost', relationType: 'knows' }, - ]) - ).rejects.toThrow('Entity with name Ghost not found'); - - const graph = await manager.readGraph(); - expect(graph.relations).toHaveLength(0); - }); - it('should handle empty relation arrays', async () => { const newRelations = await manager.createRelations([]); expect(newRelations).toHaveLength(0); }); - - it('should skip duplicate relations within a single batch', async () => { - await manager.createEntities([ - { name: 'Alice', entityType: 'person', observations: [] }, - { name: 'Bob', entityType: 'person', observations: [] }, - ]); - - const relations: Relation[] = [ - { from: 'Alice', to: 'Bob', relationType: 'knows' }, - { from: 'Alice', to: 'Bob', relationType: 'knows' }, - ]; - - const newRelations = await manager.createRelations(relations); - expect(newRelations).toHaveLength(1); - - const graph = await manager.readGraph(); - expect(graph.relations).toHaveLength(1); - }); }); describe('addObservations', () => { @@ -210,11 +146,11 @@ describe('KnowledgeGraphManager', () => { }); it('should throw error for non-existent entity', async () => { - await expect(async () => { - await manager.addObservations([ + await expect( + manager.addObservations([ { entityName: 'NonExistent', contents: ['some observation'] }, - ]); - }).rejects.toThrow('Entity with name NonExistent not found'); + ]) + ).rejects.toThrow('Entity with name NonExistent not found'); }); }); @@ -490,76 +426,6 @@ describe('KnowledgeGraphManager', () => { expect(JSON.parse(lines[1])).toHaveProperty('type', 'relation'); }); - it('should write a trailing newline to produce valid JSONL', async () => { - await manager.createEntities([ - { name: 'Alice', entityType: 'person', observations: ['test'] }, - ]); - - const fileContent = await fs.readFile(testFilePath, 'utf-8'); - expect(fileContent.endsWith('\n')).toBe(true); - }); - - it('should produce a file where every line is individually valid JSON', async () => { - // This test catches the bug where saveGraph wrote lines.join("\n") - // without a trailing newline. When the file was later appended to - // (e.g. by a concurrent process or external tool), the last JSON - // object and the new first JSON object ended up on the same line, - // producing invalid JSONL like: - // {"type":"entity","name":"Alice"}{"type":"relation","from":"Alice",...} - // which fails with: "Unexpected non-whitespace character after JSON - // at position N" - await manager.createEntities([ - { name: 'Alice', entityType: 'person', observations: ['test'] }, - { name: 'Bob', entityType: 'person', observations: [] }, - ]); - await manager.createRelations([ - { from: 'Alice', to: 'Bob', relationType: 'knows' }, - ]); - - const fileContent = await fs.readFile(testFilePath, 'utf-8'); - const allLines = fileContent.split('\n'); - - // Every non-empty line must be valid JSON on its own - for (const line of allLines) { - if (line.trim() === '') continue; - expect(() => JSON.parse(line)).not.toThrow(); - } - }); - - it('should not corrupt JSONL when content is appended to the file externally', async () => { - // Simulate the real-world corruption scenario: - // 1. saveGraph writes entities to the file - // 2. An external process appends a new JSON line to the file - // 3. loadGraph must still parse the file without errors - // - // Without a trailing newline on step 1, the appended content in - // step 2 lands on the same line as the last entity, producing - // invalid JSONL that breaks loadGraph. - await manager.createEntities([ - { name: 'Alice', entityType: 'person', observations: ['original'] }, - ]); - - // Simulate an external append (e.g. another process, a script, or - // a crash-recovery replay). This is what triggers the bug: without - // a trailing newline, this JSON object concatenates onto line 1. - const externalLine = JSON.stringify({ - type: 'entity', - name: 'External', - entityType: 'person', - observations: ['appended externally'], - }); - await fs.appendFile(testFilePath, externalLine + '\n'); - - // A new manager instance forces a fresh loadGraph from disk - const manager2 = new KnowledgeGraphManager(testFilePath); - const graph = await manager2.readGraph(); - - // Both entities must load without a JSON parse error - expect(graph.entities).toHaveLength(2); - expect(graph.entities.map(e => e.name)).toContain('Alice'); - expect(graph.entities.map(e => e.name)).toContain('External'); - }); - it('should strip type field from entities when loading from file', async () => { // Create entities and relations (these get saved with type field) await manager.createEntities([ @@ -649,126 +515,4 @@ describe('KnowledgeGraphManager', () => { expect(result.relations[0]).not.toHaveProperty('type'); }); }); - - describe('loadGraph validation', () => { - it('skips corrupt entities instead of crashing search', async () => { - const lines = [ - JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] }), - JSON.stringify({ type: 'entity', name: 'Broken', observations: ['missing entityType'] }), - JSON.stringify({ type: 'entity', name: 'BadObs', entityType: 'person', observations: ['ok', null] }), - ]; - await fs.writeFile(testFilePath, lines.join('\n') + '\n'); - - const graph = await manager.readGraph(); - expect(graph.entities).toHaveLength(1); - expect(graph.entities[0].name).toBe('Alice'); - - // searchNodes must not throw even though the file contains corrupt entries - const result = await manager.searchNodes('Acme'); - expect(result.entities).toHaveLength(1); - expect(result.entities[0].name).toBe('Alice'); - }); - - it('skips corrupt relations', async () => { - const lines = [ - JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: [] }), - JSON.stringify({ type: 'relation', from: 'Alice', to: 'Bob' }), // missing relationType - JSON.stringify({ type: 'relation', from: 'Alice', to: 'Bob', relationType: 'knows' }), - ]; - await fs.writeFile(testFilePath, lines.join('\n') + '\n'); - - const graph = await manager.readGraph(); - expect(graph.entities).toHaveLength(1); - expect(graph.relations).toHaveLength(1); - expect(graph.relations[0].relationType).toBe('knows'); - }); - - it('skips malformed JSON lines', async () => { - const lines = [ - JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: [] }), - '{this is not valid json', - JSON.stringify({ type: 'entity', name: 'Bob', entityType: 'person', observations: [] }), - ]; - await fs.writeFile(testFilePath, lines.join('\n') + '\n'); - - const graph = await manager.readGraph(); - expect(graph.entities).toHaveLength(2); - expect(graph.entities.map(e => e.name)).toEqual(['Alice', 'Bob']); - }); - }); - - describe('concurrent mutations', () => { - // Regression test for #1819: concurrent tool calls each independently - // load the graph, mutate their own copy, and write it back. Without - // serialization, whichever write lands last silently discards the - // other's changes. All mutations below are fired without awaiting each - // other first, simulating multiple tool calls landing close together. - - it('should not lose entities created concurrently', async () => { - const batch1: Entity[] = Array.from({ length: 10 }, (_, i) => ({ - name: `batch1-entity-${i}`, - entityType: 'test', - observations: [], - })); - const batch2: Entity[] = Array.from({ length: 10 }, (_, i) => ({ - name: `batch2-entity-${i}`, - entityType: 'test', - observations: [], - })); - - // Fire both concurrently instead of awaiting sequentially. - await Promise.all([ - manager.createEntities(batch1), - manager.createEntities(batch2), - ]); - - const graph = await manager.readGraph(); - expect(graph.entities).toHaveLength(20); - expect(graph.entities.map(e => e.name).sort()).toEqual( - [...batch1, ...batch2].map(e => e.name).sort() - ); - }); - - it('should not lose relations created concurrently with entity creation', async () => { - await manager.createEntities([ - { name: 'Alice', entityType: 'person', observations: [] }, - { name: 'Bob', entityType: 'person', observations: [] }, - { name: 'Carol', entityType: 'person', observations: [] }, - ]); - - await Promise.all([ - manager.createRelations([{ from: 'Alice', to: 'Bob', relationType: 'knows' }]), - manager.createRelations([{ from: 'Bob', to: 'Carol', relationType: 'knows' }]), - manager.addObservations([ - { entityName: 'Alice', contents: ['likes coffee'] }, - ]), - ]); - - const graph = await manager.readGraph(); - expect(graph.relations).toHaveLength(2); - expect(graph.entities.find(e => e.name === 'Alice')?.observations).toContain('likes coffee'); - }); - - it('should keep the file valid JSONL after many concurrent mutations', async () => { - const operations = Array.from({ length: 25 }, (_, i) => - manager.createEntities([ - { name: `stress-entity-${i}`, entityType: 'test', observations: [] }, - ]) - ); - - await Promise.all(operations); - - const raw = await fs.readFile(testFilePath, 'utf-8'); - const lines = raw.split('\n').filter(line => line.trim() !== ''); - - // Every line must parse as valid JSON; a corrupted interleaved write - // would produce a truncated or malformed line here. - for (const line of lines) { - expect(() => JSON.parse(line)).not.toThrow(); - } - - const graph = await manager.readGraph(); - expect(graph.entities).toHaveLength(25); - }); - }); }); diff --git a/src/memory/__tests__/search-nodes-schema.test.ts b/src/memory/__tests__/search-nodes-schema.test.ts deleted file mode 100644 index c03f384..0000000 --- a/src/memory/__tests__/search-nodes-schema.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { SearchNodesQuerySchema, SEARCH_QUERY_MAX_LENGTH } from '../index.js'; - -describe('search_nodes input schema', () => { - it('should accept a normal query', () => { - expect(SearchNodesQuerySchema.safeParse('Alice').success).toBe(true); - expect(SearchNodesQuerySchema.safeParse('works at Acme Corp').success).toBe(true); - }); - - it('should accept a query at exactly the max length', () => { - const atLimit = 'a'.repeat(SEARCH_QUERY_MAX_LENGTH); - expect(SearchNodesQuerySchema.safeParse(atLimit).success).toBe(true); - }); - - it('should reject a query longer than the max length', () => { - const oversized = 'a'.repeat(SEARCH_QUERY_MAX_LENGTH + 1); - const result = SearchNodesQuerySchema.safeParse(oversized); - expect(result.success).toBe(false); - }); - - it('should still reject non-string input', () => { - expect(SearchNodesQuerySchema.safeParse(42).success).toBe(false); - expect(SearchNodesQuerySchema.safeParse(null).success).toBe(false); - }); -}); diff --git a/src/memory/__tests__/server-version.test.ts b/src/memory/__tests__/server-version.test.ts deleted file mode 100644 index f9cf437..0000000 --- a/src/memory/__tests__/server-version.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createRequire } from 'node:module'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { resolvePackageVersion, SERVER_VERSION } from '../version.js'; - -const packageJson = createRequire(import.meta.url)('../package.json') as { version: string }; - -describe('server version', () => { - it('uses package.json version for serverInfo', () => { - expect(SERVER_VERSION).toBe(packageJson.version); - expect(resolvePackageVersion()).toBe(packageJson.version); - }); - - it('resolves package.json from the dist layout', () => { - const distDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist'); - const distVersionPath = path.join(distDir, 'version.js'); - - expect(() => createRequire(distVersionPath)('./version.js')).not.toThrow(); - const distModule = createRequire(distVersionPath)('./version.js') as { - SERVER_VERSION: string; - }; - expect(distModule.SERVER_VERSION).toBe(packageJson.version); - }); -}); diff --git a/src/memory/index.ts b/src/memory/index.ts index d9f8148..b8704a9 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -6,35 +6,19 @@ import { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextp import { z } from "zod"; import { promises as fs } from 'fs'; import path from 'path'; -import os from 'os'; import { randomBytes } from 'crypto'; import { fileURLToPath } from 'url'; -import { SERVER_VERSION } from './version.js'; // Define memory file path using environment variable with fallback export const defaultMemoryPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory.jsonl'); -// Expand a leading "~" to the user's home directory. MCP clients pass -// MEMORY_FILE_PATH from JSON config, where no shell performs tilde expansion, -// so an unexpanded "~" would otherwise be treated as a relative path and -// joined onto the package directory. Mirrors the helper of the same name in -// the filesystem server (src/filesystem/path-utils.ts). -export function expandHome(filepath: string): string { - if (filepath.startsWith('~/') || filepath === '~') { - return path.join(os.homedir(), filepath.slice(1)); - } - return filepath; -} - // Handle backward compatibility: migrate memory.json to memory.jsonl if needed export async function ensureMemoryFilePath(): Promise { if (process.env.MEMORY_FILE_PATH) { - // Custom path provided. Expand a leading "~" first, then resolve relative - // paths against the package directory (absolute paths are used as-is). - const customPath = expandHome(process.env.MEMORY_FILE_PATH); - return path.isAbsolute(customPath) - ? customPath - : path.join(path.dirname(fileURLToPath(import.meta.url)), customPath); + // Custom path provided, use it as-is (with absolute path resolution) + return path.isAbsolute(process.env.MEMORY_FILE_PATH) + ? process.env.MEMORY_FILE_PATH + : path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH); } // No custom path set, check for backward compatibility migration @@ -86,71 +70,28 @@ export interface KnowledgeGraph { export class KnowledgeGraphManager { constructor(private memoryFilePath: string) {} - // Serializes all read-modify-write graph mutations behind a single queue. - // Without this, concurrent tool calls (e.g. multiple mutations dispatched - // from one LLM turn) each independently load the graph, mutate their own - // copy, and write it back — so whichever write lands last silently - // overwrites the other's changes, and interleaved writes to the same file - // can corrupt it outright. See #1819. - private mutationQueue: Promise = Promise.resolve(); - - private async withLock(operation: () => Promise): Promise { - const result = this.mutationQueue.then(operation, operation); - // Always resolve the queue itself, even if this operation failed, so a - // single failed mutation doesn't permanently wedge every call after it. - // The failure still propagates normally to whoever awaited `result`. - this.mutationQueue = result.then( - () => undefined, - () => undefined, - ); - return result; - } - private async loadGraph(): Promise { try { const data = await fs.readFile(this.memoryFilePath, "utf-8"); const lines = data.split("\n").filter(line => line.trim() !== ""); - const graph: KnowledgeGraph = { entities: [], relations: [] }; - - for (const line of lines) { - let item: unknown; - try { - item = JSON.parse(line); - } catch { - console.error("Skipping malformed line in memory file"); - continue; + return lines.reduce((graph: KnowledgeGraph, line) => { + const item = JSON.parse(line); + if (item.type === "entity") { + graph.entities.push({ + name: item.name, + entityType: item.entityType, + observations: item.observations + }); } - - if (typeof item !== "object" || item === null) { - console.error("Skipping non-object line in memory file"); - continue; + if (item.type === "relation") { + graph.relations.push({ + from: item.from, + to: item.to, + relationType: item.relationType + }); } - - const record = item as Record; - if (record.type === "entity") { - const parsed = EntitySchema.safeParse(item); - if (parsed.success) { - graph.entities.push(parsed.data); - } else { - console.error( - "Skipping invalid entity in memory file:", - parsed.error.issues.map(issue => `${issue.path.join(".")}: ${issue.message}`).join(", ") - ); - } - } else if (record.type === "relation") { - const parsed = RelationSchema.safeParse(item); - if (parsed.success) { - graph.relations.push(parsed.data); - } else { - console.error( - "Skipping invalid relation in memory file:", - parsed.error.issues.map(issue => `${issue.path.join(".")}: ${issue.message}`).join(", ") - ); - } - } - } - - return graph; + return graph; + }, { entities: [], relations: [] }); } catch (error) { if (error instanceof Error && 'code' in error && (error as any).code === "ENOENT") { return { entities: [], relations: [] }; @@ -190,7 +131,7 @@ export class KnowledgeGraphManager { ); try { - await fs.writeFile(tempFilePath, lines.join("\n") + "\n"); + await fs.writeFile(tempFilePath, lines.join("\n")); await fs.rename(tempFilePath, this.memoryFilePath); } catch (error) { // Never leave a stray temp file behind on failure. @@ -200,110 +141,66 @@ export class KnowledgeGraphManager { } async createEntities(entities: Entity[]): Promise { - return this.withLock(async () => { - const graph = await this.loadGraph(); - const newEntities = entities.filter((e, index) => - !graph.entities.some(existingEntity => existingEntity.name === e.name) && - // Also skip duplicates appearing earlier in this same batch - !entities.slice(0, index).some(earlier => earlier.name === e.name) - ); - graph.entities.push(...newEntities); - await this.saveGraph(graph); - return newEntities; - }); + const graph = await this.loadGraph(); + const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name)); + graph.entities.push(...newEntities); + await this.saveGraph(graph); + return newEntities; } async createRelations(relations: Relation[]): Promise { - return this.withLock(async () => { - const graph = await this.loadGraph(); - const entityNames = new Set(graph.entities.map(e => e.name)); - - relations.forEach(r => { - if (!entityNames.has(r.from)) { - throw new Error(`Entity with name ${r.from} not found`); - } - if (!entityNames.has(r.to)) { - throw new Error(`Entity with name ${r.to} not found`); - } - }); - - const isSameRelation = (a: Relation, b: Relation) => - a.from === b.from && - a.to === b.to && - a.relationType === b.relationType; - const newRelations = relations.filter((r, index) => - !graph.relations.some(existingRelation => isSameRelation(existingRelation, r)) && - // Also skip duplicates appearing earlier in this same batch - !relations.slice(0, index).some(earlier => isSameRelation(earlier, r)) - ); - graph.relations.push(...newRelations); - await this.saveGraph(graph); - return newRelations; - }); + const graph = await this.loadGraph(); + const newRelations = relations.filter(r => !graph.relations.some(existingRelation => + existingRelation.from === r.from && + existingRelation.to === r.to && + existingRelation.relationType === r.relationType + )); + graph.relations.push(...newRelations); + await this.saveGraph(graph); + return newRelations; } async addObservations(observations: { entityName: string; contents: string[] }[]): Promise<{ entityName: string; addedObservations: string[] }[]> { - return this.withLock(async () => { - const graph = await this.loadGraph(); - const results = observations.map(o => { - const entity = graph.entities.find(e => e.name === o.entityName); - if (!entity) { - throw new Error(`Entity with name ${o.entityName} not found`); - } - const newObservations = o.contents.filter(content => !entity.observations.includes(content)); - entity.observations.push(...newObservations); - return { entityName: o.entityName, addedObservations: newObservations }; - }); - await this.saveGraph(graph); - return results; + const graph = await this.loadGraph(); + const results = observations.map(o => { + const entity = graph.entities.find(e => e.name === o.entityName); + if (!entity) { + throw new Error(`Entity with name ${o.entityName} not found`); + } + const newObservations = o.contents.filter(content => !entity.observations.includes(content)); + entity.observations.push(...newObservations); + return { entityName: o.entityName, addedObservations: newObservations }; }); + await this.saveGraph(graph); + return results; } - async deleteEntities(entityNames: string[]): Promise<{ deleted: string[]; notFound: string[] }> { - return this.withLock(async () => { - const graph = await this.loadGraph(); - const present = new Set(graph.entities.map(e => e.name)); - const deleted = entityNames.filter(name => present.has(name)); - const notFound = entityNames.filter(name => !present.has(name)); - graph.entities = graph.entities.filter(e => !entityNames.includes(e.name)); - graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to)); - await this.saveGraph(graph); - return { deleted, notFound }; - }); + async deleteEntities(entityNames: string[]): Promise { + const graph = await this.loadGraph(); + graph.entities = graph.entities.filter(e => !entityNames.includes(e.name)); + graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to)); + await this.saveGraph(graph); } - async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<{ deletedCount: number; missingEntities: string[] }> { - return this.withLock(async () => { - const graph = await this.loadGraph(); - let deletedCount = 0; - const missingEntities: string[] = []; - deletions.forEach(d => { - const entity = graph.entities.find(e => e.name === d.entityName); - if (entity) { - const before = entity.observations.length; - entity.observations = entity.observations.filter(o => !d.observations.includes(o)); - deletedCount += before - entity.observations.length; - } else { - missingEntities.push(d.entityName); - } - }); - await this.saveGraph(graph); - return { deletedCount, missingEntities }; + async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise { + const graph = await this.loadGraph(); + deletions.forEach(d => { + const entity = graph.entities.find(e => e.name === d.entityName); + if (entity) { + entity.observations = entity.observations.filter(o => !d.observations.includes(o)); + } }); + await this.saveGraph(graph); } - async deleteRelations(relations: Relation[]): Promise<{ deletedCount: number }> { - return this.withLock(async () => { - const graph = await this.loadGraph(); - const before = graph.relations.length; - graph.relations = graph.relations.filter(r => !relations.some(delRelation => - r.from === delRelation.from && - r.to === delRelation.to && - r.relationType === delRelation.relationType - )); - await this.saveGraph(graph); - return { deletedCount: before - graph.relations.length }; - }); + async deleteRelations(relations: Relation[]): Promise { + const graph = await this.loadGraph(); + graph.relations = graph.relations.filter(r => !relations.some(delRelation => + r.from === delRelation.from && + r.to === delRelation.to && + r.relationType === delRelation.relationType + )); + await this.saveGraph(graph); } async readGraph(): Promise { @@ -379,9 +276,10 @@ const RelationSchema = z.object({ relationType: z.string().describe("The type of the relation") }); +// The server instance and tools exposed to Claude const server = new McpServer({ name: "memory-server", - version: SERVER_VERSION, + version: "0.6.3", }); const RESOURCE_URI = "memory://knowledge-graph"; @@ -512,14 +410,11 @@ server.registerTool( } }, async ({ entityNames }) => { - const { deleted, notFound } = await knowledgeGraphManager.deleteEntities(entityNames); + await knowledgeGraphManager.deleteEntities(entityNames); notifyGraphUpdated(); - const message = notFound.length === 0 - ? "Entities deleted successfully" - : `Deleted ${deleted.length} of ${entityNames.length} entities. Not found: ${notFound.join(", ")}`; return { - content: [{ type: "text" as const, text: message }], - structuredContent: { success: true, message } + content: [{ type: "text" as const, text: "Entities deleted successfully" }], + structuredContent: { success: true, message: "Entities deleted successfully" } }; } ); @@ -548,16 +443,11 @@ server.registerTool( } }, async ({ deletions }) => { - const { deletedCount, missingEntities } = await knowledgeGraphManager.deleteObservations(deletions); + await knowledgeGraphManager.deleteObservations(deletions); notifyGraphUpdated(); - const requested = deletions.reduce((total, d) => total + d.observations.length, 0); - const message = deletedCount === requested - ? "Observations deleted successfully" - : `Deleted ${deletedCount} of ${requested} observations.` + - (missingEntities.length ? ` Entities not found: ${missingEntities.join(", ")}` : ""); return { - content: [{ type: "text" as const, text: message }], - structuredContent: { success: true, message } + content: [{ type: "text" as const, text: "Observations deleted successfully" }], + structuredContent: { success: true, message: "Observations deleted successfully" } }; } ); @@ -583,14 +473,11 @@ server.registerTool( } }, async ({ relations }) => { - const { deletedCount } = await knowledgeGraphManager.deleteRelations(relations); + await knowledgeGraphManager.deleteRelations(relations); notifyGraphUpdated(); - const message = deletedCount === relations.length - ? "Relations deleted successfully" - : `Deleted ${deletedCount} of ${relations.length} relations. The rest matched nothing.`; return { - content: [{ type: "text" as const, text: message }], - structuredContent: { success: true, message } + content: [{ type: "text" as const, text: "Relations deleted successfully" }], + structuredContent: { success: true, message: "Relations deleted successfully" } }; } ); @@ -622,13 +509,6 @@ server.registerTool( } ); -export const SEARCH_QUERY_MAX_LENGTH = 2048; - -export const SearchNodesQuerySchema = z - .string() - .max(SEARCH_QUERY_MAX_LENGTH) - .describe("The search query to match against entity names, types, and observation content"); - // Register search_nodes tool server.registerTool( "search_nodes", @@ -636,7 +516,7 @@ server.registerTool( title: "Search Nodes", description: "Search for nodes in the knowledge graph based on a query", inputSchema: { - query: SearchNodesQuerySchema + query: z.string().describe("The search query to match against entity names, types, and observation content") }, outputSchema: { entities: z.array(EntitySchema), diff --git a/src/memory/package.json b/src/memory/package.json index ea10979..bec8ed9 100644 --- a/src/memory/package.json +++ b/src/memory/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server-memory", - "version": "0.6.3", + "version": "2026.8.31", "description": "MCP server for enabling memory for Claude through a knowledge graph", "license": "SEE LICENSE IN LICENSE", "mcpName": "io.github.modelcontextprotocol/server-memory", @@ -25,8 +25,7 @@ "test": "vitest run --coverage" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", - "zod": "^4.0.0" + "@modelcontextprotocol/sdk": "^1.30.0" }, "devDependencies": { "@types/node": "^22", diff --git a/src/memory/version.ts b/src/memory/version.ts deleted file mode 100644 index f1df958..0000000 --- a/src/memory/version.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createRequire } from 'node:module'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -export function resolvePackageVersion(): string { - const require = createRequire(import.meta.url); - const moduleDir = path.dirname(fileURLToPath(import.meta.url)); - const candidates = [ - path.join(moduleDir, 'package.json'), - path.join(moduleDir, '..', 'package.json'), - ]; - - for (const candidate of candidates) { - try { - const pkg = require(candidate) as { version?: string }; - if (pkg.version) { - return pkg.version; - } - } catch { - // Try the next candidate when running from dist/ or source. - } - } - - throw new Error('Could not locate package.json for server version'); -} - -export const SERVER_VERSION = resolvePackageVersion(); diff --git a/src/sequentialthinking/package.json b/src/sequentialthinking/package.json index a668a47..44ccb1e 100644 --- a/src/sequentialthinking/package.json +++ b/src/sequentialthinking/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server-sequential-thinking", - "version": "0.6.2", + "version": "2026.8.31", "description": "MCP server for sequential thinking and problem solving", "license": "SEE LICENSE IN LICENSE", "mcpName": "io.github.modelcontextprotocol/server-sequential-thinking", @@ -27,8 +27,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "chalk": "^5.3.0", - "yargs": "^17.7.2", - "zod": "^4.0.0" + "yargs": "^17.7.2" }, "devDependencies": { "@types/node": "^22", @@ -38,4 +37,4 @@ "typescript": "^5.3.3", "vitest": "^4.1.8" } -} +} \ No newline at end of file