diff --git a/src/filesystem/__tests__/tools-list-schema.test.ts b/src/filesystem/__tests__/tools-list-schema.test.ts new file mode 100644 index 0000000000..a5c2b08dcd --- /dev/null +++ b/src/filesystem/__tests__/tools-list-schema.test.ts @@ -0,0 +1,45 @@ +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('tools/list input schemas', () => { + let client: Client; + let transport: StdioClientTransport; + let testDir: string; + + beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-tools-list-')); + const serverPath = path.resolve(__dirname, '../dist/index.js'); + + transport = new StdioClientTransport({ + command: 'node', + args: [serverPath, testDir], + }); + + client = new Client( + { name: 'tools-list-schema-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('advertises object input schemas for every filesystem tool', async () => { + const result = await client.listTools(); + + expect(result.tools.length).toBeGreaterThan(0); + for (const tool of result.tools) { + expect(tool.inputSchema, `${tool.name} input schema`).toMatchObject({ + type: 'object', + }); + } + }); +}); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 51ac523a66..a81287b95a 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -216,7 +216,7 @@ server.registerTool( { title: "Read File (Deprecated)", description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.", - inputSchema: ReadTextFileArgsSchema.shape, + inputSchema: ReadTextFileArgsSchema, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true, openWorldHint: false } }, @@ -235,11 +235,7 @@ server.registerTool( "the first N lines of a file, or the 'tail' parameter to read only " + "the last N lines of a file. Operates on the file as text regardless of extension. " + "Only works within allowed directories.", - inputSchema: { - path: z.string(), - tail: z.number().optional().describe("If provided, returns only the last N lines of the file"), - head: z.number().optional().describe("If provided, returns only the first N lines of the file") - }, + inputSchema: ReadTextFileArgsSchema, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true, openWorldHint: false } }, @@ -254,9 +250,7 @@ server.registerTool( "Read a file and return it as a base64-encoded content block with its MIME type. " + "Image and audio files are returned as image/audio content; any other file type is " + "returned as an embedded resource. Only works within allowed directories.", - inputSchema: { - path: z.string() - }, + inputSchema: ReadMediaFileArgsSchema, outputSchema: { content: z.array(z.union([ z.object({ @@ -326,11 +320,7 @@ server.registerTool( "or compare multiple files. Each file's content is returned with its " + "path as a reference. Failed reads for individual files won't stop " + "the entire operation. Only works within allowed directories.", - inputSchema: { - paths: z.array(z.string()) - .min(1) - .describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories.") - }, + inputSchema: ReadMultipleFilesArgsSchema, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true, openWorldHint: false } }, @@ -363,10 +353,7 @@ server.registerTool( "Create a new file or completely overwrite an existing file with new content. " + "Use with caution as it will overwrite existing files without warning. " + "Handles text content with proper encoding. Only works within allowed directories.", - inputSchema: { - path: z.string(), - content: z.string() - }, + inputSchema: WriteFileArgsSchema, outputSchema: { content: z.string() }, annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: false } }, @@ -389,14 +376,7 @@ server.registerTool( "Make line-based edits to a text file. Each edit replaces exact line sequences " + "with new content. Returns a git-style diff showing the changes made. " + "Only works within allowed directories.", - inputSchema: { - path: z.string(), - edits: z.array(z.object({ - oldText: z.string().describe("Text to search for - must match exactly"), - newText: z.string().describe("Text to replace with") - })), - dryRun: z.boolean().default(false).describe("Preview changes using git-style diff format") - }, + inputSchema: EditFileArgsSchema, outputSchema: { content: z.string() }, annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true, openWorldHint: false } }, @@ -419,9 +399,7 @@ server.registerTool( "nested directories in one operation. If the directory already exists, " + "this operation will succeed silently. Perfect for setting up directory " + "structures for projects or ensuring required paths exist. Only works within allowed directories.", - inputSchema: { - path: z.string() - }, + inputSchema: CreateDirectoryArgsSchema, outputSchema: { content: z.string() }, annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: false } }, @@ -445,9 +423,7 @@ server.registerTool( "Results clearly distinguish between files and directories with [FILE] and [DIR] " + "prefixes. This tool is essential for understanding directory structure and " + "finding specific files within a directory. Only works within allowed directories.", - inputSchema: { - path: z.string() - }, + inputSchema: ListDirectoryArgsSchema, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true, openWorldHint: false } }, @@ -473,10 +449,7 @@ server.registerTool( "Results clearly distinguish between files and directories with [FILE] and [DIR] " + "prefixes. This tool is useful for understanding directory structure and " + "finding specific files within a directory. Only works within allowed directories.", - inputSchema: { - path: z.string(), - sortBy: z.enum(["name", "size"]).optional().default("name").describe("Sort entries by name or size") - }, + inputSchema: ListDirectoryWithSizesArgsSchema, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true, openWorldHint: false } }, @@ -552,10 +525,7 @@ server.registerTool( "Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " + "Files have no children array, while directories always have a children array (which may be empty). " + "The output is formatted with 2-space indentation for readability. Only works within allowed directories.", - inputSchema: { - path: z.string(), - excludePatterns: z.array(z.string()).optional().default([]) - }, + inputSchema: DirectoryTreeArgsSchema, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true, openWorldHint: false } }, @@ -622,10 +592,7 @@ server.registerTool( "and rename them in a single operation. If the destination exists, the " + "operation will fail. Works across different directories and can be used " + "for simple renaming within the same directory. Both source and destination must be within allowed directories.", - inputSchema: { - source: z.string(), - destination: z.string() - }, + inputSchema: MoveFileArgsSchema, outputSchema: { content: z.string() }, annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true, openWorldHint: false } }, @@ -652,11 +619,7 @@ server.registerTool( "Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. " + "Returns full paths to all matching items. Great for finding files when you don't know their exact location. " + "Only searches within allowed directories.", - inputSchema: { - path: z.string(), - pattern: z.string(), - excludePatterns: z.array(z.string()).optional().default([]) - }, + inputSchema: SearchFilesArgsSchema, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true, openWorldHint: false } }, @@ -680,9 +643,7 @@ server.registerTool( "information including size, creation time, last modified time, permissions, " + "and type. This tool is perfect for understanding file characteristics " + "without reading the actual content. Only works within allowed directories.", - inputSchema: { - path: z.string() - }, + inputSchema: GetFileInfoArgsSchema, outputSchema: { content: z.string() }, annotations: { readOnlyHint: true, openWorldHint: false } }, @@ -708,7 +669,7 @@ server.registerTool( "Subdirectories within these allowed directories are also accessible. " + "Use this to understand which directories and their nested paths are available " + "before trying to access files.", - inputSchema: {}, + inputSchema: z.object({}), outputSchema: { content: z.string() }, annotations: { readOnlyHint: true, openWorldHint: false } },