@sammorrowdrums/mcpi-ext 0.2.0 → 1.0.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/LICENSE +21 -0
- package/README.md +137 -47
- package/dist/code-mode/eligibility.d.ts +35 -0
- package/dist/code-mode/eligibility.js +64 -0
- package/dist/code-mode/executor.d.ts +53 -0
- package/dist/code-mode/executor.js +185 -0
- package/dist/code-mode/index.d.ts +107 -0
- package/dist/code-mode/index.js +267 -0
- package/dist/code-mode/isolated-vm.d.ts +65 -0
- package/dist/code-mode/isolated-vm.js +116 -0
- package/dist/code-mode/tools.d.ts +48 -0
- package/dist/code-mode/tools.js +73 -0
- package/dist/code-mode/type-hints.d.ts +20 -0
- package/dist/code-mode/type-hints.js +205 -0
- package/dist/docker-e2e.d.ts +54 -0
- package/dist/docker-e2e.js +234 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +227 -0
- package/dist/mcp/call-tool-result.d.ts +10 -0
- package/dist/mcp/call-tool-result.js +70 -0
- package/dist/mcp/client-factory.d.ts +59 -0
- package/dist/mcp/client-factory.js +76 -0
- package/dist/mcp/client-manager.d.ts +88 -0
- package/dist/mcp/client-manager.js +307 -0
- package/dist/mcp/config-loader.d.ts +7 -0
- package/dist/mcp/config-loader.js +42 -0
- package/dist/mcp/config.d.ts +56 -0
- package/dist/mcp/config.js +42 -0
- package/dist/mcp/gateway-defaults.d.ts +14 -0
- package/dist/mcp/gateway-defaults.js +21 -0
- package/dist/mcp/host-approval.d.ts +16 -0
- package/dist/mcp/host-approval.js +19 -0
- package/dist/mcp/host-elicitation.d.ts +19 -0
- package/dist/mcp/host-elicitation.js +94 -0
- package/dist/mcp/index.d.ts +8 -0
- package/dist/mcp/index.js +7 -0
- package/dist/mcp/policy.d.ts +314 -0
- package/dist/mcp/policy.js +848 -0
- package/dist/routing/facilities.d.ts +109 -0
- package/dist/routing/facilities.js +175 -0
- package/dist/routing/format.d.ts +24 -0
- package/dist/routing/format.js +64 -0
- package/dist/routing/index.d.ts +4 -0
- package/dist/routing/index.js +4 -0
- package/dist/routing/seam.d.ts +35 -0
- package/dist/routing/seam.js +28 -0
- package/dist/routing/tripwire.d.ts +42 -0
- package/dist/routing/tripwire.js +80 -0
- package/dist/skills/discover.d.ts +12 -0
- package/dist/skills/discover.js +85 -0
- package/dist/skills/format.d.ts +13 -0
- package/dist/skills/format.js +42 -0
- package/dist/skills/index.d.ts +6 -0
- package/dist/skills/index.js +6 -0
- package/dist/skills/load-skill-tool.d.ts +56 -0
- package/dist/skills/load-skill-tool.js +183 -0
- package/dist/skills/mcp-tool-proxy.d.ts +7 -0
- package/dist/skills/mcp-tool-proxy.js +41 -0
- package/dist/skills/sep2640/client.d.ts +91 -0
- package/dist/skills/sep2640/client.js +160 -0
- package/dist/skills/sep2640/discover.d.ts +33 -0
- package/dist/skills/sep2640/discover.js +82 -0
- package/dist/skills/sep2640/index.d.ts +13 -0
- package/dist/skills/sep2640/index.js +13 -0
- package/dist/skills/sep2640/integrity.d.ts +58 -0
- package/dist/skills/sep2640/integrity.js +185 -0
- package/dist/skills/sep2640/load.d.ts +61 -0
- package/dist/skills/sep2640/load.js +90 -0
- package/dist/skills/sep2640/protocol.d.ts +146 -0
- package/dist/skills/sep2640/protocol.js +214 -0
- package/dist/skills/sep2640/spec.d.ts +76 -0
- package/dist/skills/sep2640/spec.js +88 -0
- package/dist/skills/skill-registry.d.ts +85 -0
- package/dist/skills/skill-registry.js +85 -0
- package/dist/tool-cli/bridge.d.ts +14 -0
- package/dist/tool-cli/bridge.js +196 -0
- package/dist/tool-cli/format.d.ts +20 -0
- package/dist/tool-cli/format.js +73 -0
- package/dist/tool-cli/index.d.ts +7 -0
- package/dist/tool-cli/index.js +5 -0
- package/dist/tool-cli/provider.d.ts +21 -0
- package/dist/tool-cli/provider.js +114 -0
- package/dist/tool-cli/startup.d.ts +22 -0
- package/dist/tool-cli/startup.js +47 -0
- package/package.json +78 -26
- package/.dockerignore +0 -6
- package/.github/dependabot.yml +0 -25
- package/.github/workflows/ci.yml +0 -50
- package/.hadolint.yaml +0 -6
- package/.prettierignore +0 -4
- package/.prettierrc +0 -7
- package/AGENTS.md +0 -118
- package/DECISIONS.md +0 -99
- package/Dockerfile +0 -39
- package/aube-lock.yaml +0 -4146
- package/docs/code-mode.md +0 -38
- package/docs/skills.md +0 -57
- package/docs/tool-cli.md +0 -54
- package/eslint.config.js +0 -27
- package/images/banner.webp +0 -0
- package/images/code-c-maude.webp +0 -0
- package/images/code-mode-histogram.png +0 -0
- package/images/nuclear-mcp-football.webp +0 -0
- package/images/skills-enabling-mcp-tools.png +0 -0
- package/images/the-skill-dealer.webp +0 -0
- package/images/tool-cli-grep.png +0 -0
- package/mise.toml +0 -53
- package/scripts/docker-entrypoint.sh +0 -29
- package/scripts/test-echo-server.mjs +0 -28
- package/scripts/test-mcp-integration.mjs +0 -59
- package/scripts/test-tool-cli-smoke.mjs +0 -93
- package/src/code-mode/code-mode-integration.test.ts +0 -135
- package/src/code-mode/eligibility.test.ts +0 -60
- package/src/code-mode/eligibility.ts +0 -16
- package/src/code-mode/executor.test.ts +0 -168
- package/src/code-mode/executor.ts +0 -163
- package/src/code-mode/index.ts +0 -208
- package/src/code-mode/tools.ts +0 -110
- package/src/code-mode/type-hints.test.ts +0 -187
- package/src/code-mode/type-hints.ts +0 -249
- package/src/docker-e2e.test.ts +0 -195
- package/src/docker-e2e.ts +0 -277
- package/src/index.test.ts +0 -8
- package/src/index.ts +0 -179
- package/src/mcp/client-manager.test.ts +0 -250
- package/src/mcp/client-manager.ts +0 -199
- package/src/mcp/config-loader.test.ts +0 -115
- package/src/mcp/config-loader.ts +0 -46
- package/src/mcp/config.ts +0 -40
- package/src/mcp/index.ts +0 -4
- package/src/skills/discover.ts +0 -81
- package/src/skills/format.test.ts +0 -70
- package/src/skills/format.ts +0 -42
- package/src/skills/index.ts +0 -9
- package/src/skills/load-skill-tool.ts +0 -136
- package/src/skills/mcp-tool-proxy.ts +0 -143
- package/src/skills/skill-integration.test.ts +0 -139
- package/src/skills/skill-registry.test.ts +0 -83
- package/src/skills/skill-registry.ts +0 -69
- package/src/test-servers/weather-server.ts +0 -171
- package/src/test-servers/weather-stdio.ts +0 -11
- package/src/tool-cli/format.test.ts +0 -33
- package/src/tool-cli/format.ts +0 -56
- package/src/tool-cli/index.ts +0 -3
- package/tsconfig.json +0 -21
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { SKILLS_EXTENSION_NAME } from "../skills/sep2640/spec.js";
|
|
3
|
+
/** A denial raised by the policy boundary before any upstream MCP request. */
|
|
4
|
+
export class McpPolicyError extends Error {
|
|
5
|
+
reason;
|
|
6
|
+
source;
|
|
7
|
+
serverName;
|
|
8
|
+
toolName;
|
|
9
|
+
uri;
|
|
10
|
+
alternatives;
|
|
11
|
+
constructor(init) {
|
|
12
|
+
super(init.message);
|
|
13
|
+
this.name = "McpPolicyError";
|
|
14
|
+
this.reason = init.reason;
|
|
15
|
+
this.source = init.source;
|
|
16
|
+
this.serverName = init.serverName;
|
|
17
|
+
this.toolName = init.toolName;
|
|
18
|
+
this.uri = init.uri;
|
|
19
|
+
this.alternatives = init.alternatives ?? [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const DEFAULT_AUDIT_LIMIT = 500;
|
|
23
|
+
const NUL = "\u0000";
|
|
24
|
+
/**
|
|
25
|
+
* The single MCP policy and dispatch boundary.
|
|
26
|
+
*
|
|
27
|
+
* Every execution path — deferred/direct proxy tools, Code Mode dispatch,
|
|
28
|
+
* tool-cli provider RPC, and skill resource reads — crosses this class exactly
|
|
29
|
+
* once per operation. Discovery, gating, argument validation, permission
|
|
30
|
+
* semantics, approval, cancellation, and audit all happen here, and nothing
|
|
31
|
+
* reaches an MCP server without passing through `dispatchToolCall` /
|
|
32
|
+
* `dispatchResourceRead` below.
|
|
33
|
+
*/
|
|
34
|
+
export class McpPolicy {
|
|
35
|
+
gateway;
|
|
36
|
+
approvals;
|
|
37
|
+
auditLimit;
|
|
38
|
+
onAudit;
|
|
39
|
+
/** Tools that require an approved skill grant before any path may call them. */
|
|
40
|
+
gatedTools = new Map();
|
|
41
|
+
/** Tools unlocked by an approved, activated skill grant. */
|
|
42
|
+
enabledTools = new Set();
|
|
43
|
+
/** Approved skill grant keys, bound to server origin plus grant content. */
|
|
44
|
+
approvedGrants = new Set();
|
|
45
|
+
/** Skill URIs registered per server origin, for `skill-load` authorization. */
|
|
46
|
+
skillUrisByServer = new Map();
|
|
47
|
+
/** URIs a server itself listed in the current discovery pass. */
|
|
48
|
+
discoveredUrisByServer = new Map();
|
|
49
|
+
/**
|
|
50
|
+
* Per-skill SEP-2640 resource allowlists, keyed by `serverName\0skillUri`.
|
|
51
|
+
*
|
|
52
|
+
* A skill's declared `resources` set is the *only* thing that authorizes a
|
|
53
|
+
* `skills-extension` read. Storing it per skill rather than per server means a
|
|
54
|
+
* file listed by skill A cannot be read while "loading" skill B.
|
|
55
|
+
*/
|
|
56
|
+
extensionResourceUris = new Map();
|
|
57
|
+
audit = [];
|
|
58
|
+
sequence = 0;
|
|
59
|
+
constructor(options) {
|
|
60
|
+
this.gateway = options.gateway;
|
|
61
|
+
this.approvals = options.approvals;
|
|
62
|
+
this.auditLimit = options.auditLimit ?? DEFAULT_AUDIT_LIMIT;
|
|
63
|
+
this.onAudit = options.onAudit;
|
|
64
|
+
}
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Registration
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
/** Register discovered skills so their tools are gated and their URIs bound. */
|
|
69
|
+
registerSkills(skills) {
|
|
70
|
+
for (const skill of skills) {
|
|
71
|
+
const key = skillGrantKey(skill);
|
|
72
|
+
for (const tool of skill.allowedTools) {
|
|
73
|
+
let owners = this.gatedTools.get(tool);
|
|
74
|
+
if (!owners) {
|
|
75
|
+
owners = new Set();
|
|
76
|
+
this.gatedTools.set(tool, owners);
|
|
77
|
+
}
|
|
78
|
+
owners.add(skill.name);
|
|
79
|
+
}
|
|
80
|
+
let uris = this.skillUrisByServer.get(skill.serverName);
|
|
81
|
+
if (!uris) {
|
|
82
|
+
uris = new Set();
|
|
83
|
+
this.skillUrisByServer.set(skill.serverName, uris);
|
|
84
|
+
}
|
|
85
|
+
uris.add(skill.uri);
|
|
86
|
+
// Registration alone never approves; `key` is only recomputed on activation.
|
|
87
|
+
void key;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Bind a SEP-2640 skill's declared resource URIs so they become readable
|
|
92
|
+
* under the `skills-extension` source — and nothing else does.
|
|
93
|
+
*
|
|
94
|
+
* The set replaces any previous one for that skill, so a rotated listing
|
|
95
|
+
* narrows access immediately instead of accumulating stale grants.
|
|
96
|
+
*/
|
|
97
|
+
registerSkillResources(serverName, skillUri, resourceUris) {
|
|
98
|
+
this.extensionResourceUris.set(extensionResourceKey(serverName, skillUri), new Set(resourceUris));
|
|
99
|
+
}
|
|
100
|
+
/** Every tool name currently gated behind an unapproved skill grant. */
|
|
101
|
+
getGatedToolNames() {
|
|
102
|
+
return [...this.gatedTools.keys()].filter((name) => !this.enabledTools.has(name)).sort();
|
|
103
|
+
}
|
|
104
|
+
/** Skills that gate `toolName`, for actionable block messages. */
|
|
105
|
+
getGatingSkills(toolName) {
|
|
106
|
+
return [...(this.gatedTools.get(toolName) ?? [])].sort();
|
|
107
|
+
}
|
|
108
|
+
/** True when a tool is discovered but still requires an approved skill grant. */
|
|
109
|
+
isGated(toolName) {
|
|
110
|
+
return this.gatedTools.has(toolName) && !this.enabledTools.has(toolName);
|
|
111
|
+
}
|
|
112
|
+
reset() {
|
|
113
|
+
this.gatedTools.clear();
|
|
114
|
+
this.enabledTools.clear();
|
|
115
|
+
this.approvedGrants.clear();
|
|
116
|
+
this.skillUrisByServer.clear();
|
|
117
|
+
this.discoveredUrisByServer.clear();
|
|
118
|
+
this.extensionResourceUris.clear();
|
|
119
|
+
this.audit.length = 0;
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Visibility (what tool-cli and other discovery surfaces may see)
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
getVisibleServers() {
|
|
125
|
+
return this.gateway.getConnectedServers();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The exact discovered schema set a discovery surface may see. Gated tools
|
|
129
|
+
* are omitted entirely, so a surface cannot learn a hidden tool's name or
|
|
130
|
+
* schema and then try to call it.
|
|
131
|
+
*/
|
|
132
|
+
getVisibleTools(serverName) {
|
|
133
|
+
return this.gateway.getToolsForServer(serverName).filter((tool) => !this.isGated(tool.name));
|
|
134
|
+
}
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Skill grants
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
/**
|
|
139
|
+
* Activate an MCP-origin skill's `allowed-tools` grant.
|
|
140
|
+
*
|
|
141
|
+
* The grant is origin- and content-bound: the key covers the server name, the
|
|
142
|
+
* skill URI, and a digest of the exact tool list being granted. A previously
|
|
143
|
+
* approved identical grant is reused without re-prompting, so no path ever
|
|
144
|
+
* asks the user twice for the same authority. Declined, cancelled, and
|
|
145
|
+
* unavailable outcomes all leave the tools gated.
|
|
146
|
+
*/
|
|
147
|
+
async activateSkillGrant(skill, signal) {
|
|
148
|
+
if (skill.allowedTools.length === 0) {
|
|
149
|
+
return { status: "granted", activatedTools: [] };
|
|
150
|
+
}
|
|
151
|
+
const key = skillGrantKey(skill);
|
|
152
|
+
if (this.approvedGrants.has(key)) {
|
|
153
|
+
this.enableTools(skill.allowedTools);
|
|
154
|
+
this.record({
|
|
155
|
+
source: "skill-load",
|
|
156
|
+
operation: "skill-grant",
|
|
157
|
+
serverName: skill.serverName,
|
|
158
|
+
uri: skill.uri,
|
|
159
|
+
decision: "allowed",
|
|
160
|
+
approval: "reused",
|
|
161
|
+
});
|
|
162
|
+
return { status: "reused", activatedTools: [...skill.allowedTools] };
|
|
163
|
+
}
|
|
164
|
+
if (signal?.aborted) {
|
|
165
|
+
this.record({
|
|
166
|
+
source: "skill-load",
|
|
167
|
+
operation: "skill-grant",
|
|
168
|
+
serverName: skill.serverName,
|
|
169
|
+
uri: skill.uri,
|
|
170
|
+
decision: "denied",
|
|
171
|
+
reason: "cancelled",
|
|
172
|
+
approval: "unavailable",
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
status: "unavailable",
|
|
176
|
+
message: `Loading skill "${skill.name}" was cancelled before its tool grant could be approved. No tools were activated.`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const granted = await this.requestApproval({
|
|
180
|
+
kind: "skill-grant",
|
|
181
|
+
source: "proxy",
|
|
182
|
+
serverName: skill.serverName,
|
|
183
|
+
skillName: skill.name,
|
|
184
|
+
grantedTools: [...skill.allowedTools],
|
|
185
|
+
title: `Activate MCP skill "${skill.name}"?`,
|
|
186
|
+
message: `MCP server "${skill.serverName}" offers skill "${skill.name}" (${skill.uri}).\n` +
|
|
187
|
+
`Approving activates these tools for this session:\n` +
|
|
188
|
+
skill.allowedTools.map((tool) => ` - ${tool}`).join("\n"),
|
|
189
|
+
...(signal !== undefined ? { signal } : {}),
|
|
190
|
+
});
|
|
191
|
+
if (granted === true) {
|
|
192
|
+
this.approvedGrants.add(key);
|
|
193
|
+
this.enableTools(skill.allowedTools);
|
|
194
|
+
this.record({
|
|
195
|
+
source: "skill-load",
|
|
196
|
+
operation: "skill-grant",
|
|
197
|
+
serverName: skill.serverName,
|
|
198
|
+
uri: skill.uri,
|
|
199
|
+
decision: "allowed",
|
|
200
|
+
approval: "granted",
|
|
201
|
+
});
|
|
202
|
+
return { status: "granted", activatedTools: [...skill.allowedTools] };
|
|
203
|
+
}
|
|
204
|
+
const approval = granted === false ? "declined" : "unavailable";
|
|
205
|
+
this.record({
|
|
206
|
+
source: "skill-load",
|
|
207
|
+
operation: "skill-grant",
|
|
208
|
+
serverName: skill.serverName,
|
|
209
|
+
uri: skill.uri,
|
|
210
|
+
decision: "denied",
|
|
211
|
+
reason: granted === false ? "approval_declined" : "approval_unavailable",
|
|
212
|
+
approval,
|
|
213
|
+
});
|
|
214
|
+
const toolList = skill.allowedTools.join(", ");
|
|
215
|
+
return {
|
|
216
|
+
status: approval,
|
|
217
|
+
message: granted === false
|
|
218
|
+
? `Activation of skill "${skill.name}" was declined, so its tools (${toolList}) remain unavailable. Call load_skill again and approve the prompt to activate them.`
|
|
219
|
+
: `Activation of skill "${skill.name}" needs explicit approval, but no interactive confirmation was available, so its tools (${toolList}) remain unavailable. Run this session interactively and call load_skill again to approve.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Tool calls
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
/**
|
|
226
|
+
* Authorize and dispatch a tool call. Every execution path funnels here, and
|
|
227
|
+
* upstream is reached only after all checks below pass.
|
|
228
|
+
*/
|
|
229
|
+
async callTool(request) {
|
|
230
|
+
const { source, serverName, toolName, args, signal } = request;
|
|
231
|
+
const deny = (reason, message, alternatives) => {
|
|
232
|
+
this.record({
|
|
233
|
+
source,
|
|
234
|
+
operation: "tool",
|
|
235
|
+
serverName,
|
|
236
|
+
toolName,
|
|
237
|
+
decision: "denied",
|
|
238
|
+
reason,
|
|
239
|
+
});
|
|
240
|
+
return new McpPolicyError({
|
|
241
|
+
reason,
|
|
242
|
+
message,
|
|
243
|
+
source,
|
|
244
|
+
serverName,
|
|
245
|
+
toolName,
|
|
246
|
+
...(alternatives !== undefined ? { alternatives } : {}),
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
if (!this.gateway.getConnectedServers().includes(serverName)) {
|
|
250
|
+
throw deny("server_not_connected", `MCP server "${serverName}" is not connected, so "${toolName}" cannot be called.`);
|
|
251
|
+
}
|
|
252
|
+
// Discovery check: an undiscovered tool name never reaches upstream.
|
|
253
|
+
const tool = this.gateway
|
|
254
|
+
.getToolsForServer(serverName)
|
|
255
|
+
.find((candidate) => candidate.name === toolName);
|
|
256
|
+
if (!tool) {
|
|
257
|
+
throw deny("tool_not_discovered", `Tool "${toolName}" was not discovered on MCP server "${serverName}". Only discovered tools can be called.`);
|
|
258
|
+
}
|
|
259
|
+
// Gating check: applies to every source, so no surface can bypass a skill
|
|
260
|
+
// grant by naming a tool it was never shown.
|
|
261
|
+
if (this.isGated(toolName)) {
|
|
262
|
+
const skills = this.getGatingSkills(toolName);
|
|
263
|
+
throw deny("tool_gated", `Tool "${toolName}" requires an approved skill grant first. Call load_skill with one of: ${skills.join(", ") || "(none)"}`, ["load_skill"]);
|
|
264
|
+
}
|
|
265
|
+
const invalid = validateToolArguments(tool.inputSchema, args);
|
|
266
|
+
if (invalid) {
|
|
267
|
+
throw deny("invalid_arguments", `Arguments for "${toolName}" do not satisfy its declared input schema: ${invalid}`);
|
|
268
|
+
}
|
|
269
|
+
if (signal?.aborted) {
|
|
270
|
+
throw deny("cancelled", `Call to "${toolName}" was cancelled before dispatch.`);
|
|
271
|
+
}
|
|
272
|
+
// Permission semantics. Code Mode keeps its strict read-only contract; the
|
|
273
|
+
// interactive paths use the same annotations to drive HITL.
|
|
274
|
+
const readOnly = isReadOnlyToolCall(tool);
|
|
275
|
+
// A tool activated by an approved skill grant is already covered by that
|
|
276
|
+
// approval, so it must not prompt again on every call.
|
|
277
|
+
const grantCovered = this.enabledTools.has(toolName);
|
|
278
|
+
let approval;
|
|
279
|
+
if (source === "code-mode") {
|
|
280
|
+
if (!readOnly) {
|
|
281
|
+
throw deny("permission_denied", `Tool "${toolName}" is visible for discovery but cannot be called from Code Mode. ` +
|
|
282
|
+
"Use load_skill or tool-cli through the host's permission-aware path.", ["load_skill", "tool-cli"]);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else if (!readOnly && grantCovered) {
|
|
286
|
+
approval = "reused";
|
|
287
|
+
}
|
|
288
|
+
else if (!readOnly) {
|
|
289
|
+
const approved = await this.requestApproval({
|
|
290
|
+
kind: "tool-call",
|
|
291
|
+
source,
|
|
292
|
+
serverName,
|
|
293
|
+
toolName,
|
|
294
|
+
title: `Run MCP tool "${toolName}"?`,
|
|
295
|
+
message: formatToolApprovalMessage(source, serverName, tool, args),
|
|
296
|
+
...(signal !== undefined ? { signal } : {}),
|
|
297
|
+
});
|
|
298
|
+
if (approved !== true) {
|
|
299
|
+
const reason = approved === false ? "approval_declined" : "approval_unavailable";
|
|
300
|
+
this.record({
|
|
301
|
+
source,
|
|
302
|
+
operation: "tool",
|
|
303
|
+
serverName,
|
|
304
|
+
toolName,
|
|
305
|
+
decision: "denied",
|
|
306
|
+
reason,
|
|
307
|
+
approval: approved === false ? "declined" : "unavailable",
|
|
308
|
+
});
|
|
309
|
+
throw new McpPolicyError({
|
|
310
|
+
reason,
|
|
311
|
+
source,
|
|
312
|
+
serverName,
|
|
313
|
+
toolName,
|
|
314
|
+
message: approved === false
|
|
315
|
+
? `Running "${toolName}" was declined, so it was not executed. Re-run the tool and approve the prompt if you want it to proceed.`
|
|
316
|
+
: `Running "${toolName}" needs explicit approval because it is not annotated read-only, but no interactive confirmation was available, so it was not executed. Run this session interactively to approve it.`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
approval = "granted";
|
|
320
|
+
}
|
|
321
|
+
if (signal?.aborted) {
|
|
322
|
+
throw deny("cancelled", `Call to "${toolName}" was cancelled before dispatch.`);
|
|
323
|
+
}
|
|
324
|
+
const terminal = await this.gateway.callTool(serverName, toolName, args, signal);
|
|
325
|
+
this.record({
|
|
326
|
+
source,
|
|
327
|
+
operation: "tool",
|
|
328
|
+
serverName,
|
|
329
|
+
toolName,
|
|
330
|
+
decision: "allowed",
|
|
331
|
+
...(approval !== undefined ? { approval } : {}),
|
|
332
|
+
});
|
|
333
|
+
return terminal;
|
|
334
|
+
}
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Skills extension (draft SEP-2640)
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
/**
|
|
339
|
+
* The server's declared settings for the draft skills extension, or
|
|
340
|
+
* `undefined` when it did not declare it.
|
|
341
|
+
*
|
|
342
|
+
* Resolved fresh on every call. The extension is negotiated in `initialize`,
|
|
343
|
+
* but a reconnect can change the answer, and caching "this server supports
|
|
344
|
+
* skills" would let a stale yes outlive the negotiation that produced it.
|
|
345
|
+
*/
|
|
346
|
+
getSkillsExtension(serverName) {
|
|
347
|
+
if (!this.gateway.getConnectedServers().includes(serverName))
|
|
348
|
+
return undefined;
|
|
349
|
+
return this.gateway.getExtensionCapability(serverName, SKILLS_EXTENSION_NAME);
|
|
350
|
+
}
|
|
351
|
+
/** True when the server declared `directoryRead: true` on the extension. */
|
|
352
|
+
supportsSkillDirectoryRead(serverName) {
|
|
353
|
+
return this.getSkillsExtension(serverName)?.["directoryRead"] === true;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Draft SEP-2640 `skills/list`.
|
|
357
|
+
*
|
|
358
|
+
* Refused unless the server declared the extension: an undeclared method is
|
|
359
|
+
* not a method this host is entitled to probe for.
|
|
360
|
+
*/
|
|
361
|
+
async listMcpSkills(serverName, cursor, signal) {
|
|
362
|
+
this.assertSkillsExtension(serverName, "skill-list");
|
|
363
|
+
if (signal?.aborted) {
|
|
364
|
+
throw this.denyExtension(serverName, "skill-list", "cancelled", "skills/list was cancelled.");
|
|
365
|
+
}
|
|
366
|
+
const result = await this.gateway.requestSkillsList(serverName, cursor, signal);
|
|
367
|
+
this.record({
|
|
368
|
+
source: "skills-extension",
|
|
369
|
+
operation: "skill-list",
|
|
370
|
+
serverName,
|
|
371
|
+
decision: "allowed",
|
|
372
|
+
});
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
/** Draft SEP-2640 `skills/get`. */
|
|
376
|
+
async getMcpSkill(serverName, uri, signal) {
|
|
377
|
+
this.assertSkillsExtension(serverName, "skill-get", uri);
|
|
378
|
+
if (signal?.aborted) {
|
|
379
|
+
throw this.denyExtension(serverName, "skill-get", "cancelled", `skills/get for ${uri} was cancelled.`, uri);
|
|
380
|
+
}
|
|
381
|
+
const result = await this.gateway.requestSkillsGet(serverName, uri, signal);
|
|
382
|
+
this.record({
|
|
383
|
+
source: "skills-extension",
|
|
384
|
+
operation: "skill-get",
|
|
385
|
+
serverName,
|
|
386
|
+
uri,
|
|
387
|
+
decision: "allowed",
|
|
388
|
+
});
|
|
389
|
+
return result;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Draft SEP-2640 `resources/directory/read`.
|
|
393
|
+
*
|
|
394
|
+
* Refused when the server declared the extension without `directoryRead`.
|
|
395
|
+
* The setting defaults to false, so silence means no.
|
|
396
|
+
*/
|
|
397
|
+
async readSkillDirectory(serverName, uri, cursor, signal) {
|
|
398
|
+
this.assertSkillsExtension(serverName, "skill-directory", uri);
|
|
399
|
+
if (!this.supportsSkillDirectoryRead(serverName)) {
|
|
400
|
+
throw this.denyExtension(serverName, "skill-directory", "directory_read_unavailable", `MCP server "${serverName}" did not declare "directoryRead" on the skills extension, so ` +
|
|
401
|
+
`${uri} cannot be enumerated.`, uri);
|
|
402
|
+
}
|
|
403
|
+
if (signal?.aborted) {
|
|
404
|
+
throw this.denyExtension(serverName, "skill-directory", "cancelled", `Directory read of ${uri} was cancelled.`, uri);
|
|
405
|
+
}
|
|
406
|
+
const result = await this.gateway.requestDirectoryRead(serverName, uri, cursor, signal);
|
|
407
|
+
this.record({
|
|
408
|
+
source: "skills-extension",
|
|
409
|
+
operation: "skill-directory",
|
|
410
|
+
serverName,
|
|
411
|
+
uri,
|
|
412
|
+
decision: "allowed",
|
|
413
|
+
});
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Resources
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
/**
|
|
420
|
+
* List the resources visible to tool-cli.
|
|
421
|
+
*
|
|
422
|
+
* `skill://` is a separate authorization domain: those resources carry
|
|
423
|
+
* workflow instructions and grants, so they remain reachable only through
|
|
424
|
+
* skill discovery/load and can never be enumerated through the shell bridge.
|
|
425
|
+
*/
|
|
426
|
+
async listResources(request) {
|
|
427
|
+
const { source, serverName, signal } = request;
|
|
428
|
+
const deny = (reason, message) => {
|
|
429
|
+
this.record({
|
|
430
|
+
source,
|
|
431
|
+
operation: "resource-list",
|
|
432
|
+
serverName,
|
|
433
|
+
decision: "denied",
|
|
434
|
+
reason,
|
|
435
|
+
});
|
|
436
|
+
return new McpPolicyError({ reason, message, source, serverName });
|
|
437
|
+
};
|
|
438
|
+
if (!this.gateway.getConnectedServers().includes(serverName)) {
|
|
439
|
+
throw deny("server_not_connected", `MCP server "${serverName}" is not connected.`);
|
|
440
|
+
}
|
|
441
|
+
if (signal?.aborted) {
|
|
442
|
+
throw deny("cancelled", `Listing resources on "${serverName}" was cancelled.`);
|
|
443
|
+
}
|
|
444
|
+
const resources = await this.gateway.listResources(serverName, signal);
|
|
445
|
+
const visible = resources.filter((resource) => !this.isSkillOwnedResource(serverName, resource.uri));
|
|
446
|
+
this.record({
|
|
447
|
+
source,
|
|
448
|
+
operation: "resource-list",
|
|
449
|
+
serverName,
|
|
450
|
+
decision: "allowed",
|
|
451
|
+
});
|
|
452
|
+
return visible;
|
|
453
|
+
}
|
|
454
|
+
/** List policy-visible resource templates for tool-cli. */
|
|
455
|
+
async listResourceTemplates(request) {
|
|
456
|
+
const { source, serverName, signal } = request;
|
|
457
|
+
const deny = (reason, message) => {
|
|
458
|
+
this.record({
|
|
459
|
+
source,
|
|
460
|
+
operation: "resource-templates",
|
|
461
|
+
serverName,
|
|
462
|
+
decision: "denied",
|
|
463
|
+
reason,
|
|
464
|
+
});
|
|
465
|
+
return new McpPolicyError({ reason, message, source, serverName });
|
|
466
|
+
};
|
|
467
|
+
if (!this.gateway.getConnectedServers().includes(serverName)) {
|
|
468
|
+
throw deny("server_not_connected", `MCP server "${serverName}" is not connected.`);
|
|
469
|
+
}
|
|
470
|
+
if (signal?.aborted) {
|
|
471
|
+
throw deny("cancelled", `Listing resource templates on "${serverName}" was cancelled.`);
|
|
472
|
+
}
|
|
473
|
+
const templates = await this.gateway.listResourceTemplates(serverName, signal);
|
|
474
|
+
const visible = templates.filter((template) => !this.isSkillOwnedResource(serverName, template.uriTemplate));
|
|
475
|
+
this.record({
|
|
476
|
+
source,
|
|
477
|
+
operation: "resource-templates",
|
|
478
|
+
serverName,
|
|
479
|
+
decision: "allowed",
|
|
480
|
+
});
|
|
481
|
+
return visible;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* List a server's skill resources and remember them as this pass's discovery
|
|
485
|
+
* candidates. Only URIs seen here become readable under `skill-discovery`.
|
|
486
|
+
*/
|
|
487
|
+
async listSkillResources(serverName, signal) {
|
|
488
|
+
if (!this.gateway.getConnectedServers().includes(serverName)) {
|
|
489
|
+
this.record({
|
|
490
|
+
source: "skill-discovery",
|
|
491
|
+
operation: "resource",
|
|
492
|
+
serverName,
|
|
493
|
+
decision: "denied",
|
|
494
|
+
reason: "server_not_connected",
|
|
495
|
+
});
|
|
496
|
+
throw new McpPolicyError({
|
|
497
|
+
reason: "server_not_connected",
|
|
498
|
+
message: `MCP server "${serverName}" is not connected.`,
|
|
499
|
+
source: "skill-discovery",
|
|
500
|
+
serverName,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
if (signal?.aborted) {
|
|
504
|
+
this.record({
|
|
505
|
+
source: "skill-discovery",
|
|
506
|
+
operation: "resource-list",
|
|
507
|
+
serverName,
|
|
508
|
+
decision: "denied",
|
|
509
|
+
reason: "cancelled",
|
|
510
|
+
});
|
|
511
|
+
throw new McpPolicyError({
|
|
512
|
+
reason: "cancelled",
|
|
513
|
+
message: `Listing skill resources on "${serverName}" was cancelled.`,
|
|
514
|
+
source: "skill-discovery",
|
|
515
|
+
serverName,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
const resources = await this.gateway.listResources(serverName, signal);
|
|
519
|
+
const skillResources = resources.filter(isSkillResourceUri);
|
|
520
|
+
this.discoveredUrisByServer.set(serverName, new Set(skillResources.map((r) => r.uri)));
|
|
521
|
+
this.record({
|
|
522
|
+
source: "skill-discovery",
|
|
523
|
+
operation: "resource-list",
|
|
524
|
+
serverName,
|
|
525
|
+
decision: "allowed",
|
|
526
|
+
});
|
|
527
|
+
return skillResources;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Authorize and dispatch a resource read using the same policy discipline as
|
|
531
|
+
* tool calls: origin binding, discovery, cancellation, and audit.
|
|
532
|
+
*/
|
|
533
|
+
async readResource(request) {
|
|
534
|
+
const { source, serverName, uri, skillUri, signal } = request;
|
|
535
|
+
const deny = (reason, message) => {
|
|
536
|
+
this.record({
|
|
537
|
+
source,
|
|
538
|
+
operation: "resource",
|
|
539
|
+
serverName,
|
|
540
|
+
uri,
|
|
541
|
+
decision: "denied",
|
|
542
|
+
reason,
|
|
543
|
+
});
|
|
544
|
+
return new McpPolicyError({ reason, message, source, serverName, uri });
|
|
545
|
+
};
|
|
546
|
+
if (!this.gateway.getConnectedServers().includes(serverName)) {
|
|
547
|
+
throw deny("server_not_connected", `MCP server "${serverName}" is not connected, so ${uri} cannot be read.`);
|
|
548
|
+
}
|
|
549
|
+
// The legacy convention recognises skills by URI shape, so the shape is the
|
|
550
|
+
// outer bound of what it may read. SEP-2640 reads are authorized by exact
|
|
551
|
+
// membership in a skill's declared `resources` set — strictly narrower — so
|
|
552
|
+
// they do not need, and must not be limited by, a scheme heuristic.
|
|
553
|
+
if (source === "tool-cli" && this.isSkillOwnedResource(serverName, uri)) {
|
|
554
|
+
throw deny("resource_not_discovered", `Resource ${uri} belongs to the skill authorization surface and cannot be read through tool-cli. Use load_skill instead.`);
|
|
555
|
+
}
|
|
556
|
+
if (source !== "skills-extension" && source !== "tool-cli" && !hasSkillScheme(uri)) {
|
|
557
|
+
throw deny("resource_not_discovered", `Resource ${uri} is outside the skill resource surface this host authorizes.`);
|
|
558
|
+
}
|
|
559
|
+
if (source === "skills-extension") {
|
|
560
|
+
if (!skillUri) {
|
|
561
|
+
throw deny("resource_not_discovered", `Read of ${uri} did not name the skill whose declared resources authorize it.`);
|
|
562
|
+
}
|
|
563
|
+
if (!this.getSkillsExtension(serverName)) {
|
|
564
|
+
throw deny("extension_not_declared", `MCP server "${serverName}" does not declare the skills extension, so ${uri} cannot be read under it.`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const allowed = source === "tool-cli" ? undefined : this.allowedReadUris(source, serverName, skillUri);
|
|
568
|
+
if (allowed !== undefined && !allowed.has(uri)) {
|
|
569
|
+
// Distinguish "belongs to a different origin" so the denial is actionable.
|
|
570
|
+
const otherOrigin = this.findOtherOrigin(uri, serverName, source);
|
|
571
|
+
if (otherOrigin) {
|
|
572
|
+
throw deny("resource_origin_mismatch", `Resource ${uri} belongs to MCP server "${otherOrigin}" and cannot be read through server "${serverName}".`);
|
|
573
|
+
}
|
|
574
|
+
throw deny("resource_not_discovered", source === "skills-extension"
|
|
575
|
+
? `Resource ${uri} is not listed in the resources of skill ${skillUri ?? "(unknown)"} on MCP server "${serverName}".`
|
|
576
|
+
: `Resource ${uri} was not discovered as a skill on MCP server "${serverName}".`);
|
|
577
|
+
}
|
|
578
|
+
if (signal?.aborted) {
|
|
579
|
+
throw deny("cancelled", `Read of ${uri} was cancelled before dispatch.`);
|
|
580
|
+
}
|
|
581
|
+
const result = await this.gateway.readResource(serverName, uri, signal);
|
|
582
|
+
if (source === "tool-cli") {
|
|
583
|
+
const hidden = result.contents.find((content) => this.isSkillOwnedResource(serverName, content.uri));
|
|
584
|
+
if (hidden !== undefined) {
|
|
585
|
+
throw deny("resource_not_discovered", `MCP server "${serverName}" returned skill-owned resource ${hidden.uri} while reading ${uri}; the entire response was refused. Use load_skill for skill resources.`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
this.record({
|
|
589
|
+
source,
|
|
590
|
+
operation: "resource",
|
|
591
|
+
serverName,
|
|
592
|
+
uri,
|
|
593
|
+
decision: "allowed",
|
|
594
|
+
});
|
|
595
|
+
return result;
|
|
596
|
+
}
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
// Audit
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
getAuditLog() {
|
|
601
|
+
return [...this.audit];
|
|
602
|
+
}
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// Internals
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
enableTools(tools) {
|
|
607
|
+
for (const tool of tools) {
|
|
608
|
+
this.enabledTools.add(tool);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
allowedReadUris(source, serverName, skillUri) {
|
|
612
|
+
if (source === "skills-extension") {
|
|
613
|
+
if (!skillUri)
|
|
614
|
+
return new Set();
|
|
615
|
+
return (this.extensionResourceUris.get(extensionResourceKey(serverName, skillUri)) ??
|
|
616
|
+
new Set());
|
|
617
|
+
}
|
|
618
|
+
if (source === "skill-discovery") {
|
|
619
|
+
return this.discoveredUrisByServer.get(serverName) ?? new Set();
|
|
620
|
+
}
|
|
621
|
+
return this.skillUrisByServer.get(serverName) ?? new Set();
|
|
622
|
+
}
|
|
623
|
+
isSkillOwnedResource(serverName, uri) {
|
|
624
|
+
if (hasSkillScheme(uri) || this.skillUrisByServer.get(serverName)?.has(uri) === true) {
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
for (const [key, resourceUris] of this.extensionResourceUris) {
|
|
628
|
+
const owner = key.slice(0, key.indexOf(NUL));
|
|
629
|
+
if (owner === serverName && resourceUris.has(uri))
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
findOtherOrigin(uri, serverName, source) {
|
|
635
|
+
if (source === "tool-cli")
|
|
636
|
+
return undefined;
|
|
637
|
+
if (source === "skills-extension") {
|
|
638
|
+
// Extension allowlists are keyed by `serverName\0skillUri`, so a match on
|
|
639
|
+
// another origin is what turns "not listed here" into "listed elsewhere".
|
|
640
|
+
for (const [key, uris] of this.extensionResourceUris) {
|
|
641
|
+
const owner = key.slice(0, key.indexOf(NUL));
|
|
642
|
+
if (owner !== serverName && uris.has(uri))
|
|
643
|
+
return owner;
|
|
644
|
+
}
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
const index = source === "skill-discovery" ? this.discoveredUrisByServer : this.skillUrisByServer;
|
|
648
|
+
for (const [candidate, uris] of index) {
|
|
649
|
+
if (candidate !== serverName && uris.has(uri))
|
|
650
|
+
return candidate;
|
|
651
|
+
}
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Refuse an extension method unless the server is connected and declared the
|
|
656
|
+
* extension. Records the denial before throwing, like every other refusal.
|
|
657
|
+
*/
|
|
658
|
+
assertSkillsExtension(serverName, operation, uri) {
|
|
659
|
+
if (!this.gateway.getConnectedServers().includes(serverName)) {
|
|
660
|
+
throw this.denyExtension(serverName, operation, "server_not_connected", `MCP server "${serverName}" is not connected.`, uri);
|
|
661
|
+
}
|
|
662
|
+
if (!this.gateway.getExtensionCapability(serverName, SKILLS_EXTENSION_NAME)) {
|
|
663
|
+
throw this.denyExtension(serverName, operation, "extension_not_declared", `MCP server "${serverName}" does not declare the "${SKILLS_EXTENSION_NAME}" extension, so ` +
|
|
664
|
+
`its skills methods are not available.`, uri);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
denyExtension(serverName, operation, reason, message, uri) {
|
|
668
|
+
this.record({
|
|
669
|
+
source: "skills-extension",
|
|
670
|
+
operation,
|
|
671
|
+
serverName,
|
|
672
|
+
...(uri !== undefined ? { uri } : {}),
|
|
673
|
+
decision: "denied",
|
|
674
|
+
reason,
|
|
675
|
+
});
|
|
676
|
+
return new McpPolicyError({
|
|
677
|
+
reason,
|
|
678
|
+
message,
|
|
679
|
+
source: "skills-extension",
|
|
680
|
+
serverName,
|
|
681
|
+
...(uri !== undefined ? { uri } : {}),
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
async requestApproval(request) {
|
|
685
|
+
if (!this.approvals)
|
|
686
|
+
return undefined;
|
|
687
|
+
try {
|
|
688
|
+
return await this.approvals.confirm(request);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
// A failed or cancelled prompt is never an approval.
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
record(record) {
|
|
696
|
+
const entry = {
|
|
697
|
+
...record,
|
|
698
|
+
id: `mcp-policy-${++this.sequence}`,
|
|
699
|
+
timestampMs: Date.now(),
|
|
700
|
+
};
|
|
701
|
+
this.audit.push(entry);
|
|
702
|
+
if (this.audit.length > this.auditLimit) {
|
|
703
|
+
this.audit.splice(0, this.audit.length - this.auditLimit);
|
|
704
|
+
}
|
|
705
|
+
this.onAudit?.(entry);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* A tool call is treated as read-only only when the server explicitly declares
|
|
710
|
+
* it read-only and does not also declare it destructive. This is the same
|
|
711
|
+
* predicate Code Mode uses for dispatch eligibility.
|
|
712
|
+
*/
|
|
713
|
+
export function isReadOnlyToolCall(tool) {
|
|
714
|
+
return tool.annotations?.readOnlyHint === true && tool.annotations.destructiveHint !== true;
|
|
715
|
+
}
|
|
716
|
+
function isSkillResourceUri(resource) {
|
|
717
|
+
return hasSkillScheme(resource.uri) && resource.uri.endsWith("/SKILL.md");
|
|
718
|
+
}
|
|
719
|
+
function hasSkillScheme(uri) {
|
|
720
|
+
return uri.slice(0, "skill://".length).toLowerCase() === "skill://";
|
|
721
|
+
}
|
|
722
|
+
function extensionResourceKey(serverName, skillUri) {
|
|
723
|
+
return `${serverName}${NUL}${skillUri}`;
|
|
724
|
+
}
|
|
725
|
+
function skillGrantKey(skill) {
|
|
726
|
+
const tools = [...skill.allowedTools].sort().join(" ");
|
|
727
|
+
const digest = createHash("sha256").update(tools).digest("hex").slice(0, 32);
|
|
728
|
+
// The fingerprint participates in the key so rotated content revokes approval.
|
|
729
|
+
// Legacy skills have none; they degrade to the previous origin+URI+tools key.
|
|
730
|
+
return [skill.serverName, skill.uri, digest, skill.contentFingerprint ?? ""].join(NUL);
|
|
731
|
+
}
|
|
732
|
+
function formatToolApprovalMessage(source, serverName, tool, args) {
|
|
733
|
+
const annotations = tool.annotations ?? {};
|
|
734
|
+
const flags = [];
|
|
735
|
+
if (annotations.readOnlyHint !== true)
|
|
736
|
+
flags.push("not annotated read-only");
|
|
737
|
+
if (annotations.destructiveHint === true)
|
|
738
|
+
flags.push("annotated destructive");
|
|
739
|
+
let preview;
|
|
740
|
+
try {
|
|
741
|
+
preview = JSON.stringify(args, null, 2) ?? "{}";
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
preview = "(arguments could not be serialized)";
|
|
745
|
+
}
|
|
746
|
+
if (preview.length > 2000)
|
|
747
|
+
preview = `${preview.slice(0, 2000)}\n… (truncated)`;
|
|
748
|
+
return [
|
|
749
|
+
`Requested by: ${source}`,
|
|
750
|
+
`MCP server: ${serverName}`,
|
|
751
|
+
`Tool: ${tool.name}${flags.length > 0 ? ` (${flags.join("; ")})` : ""}`,
|
|
752
|
+
"Arguments:",
|
|
753
|
+
preview,
|
|
754
|
+
].join("\n");
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Conservative, dependency-free structural validation of tool arguments.
|
|
758
|
+
*
|
|
759
|
+
* It rejects only what a server's own declared schema unambiguously forbids —
|
|
760
|
+
* missing required properties, undeclared properties under
|
|
761
|
+
* `additionalProperties: false`, and clearly mismatched primitive types — so
|
|
762
|
+
* schemas using `$ref`, `allOf`, or other composition keywords are not
|
|
763
|
+
* incorrectly refused. Returns a message when invalid, otherwise `undefined`.
|
|
764
|
+
*/
|
|
765
|
+
export function validateToolArguments(schema, args) {
|
|
766
|
+
if (args === null || typeof args !== "object" || Array.isArray(args)) {
|
|
767
|
+
return "arguments must be a JSON object";
|
|
768
|
+
}
|
|
769
|
+
if (!schema || typeof schema !== "object")
|
|
770
|
+
return undefined;
|
|
771
|
+
const record = args;
|
|
772
|
+
const schemaRecord = schema;
|
|
773
|
+
const required = collectRequired(schemaRecord);
|
|
774
|
+
const missing = required.filter((name) => record[name] === undefined);
|
|
775
|
+
if (missing.length > 0) {
|
|
776
|
+
return `missing required propert${missing.length === 1 ? "y" : "ies"}: ${missing.join(", ")}`;
|
|
777
|
+
}
|
|
778
|
+
const properties = asRecord(schemaRecord.properties);
|
|
779
|
+
if (schemaRecord.additionalProperties === false && properties) {
|
|
780
|
+
const declared = new Set(Object.keys(properties));
|
|
781
|
+
const patterns = asRecord(schemaRecord.patternProperties);
|
|
782
|
+
const patternKeys = patterns ? Object.keys(patterns).map((p) => new RegExp(p)) : [];
|
|
783
|
+
const unknown = Object.keys(record).filter((key) => !declared.has(key) && !patternKeys.some((re) => re.test(key)));
|
|
784
|
+
if (unknown.length > 0) {
|
|
785
|
+
return `unknown propert${unknown.length === 1 ? "y" : "ies"}: ${unknown.join(", ")}`;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (properties) {
|
|
789
|
+
for (const [key, value] of Object.entries(record)) {
|
|
790
|
+
if (value === undefined)
|
|
791
|
+
continue;
|
|
792
|
+
const propSchema = asRecord(properties[key]);
|
|
793
|
+
const expected = propSchema?.type;
|
|
794
|
+
if (typeof expected !== "string")
|
|
795
|
+
continue;
|
|
796
|
+
if (!matchesJsonType(value, expected)) {
|
|
797
|
+
return `property "${key}" must be of type ${expected}`;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return undefined;
|
|
802
|
+
}
|
|
803
|
+
function collectRequired(schema) {
|
|
804
|
+
const names = new Set();
|
|
805
|
+
const push = (value) => {
|
|
806
|
+
if (!Array.isArray(value))
|
|
807
|
+
return;
|
|
808
|
+
for (const entry of value) {
|
|
809
|
+
if (typeof entry === "string")
|
|
810
|
+
names.add(entry);
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
push(schema.required);
|
|
814
|
+
// `allOf` composition is common in MCP schemas; honor top-level required only.
|
|
815
|
+
if (Array.isArray(schema.allOf)) {
|
|
816
|
+
for (const branch of schema.allOf) {
|
|
817
|
+
const record = asRecord(branch);
|
|
818
|
+
if (record)
|
|
819
|
+
push(record.required);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return [...names];
|
|
823
|
+
}
|
|
824
|
+
function matchesJsonType(value, expected) {
|
|
825
|
+
switch (expected) {
|
|
826
|
+
case "string":
|
|
827
|
+
return typeof value === "string";
|
|
828
|
+
case "number":
|
|
829
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
830
|
+
case "integer":
|
|
831
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
832
|
+
case "boolean":
|
|
833
|
+
return typeof value === "boolean";
|
|
834
|
+
case "array":
|
|
835
|
+
return Array.isArray(value);
|
|
836
|
+
case "object":
|
|
837
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
838
|
+
case "null":
|
|
839
|
+
return value === null;
|
|
840
|
+
default:
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function asRecord(value) {
|
|
845
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
846
|
+
? value
|
|
847
|
+
: undefined;
|
|
848
|
+
}
|