Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/fetch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

A Model Context Protocol server that provides web content fetching capabilities. This server enables LLMs to retrieve and process content from web pages, converting HTML to markdown for easier consumption.

Source: https://github.com/modelcontextprotocol/servers/tree/main/src/fetch

> [!CAUTION]
> This server can access local/internal IP addresses and may represent a security risk. Exercise caution when using this MCP server to ensure this does not expose any sensitive data.

Expand Down
4 changes: 4 additions & 0 deletions src/fetch/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ dependencies = [
"requests>=2.32.3",
]

[project.urls]
Repository = "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch"
Issues = "https://github.com/modelcontextprotocol/servers/issues"

[project.scripts]
mcp-server-fetch = "mcp_server_fetch:main"

Expand Down
9 changes: 6 additions & 3 deletions src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,12 @@ The server's directory access control follows this flow:
- Cannot specify both `head` and `tail` simultaneously

- **read_media_file**
- Read an image or audio file
- Read a file and return it as a base64-encoded content block with its MIME type
- Inputs:
- `path` (string)
- Streams the file and returns base64 data with the corresponding MIME type
- Streams the file and returns base64 data with the corresponding MIME type. Image and
audio files are returned as `image`/`audio` content; any other file type is returned as
an embedded `resource` (a valid MCP content block for arbitrary binary data)

- **read_multiple_files**
- Read multiple files simultaneously
Expand Down Expand Up @@ -185,6 +187,7 @@ on each tool so clients can:
- Distinguish **read‑only** tools from write‑capable tools.
- Understand which write operations are **idempotent** (safe to retry with the same arguments).
- Highlight operations that may be **destructive** (overwriting or heavily mutating data).
- Signal that a tool does **not** reach an open or external world (every filesystem tool sets `openWorldHint: false`).

The mapping for filesystem tools is:

Expand All @@ -204,7 +207,7 @@ The mapping for filesystem tools is:
| `edit_file` | `false` | `false` | `true` | Re‑applying edits can fail or double‑apply |
| `move_file` | `false` | `false` | `true` | Deletes source file |

> Note: `idempotentHint` and `destructiveHint` are meaningful only when `readOnlyHint` is `false`, as defined by the MCP spec.
> Note: `idempotentHint` and `destructiveHint` are meaningful only when `readOnlyHint` is `false`, as defined by the MCP spec. Every tool also sets `openWorldHint: false` — this server only accesses the local filesystem within its allowed directories, never an open or external world.

## Usage with Claude Desktop
Add this to your `claude_desktop_config.json`:
Expand Down
123 changes: 123 additions & 0 deletions src/filesystem/__tests__/structured-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as os from 'os';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';

/**
* Integration tests to verify that tool handlers return structuredContent
Expand Down Expand Up @@ -155,4 +156,126 @@ describe('structuredContent schema compliance', () => {
expect(Array.isArray(structuredContent.content)).toBe(false);
});
});

// read_media_file must return a VALID MCP content block. Previously it emitted
// type: "blob" for non-image/audio files, which is not in the MCP content-block
// union (text | image | audio | resource_link | resource) and a strict client
// rejects on schema validation. See issue #4029.
describe('read_media_file (issue #4029)', () => {
// Image/audio inputs already returned valid content types before #4029 (only the
// non-media fallback was the invalid "blob"), so these two cases are "still-works"
// coverage — they round-trip the bytes to catch an encoding regression on the media
// paths. The resource + café cases below are the actual #4029 regression guards
// (they fail against the pre-fix "blob" build).
it('returns type "image" for image files, round-tripping the bytes', async () => {
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const pngPath = path.join(testDir, 'pixel.png');
// Contents aren't validated by the tool (MIME is by extension), so arbitrary bytes are fine.
await fs.writeFile(pngPath, pngBytes);

const result = await client.callTool({
name: 'read_media_file',
arguments: { path: pngPath }
});

const content = result.content as Array<{ type: string; data?: string; mimeType?: string }>;
expect(Array.isArray(content)).toBe(true);
expect(content).toHaveLength(1);
expect(content[0].type).toBe('image');
expect(content[0].mimeType).toBe('image/png');
expect(Buffer.from(content[0].data!, 'base64').equals(pngBytes)).toBe(true);
// structuredContent must MIRROR content. The SDK validates each field independently
// against the outputSchema union (either arm is valid for either field), so schema
// validation alone cannot catch the two drifting apart — only this equality can.
expect(result.structuredContent).toEqual({ content });
});

it('returns type "audio" for audio files, round-tripping the bytes', async () => {
const mp3Bytes = Buffer.from([0x49, 0x44, 0x33, 0x04]);
const mp3Path = path.join(testDir, 'clip.mp3');
await fs.writeFile(mp3Path, mp3Bytes);

const result = await client.callTool({
name: 'read_media_file',
arguments: { path: mp3Path }
});

const content = result.content as Array<{ type: string; data?: string; mimeType?: string }>;
expect(content[0].type).toBe('audio');
expect(content[0].mimeType).toBe('audio/mpeg');
expect(Buffer.from(content[0].data!, 'base64').equals(mp3Bytes)).toBe(true);
expect(result.structuredContent).toEqual({ content }); // must mirror content (see image case)
});

it('returns an embedded resource (never "blob") for non-media binaries, round-tripping the bytes', async () => {
const binBytes = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe]);
const binPath = path.join(testDir, 'data.bin');
await fs.writeFile(binPath, binBytes);

const result = await client.callTool({
name: 'read_media_file',
arguments: { path: binPath }
});

const content = result.content as Array<{
type: string;
resource?: { uri: string; mimeType: string; blob: string };
}>;
expect(content).toHaveLength(1);
// ("never blob" is enforced upstream of any assertion here: the SDK client's
// CallToolResultSchema parse rejects a blob block before content is even inspected,
// failing the callTool above — which is exactly how this test fails pre-fix.)
expect(content[0].type).toBe('resource');
expect(content[0].resource).toBeDefined();
expect(content[0].resource!.uri.startsWith('file://')).toBe(true);
// Decode the uri back to a path and confirm it is the file actually read — guards a
// refactor that builds the uri from the wrong variable while still emitting a
// well-formed file:// string. (realpath handles the /tmp -> /private/tmp macOS alias.)
expect(fileURLToPath(content[0].resource!.uri)).toBe(await fs.realpath(binPath));
expect(content[0].resource!.mimeType).toBe('application/octet-stream');
// The base64 blob must round-trip to the original bytes (data integrity, not just a valid
// shape) — this also subsumes a non-empty check.
expect(Buffer.from(content[0].resource!.blob, 'base64').equals(binBytes)).toBe(true);
expect(result.structuredContent).toEqual({ content }); // must mirror content (see image case)
});

it('percent-encodes spaces and non-ASCII chars in the resource uri (pathToFileURL, not a raw file:// concatenation)', async () => {
const fancyPath = path.join(testDir, 'my café.bin');
await fs.writeFile(fancyPath, Buffer.from([0x10, 0x20, 0x30]));

const result = await client.callTool({
name: 'read_media_file',
arguments: { path: fancyPath }
});

const content = result.content as Array<{ type: string; resource?: { uri: string } }>;
expect(content[0].type).toBe('resource');
const uri = content[0].resource!.uri;
// pathToFileURL percent-encodes the space (%20) and the non-ASCII "é" (%C3%A9 for NFC,
// or the base "e" + %CC%81 for the NFD form some filesystems store); a raw
// `file://${validPath}` (as in PR #4044) would leave both literal.
expect(uri).toContain('%20');
expect(uri).not.toContain(' ');
// The non-ASCII "é" must be percent-encoded: %C3%A9 (NFC) or base-"e" + %CC%81 (the NFD
// form some filesystems decompose to). This regex — not a `.not.toContain('é')`, which is
// vacuous on an NFD runner where the precomposed char never appears — is the load-bearing
// assertion that distinguishes pathToFileURL from a raw `file://${path}` concatenation.
expect(uri).toMatch(/%C3%A9|%CC%81/);
expect(result.structuredContent).toEqual({ content }); // must mirror content (see image case)
});

it('advertises the widened outputSchema union via tools/list (both branches serialize)', async () => {
const { tools } = await client.listTools();
const tool = tools.find((t) => t.name === 'read_media_file');
expect(tool).toBeDefined();
// The union outputSchema must serialize to JSON Schema — a DIFFERENT SDK path
// (toJsonSchemaCompat / tools-list) than the callTool structuredContent validation the
// other cases exercise — with BOTH branches present. Guards a nested-union-in-array
// serialization quirk that could drop the resource arm from the advertised schema.
const schemaStr = JSON.stringify(tool!.outputSchema);
expect(schemaStr).toMatch(/resource/);
expect(schemaStr).toMatch(/image/);
expect(schemaStr).toMatch(/audio/);
});
});
});
78 changes: 48 additions & 30 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolResult,
RootsListChangedNotificationSchema,
type Root,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "fs/promises";
import { createReadStream } from "fs";
import path from "path";
import { pathToFileURL } from "url";
import { z } from "zod";
import { minimatch } from "minimatch";
import { normalizePath, expandHome } from './path-utils.js';
Expand Down Expand Up @@ -217,7 +217,7 @@ server.registerTool(
description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.",
inputSchema: ReadTextFileArgsSchema.shape,
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
annotations: { readOnlyHint: true, openWorldHint: false }
},
readTextFileHandler
);
Expand All @@ -240,7 +240,7 @@ server.registerTool(
head: z.number().optional().describe("If provided, returns only the first N lines of the file")
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
annotations: { readOnlyHint: true, openWorldHint: false }
},
readTextFileHandler
);
Expand All @@ -250,19 +250,31 @@ server.registerTool(
{
title: "Read Media File",
description:
"Read an image or audio file. Returns the base64 encoded data and MIME type. " +
"Only works within allowed directories.",
"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()
},
outputSchema: {
content: z.array(z.object({
type: z.enum(["image", "audio", "blob"]),
data: z.string(),
mimeType: z.string()
}))
content: z.array(z.union([
z.object({
type: z.enum(["image", "audio"]),
data: z.string(),
mimeType: z.string()
}),
z.object({
type: z.literal("resource"),
resource: z.object({
uri: z.string(),
// Optional, matching the SDK's BlobResourceContents shape (the handler always sets it).
mimeType: z.string().optional(),
blob: z.string()
})
})
]))
},
annotations: { readOnlyHint: true }
annotations: { readOnlyHint: true, openWorldHint: false }
},
async (args: z.infer<typeof ReadMediaFileArgsSchema>) => {
const validPath = await validatePath(args.path);
Expand All @@ -283,17 +295,23 @@ server.registerTool(
const mimeType = mimeTypes[extension] || "application/octet-stream";
const data = await readFileAsBase64Stream(validPath);

const type = mimeType.startsWith("image/")
? "image"
: mimeType.startsWith("audio/")
? "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 };
// Map the MIME type to a valid MCP content block. The spec only allows
// text, image, audio, resource_link, and resource — so non-image/audio
// binaries are returned as an embedded resource (NOT type:"blob", which the
// SDK content-block union rejects on schema validation).
const contentItem =
mimeType.startsWith("image/")
? { type: "image" as const, data, mimeType }
: mimeType.startsWith("audio/")
? { type: "audio" as const, data, mimeType }
: {
type: "resource" as const,
resource: { uri: pathToFileURL(validPath).href, mimeType, blob: data }
};
return {
content: [contentItem],
structuredContent: { content: [contentItem] }
} as unknown as CallToolResult;
};
}
);

Expand All @@ -313,7 +331,7 @@ server.registerTool(
.describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories.")
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
annotations: { readOnlyHint: true, openWorldHint: false }
},
async (args: z.infer<typeof ReadMultipleFilesArgsSchema>) => {
const results = await Promise.all(
Expand Down Expand Up @@ -349,7 +367,7 @@ server.registerTool(
content: z.string()
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true }
annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true, openWorldHint: false }
},
async (args: z.infer<typeof WriteFileArgsSchema>) => {
const validPath = await validatePath(args.path);
Expand Down Expand Up @@ -379,7 +397,7 @@ server.registerTool(
dryRun: z.boolean().default(false).describe("Preview changes using git-style diff format")
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true }
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true, openWorldHint: false }
},
async (args: z.infer<typeof EditFileArgsSchema>) => {
const validPath = await validatePath(args.path);
Expand All @@ -404,7 +422,7 @@ server.registerTool(
path: z.string()
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false }
annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: false }
},
async (args: z.infer<typeof CreateDirectoryArgsSchema>) => {
const validPath = await validatePath(args.path);
Expand All @@ -430,7 +448,7 @@ server.registerTool(
path: z.string()
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
annotations: { readOnlyHint: true, openWorldHint: false }
},
async (args: z.infer<typeof ListDirectoryArgsSchema>) => {
const validPath = await validatePath(args.path);
Expand Down Expand Up @@ -459,7 +477,7 @@ server.registerTool(
sortBy: z.enum(["name", "size"]).optional().default("name").describe("Sort entries by name or size")
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
annotations: { readOnlyHint: true, openWorldHint: false }
},
async (args: z.infer<typeof ListDirectoryWithSizesArgsSchema>) => {
const validPath = await validatePath(args.path);
Expand Down Expand Up @@ -538,7 +556,7 @@ server.registerTool(
excludePatterns: z.array(z.string()).optional().default([])
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
annotations: { readOnlyHint: true, openWorldHint: false }
},
async (args: z.infer<typeof DirectoryTreeArgsSchema>) => {
interface TreeEntry {
Expand Down Expand Up @@ -608,7 +626,7 @@ server.registerTool(
destination: z.string()
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true }
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true, openWorldHint: false }
},
async (args: z.infer<typeof MoveFileArgsSchema>) => {
const validSourcePath = await validatePath(args.source);
Expand Down Expand Up @@ -639,7 +657,7 @@ server.registerTool(
excludePatterns: z.array(z.string()).optional().default([])
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
annotations: { readOnlyHint: true, openWorldHint: false }
},
async (args: z.infer<typeof SearchFilesArgsSchema>) => {
const validPath = await validatePath(args.path);
Expand All @@ -665,7 +683,7 @@ server.registerTool(
path: z.string()
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
annotations: { readOnlyHint: true, openWorldHint: false }
},
async (args: z.infer<typeof GetFileInfoArgsSchema>) => {
const validPath = await validatePath(args.path);
Expand All @@ -691,7 +709,7 @@ server.registerTool(
"before trying to access files.",
inputSchema: {},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
annotations: { readOnlyHint: true, openWorldHint: false }
},
async () => {
const text = `Allowed directories:\n${allowedDirectories.join('\n')}`;
Expand Down
2 changes: 2 additions & 0 deletions src/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

A Model Context Protocol server for Git repository interaction and automation. This server provides tools to read, search, and manipulate Git repositories via Large Language Models.

Source: https://github.com/modelcontextprotocol/servers/tree/main/src/git

Please note that mcp-server-git is currently in early development. The functionality and available tools are subject to change and expansion as we continue to develop and improve the server.

### Tools
Expand Down
Loading
Loading