@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.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -47
  3. package/dist/code-mode/eligibility.d.ts +35 -0
  4. package/dist/code-mode/eligibility.js +64 -0
  5. package/dist/code-mode/executor.d.ts +53 -0
  6. package/dist/code-mode/executor.js +185 -0
  7. package/dist/code-mode/index.d.ts +107 -0
  8. package/dist/code-mode/index.js +267 -0
  9. package/dist/code-mode/isolated-vm.d.ts +65 -0
  10. package/dist/code-mode/isolated-vm.js +116 -0
  11. package/dist/code-mode/tools.d.ts +48 -0
  12. package/dist/code-mode/tools.js +73 -0
  13. package/dist/code-mode/type-hints.d.ts +20 -0
  14. package/dist/code-mode/type-hints.js +205 -0
  15. package/dist/docker-e2e.d.ts +54 -0
  16. package/dist/docker-e2e.js +234 -0
  17. package/dist/index.d.ts +11 -0
  18. package/dist/index.js +227 -0
  19. package/dist/mcp/call-tool-result.d.ts +10 -0
  20. package/dist/mcp/call-tool-result.js +70 -0
  21. package/dist/mcp/client-factory.d.ts +59 -0
  22. package/dist/mcp/client-factory.js +76 -0
  23. package/dist/mcp/client-manager.d.ts +88 -0
  24. package/dist/mcp/client-manager.js +307 -0
  25. package/dist/mcp/config-loader.d.ts +7 -0
  26. package/dist/mcp/config-loader.js +42 -0
  27. package/dist/mcp/config.d.ts +56 -0
  28. package/dist/mcp/config.js +42 -0
  29. package/dist/mcp/gateway-defaults.d.ts +14 -0
  30. package/dist/mcp/gateway-defaults.js +21 -0
  31. package/dist/mcp/host-approval.d.ts +16 -0
  32. package/dist/mcp/host-approval.js +19 -0
  33. package/dist/mcp/host-elicitation.d.ts +19 -0
  34. package/dist/mcp/host-elicitation.js +94 -0
  35. package/dist/mcp/index.d.ts +8 -0
  36. package/dist/mcp/index.js +7 -0
  37. package/dist/mcp/policy.d.ts +314 -0
  38. package/dist/mcp/policy.js +848 -0
  39. package/dist/routing/facilities.d.ts +109 -0
  40. package/dist/routing/facilities.js +175 -0
  41. package/dist/routing/format.d.ts +24 -0
  42. package/dist/routing/format.js +64 -0
  43. package/dist/routing/index.d.ts +4 -0
  44. package/dist/routing/index.js +4 -0
  45. package/dist/routing/seam.d.ts +35 -0
  46. package/dist/routing/seam.js +28 -0
  47. package/dist/routing/tripwire.d.ts +42 -0
  48. package/dist/routing/tripwire.js +80 -0
  49. package/dist/skills/discover.d.ts +12 -0
  50. package/dist/skills/discover.js +85 -0
  51. package/dist/skills/format.d.ts +13 -0
  52. package/dist/skills/format.js +42 -0
  53. package/dist/skills/index.d.ts +6 -0
  54. package/dist/skills/index.js +6 -0
  55. package/dist/skills/load-skill-tool.d.ts +56 -0
  56. package/dist/skills/load-skill-tool.js +183 -0
  57. package/dist/skills/mcp-tool-proxy.d.ts +7 -0
  58. package/dist/skills/mcp-tool-proxy.js +41 -0
  59. package/dist/skills/sep2640/client.d.ts +91 -0
  60. package/dist/skills/sep2640/client.js +160 -0
  61. package/dist/skills/sep2640/discover.d.ts +33 -0
  62. package/dist/skills/sep2640/discover.js +82 -0
  63. package/dist/skills/sep2640/index.d.ts +13 -0
  64. package/dist/skills/sep2640/index.js +13 -0
  65. package/dist/skills/sep2640/integrity.d.ts +58 -0
  66. package/dist/skills/sep2640/integrity.js +185 -0
  67. package/dist/skills/sep2640/load.d.ts +61 -0
  68. package/dist/skills/sep2640/load.js +90 -0
  69. package/dist/skills/sep2640/protocol.d.ts +146 -0
  70. package/dist/skills/sep2640/protocol.js +214 -0
  71. package/dist/skills/sep2640/spec.d.ts +76 -0
  72. package/dist/skills/sep2640/spec.js +88 -0
  73. package/dist/skills/skill-registry.d.ts +85 -0
  74. package/dist/skills/skill-registry.js +85 -0
  75. package/dist/tool-cli/bridge.d.ts +14 -0
  76. package/dist/tool-cli/bridge.js +196 -0
  77. package/dist/tool-cli/format.d.ts +20 -0
  78. package/dist/tool-cli/format.js +73 -0
  79. package/dist/tool-cli/index.d.ts +7 -0
  80. package/dist/tool-cli/index.js +5 -0
  81. package/dist/tool-cli/provider.d.ts +21 -0
  82. package/dist/tool-cli/provider.js +114 -0
  83. package/dist/tool-cli/startup.d.ts +22 -0
  84. package/dist/tool-cli/startup.js +47 -0
  85. package/package.json +78 -26
  86. package/.dockerignore +0 -6
  87. package/.github/dependabot.yml +0 -25
  88. package/.github/workflows/ci.yml +0 -50
  89. package/.hadolint.yaml +0 -6
  90. package/.prettierignore +0 -4
  91. package/.prettierrc +0 -7
  92. package/AGENTS.md +0 -118
  93. package/DECISIONS.md +0 -99
  94. package/Dockerfile +0 -39
  95. package/aube-lock.yaml +0 -4146
  96. package/docs/code-mode.md +0 -38
  97. package/docs/skills.md +0 -57
  98. package/docs/tool-cli.md +0 -54
  99. package/eslint.config.js +0 -27
  100. package/images/banner.webp +0 -0
  101. package/images/code-c-maude.webp +0 -0
  102. package/images/code-mode-histogram.png +0 -0
  103. package/images/nuclear-mcp-football.webp +0 -0
  104. package/images/skills-enabling-mcp-tools.png +0 -0
  105. package/images/the-skill-dealer.webp +0 -0
  106. package/images/tool-cli-grep.png +0 -0
  107. package/mise.toml +0 -53
  108. package/scripts/docker-entrypoint.sh +0 -29
  109. package/scripts/test-echo-server.mjs +0 -28
  110. package/scripts/test-mcp-integration.mjs +0 -59
  111. package/scripts/test-tool-cli-smoke.mjs +0 -93
  112. package/src/code-mode/code-mode-integration.test.ts +0 -135
  113. package/src/code-mode/eligibility.test.ts +0 -60
  114. package/src/code-mode/eligibility.ts +0 -16
  115. package/src/code-mode/executor.test.ts +0 -168
  116. package/src/code-mode/executor.ts +0 -163
  117. package/src/code-mode/index.ts +0 -208
  118. package/src/code-mode/tools.ts +0 -110
  119. package/src/code-mode/type-hints.test.ts +0 -187
  120. package/src/code-mode/type-hints.ts +0 -249
  121. package/src/docker-e2e.test.ts +0 -195
  122. package/src/docker-e2e.ts +0 -277
  123. package/src/index.test.ts +0 -8
  124. package/src/index.ts +0 -179
  125. package/src/mcp/client-manager.test.ts +0 -250
  126. package/src/mcp/client-manager.ts +0 -199
  127. package/src/mcp/config-loader.test.ts +0 -115
  128. package/src/mcp/config-loader.ts +0 -46
  129. package/src/mcp/config.ts +0 -40
  130. package/src/mcp/index.ts +0 -4
  131. package/src/skills/discover.ts +0 -81
  132. package/src/skills/format.test.ts +0 -70
  133. package/src/skills/format.ts +0 -42
  134. package/src/skills/index.ts +0 -9
  135. package/src/skills/load-skill-tool.ts +0 -136
  136. package/src/skills/mcp-tool-proxy.ts +0 -143
  137. package/src/skills/skill-integration.test.ts +0 -139
  138. package/src/skills/skill-registry.test.ts +0 -83
  139. package/src/skills/skill-registry.ts +0 -69
  140. package/src/test-servers/weather-server.ts +0 -171
  141. package/src/test-servers/weather-stdio.ts +0 -11
  142. package/src/tool-cli/format.test.ts +0 -33
  143. package/src/tool-cli/format.ts +0 -56
  144. package/src/tool-cli/index.ts +0 -3
  145. 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
+ }