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
7 changes: 7 additions & 0 deletions .changeset/3669-input-property-descriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@modelcontextprotocol/server-filesystem": patch
"@modelcontextprotocol/server-memory": patch
"@modelcontextprotocol/server-everything": patch
---

Describe every tool input property in tools/list (#3669). Every filesystem path argument now says how it is resolved and that it must stay inside the allowed directories, and `excludePatterns` says how its globs match (which differs between `directory_tree` and `search_files`). The memory server's top-level array arguments, and the everything server's `resourceType`, gain descriptions, and `get-resource-reference`'s `resourceId` no longer claims to fetch only text resources.
87 changes: 87 additions & 0 deletions src/everything/__tests__/input-descriptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Every property of every tool input schema the server advertises in
// tools/list carries a description (#3669). A model reads these to decide what
// to pass, and with no description it guesses from the property name alone.
// This is the reference implementation other server authors copy, so the rule
// is pinned for every tool, existing or added later, and for nested
// properties (array items, object fields) as well as top-level ones.

import { afterEach, describe, expect, it } from "vitest";
import {
ALL_CAPABILITIES,
connect,
connectModern,
MODERN_CAPABILITIES,
type Session,
} from "./harness.js";

/** A JSON Schema node, as much of it as the walk reads. */
type SchemaNode = {
description?: unknown;
properties?: Record<string, SchemaNode>;
items?: SchemaNode | SchemaNode[];
anyOf?: SchemaNode[];
oneOf?: SchemaNode[];
allOf?: SchemaNode[];
};

/**
* The path of every property under `node`, at any depth, that has no
* non-empty description: the properties of nested objects and of array
* items count, since a model fills those in too.
*/
function undescribed(node: SchemaNode, path: string): string[] {
const missing: string[] = [];
for (const [key, child] of Object.entries(node.properties ?? {})) {
const at = `${path}.${key}`;
if (
typeof child.description !== "string" ||
child.description.trim() === ""
) {
missing.push(at);
}
missing.push(...undescribed(child, at));
}
const items = node.items;
for (const item of Array.isArray(items) ? items : items ? [items] : []) {
missing.push(...undescribed(item, `${path}[]`));
}
for (const branch of [
...(node.anyOf ?? []),
...(node.oneOf ?? []),
...(node.allOf ?? []),
]) {
missing.push(...undescribed(branch, path));
}
return missing;
}

let session: Session | undefined;

afterEach(async () => {
await session?.close();
session = undefined;
});

// Each era lists some tools the other does not (the task tools only on
// 2026-07-28, the toggle-* tools only on the 2025 era), so both are checked.
// Every capability is declared, so the tools gated on one are listed too.
describe.each([
{
era: "2025-era",
open: () => connect({ capabilities: ALL_CAPABILITIES }),
},
{
era: "2026-07-28",
open: () => connectModern({ capabilities: MODERN_CAPABILITIES }),
},
])("tool input descriptions on the $era surface (#3669)", ({ open }) => {
it("describes every input property of every tool", async () => {
session = await open();
const { tools } = await session.client.listTools();
expect(tools.length).toBeGreaterThan(0);
const missing = tools.flatMap((tool) =>
undescribed(tool.inputSchema as SchemaNode, tool.name),
);
expect(missing).toEqual([]);
});
});
3 changes: 2 additions & 1 deletion src/everything/__tests__/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,11 +509,12 @@ describe("tools/list", () => {
"properties": {
"resourceId": {
"default": 1,
"description": "ID of the text resource to fetch",
"description": "ID of the text or blob resource to fetch, a positive integer",
"type": "number",
},
"resourceType": {
"default": "Text",
"description": "Whether to reference a text resource or a blob resource",
"enum": [
"Text",
"Blob",
Expand Down
5 changes: 3 additions & 2 deletions src/everything/tools/get-resource-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ import { TOOL_ICONS } from "../server/icons.js";
const GetResourceReferenceSchema = z.object({
resourceType: z
.enum([RESOURCE_TYPE_TEXT, RESOURCE_TYPE_BLOB])
.default(RESOURCE_TYPE_TEXT),
.default(RESOURCE_TYPE_TEXT)
.describe("Whether to reference a text resource or a blob resource"),
resourceId: z
.number()
.default(1)
.describe("ID of the text resource to fetch"),
.describe("ID of the text or blob resource to fetch, a positive integer"),
});

// Tool configuration
Expand Down
9 changes: 7 additions & 2 deletions src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,9 @@ Files inside the allowed directories are also [resources](https://modelcontextpr
- `pattern` (string): Glob matched, case-sensitively, against each entry's
path relative to `path`: `*.ext` matches entries directly under `path`,
`**/*.ext` or `**/name` matches at any depth
- `excludePatterns` (string[]): Exclude any patterns. An excluded entry is
- `excludePatterns` (string[]): Globs for entries to skip, matched like
`pattern`, so a bare name skips only the top-level entry: use
`**/node_modules` to skip that name at any depth. An excluded entry is
skipped before it is resolved or read, so excluding a slow folder keeps
the search out of it entirely.
- Glob-style pattern matching
Expand All @@ -261,7 +263,10 @@ Files inside the allowed directories are also [resources](https://modelcontextpr
- Get recursive JSON tree structure of directory contents
- Inputs:
- `path` (string): Starting directory
- `excludePatterns` (string[]): Exclude any patterns. Glob formats are supported.
- `excludePatterns` (string[]): Globs for entries to leave out, with their
contents, matched against each entry's path relative to `path`. A
pattern with no `*` also matches that name at any depth, so
`node_modules` leaves out every `node_modules` directory.
- `depth` (positive integer, optional): How many levels to descend. `1`
lists only the entries directly in `path`, `2` adds their children, and
so on. Omitted, the whole tree is returned
Expand Down
131 changes: 131 additions & 0 deletions src/filesystem/__tests__/input-descriptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Every property of every tool input schema the server advertises in
// tools/list carries a description (#3669). A model reads these to decide what
// to pass, and with no description it guesses from the property name alone.
// This is the reference implementation other server authors copy, so the rule
// is pinned for every tool, existing or added later, and for nested
// properties (array items, object fields) as well as top-level ones. The
// excludePatterns descriptions state matching rules that differ between
// directory_tree and search_files, so those rules are pinned here as well.

import fs from "fs/promises";
import path from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
call,
connect,
makeTempDir,
quietStderr,
textOf,
type Connected,
} from "./helpers.js";

/** A JSON Schema node, as much of it as the walk reads. */
type SchemaNode = {
description?: unknown;
properties?: Record<string, SchemaNode>;
items?: SchemaNode | SchemaNode[];
anyOf?: SchemaNode[];
oneOf?: SchemaNode[];
allOf?: SchemaNode[];
};

/**
* The path of every property under `node`, at any depth, that has no
* non-empty description: the properties of nested objects and of array
* items count, since a model fills those in too.
*/
function undescribed(node: SchemaNode, path: string): string[] {
const missing: string[] = [];
for (const [key, child] of Object.entries(node.properties ?? {})) {
const at = `${path}.${key}`;
if (
typeof child.description !== "string" ||
child.description.trim() === ""
) {
missing.push(at);
}
missing.push(...undescribed(child, at));
}
const items = node.items;
for (const item of Array.isArray(items) ? items : items ? [items] : []) {
missing.push(...undescribed(item, `${path}[]`));
}
for (const branch of [
...(node.anyOf ?? []),
...(node.oneOf ?? []),
...(node.allOf ?? []),
]) {
missing.push(...undescribed(branch, path));
}
return missing;
}

let dir: string;
let conn: Connected;

beforeEach(async () => {
// The server logs its roots decision to stderr on every connect.
quietStderr();
dir = await makeTempDir("mcp-fs-descriptions-");
conn = await connect([dir]);
});

afterEach(async () => {
vi.restoreAllMocks();
await conn.close();
await fs.rm(dir, { recursive: true, force: true });
});

describe("tool input descriptions (#3669)", () => {
it("describes every input property of every tool", async () => {
const { tools } = await conn.client.listTools();
expect(tools.length).toBeGreaterThan(0);
const missing = tools.flatMap((tool) =>
undescribed(tool.inputSchema as SchemaNode, tool.name),
);
expect(missing).toEqual([]);
});
});

describe("excludePatterns, as the descriptions state it (#3669)", () => {
beforeEach(async () => {
// A node_modules at the top level and one nested a level down.
await fs.mkdir(path.join(dir, "node_modules"));
await fs.writeFile(path.join(dir, "node_modules", "top.txt"), "");
await fs.mkdir(path.join(dir, "pkg", "node_modules"), { recursive: true });
await fs.writeFile(path.join(dir, "pkg", "node_modules", "deep.txt"), "");
await fs.writeFile(path.join(dir, "pkg", "keep.txt"), "");
});

async function search(excludePatterns: string[]): Promise<string[]> {
const result = await call(conn.client, "search_files", {
path: dir,
pattern: "**/*.txt",
excludePatterns,
});
return textOf(result).split("\n").sort();
}

it("directory_tree: a name with no '*' is left out at any depth", async () => {
const result = await call(conn.client, "directory_tree", {
path: dir,
excludePatterns: ["node_modules"],
});
const text = textOf(result);
expect(text).not.toContain("node_modules");
expect(text).toContain("keep.txt");
});

it("search_files: a bare name skips only the top-level entry", async () => {
expect(await search(["node_modules"])).toEqual([
path.join(dir, "pkg", "keep.txt"),
path.join(dir, "pkg", "node_modules", "deep.txt"),
]);
});

it("search_files: '**/node_modules' skips that name at any depth", async () => {
expect(await search(["**/node_modules"])).toEqual([
path.join(dir, "pkg", "keep.txt"),
]);
});
});
19 changes: 17 additions & 2 deletions src/filesystem/__tests__/server-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,11 @@ describe("server identity and tool list", () => {
expect(strip(byName.read_text_file.inputSchema)).toEqual({
type: "object",
properties: {
path: { type: "string" },
path: {
type: "string",
description:
"The file to read as text. Absolute, or relative to an allowed directory; it must resolve inside the allowed directories.",
},
tail: {
type: "integer",
minimum: 0,
Expand All @@ -247,12 +251,23 @@ describe("server identity and tool list", () => {
},
required: ["path"],
});
// The deprecated alias advertises read_text_file's schema, not a copy.
expect(byName.read_file.inputSchema).toEqual(
byName.read_text_file.inputSchema,
);
expect(strip(byName.edit_file.inputSchema)).toEqual({
type: "object",
properties: {
path: { type: "string" },
path: {
type: "string",
description:
"The text file to edit. Absolute, or relative to an allowed directory; it must resolve inside the allowed directories.",
},
edits: {
type: "array",
description:
"The replacements to make, applied in order, each to the result of the ones before it. " +
"If any one has no match, none is written.",
items: {
type: "object",
properties: {
Expand Down
Loading
Loading