@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,85 @@
1
+ import { parseFrontmatter } from "@sammorrowdrums/mcpi";
2
+ /**
3
+ * Discover skills from a connected MCP server by reading its resources.
4
+ *
5
+ * Looks for resources with `skill://` URIs ending in `/SKILL.md`,
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.
10
+ */
11
+ export async function discoverSkillsFromServer(policy, serverName, log = console.error, signal) {
12
+ const skills = [];
13
+ let skillResources;
14
+ try {
15
+ skillResources = await policy.listSkillResources(serverName, signal);
16
+ }
17
+ catch {
18
+ log(`[skills] Server "${serverName}" does not support resources/list, skipping skill discovery`);
19
+ return skills;
20
+ }
21
+ if (skillResources.length === 0)
22
+ return skills;
23
+ for (const resource of skillResources) {
24
+ try {
25
+ const result = await policy.readResource({
26
+ source: "skill-discovery",
27
+ serverName,
28
+ uri: resource.uri,
29
+ ...(signal ? { signal } : {}),
30
+ });
31
+ const textContent = result.contents.find((c) => "text" in c);
32
+ if (!textContent) {
33
+ log(`[skills] Skill resource ${resource.uri} returned no text content, skipping`);
34
+ continue;
35
+ }
36
+ const parsed = parseFrontmatter(textContent.text);
37
+ const fm = parsed.frontmatter;
38
+ const name = fm.name ?? resource.name;
39
+ const description = fm.description ?? "";
40
+ const allowedTools = parseAllowedTools(fm);
41
+ if (!name) {
42
+ log(`[skills] Skill at ${resource.uri} has no name, skipping`);
43
+ continue;
44
+ }
45
+ skills.push({
46
+ name,
47
+ description,
48
+ uri: resource.uri,
49
+ serverName,
50
+ allowedTools,
51
+ });
52
+ log(`[skills] Discovered skill "${name}" from "${serverName}" (${allowedTools.length} gated tools)`);
53
+ }
54
+ catch (err) {
55
+ log(`[skills] Failed to read skill ${resource.uri}: ${err.message}`);
56
+ }
57
+ }
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;
62
+ }
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");
83
+ }
84
+ return [];
85
+ }
@@ -0,0 +1,13 @@
1
+ import type { McpSkillMetadata } from "./skill-registry.js";
2
+ /**
3
+ * Format MCP-discovered skills for inclusion in the system prompt.
4
+ *
5
+ * Produces XML matching Pi's native `formatSkillsForPrompt` structure,
6
+ * but references the `load_skill` tool instead of `read` and uses
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.
12
+ */
13
+ export declare function formatMcpSkillsForPrompt(skills: McpSkillMetadata[]): string;
@@ -0,0 +1,42 @@
1
+ function escapeXml(str) {
2
+ return str
3
+ .replace(/&/g, "&amp;")
4
+ .replace(/</g, "&lt;")
5
+ .replace(/>/g, "&gt;")
6
+ .replace(/"/g, "&quot;")
7
+ .replace(/'/g, "&apos;");
8
+ }
9
+ /**
10
+ * Format MCP-discovered skills for inclusion in the system prompt.
11
+ *
12
+ * Produces XML matching Pi's native `formatSkillsForPrompt` structure,
13
+ * but references the `load_skill` tool instead of `read` and uses
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.
19
+ */
20
+ export function formatMcpSkillsForPrompt(skills) {
21
+ if (skills.length === 0)
22
+ return "";
23
+ const lines = [
24
+ "",
25
+ "",
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.",
30
+ "",
31
+ "<available_mcp_skills>",
32
+ ];
33
+ for (const skill of skills) {
34
+ lines.push(" <skill>");
35
+ lines.push(` <name>${escapeXml(skill.name)}</name>`);
36
+ lines.push(` <description>${escapeXml(skill.description)}</description>`);
37
+ lines.push(` <server>${escapeXml(skill.serverName)}</server>`);
38
+ lines.push(" </skill>");
39
+ }
40
+ lines.push("</available_mcp_skills>");
41
+ return lines.join("\n");
42
+ }
@@ -0,0 +1,6 @@
1
+ export { SkillRegistry, type McpSkillMetadata, type SkillNameCollision } from "./skill-registry.js";
2
+ export { formatMcpSkillsForPrompt } from "./format.js";
3
+ export { createLoadSkillTool, type LoadSkillDeps, type LoadSkillDetails, } from "./load-skill-tool.js";
4
+ export { discoverSkillsFromServer } from "./discover.js";
5
+ export { registerMcpToolProxies } from "./mcp-tool-proxy.js";
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";
@@ -0,0 +1,6 @@
1
+ export { SkillRegistry } from "./skill-registry.js";
2
+ export { formatMcpSkillsForPrompt } from "./format.js";
3
+ export { createLoadSkillTool, } from "./load-skill-tool.js";
4
+ export { discoverSkillsFromServer } from "./discover.js";
5
+ export { registerMcpToolProxies } from "./mcp-tool-proxy.js";
6
+ export { SKILLS_EXTENSION_NAME, SKILLS_EXTENSION_REVISION, SKILLS_EXTENSION_STATUS, SkillsExtensionClient, describeNegotiation, discoverSkillsViaExtension, loadSkillDocument, readSkillResource, skillsExtensionDiagnostic, } from "./sep2640/index.js";
@@ -0,0 +1,56 @@
1
+ import type { AgentToolResult, ExtensionContext } from "@sammorrowdrums/mcpi";
2
+ import { Type, type Static } from "typebox";
3
+ import type { McpPolicy } from "../mcp/policy.js";
4
+ import type { SkillsExtensionClient } from "./sep2640/client.js";
5
+ import type { SkillRegistry } from "./skill-registry.js";
6
+ declare const LoadSkillParams: Type.TObject<{
7
+ name: Type.TString;
8
+ }>;
9
+ type LoadSkillInput = Static<typeof LoadSkillParams>;
10
+ export interface LoadSkillDeps {
11
+ registry: SkillRegistry;
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;
21
+ }
22
+ export interface LoadSkillDetails {
23
+ skillName: string;
24
+ serverName?: string;
25
+ activatedTools?: string[];
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;
31
+ }
32
+ /**
33
+ * Create the load_skill tool definition.
34
+ *
35
+ * When the model calls this tool, it:
36
+ * 1. Looks up the skill in the registry
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
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.
45
+ */
46
+ export declare function createLoadSkillTool(deps: LoadSkillDeps): {
47
+ name: string;
48
+ label: string;
49
+ description: string;
50
+ promptSnippet: string;
51
+ parameters: Type.TObject<{
52
+ name: Type.TString;
53
+ }>;
54
+ execute(_toolCallId: string, params: LoadSkillInput, signal: AbortSignal | undefined, _onUpdate: undefined, _ctx: ExtensionContext): Promise<AgentToolResult<LoadSkillDetails>>;
55
+ };
56
+ export {};
@@ -0,0 +1,183 @@
1
+ import { stripFrontmatter } from "@sammorrowdrums/mcpi";
2
+ import { Type } from "typebox";
3
+ import { loadSkillDocument, SkillFetchBudget } from "./sep2640/load.js";
4
+ import { resourceSetFingerprint } from "./sep2640/protocol.js";
5
+ const LoadSkillParams = Type.Object({
6
+ name: Type.String({ description: "Name of the MCP skill to load" }),
7
+ });
8
+ /**
9
+ * Create the load_skill tool definition.
10
+ *
11
+ * When the model calls this tool, it:
12
+ * 1. Looks up the skill in the registry
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
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.
21
+ */
22
+ export function createLoadSkillTool(deps) {
23
+ const { registry, policy, skillsClient } = deps;
24
+ return {
25
+ name: "load_skill",
26
+ label: "Load Skill",
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.",
29
+ parameters: LoadSkillParams,
30
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
31
+ const skill = registry.get(params.name);
32
+ if (!skill) {
33
+ const available = registry
34
+ .getAll()
35
+ .map((s) => s.name)
36
+ .join(", ");
37
+ return {
38
+ content: [
39
+ {
40
+ type: "text",
41
+ text: `Skill "${params.name}" not found. Available skills: ${available || "(none)"}`,
42
+ },
43
+ ],
44
+ details: { skillName: params.name, error: "not_found" },
45
+ };
46
+ }
47
+ const verifiable = skill.origin === "sep2640";
48
+ if (verifiable && !skillsClient) {
49
+ return {
50
+ content: [
51
+ {
52
+ type: "text",
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.`,
54
+ },
55
+ ],
56
+ details: {
57
+ skillName: params.name,
58
+ serverName: skill.serverName,
59
+ error: "verification_unavailable",
60
+ verified: false,
61
+ },
62
+ };
63
+ }
64
+ let body;
65
+ let entry;
66
+ let rotated = false;
67
+ try {
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",
105
+ },
106
+ };
107
+ }
108
+ body = stripFrontmatter(textContent.text);
109
+ }
110
+ }
111
+ catch (err) {
112
+ return {
113
+ content: [
114
+ {
115
+ type: "text",
116
+ text: `Failed to read skill "${params.name}" from server "${skill.serverName}": ${err.message}`,
117
+ },
118
+ ],
119
+ details: {
120
+ skillName: params.name,
121
+ serverName: skill.serverName,
122
+ error: err.message,
123
+ verified: false,
124
+ },
125
+ };
126
+ }
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
+ };
148
+ }
149
+ return {
150
+ content: [{ type: "text", text: grant.message }],
151
+ details: {
152
+ skillName: params.name,
153
+ serverName: skill.serverName,
154
+ activatedTools: [],
155
+ error: grant.status === "declined" ? "approval_declined" : "approval_unavailable",
156
+ verified: verifiable,
157
+ resourceSetRotated: rotated,
158
+ },
159
+ };
160
+ },
161
+ };
162
+ }
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
+ }
@@ -0,0 +1,7 @@
1
+ import type { ExtensionAPI } from "@sammorrowdrums/mcpi";
2
+ import type { McpClientManager } from "../mcp/client-manager.js";
3
+ import type { McpPolicy } from "../mcp/policy.js";
4
+ /**
5
+ * Register MCP tools as deferred mcpi proxies backed by the shared policy boundary.
6
+ */
7
+ export declare function registerMcpToolProxies(toolNames: string[], manager: McpClientManager, policy: McpPolicy, pi: ExtensionAPI): string[];
@@ -0,0 +1,41 @@
1
+ import { Type } from "typebox";
2
+ import { renderTerminalCallToolResult } from "../mcp/call-tool-result.js";
3
+ /**
4
+ * Register MCP tools as deferred mcpi proxies backed by the shared policy boundary.
5
+ */
6
+ export function registerMcpToolProxies(toolNames, manager, policy, pi) {
7
+ const registered = [];
8
+ const existingTools = new Set(pi.getAllTools().map((tool) => tool.name));
9
+ const toolsByName = new Map(manager.getTools().map((tool) => [tool.name, tool]));
10
+ for (const name of toolNames) {
11
+ if (existingTools.has(name)) {
12
+ registered.push(name);
13
+ continue;
14
+ }
15
+ const tool = toolsByName.get(name);
16
+ if (!tool)
17
+ continue;
18
+ pi.registerTool(createMcpToolProxy(policy, tool));
19
+ registered.push(name);
20
+ }
21
+ return registered;
22
+ }
23
+ function createMcpToolProxy(policy, tool) {
24
+ return {
25
+ name: tool.name,
26
+ label: tool.name,
27
+ description: tool.description ?? `MCP tool from ${tool.serverName}`,
28
+ deferred: true,
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);
39
+ },
40
+ };
41
+ }
@@ -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
+ }