@yagni-app/code 1.0.4 → 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 +94 -31
- 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/execPolicy.js +17 -2
- 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
|
|
@@ -616,10 +616,20 @@ function classifySegmentTokens(rawTokens, policy, opts) {
|
|
|
616
616
|
}
|
|
617
617
|
const { tokens: strippedTokens, stripped } = stripLeadingTokens(rawTokens);
|
|
618
618
|
if (strippedTokens.length === 0) {
|
|
619
|
+
// A segment made only of environment assignments (`FOO=bar`) is a no-op: it
|
|
620
|
+
// sets shell variables and executes nothing. Recognize it so a compound like
|
|
621
|
+
// `SF="$F"; echo hi` is not floored by its leading assignment. Safety holds
|
|
622
|
+
// because classifyCommand's construct floor + substitution danger scan run
|
|
623
|
+
// afterward: an assignment whose value is a `$(...)` substitution frees the
|
|
624
|
+
// inner text for the danger scan and downgrades the overall result to prompt
|
|
625
|
+
// or forbidden there.
|
|
626
|
+
if (!opts.forbiddenOnly && rawTokens.length > 0 && rawTokens.every((t) => ENV_ASSIGNMENT_RE.test(t))) {
|
|
627
|
+
return { decision: "allow", justification: "no-op environment assignment" };
|
|
628
|
+
}
|
|
619
629
|
// env standalone: `env` or `env VAR=val` with no following command prints
|
|
620
630
|
// environment variables — a read-only operation. stripLeadingTokens removes
|
|
621
631
|
// `env` as a wrapper word, leaving empty tokens. Recognize this case instead
|
|
622
|
-
// of returning "empty command segment"
|
|
632
|
+
// of returning "empty command segment".
|
|
623
633
|
if (!opts.forbiddenOnly && rawTokens.length > 0 && basenameToken(rawTokens[0]) === "env") {
|
|
624
634
|
const onlyEnvAndAssignments = rawTokens.every((t) => basenameToken(t) === "env" || ENV_ASSIGNMENT_RE.test(t));
|
|
625
635
|
if (onlyEnvAndAssignments) {
|
|
@@ -951,6 +961,9 @@ export const DEFAULT_EXEC_POLICY = {
|
|
|
951
961
|
{ pattern: ["printf"], decision: "allow", justification: "print formatted text" },
|
|
952
962
|
{ pattern: ["whoami"], decision: "allow", justification: "show current user" },
|
|
953
963
|
{ pattern: ["uname"], decision: "allow", justification: "show system info" },
|
|
964
|
+
{ pattern: ["ps"], decision: "allow", justification: "list processes (read-only)" },
|
|
965
|
+
{ pattern: ["lsof"], decision: "allow", justification: "list open files and ports (read-only)" },
|
|
966
|
+
{ pattern: ["sleep"], decision: "allow", justification: "no-op delay" },
|
|
954
967
|
{ pattern: ["git", "status"], decision: "allow", justification: "show working tree status" },
|
|
955
968
|
{ pattern: ["git", "log"], decision: "allow", justification: "show commit log" },
|
|
956
969
|
{ pattern: ["git", "diff"], decision: "allow", justification: "show changes" },
|
|
@@ -989,6 +1002,9 @@ export const DEFAULT_EXEC_POLICY = {
|
|
|
989
1002
|
},
|
|
990
1003
|
{ pattern: ["linear", ["--help", "-h"]], decision: "allow", justification: "show Linear CLI help" },
|
|
991
1004
|
{ pattern: ["linear", ["--version", "-V"]], decision: "allow", justification: "show Linear CLI version" },
|
|
1005
|
+
{ pattern: ["linear", "user", "list"], decision: "allow", justification: "list Linear workspace members (read-only)" },
|
|
1006
|
+
{ pattern: ["linear", "team", ["list", "members", "states", "id"]], decision: "allow", justification: "read Linear team data (read-only)" },
|
|
1007
|
+
{ pattern: ["linear", "project", ["list", "view"]], decision: "allow", justification: "read Linear project data (read-only)" },
|
|
992
1008
|
// gh api — REST GET (no params, no method override) and GraphQL queries (no mutation)
|
|
993
1009
|
// gh api defaults to POST when -f/-F params are present, so params are disqualifying.
|
|
994
1010
|
// GraphQL is always POST, but queries are reads; mutations/subscriptions are disqualifying.
|
|
@@ -1065,7 +1081,6 @@ export const DEFAULT_EXEC_POLICY = {
|
|
|
1065
1081
|
{ pattern: ["tar"], decision: "prompt", justification: "archive operation" },
|
|
1066
1082
|
{ pattern: ["zip"], decision: "prompt", justification: "archive operation" },
|
|
1067
1083
|
{ pattern: ["unzip"], decision: "prompt", justification: "archive operation" },
|
|
1068
|
-
{ pattern: ["ps"], decision: "prompt", justification: "lists processes" },
|
|
1069
1084
|
{ pattern: ["kill"], decision: "prompt", justification: "sends a signal to a process" },
|
|
1070
1085
|
{ pattern: ["chmod"], decision: "prompt", justification: "permission change — review the mode" },
|
|
1071
1086
|
{ pattern: ["chown"], decision: "prompt", justification: "ownership change" },
|
|
@@ -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({
|