@standardagents/code 0.0.2-dev.51cdb4c → 0.1.0
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 +11 -3
- package/dist/index.js +3463 -0
- package/dist/index.js.map +1 -0
- package/package.json +5 -4
- package/bin/standardcode.js +0 -25
- package/src/api.ts +0 -169
- package/src/approvals.ts +0 -42
- package/src/bridge.ts +0 -303
- package/src/credentials.ts +0 -49
- package/src/events-stream.ts +0 -99
- package/src/host-tools.ts +0 -570
- package/src/index.ts +0 -1152
- package/src/markdown.ts +0 -226
- package/src/mcp-config.ts +0 -137
- package/src/mcp.ts +0 -563
- package/src/permissions.ts +0 -53
- package/src/process-registry.ts +0 -122
- package/src/stream.ts +0 -134
- package/src/tui.ts +0 -911
- package/src/types.ts +0 -78
package/src/mcp.ts
DELETED
|
@@ -1,563 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Model Context Protocol (MCP) client + manager.
|
|
3
|
-
*
|
|
4
|
-
* The CLI is the MCP **host**: for each configured server it spawns the process
|
|
5
|
-
* locally and speaks JSON-RPC 2.0 over stdio (newline-delimited UTF-8 frames, as
|
|
6
|
-
* the MCP stdio transport specifies). It performs the real capability handshake
|
|
7
|
-
* (`initialize` → `notifications/initialized`), discovers tools and resources
|
|
8
|
-
* (`tools/list`, `resources/list`), and executes the agent's forwarded calls
|
|
9
|
-
* (`tools/call`, `resources/read`).
|
|
10
|
-
*
|
|
11
|
-
* Nothing here talks to the Standard Agents instance — the instance only ever
|
|
12
|
-
* forwards an `mcp` tool request down the bridge; this module is what turns that
|
|
13
|
-
* into a genuine MCP exchange on the user's machine and returns the result.
|
|
14
|
-
*
|
|
15
|
-
* Zero-dependency: Node globals only.
|
|
16
|
-
*/
|
|
17
|
-
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
18
|
-
import crypto from "node:crypto";
|
|
19
|
-
import type { McpServerConfig } from "./mcp-config.ts";
|
|
20
|
-
|
|
21
|
-
/** MCP protocol revision this client implements/negotiates. */
|
|
22
|
-
export const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
23
|
-
/** Protocol versions we know how to speak, newest first (for negotiation fallback). */
|
|
24
|
-
const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
25
|
-
|
|
26
|
-
const CLIENT_INFO = { name: "standard-code", version: "0.1.0" };
|
|
27
|
-
|
|
28
|
-
interface JsonRpcRequest {
|
|
29
|
-
jsonrpc: "2.0";
|
|
30
|
-
id: number;
|
|
31
|
-
method: string;
|
|
32
|
-
params?: unknown;
|
|
33
|
-
}
|
|
34
|
-
interface JsonRpcNotification {
|
|
35
|
-
jsonrpc: "2.0";
|
|
36
|
-
method: string;
|
|
37
|
-
params?: unknown;
|
|
38
|
-
}
|
|
39
|
-
interface JsonRpcResponse {
|
|
40
|
-
jsonrpc: "2.0";
|
|
41
|
-
id: number;
|
|
42
|
-
result?: unknown;
|
|
43
|
-
error?: { code: number; message: string; data?: unknown };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface McpToolDef {
|
|
47
|
-
name: string;
|
|
48
|
-
description?: string;
|
|
49
|
-
inputSchema?: Record<string, unknown>;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface McpResourceDef {
|
|
53
|
-
uri: string;
|
|
54
|
-
name?: string;
|
|
55
|
-
description?: string;
|
|
56
|
-
mimeType?: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface McpServerInfo {
|
|
60
|
-
name: string;
|
|
61
|
-
version?: string;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** A signed-ish provenance record proving which server produced a result. */
|
|
65
|
-
export interface McpAttestation {
|
|
66
|
-
/** Configured server id. */
|
|
67
|
-
server: string;
|
|
68
|
-
/** Server's self-reported identity from `initialize`. */
|
|
69
|
-
serverInfo: McpServerInfo;
|
|
70
|
-
/** Negotiated protocol version. */
|
|
71
|
-
protocolVersion: string;
|
|
72
|
-
/** Tool or resource invoked. */
|
|
73
|
-
target: string;
|
|
74
|
-
/** sha256 of the canonicalized arguments. */
|
|
75
|
-
argsSha256: string;
|
|
76
|
-
/** sha256 of the returned content. */
|
|
77
|
-
resultSha256: string;
|
|
78
|
-
/** Random per-call nonce. */
|
|
79
|
-
nonce: string;
|
|
80
|
-
/** Whether the server flagged the result as an error. */
|
|
81
|
-
isError: boolean;
|
|
82
|
-
/** Unix ms when the result was received. */
|
|
83
|
-
at: number;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export interface McpCallResult {
|
|
87
|
-
ok: boolean;
|
|
88
|
-
/** Flattened text content from the server's response. */
|
|
89
|
-
text: string;
|
|
90
|
-
attestation: McpAttestation;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Public, JSON-serializable view of a connected server (for the KV catalog). */
|
|
94
|
-
export interface McpServerCatalogEntry {
|
|
95
|
-
name: string;
|
|
96
|
-
status: "connected" | "error" | "disabled";
|
|
97
|
-
serverInfo?: McpServerInfo;
|
|
98
|
-
protocolVersion?: string;
|
|
99
|
-
instructions?: string;
|
|
100
|
-
capabilities?: Record<string, unknown>;
|
|
101
|
-
tools: McpToolDef[];
|
|
102
|
-
resources: McpResourceDef[];
|
|
103
|
-
error?: string;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const RPC_TIMEOUT_MS = 30_000;
|
|
107
|
-
// Generous: a first `npx -y <server>` cold-start downloads the package before the
|
|
108
|
-
// server even speaks, which can take a while on a slow network.
|
|
109
|
-
const INIT_TIMEOUT_MS = 90_000;
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* One live connection to a single MCP server process. Owns the child process,
|
|
113
|
-
* the JSON-RPC framing, the pending-request map, and the handshake.
|
|
114
|
-
*/
|
|
115
|
-
export class McpClient {
|
|
116
|
-
private child: ChildProcessWithoutNullStreams | null = null;
|
|
117
|
-
private nextId = 1;
|
|
118
|
-
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
119
|
-
private buffer = "";
|
|
120
|
-
private closed = false;
|
|
121
|
-
|
|
122
|
-
serverInfo: McpServerInfo;
|
|
123
|
-
protocolVersion = MCP_PROTOCOL_VERSION;
|
|
124
|
-
capabilities: Record<string, unknown> = {};
|
|
125
|
-
instructions = "";
|
|
126
|
-
tools: McpToolDef[] = [];
|
|
127
|
-
resources: McpResourceDef[] = [];
|
|
128
|
-
lastError: string | null = null;
|
|
129
|
-
|
|
130
|
-
constructor(private config: McpServerConfig) {
|
|
131
|
-
this.serverInfo = { name: config.name };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Spawn the server, run the initialize handshake, and discover capabilities. */
|
|
135
|
-
async connect(defaultCwd: string): Promise<void> {
|
|
136
|
-
const child = spawn(this.config.command, this.config.args, {
|
|
137
|
-
cwd: this.config.cwd || defaultCwd,
|
|
138
|
-
env: { ...process.env, ...(this.config.env || {}) },
|
|
139
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
140
|
-
});
|
|
141
|
-
this.child = child;
|
|
142
|
-
|
|
143
|
-
child.on("error", (err) => this.failAll(new Error(`MCP server '${this.config.name}' failed to start: ${err.message}`)));
|
|
144
|
-
child.on("exit", (code) => {
|
|
145
|
-
if (!this.closed) this.failAll(new Error(`MCP server '${this.config.name}' exited (code ${code ?? "unknown"}).`));
|
|
146
|
-
});
|
|
147
|
-
child.stdout.setEncoding("utf8");
|
|
148
|
-
child.stdout.on("data", (chunk: string) => this.onData(chunk));
|
|
149
|
-
// Surface server stderr only on failure; many servers log freely to stderr.
|
|
150
|
-
child.stderr.setEncoding("utf8");
|
|
151
|
-
let stderrTail = "";
|
|
152
|
-
child.stderr.on("data", (d: string) => {
|
|
153
|
-
stderrTail = (stderrTail + d).slice(-2000);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
try {
|
|
157
|
-
const initResult = (await this.request(
|
|
158
|
-
"initialize",
|
|
159
|
-
{
|
|
160
|
-
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
161
|
-
capabilities: { tools: {}, resources: {} },
|
|
162
|
-
clientInfo: CLIENT_INFO,
|
|
163
|
-
},
|
|
164
|
-
INIT_TIMEOUT_MS
|
|
165
|
-
)) as {
|
|
166
|
-
protocolVersion?: string;
|
|
167
|
-
capabilities?: Record<string, unknown>;
|
|
168
|
-
serverInfo?: McpServerInfo;
|
|
169
|
-
instructions?: string;
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
const negotiated = initResult.protocolVersion || MCP_PROTOCOL_VERSION;
|
|
173
|
-
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(negotiated)) {
|
|
174
|
-
// The server insists on a revision we don't implement. Per the spec the
|
|
175
|
-
// host should disconnect rather than guess at an incompatible wire format.
|
|
176
|
-
throw new Error(
|
|
177
|
-
`Server requires unsupported MCP protocol version '${negotiated}' (this client speaks ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")}).`
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
this.protocolVersion = negotiated;
|
|
181
|
-
this.capabilities = initResult.capabilities || {};
|
|
182
|
-
this.serverInfo = initResult.serverInfo || { name: this.config.name };
|
|
183
|
-
this.instructions = initResult.instructions || "";
|
|
184
|
-
|
|
185
|
-
// Tell the server the handshake is complete before issuing other calls.
|
|
186
|
-
this.notify("notifications/initialized");
|
|
187
|
-
|
|
188
|
-
// Discover what the server offers (guarded by advertised capabilities).
|
|
189
|
-
if (this.capabilities.tools) await this.refreshTools();
|
|
190
|
-
if (this.capabilities.resources) await this.refreshResources();
|
|
191
|
-
} catch (err) {
|
|
192
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
193
|
-
this.lastError = stderrTail ? `${msg}\n${stderrTail.trim()}` : msg;
|
|
194
|
-
this.close();
|
|
195
|
-
throw new Error(this.lastError);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async refreshTools(): Promise<void> {
|
|
200
|
-
const res = (await this.request("tools/list", {})) as { tools?: McpToolDef[] };
|
|
201
|
-
this.tools = Array.isArray(res?.tools) ? res.tools : [];
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async refreshResources(): Promise<void> {
|
|
205
|
-
try {
|
|
206
|
-
const res = (await this.request("resources/list", {})) as { resources?: McpResourceDef[] };
|
|
207
|
-
this.resources = Array.isArray(res?.resources) ? res.resources : [];
|
|
208
|
-
} catch {
|
|
209
|
-
this.resources = []; // resources are optional; tolerate servers that decline
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Call a tool and return its flattened text + a provenance attestation. */
|
|
214
|
-
async callTool(name: string, args: Record<string, unknown>): Promise<McpCallResult> {
|
|
215
|
-
const res = (await this.request("tools/call", { name, arguments: args })) as {
|
|
216
|
-
content?: Array<Record<string, unknown>>;
|
|
217
|
-
isError?: boolean;
|
|
218
|
-
structuredContent?: unknown;
|
|
219
|
-
};
|
|
220
|
-
const text = flattenContent(res?.content, res?.structuredContent);
|
|
221
|
-
return this.attest(name, args, text, !!res?.isError);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** Read a resource and return its flattened text + attestation. */
|
|
225
|
-
async readResource(uri: string): Promise<McpCallResult> {
|
|
226
|
-
const res = (await this.request("resources/read", { uri })) as {
|
|
227
|
-
contents?: Array<Record<string, unknown>>;
|
|
228
|
-
};
|
|
229
|
-
const text = flattenResourceContents(res?.contents);
|
|
230
|
-
return this.attest(uri, { uri }, text, false);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
private attest(target: string, args: Record<string, unknown>, text: string, isError: boolean): McpCallResult {
|
|
234
|
-
const attestation: McpAttestation = {
|
|
235
|
-
server: this.config.name,
|
|
236
|
-
serverInfo: this.serverInfo,
|
|
237
|
-
protocolVersion: this.protocolVersion,
|
|
238
|
-
target,
|
|
239
|
-
argsSha256: sha256(canonicalJson(args)),
|
|
240
|
-
resultSha256: sha256(text),
|
|
241
|
-
nonce: crypto.randomBytes(8).toString("hex"),
|
|
242
|
-
isError,
|
|
243
|
-
at: Date.now(),
|
|
244
|
-
};
|
|
245
|
-
return { ok: !isError, text, attestation };
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
catalogEntry(): McpServerCatalogEntry {
|
|
249
|
-
return {
|
|
250
|
-
name: this.config.name,
|
|
251
|
-
status: this.lastError ? "error" : "connected",
|
|
252
|
-
serverInfo: this.serverInfo,
|
|
253
|
-
protocolVersion: this.protocolVersion,
|
|
254
|
-
instructions: this.instructions || undefined,
|
|
255
|
-
capabilities: this.capabilities,
|
|
256
|
-
tools: this.tools,
|
|
257
|
-
resources: this.resources,
|
|
258
|
-
error: this.lastError || undefined,
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
close(): void {
|
|
263
|
-
this.closed = true;
|
|
264
|
-
this.failAll(new Error("connection closed"));
|
|
265
|
-
try {
|
|
266
|
-
this.child?.stdin.end();
|
|
267
|
-
} catch {
|
|
268
|
-
/* ignore */
|
|
269
|
-
}
|
|
270
|
-
try {
|
|
271
|
-
this.child?.kill("SIGTERM");
|
|
272
|
-
} catch {
|
|
273
|
-
/* ignore */
|
|
274
|
-
}
|
|
275
|
-
this.child = null;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// ── JSON-RPC plumbing ─────────────────────────────────────────────────────
|
|
279
|
-
|
|
280
|
-
private request(method: string, params: unknown, timeoutMs = RPC_TIMEOUT_MS): Promise<unknown> {
|
|
281
|
-
return new Promise((resolve, reject) => {
|
|
282
|
-
if (!this.child || this.closed) {
|
|
283
|
-
reject(new Error(`MCP server '${this.config.name}' is not connected.`));
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
const id = this.nextId++;
|
|
287
|
-
const payload: JsonRpcRequest = { jsonrpc: "2.0", id, method, params };
|
|
288
|
-
const timer = setTimeout(() => {
|
|
289
|
-
this.pending.delete(id);
|
|
290
|
-
reject(new Error(`MCP request '${method}' to '${this.config.name}' timed out after ${timeoutMs}ms.`));
|
|
291
|
-
}, timeoutMs);
|
|
292
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
293
|
-
this.write(payload);
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
private notify(method: string, params?: unknown): void {
|
|
298
|
-
const payload: JsonRpcNotification = { jsonrpc: "2.0", method, params };
|
|
299
|
-
this.write(payload);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
private write(payload: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse): void {
|
|
303
|
-
if (!this.child) return;
|
|
304
|
-
try {
|
|
305
|
-
// Newline-delimited JSON: one message per line, no embedded newlines.
|
|
306
|
-
this.child.stdin.write(JSON.stringify(payload) + "\n");
|
|
307
|
-
} catch (err) {
|
|
308
|
-
this.failAll(err instanceof Error ? err : new Error(String(err)));
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
private onData(chunk: string): void {
|
|
313
|
-
this.buffer += chunk;
|
|
314
|
-
let nl: number;
|
|
315
|
-
while ((nl = this.buffer.indexOf("\n")) >= 0) {
|
|
316
|
-
const line = this.buffer.slice(0, nl).trim();
|
|
317
|
-
this.buffer = this.buffer.slice(nl + 1);
|
|
318
|
-
if (!line) continue;
|
|
319
|
-
let msg: JsonRpcResponse & JsonRpcRequest & JsonRpcNotification;
|
|
320
|
-
try {
|
|
321
|
-
msg = JSON.parse(line);
|
|
322
|
-
} catch {
|
|
323
|
-
continue; // ignore non-JSON noise (some servers print banners)
|
|
324
|
-
}
|
|
325
|
-
this.dispatch(msg);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
private dispatch(msg: JsonRpcResponse & Partial<JsonRpcRequest>): void {
|
|
330
|
-
// A response to one of our requests.
|
|
331
|
-
if (typeof msg.id === "number" && (("result" in msg) || ("error" in msg)) && msg.method === undefined) {
|
|
332
|
-
const entry = this.pending.get(msg.id);
|
|
333
|
-
if (!entry) return;
|
|
334
|
-
this.pending.delete(msg.id);
|
|
335
|
-
clearTimeout(entry.timer);
|
|
336
|
-
if (msg.error) entry.reject(new Error(`${msg.error.message} (code ${msg.error.code})`));
|
|
337
|
-
else entry.resolve(msg.result);
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
// A request FROM the server to us. We don't offer sampling/roots, so reply
|
|
341
|
-
// politely: answer pings, decline everything else with method-not-found so
|
|
342
|
-
// the server never hangs waiting on us.
|
|
343
|
-
if (msg.method && typeof msg.id === "number") {
|
|
344
|
-
if (msg.method === "ping") {
|
|
345
|
-
this.write({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
346
|
-
} else {
|
|
347
|
-
this.write({
|
|
348
|
-
jsonrpc: "2.0",
|
|
349
|
-
id: msg.id,
|
|
350
|
-
error: { code: -32601, message: `Method not supported by host: ${msg.method}` },
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
// A notification from the server (e.g. tools/list_changed) — refresh lazily.
|
|
356
|
-
if (msg.method && msg.id === undefined) {
|
|
357
|
-
if (msg.method === "notifications/tools/list_changed") void this.refreshTools().catch(() => {});
|
|
358
|
-
if (msg.method === "notifications/resources/list_changed") void this.refreshResources().catch(() => {});
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
private failAll(err: Error): void {
|
|
363
|
-
for (const [, entry] of this.pending) {
|
|
364
|
-
clearTimeout(entry.timer);
|
|
365
|
-
entry.reject(err);
|
|
366
|
-
}
|
|
367
|
-
this.pending.clear();
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Manages every configured MCP server connection for a session: connects the
|
|
373
|
-
* enabled ones, exposes a catalog for context injection, and dispatches the
|
|
374
|
-
* agent's forwarded `mcp` tool calls to the right client.
|
|
375
|
-
*/
|
|
376
|
-
export class McpManager {
|
|
377
|
-
private clients = new Map<string, McpClient>();
|
|
378
|
-
/** Last attestations produced this session (most recent last). */
|
|
379
|
-
private attestations: McpAttestation[] = [];
|
|
380
|
-
|
|
381
|
-
constructor(private defaultCwd: string) {}
|
|
382
|
-
|
|
383
|
-
/** Connect one server (replacing any existing client of the same name). */
|
|
384
|
-
async connect(config: McpServerConfig): Promise<McpClient> {
|
|
385
|
-
this.disconnect(config.name);
|
|
386
|
-
const client = new McpClient(config);
|
|
387
|
-
this.clients.set(config.name, client);
|
|
388
|
-
await client.connect(this.defaultCwd);
|
|
389
|
-
return client;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
disconnect(name: string): void {
|
|
393
|
-
const existing = this.clients.get(name);
|
|
394
|
-
if (existing) {
|
|
395
|
-
existing.close();
|
|
396
|
-
this.clients.delete(name);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
closeAll(): void {
|
|
401
|
-
for (const [, c] of this.clients) c.close();
|
|
402
|
-
this.clients.clear();
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
get(name: string): McpClient | undefined {
|
|
406
|
-
return this.clients.get(name);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
connectedNames(): string[] {
|
|
410
|
-
return Array.from(this.clients.keys()).sort();
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
toolCount(): number {
|
|
414
|
-
let n = 0;
|
|
415
|
-
for (const [, c] of this.clients) n += c.tools.length;
|
|
416
|
-
return n;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/** A JSON-serializable catalog of every connected server for the KV/context. */
|
|
420
|
-
catalog(): { servers: McpServerCatalogEntry[]; generatedAt: number } {
|
|
421
|
-
return {
|
|
422
|
-
servers: Array.from(this.clients.values()).map((c) => c.catalogEntry()),
|
|
423
|
-
generatedAt: Date.now(),
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
recentAttestations(n = 10): McpAttestation[] {
|
|
428
|
-
return this.attestations.slice(-n);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/** Execute a forwarded `mcp` tool request and return host-shaped text. */
|
|
432
|
-
async dispatch(args: Record<string, unknown>): Promise<{ ok: boolean; result?: string; error?: string }> {
|
|
433
|
-
const action = String(args.action || "call");
|
|
434
|
-
const serverName = typeof args.server === "string" ? args.server : "";
|
|
435
|
-
|
|
436
|
-
if (action === "list") {
|
|
437
|
-
// A concise inventory of connected MCP servers (name + tool count) — what
|
|
438
|
-
// the agent should consult to answer "which MCP servers are installed?".
|
|
439
|
-
const servers = this.catalog().servers.map((s) => ({
|
|
440
|
-
name: s.name,
|
|
441
|
-
tools: s.tools.length,
|
|
442
|
-
resources: s.resources?.length ?? 0,
|
|
443
|
-
}));
|
|
444
|
-
return {
|
|
445
|
-
ok: true,
|
|
446
|
-
result: servers.length
|
|
447
|
-
? JSON.stringify({ servers }, null, 2)
|
|
448
|
-
: "No MCP servers are connected. The user can add one with the /mcp command, or you can install one with install_mcp.",
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
if (action === "list_tools") {
|
|
453
|
-
// No server → list everything; a server → just that one.
|
|
454
|
-
const cat = this.catalog().servers.filter((s) => !serverName || s.name === serverName);
|
|
455
|
-
if (!cat.length) return { ok: false, error: serverName ? `No connected MCP server named '${serverName}'.` : "No MCP servers are connected. The user can add one with the /mcp command." };
|
|
456
|
-
return { ok: true, result: JSON.stringify(cat, null, 2) };
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const client = this.clients.get(serverName);
|
|
460
|
-
if (!client) {
|
|
461
|
-
const avail = this.connectedNames();
|
|
462
|
-
return {
|
|
463
|
-
ok: false,
|
|
464
|
-
error: avail.length
|
|
465
|
-
? `No connected MCP server named '${serverName}'. Connected servers: ${avail.join(", ")}.`
|
|
466
|
-
: `No MCP servers are connected. The user can add one with the /mcp command.`,
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
try {
|
|
471
|
-
let res: McpCallResult;
|
|
472
|
-
if (action === "read_resource") {
|
|
473
|
-
const uri = String(args.uri || "");
|
|
474
|
-
if (!uri) return { ok: false, error: "read_resource requires a 'uri'." };
|
|
475
|
-
res = await client.readResource(uri);
|
|
476
|
-
} else {
|
|
477
|
-
const toolName = String(args.tool || "");
|
|
478
|
-
if (!toolName) return { ok: false, error: "call requires a 'tool' name." };
|
|
479
|
-
const callArgs = parseArgs(args.arguments_json);
|
|
480
|
-
if (callArgs instanceof Error) return { ok: false, error: callArgs.message };
|
|
481
|
-
res = await client.callTool(toolName, callArgs);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
this.attestations.push(res.attestation);
|
|
485
|
-
const footer = formatAttestation(res.attestation);
|
|
486
|
-
if (!res.ok) {
|
|
487
|
-
return { ok: false, error: `${res.text || "The MCP tool reported an error."}\n${footer}` };
|
|
488
|
-
}
|
|
489
|
-
return { ok: true, result: `${res.text}\n${footer}` };
|
|
490
|
-
} catch (err) {
|
|
491
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
497
|
-
|
|
498
|
-
function parseArgs(raw: unknown): Record<string, unknown> | Error {
|
|
499
|
-
if (raw == null || raw === "") return {};
|
|
500
|
-
if (typeof raw === "object") return raw as Record<string, unknown>;
|
|
501
|
-
if (typeof raw !== "string") return new Error("arguments_json must be a JSON object string.");
|
|
502
|
-
try {
|
|
503
|
-
const parsed = JSON.parse(raw);
|
|
504
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
|
|
505
|
-
return new Error("arguments_json must encode a JSON object.");
|
|
506
|
-
} catch (e) {
|
|
507
|
-
return new Error(`arguments_json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
/** Flatten MCP tool-call `content` blocks into a single text string. */
|
|
512
|
-
function flattenContent(content: Array<Record<string, unknown>> | undefined, structured: unknown): string {
|
|
513
|
-
const parts: string[] = [];
|
|
514
|
-
for (const block of content || []) {
|
|
515
|
-
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
516
|
-
else if (block.type === "resource" && block.resource && typeof block.resource === "object") {
|
|
517
|
-
const r = block.resource as Record<string, unknown>;
|
|
518
|
-
if (typeof r.text === "string") parts.push(r.text);
|
|
519
|
-
else parts.push(`[resource ${String(r.uri ?? "")}]`);
|
|
520
|
-
} else if (block.type === "image") parts.push(`[image ${String(block.mimeType ?? "")}]`);
|
|
521
|
-
else if (block.type === "audio") parts.push(`[audio ${String(block.mimeType ?? "")}]`);
|
|
522
|
-
else parts.push(JSON.stringify(block));
|
|
523
|
-
}
|
|
524
|
-
if (!parts.length && structured !== undefined) parts.push(JSON.stringify(structured, null, 2));
|
|
525
|
-
return parts.join("\n").trim();
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
function flattenResourceContents(contents: Array<Record<string, unknown>> | undefined): string {
|
|
529
|
-
const parts: string[] = [];
|
|
530
|
-
for (const c of contents || []) {
|
|
531
|
-
if (typeof c.text === "string") parts.push(c.text);
|
|
532
|
-
else if (typeof c.blob === "string") parts.push(`[binary resource ${String(c.uri ?? "")} (${c.blob.length} b64 chars)]`);
|
|
533
|
-
}
|
|
534
|
-
return parts.join("\n").trim();
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function formatAttestation(a: McpAttestation): string {
|
|
538
|
-
const id = `${a.serverInfo.name}${a.serverInfo.version ? `@${a.serverInfo.version}` : ""}`;
|
|
539
|
-
return (
|
|
540
|
-
`[attestation] server=${a.server} (${id}) protocol=${a.protocolVersion} ` +
|
|
541
|
-
`target=${a.target} args_sha256=${a.argsSha256.slice(0, 16)} ` +
|
|
542
|
-
`result_sha256=${a.resultSha256.slice(0, 16)} nonce=${a.nonce}`
|
|
543
|
-
);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/** Stable JSON for hashing (sorted keys) so identical args hash identically. */
|
|
547
|
-
function canonicalJson(value: unknown): string {
|
|
548
|
-
return JSON.stringify(sortKeys(value));
|
|
549
|
-
}
|
|
550
|
-
function sortKeys(value: unknown): unknown {
|
|
551
|
-
if (Array.isArray(value)) return value.map(sortKeys);
|
|
552
|
-
if (value && typeof value === "object") {
|
|
553
|
-
const out: Record<string, unknown> = {};
|
|
554
|
-
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
|
|
555
|
-
out[k] = sortKeys((value as Record<string, unknown>)[k]);
|
|
556
|
-
}
|
|
557
|
-
return out;
|
|
558
|
-
}
|
|
559
|
-
return value;
|
|
560
|
-
}
|
|
561
|
-
function sha256(input: string): string {
|
|
562
|
-
return crypto.createHash("sha256").update(input).digest("hex");
|
|
563
|
-
}
|
package/src/permissions.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Permission engine. Combines the current mode with the model-supplied risk
|
|
3
|
-
* (1–5) to decide whether a forwarded tool runs automatically, needs the user's
|
|
4
|
-
* approval, or is blocked outright.
|
|
5
|
-
*/
|
|
6
|
-
import type { Level } from "./types.ts";
|
|
7
|
-
|
|
8
|
-
export type Decision = "allow" | "ask" | "deny";
|
|
9
|
-
|
|
10
|
-
export interface PermissionState {
|
|
11
|
-
/** Auto-accept level 1–5: calls at risk ≤ level run automatically; riskier ones ask. */
|
|
12
|
-
level: Level;
|
|
13
|
-
/** Tool names the user chose to always allow this session. */
|
|
14
|
-
alwaysAllow: Set<string>;
|
|
15
|
-
/** Risk levels the user chose to always allow this session ("allow all level N"). */
|
|
16
|
-
allowRisk: Set<number>;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function decide(
|
|
20
|
-
state: PermissionState,
|
|
21
|
-
tool: string,
|
|
22
|
-
risk: number | null,
|
|
23
|
-
hasPermissionRequest: boolean
|
|
24
|
-
): Decision {
|
|
25
|
-
const effectiveRisk = typeof risk === "number" ? Math.min(5, Math.max(1, risk)) : hasPermissionRequest ? 3 : 1;
|
|
26
|
-
|
|
27
|
-
// Standing approvals the user granted this session: a specific tool, or every
|
|
28
|
-
// call at a given risk level. The risk profile is aware of both.
|
|
29
|
-
if (state.alwaysAllow.has(tool)) return "allow";
|
|
30
|
-
if (state.allowRisk.has(effectiveRisk)) return "allow";
|
|
31
|
-
|
|
32
|
-
// The auto-accept level: anything at or below it runs without asking.
|
|
33
|
-
return effectiveRisk <= state.level ? "allow" : "ask";
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Catastrophic-command guard that applies regardless of mode. These are blocked
|
|
38
|
-
* even in bypass mode — a coding agent should never be able to wipe the machine.
|
|
39
|
-
*/
|
|
40
|
-
const CATASTROPHIC_PATTERNS: RegExp[] = [
|
|
41
|
-
/\brm\s+(-[a-z]*\s+)*-[a-z]*f[a-z]*\s+(-[a-z]*\s+)*(\/|~|\$HOME|\/\*|\.\s*$|\/\s*$)/i, // rm -rf / , rm -rf ~
|
|
42
|
-
/\brm\s+-rf\s+--no-preserve-root/i,
|
|
43
|
-
/:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, // fork bomb
|
|
44
|
-
/\bmkfs(\.\w+)?\b/i, // format filesystem
|
|
45
|
-
/\bdd\b[^\n]*\bof=\/dev\/(sd|disk|nvme|hd)/i, // overwrite raw disk
|
|
46
|
-
/\b(shutdown|reboot|halt|poweroff)\b/i,
|
|
47
|
-
/>\s*\/dev\/(sd|disk|nvme|hd)/i,
|
|
48
|
-
/\bchmod\s+-R\s+(000|777)\s+\/(?:\s|$)/i,
|
|
49
|
-
];
|
|
50
|
-
|
|
51
|
-
export function isCatastrophic(command: string): boolean {
|
|
52
|
-
return CATASTROPHIC_PATTERNS.some((re) => re.test(command));
|
|
53
|
-
}
|