@njuptlzf/mcp-adapter 2.29.0-0.0.1
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/CHANGELOG.md +1205 -0
- package/LICENSE +21 -0
- package/MAPPING.md +73 -0
- package/README.md +564 -0
- package/abort.ts +34 -0
- package/adapters/pi-renderer.ts +7 -0
- package/adapters/protocol-elicitation-forwarder.ts +164 -0
- package/adapters/protocol-sampling-forwarder.ts +108 -0
- package/adapters/universal-host.ts +213 -0
- package/agent-dir.ts +68 -0
- package/agent-plugin-loader.ts +377 -0
- package/app-bridge.bundle.js +67 -0
- package/banner.png +0 -0
- package/bin/mcp-server.ts +282 -0
- package/cli.js +392 -0
- package/commands.ts +735 -0
- package/config.ts +1284 -0
- package/consent-manager.ts +64 -0
- package/direct-tools.ts +619 -0
- package/dist/agent-dir.d.ts +10 -0
- package/dist/agent-dir.js +63 -0
- package/dist/agent-dir.js.map +1 -0
- package/dist/agent-plugin-loader.d.ts +8 -0
- package/dist/agent-plugin-loader.js +356 -0
- package/dist/agent-plugin-loader.js.map +1 -0
- package/dist/config.d.ts +123 -0
- package/dist/config.js +1087 -0
- package/dist/config.js.map +1 -0
- package/dist/metadata-cache.d.ts +19 -0
- package/dist/metadata-cache.js +319 -0
- package/dist/metadata-cache.js.map +1 -0
- package/dist/package-mcp-loader.d.ts +2 -0
- package/dist/package-mcp-loader.js +156 -0
- package/dist/package-mcp-loader.js.map +1 -0
- package/dist/resource-tools.d.ts +1 -0
- package/dist/resource-tools.js +15 -0
- package/dist/resource-tools.js.map +1 -0
- package/dist/types.d.ts +564 -0
- package/dist/types.js +287 -0
- package/dist/types.js.map +1 -0
- package/dist/ui-app-bridge-helpers.d.ts +6 -0
- package/dist/ui-app-bridge-helpers.js +37 -0
- package/dist/ui-app-bridge-helpers.js.map +1 -0
- package/dist/ui-stream-types.d.ts +104 -0
- package/dist/ui-stream-types.js +59 -0
- package/dist/ui-stream-types.js.map +1 -0
- package/dist/ui-tool-visibility.d.ts +4 -0
- package/dist/ui-tool-visibility.js +27 -0
- package/dist/ui-tool-visibility.js.map +1 -0
- package/dist/utils.d.ts +32 -0
- package/dist/utils.js +338 -0
- package/dist/utils.js.map +1 -0
- package/elicitation-handler.ts +348 -0
- package/error-signal.ts +21 -0
- package/errors.ts +223 -0
- package/failure-backoff.ts +23 -0
- package/glimpse-ui.ts +80 -0
- package/host-html-template.ts +461 -0
- package/index.ts +1291 -0
- package/init.ts +692 -0
- package/interfaces/agent-paths.ts +67 -0
- package/interfaces/host-types.ts +102 -0
- package/interfaces/sampling.ts +57 -0
- package/json-schema-validator.ts +69 -0
- package/lifecycle.ts +446 -0
- package/logger.ts +169 -0
- package/mcp-auth-flow.ts +1026 -0
- package/mcp-auth.ts +1000 -0
- package/mcp-bearer-store.ts +387 -0
- package/mcp-callback-server.ts +534 -0
- package/mcp-code.ts +360 -0
- package/mcp-keyring-helper.cjs +89 -0
- package/mcp-oauth-provider.ts +666 -0
- package/mcp-output-guard.ts +496 -0
- package/mcp-panel.ts +1044 -0
- package/mcp-probe.ts +201 -0
- package/mcp-references.ts +368 -0
- package/mcp-script-worker.mjs +130 -0
- package/mcp-server.mjs +2133 -0
- package/mcp-setup-panel.ts +667 -0
- package/mcp-status.ts +98 -0
- package/mcp-trace.ts +306 -0
- package/metadata-cache.ts +382 -0
- package/namespace-tools.ts +276 -0
- package/npx-resolver.ts +543 -0
- package/oauth-handler.ts +30 -0
- package/oauth.ts +48 -0
- package/onboarding-state.ts +76 -0
- package/package-mcp-loader.ts +168 -0
- package/package.json +225 -0
- package/panel-keys.ts +53 -0
- package/prompts.ts +353 -0
- package/proxy-modes.ts +1418 -0
- package/request-headers-command.ts +330 -0
- package/resource-tools.ts +17 -0
- package/runtime-owner.ts +91 -0
- package/sampling-handler.ts +284 -0
- package/search-ranking.ts +278 -0
- package/server-manager.ts +1318 -0
- package/session-recovery.ts +158 -0
- package/skills/README.md +64 -0
- package/skills/mcp-adapter/SKILL.md +332 -0
- package/skills/mcp-adapter/references/deploy-examples.md +259 -0
- package/skills/mcp-adapter/references/deploy.md +89 -0
- package/skills/mcp-adapter/references/generate.md +144 -0
- package/skills/mcp-adapter/references/resolver.md +105 -0
- package/skills/mcp-adapter/references/verify.md +157 -0
- package/skills/mcp-scripting/SKILL.md +38 -0
- package/skills/resolve-conflicts/SKILL.md +482 -0
- package/skills/resolve-conflicts/references/patterns.md +432 -0
- package/skills/resolve-conflicts/references/sample-plan.md +96 -0
- package/skills/resolve-conflicts/scripts/handle-deleted-modified.sh +183 -0
- package/skills/resolve-conflicts/scripts/validate-conflicts.sh +120 -0
- package/skills/upstream-merge/SKILL.md +486 -0
- package/skills/upstream-merge/references/pi-coupling-markers.md +129 -0
- package/skills/upstream-merge/references/special-cases.md +39 -0
- package/state.ts +60 -0
- package/tool-approval.ts +195 -0
- package/tool-metadata.ts +296 -0
- package/tool-registrar.ts +253 -0
- package/tool-result-renderer.ts +477 -0
- package/ts-shape.ts +149 -0
- package/types.ts +991 -0
- package/ui-app-bridge-helpers.ts +40 -0
- package/ui-resource-handler.ts +250 -0
- package/ui-server.ts +852 -0
- package/ui-session.ts +571 -0
- package/ui-stream-types.ts +89 -0
- package/ui-tool-visibility.ts +25 -0
- package/unix-socket-transport.ts +85 -0
- package/utils.ts +382 -0
package/abort.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function throwIfAborted(signal?: AbortSignal): void {
|
|
2
|
+
if (!signal?.aborted) return;
|
|
3
|
+
throw signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "MCP request aborted"));
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export async function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
7
|
+
if (!signal) return promise;
|
|
8
|
+
throwIfAborted(signal);
|
|
9
|
+
return await new Promise<T>((resolve, reject) => {
|
|
10
|
+
let settled = false;
|
|
11
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
12
|
+
const onAbort = () => {
|
|
13
|
+
if (settled) return;
|
|
14
|
+
settled = true;
|
|
15
|
+
cleanup();
|
|
16
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "MCP request aborted")));
|
|
17
|
+
};
|
|
18
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
19
|
+
promise.then(
|
|
20
|
+
value => {
|
|
21
|
+
if (settled) return;
|
|
22
|
+
settled = true;
|
|
23
|
+
cleanup();
|
|
24
|
+
resolve(value);
|
|
25
|
+
},
|
|
26
|
+
error => {
|
|
27
|
+
if (settled) return;
|
|
28
|
+
settled = true;
|
|
29
|
+
cleanup();
|
|
30
|
+
reject(error);
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProtocolElicitationForwarder — forwards MCP elicitation requests from
|
|
3
|
+
* downstream servers to the Agent Client via the MCP Server→Client
|
|
4
|
+
* `elicitation/create` reverse call.
|
|
5
|
+
*
|
|
6
|
+
* D-07: implements the `UISystem.form` interface.
|
|
7
|
+
*
|
|
8
|
+
* Threat model:
|
|
9
|
+
* T-12-02 (Information Disclosure): `form()` must NEVER log
|
|
10
|
+
* `result.content` — it may contain user PII. Only `result.action`
|
|
11
|
+
* ("accept"/"decline"/"cancel") may be logged.
|
|
12
|
+
*
|
|
13
|
+
* Note: This causes a double conversion (MCP → FormConfig → MCP → Agent →
|
|
14
|
+
* MCP → FormResult → ElicitResult) because `elicitation-handler.ts`
|
|
15
|
+
* already converts MCP schema to FormConfig. This is an accepted
|
|
16
|
+
* trade-off for architectural consistency. See RESEARCH Pitfall 3.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
FormConfig,
|
|
21
|
+
FormField,
|
|
22
|
+
FormResult,
|
|
23
|
+
} from "../interfaces/host-types.ts";
|
|
24
|
+
import type {
|
|
25
|
+
ElicitRequestFormParams,
|
|
26
|
+
ElicitResult,
|
|
27
|
+
} from "@modelcontextprotocol/client";
|
|
28
|
+
|
|
29
|
+
/** Minimal Server→Client reverse-call surface the forwarder needs. */
|
|
30
|
+
type ElicitationForwardTarget = {
|
|
31
|
+
elicitInput(params: ElicitRequestFormParams): Promise<ElicitResult>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Convert a `FormField` to a JSON Schema property definition.
|
|
36
|
+
*
|
|
37
|
+
* This is the reverse of `elicitation-handler.ts`'s
|
|
38
|
+
* `convertMcpSchemaToPiForm()`. It maps each FormField type to the
|
|
39
|
+
* corresponding JSON Schema property for `elicitInput`'s
|
|
40
|
+
* `requestedSchema`.
|
|
41
|
+
*
|
|
42
|
+
* Supported types:
|
|
43
|
+
* - text → { type: "string", minLength?, maxLength? }
|
|
44
|
+
* - number → { type: "number", minimum?, maximum? }
|
|
45
|
+
* - integer → { type: "integer", minimum?, maximum? }
|
|
46
|
+
* - boolean → { type: "boolean" }
|
|
47
|
+
* - select → { type: "string", enum: [...] }
|
|
48
|
+
* - multiSelect → { type: "array", items: { type: "string", enum: [...] } }
|
|
49
|
+
*
|
|
50
|
+
* `title` (from `field.label`) is always included when present.
|
|
51
|
+
* `description` (from `field.description`) is included when present.
|
|
52
|
+
*/
|
|
53
|
+
export function convertFieldToSchema(field: FormField): Record<string, unknown> {
|
|
54
|
+
const type = field.type;
|
|
55
|
+
const label = field.label;
|
|
56
|
+
const description = (field as { description?: string }).description;
|
|
57
|
+
|
|
58
|
+
// Build the base object with optional title/description.
|
|
59
|
+
const buildBase = (): Record<string, unknown> => {
|
|
60
|
+
const base: Record<string, unknown> = {};
|
|
61
|
+
if (label !== undefined) base.title = label;
|
|
62
|
+
if (description !== undefined) base.description = description;
|
|
63
|
+
return base;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (type === "select" || type === "multiSelect") {
|
|
67
|
+
const options = (field as { options?: Array<{ value: string; label?: string }> }).options ?? [];
|
|
68
|
+
if (type === "multiSelect") {
|
|
69
|
+
return {
|
|
70
|
+
type: "array",
|
|
71
|
+
items: { type: "string", enum: options.map((o) => o.value) },
|
|
72
|
+
...buildBase(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
type: "string",
|
|
77
|
+
enum: options.map((o) => o.value),
|
|
78
|
+
...buildBase(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (type === "number" || type === "integer") {
|
|
83
|
+
const schema: Record<string, unknown> = { type, ...buildBase() };
|
|
84
|
+
const minimum = (field as { minimum?: number }).minimum;
|
|
85
|
+
const maximum = (field as { maximum?: number }).maximum;
|
|
86
|
+
if (minimum !== undefined) schema.minimum = minimum;
|
|
87
|
+
if (maximum !== undefined) schema.maximum = maximum;
|
|
88
|
+
return schema;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (type === "boolean") {
|
|
92
|
+
return { type: "boolean", ...buildBase() };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Default: text → string with optional minLength/maxLength
|
|
96
|
+
const schema: Record<string, unknown> = { type: "string", ...buildBase() };
|
|
97
|
+
const minLength = (field as { minLength?: number }).minLength;
|
|
98
|
+
const maxLength = (field as { maxLength?: number }).maxLength;
|
|
99
|
+
if (minLength !== undefined) schema.minLength = minLength;
|
|
100
|
+
if (maxLength !== undefined) schema.maxLength = maxLength;
|
|
101
|
+
return schema;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Forwards MCP elicitation requests to the Agent Client via
|
|
106
|
+
* `server.elicitInput()`.
|
|
107
|
+
*
|
|
108
|
+
* The forwarder is used when the connecting Agent Client declares
|
|
109
|
+
* `elicitation.form` capability. It implements the `UISystem.form`
|
|
110
|
+
* interface, converting `FormConfig` → `elicitInput` params and
|
|
111
|
+
* `ElicitResult` → `FormResult`.
|
|
112
|
+
*
|
|
113
|
+
* Action mapping:
|
|
114
|
+
* ElicitResult.action "accept" → FormResult.action "submit" (with values)
|
|
115
|
+
* ElicitResult.action "decline" → FormResult.action "secondary"
|
|
116
|
+
* ElicitResult.action "cancel" → FormResult.action "cancel"
|
|
117
|
+
*/
|
|
118
|
+
export class ProtocolElicitationForwarder {
|
|
119
|
+
constructor(private readonly server: ElicitationForwardTarget) {}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Convert `FormConfig` to `ElicitRequestFormParams` and forward to
|
|
123
|
+
* the Agent Client via `server.elicitInput()`.
|
|
124
|
+
*
|
|
125
|
+
* T-12-02: does NOT log `result.content` (may contain user PII).
|
|
126
|
+
*/
|
|
127
|
+
async form(config: FormConfig): Promise<FormResult> {
|
|
128
|
+
// Convert FormConfig fields back to JSON Schema for requestedSchema
|
|
129
|
+
const properties: Record<string, unknown> = {};
|
|
130
|
+
const required: string[] = [];
|
|
131
|
+
|
|
132
|
+
for (const field of config.fields) {
|
|
133
|
+
const schema = convertFieldToSchema(field);
|
|
134
|
+
properties[field.name] = schema;
|
|
135
|
+
if ((field as { required?: boolean }).required) {
|
|
136
|
+
required.push(field.name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const result: ElicitResult = await this.server.elicitInput({
|
|
141
|
+
mode: "form",
|
|
142
|
+
message: config.message ?? "",
|
|
143
|
+
requestedSchema: {
|
|
144
|
+
type: "object" as const,
|
|
145
|
+
properties,
|
|
146
|
+
required: required.length > 0 ? required : undefined,
|
|
147
|
+
},
|
|
148
|
+
} as ElicitRequestFormParams);
|
|
149
|
+
|
|
150
|
+
// Map ElicitResult → FormResult
|
|
151
|
+
// T-12-02: only result.action is used for control flow — result.content
|
|
152
|
+
// is passed through as values without logging.
|
|
153
|
+
if (result.action === "accept") {
|
|
154
|
+
return {
|
|
155
|
+
action: "submit",
|
|
156
|
+
values: result.content as Record<string, unknown> | undefined,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (result.action === "decline") {
|
|
160
|
+
return { action: "secondary" };
|
|
161
|
+
}
|
|
162
|
+
return { action: "cancel" };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProtocolSamplingForwarder — forwards MCP sampling requests from downstream
|
|
3
|
+
* servers to the Agent Client via the MCP Server→Client `sampling/createMessage`
|
|
4
|
+
* reverse call.
|
|
5
|
+
*
|
|
6
|
+
* D-06: implements the `SamplingProvider` interface.
|
|
7
|
+
* D-11: pure forwarding — no config check, no local approval. If the Agent
|
|
8
|
+
* Client declares `sampling` capability, mcp-adapter unconditionally
|
|
9
|
+
* forwards. The Agent handles user approval, LLM call, and result.
|
|
10
|
+
*
|
|
11
|
+
* Threat model:
|
|
12
|
+
* T-12-01 (Information Disclosure): `complete()` must NEVER log
|
|
13
|
+
* `request.messages` or `request.systemPrompt` — they may contain tokens
|
|
14
|
+
* or user content. Error paths only include the event name + error
|
|
15
|
+
* message, following the T-10-01 pattern from StoreAgentAdapter.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
SamplingModel,
|
|
20
|
+
SamplingProvider,
|
|
21
|
+
SamplingRequest,
|
|
22
|
+
SamplingResponse,
|
|
23
|
+
} from "../interfaces/sampling.ts";
|
|
24
|
+
import type {
|
|
25
|
+
CreateMessageRequestParams,
|
|
26
|
+
CreateMessageResult,
|
|
27
|
+
ModelPreferences,
|
|
28
|
+
} from "@modelcontextprotocol/client";
|
|
29
|
+
|
|
30
|
+
/** Minimal Server→Client reverse-call surface the forwarder needs. */
|
|
31
|
+
type SamplingForwardTarget = {
|
|
32
|
+
createMessage(params: CreateMessageRequestParams): Promise<CreateMessageResult>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Forwards MCP sampling requests to the Agent Client via
|
|
37
|
+
* `server.createMessage()`.
|
|
38
|
+
*
|
|
39
|
+
* The forwarder is injected into `McpServerManager.setSamplingConfig()`
|
|
40
|
+
* when the connecting Agent Client declares `sampling` capability.
|
|
41
|
+
*/
|
|
42
|
+
export class ProtocolSamplingForwarder implements SamplingProvider {
|
|
43
|
+
constructor(private readonly server: SamplingForwardTarget) {}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns a placeholder model. In pure forwarding mode (D-11), the Agent
|
|
47
|
+
* Client handles actual model selection. The model returned here is not
|
|
48
|
+
* used for the LLM call — it's only passed to `complete()` which ignores it.
|
|
49
|
+
*/
|
|
50
|
+
async resolveModel(_prefs?: ModelPreferences): Promise<SamplingModel | undefined> {
|
|
51
|
+
return { provider: "mcp-protocol", id: "forwarded" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Forward the sampling request to the Agent Client via
|
|
56
|
+
* `server.createMessage()`.
|
|
57
|
+
*
|
|
58
|
+
* Converts `SamplingRequest.messages` to MCP `CreateMessageRequestParam`
|
|
59
|
+
* messages (string content → text content block), calls
|
|
60
|
+
* `server.createMessage()`, and maps `CreateMessageResult` to
|
|
61
|
+
* `SamplingResponse`.
|
|
62
|
+
*
|
|
63
|
+
* T-12-01: does NOT log `request.messages` or `request.systemPrompt`.
|
|
64
|
+
*/
|
|
65
|
+
async complete(_model: SamplingModel, request: SamplingRequest): Promise<SamplingResponse> {
|
|
66
|
+
try {
|
|
67
|
+
// Convert SamplingRequest messages to MCP createMessage params.
|
|
68
|
+
// String content is wrapped as { type: "text", text }; array content
|
|
69
|
+
// (SamplingTextContent[]) is passed through.
|
|
70
|
+
const messages = request.messages.map((m) => ({
|
|
71
|
+
role: m.role,
|
|
72
|
+
content: typeof m.content === "string"
|
|
73
|
+
? { type: "text" as const, text: m.content }
|
|
74
|
+
: m.content,
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
const result: CreateMessageResult = await this.server.createMessage({
|
|
78
|
+
messages,
|
|
79
|
+
systemPrompt: request.systemPrompt,
|
|
80
|
+
// maxTokens is required by the MCP protocol; SamplingRequest.maxTokens
|
|
81
|
+
// is optional at the interface level but always present at runtime
|
|
82
|
+
// (the downstream server's createMessage request includes it).
|
|
83
|
+
maxTokens: request.maxTokens ?? 0,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Map CreateMessageResult → SamplingResponse
|
|
87
|
+
const text = result.content.type === "text" ? result.content.text : "";
|
|
88
|
+
return {
|
|
89
|
+
text,
|
|
90
|
+
model: result.model,
|
|
91
|
+
stopReason: result.stopReason ?? "endTurn",
|
|
92
|
+
};
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// T-12-01: only log event name + error message — never request payload.
|
|
95
|
+
throw new Error(
|
|
96
|
+
`[ProtocolSamplingForwarder] complete failed: ${(err as Error).message}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* D-11: No local approval — the Agent Client handles user approval.
|
|
103
|
+
* Always returns `true` unconditionally.
|
|
104
|
+
*/
|
|
105
|
+
async confirm(_title: string, _message: string): Promise<boolean> {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapters/universal-host.ts — Universal MCP stdio host disguised as a Pi
|
|
3
|
+
* `ExtensionAPI`.
|
|
4
|
+
*
|
|
5
|
+
* Stage 2 (fork-host, not fork-engine): instead of the fork's parallel engine
|
|
6
|
+
* (`adapters/entry.ts`) driving registration through the fork `AgentAPI`
|
|
7
|
+
* abstraction, this class implements the *Pi* `ExtensionAPI` surface so the
|
|
8
|
+
* upstream `index.ts` engine (`installMcpAdapter`) can run unchanged against a
|
|
9
|
+
* non-Pi, MCP-stdio host.
|
|
10
|
+
*
|
|
11
|
+
* It is deliberately NOT declared as `implements ExtensionAPI` — the npm
|
|
12
|
+
* `@earendil-works/pi-coding-agent` types degrade `ExtensionAPI` to `any`, so
|
|
13
|
+
* the precise surface below is the fork-owned contract. The caller
|
|
14
|
+
* (`bin/mcp-server.ts`) casts to `ExtensionAPI` at the boundary.
|
|
15
|
+
*
|
|
16
|
+
* Surface implemented (evidence in upstream `index.ts`/`init.ts`/`utils.ts`):
|
|
17
|
+
* registerTool / registerCommand / registerFlag / getFlag / on / events /
|
|
18
|
+
* getAllTools / setActiveTools / getActiveTools / unregisterTool /
|
|
19
|
+
* sendMessage / exec (+ `tools`/`activeTools` stores for MCP ListTools
|
|
20
|
+
* filtering).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { EventEmitter } from "node:events";
|
|
24
|
+
import type {
|
|
25
|
+
AgentChannel,
|
|
26
|
+
CommandConfig,
|
|
27
|
+
FlagConfig,
|
|
28
|
+
ToolInfo,
|
|
29
|
+
ToolRegistration,
|
|
30
|
+
} from "../interfaces/host-types.ts";
|
|
31
|
+
|
|
32
|
+
type EventHandler = (...args: unknown[]) => void | Promise<void>;
|
|
33
|
+
|
|
34
|
+
/** Shape of an MCP-status event bus; satisfies upstream's `McpStatusEventBus`. */
|
|
35
|
+
export type UniversalStatusEventBus = Pick<EventEmitter, "emit" | "on" | "removeAllListeners">;
|
|
36
|
+
|
|
37
|
+
/** The fork-owned `ExtensionAPI` contract that upstream `installMcpAdapter` needs. */
|
|
38
|
+
export interface UniversalHostSurface {
|
|
39
|
+
registerTool(tool: ToolRegistration): void;
|
|
40
|
+
registerCommand(name: string, config: CommandConfig): void;
|
|
41
|
+
registerFlag(name: string, config: FlagConfig): void;
|
|
42
|
+
getFlag(name: string): string | undefined;
|
|
43
|
+
on(event: string, handler: EventHandler): void;
|
|
44
|
+
getAllTools(): ToolInfo[];
|
|
45
|
+
setActiveTools(names: string[]): void;
|
|
46
|
+
getActiveTools(): string[];
|
|
47
|
+
unregisterTool(name: string): boolean;
|
|
48
|
+
sendMessage(message: unknown, options?: unknown): void;
|
|
49
|
+
exec(command: string, args: string[], options?: unknown): Promise<unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A host that presents the Pi `ExtensionAPI` surface backed by in-memory
|
|
54
|
+
* stores, so `index.ts`'s engine can register tools/commands/flags and wire
|
|
55
|
+
* session lifecycle events without a real Pi runtime.
|
|
56
|
+
*/
|
|
57
|
+
export class UniversalMcpHost implements UniversalHostSurface {
|
|
58
|
+
/** Registered tools by name. Public so MCP `ListTools`/`CallTool` handlers can enumerate. */
|
|
59
|
+
readonly tools = new Map<string, ToolRegistration>();
|
|
60
|
+
/** Registered tools that are currently "active" (surface-visible). */
|
|
61
|
+
private activeTools = new Set<string>();
|
|
62
|
+
/** Registered commands by name (no-op surface for MCP stdio). */
|
|
63
|
+
readonly commands = new Map<string, CommandConfig>();
|
|
64
|
+
/** Registered flags by name. Value mutable so `getFlag` reflects later updates. */
|
|
65
|
+
readonly flags = new Map<string, FlagConfig & { value?: string }>();
|
|
66
|
+
/** Event handlers keyed by event name. */
|
|
67
|
+
private readonly handlers = new Map<string, Set<EventHandler>>();
|
|
68
|
+
/** Status event bus (upstream sets `state.statusEvents = pi.events`). */
|
|
69
|
+
readonly events: UniversalStatusEventBus = new EventEmitter();
|
|
70
|
+
|
|
71
|
+
private channel: AgentChannel | undefined;
|
|
72
|
+
private static readonly PREFIX = "[mcp-adapter]";
|
|
73
|
+
|
|
74
|
+
// ------------------------------------------------------------------
|
|
75
|
+
// ExtensionAPI surface
|
|
76
|
+
// ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
registerTool(tool: ToolRegistration): void {
|
|
79
|
+
this.tools.set(tool.name, tool);
|
|
80
|
+
// Pi auto-activates a registered tool; mirror that so `getActiveTools()`
|
|
81
|
+
// returns the full registered surface unless the engine explicitly
|
|
82
|
+
// `setActiveTools`/`unregisterTool` narrows it.
|
|
83
|
+
this.activeTools.add(tool.name);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
registerCommand(name: string, config: CommandConfig): void {
|
|
87
|
+
this.commands.set(name, config);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
registerFlag(name: string, config: FlagConfig): void {
|
|
91
|
+
this.flags.set(name, { ...config });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getFlag(name: string): string | undefined {
|
|
95
|
+
return this.flags.get(name)?.value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
on(event: string, handler: EventHandler): void {
|
|
99
|
+
let set = this.handlers.get(event);
|
|
100
|
+
if (!set) {
|
|
101
|
+
set = new Set();
|
|
102
|
+
this.handlers.set(event, set);
|
|
103
|
+
}
|
|
104
|
+
set.add(handler);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
getAllTools(): ToolInfo[] {
|
|
108
|
+
return [...this.tools.values()].map((t) => ({ name: t.name }));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setActiveTools(names: string[]): void {
|
|
112
|
+
this.activeTools = new Set(names);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getActiveTools(): string[] {
|
|
116
|
+
return [...this.activeTools];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
unregisterTool(name: string): boolean {
|
|
120
|
+
this.activeTools.delete(name);
|
|
121
|
+
return this.tools.delete(name);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
sendMessage(message: unknown, _options?: unknown): void {
|
|
125
|
+
if (this.channel) {
|
|
126
|
+
void this.channel.send(message);
|
|
127
|
+
}
|
|
128
|
+
// No channel — no-op. stderr routing is handled by the channel in main().
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Spawn a child process via node:child_process.spawn.
|
|
133
|
+
* Used by upstream `openUrl` (OAuth browser launch) and panels.
|
|
134
|
+
* No path from an untrusted MCP tool result to exec.
|
|
135
|
+
*/
|
|
136
|
+
async exec(command: string, args: string[], _options?: unknown): Promise<unknown> {
|
|
137
|
+
const cp = (await import("node:child_process")) as typeof import("node:child_process");
|
|
138
|
+
return await new Promise<{
|
|
139
|
+
code: number | null;
|
|
140
|
+
stdout: string;
|
|
141
|
+
stderr: string;
|
|
142
|
+
}>((resolvePromise, reject) => {
|
|
143
|
+
const child = cp.spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
144
|
+
const stdoutChunks: Buffer[] = [];
|
|
145
|
+
const stderrChunks: Buffer[] = [];
|
|
146
|
+
child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
|
|
147
|
+
child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
|
|
148
|
+
child.once("error", reject);
|
|
149
|
+
child.once("close", (code) => {
|
|
150
|
+
resolvePromise({
|
|
151
|
+
code,
|
|
152
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
153
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ------------------------------------------------------------------
|
|
160
|
+
// Companion methods (not part of ExtensionAPI)
|
|
161
|
+
// ------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/** Attach a universal AgentChannel for bidirectional communication. */
|
|
164
|
+
attachChannel(channel: AgentChannel): void {
|
|
165
|
+
this.channel = channel;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Drive a simulated `session_start` event with the supplied runtime context. */
|
|
169
|
+
async fireSessionStart(ctx: unknown): Promise<void> {
|
|
170
|
+
await this.fire("session_start", "session_start", ctx);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Drive a simulated `session_shutdown` event. */
|
|
174
|
+
async fireSessionShutdown(): Promise<void> {
|
|
175
|
+
await this.fire("session_shutdown");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Drive a simulated `input` event (keep-alive convergence hook). */
|
|
179
|
+
async fireInput(): Promise<void> {
|
|
180
|
+
await this.fire("input");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Drive a simulated `tool_result` event with the result details. */
|
|
184
|
+
async fireToolResult(details: unknown): Promise<void> {
|
|
185
|
+
await this.fire("tool_result", { details });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ------------------------------------------------------------------
|
|
189
|
+
// Private helpers
|
|
190
|
+
// ------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Invoke every handler registered for `event`, catching handler errors so
|
|
194
|
+
* a single failing listener cannot break the host. Logs error prefix +
|
|
195
|
+
* event name + handler count only — never the event args.
|
|
196
|
+
*/
|
|
197
|
+
private async fire(event: string, ...args: unknown[]): Promise<void> {
|
|
198
|
+
const set = this.handlers.get(event);
|
|
199
|
+
if (!set || set.size === 0) return;
|
|
200
|
+
const handlers = [...set];
|
|
201
|
+
await Promise.all(
|
|
202
|
+
handlers.map(async (h) => {
|
|
203
|
+
try {
|
|
204
|
+
await Promise.resolve(h(...args));
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.error(
|
|
207
|
+
`${UniversalMcpHost.PREFIX} handler error for event '${event}' (${set.size} handlers): ${(err as Error).message}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
package/agent-dir.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function getConfigDirName(): string {
|
|
6
|
+
const configDir = readPiConfig()?.configDir;
|
|
7
|
+
return typeof configDir === "string" && configDir.trim() ? configDir.trim() : ".pi";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getAgentDir(): string {
|
|
11
|
+
const piConfig = readPiConfig();
|
|
12
|
+
const name = piConfig?.name;
|
|
13
|
+
const appName = typeof name === "string" && name.trim() ? name.trim() : "pi";
|
|
14
|
+
const configured = process.env[`${appName.toUpperCase()}_CODING_AGENT_DIR`]?.trim();
|
|
15
|
+
if (!configured) {
|
|
16
|
+
return join(homedir(), getConfigDirName(), "agent");
|
|
17
|
+
}
|
|
18
|
+
if (configured === "~") {
|
|
19
|
+
return homedir();
|
|
20
|
+
}
|
|
21
|
+
if (configured.startsWith("~/")) {
|
|
22
|
+
return resolve(homedir(), configured.slice(2));
|
|
23
|
+
}
|
|
24
|
+
return resolve(configured);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getAgentPath(...segments: string[]): string {
|
|
28
|
+
return join(getAgentDir(), ...segments);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What the host calls itself.
|
|
33
|
+
*
|
|
34
|
+
* pi supports rebranding through `piConfig.name` in the package.json that its
|
|
35
|
+
* `getPackageDir()` resolves, and distributions built on pi (arc, tau, …) point
|
|
36
|
+
* `PI_PACKAGE_DIR` at their own manifest. Read that manifest directly rather
|
|
37
|
+
* than importing pi: this package deliberately depends on pi-ai and pi-tui
|
|
38
|
+
* only, and `getAgentDir()` above reads its env var the same self-contained way.
|
|
39
|
+
*
|
|
40
|
+
* Falls back to "pi", which is what pi's own APP_NAME resolves to.
|
|
41
|
+
*/
|
|
42
|
+
function readPiConfig(): { name?: unknown; configDir?: unknown; clientUri?: unknown } | undefined {
|
|
43
|
+
const dir = process.env.PI_PACKAGE_DIR?.trim()
|
|
44
|
+
if (!dir) return undefined
|
|
45
|
+
try {
|
|
46
|
+
const manifest = JSON.parse(readFileSync(join(resolve(dir), "package.json"), "utf8")) as {
|
|
47
|
+
piConfig?: { name?: unknown; configDir?: unknown; clientUri?: unknown }
|
|
48
|
+
}
|
|
49
|
+
return manifest.piConfig
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getAppName(): string {
|
|
56
|
+
const name = readPiConfig()?.name
|
|
57
|
+
return typeof name === "string" && name.trim() ? name.trim() : "pi"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Home page the host declares for itself, via `piConfig.clientUri` in the same
|
|
62
|
+
* manifest that carries `piConfig.name`. Only the distribution knows its own
|
|
63
|
+
* URL, so this is the one place it can come from without guessing.
|
|
64
|
+
*/
|
|
65
|
+
export function getAppClientUri(): string | undefined {
|
|
66
|
+
const uri = readPiConfig()?.clientUri
|
|
67
|
+
return typeof uri === "string" && uri.trim() ? uri.trim() : undefined
|
|
68
|
+
}
|