From c8bea379564fcb0c6a7d699b5acd276c4d2e0c23 Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Fri, 20 Mar 2026 16:35:16 +0800 Subject: [PATCH 1/2] feat(fetch): add tool annotations to server-fetch Adds MCP tool annotations to the fetch tool as requested in #3572. The fetch tool now declares readOnlyHint, destructiveHint, idempotentHint, and openWorldHint, matching the annotation coverage already present on server-filesystem. Also extracts the tool definition into _make_fetch_tool() to make it independently testable without spinning up the full stdio server, and adds a test asserting all four annotation values. --- src/fetch/src/mcp_server_fetch/server.py | 27 ++++++++++++++++-------- src/fetch/tests/test_server.py | 16 ++++++++++++++ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/fetch/src/mcp_server_fetch/server.py b/src/fetch/src/mcp_server_fetch/server.py index b42c7b1f6b..b66880a8ee 100644 --- a/src/fetch/src/mcp_server_fetch/server.py +++ b/src/fetch/src/mcp_server_fetch/server.py @@ -14,6 +14,7 @@ PromptMessage, TextContent, Tool, + ToolAnnotations, INVALID_PARAMS, INTERNAL_ERROR, ) @@ -178,6 +179,22 @@ class Fetch(BaseModel): ] +def _make_fetch_tool() -> Tool: + return Tool( + name="fetch", + description="""Fetches a URL from the internet and optionally extracts its contents as markdown. + +Although originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that.""", + inputSchema=Fetch.model_json_schema(), + annotations=ToolAnnotations( + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=True, + ), + ) + + async def serve( custom_user_agent: str | None = None, ignore_robots_txt: bool = False, @@ -196,15 +213,7 @@ async def serve( @server.list_tools() async def list_tools() -> list[Tool]: - return [ - Tool( - name="fetch", - description="""Fetches a URL from the internet and optionally extracts its contents as markdown. - -Although originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that.""", - inputSchema=Fetch.model_json_schema(), - ) - ] + return [_make_fetch_tool()] @server.list_prompts() async def list_prompts() -> list[Prompt]: diff --git a/src/fetch/tests/test_server.py b/src/fetch/tests/test_server.py index 96c1cb38c7..18bc892b3d 100644 --- a/src/fetch/tests/test_server.py +++ b/src/fetch/tests/test_server.py @@ -9,10 +9,26 @@ get_robots_txt_url, check_may_autonomously_fetch_url, fetch_url, + _make_fetch_tool, DEFAULT_USER_AGENT_AUTONOMOUS, ) +class TestListTools: + """Tests for list_tools handler.""" + + def test_fetch_tool_annotations(self): + """Test that the fetch tool has correct MCP tool annotations.""" + tool = _make_fetch_tool() + + assert tool.name == "fetch" + assert tool.annotations is not None + assert tool.annotations.readOnlyHint is True + assert tool.annotations.destructiveHint is False + assert tool.annotations.idempotentHint is True + assert tool.annotations.openWorldHint is True + + class TestGetRobotsTxtUrl: """Tests for get_robots_txt_url function.""" From dcb728f95358053e3f1d93bfbcdb4cd81f429c4c Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Fri, 20 Mar 2026 16:42:17 +0800 Subject: [PATCH 2/2] fix(filesystem): sort directory_tree entries in stable lexicographic order fs.readdir returns entries in filesystem enumeration order, which varies across platforms and runs. This caused directory_tree to produce non-deterministic output for the same directory contents. Sort entries by name using localeCompare after each readdir call in buildTree so sibling ordering is stable at every level of the tree. Applies the same fix to the buildTreeForTesting helper in the test file and adds three new tests: sibling order, nested sibling order, and idempotency across repeated calls. Closes #3539 --- .../__tests__/directory-tree.test.ts | 51 ++++++++++++++++++- src/filesystem/index.ts | 2 +- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/filesystem/__tests__/directory-tree.test.ts b/src/filesystem/__tests__/directory-tree.test.ts index 04c8278c59..f03008101a 100644 --- a/src/filesystem/__tests__/directory-tree.test.ts +++ b/src/filesystem/__tests__/directory-tree.test.ts @@ -14,7 +14,7 @@ interface TreeEntry { } async function buildTreeForTesting(currentPath: string, rootPath: string, excludePatterns: string[] = []): Promise { - const entries = await fs.readdir(currentPath, {withFileTypes: true}); + const entries = (await fs.readdir(currentPath, {withFileTypes: true})).sort((a, b) => a.name.localeCompare(b.name)); const result: TreeEntry[] = []; for (const entry of entries) { @@ -137,11 +137,58 @@ describe('buildTree exclude patterns', () => { it('should handle empty exclude patterns', async () => { const tree = await buildTreeForTesting(testDir, testDir, []); const entryNames = tree.map(entry => entry.name); - + // All entries should be included expect(entryNames).toContain('node_modules'); expect(entryNames).toContain('.env'); expect(entryNames).toContain('.git'); expect(entryNames).toContain('src'); }); +}); + +describe('buildTree ordering', () => { + let testDir: string; + + beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'filesystem-order-test-')); + + // Create entries whose lexicographic order differs from typical filesystem insertion order + await fs.writeFile(path.join(testDir, 'zebra.txt'), ''); + await fs.mkdir(path.join(testDir, 'alpha')); + await fs.writeFile(path.join(testDir, 'mango.txt'), ''); + await fs.mkdir(path.join(testDir, 'beta')); + await fs.writeFile(path.join(testDir, 'apple.txt'), ''); + await fs.writeFile(path.join(testDir, 'alpha', 'z.txt'), ''); + await fs.writeFile(path.join(testDir, 'alpha', 'a.txt'), ''); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('should return siblings in stable lexicographic order', async () => { + const tree = await buildTreeForTesting(testDir, testDir); + const names = tree.map(e => e.name); + + for (let i = 0; i < names.length - 1; i++) { + expect(names[i].localeCompare(names[i + 1])).toBeLessThanOrEqual(0); + } + }); + + it('should return nested siblings in stable lexicographic order', async () => { + const tree = await buildTreeForTesting(testDir, testDir); + const alphaDir = tree.find(e => e.name === 'alpha'); + expect(alphaDir).toBeDefined(); + const childNames = alphaDir!.children!.map(e => e.name); + + for (let i = 0; i < childNames.length - 1; i++) { + expect(childNames[i].localeCompare(childNames[i + 1])).toBeLessThanOrEqual(0); + } + }); + + it('should produce identical output on repeated calls', async () => { + const tree1 = await buildTreeForTesting(testDir, testDir); + const tree2 = await buildTreeForTesting(testDir, testDir); + expect(JSON.stringify(tree1)).toBe(JSON.stringify(tree2)); + }); }); \ No newline at end of file diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7b67e63e58..30c9d3099f 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -550,7 +550,7 @@ server.registerTool( async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise { const validPath = await validatePath(currentPath); - const entries = await fs.readdir(validPath, { withFileTypes: true }); + const entries = (await fs.readdir(validPath, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)); const result: TreeEntry[] = []; for (const entry of entries) {