@sammorrowdrums/mcpi-ext 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +137 -47
- package/dist/code-mode/eligibility.d.ts +35 -0
- package/dist/code-mode/eligibility.js +64 -0
- package/dist/code-mode/executor.d.ts +53 -0
- package/dist/code-mode/executor.js +185 -0
- package/dist/code-mode/index.d.ts +107 -0
- package/dist/code-mode/index.js +267 -0
- package/dist/code-mode/isolated-vm.d.ts +65 -0
- package/dist/code-mode/isolated-vm.js +116 -0
- package/dist/code-mode/tools.d.ts +48 -0
- package/dist/code-mode/tools.js +73 -0
- package/dist/code-mode/type-hints.d.ts +20 -0
- package/dist/code-mode/type-hints.js +205 -0
- package/dist/docker-e2e.d.ts +54 -0
- package/dist/docker-e2e.js +234 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +227 -0
- package/dist/mcp/call-tool-result.d.ts +10 -0
- package/dist/mcp/call-tool-result.js +70 -0
- package/dist/mcp/client-factory.d.ts +59 -0
- package/dist/mcp/client-factory.js +76 -0
- package/dist/mcp/client-manager.d.ts +88 -0
- package/dist/mcp/client-manager.js +307 -0
- package/dist/mcp/config-loader.d.ts +7 -0
- package/dist/mcp/config-loader.js +42 -0
- package/dist/mcp/config.d.ts +56 -0
- package/dist/mcp/config.js +42 -0
- package/dist/mcp/gateway-defaults.d.ts +14 -0
- package/dist/mcp/gateway-defaults.js +21 -0
- package/dist/mcp/host-approval.d.ts +16 -0
- package/dist/mcp/host-approval.js +19 -0
- package/dist/mcp/host-elicitation.d.ts +19 -0
- package/dist/mcp/host-elicitation.js +94 -0
- package/dist/mcp/index.d.ts +8 -0
- package/dist/mcp/index.js +7 -0
- package/dist/mcp/policy.d.ts +314 -0
- package/dist/mcp/policy.js +848 -0
- package/dist/routing/facilities.d.ts +109 -0
- package/dist/routing/facilities.js +175 -0
- package/dist/routing/format.d.ts +24 -0
- package/dist/routing/format.js +64 -0
- package/dist/routing/index.d.ts +4 -0
- package/dist/routing/index.js +4 -0
- package/dist/routing/seam.d.ts +35 -0
- package/dist/routing/seam.js +28 -0
- package/dist/routing/tripwire.d.ts +42 -0
- package/dist/routing/tripwire.js +80 -0
- package/dist/skills/discover.d.ts +12 -0
- package/dist/skills/discover.js +85 -0
- package/dist/skills/format.d.ts +13 -0
- package/dist/skills/format.js +42 -0
- package/dist/skills/index.d.ts +6 -0
- package/dist/skills/index.js +6 -0
- package/dist/skills/load-skill-tool.d.ts +56 -0
- package/dist/skills/load-skill-tool.js +183 -0
- package/dist/skills/mcp-tool-proxy.d.ts +7 -0
- package/dist/skills/mcp-tool-proxy.js +41 -0
- package/dist/skills/sep2640/client.d.ts +91 -0
- package/dist/skills/sep2640/client.js +160 -0
- package/dist/skills/sep2640/discover.d.ts +33 -0
- package/dist/skills/sep2640/discover.js +82 -0
- package/dist/skills/sep2640/index.d.ts +13 -0
- package/dist/skills/sep2640/index.js +13 -0
- package/dist/skills/sep2640/integrity.d.ts +58 -0
- package/dist/skills/sep2640/integrity.js +185 -0
- package/dist/skills/sep2640/load.d.ts +61 -0
- package/dist/skills/sep2640/load.js +90 -0
- package/dist/skills/sep2640/protocol.d.ts +146 -0
- package/dist/skills/sep2640/protocol.js +214 -0
- package/dist/skills/sep2640/spec.d.ts +76 -0
- package/dist/skills/sep2640/spec.js +88 -0
- package/dist/skills/skill-registry.d.ts +85 -0
- package/dist/skills/skill-registry.js +85 -0
- package/dist/tool-cli/bridge.d.ts +14 -0
- package/dist/tool-cli/bridge.js +196 -0
- package/dist/tool-cli/format.d.ts +20 -0
- package/dist/tool-cli/format.js +73 -0
- package/dist/tool-cli/index.d.ts +7 -0
- package/dist/tool-cli/index.js +5 -0
- package/dist/tool-cli/provider.d.ts +21 -0
- package/dist/tool-cli/provider.js +114 -0
- package/dist/tool-cli/startup.d.ts +22 -0
- package/dist/tool-cli/startup.js +47 -0
- package/package.json +78 -26
- package/.dockerignore +0 -6
- package/.github/dependabot.yml +0 -25
- package/.github/workflows/ci.yml +0 -50
- package/.hadolint.yaml +0 -6
- package/.prettierignore +0 -4
- package/.prettierrc +0 -7
- package/AGENTS.md +0 -118
- package/DECISIONS.md +0 -99
- package/Dockerfile +0 -39
- package/aube-lock.yaml +0 -4146
- package/docs/code-mode.md +0 -38
- package/docs/skills.md +0 -57
- package/docs/tool-cli.md +0 -54
- package/eslint.config.js +0 -27
- package/images/banner.webp +0 -0
- package/images/code-c-maude.webp +0 -0
- package/images/code-mode-histogram.png +0 -0
- package/images/nuclear-mcp-football.webp +0 -0
- package/images/skills-enabling-mcp-tools.png +0 -0
- package/images/the-skill-dealer.webp +0 -0
- package/images/tool-cli-grep.png +0 -0
- package/mise.toml +0 -53
- package/scripts/docker-entrypoint.sh +0 -29
- package/scripts/test-echo-server.mjs +0 -28
- package/scripts/test-mcp-integration.mjs +0 -59
- package/scripts/test-tool-cli-smoke.mjs +0 -93
- package/src/code-mode/code-mode-integration.test.ts +0 -135
- package/src/code-mode/eligibility.test.ts +0 -60
- package/src/code-mode/eligibility.ts +0 -16
- package/src/code-mode/executor.test.ts +0 -168
- package/src/code-mode/executor.ts +0 -163
- package/src/code-mode/index.ts +0 -208
- package/src/code-mode/tools.ts +0 -110
- package/src/code-mode/type-hints.test.ts +0 -187
- package/src/code-mode/type-hints.ts +0 -249
- package/src/docker-e2e.test.ts +0 -195
- package/src/docker-e2e.ts +0 -277
- package/src/index.test.ts +0 -8
- package/src/index.ts +0 -179
- package/src/mcp/client-manager.test.ts +0 -250
- package/src/mcp/client-manager.ts +0 -199
- package/src/mcp/config-loader.test.ts +0 -115
- package/src/mcp/config-loader.ts +0 -46
- package/src/mcp/config.ts +0 -40
- package/src/mcp/index.ts +0 -4
- package/src/skills/discover.ts +0 -81
- package/src/skills/format.test.ts +0 -70
- package/src/skills/format.ts +0 -42
- package/src/skills/index.ts +0 -9
- package/src/skills/load-skill-tool.ts +0 -136
- package/src/skills/mcp-tool-proxy.ts +0 -143
- package/src/skills/skill-integration.test.ts +0 -139
- package/src/skills/skill-registry.test.ts +0 -83
- package/src/skills/skill-registry.ts +0 -69
- package/src/test-servers/weather-server.ts +0 -171
- package/src/test-servers/weather-stdio.ts +0 -11
- package/src/tool-cli/format.test.ts +0 -33
- package/src/tool-cli/format.ts +0 -56
- package/src/tool-cli/index.ts +0 -3
- package/tsconfig.json +0 -21
|
@@ -0,0 +1,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, "&")
|
|
4
|
+
.replace(/</g, "<")
|
|
5
|
+
.replace(/>/g, ">")
|
|
6
|
+
.replace(/"/g, """)
|
|
7
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|