fix: simplify output schemas for text-only tools and add structuredContent

For text-only tool responses, simplify outputSchemas from complex nested
arrays to simple { content: z.string() } format. All tool responses now
include structuredContent matching their outputSchema, fixing MCP protocol
violations when tools had output schemas but no structured content.

This applies to both filesystem and everything servers.
This commit is contained in:
Adam Jones
2025-11-20 21:39:14 +00:00
parent 55c3a31690
commit 3f2ddb0479

View File

@@ -176,22 +176,18 @@ const readTextFileHandler = async (args: z.infer<typeof ReadTextFileArgsSchema>)
throw new Error("Cannot specify both head and tail parameters simultaneously");
}
let content: string;
if (args.tail) {
const tailContent = await tailFile(validPath, args.tail);
return {
content: [{ type: "text" as const, text: tailContent }],
};
content = await tailFile(validPath, args.tail);
} else if (args.head) {
content = await headFile(validPath, args.head);
} else {
content = await readFileContent(validPath);
}
if (args.head) {
const headContent = await headFile(validPath, args.head);
return {
content: [{ type: "text" as const, text: headContent }],
};
}
const content = await readFileContent(validPath);
return {
content: [{ type: "text" as const, text: content }],
structuredContent: { content }
};
};
@@ -201,12 +197,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,
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
readTextFileHandler
);
@@ -228,12 +219,7 @@ server.registerTool(
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")
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
readTextFileHandler
);
@@ -281,8 +267,10 @@ server.registerTool(
? "audio"
// Fallback for other binary types, not officially supported by the spec but has been used for some time
: "blob";
const contentItem = { type: type as 'image' | 'audio' | 'blob', data, mimeType };
return {
content: [{ type, data, mimeType }],
content: [contentItem],
structuredContent: { content: [contentItem] }
} as unknown as CallToolResult;
}
);
@@ -302,12 +290,7 @@ server.registerTool(
.min(1)
.describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories.")
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof ReadMultipleFilesArgsSchema>) => {
const results = await Promise.all(
@@ -322,8 +305,10 @@ server.registerTool(
}
}),
);
const text = results.join("\n---\n");
return {
content: [{ type: "text" as const, text: results.join("\n---\n") }],
content: [{ type: "text" as const, text }],
structuredContent: { content: text }
};
}
);
@@ -340,18 +325,15 @@ server.registerTool(
path: z.string(),
content: z.string()
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof WriteFileArgsSchema>) => {
const validPath = await validatePath(args.path);
await writeFileContent(validPath, args.content);
const text = `Successfully wrote to ${args.path}`;
return {
content: [{ type: "text" as const, text: `Successfully wrote to ${args.path}` }],
content: [{ type: "text" as const, text }],
structuredContent: { content: text }
};
}
);
@@ -372,18 +354,14 @@ server.registerTool(
})),
dryRun: z.boolean().default(false).describe("Preview changes using git-style diff format")
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof EditFileArgsSchema>) => {
const validPath = await validatePath(args.path);
const result = await applyFileEdits(validPath, args.edits, args.dryRun);
return {
content: [{ type: "text" as const, text: result }],
structuredContent: { content: result }
};
}
);
@@ -400,18 +378,15 @@ server.registerTool(
inputSchema: {
path: z.string()
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof CreateDirectoryArgsSchema>) => {
const validPath = await validatePath(args.path);
await fs.mkdir(validPath, { recursive: true });
const text = `Successfully created directory ${args.path}`;
return {
content: [{ type: "text" as const, text: `Successfully created directory ${args.path}` }],
content: [{ type: "text" as const, text }],
structuredContent: { content: text }
};
}
);
@@ -428,12 +403,7 @@ server.registerTool(
inputSchema: {
path: z.string()
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof ListDirectoryArgsSchema>) => {
const validPath = await validatePath(args.path);
@@ -443,6 +413,7 @@ server.registerTool(
.join("\n");
return {
content: [{ type: "text" as const, text: formatted }],
structuredContent: { content: formatted }
};
}
);
@@ -460,12 +431,7 @@ server.registerTool(
path: z.string(),
sortBy: z.enum(["name", "size"]).optional().default("name").describe("Sort entries by name or size")
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof ListDirectoryWithSizesArgsSchema>) => {
const validPath = await validatePath(args.path);
@@ -521,11 +487,11 @@ server.registerTool(
`Combined size: ${formatSize(totalSize)}`
];
const text = [...formattedEntries, ...summary].join("\n");
const contentBlock = { type: "text" as const, text };
return {
content: [{
type: "text" as const,
text: [...formattedEntries, ...summary].join("\n")
}],
content: [contentBlock],
structuredContent: { content: [contentBlock] }
};
}
);
@@ -543,12 +509,7 @@ server.registerTool(
path: z.string(),
excludePatterns: z.array(z.string()).optional().default([])
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof DirectoryTreeArgsSchema>) => {
interface TreeEntry {
@@ -595,11 +556,11 @@ server.registerTool(
}
const treeData = await buildTree(rootPath, args.excludePatterns);
const text = JSON.stringify(treeData, null, 2);
const contentBlock = { type: "text" as const, text };
return {
content: [{
type: "text" as const,
text: JSON.stringify(treeData, null, 2)
}],
content: [contentBlock],
structuredContent: { content: [contentBlock] }
};
}
);
@@ -617,19 +578,17 @@ server.registerTool(
source: z.string(),
destination: z.string()
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof MoveFileArgsSchema>) => {
const validSourcePath = await validatePath(args.source);
const validDestPath = await validatePath(args.destination);
await fs.rename(validSourcePath, validDestPath);
const text = `Successfully moved ${args.source} to ${args.destination}`;
const contentBlock = { type: "text" as const, text };
return {
content: [{ type: "text" as const, text: `Successfully moved ${args.source} to ${args.destination}` }],
content: [contentBlock],
structuredContent: { content: [contentBlock] }
};
}
);
@@ -649,18 +608,15 @@ server.registerTool(
pattern: z.string(),
excludePatterns: z.array(z.string()).optional().default([])
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof SearchFilesArgsSchema>) => {
const validPath = await validatePath(args.path);
const results = await searchFilesWithValidation(validPath, args.pattern, allowedDirectories, { excludePatterns: args.excludePatterns });
const text = results.length > 0 ? results.join("\n") : "No matches found";
return {
content: [{ type: "text" as const, text: results.length > 0 ? results.join("\n") : "No matches found" }],
content: [{ type: "text" as const, text }],
structuredContent: { content: text }
};
}
);
@@ -677,20 +633,17 @@ server.registerTool(
inputSchema: {
path: z.string()
},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof GetFileInfoArgsSchema>) => {
const validPath = await validatePath(args.path);
const info = await getFileStats(validPath);
const text = Object.entries(info)
.map(([key, value]) => `${key}: ${value}`)
.join("\n");
return {
content: [{ type: "text" as const, text: Object.entries(info)
.map(([key, value]) => `${key}: ${value}`)
.join("\n") }],
content: [{ type: "text" as const, text }],
structuredContent: { content: text }
};
}
);
@@ -705,19 +658,13 @@ server.registerTool(
"Use this to understand which directories and their nested paths are available " +
"before trying to access files.",
inputSchema: {},
outputSchema: {
content: z.array(z.object({
type: z.literal("text"),
text: z.string()
}))
}
outputSchema: { content: z.string() }
},
async () => {
const text = `Allowed directories:\n${allowedDirectories.join('\n')}`;
return {
content: [{
type: "text" as const,
text: `Allowed directories:\n${allowedDirectories.join('\n')}`
}],
content: [{ type: "text" as const, text }],
structuredContent: { content: text }
};
}
);