@yagni-app/code 1.0.5 → 1.0.6
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/README.md +30 -6
- package/dist/claudePlugins.d.ts +3 -1
- package/dist/claudePlugins.js +3 -1
- package/dist/cli.js +12 -0
- package/dist/doctor.d.ts +28 -3
- package/dist/doctor.js +117 -7
- package/dist/extension/index.d.ts +5 -5
- package/dist/extension/index.js +89 -29
- package/dist/extension/mcp/approval.d.ts +45 -0
- package/dist/extension/mcp/approval.js +164 -0
- package/dist/extension/mcp/auth.d.ts +124 -0
- package/dist/extension/mcp/auth.js +560 -0
- package/dist/extension/mcp/authStore.d.ts +61 -0
- package/dist/extension/mcp/authStore.js +105 -0
- package/dist/extension/mcp/callbackPage.d.ts +31 -0
- package/dist/extension/mcp/callbackPage.js +222 -0
- package/dist/extension/mcp/cliConfig.d.ts +12 -0
- package/dist/extension/mcp/cliConfig.js +12 -0
- package/dist/extension/mcp/config.d.ts +131 -0
- package/dist/extension/mcp/config.js +309 -0
- package/dist/extension/mcp/log.d.ts +28 -0
- package/dist/extension/mcp/log.js +82 -0
- package/dist/extension/mcp/manager.d.ts +98 -0
- package/dist/extension/mcp/manager.js +273 -0
- package/dist/extension/mcp/names.d.ts +25 -0
- package/dist/extension/mcp/names.js +40 -0
- package/dist/extension/mcp/panel.d.ts +34 -0
- package/dist/extension/mcp/panel.js +258 -0
- package/dist/extension/mcp/prompts.d.ts +23 -0
- package/dist/extension/mcp/prompts.js +93 -0
- package/dist/extension/mcp/startup.d.ts +55 -0
- package/dist/extension/mcp/startup.js +150 -0
- package/dist/extension/mcp/tools.d.ts +31 -0
- package/dist/extension/mcp/tools.js +117 -0
- package/dist/extension/mcp/transports.d.ts +17 -0
- package/dist/extension/mcp/transports.js +44 -0
- package/dist/extension/permission/gate.d.ts +7 -0
- package/dist/extension/permission/gate.js +12 -5
- package/dist/extension/permission/guardian.d.ts +24 -5
- package/dist/extension/permission/guardian.js +162 -24
- package/dist/extension/pipeline/personas.js +5 -0
- package/dist/mcpCommand.d.ts +113 -0
- package/dist/mcpCommand.js +755 -0
- package/dist/otel.d.ts +36 -7
- package/dist/otel.js +90 -12
- package/dist/upgrade.d.ts +11 -2
- package/dist/upgrade.js +48 -8
- package/package.json +3 -2
- package/dist/extension/mcpTools.d.ts +0 -57
- package/dist/extension/mcpTools.js +0 -132
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP prompts as dynamically-registered pi slash commands: every prompt a
|
|
3
|
+
* server exposes becomes `/mcp__<server>__<prompt> [args]` whose handler
|
|
4
|
+
* fetches the rendered messages and injects them as the next user turn via
|
|
5
|
+
* pi.sendUserMessage. pi prompt templates are file-only, so runtime
|
|
6
|
+
* registration is the only path — commands are resolved live on each
|
|
7
|
+
* getRegisteredCommands() call.
|
|
8
|
+
*/
|
|
9
|
+
import { buildMcpPromptName } from "./names.js";
|
|
10
|
+
export async function registerServerPrompts(pi, manager, serverName) {
|
|
11
|
+
const server = manager.get(serverName);
|
|
12
|
+
const result = { commands: [], warnings: [] };
|
|
13
|
+
if (!server?.client)
|
|
14
|
+
return result;
|
|
15
|
+
let promptList;
|
|
16
|
+
try {
|
|
17
|
+
promptList = await server.client.listPrompts();
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
result.warnings.push(`${serverName}: listPrompts failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
for (const prompt of promptList.prompts ?? []) {
|
|
24
|
+
const commandName = buildMcpPromptName(serverName, prompt.name);
|
|
25
|
+
if (!commandName)
|
|
26
|
+
continue;
|
|
27
|
+
const argSpec = (prompt.arguments ?? [])
|
|
28
|
+
.map((a) => (a.required ? `<${a.name}>` : `[${a.name}]`))
|
|
29
|
+
.join(" ");
|
|
30
|
+
pi.registerCommand(commandName, {
|
|
31
|
+
description: prompt.description
|
|
32
|
+
? `${prompt.description} (MCP prompt from "${serverName}")`
|
|
33
|
+
: `MCP prompt "${prompt.name}" from server "${serverName}"`,
|
|
34
|
+
handler: async (args, ctx) => {
|
|
35
|
+
const active = manager.get(serverName);
|
|
36
|
+
if (!active?.client) {
|
|
37
|
+
ctx.ui.notify(`MCP server "${serverName}" is not connected (try /mcp reconnect).`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const parsedArgs = parsePromptArgs(args);
|
|
41
|
+
const missing = (prompt.arguments ?? [])
|
|
42
|
+
.filter((a) => a.required && parsedArgs[a.name] === undefined)
|
|
43
|
+
.map((a) => a.name);
|
|
44
|
+
if (missing.length > 0) {
|
|
45
|
+
ctx.ui.notify(`Missing required argument(s): ${missing.join(", ")}`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const rendered = await active.client.getPrompt({ name: prompt.name, arguments: parsedArgs });
|
|
50
|
+
const text = (rendered.messages ?? [])
|
|
51
|
+
.map((m) => {
|
|
52
|
+
if (!("content" in m))
|
|
53
|
+
return "";
|
|
54
|
+
const content = Array.isArray(m.content) ? m.content : [m.content];
|
|
55
|
+
return content
|
|
56
|
+
.filter((c) => c && typeof c === "object" && "type" in c && c.type === "text")
|
|
57
|
+
.map((c) => c.text)
|
|
58
|
+
.join("\n");
|
|
59
|
+
})
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join("\n\n");
|
|
62
|
+
if (!text) {
|
|
63
|
+
ctx.ui.notify(`Prompt "${prompt.name}" returned no text content.`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
pi.sendUserMessage(text);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
ctx.ui.notify(`Prompt failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
...(argSpec ? { getArgumentCompletions: () => null } : {}),
|
|
73
|
+
});
|
|
74
|
+
result.commands.push({
|
|
75
|
+
command: commandName,
|
|
76
|
+
serverName,
|
|
77
|
+
promptName: prompt.name,
|
|
78
|
+
description: prompt.description ?? `MCP prompt "${prompt.name}"`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
/** "a=1 b=two" → { a: "1", b: "two" }; quotes group spaces. */
|
|
84
|
+
export function parsePromptArgs(args) {
|
|
85
|
+
const parsed = {};
|
|
86
|
+
const re = /(\w+)=("([^"]*)"|'([^']*)'|(\S+))/g;
|
|
87
|
+
let match;
|
|
88
|
+
while ((match = re.exec(args)) !== null) {
|
|
89
|
+
parsed[match[1]] = match[3] ?? match[4] ?? match[5] ?? "";
|
|
90
|
+
}
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=prompts.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session wiring for MCP: load config, apply the approval gate and the
|
|
3
|
+
* kill-switch, connect servers, register tools/prompts/panel, and tear it
|
|
4
|
+
* all down on session_shutdown. Everything is fail-soft — a broken server
|
|
5
|
+
* or a broken config file never blocks the session.
|
|
6
|
+
*
|
|
7
|
+
* Startup notify (one line when a configured server failed to connect) is a
|
|
8
|
+
* deliberate deviation from Claude Code, which stays silent on failures.
|
|
9
|
+
*
|
|
10
|
+
* `YAGNI_CODE_MCP_DISABLED=1` skips the whole subsystem (kill-switch for
|
|
11
|
+
* incidents / suspicious servers), mirroring CC's MCP_AUTO_CONNECT killer
|
|
12
|
+
* but namespaced to this CLI.
|
|
13
|
+
*/
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { McpManager } from "./manager.js";
|
|
16
|
+
export { _setMcpLogHomeForTest, appendMcpLogLine } from "./log.js";
|
|
17
|
+
export declare function isMcpDisabled(env?: NodeJS.ProcessEnv): boolean;
|
|
18
|
+
export interface StartupOutcome {
|
|
19
|
+
manager: McpManager;
|
|
20
|
+
connectedServers: string[];
|
|
21
|
+
failedServers: {
|
|
22
|
+
name: string;
|
|
23
|
+
error: string;
|
|
24
|
+
}[];
|
|
25
|
+
/** OAuth servers whose connect threw `UnauthorizedError` (interactive auth pending). */
|
|
26
|
+
needsAuthServers: string[];
|
|
27
|
+
skippedApproval: string[];
|
|
28
|
+
configErrors: string[];
|
|
29
|
+
/** Full wire names of registered tools that look mutating (gate input). */
|
|
30
|
+
mutatingToolNames: string[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The full session-start sequence. Pure with respect to config homes (tests
|
|
34
|
+
* point them at tmpdirs via _setMcpHomeForTest / _setMcpLogHomeForTest).
|
|
35
|
+
*/
|
|
36
|
+
export declare function startMcp(pi: ExtensionAPI, opts: {
|
|
37
|
+
cwd: string;
|
|
38
|
+
env?: NodeJS.ProcessEnv;
|
|
39
|
+
hasUI: boolean;
|
|
40
|
+
notify?: (msg: string) => void;
|
|
41
|
+
}): Promise<StartupOutcome>;
|
|
42
|
+
/**
|
|
43
|
+
* Derive the connectivity startup notices from the manager's LIVE state.
|
|
44
|
+
*
|
|
45
|
+
* These must NOT be frozen at `startMcp()` time: the index.ts wiring defers
|
|
46
|
+
* them to the first provider response, and by then the user may have already
|
|
47
|
+
* healed a server via `/mcp` (e.g. authenticated a `needs_auth` server). A
|
|
48
|
+
* server that has since reached `connected` is no longer "needs authentication"
|
|
49
|
+
* or "failed to connect", so recomputing against `manager.list()` at flush
|
|
50
|
+
* time is what keeps the banner honest.
|
|
51
|
+
*/
|
|
52
|
+
export declare function deriveStartupConnectivityNotices(manager: McpManager): string[];
|
|
53
|
+
/** Wire shutdown cleanup: closes every transport on session_shutdown (any reason). */
|
|
54
|
+
export declare function wireShutdown(pi: ExtensionAPI, manager: McpManager): void;
|
|
55
|
+
//# sourceMappingURL=startup.d.ts.map
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session wiring for MCP: load config, apply the approval gate and the
|
|
3
|
+
* kill-switch, connect servers, register tools/prompts/panel, and tear it
|
|
4
|
+
* all down on session_shutdown. Everything is fail-soft — a broken server
|
|
5
|
+
* or a broken config file never blocks the session.
|
|
6
|
+
*
|
|
7
|
+
* Startup notify (one line when a configured server failed to connect) is a
|
|
8
|
+
* deliberate deviation from Claude Code, which stays silent on failures.
|
|
9
|
+
*
|
|
10
|
+
* `YAGNI_CODE_MCP_DISABLED=1` skips the whole subsystem (kill-switch for
|
|
11
|
+
* incidents / suspicious servers), mirroring CC's MCP_AUTO_CONNECT killer
|
|
12
|
+
* but namespaced to this CLI.
|
|
13
|
+
*/
|
|
14
|
+
import { loadMcpServers, resolveProjectRoot } from "./config.js";
|
|
15
|
+
import { McpManager } from "./manager.js";
|
|
16
|
+
import { readProjectApproval, undecidedProjectServers } from "./approval.js";
|
|
17
|
+
import { registerMcpPanel } from "./panel.js";
|
|
18
|
+
import { registerServerPrompts } from "./prompts.js";
|
|
19
|
+
import { registerServerTools } from "./tools.js";
|
|
20
|
+
// Re-export the log helpers from the shared module so callers (auth.ts) can
|
|
21
|
+
// import logging without taking a dependency on the session wiring that was
|
|
22
|
+
// their previous home.
|
|
23
|
+
export { _setMcpLogHomeForTest, appendMcpLogLine } from "./log.js";
|
|
24
|
+
import { appendMcpLogLine } from "./log.js";
|
|
25
|
+
export function isMcpDisabled(env = process.env) {
|
|
26
|
+
return env.YAGNI_CODE_MCP_DISABLED === "1";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The full session-start sequence. Pure with respect to config homes (tests
|
|
30
|
+
* point them at tmpdirs via _setMcpHomeForTest / _setMcpLogHomeForTest).
|
|
31
|
+
*/
|
|
32
|
+
export async function startMcp(pi, opts) {
|
|
33
|
+
const env = opts.env ?? process.env;
|
|
34
|
+
const outcome = {
|
|
35
|
+
manager: new McpManager({
|
|
36
|
+
events: { onLog: appendMcpLogLine },
|
|
37
|
+
env,
|
|
38
|
+
}),
|
|
39
|
+
connectedServers: [],
|
|
40
|
+
failedServers: [],
|
|
41
|
+
needsAuthServers: [],
|
|
42
|
+
skippedApproval: [],
|
|
43
|
+
configErrors: [],
|
|
44
|
+
mutatingToolNames: [],
|
|
45
|
+
};
|
|
46
|
+
if (isMcpDisabled(env)) {
|
|
47
|
+
appendMcpLogLine(JSON.stringify({ ts: new Date().toISOString(), event: "disabled_by_env" }));
|
|
48
|
+
return outcome;
|
|
49
|
+
}
|
|
50
|
+
const repoRoot = resolveProjectRoot(opts.cwd);
|
|
51
|
+
const { servers, errors } = loadMcpServers(opts.cwd, env);
|
|
52
|
+
outcome.configErrors = errors.map((e) => (e.serverName ? `${e.serverName}: ${e.message}` : e.message));
|
|
53
|
+
registerMcpPanel(pi, outcome.manager, () => outcome.configErrors);
|
|
54
|
+
if (servers.length === 0 && errors.length === 0)
|
|
55
|
+
return outcome;
|
|
56
|
+
// Approval gate for project-scope servers (fail-closed: undecided = skip).
|
|
57
|
+
const { state } = readProjectApproval(repoRoot);
|
|
58
|
+
const projectNames = servers.filter((s) => s.scope === "project").map((s) => s.name);
|
|
59
|
+
const undecided = undecidedProjectServers(state, projectNames);
|
|
60
|
+
outcome.skippedApproval = undecided;
|
|
61
|
+
const gate = (server) => {
|
|
62
|
+
if (server.scope !== "project")
|
|
63
|
+
return true;
|
|
64
|
+
return !undecided.includes(server.name);
|
|
65
|
+
};
|
|
66
|
+
// Ask once, interactively, about undecided project servers (CC parity).
|
|
67
|
+
if (undecided.length > 0 && opts.hasUI && opts.notify) {
|
|
68
|
+
opts.notify(`.mcp.json in this repo defines MCP server(s) ${undecided.join(", ")} — not yet approved. ` +
|
|
69
|
+
`Run /mcp to approve them individually, or \`yagni mcp reset-project-choices\` to clear old choices.`);
|
|
70
|
+
}
|
|
71
|
+
const connectable = [];
|
|
72
|
+
for (const server of servers) {
|
|
73
|
+
if (!gate(server)) {
|
|
74
|
+
outcome.manager.register(server.name, server.scope, server.config, "disabled");
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
connectable.push(server);
|
|
78
|
+
}
|
|
79
|
+
// Connect in parallel; each server fails soft.
|
|
80
|
+
const results = await Promise.all(connectable.map(async (server) => {
|
|
81
|
+
const managed = outcome.manager.register(server.name, server.scope, server.config, "connecting");
|
|
82
|
+
const connected = await outcome.manager.connect(server.name);
|
|
83
|
+
if (connected?.status === "connected") {
|
|
84
|
+
const tools = await registerServerTools(pi, outcome.manager, server.name, env);
|
|
85
|
+
const prompts = await registerServerPrompts(pi, outcome.manager, server.name);
|
|
86
|
+
outcome.mutatingToolNames.push(...tools.mutatingToolNames);
|
|
87
|
+
for (const w of [...tools.warnings, ...prompts.warnings])
|
|
88
|
+
appendMcpLogLine(JSON.stringify({ ts: new Date().toISOString(), server: server.name, event: "registration_warning", warning: w }));
|
|
89
|
+
return { name: server.name, ok: true, toolCount: tools.tools.length, promptCount: prompts.commands.length };
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
name: server.name,
|
|
93
|
+
ok: false,
|
|
94
|
+
needsAuth: connected?.status === "needs_auth",
|
|
95
|
+
error: connected?.error ?? "connect failed",
|
|
96
|
+
toolCount: 0,
|
|
97
|
+
promptCount: 0,
|
|
98
|
+
};
|
|
99
|
+
}));
|
|
100
|
+
for (const r of results) {
|
|
101
|
+
if (r.ok) {
|
|
102
|
+
outcome.connectedServers.push(r.name);
|
|
103
|
+
appendMcpLogLine(JSON.stringify({ ts: new Date().toISOString(), server: r.name, event: "connected", tools: r.toolCount, prompts: r.promptCount }));
|
|
104
|
+
}
|
|
105
|
+
else if ("needsAuth" in r && r.needsAuth) {
|
|
106
|
+
outcome.needsAuthServers.push(r.name);
|
|
107
|
+
appendMcpLogLine(JSON.stringify({ ts: new Date().toISOString(), server: r.name, event: "needs_auth" }));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
outcome.failedServers.push({ name: r.name, error: r.error });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return outcome;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Derive the connectivity startup notices from the manager's LIVE state.
|
|
117
|
+
*
|
|
118
|
+
* These must NOT be frozen at `startMcp()` time: the index.ts wiring defers
|
|
119
|
+
* them to the first provider response, and by then the user may have already
|
|
120
|
+
* healed a server via `/mcp` (e.g. authenticated a `needs_auth` server). A
|
|
121
|
+
* server that has since reached `connected` is no longer "needs authentication"
|
|
122
|
+
* or "failed to connect", so recomputing against `manager.list()` at flush
|
|
123
|
+
* time is what keeps the banner honest.
|
|
124
|
+
*/
|
|
125
|
+
export function deriveStartupConnectivityNotices(manager) {
|
|
126
|
+
const failed = [];
|
|
127
|
+
const needsAuth = [];
|
|
128
|
+
for (const server of manager.list()) {
|
|
129
|
+
if (server.status === "failed")
|
|
130
|
+
failed.push(server.name);
|
|
131
|
+
else if (server.status === "needs_auth")
|
|
132
|
+
needsAuth.push(server.name);
|
|
133
|
+
}
|
|
134
|
+
const notices = [];
|
|
135
|
+
if (failed.length > 0) {
|
|
136
|
+
notices.push(`MCP server${failed.length === 1 ? "" : "s"} ${failed.join(", ")} failed to connect — run /mcp to inspect or reconnect.`);
|
|
137
|
+
}
|
|
138
|
+
if (needsAuth.length > 0) {
|
|
139
|
+
notices.push(`MCP server${needsAuth.length === 1 ? "" : "s"} ${needsAuth.join(", ")} needs authentication — run /mcp to authenticate.`);
|
|
140
|
+
}
|
|
141
|
+
return notices;
|
|
142
|
+
}
|
|
143
|
+
/** Wire shutdown cleanup: closes every transport on session_shutdown (any reason). */
|
|
144
|
+
export function wireShutdown(pi, manager) {
|
|
145
|
+
pi.on("session_shutdown", async () => {
|
|
146
|
+
appendMcpLogLine(JSON.stringify({ ts: new Date().toISOString(), event: "session_shutdown" }));
|
|
147
|
+
await manager.closeAll();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=startup.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registration for connected MCP servers: one pi tool per MCP tool,
|
|
3
|
+
* named mcp__<server>__<tool> (Claude Code-compatible, so muscle memory and
|
|
4
|
+
* prompts transfer). Descriptions are capped at 2048 chars like Claude Code's
|
|
5
|
+
* client; MCP inputSchemas are plain JSON Schema, which is what TypeBox
|
|
6
|
+
* schemas are at runtime, so they pass through untouched.
|
|
7
|
+
*
|
|
8
|
+
* Known v1 limitation (shared with early Claude Code): tools are registered
|
|
9
|
+
* eagerly per server — a server exposing 100 tools puts 100 tools into every
|
|
10
|
+
* request. No tool-list deferral / ToolSearch in v1.
|
|
11
|
+
*/
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { McpManager } from "./manager.js";
|
|
14
|
+
export declare const MAX_MCP_DESCRIPTION_LENGTH = 2048;
|
|
15
|
+
export interface RegisteredToolInfo {
|
|
16
|
+
toolName: string;
|
|
17
|
+
serverName: string;
|
|
18
|
+
originalName: string;
|
|
19
|
+
description: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ToolRegistrationResult {
|
|
22
|
+
tools: RegisteredToolInfo[];
|
|
23
|
+
/** Full registered tool names that look mutating (gate input). */
|
|
24
|
+
mutatingToolNames: string[];
|
|
25
|
+
warnings: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare function registerServerTools(pi: ExtensionAPI, manager: McpManager, serverName: string, env?: NodeJS.ProcessEnv): Promise<ToolRegistrationResult>;
|
|
28
|
+
export declare function capDescription(description: string): string;
|
|
29
|
+
/** Cheap heuristic in the spirit of Claude Code's input-hint check; per-tool annotations arrive via listTools only in newer servers. */
|
|
30
|
+
export declare function looksMutating(toolName: string, description?: string): boolean;
|
|
31
|
+
//# sourceMappingURL=tools.d.ts.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registration for connected MCP servers: one pi tool per MCP tool,
|
|
3
|
+
* named mcp__<server>__<tool> (Claude Code-compatible, so muscle memory and
|
|
4
|
+
* prompts transfer). Descriptions are capped at 2048 chars like Claude Code's
|
|
5
|
+
* client; MCP inputSchemas are plain JSON Schema, which is what TypeBox
|
|
6
|
+
* schemas are at runtime, so they pass through untouched.
|
|
7
|
+
*
|
|
8
|
+
* Known v1 limitation (shared with early Claude Code): tools are registered
|
|
9
|
+
* eagerly per server — a server exposing 100 tools puts 100 tools into every
|
|
10
|
+
* request. No tool-list deferral / ToolSearch in v1.
|
|
11
|
+
*/
|
|
12
|
+
import { toolTimeoutFromEnv } from "./manager.js";
|
|
13
|
+
import { buildMcpToolName } from "./names.js";
|
|
14
|
+
export const MAX_MCP_DESCRIPTION_LENGTH = 2048;
|
|
15
|
+
export async function registerServerTools(pi, manager, serverName, env = process.env) {
|
|
16
|
+
const server = manager.get(serverName);
|
|
17
|
+
const result = { tools: [], mutatingToolNames: [], warnings: [] };
|
|
18
|
+
if (!server?.client)
|
|
19
|
+
return result;
|
|
20
|
+
let toolList;
|
|
21
|
+
try {
|
|
22
|
+
toolList = await server.client.listTools();
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
result.warnings.push(`${serverName}: listTools failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
const toolTimeout = toolTimeoutFromEnv(env);
|
|
29
|
+
for (const tool of toolList.tools ?? []) {
|
|
30
|
+
const fullToolName = buildMcpToolName(serverName, tool.name);
|
|
31
|
+
if (!fullToolName) {
|
|
32
|
+
result.warnings.push(`${serverName}: tool "${tool.name}" produced an empty wire name; skipped`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const description = capDescription(`${tool.description ?? `MCP tool ${tool.name}`} (MCP server "${serverName}")`);
|
|
36
|
+
const mutating = looksMutating(tool.name, tool.description);
|
|
37
|
+
const definition = {
|
|
38
|
+
name: fullToolName,
|
|
39
|
+
label: `${serverName}: ${tool.name}`,
|
|
40
|
+
description,
|
|
41
|
+
parameters: schemaFor(tool.inputSchema),
|
|
42
|
+
async execute(_toolCallId, params) {
|
|
43
|
+
const active = manager.get(serverName);
|
|
44
|
+
if (!active?.client) {
|
|
45
|
+
throw new Error(`MCP server "${serverName}" is not connected (try /mcp reconnect).`);
|
|
46
|
+
}
|
|
47
|
+
const args = (params && typeof params === "object" ? params : {});
|
|
48
|
+
const call = active.client.callTool({ name: tool.name, arguments: args });
|
|
49
|
+
const settled = toolTimeout
|
|
50
|
+
? await withTimeout(call, toolTimeout, `MCP tool call timed out after ${toolTimeout}ms`)
|
|
51
|
+
: await call;
|
|
52
|
+
return renderCallResult(settled, serverName, tool.name);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
pi.registerTool(definition);
|
|
56
|
+
result.tools.push({ toolName: fullToolName, serverName, originalName: tool.name, description });
|
|
57
|
+
if (mutating)
|
|
58
|
+
result.mutatingToolNames.push(fullToolName);
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
export function capDescription(description) {
|
|
63
|
+
return description.length > MAX_MCP_DESCRIPTION_LENGTH
|
|
64
|
+
? description.slice(0, MAX_MCP_DESCRIPTION_LENGTH - 1) + "…"
|
|
65
|
+
: description;
|
|
66
|
+
}
|
|
67
|
+
/** Cheap heuristic in the spirit of Claude Code's input-hint check; per-tool annotations arrive via listTools only in newer servers. */
|
|
68
|
+
export function looksMutating(toolName, description) {
|
|
69
|
+
const name = toolName.toLowerCase();
|
|
70
|
+
const writeHints = /^(create|add|update|edit|delete|remove|set|write|send|post|put|patch|deploy|publish|close|merge|assign|move|archive|trash|restore)/;
|
|
71
|
+
if (writeHints.test(name))
|
|
72
|
+
return true;
|
|
73
|
+
if (description) {
|
|
74
|
+
const d = description.toLowerCase();
|
|
75
|
+
if (/\b(create|delete|update|modify|write|send|publish|deploy|remove)s?\b/.test(d.slice(0, 400)))
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
function schemaFor(inputSchema) {
|
|
81
|
+
if (inputSchema !== null &&
|
|
82
|
+
typeof inputSchema === "object" &&
|
|
83
|
+
!Array.isArray(inputSchema) &&
|
|
84
|
+
Object.keys(inputSchema).length > 0) {
|
|
85
|
+
// MCP inputSchemas are plain JSON Schema objects, which is exactly what
|
|
86
|
+
// TypeBox schemas are at runtime — pass the server's schema through so
|
|
87
|
+
// the model sees real parameter shapes.
|
|
88
|
+
return inputSchema;
|
|
89
|
+
}
|
|
90
|
+
return { type: "object", properties: {} };
|
|
91
|
+
}
|
|
92
|
+
function renderCallResult(settled, serverName, toolName) {
|
|
93
|
+
const parts = [];
|
|
94
|
+
for (const item of settled.content ?? []) {
|
|
95
|
+
if (item && typeof item === "object" && item.type === "text") {
|
|
96
|
+
parts.push(String(item.text ?? ""));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const text = parts.join("\n") || "(no text content)";
|
|
100
|
+
if (settled.isError === true) {
|
|
101
|
+
throw new Error(`MCP tool error from "${serverName}.${toolName}": ${text}`);
|
|
102
|
+
}
|
|
103
|
+
return { content: [{ type: "text", text }], details: { server: serverName, tool: toolName } };
|
|
104
|
+
}
|
|
105
|
+
function withTimeout(promise, ms, message) {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
108
|
+
promise.then((value) => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
resolve(value);
|
|
111
|
+
}, (err) => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
reject(err);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=tools.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport construction: config shape → SDK transport instance. Kept as a
|
|
3
|
+
* separate module so tests can construct transports without a live process,
|
|
4
|
+
* and so the manager stays free of SDK import-path churn.
|
|
5
|
+
*/
|
|
6
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
9
|
+
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
10
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
11
|
+
import type { McpHttpServerConfig, McpServerConfig, McpStdioServerConfig } from "./config.js";
|
|
12
|
+
export declare const DEFAULT_CONNECT_TIMEOUT_MS = 30000;
|
|
13
|
+
export declare function stdioTransport(config: McpStdioServerConfig): StdioClientTransport;
|
|
14
|
+
export declare function httpTransport(config: McpHttpServerConfig, authProvider?: OAuthClientProvider, fetchImpl?: typeof fetch): StreamableHTTPClientTransport;
|
|
15
|
+
export declare function sseTransport(config: McpHttpServerConfig, authProvider?: OAuthClientProvider, fetchImpl?: typeof fetch): SSEClientTransport;
|
|
16
|
+
export declare function transportFor(config: McpServerConfig, authProvider?: OAuthClientProvider, fetchImpl?: typeof fetch): Transport;
|
|
17
|
+
//# sourceMappingURL=transports.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport construction: config shape → SDK transport instance. Kept as a
|
|
3
|
+
* separate module so tests can construct transports without a live process,
|
|
4
|
+
* and so the manager stays free of SDK import-path churn.
|
|
5
|
+
*/
|
|
6
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
9
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
|
|
10
|
+
export function stdioTransport(config) {
|
|
11
|
+
return new StdioClientTransport({
|
|
12
|
+
command: config.command,
|
|
13
|
+
args: config.args ?? [],
|
|
14
|
+
env: config.env,
|
|
15
|
+
stderr: "pipe",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function httpTransport(config, authProvider, fetchImpl) {
|
|
19
|
+
const url = new URL(config.url);
|
|
20
|
+
const headers = { ...config.headers };
|
|
21
|
+
return new StreamableHTTPClientTransport(url, {
|
|
22
|
+
requestInit: { headers },
|
|
23
|
+
...(authProvider ? { authProvider } : {}),
|
|
24
|
+
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function sseTransport(config, authProvider, fetchImpl) {
|
|
28
|
+
const headers = { ...config.headers };
|
|
29
|
+
return new SSEClientTransport(new URL(config.url), {
|
|
30
|
+
eventSourceInit: {},
|
|
31
|
+
requestInit: { headers },
|
|
32
|
+
...(authProvider ? { authProvider } : {}),
|
|
33
|
+
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export function transportFor(config, authProvider, fetchImpl) {
|
|
37
|
+
if (config.type === "http")
|
|
38
|
+
return httpTransport(config, authProvider, fetchImpl);
|
|
39
|
+
if (config.type === "sse")
|
|
40
|
+
return sseTransport(config, authProvider, fetchImpl);
|
|
41
|
+
// type "stdio" or absent (CC-style bare command entries)
|
|
42
|
+
return stdioTransport(config);
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=transports.js.map
|
|
@@ -53,6 +53,13 @@ export interface PermissionPolicy {
|
|
|
53
53
|
reviewConfirmTools: string[];
|
|
54
54
|
/** Consequential external writes that require fresh consent in every mode. */
|
|
55
55
|
alwaysConfirmTools?: string[];
|
|
56
|
+
/**
|
|
57
|
+
* Optional predicate: this tool is a mutating action (an MCP server tool
|
|
58
|
+
* that writes, for instance) so it joins planBlock/reviewConfirm even
|
|
59
|
+
* though it was registered dynamically and never appears in the static
|
|
60
|
+
* lists above. Evaluated AFTER the static lists (a static entry wins).
|
|
61
|
+
*/
|
|
62
|
+
isMutating?: (toolName: string) => boolean;
|
|
56
63
|
/**
|
|
57
64
|
* Optional: a recorded decision already blesses this action, so it auto-runs in
|
|
58
65
|
* review mode instead of prompting. The hook for tying the gate to captured
|
|
@@ -92,7 +92,7 @@ export function decideGate(toolName, params, mode, policy) {
|
|
|
92
92
|
}
|
|
93
93
|
return { block: false };
|
|
94
94
|
}
|
|
95
|
-
if (policy.planBlockTools.includes(toolName)) {
|
|
95
|
+
if (policy.planBlockTools.includes(toolName) || policy.isMutating?.(toolName)) {
|
|
96
96
|
return {
|
|
97
97
|
block: true,
|
|
98
98
|
reason: `plan mode: ${toolName} is a write or exec action and is held. Switch to /mode auto to apply changes.`,
|
|
@@ -142,7 +142,7 @@ export function decideGate(toolName, params, mode, policy) {
|
|
|
142
142
|
if (mode === "auto")
|
|
143
143
|
return { block: false };
|
|
144
144
|
// review
|
|
145
|
-
if (policy.reviewConfirmTools.includes(toolName)) {
|
|
145
|
+
if (policy.reviewConfirmTools.includes(toolName) || policy.isMutating?.(toolName)) {
|
|
146
146
|
if (policy.isBlessed?.(toolName, params))
|
|
147
147
|
return { block: false };
|
|
148
148
|
return { block: false, confirm: true };
|
|
@@ -314,6 +314,8 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
314
314
|
// and dropping it here silently reverts every custom policy to the
|
|
315
315
|
// default (round-2 review blocker).
|
|
316
316
|
execPolicy: basePolicy.execPolicy,
|
|
317
|
+
// Same carry-through for the mutating-tool predicate (dynamic mcp__* tools).
|
|
318
|
+
isMutating: basePolicy.isMutating,
|
|
317
319
|
isBlessed: basePolicy.isBlessed ?? ((tool, params) => blessStore?.isBlessed(tool, params) ?? false),
|
|
318
320
|
};
|
|
319
321
|
const sideEffects = sideEffectTools(effectivePolicy);
|
|
@@ -575,6 +577,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
575
577
|
void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(outcome, {
|
|
576
578
|
durationMs,
|
|
577
579
|
tier: guardianTier,
|
|
580
|
+
...(reviewResult.repaired ? { repaired: true } : {}),
|
|
578
581
|
...(rationale ? { rationale } : {}),
|
|
579
582
|
...(rawOutput ? { rawOutput } : {}),
|
|
580
583
|
debug: isDebug(),
|
|
@@ -583,7 +586,11 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
583
586
|
const verdict = reviewResult.verdict;
|
|
584
587
|
if (verdict?.outcome === "allow") {
|
|
585
588
|
guardianState.recordReview("allow");
|
|
586
|
-
|
|
589
|
+
// A repaired verdict is a REAL allow: it flows through the normal
|
|
590
|
+
// path; the diag event carries `repaired` + the pre-repair shape so
|
|
591
|
+
// telemetry can count salvage hits (the rawOutput is already
|
|
592
|
+
// scrubbed + capped at the source).
|
|
593
|
+
emitDiag("allow", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
587
594
|
emitGateEvent({
|
|
588
595
|
...eventBase,
|
|
589
596
|
outcome: "allow",
|
|
@@ -597,7 +604,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
597
604
|
if (verdict?.outcome === "deny") {
|
|
598
605
|
guardianState.recordReview("deny");
|
|
599
606
|
const rationale = verdict.rationale;
|
|
600
|
-
emitDiag("deny", rationale);
|
|
607
|
+
emitDiag("deny", rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
601
608
|
emitGateEvent({
|
|
602
609
|
...eventBase,
|
|
603
610
|
outcome: "deny",
|
|
@@ -622,7 +629,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
622
629
|
}
|
|
623
630
|
if (verdict?.outcome === "ask") {
|
|
624
631
|
guardianState.recordReview("ask");
|
|
625
|
-
emitDiag("ask", verdict.rationale);
|
|
632
|
+
emitDiag("ask", verdict.rationale, reviewResult.repaired ? reviewResult.rawOutput : undefined);
|
|
626
633
|
if (!ctx?.hasUI) {
|
|
627
634
|
// Headless (includes every /go child stage): fail closed.
|
|
628
635
|
emitGateEvent({
|
|
@@ -99,6 +99,14 @@ export interface CircuitBreakerResult {
|
|
|
99
99
|
reason?: string;
|
|
100
100
|
}
|
|
101
101
|
export declare function checkCircuitBreaker(state: GuardianState, limits: GuardianLimits): CircuitBreakerResult;
|
|
102
|
+
export interface ParsedVerdict {
|
|
103
|
+
verdict: GuardianVerdict;
|
|
104
|
+
/** True when the strict parse failed and the lenient repair ladder salvaged it. */
|
|
105
|
+
repaired: boolean;
|
|
106
|
+
}
|
|
107
|
+
export declare function parseVerdictDetailed(raw: string): ParsedVerdict | null;
|
|
108
|
+
/** Strict-shaped convenience wrapper: the verdict, or null. Callers that need
|
|
109
|
+
* the repaired signal use {@link parseVerdictDetailed}. */
|
|
102
110
|
export declare function parseVerdict(raw: string): GuardianVerdict | null;
|
|
103
111
|
export declare function formatGuardianSubtotal(state: GuardianState, limits: GuardianLimits): string;
|
|
104
112
|
export type GuardianError = "timeout" | "malformed" | "network" | "empty" | "aborted";
|
|
@@ -106,11 +114,17 @@ export interface ReviewResult {
|
|
|
106
114
|
verdict: GuardianVerdict | null;
|
|
107
115
|
error?: GuardianError;
|
|
108
116
|
cost: number;
|
|
117
|
+
/** True when the verdict came from the lenient repair ladder (strict parse
|
|
118
|
+
* failed first). The verdict is real and flows through the normal
|
|
119
|
+
* allow/ask/deny handling; this flag only marks it for telemetry. */
|
|
120
|
+
repaired?: boolean;
|
|
109
121
|
/**
|
|
110
122
|
* Scrubbed + capped copy of the model output when the verdict failed to
|
|
111
|
-
* parse (`error: "malformed"`)
|
|
112
|
-
*
|
|
113
|
-
*
|
|
123
|
+
* parse (`error: "malformed"`), or of the pre-repair extracted block when
|
|
124
|
+
* the repair ladder salvaged it (`repaired: true`). Present so the sink can
|
|
125
|
+
* capture the exact failure shape either way. Never contains the raw
|
|
126
|
+
* command unredacted: `scrubSecrets` removes secret-shaped values before
|
|
127
|
+
* this is stored.
|
|
114
128
|
*/
|
|
115
129
|
rawOutput?: string;
|
|
116
130
|
}
|
|
@@ -152,14 +166,18 @@ export interface GuardianDiagnosticEvent {
|
|
|
152
166
|
outcome: GuardianOutcome | GuardianError;
|
|
153
167
|
durationMs?: number;
|
|
154
168
|
tier?: string;
|
|
169
|
+
/** True when the lenient repair ladder salvaged a broken verdict — the
|
|
170
|
+
* outcome is still the REAL verdict (allow/ask/deny); this flag marks it
|
|
171
|
+
* for telemetry so repair hit-rate is measurable. */
|
|
172
|
+
repaired?: true;
|
|
155
173
|
/** Debug-only: command hash for correlation (never the raw command). */
|
|
156
174
|
commandHash?: string;
|
|
157
175
|
/** Debug-only: the Guardian's rationale. */
|
|
158
176
|
rationale?: string;
|
|
159
177
|
/**
|
|
160
178
|
* Scrubbed + capped copy of the unparseable model output, present only for
|
|
161
|
-
* `outcome: "malformed"`. Always-on (NOT debug-gated):
|
|
162
|
-
* `scrubSecrets`-redacted and size-capped at the source.
|
|
179
|
+
* `outcome: "malformed"` or `repaired: true`. Always-on (NOT debug-gated):
|
|
180
|
+
* it is already `scrubSecrets`-redacted and size-capped at the source.
|
|
163
181
|
*/
|
|
164
182
|
rawOutput?: string;
|
|
165
183
|
}
|
|
@@ -170,6 +188,7 @@ export interface GuardianDiagnosticEvent {
|
|
|
170
188
|
export declare function buildDiagnosticEvent(outcome: GuardianOutcome | GuardianError, opts: {
|
|
171
189
|
durationMs?: number;
|
|
172
190
|
tier?: string;
|
|
191
|
+
repaired?: boolean;
|
|
173
192
|
rationale?: string;
|
|
174
193
|
commandHash?: string;
|
|
175
194
|
rawOutput?: string;
|