argsbarg 7.1.0 → 7.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -1
- package/docs/mcp.md +34 -5
- package/examples/mcp-plugin/.claude-plugin/marketplace.json +13 -0
- package/examples/mcp-plugin/.claude-plugin/plugin.json +1 -2
- package/examples/mcp-plugin/.cursor-plugin/plugin.json +1 -2
- package/examples/mcp-plugin/README.md +25 -23
- package/index.d.ts +62 -0
- package/package.json +1 -1
- package/src/cli-tool/program.ts +1 -1
- package/src/config/validate.test.ts +157 -0
- package/src/config/validate.ts +352 -7
- package/src/core/document-leaf.test.ts +53 -0
- package/src/core/types.ts +33 -0
- package/src/core/validate.ts +4 -0
- package/src/docs/docs.test.ts +7 -0
- package/src/docs/mcp-guide.ts +43 -1
- package/src/headless/tool-call.test.ts +32 -0
- package/src/headless/tool-call.ts +19 -8
- package/src/index.ts +3 -0
- package/src/mcp/bundle.ts +3 -2
- package/src/mcp/claude.ts +4 -2
- package/src/mcp/cursor.ts +4 -2
- package/src/mcp/server.ts +28 -4
- package/src/mcp/tools.test.ts +158 -0
- package/src/mcp/tools.ts +94 -1
- package/src/runtime/cli.ts +6 -1
- package/src/server/context.ts +6 -0
- package/src/test/integration/mcp.test.ts +73 -0
- package/src/test/mcp-integration-fixture.ts +1 -0
- package/src/test/mcp-size-fixture.ts +31 -0
package/src/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ export type {
|
|
|
44
44
|
CliMcpBundleConfig,
|
|
45
45
|
CliMcpResource,
|
|
46
46
|
CliMcpServerConfig,
|
|
47
|
+
CliMcpSizeLimits,
|
|
47
48
|
CliMcpToolConfig,
|
|
48
49
|
CliMcpWireContext,
|
|
49
50
|
CliMcpWireHooks,
|
|
@@ -87,6 +88,8 @@ export type { EcsLogEvent, LogEnrichContext } from "./log/ecs.ts";
|
|
|
87
88
|
export { ECS_VERSION, formatEcsLine } from "./log/ecs.ts";
|
|
88
89
|
export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
|
|
89
90
|
export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
|
|
91
|
+
export type { McpSizeReport, McpToolSize } from "./mcp/tools.ts";
|
|
92
|
+
export { DEFAULT_MCP_SIZE_LIMITS, mcpSizeReport } from "./mcp/tools.ts";
|
|
90
93
|
export { userHome } from "./paths/host.ts";
|
|
91
94
|
export { Cli, type CliInvokeKind, type CliInvokeResult } from "./runtime/cli.ts";
|
|
92
95
|
export { cliErrWithHelp } from "./runtime/cli-errors.ts";
|
package/src/mcp/bundle.ts
CHANGED
|
@@ -3,7 +3,7 @@ Packs a CLI program into an MCP Bundle (`.mcpb`) for Claude Desktop.
|
|
|
3
3
|
Expects `dist/<program.key>` as the compiled binary input.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { basename, join, resolve } from "node:path";
|
|
9
9
|
import { buildProgramUserConfig } from "../config/manifest.ts";
|
|
@@ -119,7 +119,8 @@ export function packMcpBundle(program: CliProgram, opts: PackMcpBundleOpts = {})
|
|
|
119
119
|
const staging = mkdtempSync(join(tmpdir(), "mcpb-"));
|
|
120
120
|
try {
|
|
121
121
|
const stagedBinary = join(staging, binaryName);
|
|
122
|
-
cpSync(binaryPath, stagedBinary
|
|
122
|
+
cpSync(binaryPath, stagedBinary);
|
|
123
|
+
chmodSync(stagedBinary, 0o755);
|
|
123
124
|
|
|
124
125
|
const manifest = generateMcpManifest(program, binaryName);
|
|
125
126
|
const files: { name: string; data: Buffer }[] = [
|
package/src/mcp/claude.ts
CHANGED
|
@@ -3,7 +3,7 @@ Packs a Claude Code plugin zip from a compiled CLI binary.
|
|
|
3
3
|
Internal module — not exported from index.ts.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { basename, join, resolve } from "node:path";
|
|
9
9
|
import { buildPluginMcpEnvMapping, buildProgramUserConfig } from "../config/manifest.ts";
|
|
@@ -104,7 +104,9 @@ function writePluginTree(
|
|
|
104
104
|
join(pluginRoot, ".mcp.json"),
|
|
105
105
|
`${JSON.stringify(generatePluginMcpJson(program, binaryName), null, 2)}\n`,
|
|
106
106
|
);
|
|
107
|
-
|
|
107
|
+
const stagedBinary = join(pluginRoot, "bin", binaryName);
|
|
108
|
+
cpSync(binaryPath, stagedBinary);
|
|
109
|
+
chmodSync(stagedBinary, 0o755);
|
|
108
110
|
stagePluginSkills(pluginRoot, program, cwd);
|
|
109
111
|
}
|
|
110
112
|
|
package/src/mcp/cursor.ts
CHANGED
|
@@ -3,7 +3,7 @@ Packs a Cursor plugin zip from a compiled CLI binary.
|
|
|
3
3
|
Internal module — not exported from index.ts.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { basename, join, resolve } from "node:path";
|
|
9
9
|
import { buildCursorPluginMcpEnvMapping, buildCursorPluginVariables } from "../config/manifest.ts";
|
|
@@ -117,7 +117,9 @@ function writePluginTree(
|
|
|
117
117
|
join(pluginRoot, "mcp.json"),
|
|
118
118
|
`${JSON.stringify(generateCursorPluginMcpJson(program, binaryName), null, 2)}\n`,
|
|
119
119
|
);
|
|
120
|
-
|
|
120
|
+
const stagedBinary = join(pluginRoot, "bin", binaryName);
|
|
121
|
+
cpSync(binaryPath, stagedBinary);
|
|
122
|
+
chmodSync(stagedBinary, 0o755);
|
|
121
123
|
stagePluginSkills(pluginRoot, program, cwd);
|
|
122
124
|
}
|
|
123
125
|
|
package/src/mcp/server.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { executeHeadlessToolCall, headlessFailureMcpMessage, lookupHeadlessTool
|
|
|
8
8
|
import type { Cli } from "../runtime/cli.ts";
|
|
9
9
|
import { allMcpResources, collectMcpTools, resolveMcpServerInfo } from "./tools.ts";
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** Protocol versions this server understands, newest first. `initialize` echoes a match or answers the first. */
|
|
12
|
+
export const MCP_PROTOCOL_VERSIONS = ["2025-06-18", "2024-11-05"] as const;
|
|
13
|
+
|
|
14
|
+
/** The first protocol version to define `outputSchema` (tools/list) and `structuredContent` (tools/call). */
|
|
15
|
+
const MCP_STRUCTURED_OUTPUT_SINCE = "2025-06-18";
|
|
12
16
|
|
|
13
17
|
/** JSON-RPC request shape from stdin. */
|
|
14
18
|
interface JsonRpcRequest {
|
|
@@ -35,6 +39,13 @@ function writeError(id: string | number | null | undefined, code: number, messag
|
|
|
35
39
|
});
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
/** True when `version` is at or after {@link MCP_STRUCTURED_OUTPUT_SINCE} in {@link MCP_PROTOCOL_VERSIONS}. */
|
|
43
|
+
function supportsStructuredOutput(version: string | undefined): boolean {
|
|
44
|
+
const idx = version ? (MCP_PROTOCOL_VERSIONS as readonly string[]).indexOf(version) : -1;
|
|
45
|
+
const sinceIdx = (MCP_PROTOCOL_VERSIONS as readonly string[]).indexOf(MCP_STRUCTURED_OUTPUT_SINCE);
|
|
46
|
+
return idx !== -1 && idx <= sinceIdx;
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
/** Handles one NDJSON request line. */
|
|
39
50
|
async function handleRequestLine(cli: Cli, line: string): Promise<void> {
|
|
40
51
|
const root = cli.program;
|
|
@@ -98,13 +109,23 @@ async function handleRequestLine(cli: Cli, line: string): Promise<void> {
|
|
|
98
109
|
try {
|
|
99
110
|
if (method === "initialize") {
|
|
100
111
|
const info = resolveMcpServerInfo(root);
|
|
112
|
+
const requested = params.protocolVersion;
|
|
113
|
+
const negotiated =
|
|
114
|
+
typeof requested === "string" && (MCP_PROTOCOL_VERSIONS as readonly string[]).includes(requested)
|
|
115
|
+
? requested
|
|
116
|
+
: MCP_PROTOCOL_VERSIONS[0];
|
|
117
|
+
if (cli.server) {
|
|
118
|
+
cli.server.mcpProtocolVersion = negotiated;
|
|
119
|
+
}
|
|
120
|
+
const instructions = root.mcpServer?.instructions;
|
|
101
121
|
writeResponse({
|
|
102
122
|
jsonrpc: "2.0",
|
|
103
123
|
id,
|
|
104
124
|
result: {
|
|
105
|
-
protocolVersion:
|
|
125
|
+
protocolVersion: negotiated,
|
|
106
126
|
capabilities: { tools: {}, resources: {} },
|
|
107
127
|
serverInfo: { name: info.name, version: info.version },
|
|
128
|
+
...(instructions ? { instructions } : {}),
|
|
108
129
|
},
|
|
109
130
|
});
|
|
110
131
|
await finish();
|
|
@@ -118,11 +139,12 @@ async function handleRequestLine(cli: Cli, line: string): Promise<void> {
|
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
if (method === "tools/list") {
|
|
142
|
+
const structured = supportsStructuredOutput(cli.server?.mcpProtocolVersion);
|
|
121
143
|
const tools = collectMcpTools(root).map((t) => ({
|
|
122
144
|
name: t.name,
|
|
123
145
|
description: t.description,
|
|
124
146
|
inputSchema: t.inputSchema,
|
|
125
|
-
...(t.outputSchema
|
|
147
|
+
...(structured && t.outputSchema !== undefined ? { outputSchema: t.outputSchema } : {}),
|
|
126
148
|
}));
|
|
127
149
|
writeResponse({ jsonrpc: "2.0", id, result: { tools } });
|
|
128
150
|
await finish();
|
|
@@ -168,10 +190,12 @@ async function handleRequestLine(cli: Cli, line: string): Promise<void> {
|
|
|
168
190
|
{ rpcMethod: method, toolName: name, requestId },
|
|
169
191
|
);
|
|
170
192
|
if (invokeResult.ok) {
|
|
193
|
+
const structured = supportsStructuredOutput(cli.server?.mcpProtocolVersion);
|
|
194
|
+
const { structuredContent: _structuredContent, ...rest } = invokeResult.mcpResult;
|
|
171
195
|
writeResponse({
|
|
172
196
|
jsonrpc: "2.0",
|
|
173
197
|
id,
|
|
174
|
-
result: invokeResult.mcpResult,
|
|
198
|
+
result: structured ? invokeResult.mcpResult : rest,
|
|
175
199
|
});
|
|
176
200
|
await finish();
|
|
177
201
|
return;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Tests for mcp/tools module: MCP tool derivation, size reporting, and per-leaf MCP-only notes.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { cliPresentationRoot } from "../builtins/presentation.ts";
|
|
7
|
+
import { CliOptionKind } from "../core/types.ts";
|
|
8
|
+
import { cliHelpRender } from "../help.ts";
|
|
9
|
+
import { testProgram } from "../test/fixtures.ts";
|
|
10
|
+
import { collectMcpTools, DEFAULT_MCP_SIZE_LIMITS, mcpSizeReport } from "./tools.ts";
|
|
11
|
+
|
|
12
|
+
describe("mcpSizeReport", () => {
|
|
13
|
+
test("measures description and pretty definition against a hand-built JSON.stringify", () => {
|
|
14
|
+
const program = testProgram({
|
|
15
|
+
key: "sizetest",
|
|
16
|
+
description: "size test",
|
|
17
|
+
mcpServer: { enabled: true },
|
|
18
|
+
commands: [{ key: "run", description: "Run it.", handler: () => {} }],
|
|
19
|
+
});
|
|
20
|
+
const report = mcpSizeReport(program);
|
|
21
|
+
const [tool] = collectMcpTools(program);
|
|
22
|
+
expect(tool).toBeDefined();
|
|
23
|
+
|
|
24
|
+
const expectedJson = JSON.stringify(
|
|
25
|
+
{ name: tool?.name, description: tool?.description, inputSchema: tool?.inputSchema },
|
|
26
|
+
null,
|
|
27
|
+
2,
|
|
28
|
+
);
|
|
29
|
+
expect(report.tools).toHaveLength(1);
|
|
30
|
+
expect(report.tools[0]).toEqual({
|
|
31
|
+
name: tool?.name,
|
|
32
|
+
descriptionChars: tool?.description.length,
|
|
33
|
+
definitionBytes: Buffer.byteLength(expectedJson, "utf8"),
|
|
34
|
+
definitionLines: expectedJson.split("\n").length,
|
|
35
|
+
});
|
|
36
|
+
expect(report.warnings).toEqual([]);
|
|
37
|
+
expect(report.instructionsChars).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("warns past default limits for description and definition size", () => {
|
|
41
|
+
const program = testProgram({
|
|
42
|
+
key: "sizetest2",
|
|
43
|
+
description: "size test 2",
|
|
44
|
+
mcpServer: { enabled: true },
|
|
45
|
+
commands: [
|
|
46
|
+
{
|
|
47
|
+
key: "small",
|
|
48
|
+
description: "Small tool.",
|
|
49
|
+
notes: "x".repeat(3_000),
|
|
50
|
+
handler: () => {},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: "big",
|
|
54
|
+
description: "Big tool.",
|
|
55
|
+
options: [
|
|
56
|
+
{
|
|
57
|
+
name: "mode",
|
|
58
|
+
description: "Mode.",
|
|
59
|
+
kind: CliOptionKind.Enum,
|
|
60
|
+
choices: Array.from({ length: 4_000 }, (_, i) => `choice-${i}`),
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
handler: () => {},
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
const report = mcpSizeReport(program);
|
|
68
|
+
|
|
69
|
+
const smallWarning = report.warnings.find((w) => w.includes('"small"'));
|
|
70
|
+
expect(smallWarning).toBeDefined();
|
|
71
|
+
expect(smallWarning).toContain("description is");
|
|
72
|
+
expect(smallWarning).toContain(`limit ${DEFAULT_MCP_SIZE_LIMITS.descriptionChars.toLocaleString()}`);
|
|
73
|
+
|
|
74
|
+
const bigWarning = report.warnings.find((w) => w.includes('"big"'));
|
|
75
|
+
expect(bigWarning).toBeDefined();
|
|
76
|
+
expect(bigWarning).toContain("definition is");
|
|
77
|
+
expect(bigWarning).toContain("pretty-printed");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("sizeLimits overrides raise or lower the threshold", () => {
|
|
81
|
+
const program = testProgram({
|
|
82
|
+
key: "sizetest3",
|
|
83
|
+
description: "size test 3",
|
|
84
|
+
mcpServer: { enabled: true, sizeLimits: { descriptionChars: 5 } },
|
|
85
|
+
commands: [
|
|
86
|
+
{ key: "run", description: "Run it, with a description longer than five characters.", handler: () => {} },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
const report = mcpSizeReport(program);
|
|
90
|
+
expect(report.warnings.some((w) => w.includes("description is"))).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("sizeLimits: false disables a check entirely", () => {
|
|
94
|
+
const program = testProgram({
|
|
95
|
+
key: "sizetest4",
|
|
96
|
+
description: "size test 4",
|
|
97
|
+
mcpServer: { enabled: true, sizeLimits: { descriptionChars: false } },
|
|
98
|
+
commands: [
|
|
99
|
+
{ key: "run", description: "Run it, with a description longer than five characters.", handler: () => {} },
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
const report = mcpSizeReport(program);
|
|
103
|
+
expect(report.warnings.some((w) => w.includes("description is"))).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("warns when instructions exceed the limit", () => {
|
|
107
|
+
const program = testProgram({
|
|
108
|
+
key: "sizetest5",
|
|
109
|
+
description: "size test 5",
|
|
110
|
+
mcpServer: { enabled: true, instructions: "x".repeat(3_000) },
|
|
111
|
+
commands: [{ key: "run", description: "Run it.", handler: () => {} }],
|
|
112
|
+
});
|
|
113
|
+
const report = mcpSizeReport(program);
|
|
114
|
+
expect(report.instructionsChars).toBe(3_000);
|
|
115
|
+
expect(report.warnings.some((w) => w.startsWith("MCP instructions are"))).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("mcpTool.notes", () => {
|
|
120
|
+
function programWithNotesOverride(notesOverride: string | false | undefined) {
|
|
121
|
+
return testProgram({
|
|
122
|
+
key: "notestest",
|
|
123
|
+
description: "notes test",
|
|
124
|
+
mcpServer: { enabled: true },
|
|
125
|
+
commands: [
|
|
126
|
+
{
|
|
127
|
+
key: "run",
|
|
128
|
+
description: "Run it.",
|
|
129
|
+
notes: "Original CLI notes.",
|
|
130
|
+
...(notesOverride === undefined ? {} : { mcpTool: { notes: notesOverride } }),
|
|
131
|
+
handler: () => {},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
test("false omits notes from the MCP description", () => {
|
|
138
|
+
const [tool] = collectMcpTools(programWithNotesOverride(false));
|
|
139
|
+
expect(tool?.description).not.toContain("Original CLI notes.");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a string replaces the leaf's notes in the MCP description", () => {
|
|
143
|
+
const [tool] = collectMcpTools(programWithNotesOverride("Custom MCP-only note."));
|
|
144
|
+
expect(tool?.description).toContain("Custom MCP-only note.");
|
|
145
|
+
expect(tool?.description).not.toContain("Original CLI notes.");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("omitted falls through to the leaf's own notes", () => {
|
|
149
|
+
const [tool] = collectMcpTools(programWithNotesOverride(undefined));
|
|
150
|
+
expect(tool?.description).toContain("Original CLI notes.");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("CLI help always shows the leaf's own notes regardless of mcpTool.notes", () => {
|
|
154
|
+
const program = programWithNotesOverride(false);
|
|
155
|
+
const help = cliHelpRender(cliPresentationRoot(program), ["run"], false);
|
|
156
|
+
expect(help).toContain("Original CLI notes.");
|
|
157
|
+
});
|
|
158
|
+
});
|
package/src/mcp/tools.ts
CHANGED
|
@@ -6,6 +6,7 @@ flat JSON tool arguments into argv for Cli.invoke.
|
|
|
6
6
|
import { cliSchemaJson } from "../core/schema.ts";
|
|
7
7
|
import {
|
|
8
8
|
type CliLeaf,
|
|
9
|
+
type CliMcpSizeLimits,
|
|
9
10
|
type CliNode,
|
|
10
11
|
type CliOption,
|
|
11
12
|
CliOptionKind,
|
|
@@ -107,7 +108,13 @@ function resolveToolDescription(root: CliProgram, path: string[], leaf: CliLeaf)
|
|
|
107
108
|
} else {
|
|
108
109
|
desc = mcpToolDescription(path, root.key, leaf.description);
|
|
109
110
|
}
|
|
110
|
-
|
|
111
|
+
// `mcpTool.notes` overrides what CLI help shows (leaf.notes) for the MCP description only:
|
|
112
|
+
// `false` omits notes entirely; a string replaces them; omitted falls through to leaf.notes.
|
|
113
|
+
const notesOverride = leaf.mcpTool?.notes;
|
|
114
|
+
if (notesOverride === false) {
|
|
115
|
+
return desc;
|
|
116
|
+
}
|
|
117
|
+
const notes = (typeof notesOverride === "string" ? notesOverride : (leaf.notes ?? "")).trim();
|
|
111
118
|
if (notes.length > 0) {
|
|
112
119
|
desc += `\n\n${cliResolveNotes(notes, root.key)}`;
|
|
113
120
|
}
|
|
@@ -272,3 +279,89 @@ export function mcpToolCallToArgv(
|
|
|
272
279
|
|
|
273
280
|
return argv;
|
|
274
281
|
}
|
|
282
|
+
|
|
283
|
+
/** Default {@link CliMcpSizeLimits}; see that type for what each limit approximates and why. */
|
|
284
|
+
export const DEFAULT_MCP_SIZE_LIMITS: Required<CliMcpSizeLimits> = {
|
|
285
|
+
definitionBytes: 51_200,
|
|
286
|
+
definitionLines: 2_000,
|
|
287
|
+
descriptionChars: 2_048,
|
|
288
|
+
instructionsChars: 2_048,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/** Measured size of one MCP tool's description and pretty-printed definition. */
|
|
292
|
+
export interface McpToolSize {
|
|
293
|
+
/** Pretty-printed `{name, description, inputSchema, outputSchema}`, in UTF-8 bytes. */
|
|
294
|
+
definitionBytes: number;
|
|
295
|
+
/** Line count of the same pretty-printed definition. */
|
|
296
|
+
definitionLines: number;
|
|
297
|
+
/** Character length of `description` alone. */
|
|
298
|
+
descriptionChars: number;
|
|
299
|
+
/** MCP tool name. */
|
|
300
|
+
name: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Per-tool sizes plus any warnings past {@link CliMcpSizeLimits} (defaults or `mcpServer.sizeLimits`). */
|
|
304
|
+
export interface McpSizeReport {
|
|
305
|
+
/** Character length of `mcpServer.instructions`, or 0 when unset. */
|
|
306
|
+
instructionsChars: number;
|
|
307
|
+
/** One entry per MCP tool, in `tools/list` order. */
|
|
308
|
+
tools: McpToolSize[];
|
|
309
|
+
/** Human-readable warnings for anything past its limit; empty when everything fits. */
|
|
310
|
+
warnings: string[];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Formats a definition's pretty-printed JSON exactly as Cursor's synced tool file would show it. */
|
|
314
|
+
function mcpToolDefinitionJson(tool: McpToolDef): string {
|
|
315
|
+
return JSON.stringify(
|
|
316
|
+
{
|
|
317
|
+
name: tool.name,
|
|
318
|
+
description: tool.description,
|
|
319
|
+
inputSchema: tool.inputSchema,
|
|
320
|
+
...(tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }),
|
|
321
|
+
},
|
|
322
|
+
null,
|
|
323
|
+
2,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Measures every MCP tool's description and definition size against {@link CliMcpSizeLimits}. */
|
|
328
|
+
export function mcpSizeReport(root: CliProgram): McpSizeReport {
|
|
329
|
+
const limits = { ...DEFAULT_MCP_SIZE_LIMITS, ...root.mcpServer?.sizeLimits };
|
|
330
|
+
const warnings: string[] = [];
|
|
331
|
+
|
|
332
|
+
const tools = collectMcpTools(root).map((tool): McpToolSize => {
|
|
333
|
+
const definitionJson = mcpToolDefinitionJson(tool);
|
|
334
|
+
const definitionBytes = Buffer.byteLength(definitionJson, "utf8");
|
|
335
|
+
const definitionLines = definitionJson.split("\n").length;
|
|
336
|
+
const descriptionChars = tool.description.length;
|
|
337
|
+
|
|
338
|
+
if (limits.descriptionChars !== false && descriptionChars > limits.descriptionChars) {
|
|
339
|
+
warnings.push(
|
|
340
|
+
`MCP tool "${tool.name}" description is ${descriptionChars.toLocaleString()} chars ` +
|
|
341
|
+
`(limit ${limits.descriptionChars.toLocaleString()}; Claude Code truncates longer descriptions)`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const overBytes = limits.definitionBytes !== false && definitionBytes > limits.definitionBytes;
|
|
345
|
+
const overLines = limits.definitionLines !== false && definitionLines > limits.definitionLines;
|
|
346
|
+
if (overBytes || overLines) {
|
|
347
|
+
const byteLimit = limits.definitionBytes === false ? "∞" : limits.definitionBytes.toLocaleString();
|
|
348
|
+
const lineLimit = limits.definitionLines === false ? "∞" : limits.definitionLines.toLocaleString();
|
|
349
|
+
warnings.push(
|
|
350
|
+
`MCP tool "${tool.name}" definition is ${definitionBytes.toLocaleString()} bytes / ` +
|
|
351
|
+
`${definitionLines.toLocaleString()} lines pretty-printed (limit ${byteLimit} bytes / ${lineLimit} lines; ` +
|
|
352
|
+
`Cursor reads tool definitions in chunks of at most that size)`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { definitionBytes, definitionLines, descriptionChars, name: tool.name };
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const instructionsChars = (root.mcpServer?.instructions ?? "").length;
|
|
360
|
+
if (limits.instructionsChars !== false && instructionsChars > limits.instructionsChars) {
|
|
361
|
+
warnings.push(
|
|
362
|
+
`MCP instructions are ${instructionsChars.toLocaleString()} chars (limit ${limits.instructionsChars.toLocaleString()})`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { instructionsChars, tools, warnings };
|
|
367
|
+
}
|
package/src/runtime/cli.ts
CHANGED
|
@@ -33,7 +33,8 @@ import { buildInvokeHookContext, classifyFailureKind, runErrorPipeline, runHook
|
|
|
33
33
|
import { httpServeHttp } from "../http/server.ts";
|
|
34
34
|
import { LogEmitter } from "../log/emitter.ts";
|
|
35
35
|
import { bootstrapMcpEnv } from "../mcp/env.ts";
|
|
36
|
-
import { mcpServeStdioLoop } from "../mcp/server.ts";
|
|
36
|
+
import { MCP_PROTOCOL_VERSIONS, mcpServeStdioLoop } from "../mcp/server.ts";
|
|
37
|
+
import { mcpSizeReport } from "../mcp/tools.ts";
|
|
37
38
|
import { createServerRuntime, type ServerHandleContext } from "../server/context.ts";
|
|
38
39
|
import { resolveHttpServeConfig, resolveMcpServeConfig, type ServeOverrides } from "../server/overrides.ts";
|
|
39
40
|
import {
|
|
@@ -414,6 +415,7 @@ export class Cli {
|
|
|
414
415
|
emitter,
|
|
415
416
|
mcp: resolved,
|
|
416
417
|
mcpHooks: this.program.mcpServer?.hooks,
|
|
418
|
+
mcpProtocolVersion: MCP_PROTOCOL_VERSIONS[0],
|
|
417
419
|
};
|
|
418
420
|
bootstrapAppConfig(this.program, { validateFile: "soft", runtime, emitter });
|
|
419
421
|
const shutdown = () => {
|
|
@@ -422,6 +424,9 @@ export class Cli {
|
|
|
422
424
|
};
|
|
423
425
|
process.once("SIGINT", shutdown);
|
|
424
426
|
process.once("SIGTERM", shutdown);
|
|
427
|
+
for (const message of mcpSizeReport(this.program).warnings) {
|
|
428
|
+
emitter.emit({ level: "warn", message, action: "mcp.size" });
|
|
429
|
+
}
|
|
425
430
|
emitter.emitLifecycle(`${this.program.key} ${this.program.version} — MCP ready (stdio)`, "mcp.server.ready");
|
|
426
431
|
await mcpServeStdioLoop(this);
|
|
427
432
|
process.exit(0);
|
package/src/server/context.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface ServerHandleContext {
|
|
|
14
14
|
mcp?: ResolvedMcpServeConfig;
|
|
15
15
|
httpHooks?: CliHttpWireHooks;
|
|
16
16
|
mcpHooks?: CliMcpWireHooks;
|
|
17
|
+
/**
|
|
18
|
+
* The MCP protocol version negotiated with `initialize`, or the newest supported version before
|
|
19
|
+
* `initialize` has been handled. Later requests (`tools/list`, `tools/call`) gate version-specific
|
|
20
|
+
* response fields (e.g. `outputSchema`, `structuredContent`) on this.
|
|
21
|
+
*/
|
|
22
|
+
mcpProtocolVersion?: string;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
/** Creates a fresh {@link ServerRuntime} for HTTP or MCP. */
|
|
@@ -353,6 +353,79 @@ test("MCP initialize returns tools and resources capabilities", async () => {
|
|
|
353
353
|
expect(res.result.capabilities.resources).toBeDefined();
|
|
354
354
|
});
|
|
355
355
|
|
|
356
|
+
test("MCP initialize echoes a supported protocol version", async () => {
|
|
357
|
+
for (const version of ["2024-11-05", "2025-06-18"]) {
|
|
358
|
+
const responses = await mcpRequest([
|
|
359
|
+
{ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: version } },
|
|
360
|
+
]);
|
|
361
|
+
const res = responses.get(1) as { result: { protocolVersion: string } };
|
|
362
|
+
expect(res.result.protocolVersion).toBe(version);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("MCP initialize answers unsupported or missing versions with the newest", async () => {
|
|
367
|
+
for (const params of [{ protocolVersion: "2099-01-01" }, {}]) {
|
|
368
|
+
const responses = await mcpRequest([{ jsonrpc: "2.0", id: 1, method: "initialize", params }]);
|
|
369
|
+
const res = responses.get(1) as { result: { protocolVersion: string } };
|
|
370
|
+
expect(res.result.protocolVersion).toBe("2025-06-18");
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("2024-11-05 sessions omit outputSchema and structuredContent", async () => {
|
|
375
|
+
const readme = join(import.meta.dir, "..", "..", "..", "README.md");
|
|
376
|
+
const responses = await mcpRequest([
|
|
377
|
+
{ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05" } },
|
|
378
|
+
{ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
|
|
379
|
+
{
|
|
380
|
+
jsonrpc: "2.0",
|
|
381
|
+
id: 3,
|
|
382
|
+
method: "tools/call",
|
|
383
|
+
params: { name: "stat_owner_lookup", arguments: { path: readme, "user-name": "test" } },
|
|
384
|
+
},
|
|
385
|
+
]);
|
|
386
|
+
const listRes = responses.get(2) as { result: { tools: { name: string; outputSchema?: unknown }[] } };
|
|
387
|
+
const lookup = listRes.result.tools.find((t) => t.name === "stat_owner_lookup");
|
|
388
|
+
expect(lookup).toBeDefined();
|
|
389
|
+
expect(lookup?.outputSchema).toBeUndefined();
|
|
390
|
+
|
|
391
|
+
const callRes = responses.get(3) as { result: { structuredContent?: unknown; isError: boolean } };
|
|
392
|
+
expect(callRes.result.isError).toBe(false);
|
|
393
|
+
expect(callRes.result.structuredContent).toBeUndefined();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("MCP initialize includes configured instructions", async () => {
|
|
397
|
+
const responses = await mcpRequest([{ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }], {
|
|
398
|
+
script: "src/test/mcp-integration-fixture.ts",
|
|
399
|
+
});
|
|
400
|
+
const res = responses.get(1) as { result: { instructions?: string } };
|
|
401
|
+
expect(res.result.instructions).toBe("Read the fixture skill.");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("MCP startup warns about oversized tools on stderr", async () => {
|
|
405
|
+
const proc = Bun.spawn(["bun", "run", "src/test/mcp-size-fixture.ts", "mcp"], {
|
|
406
|
+
stdin: "pipe",
|
|
407
|
+
stdout: "pipe",
|
|
408
|
+
stderr: "pipe",
|
|
409
|
+
});
|
|
410
|
+
proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} })}\n`);
|
|
411
|
+
proc.stdin.end();
|
|
412
|
+
const timeout = setTimeout(() => proc.kill(), 10_000);
|
|
413
|
+
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
414
|
+
await proc.exited;
|
|
415
|
+
clearTimeout(timeout);
|
|
416
|
+
|
|
417
|
+
expect(stderr).toContain("description is 3,");
|
|
418
|
+
|
|
419
|
+
const lines = stdout
|
|
420
|
+
.split("\n")
|
|
421
|
+
.map((l) => l.trim())
|
|
422
|
+
.filter(Boolean);
|
|
423
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
424
|
+
for (const line of lines) {
|
|
425
|
+
expect(() => JSON.parse(line)).not.toThrow();
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
356
429
|
test("MCP tools/list includes stat_owner_lookup", async () => {
|
|
357
430
|
const responses = await mcpRequest([{ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }]);
|
|
358
431
|
const res = responses.get(2) as {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/*
|
|
3
|
+
MCP size-warning integration test fixture (not a public example). One leaf with oversized notes,
|
|
4
|
+
triggering the "description" startup size warning on stderr.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Cli, type CliProgram } from "../index.ts";
|
|
8
|
+
|
|
9
|
+
const program = {
|
|
10
|
+
commands: [
|
|
11
|
+
{
|
|
12
|
+
key: "run",
|
|
13
|
+
description: "Run it.",
|
|
14
|
+
notes: "x".repeat(3_000),
|
|
15
|
+
handler: (ctx) => {
|
|
16
|
+
if (ctx.invocation === "cli") {
|
|
17
|
+
console.log("ran");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
return "ran";
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
description: "MCP size-warning test fixture.",
|
|
25
|
+
key: "mcp-size-test",
|
|
26
|
+
mcpServer: { enabled: true },
|
|
27
|
+
version: "0.0.0-test",
|
|
28
|
+
} satisfies CliProgram;
|
|
29
|
+
|
|
30
|
+
const cli = new Cli(program);
|
|
31
|
+
await cli.run();
|