@sammorrowdrums/mcpi-ext 0.2.1 → 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 (199) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -47
  3. package/dist/code-mode/eligibility.d.ts +29 -4
  4. package/dist/code-mode/eligibility.js +56 -6
  5. package/dist/code-mode/executor.d.ts +19 -1
  6. package/dist/code-mode/executor.js +73 -8
  7. package/dist/code-mode/index.d.ts +54 -12
  8. package/dist/code-mode/index.js +157 -65
  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 +4 -1
  12. package/dist/code-mode/tools.js +7 -4
  13. package/dist/code-mode/type-hints.d.ts +2 -2
  14. package/dist/code-mode/type-hints.js +24 -8
  15. package/dist/docker-e2e.d.ts +0 -1
  16. package/dist/docker-e2e.js +0 -1
  17. package/dist/index.d.ts +9 -1
  18. package/dist/index.js +146 -67
  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 +61 -19
  24. package/dist/mcp/client-manager.js +223 -64
  25. package/dist/mcp/config-loader.d.ts +0 -1
  26. package/dist/mcp/config-loader.js +0 -1
  27. package/dist/mcp/config.d.ts +11 -1
  28. package/dist/mcp/config.js +15 -1
  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 +4 -1
  36. package/dist/mcp/index.js +4 -1
  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 +5 -3
  50. package/dist/skills/discover.js +37 -12
  51. package/dist/skills/format.d.ts +4 -1
  52. package/dist/skills/format.js +8 -4
  53. package/dist/skills/index.d.ts +2 -2
  54. package/dist/skills/index.js +1 -1
  55. package/dist/skills/load-skill-tool.d.ts +23 -7
  56. package/dist/skills/load-skill-tool.js +112 -33
  57. package/dist/skills/mcp-tool-proxy.d.ts +4 -8
  58. package/dist/skills/mcp-tool-proxy.js +22 -97
  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 +48 -2
  74. package/dist/skills/skill-registry.js +40 -3
  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 +17 -5
  78. package/dist/tool-cli/format.js +36 -20
  79. package/dist/tool-cli/index.d.ts +5 -2
  80. package/dist/tool-cli/index.js +3 -1
  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 -30
  86. package/dist/code-mode/code-mode-integration.test.d.ts +0 -2
  87. package/dist/code-mode/code-mode-integration.test.d.ts.map +0 -1
  88. package/dist/code-mode/code-mode-integration.test.js +0 -116
  89. package/dist/code-mode/code-mode-integration.test.js.map +0 -1
  90. package/dist/code-mode/eligibility.d.ts.map +0 -1
  91. package/dist/code-mode/eligibility.js.map +0 -1
  92. package/dist/code-mode/eligibility.test.d.ts +0 -2
  93. package/dist/code-mode/eligibility.test.d.ts.map +0 -1
  94. package/dist/code-mode/eligibility.test.js +0 -53
  95. package/dist/code-mode/eligibility.test.js.map +0 -1
  96. package/dist/code-mode/executor.d.ts.map +0 -1
  97. package/dist/code-mode/executor.js.map +0 -1
  98. package/dist/code-mode/executor.test.d.ts +0 -2
  99. package/dist/code-mode/executor.test.d.ts.map +0 -1
  100. package/dist/code-mode/executor.test.js +0 -130
  101. package/dist/code-mode/executor.test.js.map +0 -1
  102. package/dist/code-mode/index.d.ts.map +0 -1
  103. package/dist/code-mode/index.js.map +0 -1
  104. package/dist/code-mode/tools.d.ts.map +0 -1
  105. package/dist/code-mode/tools.js.map +0 -1
  106. package/dist/code-mode/type-hints.d.ts.map +0 -1
  107. package/dist/code-mode/type-hints.js.map +0 -1
  108. package/dist/code-mode/type-hints.test.d.ts +0 -2
  109. package/dist/code-mode/type-hints.test.d.ts.map +0 -1
  110. package/dist/code-mode/type-hints.test.js +0 -157
  111. package/dist/code-mode/type-hints.test.js.map +0 -1
  112. package/dist/docker-e2e.d.ts.map +0 -1
  113. package/dist/docker-e2e.js.map +0 -1
  114. package/dist/docker-e2e.test.d.ts +0 -2
  115. package/dist/docker-e2e.test.d.ts.map +0 -1
  116. package/dist/docker-e2e.test.js +0 -176
  117. package/dist/docker-e2e.test.js.map +0 -1
  118. package/dist/index.d.ts.map +0 -1
  119. package/dist/index.js.map +0 -1
  120. package/dist/index.test.d.ts +0 -2
  121. package/dist/index.test.d.ts.map +0 -1
  122. package/dist/index.test.js +0 -8
  123. package/dist/index.test.js.map +0 -1
  124. package/dist/mcp/client-manager.d.ts.map +0 -1
  125. package/dist/mcp/client-manager.js.map +0 -1
  126. package/dist/mcp/client-manager.test.d.ts +0 -2
  127. package/dist/mcp/client-manager.test.d.ts.map +0 -1
  128. package/dist/mcp/client-manager.test.js +0 -204
  129. package/dist/mcp/client-manager.test.js.map +0 -1
  130. package/dist/mcp/config-loader.d.ts.map +0 -1
  131. package/dist/mcp/config-loader.js.map +0 -1
  132. package/dist/mcp/config-loader.test.d.ts +0 -2
  133. package/dist/mcp/config-loader.test.d.ts.map +0 -1
  134. package/dist/mcp/config-loader.test.js +0 -93
  135. package/dist/mcp/config-loader.test.js.map +0 -1
  136. package/dist/mcp/config.d.ts.map +0 -1
  137. package/dist/mcp/config.js.map +0 -1
  138. package/dist/mcp/index.d.ts.map +0 -1
  139. package/dist/mcp/index.js.map +0 -1
  140. package/dist/skills/discover.d.ts.map +0 -1
  141. package/dist/skills/discover.js.map +0 -1
  142. package/dist/skills/format.d.ts.map +0 -1
  143. package/dist/skills/format.js.map +0 -1
  144. package/dist/skills/format.test.d.ts +0 -2
  145. package/dist/skills/format.test.d.ts.map +0 -1
  146. package/dist/skills/format.test.js +0 -63
  147. package/dist/skills/format.test.js.map +0 -1
  148. package/dist/skills/index.d.ts.map +0 -1
  149. package/dist/skills/index.js.map +0 -1
  150. package/dist/skills/load-skill-tool.d.ts.map +0 -1
  151. package/dist/skills/load-skill-tool.js.map +0 -1
  152. package/dist/skills/mcp-tool-proxy.d.ts.map +0 -1
  153. package/dist/skills/mcp-tool-proxy.js.map +0 -1
  154. package/dist/skills/skill-integration.test.d.ts +0 -2
  155. package/dist/skills/skill-integration.test.d.ts.map +0 -1
  156. package/dist/skills/skill-integration.test.js +0 -119
  157. package/dist/skills/skill-integration.test.js.map +0 -1
  158. package/dist/skills/skill-registry.d.ts.map +0 -1
  159. package/dist/skills/skill-registry.js.map +0 -1
  160. package/dist/skills/skill-registry.test.d.ts +0 -2
  161. package/dist/skills/skill-registry.test.d.ts.map +0 -1
  162. package/dist/skills/skill-registry.test.js +0 -67
  163. package/dist/skills/skill-registry.test.js.map +0 -1
  164. package/dist/test-servers/weather-server.d.ts +0 -12
  165. package/dist/test-servers/weather-server.d.ts.map +0 -1
  166. package/dist/test-servers/weather-server.js +0 -139
  167. package/dist/test-servers/weather-server.js.map +0 -1
  168. package/dist/test-servers/weather-stdio.d.ts +0 -2
  169. package/dist/test-servers/weather-stdio.d.ts.map +0 -1
  170. package/dist/test-servers/weather-stdio.js +0 -11
  171. package/dist/test-servers/weather-stdio.js.map +0 -1
  172. package/dist/tool-cli/cli.d.ts +0 -3
  173. package/dist/tool-cli/cli.d.ts.map +0 -1
  174. package/dist/tool-cli/cli.js +0 -179
  175. package/dist/tool-cli/cli.js.map +0 -1
  176. package/dist/tool-cli/constants.d.ts +0 -7
  177. package/dist/tool-cli/constants.d.ts.map +0 -1
  178. package/dist/tool-cli/constants.js +0 -15
  179. package/dist/tool-cli/constants.js.map +0 -1
  180. package/dist/tool-cli/format.d.ts.map +0 -1
  181. package/dist/tool-cli/format.js.map +0 -1
  182. package/dist/tool-cli/format.test.d.ts +0 -2
  183. package/dist/tool-cli/format.test.d.ts.map +0 -1
  184. package/dist/tool-cli/format.test.js +0 -30
  185. package/dist/tool-cli/format.test.js.map +0 -1
  186. package/dist/tool-cli/index.d.ts.map +0 -1
  187. package/dist/tool-cli/index.js.map +0 -1
  188. package/dist/tool-cli/rpc-client.d.ts +0 -6
  189. package/dist/tool-cli/rpc-client.d.ts.map +0 -1
  190. package/dist/tool-cli/rpc-client.js +0 -25
  191. package/dist/tool-cli/rpc-client.js.map +0 -1
  192. package/dist/tool-cli/rpc-server.d.ts +0 -48
  193. package/dist/tool-cli/rpc-server.d.ts.map +0 -1
  194. package/dist/tool-cli/rpc-server.js +0 -220
  195. package/dist/tool-cli/rpc-server.js.map +0 -1
  196. package/dist/tool-cli/rpc-server.test.d.ts +0 -2
  197. package/dist/tool-cli/rpc-server.test.d.ts.map +0 -1
  198. package/dist/tool-cli/rpc-server.test.js +0 -154
  199. package/dist/tool-cli/rpc-server.test.js.map +0 -1
@@ -4,24 +4,30 @@ import { parseFrontmatter } from "@sammorrowdrums/mcpi";
4
4
  *
5
5
  * Looks for resources with `skill://` URIs ending in `/SKILL.md`,
6
6
  * reads each one, and parses YAML frontmatter for skill metadata.
7
+ *
8
+ * All resource I/O goes through the shared policy boundary, so a server can
9
+ * only ever surface its own skill resources.
7
10
  */
8
- export async function discoverSkillsFromServer(client, serverName, log = console.error) {
11
+ export async function discoverSkillsFromServer(policy, serverName, log = console.error, signal) {
9
12
  const skills = [];
10
- let resources;
13
+ let skillResources;
11
14
  try {
12
- const result = await client.listResources();
13
- resources = result.resources;
15
+ skillResources = await policy.listSkillResources(serverName, signal);
14
16
  }
15
17
  catch {
16
18
  log(`[skills] Server "${serverName}" does not support resources/list, skipping skill discovery`);
17
19
  return skills;
18
20
  }
19
- const skillResources = resources.filter((r) => r.uri.startsWith("skill://") && r.uri.endsWith("/SKILL.md"));
20
21
  if (skillResources.length === 0)
21
22
  return skills;
22
23
  for (const resource of skillResources) {
23
24
  try {
24
- const result = await client.readResource({ uri: resource.uri });
25
+ const result = await policy.readResource({
26
+ source: "skill-discovery",
27
+ serverName,
28
+ uri: resource.uri,
29
+ ...(signal ? { signal } : {}),
30
+ });
25
31
  const textContent = result.contents.find((c) => "text" in c);
26
32
  if (!textContent) {
27
33
  log(`[skills] Skill resource ${resource.uri} returned no text content, skipping`);
@@ -31,7 +37,7 @@ export async function discoverSkillsFromServer(client, serverName, log = console
31
37
  const fm = parsed.frontmatter;
32
38
  const name = fm.name ?? resource.name;
33
39
  const description = fm.description ?? "";
34
- const allowedTools = parseAllowedTools(fm["allowed-tools"]);
40
+ const allowedTools = parseAllowedTools(fm);
35
41
  if (!name) {
36
42
  log(`[skills] Skill at ${resource.uri} has no name, skipping`);
37
43
  continue;
@@ -49,12 +55,31 @@ export async function discoverSkillsFromServer(client, serverName, log = console
49
55
  log(`[skills] Failed to read skill ${resource.uri}: ${err.message}`);
50
56
  }
51
57
  }
52
- return skills;
58
+ return skills.sort((left, right) => compareStrings(left.name, right.name));
59
+ }
60
+ function compareStrings(left, right) {
61
+ return left < right ? -1 : left > right ? 1 : 0;
53
62
  }
54
- function parseAllowedTools(value) {
55
- if (Array.isArray(value)) {
56
- return value.filter((v) => typeof v === "string");
63
+ /**
64
+ * Parse tool names from frontmatter, supporting both formats:
65
+ * - Current: `allowed-tools: [tool_a, tool_b]` (YAML array)
66
+ * - Proposed spec: `metadata.io.modelcontextprotocol/tools: "tool_a tool_b"` (space-separated)
67
+ *
68
+ * Prefers the proposed spec format when both are present.
69
+ */
70
+ function parseAllowedTools(fm) {
71
+ // Proposed spec format: metadata.io.modelcontextprotocol/tools (space-separated string)
72
+ const metadata = fm.metadata;
73
+ if (metadata) {
74
+ const specTools = metadata["io.modelcontextprotocol/tools"];
75
+ if (typeof specTools === "string" && specTools.trim().length > 0) {
76
+ return specTools.trim().split(/\s+/);
77
+ }
78
+ }
79
+ // Current format: allowed-tools (YAML array)
80
+ const legacy = fm["allowed-tools"];
81
+ if (Array.isArray(legacy)) {
82
+ return legacy.filter((v) => typeof v === "string");
57
83
  }
58
84
  return [];
59
85
  }
60
- //# sourceMappingURL=discover.js.map
@@ -5,6 +5,9 @@ import type { McpSkillMetadata } from "./skill-registry.js";
5
5
  * Produces XML matching Pi's native `formatSkillsForPrompt` structure,
6
6
  * but references the `load_skill` tool instead of `read` and uses
7
7
  * `mcp:<serverName>` as the location.
8
+ *
9
+ * This is the catalogue of what exists. Which kind of task suits a skill at all
10
+ * is decided by the `<execution_routing>` section, which reports skill
11
+ * availability whether or not any were discovered.
8
12
  */
9
13
  export declare function formatMcpSkillsForPrompt(skills: McpSkillMetadata[]): string;
10
- //# sourceMappingURL=format.d.ts.map
@@ -12,6 +12,10 @@ function escapeXml(str) {
12
12
  * Produces XML matching Pi's native `formatSkillsForPrompt` structure,
13
13
  * but references the `load_skill` tool instead of `read` and uses
14
14
  * `mcp:<serverName>` as the location.
15
+ *
16
+ * This is the catalogue of what exists. Which kind of task suits a skill at all
17
+ * is decided by the `<execution_routing>` section, which reports skill
18
+ * availability whether or not any were discovered.
15
19
  */
16
20
  export function formatMcpSkillsForPrompt(skills) {
17
21
  if (skills.length === 0)
@@ -19,9 +23,10 @@ export function formatMcpSkillsForPrompt(skills) {
19
23
  const lines = [
20
24
  "",
21
25
  "",
22
- "The following MCP skills provide specialized instructions and tools for specific tasks.",
23
- "MCP tools are deferred they are available but not described in this prompt.",
24
- "You MUST call load_skill to get usage instructions before using a skill's tools.",
26
+ "Use when a task matches one of the domain workflows these MCP skills document.",
27
+ "Call load_skill with the skill's name to read its instructions before working through it.",
28
+ "MCP tools are deferred registered for dispatch but not described in this prompt. A skill's",
29
+ "declared tools are enabled only after you approve its grant; declining leaves them locked.",
25
30
  "",
26
31
  "<available_mcp_skills>",
27
32
  ];
@@ -35,4 +40,3 @@ export function formatMcpSkillsForPrompt(skills) {
35
40
  lines.push("</available_mcp_skills>");
36
41
  return lines.join("\n");
37
42
  }
38
- //# sourceMappingURL=format.js.map
@@ -1,6 +1,6 @@
1
- export { SkillRegistry, type McpSkillMetadata } from "./skill-registry.js";
1
+ export { SkillRegistry, type McpSkillMetadata, type SkillNameCollision } from "./skill-registry.js";
2
2
  export { formatMcpSkillsForPrompt } from "./format.js";
3
3
  export { createLoadSkillTool, type LoadSkillDeps, type LoadSkillDetails, } from "./load-skill-tool.js";
4
4
  export { discoverSkillsFromServer } from "./discover.js";
5
5
  export { registerMcpToolProxies } from "./mcp-tool-proxy.js";
6
- //# sourceMappingURL=index.d.ts.map
6
+ export { SKILLS_EXTENSION_NAME, SKILLS_EXTENSION_REVISION, SKILLS_EXTENSION_STATUS, SkillsExtensionClient, describeNegotiation, discoverSkillsViaExtension, loadSkillDocument, readSkillResource, skillsExtensionDiagnostic, type Sep2640DiscoveryResult, type SkillEntry, type SkillsListing, } from "./sep2640/index.js";
@@ -3,4 +3,4 @@ export { formatMcpSkillsForPrompt } from "./format.js";
3
3
  export { createLoadSkillTool, } from "./load-skill-tool.js";
4
4
  export { discoverSkillsFromServer } from "./discover.js";
5
5
  export { registerMcpToolProxies } from "./mcp-tool-proxy.js";
6
- //# sourceMappingURL=index.js.map
6
+ export { SKILLS_EXTENSION_NAME, SKILLS_EXTENSION_REVISION, SKILLS_EXTENSION_STATUS, SkillsExtensionClient, describeNegotiation, discoverSkillsViaExtension, loadSkillDocument, readSkillResource, skillsExtensionDiagnostic, } from "./sep2640/index.js";
@@ -1,6 +1,7 @@
1
1
  import type { AgentToolResult, ExtensionContext } from "@sammorrowdrums/mcpi";
2
2
  import { Type, type Static } from "typebox";
3
- import type { McpClientManager } from "../mcp/index.js";
3
+ import type { McpPolicy } from "../mcp/policy.js";
4
+ import type { SkillsExtensionClient } from "./sep2640/client.js";
4
5
  import type { SkillRegistry } from "./skill-registry.js";
5
6
  declare const LoadSkillParams: Type.TObject<{
6
7
  name: Type.TString;
@@ -8,23 +9,39 @@ declare const LoadSkillParams: Type.TObject<{
8
9
  type LoadSkillInput = Static<typeof LoadSkillParams>;
9
10
  export interface LoadSkillDeps {
10
11
  registry: SkillRegistry;
11
- mcpManager: McpClientManager;
12
- enabledTools: Set<string>;
12
+ policy: McpPolicy;
13
+ /**
14
+ * Client for the draft skills extension.
15
+ *
16
+ * Required to load a skill discovered over SEP-2640: that contract only holds
17
+ * if the digests are re-fetched at load time, so a skill with no client to
18
+ * ask is refused rather than loaded unverified.
19
+ */
20
+ skillsClient?: SkillsExtensionClient;
13
21
  }
14
22
  export interface LoadSkillDetails {
15
23
  skillName: string;
16
24
  serverName?: string;
17
25
  activatedTools?: string[];
18
26
  error?: string;
27
+ /** True when the content was verified against SEP-2640 digests. */
28
+ verified?: boolean;
29
+ /** True when the server's resource set changed since discovery. */
30
+ resourceSetRotated?: boolean;
19
31
  }
20
32
  /**
21
33
  * Create the load_skill tool definition.
22
34
  *
23
35
  * When the model calls this tool, it:
24
36
  * 1. Looks up the skill in the registry
25
- * 2. Reads the full SKILL.md content from the MCP server
26
- * 3. Returns the SKILL.md body (the skill names its tools, and the model
37
+ * 2. Reads the full SKILL.md content through the shared policy boundary
38
+ * 3. Asks the user to approve the skill's `allowed-tools` grant
39
+ * 4. Returns the SKILL.md body (the skill names its tools, and the model
27
40
  * already has their schemas from the deferred tools array)
41
+ *
42
+ * The grant is requested before the body is returned, so a server cannot use
43
+ * skill instructions to influence a pending authorization decision. A declined
44
+ * or unavailable approval leaves every gated tool locked.
28
45
  */
29
46
  export declare function createLoadSkillTool(deps: LoadSkillDeps): {
30
47
  name: string;
@@ -34,7 +51,6 @@ export declare function createLoadSkillTool(deps: LoadSkillDeps): {
34
51
  parameters: Type.TObject<{
35
52
  name: Type.TString;
36
53
  }>;
37
- execute(_toolCallId: string, params: LoadSkillInput, _signal: AbortSignal | undefined, _onUpdate: undefined, _ctx: ExtensionContext): Promise<AgentToolResult<LoadSkillDetails>>;
54
+ execute(_toolCallId: string, params: LoadSkillInput, signal: AbortSignal | undefined, _onUpdate: undefined, _ctx: ExtensionContext): Promise<AgentToolResult<LoadSkillDetails>>;
38
55
  };
39
56
  export {};
40
- //# sourceMappingURL=load-skill-tool.d.ts.map
@@ -1,5 +1,7 @@
1
1
  import { stripFrontmatter } from "@sammorrowdrums/mcpi";
2
2
  import { Type } from "typebox";
3
+ import { loadSkillDocument, SkillFetchBudget } from "./sep2640/load.js";
4
+ import { resourceSetFingerprint } from "./sep2640/protocol.js";
3
5
  const LoadSkillParams = Type.Object({
4
6
  name: Type.String({ description: "Name of the MCP skill to load" }),
5
7
  });
@@ -8,19 +10,24 @@ const LoadSkillParams = Type.Object({
8
10
  *
9
11
  * When the model calls this tool, it:
10
12
  * 1. Looks up the skill in the registry
11
- * 2. Reads the full SKILL.md content from the MCP server
12
- * 3. Returns the SKILL.md body (the skill names its tools, and the model
13
+ * 2. Reads the full SKILL.md content through the shared policy boundary
14
+ * 3. Asks the user to approve the skill's `allowed-tools` grant
15
+ * 4. Returns the SKILL.md body (the skill names its tools, and the model
13
16
  * already has their schemas from the deferred tools array)
17
+ *
18
+ * The grant is requested before the body is returned, so a server cannot use
19
+ * skill instructions to influence a pending authorization decision. A declined
20
+ * or unavailable approval leaves every gated tool locked.
14
21
  */
15
22
  export function createLoadSkillTool(deps) {
16
- const { registry, mcpManager, enabledTools } = deps;
23
+ const { registry, policy, skillsClient } = deps;
17
24
  return {
18
25
  name: "load_skill",
19
26
  label: "Load Skill",
20
- description: "Load an MCP skill by name. Activates the skill's instructions and tools.",
21
- promptSnippet: "Load an MCP skill to get specialized instructions and activate its tools.",
27
+ description: "Use when a task matches an MCP skill's documented workflow and you need its instructions. Returns the skill body and requests approval to enable the tools it declares; the tools stay locked unless that grant is approved.",
28
+ promptSnippet: "Use when a task matches an MCP skill's workflow: returns its instructions and, once you approve the grant, enables the tools it declares.",
22
29
  parameters: LoadSkillParams,
23
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
30
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
24
31
  const skill = registry.get(params.name);
25
32
  if (!skill) {
26
33
  const available = registry
@@ -37,34 +44,69 @@ export function createLoadSkillTool(deps) {
37
44
  details: { skillName: params.name, error: "not_found" },
38
45
  };
39
46
  }
40
- const client = mcpManager.getClient(skill.serverName);
41
- if (!client) {
47
+ const verifiable = skill.origin === "sep2640";
48
+ if (verifiable && !skillsClient) {
42
49
  return {
43
50
  content: [
44
51
  {
45
52
  type: "text",
46
- text: `MCP server "${skill.serverName}" is not connected. Cannot load skill "${params.name}".`,
53
+ text: `Skill "${params.name}" was discovered over the draft skills extension, but no extension client is available to verify it. Refusing to load unverified content.`,
47
54
  },
48
55
  ],
49
- details: { skillName: params.name, serverName: skill.serverName, error: "disconnected" },
56
+ details: {
57
+ skillName: params.name,
58
+ serverName: skill.serverName,
59
+ error: "verification_unavailable",
60
+ verified: false,
61
+ },
50
62
  };
51
63
  }
52
64
  let body;
65
+ let entry;
66
+ let rotated = false;
53
67
  try {
54
- const result = await client.readResource({ uri: skill.uri });
55
- const textContent = result.contents.find((c) => "text" in c);
56
- if (!textContent) {
57
- return {
58
- content: [
59
- {
60
- type: "text",
61
- text: `Skill "${params.name}" returned no text content.`,
68
+ if (verifiable && skillsClient) {
69
+ // Re-fetch the entry so verification uses the digests the server is
70
+ // publishing now, not the ones it published at discovery.
71
+ entry = await skillsClient.getSkill(skill.serverName, skill.uri, signal);
72
+ const fingerprint = resourceSetFingerprint(entry);
73
+ rotated =
74
+ skill.contentFingerprint !== undefined && skill.contentFingerprint !== fingerprint;
75
+ policy.registerSkillResources(skill.serverName, entry.uri, entry.resources === "dynamic" ? [] : entry.resources.map((ref) => ref.uri));
76
+ const document = await loadSkillDocument({
77
+ policy,
78
+ entry,
79
+ serverName: skill.serverName,
80
+ budget: new SkillFetchBudget(),
81
+ ...(signal ? { signal } : {}),
82
+ });
83
+ body = stripFrontmatter(document.text);
84
+ }
85
+ else {
86
+ const result = await policy.readResource({
87
+ source: "skill-load",
88
+ serverName: skill.serverName,
89
+ uri: skill.uri,
90
+ ...(signal ? { signal } : {}),
91
+ });
92
+ const textContent = result.contents.find((c) => "text" in c);
93
+ if (!textContent) {
94
+ return {
95
+ content: [
96
+ {
97
+ type: "text",
98
+ text: `Skill "${params.name}" returned no text content.`,
99
+ },
100
+ ],
101
+ details: {
102
+ skillName: params.name,
103
+ serverName: skill.serverName,
104
+ error: "no_content",
62
105
  },
63
- ],
64
- details: { skillName: params.name, serverName: skill.serverName, error: "no_content" },
65
- };
106
+ };
107
+ }
108
+ body = stripFrontmatter(textContent.text);
66
109
  }
67
- body = stripFrontmatter(textContent.text);
68
110
  }
69
111
  catch (err) {
70
112
  return {
@@ -78,27 +120,64 @@ export function createLoadSkillTool(deps) {
78
120
  skillName: params.name,
79
121
  serverName: skill.serverName,
80
122
  error: err.message,
123
+ verified: false,
81
124
  },
82
125
  };
83
126
  }
84
- // Enable the skill's tools so the tool_call gate allows them
85
- for (const t of skill.allowedTools) {
86
- enabledTools.add(t);
127
+ // Approval is bound to the resource set the server just published. A
128
+ // rotated set produces a different grant key, so a previously approved
129
+ // skill is re-prompted instead of inheriting the old answer.
130
+ const grantSubject = entry ? withFreshContent(skill, entry) : skill;
131
+ const grant = await policy.activateSkillGrant(grantSubject, signal);
132
+ if (grant.status === "granted" || grant.status === "reused") {
133
+ return {
134
+ content: [
135
+ {
136
+ type: "text",
137
+ text: body,
138
+ },
139
+ ],
140
+ details: {
141
+ skillName: params.name,
142
+ serverName: skill.serverName,
143
+ activatedTools: [...grant.activatedTools],
144
+ verified: verifiable,
145
+ resourceSetRotated: rotated,
146
+ },
147
+ };
87
148
  }
88
149
  return {
89
- content: [
90
- {
91
- type: "text",
92
- text: body,
93
- },
94
- ],
150
+ content: [{ type: "text", text: grant.message }],
95
151
  details: {
96
152
  skillName: params.name,
97
153
  serverName: skill.serverName,
98
- activatedTools: skill.allowedTools,
154
+ activatedTools: [],
155
+ error: grant.status === "declined" ? "approval_declined" : "approval_unavailable",
156
+ verified: verifiable,
157
+ resourceSetRotated: rotated,
99
158
  },
100
159
  };
101
160
  },
102
161
  };
103
162
  }
104
- //# sourceMappingURL=load-skill-tool.js.map
163
+ /**
164
+ * Rebuild skill metadata from the entry the server just served.
165
+ *
166
+ * Both the gated tool names and the content fingerprint come from the verified
167
+ * entry rather than the discovery-time copy, so an `allowed-tools` list that
168
+ * grew since discovery cannot ride in on an approval the user gave for a
169
+ * smaller one.
170
+ */
171
+ function withFreshContent(skill, entry) {
172
+ const declared = entry.frontmatter["allowed-tools"];
173
+ const allowedTools = Array.isArray(declared)
174
+ ? declared.filter((value) => typeof value === "string")
175
+ : typeof declared === "string" && declared.trim().length > 0
176
+ ? declared.trim().split(/\s+/)
177
+ : [];
178
+ return {
179
+ ...skill,
180
+ allowedTools,
181
+ contentFingerprint: resourceSetFingerprint(entry),
182
+ };
183
+ }
@@ -1,11 +1,7 @@
1
1
  import type { ExtensionAPI } from "@sammorrowdrums/mcpi";
2
- import type { McpClientManager } from "../mcp/index.js";
2
+ import type { McpClientManager } from "../mcp/client-manager.js";
3
+ import type { McpPolicy } from "../mcp/policy.js";
3
4
  /**
4
- * Register MCP tools as Pi tool proxies.
5
- *
6
- * Each registered tool forwards calls to the MCP server via `client.callTool()`.
7
- * Uses `Type.Unsafe()` to pass the MCP tool's original JSON Schema through
8
- * to Pi, preserving property names and types for the model.
5
+ * Register MCP tools as deferred mcpi proxies backed by the shared policy boundary.
9
6
  */
10
- export declare function registerMcpToolProxies(toolNames: string[], mcpManager: McpClientManager, pi: ExtensionAPI): string[];
11
- //# sourceMappingURL=mcp-tool-proxy.d.ts.map
7
+ export declare function registerMcpToolProxies(toolNames: string[], manager: McpClientManager, policy: McpPolicy, pi: ExtensionAPI): string[];
@@ -1,116 +1,41 @@
1
- import { writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
1
  import { Type } from "typebox";
5
- /** Threshold in chars above which tool output is written to a tmp file. */
6
- const LARGE_OUTPUT_THRESHOLD = 10_000;
2
+ import { renderTerminalCallToolResult } from "../mcp/call-tool-result.js";
7
3
  /**
8
- * Register MCP tools as Pi tool proxies.
9
- *
10
- * Each registered tool forwards calls to the MCP server via `client.callTool()`.
11
- * Uses `Type.Unsafe()` to pass the MCP tool's original JSON Schema through
12
- * to Pi, preserving property names and types for the model.
4
+ * Register MCP tools as deferred mcpi proxies backed by the shared policy boundary.
13
5
  */
14
- export function registerMcpToolProxies(toolNames, mcpManager, pi) {
6
+ export function registerMcpToolProxies(toolNames, manager, policy, pi) {
15
7
  const registered = [];
16
- const allTools = mcpManager.getTools();
17
- // Check which tools are already registered to avoid double-registration
18
- const existingTools = new Set(pi.getAllTools().map((t) => t.name));
8
+ const existingTools = new Set(pi.getAllTools().map((tool) => tool.name));
9
+ const toolsByName = new Map(manager.getTools().map((tool) => [tool.name, tool]));
19
10
  for (const name of toolNames) {
20
11
  if (existingTools.has(name)) {
21
12
  registered.push(name);
22
13
  continue;
23
14
  }
24
- const mcpTool = allTools.find((t) => t.name === name);
25
- if (!mcpTool)
15
+ const tool = toolsByName.get(name);
16
+ if (!tool)
26
17
  continue;
27
- pi.registerTool(createMcpToolProxy(mcpTool, mcpManager));
18
+ pi.registerTool(createMcpToolProxy(policy, tool));
28
19
  registered.push(name);
29
20
  }
30
21
  return registered;
31
22
  }
32
- function createMcpToolProxy(mcpTool, mcpManager) {
33
- // Pass through the MCP tool's JSON Schema directly via Type.Unsafe()
34
- // This preserves the original property names and types for the model
35
- const inputSchema = mcpTool.inputSchema;
36
- const parameters = Type.Unsafe({
37
- type: "object",
38
- properties: inputSchema.properties ?? {},
39
- required: inputSchema.required ?? [],
40
- });
23
+ function createMcpToolProxy(policy, tool) {
41
24
  return {
42
- name: mcpTool.name,
43
- label: mcpTool.name,
44
- description: mcpTool.description ?? `MCP tool from ${mcpTool.serverName}`,
25
+ name: tool.name,
26
+ label: tool.name,
27
+ description: tool.description ?? `MCP tool from ${tool.serverName}`,
45
28
  deferred: true,
46
- parameters,
47
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
48
- const client = mcpManager.getClient(mcpTool.serverName);
49
- if (!client) {
50
- return {
51
- content: [
52
- {
53
- type: "text",
54
- text: `MCP server "${mcpTool.serverName}" is not connected.`,
55
- },
56
- ],
57
- details: {
58
- serverName: mcpTool.serverName,
59
- toolName: mcpTool.name,
60
- error: "disconnected",
61
- },
62
- };
63
- }
64
- try {
65
- const result = await client.callTool({
66
- name: mcpTool.name,
67
- arguments: params,
68
- });
69
- // Prefer structuredContent when available
70
- let text;
71
- if (result.structuredContent) {
72
- text = JSON.stringify(result.structuredContent, null, 2);
73
- }
74
- else if (Array.isArray(result.content)) {
75
- text = result.content
76
- .map((c) => {
77
- if (typeof c === "object" && c !== null && "text" in c) {
78
- return String(c.text);
79
- }
80
- return JSON.stringify(c);
81
- })
82
- .join("\n");
83
- }
84
- else {
85
- text = JSON.stringify(result);
86
- }
87
- // Write large outputs to tmp file to avoid bloating context
88
- if (text.length > LARGE_OUTPUT_THRESHOLD) {
89
- const tmpPath = join(tmpdir(), `mcp-${mcpTool.name}-${Date.now()}.json`);
90
- writeFileSync(tmpPath, text, "utf-8");
91
- text = `Output too large (${text.length} chars). Written to: ${tmpPath}`;
92
- }
93
- return {
94
- content: [{ type: "text", text }],
95
- details: { serverName: mcpTool.serverName, toolName: mcpTool.name },
96
- };
97
- }
98
- catch (err) {
99
- return {
100
- content: [
101
- {
102
- type: "text",
103
- text: `MCP tool "${mcpTool.name}" failed: ${err.message}`,
104
- },
105
- ],
106
- details: {
107
- serverName: mcpTool.serverName,
108
- toolName: mcpTool.name,
109
- error: err.message,
110
- },
111
- };
112
- }
29
+ parameters: Type.Unsafe(tool.inputSchema),
30
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
31
+ const terminal = await policy.callTool({
32
+ source: "proxy",
33
+ serverName: tool.serverName,
34
+ toolName: tool.name,
35
+ args: params,
36
+ ...(signal ? { signal } : {}),
37
+ });
38
+ return renderTerminalCallToolResult(terminal);
113
39
  },
114
40
  };
115
41
  }
116
- //# sourceMappingURL=mcp-tool-proxy.js.map
@@ -0,0 +1,91 @@
1
+ import type { McpPolicy } from "../../mcp/policy.js";
2
+ import { type DirectoryResource, type SkillEntry } from "./protocol.js";
3
+ /** What a listing pass observed, including why it may be incomplete. */
4
+ export interface SkillsListing {
5
+ /** Entries that passed pre-fetch validation. */
6
+ readonly skills: SkillEntry[];
7
+ /**
8
+ * Entries the server returned that this host refused to consider, with the
9
+ * reason. Surfaced rather than silently dropped so a malformed skill is
10
+ * visible instead of just missing.
11
+ */
12
+ readonly rejected: {
13
+ readonly uri: string;
14
+ readonly reason: string;
15
+ }[];
16
+ /**
17
+ * True when the server had more pages than this host was willing to walk.
18
+ *
19
+ * SEP-2640 forbids treating a listing as proof of what a server has; this
20
+ * flag is how that uncertainty travels with the data instead of being
21
+ * flattened into "these are the skills".
22
+ */
23
+ readonly truncated: boolean;
24
+ /** Whether this listing came from the in-memory freshness cache. */
25
+ readonly fromCache: boolean;
26
+ }
27
+ export interface SkillsExtensionClientOptions {
28
+ readonly policy: McpPolicy;
29
+ /** Injectable clock; defaults to `Date.now`. */
30
+ readonly now?: () => number;
31
+ }
32
+ /**
33
+ * A client for the draft SEP-2640 skills extension.
34
+ *
35
+ * Every request goes through {@link McpPolicy}, which re-checks the negotiated
36
+ * capability immediately before dispatch. This class therefore never caches
37
+ * "server X supports skills" — it caches only listing *content*, and only when
38
+ * the server asked it to with terms this host is willing to honour.
39
+ */
40
+ export declare class SkillsExtensionClient {
41
+ private readonly policy;
42
+ private readonly now;
43
+ private readonly listCache;
44
+ constructor(options: SkillsExtensionClientOptions);
45
+ /** True when the server declared the extension on the live connection. */
46
+ supports(serverName: string): boolean;
47
+ /** True when the server additionally declared `directoryRead: true`. */
48
+ supportsDirectoryRead(serverName: string): boolean;
49
+ /** The declared extension settings, for diagnostics. */
50
+ capability(serverName: string): Record<string, unknown> | undefined;
51
+ /**
52
+ * Walk `skills/list` to completion, validating each entry before it is
53
+ * admitted.
54
+ *
55
+ * Pagination stops at {@link MAX_SKILL_LIST_PAGES}; the result is then marked
56
+ * `truncated` rather than presented as the whole set. A server that returns
57
+ * the same cursor twice is also treated as truncated, which stops a cursor
58
+ * loop from becoming an unbounded request stream.
59
+ */
60
+ listSkills(serverName: string, signal?: AbortSignal): Promise<SkillsListing>;
61
+ /**
62
+ * Fetch one skill's authoritative entry with `skills/get`.
63
+ *
64
+ * Always goes to the server: `skills/get` is what re-establishes current
65
+ * digests after a verification failure, so serving it from cache would defeat
66
+ * the recovery path the spec defines.
67
+ */
68
+ getSkill(serverName: string, uri: string, signal?: AbortSignal): Promise<SkillEntry>;
69
+ /**
70
+ * Enumerate a skill directory, when the server declared `directoryRead`.
71
+ *
72
+ * Directory listings are navigational only. Nothing they return becomes
73
+ * readable: a file is readable because the skill entry lists it with a
74
+ * digest, and a directory cannot add entries to that list.
75
+ */
76
+ readDirectory(serverName: string, uri: string, signal?: AbortSignal): Promise<DirectoryResource[]>;
77
+ /** Drop cached listings; call on disconnect or reset. */
78
+ clearCache(serverName?: string): void;
79
+ private readCache;
80
+ /**
81
+ * Cache a listing only when the server asked for it in terms this host
82
+ * understands.
83
+ *
84
+ * `cacheScope`'s value set is defined by SEP-2549, not SEP-2640, so an
85
+ * unrecognised scope is treated as "do not cache" rather than guessed at. A
86
+ * truncated listing is never cached, because caching an incomplete answer is
87
+ * exactly the "empty listing means no skills" mistake the spec warns against,
88
+ * just with a longer lifetime.
89
+ */
90
+ private writeCache;
91
+ }