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.""" 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) {