@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,234 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
const DOCKER_IMAGE = "mcpi-ext-e2e";
|
|
4
|
+
const DockerE2EParams = Type.Object({
|
|
5
|
+
task: Type.String({ description: "The task/prompt to run inside the sandbox" }),
|
|
6
|
+
token: Type.String({
|
|
7
|
+
description: "GitHub token for Copilot model access",
|
|
8
|
+
}),
|
|
9
|
+
model: Type.Optional(Type.String({
|
|
10
|
+
description: "Model to use (e.g. claude-sonnet-4-5). Defaults to the provider default.",
|
|
11
|
+
})),
|
|
12
|
+
timeout: Type.Optional(Type.Number({
|
|
13
|
+
description: "Timeout in seconds. Defaults to 300 (5 minutes).",
|
|
14
|
+
})),
|
|
15
|
+
});
|
|
16
|
+
export function parseJsonlLine(line) {
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
if (!trimmed)
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(trimmed);
|
|
22
|
+
return { type: parsed.type ?? "unknown", timestamp: Date.now(), raw: trimmed };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { type: "raw", timestamp: Date.now(), raw: trimmed };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function extractAssistantText(log) {
|
|
29
|
+
const parts = [];
|
|
30
|
+
for (const entry of log) {
|
|
31
|
+
if (entry.type !== "message_end")
|
|
32
|
+
continue;
|
|
33
|
+
try {
|
|
34
|
+
const event = JSON.parse(entry.raw);
|
|
35
|
+
const msg = event.message;
|
|
36
|
+
if (msg?.role !== "assistant")
|
|
37
|
+
continue;
|
|
38
|
+
for (const part of msg.content ?? []) {
|
|
39
|
+
if (part.type === "text")
|
|
40
|
+
parts.push(part.text);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// skip malformed
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return parts.join("\n");
|
|
48
|
+
}
|
|
49
|
+
export function extractUsage(log) {
|
|
50
|
+
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
51
|
+
for (const entry of log) {
|
|
52
|
+
if (entry.type !== "message_end")
|
|
53
|
+
continue;
|
|
54
|
+
try {
|
|
55
|
+
const event = JSON.parse(entry.raw);
|
|
56
|
+
const msg = event.message;
|
|
57
|
+
if (msg?.role !== "assistant")
|
|
58
|
+
continue;
|
|
59
|
+
usage.turns++;
|
|
60
|
+
const u = msg.usage;
|
|
61
|
+
if (u) {
|
|
62
|
+
usage.input += u.input ?? 0;
|
|
63
|
+
usage.output += u.output ?? 0;
|
|
64
|
+
usage.cacheRead += u.cacheRead ?? 0;
|
|
65
|
+
usage.cacheWrite += u.cacheWrite ?? 0;
|
|
66
|
+
usage.cost += u.cost?.total ?? 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// skip
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return usage;
|
|
74
|
+
}
|
|
75
|
+
export function extractModel(log) {
|
|
76
|
+
for (const entry of log) {
|
|
77
|
+
if (entry.type !== "message_end")
|
|
78
|
+
continue;
|
|
79
|
+
try {
|
|
80
|
+
const event = JSON.parse(entry.raw);
|
|
81
|
+
if (event.message?.model)
|
|
82
|
+
return event.message.model;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// skip
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
export function buildDockerArgs(image, params) {
|
|
91
|
+
const args = ["run", "--rm", "-e", `GITHUB_TOKEN=${params.token}`];
|
|
92
|
+
if (params.model) {
|
|
93
|
+
args.push("-e", `PI_MODEL=${params.model}`);
|
|
94
|
+
}
|
|
95
|
+
args.push(image, params.task);
|
|
96
|
+
return args;
|
|
97
|
+
}
|
|
98
|
+
export const dockerE2ETool = {
|
|
99
|
+
name: "docker_e2e",
|
|
100
|
+
label: "Docker E2E",
|
|
101
|
+
description: "Run a task end-to-end inside an isolated Docker container with pi and this project's extensions. Returns the full run log for evaluation.",
|
|
102
|
+
promptSnippet: "Run an isolated e2e task in Docker. Requires a GitHub token and task prompt.",
|
|
103
|
+
parameters: DockerE2EParams,
|
|
104
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
105
|
+
const startTime = Date.now();
|
|
106
|
+
const timeoutMs = (params.timeout ?? 300) * 1000;
|
|
107
|
+
const log = [];
|
|
108
|
+
let lastAssistantText = "";
|
|
109
|
+
const dockerArgs = buildDockerArgs(DOCKER_IMAGE, params);
|
|
110
|
+
const exitCode = await new Promise((resolve) => {
|
|
111
|
+
const proc = spawn("docker", dockerArgs, {
|
|
112
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
113
|
+
});
|
|
114
|
+
let buffer = "";
|
|
115
|
+
let timedOut = false;
|
|
116
|
+
let timeoutHandle;
|
|
117
|
+
if (timeoutMs > 0) {
|
|
118
|
+
timeoutHandle = setTimeout(() => {
|
|
119
|
+
timedOut = true;
|
|
120
|
+
proc.kill("SIGTERM");
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
if (!proc.killed)
|
|
123
|
+
proc.kill("SIGKILL");
|
|
124
|
+
}, 5000);
|
|
125
|
+
}, timeoutMs);
|
|
126
|
+
}
|
|
127
|
+
const processLine = (line) => {
|
|
128
|
+
const entry = parseJsonlLine(line);
|
|
129
|
+
if (!entry)
|
|
130
|
+
return;
|
|
131
|
+
log.push(entry);
|
|
132
|
+
if (entry.type === "message_end") {
|
|
133
|
+
const text = extractAssistantText(log);
|
|
134
|
+
if (text)
|
|
135
|
+
lastAssistantText = text;
|
|
136
|
+
}
|
|
137
|
+
onUpdate?.({
|
|
138
|
+
content: [
|
|
139
|
+
{
|
|
140
|
+
type: "text",
|
|
141
|
+
text: lastAssistantText || `(running… ${log.length} events)`,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
details: {
|
|
145
|
+
exitCode: null,
|
|
146
|
+
log,
|
|
147
|
+
duration: Date.now() - startTime,
|
|
148
|
+
model: extractModel(log),
|
|
149
|
+
usage: extractUsage(log),
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
proc.stdout.on("data", (data) => {
|
|
154
|
+
buffer += data.toString();
|
|
155
|
+
const lines = buffer.split("\n");
|
|
156
|
+
buffer = lines.pop() ?? "";
|
|
157
|
+
for (const line of lines)
|
|
158
|
+
processLine(line);
|
|
159
|
+
});
|
|
160
|
+
proc.stderr.on("data", (data) => {
|
|
161
|
+
const text = data.toString().trim();
|
|
162
|
+
if (text) {
|
|
163
|
+
log.push({ type: "stderr", timestamp: Date.now(), raw: text });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
proc.on("close", (code) => {
|
|
167
|
+
if (timeoutHandle)
|
|
168
|
+
clearTimeout(timeoutHandle);
|
|
169
|
+
if (buffer.trim())
|
|
170
|
+
processLine(buffer);
|
|
171
|
+
if (timedOut) {
|
|
172
|
+
log.push({
|
|
173
|
+
type: "timeout",
|
|
174
|
+
timestamp: Date.now(),
|
|
175
|
+
raw: `Timed out after ${params.timeout ?? 300}s`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
resolve(timedOut ? null : (code ?? 1));
|
|
179
|
+
});
|
|
180
|
+
proc.on("error", (err) => {
|
|
181
|
+
if (timeoutHandle)
|
|
182
|
+
clearTimeout(timeoutHandle);
|
|
183
|
+
log.push({
|
|
184
|
+
type: "error",
|
|
185
|
+
timestamp: Date.now(),
|
|
186
|
+
raw: err.message,
|
|
187
|
+
});
|
|
188
|
+
resolve(null);
|
|
189
|
+
});
|
|
190
|
+
if (signal) {
|
|
191
|
+
const onAbort = () => {
|
|
192
|
+
proc.kill("SIGTERM");
|
|
193
|
+
setTimeout(() => {
|
|
194
|
+
if (!proc.killed)
|
|
195
|
+
proc.kill("SIGKILL");
|
|
196
|
+
}, 5000);
|
|
197
|
+
};
|
|
198
|
+
if (signal.aborted)
|
|
199
|
+
onAbort();
|
|
200
|
+
else
|
|
201
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
const duration = Date.now() - startTime;
|
|
205
|
+
const assistantText = extractAssistantText(log);
|
|
206
|
+
const usage = extractUsage(log);
|
|
207
|
+
const model = extractModel(log);
|
|
208
|
+
const fullLog = log.map((e) => e.raw).join("\n");
|
|
209
|
+
const summary = [
|
|
210
|
+
`## Docker E2E Run Complete`,
|
|
211
|
+
``,
|
|
212
|
+
`**Exit code:** ${exitCode ?? "killed"}`,
|
|
213
|
+
`**Duration:** ${(duration / 1000).toFixed(1)}s`,
|
|
214
|
+
model ? `**Model:** ${model}` : null,
|
|
215
|
+
usage.turns > 0
|
|
216
|
+
? `**Usage:** ${usage.turns} turns, ↑${usage.input} ↓${usage.output} tokens, $${usage.cost.toFixed(4)}`
|
|
217
|
+
: null,
|
|
218
|
+
``,
|
|
219
|
+
`### Output`,
|
|
220
|
+
assistantText || "(no assistant output)",
|
|
221
|
+
``,
|
|
222
|
+
`### Full Log (${log.length} events)`,
|
|
223
|
+
"```",
|
|
224
|
+
fullLog,
|
|
225
|
+
"```",
|
|
226
|
+
]
|
|
227
|
+
.filter((l) => l !== null)
|
|
228
|
+
.join("\n");
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: "text", text: summary }],
|
|
231
|
+
details: { exitCode, log, duration, model, usage },
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@sammorrowdrums/mcpi";
|
|
2
|
+
import type { BashState } from "./routing/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Report whether the host currently exposes a shell tool to the agent.
|
|
5
|
+
*
|
|
6
|
+
* `getActiveTools` may be missing on an older host or throw if the runner context
|
|
7
|
+
* is not bound, and neither is evidence that bash is absent — so both collapse
|
|
8
|
+
* to `undiscoverable` rather than a false negative.
|
|
9
|
+
*/
|
|
10
|
+
export declare function detectBashState(pi: ExtensionAPI): BashState;
|
|
11
|
+
export default function (pi: ExtensionAPI): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { CodeModeManager } from "./code-mode/index.js";
|
|
2
|
+
import { dockerE2ETool } from "./docker-e2e.js";
|
|
3
|
+
import { McpiHostApproval } from "./mcp/host-approval.js";
|
|
4
|
+
import { McpiHostElicitation } from "./mcp/host-elicitation.js";
|
|
5
|
+
import { McpClientManager, McpPolicy, isSkillsExtensionEnabled, loadMcpConfig, } from "./mcp/index.js";
|
|
6
|
+
import { SkillRegistry, SkillsExtensionClient, createLoadSkillTool, discoverSkillsFromServer, discoverSkillsViaExtension, formatMcpSkillsForPrompt, registerMcpToolProxies, skillsExtensionDiagnostic, } from "./skills/index.js";
|
|
7
|
+
import { ToolCliServer, createPolicyToolProvider, formatToolCliForPrompt, startToolCliBridge, withholdToolCliCredentials, } from "./tool-cli/index.js";
|
|
8
|
+
import { buildExecutionFacilities, formatExecutionFacilities, publishExecutionFacilities, } from "./routing/index.js";
|
|
9
|
+
function errorMessage(err) {
|
|
10
|
+
return err instanceof Error ? err.message : String(err);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Report whether the host currently exposes a shell tool to the agent.
|
|
14
|
+
*
|
|
15
|
+
* `getActiveTools` may be missing on an older host or throw if the runner context
|
|
16
|
+
* is not bound, and neither is evidence that bash is absent — so both collapse
|
|
17
|
+
* to `undiscoverable` rather than a false negative.
|
|
18
|
+
*/
|
|
19
|
+
export function detectBashState(pi) {
|
|
20
|
+
try {
|
|
21
|
+
return pi.getActiveTools().includes("bash")
|
|
22
|
+
? { kind: "registered", toolName: "bash" }
|
|
23
|
+
: { kind: "absent" };
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
return { kind: "undiscoverable", reason: errorMessage(err) };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export default function (pi) {
|
|
30
|
+
pi.registerTool(dockerE2ETool);
|
|
31
|
+
pi.registerFlag("mcp-config", {
|
|
32
|
+
description: "Path to MCP server configuration JSON file",
|
|
33
|
+
type: "string",
|
|
34
|
+
});
|
|
35
|
+
pi.registerFlag("mcp-skills-extension", {
|
|
36
|
+
description: "Enable the DRAFT MCP skills extension (SEP-2640). Unratified; off by default.",
|
|
37
|
+
type: "boolean",
|
|
38
|
+
});
|
|
39
|
+
const hostElicitation = new McpiHostElicitation();
|
|
40
|
+
const hostApproval = new McpiHostApproval();
|
|
41
|
+
const mcpManager = new McpClientManager({ elicitation: hostElicitation });
|
|
42
|
+
const policy = new McpPolicy({ gateway: mcpManager, approvals: hostApproval });
|
|
43
|
+
const skillRegistry = new SkillRegistry();
|
|
44
|
+
const skillsClient = new SkillsExtensionClient({ policy });
|
|
45
|
+
const codeModeManager = new CodeModeManager();
|
|
46
|
+
const { codeSearch, codeExecute } = codeModeManager.createTools();
|
|
47
|
+
// Bridge the shared policy boundary to the ToolProvider interface. tool-cli
|
|
48
|
+
// sees exactly the policy-visible discovered schema set, and every call it
|
|
49
|
+
// makes is re-authorized by the same dispatcher, so it cannot reach a hidden
|
|
50
|
+
// tool by naming it directly.
|
|
51
|
+
const toolProvider = createPolicyToolProvider(policy, { upstream: mcpManager });
|
|
52
|
+
const rpcServer = new ToolCliServer(toolProvider);
|
|
53
|
+
// Routing state the prompt reports on. tool-cli is only ever advertised after
|
|
54
|
+
// a compatible authenticated v1 handshake and confirmed bash availability.
|
|
55
|
+
let toolCliState = {
|
|
56
|
+
kind: "not_started",
|
|
57
|
+
reason: "the session has not finished starting",
|
|
58
|
+
};
|
|
59
|
+
let skillsExtensionEnabled = false;
|
|
60
|
+
let facilitiesPublished = false;
|
|
61
|
+
// Register the load_skill tool so the model can activate MCP skills
|
|
62
|
+
pi.registerTool(createLoadSkillTool({ registry: skillRegistry, policy, skillsClient }));
|
|
63
|
+
pi.registerTool(codeSearch);
|
|
64
|
+
pi.registerTool(codeExecute);
|
|
65
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
66
|
+
hostElicitation.setContext(ctx);
|
|
67
|
+
hostApproval.setContext(ctx);
|
|
68
|
+
withholdToolCliCredentials(pi);
|
|
69
|
+
if (ctx.hasUI) {
|
|
70
|
+
ctx.ui.notify("mcpi-ext loaded", "info");
|
|
71
|
+
}
|
|
72
|
+
const configPath = pi.getFlag("mcp-config");
|
|
73
|
+
const log = (msg) => {
|
|
74
|
+
if (ctx.hasUI) {
|
|
75
|
+
ctx.ui.notify(msg, "info");
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
console.error(msg);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
const config = await loadMcpConfig(configPath);
|
|
83
|
+
const serverCount = Object.keys(config.mcpServers).length;
|
|
84
|
+
// Draft extension support is opt-in from either the config file or the
|
|
85
|
+
// CLI flag, and must be decided before any connection is opened because
|
|
86
|
+
// capabilities are fixed at initialize.
|
|
87
|
+
const skillsExtensionEnabledNow = pi.getFlag("mcp-skills-extension") === true || isSkillsExtensionEnabled(config);
|
|
88
|
+
skillsExtensionEnabled = skillsExtensionEnabledNow;
|
|
89
|
+
mcpManager.enableSkillsExtension(skillsExtensionEnabledNow);
|
|
90
|
+
if (skillsExtensionEnabledNow) {
|
|
91
|
+
log(skillsExtensionDiagnostic());
|
|
92
|
+
}
|
|
93
|
+
if (serverCount > 0) {
|
|
94
|
+
await mcpManager.connectAll(config, log);
|
|
95
|
+
const tools = mcpManager.getTools();
|
|
96
|
+
log(`MCP: ${mcpManager.getConnectedServers().length} server(s), ${tools.length} tool(s) discovered`);
|
|
97
|
+
// Pre-register all MCP tools as deferred Pi tool proxies
|
|
98
|
+
// (in tools array for dispatch but excluded from system prompt)
|
|
99
|
+
const allToolNames = tools.map((t) => t.name);
|
|
100
|
+
registerMcpToolProxies(allToolNames, mcpManager, policy, pi);
|
|
101
|
+
// Discover skills from all connected servers. A server that declares
|
|
102
|
+
// the draft extension is served entirely by it: the legacy skill://
|
|
103
|
+
// resource scan is a compatibility fallback for servers that do not,
|
|
104
|
+
// never a second opinion on one that does. An empty extension listing
|
|
105
|
+
// therefore means "no skills right now", not "try the old way".
|
|
106
|
+
for (const serverName of mcpManager.getConnectedServers()) {
|
|
107
|
+
const viaExtension = skillsExtensionEnabled && skillsClient.supports(serverName);
|
|
108
|
+
try {
|
|
109
|
+
if (viaExtension) {
|
|
110
|
+
const result = await discoverSkillsViaExtension(policy, skillsClient, serverName, log);
|
|
111
|
+
skillRegistry.registerAll(result.skills);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const skills = await discoverSkillsFromServer(policy, serverName, log);
|
|
115
|
+
skillRegistry.registerAll(skills);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
log(`[skills] Failed to discover skills from "${serverName}" via ${viaExtension ? "the draft skills extension" : "skill:// resources"}: ${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const collision of skillRegistry.getCollisions()) {
|
|
123
|
+
log(`[skills] Name collision on "${collision.name}": "${collision.challenger.serverName}" exposed as "${collision.registeredAs}" (name held by "${collision.incumbent.serverName}")`);
|
|
124
|
+
}
|
|
125
|
+
// Hand the discovered skills to the policy so their tools are gated
|
|
126
|
+
// everywhere, not just on the mcpi dispatch path.
|
|
127
|
+
policy.registerSkills(skillRegistry.getAll());
|
|
128
|
+
if (skillRegistry.size > 0) {
|
|
129
|
+
log(`MCP: ${skillRegistry.size} skill(s) discovered, ${policy.getGatedToolNames().length} tool(s) deferred`);
|
|
130
|
+
}
|
|
131
|
+
toolCliState = await startToolCliBridge({
|
|
132
|
+
bash: detectBashState(pi),
|
|
133
|
+
server: rpcServer,
|
|
134
|
+
environment: pi,
|
|
135
|
+
log,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
toolCliState = {
|
|
140
|
+
kind: "not_started",
|
|
141
|
+
reason: "no MCP servers are configured, so there is nothing for it to expose",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// Code execution remains available even when no MCP servers or callable tools exist.
|
|
145
|
+
codeModeManager.initialize(mcpManager, policy, log);
|
|
146
|
+
// Probe the optional native sandbox backend once, so routing can state
|
|
147
|
+
// plainly whether code mode can run rather than assuming it can.
|
|
148
|
+
await codeModeManager.probeSandbox();
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
withholdToolCliCredentials(pi);
|
|
152
|
+
await rpcServer.stop();
|
|
153
|
+
const msg = `MCP config error: ${errorMessage(err)}`;
|
|
154
|
+
toolCliState = { kind: "not_started", reason: msg };
|
|
155
|
+
if (ctx.hasUI) {
|
|
156
|
+
ctx.ui.notify(msg, "warning");
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
console.error(msg);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
// Inject execution routing, MCP skills, tool-cli usage docs, and code mode
|
|
164
|
+
// type hints into the system prompt.
|
|
165
|
+
pi.on("before_agent_start", async (event) => {
|
|
166
|
+
let extra = "";
|
|
167
|
+
const skills = skillRegistry.getAll();
|
|
168
|
+
const bashState = detectBashState(pi);
|
|
169
|
+
// Idempotent after the first resolution; covers hosts that reach
|
|
170
|
+
// before_agent_start without a preceding successful session_start.
|
|
171
|
+
const sandbox = await codeModeManager.probeSandbox();
|
|
172
|
+
// The routing section is emitted on every load, including with zero MCP
|
|
173
|
+
// servers — an agent still needs to know that exact computation and the
|
|
174
|
+
// shell are on the table, and why the MCP-backed facilities are not.
|
|
175
|
+
const facilities = buildExecutionFacilities({
|
|
176
|
+
skills: {
|
|
177
|
+
count: skills.length,
|
|
178
|
+
draftExtensionEnabled: skillsExtensionEnabled,
|
|
179
|
+
},
|
|
180
|
+
codeMode: { active: codeModeManager.isActive, reason: sandbox.reason },
|
|
181
|
+
toolCli: toolCliState,
|
|
182
|
+
bash: bashState,
|
|
183
|
+
});
|
|
184
|
+
// Prefer the host's own facility registry when it grows one. The two paths
|
|
185
|
+
// are mutually exclusive, so the section can never be emitted twice.
|
|
186
|
+
if (!facilitiesPublished) {
|
|
187
|
+
facilitiesPublished = publishExecutionFacilities(pi, facilities);
|
|
188
|
+
}
|
|
189
|
+
if (!facilitiesPublished) {
|
|
190
|
+
extra += formatExecutionFacilities(facilities);
|
|
191
|
+
}
|
|
192
|
+
if (skills.length > 0) {
|
|
193
|
+
extra += formatMcpSkillsForPrompt(skills);
|
|
194
|
+
}
|
|
195
|
+
extra += formatToolCliForPrompt({ toolCli: toolCliState, bash: bashState });
|
|
196
|
+
if (codeModeManager.isActive) {
|
|
197
|
+
codeModeManager.refresh();
|
|
198
|
+
extra += codeModeManager.formatSystemPromptSection();
|
|
199
|
+
}
|
|
200
|
+
if (extra.length === 0)
|
|
201
|
+
return;
|
|
202
|
+
return { systemPrompt: event.systemPrompt + extra };
|
|
203
|
+
});
|
|
204
|
+
// Block deferred MCP tools until their skill is loaded. The policy owns the
|
|
205
|
+
// decision so mcpi dispatch and the RPC path cannot disagree.
|
|
206
|
+
pi.on("tool_call", async (event) => {
|
|
207
|
+
const name = "toolName" in event ? event.toolName : undefined;
|
|
208
|
+
if (!name || !policy.isGated(name))
|
|
209
|
+
return;
|
|
210
|
+
const relevantSkills = policy.getGatingSkills(name);
|
|
211
|
+
return {
|
|
212
|
+
block: true,
|
|
213
|
+
reason: `Tool "${name}" requires loading a skill first. Call load_skill with one of: ${relevantSkills.join(", ")}`,
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
pi.on("session_shutdown", async () => {
|
|
217
|
+
hostElicitation.setContext(undefined);
|
|
218
|
+
hostApproval.setContext(undefined);
|
|
219
|
+
withholdToolCliCredentials(pi);
|
|
220
|
+
toolCliState = { kind: "not_started", reason: "the session has shut down" };
|
|
221
|
+
facilitiesPublished = false;
|
|
222
|
+
await rpcServer.stop();
|
|
223
|
+
await mcpManager.disconnectAll();
|
|
224
|
+
skillRegistry.clear();
|
|
225
|
+
policy.reset();
|
|
226
|
+
});
|
|
227
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@sammorrowdrums/mcpi";
|
|
2
|
+
import type { CallToolResult } from "@modelcontextprotocol/client";
|
|
3
|
+
import type { CallToolResult as ToolCliCallToolResult } from "@sammorrowdrums/tool-cli";
|
|
4
|
+
export interface TerminalCallToolResult {
|
|
5
|
+
readonly kind: "terminal";
|
|
6
|
+
readonly result: CallToolResult;
|
|
7
|
+
}
|
|
8
|
+
export declare function adaptTerminalCallToolResult(result: CallToolResult): TerminalCallToolResult;
|
|
9
|
+
export declare function renderTerminalCallToolResult(terminal: TerminalCallToolResult): AgentToolResult<CallToolResult>;
|
|
10
|
+
export declare function toToolCliCallToolResult(terminal: TerminalCallToolResult): ToolCliCallToolResult;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export function adaptTerminalCallToolResult(result) {
|
|
2
|
+
return { kind: "terminal", result };
|
|
3
|
+
}
|
|
4
|
+
export function renderTerminalCallToolResult(terminal) {
|
|
5
|
+
const content = [];
|
|
6
|
+
if (terminal.result.isError) {
|
|
7
|
+
content.push({ type: "text", text: "MCP tool reported an error." });
|
|
8
|
+
}
|
|
9
|
+
for (const block of terminal.result.content) {
|
|
10
|
+
switch (block.type) {
|
|
11
|
+
case "text":
|
|
12
|
+
content.push({ type: "text", text: block.text });
|
|
13
|
+
break;
|
|
14
|
+
case "image":
|
|
15
|
+
content.push({ type: "image", data: block.data, mimeType: block.mimeType });
|
|
16
|
+
break;
|
|
17
|
+
case "audio":
|
|
18
|
+
content.push({
|
|
19
|
+
type: "text",
|
|
20
|
+
text: `[MCP audio: ${block.mimeType}; base64 payload preserved in result details]`,
|
|
21
|
+
});
|
|
22
|
+
break;
|
|
23
|
+
case "resource_link":
|
|
24
|
+
content.push({
|
|
25
|
+
type: "text",
|
|
26
|
+
text: formatResourceLink(block),
|
|
27
|
+
});
|
|
28
|
+
break;
|
|
29
|
+
case "resource":
|
|
30
|
+
content.push({
|
|
31
|
+
type: "text",
|
|
32
|
+
text: formatEmbeddedResource(block),
|
|
33
|
+
});
|
|
34
|
+
break;
|
|
35
|
+
default:
|
|
36
|
+
content.push({ type: "text", text: JSON.stringify(block, null, 2) });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (terminal.result.structuredContent !== undefined) {
|
|
40
|
+
content.push({
|
|
41
|
+
type: "text",
|
|
42
|
+
text: `Structured content:\n${JSON.stringify(terminal.result.structuredContent, null, 2)}`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (content.length === 0) {
|
|
46
|
+
content.push({ type: "text", text: terminal.result.isError ? "MCP tool failed." : "" });
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
content,
|
|
50
|
+
details: terminal.result,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function toToolCliCallToolResult(terminal) {
|
|
54
|
+
return {
|
|
55
|
+
...terminal.result,
|
|
56
|
+
content: [...terminal.result.content],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function formatResourceLink(block) {
|
|
60
|
+
const label = block.title ?? block.name ?? block.uri;
|
|
61
|
+
const details = [block.mimeType, block.description].filter(Boolean).join("; ");
|
|
62
|
+
return `[MCP resource link: ${label}] ${block.uri}${details ? ` (${details})` : ""}`;
|
|
63
|
+
}
|
|
64
|
+
function formatEmbeddedResource(block) {
|
|
65
|
+
const resource = block.resource;
|
|
66
|
+
if ("text" in resource) {
|
|
67
|
+
return `[MCP embedded resource: ${resource.uri}]\n${resource.text}`;
|
|
68
|
+
}
|
|
69
|
+
return `[MCP embedded resource: ${resource.uri}; ${resource.mimeType ?? "binary"} blob preserved in result details]`;
|
|
70
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Client, type DiscoverResult, type ElicitRequestParams, type ElicitResult, type Implementation, type ListChangedHandlers, type ProtocolEra, type ServerCapabilities } from "@modelcontextprotocol/client";
|
|
2
|
+
export declare const MCP_CLIENT_IDENTITY: {
|
|
3
|
+
readonly name: "@sammorrowdrums/mcpi-ext";
|
|
4
|
+
readonly version: "1.0.0";
|
|
5
|
+
};
|
|
6
|
+
export declare const MCP_CLIENT_POLICY: {
|
|
7
|
+
readonly listMaxPages: 64;
|
|
8
|
+
readonly maxInputRequiredRounds: 4;
|
|
9
|
+
readonly probeTimeoutMs: 5000;
|
|
10
|
+
readonly requestTimeoutMs: 60000;
|
|
11
|
+
readonly maxTotalTimeoutMs: number;
|
|
12
|
+
};
|
|
13
|
+
export declare const MCP_SUPPORTED_PROTOCOL_VERSIONS: readonly ["2026-07-28", ...string[]];
|
|
14
|
+
export interface McpElicitationHandler {
|
|
15
|
+
elicit(params: ElicitRequestParams): Promise<ElicitResult>;
|
|
16
|
+
}
|
|
17
|
+
export interface McpClientDiagnostics {
|
|
18
|
+
protocolVersion: string | undefined;
|
|
19
|
+
protocolEra: ProtocolEra | undefined;
|
|
20
|
+
discoverResult: DiscoverResult | undefined;
|
|
21
|
+
serverImplementation: Implementation | undefined;
|
|
22
|
+
serverCapabilities: ServerCapabilities | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* What this client requested and what the server declared for the draft
|
|
25
|
+
* skills extension, so a draft feature is never silently in play.
|
|
26
|
+
*/
|
|
27
|
+
skillsExtension: {
|
|
28
|
+
/** Whether this client advertised the extension at initialize. */
|
|
29
|
+
requested: boolean;
|
|
30
|
+
/** Pinned draft revision this implementation targets. */
|
|
31
|
+
revision: string;
|
|
32
|
+
/** Always `"draft"` — this is an unratified proposal. */
|
|
33
|
+
status: string;
|
|
34
|
+
/** Whether the server declared the extension back. */
|
|
35
|
+
serverDeclared: boolean;
|
|
36
|
+
/** Settings the server declared, or `undefined` if it declared none. */
|
|
37
|
+
serverCapability: Record<string, unknown> | undefined;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export interface CreateMcpClientOptions {
|
|
41
|
+
elicitation: McpElicitationHandler;
|
|
42
|
+
listChanged?: ListChangedHandlers;
|
|
43
|
+
/**
|
|
44
|
+
* Advertise the draft skills extension at initialize.
|
|
45
|
+
*
|
|
46
|
+
* Off by default. When false this client never mentions SEP-2640 on the
|
|
47
|
+
* wire, which is what makes the fallback path a genuine absence of the
|
|
48
|
+
* extension rather than a suppressed one.
|
|
49
|
+
*/
|
|
50
|
+
skillsExtension?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Creates every MCP client with the same identity, negotiated protocol policy,
|
|
54
|
+
* capabilities, pagination limit, and SDK-managed input_required handling.
|
|
55
|
+
*/
|
|
56
|
+
export declare function createMcpClient(options: CreateMcpClientOptions): Client;
|
|
57
|
+
export declare function getMcpClientDiagnostics(client: Client, options?: {
|
|
58
|
+
skillsExtensionRequested?: boolean;
|
|
59
|
+
}): McpClientDiagnostics;
|