@fitchmultz/pi-mcp-adapter 7.1.0 → 8.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/CHANGELOG.md +27 -0
- package/OAUTH.md +3 -3
- package/README.md +26 -17
- package/dist/app-bridge.bundle.js +13 -13
- package/dist/direct-tools.js +6 -5
- package/dist/error-signal.d.ts +3 -5
- package/dist/error-signal.js +3 -5
- package/dist/index.js +6 -23
- package/dist/init.js +0 -1
- package/dist/lifecycle.d.ts +0 -4
- package/dist/lifecycle.js +1 -14
- package/dist/mcp-auth-flow.d.ts +3 -9
- package/dist/mcp-auth-flow.js +4 -43
- package/dist/mcp-code.js +0 -1
- package/dist/mcp-output-guard.d.ts +5 -0
- package/dist/mcp-output-guard.js +55 -1
- package/dist/proxy-modes.d.ts +1 -1
- package/dist/proxy-modes.js +89 -9
- package/dist/runtime-owner.d.ts +0 -4
- package/dist/runtime-owner.js +5 -18
- package/dist/server-manager.d.ts +2 -9
- package/dist/server-manager.js +9 -100
- package/dist/tool-loader.d.ts +6 -21
- package/dist/tool-loader.js +76 -61
- package/dist/tool-registrar.d.ts +11 -0
- package/dist/tool-registrar.js +23 -0
- package/dist/types.d.ts +5 -0
- package/dist/ui-message-history.d.ts +11 -0
- package/dist/ui-message-history.js +81 -0
- package/dist/ui-server.js +17 -10
- package/dist/ui-session.js +3 -2
- package/package.json +12 -12
- package/dist/checkpoint.d.ts +0 -18
- package/dist/checkpoint.js +0 -53
package/dist/tool-loader.js
CHANGED
|
@@ -4,21 +4,13 @@ import { isServerCacheValid, reconstructToolMetadata } from "./metadata-cache.js
|
|
|
4
4
|
import { createMcpDirectToolCallRenderer, renderMcpToolResult } from "./tool-result-renderer.js";
|
|
5
5
|
import { isServerDisabled } from "./types.js";
|
|
6
6
|
import { normalizeDirectToolInputSchema } from "./utils.js";
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import { nativeMcpOutputSchema } from "./tool-registrar.js";
|
|
8
|
+
import { toolErrorOverride } from "./error-signal.js";
|
|
9
|
+
const featureNames = { gateway: "mcp", script: "mcp_script" };
|
|
9
10
|
const ENTRY = "mcp-tool-selection";
|
|
10
11
|
const key = (value) => JSON.stringify([value.server, value.tool]);
|
|
11
|
-
const refKey = (value) => JSON.stringify([value.namespace ?? null, value.name]);
|
|
12
12
|
const reserved = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls", "mcp", "mcp_search", "mcp_script"]);
|
|
13
|
-
export function supportsNativeAsync(ctx) {
|
|
14
|
-
const compat = ctx.model?.compat;
|
|
15
|
-
return "getPendingToolCalls" in ctx && typeof ctx.getPendingToolCalls === "function"
|
|
16
|
-
&& !!compat && "supportsAsyncTools" in compat && compat.supportsAsyncTools === true;
|
|
17
|
-
}
|
|
18
13
|
export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
19
|
-
const native = typeof pi.registerToolSearch === "function"
|
|
20
|
-
&& typeof pi.getActiveToolReferences === "function"
|
|
21
|
-
&& typeof pi.setActiveToolReferences === "function";
|
|
22
14
|
pi.registerEntryRenderer(ENTRY, () => undefined);
|
|
23
15
|
const registered = new Map();
|
|
24
16
|
let available = new Set();
|
|
@@ -29,24 +21,24 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
29
21
|
let inactiveFeatures = new Set();
|
|
30
22
|
const registeredFeatures = new Set();
|
|
31
23
|
let restoring = true;
|
|
32
|
-
const active = () =>
|
|
33
|
-
const setActive = (
|
|
34
|
-
const unique = [...new
|
|
24
|
+
const active = () => pi.getActiveTools();
|
|
25
|
+
const setActive = (names) => {
|
|
26
|
+
const unique = [...new Set(names)];
|
|
35
27
|
if (JSON.stringify(active()) === JSON.stringify(unique))
|
|
36
28
|
return;
|
|
37
|
-
|
|
38
|
-
pi.setActiveToolReferences(unique);
|
|
39
|
-
else
|
|
40
|
-
pi.setActiveTools(unique.map(ref => ref.name));
|
|
29
|
+
pi.setActiveTools(unique);
|
|
41
30
|
};
|
|
42
31
|
function registerFeature(feature, definition) {
|
|
43
|
-
pi.registerTool(definition)
|
|
32
|
+
pi.registerTool({ defaultActive: false, ...definition, async execute(...args) {
|
|
33
|
+
const result = await definition.execute(...args);
|
|
34
|
+
return { ...result, ...toolErrorOverride(result.details) };
|
|
35
|
+
} });
|
|
44
36
|
registeredFeatures.add(feature);
|
|
45
37
|
}
|
|
46
38
|
function capture() {
|
|
47
|
-
const current = new Set(active()
|
|
39
|
+
const current = new Set(active());
|
|
48
40
|
for (const feature of Object.keys(features)) {
|
|
49
|
-
if (current.has(
|
|
41
|
+
if (current.has(featureNames[feature])) {
|
|
50
42
|
if (!features[feature]?.eager)
|
|
51
43
|
selectedFeatures.add(feature);
|
|
52
44
|
inactiveFeatures.delete(feature);
|
|
@@ -57,10 +49,10 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
57
49
|
inactiveFeatures.add(feature);
|
|
58
50
|
}
|
|
59
51
|
}
|
|
60
|
-
for (const [id, { selection,
|
|
52
|
+
for (const [id, { selection, name, pinned }] of registered) {
|
|
61
53
|
if (!available.has(id))
|
|
62
54
|
continue;
|
|
63
|
-
if (current.has(
|
|
55
|
+
if (current.has(name)) {
|
|
64
56
|
if (!pinned)
|
|
65
57
|
selected.set(id, selection);
|
|
66
58
|
inactive.delete(id);
|
|
@@ -81,10 +73,25 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
81
73
|
inactive = new Map();
|
|
82
74
|
selectedFeatures = new Set();
|
|
83
75
|
inactiveFeatures = new Set();
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
const manager = ctx.sessionManager;
|
|
77
|
+
function* snapshots() {
|
|
78
|
+
let id = manager.getLeafId();
|
|
79
|
+
while (id) {
|
|
80
|
+
const entry = manager.getEntry(id);
|
|
81
|
+
if (!entry)
|
|
82
|
+
break;
|
|
83
|
+
yield entry;
|
|
84
|
+
id = entry.parentId;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const entry of snapshots()) {
|
|
88
|
+
if (entry?.type !== "custom" || entry.customType !== ENTRY || !entry.data || typeof entry.data !== "object")
|
|
86
89
|
continue;
|
|
87
90
|
const data = entry.data;
|
|
91
|
+
if (!Array.isArray(data.selected) || [data.inactive, data.features, data.inactiveFeatures].some(value => value !== undefined && !Array.isArray(value)))
|
|
92
|
+
continue;
|
|
93
|
+
if ([...data.selected, ...(data.inactive ?? [])].some(value => !value || typeof value.server !== "string" || typeof value.tool !== "string"))
|
|
94
|
+
continue;
|
|
88
95
|
selectedFeatures = new Set(Array.isArray(data.features) ? data.features.filter(value => value === "gateway" || value === "script") : []);
|
|
89
96
|
inactiveFeatures = new Set(Array.isArray(data.inactiveFeatures) ? data.inactiveFeatures.filter(value => value === "gateway" || value === "script") : []);
|
|
90
97
|
for (const [field, target] of [[data.selected, selected], [data.inactive, inactive]]) {
|
|
@@ -94,14 +101,15 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
94
101
|
target.set(key(selection), selection);
|
|
95
102
|
}
|
|
96
103
|
}
|
|
104
|
+
break;
|
|
97
105
|
}
|
|
98
106
|
restoring = true;
|
|
99
107
|
}
|
|
100
|
-
function sync(config, cache, env,
|
|
108
|
+
function sync(config, cache, env, registerGateways) {
|
|
101
109
|
if (!restoring)
|
|
102
110
|
capture();
|
|
103
|
-
const
|
|
104
|
-
const existing = new Set(pi.getAllTools().map(tool =>
|
|
111
|
+
const previousNames = new Set([...registered.values()].map(item => item.name));
|
|
112
|
+
const existing = new Set(pi.getAllTools().map(tool => tool.name));
|
|
105
113
|
const specs = [];
|
|
106
114
|
for (const [server, definition] of Object.entries(env === null ? {} : config.mcpServers)) {
|
|
107
115
|
if (isServerDisabled(definition))
|
|
@@ -114,27 +122,34 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
114
122
|
if (tool.resourceUri)
|
|
115
123
|
continue;
|
|
116
124
|
const pinned = selection === true || (Array.isArray(selection) && selection.includes(tool.originalName));
|
|
117
|
-
|
|
118
|
-
specs.push({ spec: { ...tool, prefixedName, serverName: server }, selection: { server, tool: tool.originalName }, ref, pinned });
|
|
125
|
+
specs.push({ spec: { ...tool, prefixedName, serverName: server }, selection: { server, tool: tool.originalName }, name: prefixedName, pinned });
|
|
119
126
|
}
|
|
120
127
|
}
|
|
121
128
|
const counts = new Map();
|
|
122
|
-
for (const {
|
|
123
|
-
counts.set(
|
|
129
|
+
for (const { name } of specs)
|
|
130
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
124
131
|
available = new Set();
|
|
125
132
|
const enabled = [];
|
|
126
|
-
for (const { spec, selection,
|
|
133
|
+
for (const { spec, selection, name, pinned } of specs) {
|
|
127
134
|
const id = key(selection);
|
|
128
|
-
|
|
129
|
-
if ((!ref.namespace && reserved.has(ref.name)) || counts.get(refId) > 1 || (existing.has(refId) && !previousRefs.has(refId)))
|
|
135
|
+
if (reserved.has(name) || counts.get(name) > 1 || (existing.has(name) && !previousNames.has(name)))
|
|
130
136
|
continue;
|
|
131
137
|
available.add(id);
|
|
132
|
-
const
|
|
133
|
-
const fingerprint = JSON.stringify({ spec,
|
|
138
|
+
const instructions = getState()?.serverInstructions.get(spec.serverName) ?? cache?.servers[spec.serverName]?.instructions;
|
|
139
|
+
const fingerprint = JSON.stringify({ spec, name, instructions });
|
|
134
140
|
if (registered.get(id)?.fingerprint !== fingerprint) {
|
|
135
141
|
pi.registerTool({
|
|
136
|
-
|
|
137
|
-
...(
|
|
142
|
+
name,
|
|
143
|
+
namespace: { name: `mcp_${spec.serverName}`, ...(instructions !== undefined ? { instructions } : {}) },
|
|
144
|
+
...(spec.outputSchema ? { outputSchema: nativeMcpOutputSchema(spec.outputSchema) } : {}),
|
|
145
|
+
...(spec.annotations ? { annotations: {
|
|
146
|
+
...(typeof spec.annotations.readOnlyHint === "boolean" ? { readOnlyHint: spec.annotations.readOnlyHint } : {}),
|
|
147
|
+
...(typeof spec.annotations.destructiveHint === "boolean" ? { destructiveHint: spec.annotations.destructiveHint } : {}),
|
|
148
|
+
...(typeof spec.annotations.idempotentHint === "boolean" ? { idempotentHint: spec.annotations.idempotentHint } : {}),
|
|
149
|
+
...(typeof spec.annotations.openWorldHint === "boolean" ? { openWorldHint: spec.annotations.openWorldHint } : {}),
|
|
150
|
+
} } : {}),
|
|
151
|
+
// Inactive direct tools are not callable through codemode or nested execution.
|
|
152
|
+
defaultActive: false,
|
|
138
153
|
...(beforeExecute ? { executionMode: "sequential" } : {}),
|
|
139
154
|
label: spec.title ?? `MCP: ${spec.originalName}`,
|
|
140
155
|
description: spec.description || "(no description)",
|
|
@@ -144,22 +159,22 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
144
159
|
renderResult: renderMcpToolResult,
|
|
145
160
|
});
|
|
146
161
|
}
|
|
147
|
-
registered.set(id, { selection,
|
|
162
|
+
registered.set(id, { selection, name, fingerprint, pinned });
|
|
148
163
|
if (selected.has(id) || (pinned && !inactive.has(id)))
|
|
149
|
-
enabled.push(
|
|
164
|
+
enabled.push(name);
|
|
150
165
|
}
|
|
151
|
-
const permitted = new Set(pi.getAllTools().map(
|
|
152
|
-
const pins = specs.filter(item => item.pinned && available.has(key(item.selection)) && permitted.has(
|
|
166
|
+
const permitted = new Set(pi.getAllTools().map(tool => tool.name));
|
|
167
|
+
const pins = specs.filter(item => item.pinned && available.has(key(item.selection)) && permitted.has(item.name)).map(item => item.spec);
|
|
153
168
|
const policy = registerGateways?.(pins);
|
|
154
169
|
if (policy) {
|
|
155
|
-
const permittedFeatures = new Set(pi.getAllTools().map(
|
|
156
|
-
const loaderMissing = !permittedFeatures.has(
|
|
170
|
+
const permittedFeatures = new Set(pi.getAllTools().map(tool => tool.name));
|
|
171
|
+
const loaderMissing = !permittedFeatures.has("mcp_search");
|
|
157
172
|
features = {};
|
|
158
|
-
for (const feature of Object.keys(
|
|
159
|
-
if (policy[feature] && permittedFeatures.has(
|
|
173
|
+
for (const feature of Object.keys(featureNames)) {
|
|
174
|
+
if (policy[feature] && permittedFeatures.has(featureNames[feature])) {
|
|
160
175
|
features[feature] = { eager: policy[feature].eager || loaderMissing };
|
|
161
176
|
if (selectedFeatures.has(feature) || (features[feature]?.eager && !inactiveFeatures.has(feature)))
|
|
162
|
-
enabled.push(
|
|
177
|
+
enabled.push(featureNames[feature]);
|
|
163
178
|
}
|
|
164
179
|
else {
|
|
165
180
|
selectedFeatures.delete(feature);
|
|
@@ -167,11 +182,11 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
167
182
|
}
|
|
168
183
|
}
|
|
169
184
|
}
|
|
170
|
-
//
|
|
171
|
-
const
|
|
185
|
+
// Retain the journal: official 1.0 initial SDK resume has a native loadout restoration gap.
|
|
186
|
+
const ownedNames = new Set([...previousNames, ...[...registered.values()].map(item => item.name)]);
|
|
172
187
|
for (const feature of registeredFeatures)
|
|
173
|
-
|
|
174
|
-
setActive([...active().filter(
|
|
188
|
+
ownedNames.add(featureNames[feature]);
|
|
189
|
+
setActive([...active().filter(name => !ownedNames.has(name)), ...enabled]);
|
|
175
190
|
restoring = false;
|
|
176
191
|
return pins;
|
|
177
192
|
}
|
|
@@ -182,22 +197,22 @@ export function createToolLoader(pi, getState, getInitPromise, beforeExecute) {
|
|
|
182
197
|
return registration ? [registration] : [];
|
|
183
198
|
});
|
|
184
199
|
// No await between reading and extending the current loadout: parallel searches union their selections.
|
|
185
|
-
const permitted = new Set(pi.getAllTools().map(
|
|
186
|
-
const requestedFeatures = [...new Set(enable)].filter(feature => features[feature] && permitted.has(
|
|
187
|
-
setActive([...active(), ...requested.map(item => item.
|
|
188
|
-
const current = new Set(active()
|
|
189
|
-
const tools = requested.filter(item => current.has(
|
|
200
|
+
const permitted = new Set(pi.getAllTools().map(tool => tool.name));
|
|
201
|
+
const requestedFeatures = [...new Set(enable)].filter(feature => features[feature] && permitted.has(featureNames[feature]));
|
|
202
|
+
setActive([...active(), ...requested.map(item => item.name), ...requestedFeatures.map(feature => featureNames[feature])]);
|
|
203
|
+
const current = new Set(active());
|
|
204
|
+
const tools = requested.filter(item => current.has(item.name)).map(({ selection, name }) => {
|
|
190
205
|
selected.set(key(selection), selection);
|
|
191
|
-
return
|
|
206
|
+
return { name };
|
|
192
207
|
});
|
|
193
208
|
for (const feature of requestedFeatures) {
|
|
194
|
-
if (!current.has(
|
|
209
|
+
if (!current.has(featureNames[feature]))
|
|
195
210
|
continue;
|
|
196
211
|
selectedFeatures.add(feature);
|
|
197
|
-
tools.push(
|
|
212
|
+
tools.push({ name: featureNames[feature] });
|
|
198
213
|
}
|
|
199
214
|
persist();
|
|
200
215
|
return tools;
|
|
201
216
|
}
|
|
202
|
-
return {
|
|
217
|
+
return { registerFeature, restore, sync, activate, persist, selectedServers: () => [...new Set([...selected.values()].map(item => item.server))] };
|
|
203
218
|
}
|
package/dist/tool-registrar.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import type { McpContent, ContentBlock } from "./types.ts";
|
|
2
2
|
import { type McpPayloadFile } from "./mcp-output-guard.ts";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
/** Overflow/not-run outcomes are receipts, never a fabricated server-schema result. */
|
|
6
|
+
export declare function nativeMcpOutputSchema(schema: Record<string, unknown>): Type.TUnion<[Type.TUnsafe<unknown>, Type.TObject<{
|
|
7
|
+
mcpAdapter: Type.TObject<{
|
|
8
|
+
omitted: Type.TLiteral<true>;
|
|
9
|
+
resultRef: Type.TOptional<Type.TString>;
|
|
10
|
+
error: Type.TOptional<Type.TString>;
|
|
11
|
+
}>;
|
|
12
|
+
}>]>;
|
|
13
|
+
export declare function nativeMcpResult(result: AgentToolResult<Record<string, unknown>>, structured?: boolean): AgentToolResult<Record<string, unknown>>;
|
|
3
14
|
/**
|
|
4
15
|
* Transform MCP content types to Pi content blocks.
|
|
5
16
|
*/
|
package/dist/tool-registrar.js
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
// MCP protocol content to model-visible content.
|
|
2
2
|
import { formatMcpPayloadFile } from "./mcp-output-guard.js";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { toolErrorOverride } from "./error-signal.js";
|
|
5
|
+
/** Overflow/not-run outcomes are receipts, never a fabricated server-schema result. */
|
|
6
|
+
export function nativeMcpOutputSchema(schema) {
|
|
7
|
+
return Type.Union([Type.Unsafe(schema), Type.Object({
|
|
8
|
+
mcpAdapter: Type.Object({
|
|
9
|
+
omitted: Type.Literal(true),
|
|
10
|
+
resultRef: Type.Optional(Type.String()),
|
|
11
|
+
error: Type.Optional(Type.String()),
|
|
12
|
+
}),
|
|
13
|
+
})]);
|
|
14
|
+
}
|
|
15
|
+
export function nativeMcpResult(result, structured = false) {
|
|
16
|
+
const { details } = result;
|
|
17
|
+
const raw = details.mcpResult;
|
|
18
|
+
return {
|
|
19
|
+
...result,
|
|
20
|
+
...toolErrorOverride(details),
|
|
21
|
+
...(structured ? { structuredContent: !details.outputGuard && (!details.error || details.error === "tool_error") && raw?.structuredContent !== undefined
|
|
22
|
+
? raw.structuredContent
|
|
23
|
+
: { mcpAdapter: { omitted: true, ...(typeof details.resultRef === "string" ? { resultRef: details.resultRef } : {}), ...(typeof details.error === "string" ? { error: details.error } : {}) } } } : {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
3
26
|
/**
|
|
4
27
|
* Transform MCP content types to Pi content blocks.
|
|
5
28
|
*/
|
package/dist/types.d.ts
CHANGED
|
@@ -136,6 +136,11 @@ export declare function parseUiPromptHandoff(prompt: string): UiPromptHandoff |
|
|
|
136
136
|
* Collected during the session and available when it ends.
|
|
137
137
|
*/
|
|
138
138
|
export interface UiSessionMessages {
|
|
139
|
+
/** Private JSONL history, readable with the normal paged result reader. */
|
|
140
|
+
historyRef?: string;
|
|
141
|
+
historyBytes?: number;
|
|
142
|
+
historyCount?: number;
|
|
143
|
+
historyError?: string;
|
|
139
144
|
prompts: string[];
|
|
140
145
|
notifications: string[];
|
|
141
146
|
intents: Array<{
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { UiSessionMessages } from "./types.ts";
|
|
2
|
+
type Category = "prompts" | "intents" | "notifications";
|
|
3
|
+
type Message = string | UiSessionMessages["intents"][number];
|
|
4
|
+
/** Private append-only history; failed writes stay owned in memory, never silently discarded. */
|
|
5
|
+
export declare function createUiMessageHistory(outputDirectory?: string): {
|
|
6
|
+
messages: UiSessionMessages;
|
|
7
|
+
append(category: Category, value: Message): string | undefined;
|
|
8
|
+
};
|
|
9
|
+
export declare function uiMessages(messages: UiSessionMessages, category: Category): AsyncGenerator<Message>;
|
|
10
|
+
export declare function materializeUiMessages(messages: UiSessionMessages): Promise<UiSessionMessages>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { appendFileSync, createReadStream, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
/** Private append-only history; failed writes stay owned in memory, never silently discarded. */
|
|
7
|
+
export function createUiMessageHistory(outputDirectory) {
|
|
8
|
+
const messages = { prompts: [], intents: [], notifications: [], contexts: [] };
|
|
9
|
+
let failed = false;
|
|
10
|
+
return {
|
|
11
|
+
messages,
|
|
12
|
+
append(category, value) {
|
|
13
|
+
try {
|
|
14
|
+
if (failed)
|
|
15
|
+
throw new Error(messages.historyError);
|
|
16
|
+
if (!messages.historyRef) {
|
|
17
|
+
const root = outputDirectory === undefined ? tmpdir() : resolve(outputDirectory);
|
|
18
|
+
if (outputDirectory !== undefined)
|
|
19
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
20
|
+
const dir = mkdtempSync(join(root, "pi-mcp-output-"));
|
|
21
|
+
const ref = join(dir, `output-${randomBytes(4).toString("hex")}.txt`);
|
|
22
|
+
writeFileSync(ref, "", { mode: 0o600 });
|
|
23
|
+
messages.historyRef = ref;
|
|
24
|
+
messages.historyBytes = 0;
|
|
25
|
+
messages.historyCount = 0;
|
|
26
|
+
}
|
|
27
|
+
const line = JSON.stringify({ category, value }) + "\n";
|
|
28
|
+
// ponytail: synchronous append owns acceptance order; upgrade to an awaited serial writer if local disk latency becomes material.
|
|
29
|
+
appendFileSync(messages.historyRef, line, { flush: true });
|
|
30
|
+
messages.historyBytes += Buffer.byteLength(line);
|
|
31
|
+
messages.historyCount++;
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
failed = true;
|
|
36
|
+
messages.historyError = error instanceof Error ? error.message : String(error);
|
|
37
|
+
if (category === "intents")
|
|
38
|
+
messages.intents.push(value);
|
|
39
|
+
else
|
|
40
|
+
messages[category].push(value);
|
|
41
|
+
return `UI history could not be saved; messages remain in memory for recovery: ${messages.historyError}. Unsaved messages will be lost on reload or process exit.`;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export async function* uiMessages(messages, category) {
|
|
47
|
+
if (messages.historyRef && messages.historyBytes) {
|
|
48
|
+
// A failed partial append is not an accepted event; the in-memory tail owns it instead.
|
|
49
|
+
const input = createReadStream(messages.historyRef, { encoding: "utf8", end: messages.historyBytes - 1 });
|
|
50
|
+
let bytes = 0, count = 0;
|
|
51
|
+
input.on("data", chunk => { bytes += Buffer.byteLength(chunk); });
|
|
52
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
53
|
+
try {
|
|
54
|
+
for await (const line of lines) {
|
|
55
|
+
const record = JSON.parse(line);
|
|
56
|
+
count++;
|
|
57
|
+
if (record.category === category)
|
|
58
|
+
yield record.value;
|
|
59
|
+
}
|
|
60
|
+
if (bytes !== messages.historyBytes || (messages.historyCount !== undefined && count !== messages.historyCount)) {
|
|
61
|
+
throw new Error("UI history is incomplete; accepted messages remain pending recovery");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
lines.close();
|
|
66
|
+
input.destroy();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const value of messages[category])
|
|
70
|
+
yield value;
|
|
71
|
+
}
|
|
72
|
+
export async function materializeUiMessages(messages) {
|
|
73
|
+
const result = { prompts: [], intents: [], notifications: [], contexts: messages.contexts };
|
|
74
|
+
for await (const value of uiMessages(messages, "prompts"))
|
|
75
|
+
result.prompts.push(value);
|
|
76
|
+
for await (const value of uiMessages(messages, "intents"))
|
|
77
|
+
result.intents.push(value);
|
|
78
|
+
for await (const value of uiMessages(messages, "notifications"))
|
|
79
|
+
result.notifications.push(value);
|
|
80
|
+
return result;
|
|
81
|
+
}
|
package/dist/ui-server.js
CHANGED
|
@@ -10,6 +10,7 @@ import { logger } from "./logger.js";
|
|
|
10
10
|
import { SessionRecoveryAuthRequiredError, withSessionRecovery } from "./session-recovery.js";
|
|
11
11
|
import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-approval.js";
|
|
12
12
|
import { extractUiToolVisibility, isUiToolCallableByApp } from "./ui-tool-visibility.js";
|
|
13
|
+
import { createUiMessageHistory } from "./ui-message-history.js";
|
|
13
14
|
import { createUiModelContextUpdate, extractUiPromptText, getVisualizationStreamEnvelope, isServerDisabled, isToolAllowed, resolveToolPrefix, } from "./types.js";
|
|
14
15
|
const MAX_BODY_SIZE = 2 * 1024 * 1024;
|
|
15
16
|
const ABANDONED_GRACE_MS = 60_000;
|
|
@@ -38,12 +39,8 @@ export async function startUiServer(options) {
|
|
|
38
39
|
const eventLog = [];
|
|
39
40
|
let streamSummary;
|
|
40
41
|
// Track messages from UI for retrieval
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
notifications: [],
|
|
44
|
-
intents: [],
|
|
45
|
-
contexts: [],
|
|
46
|
-
};
|
|
42
|
+
const history = createUiMessageHistory(options.state?.outputDirectory);
|
|
43
|
+
const sessionMessages = history.messages;
|
|
47
44
|
const hostContext = {
|
|
48
45
|
displayMode: currentDisplayMode,
|
|
49
46
|
availableDisplayModes: ["inline", "fullscreen", "pip"],
|
|
@@ -281,6 +278,11 @@ export async function startUiServer(options) {
|
|
|
281
278
|
return;
|
|
282
279
|
if (!validateTokenBody(body, sessionToken, res))
|
|
283
280
|
return;
|
|
281
|
+
// A partial upload is not accepted until its authenticated body is complete.
|
|
282
|
+
if (completed && (url.pathname === "/proxy/ui/message" || url.pathname === "/proxy/ui/context")) {
|
|
283
|
+
sendJson(res, 409, { ok: false, error: "UI session is complete; message was not accepted" });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
284
286
|
const params = body.params ?? {};
|
|
285
287
|
touchHeartbeat();
|
|
286
288
|
if (url.pathname === "/proxy/tools/call") {
|
|
@@ -376,14 +378,15 @@ export async function startUiServer(options) {
|
|
|
376
378
|
const promptText = extractUiPromptText(msgParams);
|
|
377
379
|
// Track messages by type (order: prompt → intent → notify)
|
|
378
380
|
// Must match the order in index.ts onMessage handler
|
|
381
|
+
let warning;
|
|
379
382
|
if (promptText) {
|
|
380
|
-
|
|
383
|
+
warning = history.append("prompts", promptText);
|
|
381
384
|
log.debug("UI prompt received", { prompt: promptText.slice(0, 100) });
|
|
382
385
|
}
|
|
383
386
|
else if (msgParams.type === "intent" || msgParams.intent) {
|
|
384
387
|
const intentName = msgParams.intent ?? "";
|
|
385
388
|
if (intentName) {
|
|
386
|
-
|
|
389
|
+
warning = history.append("intents", {
|
|
387
390
|
intent: intentName,
|
|
388
391
|
...(msgParams.params !== undefined ? { params: msgParams.params } : {}),
|
|
389
392
|
});
|
|
@@ -393,12 +396,16 @@ export async function startUiServer(options) {
|
|
|
393
396
|
else if (msgParams.type === "notify" || msgParams.message) {
|
|
394
397
|
const notifyText = msgParams.message ?? "";
|
|
395
398
|
if (notifyText) {
|
|
396
|
-
|
|
399
|
+
warning = history.append("notifications", notifyText);
|
|
397
400
|
log.debug("UI notification", { message: notifyText.slice(0, 100) });
|
|
398
401
|
}
|
|
399
402
|
}
|
|
400
403
|
await options.onMessage?.(msgParams);
|
|
401
|
-
|
|
404
|
+
if (warning) {
|
|
405
|
+
log.warn(warning);
|
|
406
|
+
options.state?.ui?.notify(warning, "warning");
|
|
407
|
+
}
|
|
408
|
+
sendJson(res, 200, { ok: true, result: {}, ...(warning ? { warning } : {}) });
|
|
402
409
|
return;
|
|
403
410
|
}
|
|
404
411
|
if (url.pathname === "/proxy/ui/context") {
|
package/dist/ui-session.js
CHANGED
|
@@ -297,7 +297,8 @@ export async function maybeStartUiSession(state, request) {
|
|
|
297
297
|
if (state.uiServer === handle) {
|
|
298
298
|
const messages = handle.getSessionMessages();
|
|
299
299
|
const stream = handle.getStreamSummary();
|
|
300
|
-
const hasContent = messages.
|
|
300
|
+
const hasContent = !!messages.historyCount ||
|
|
301
|
+
messages.prompts.length > 0 ||
|
|
301
302
|
messages.intents.length > 0 ||
|
|
302
303
|
messages.notifications.length > 0 ||
|
|
303
304
|
messages.contexts.length > 0 ||
|
|
@@ -316,7 +317,7 @@ export async function maybeStartUiSession(state, request) {
|
|
|
316
317
|
}
|
|
317
318
|
log.debug("Session completed", {
|
|
318
319
|
reason,
|
|
319
|
-
prompts: messages.prompts.length,
|
|
320
|
+
prompts: messages.historyCount ?? messages.prompts.length,
|
|
320
321
|
intents: messages.intents.length,
|
|
321
322
|
notifications: messages.notifications.length,
|
|
322
323
|
contexts: messages.contexts.length,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fitchmultz/pi-mcp-adapter",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.0",
|
|
4
4
|
"description": "Fitch's independently maintained MCP adapter for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"build:bridge": "node ./scripts/build-app-bridge.mjs",
|
|
36
36
|
"prepare": "node ./scripts/prepare.mjs",
|
|
37
37
|
"check:compat": "node scripts/compat-host.mjs && npm run build && npm run typecheck && vitest run --exclude __tests__/interactive-visualizer-server.test.ts && npm run test:oauth && node scripts/compat-package-smoke.mjs",
|
|
38
|
-
"typecheck": "tsc --noEmit",
|
|
38
|
+
"typecheck": "node ./scripts/tsc.mjs --noEmit",
|
|
39
39
|
"test": "vitest run && npm run test:oauth",
|
|
40
40
|
"test:watch": "vitest",
|
|
41
41
|
"test:oauth": "PI_MCP_ADAPTER_TEST_AUTH_STORE=memory node --test --test-concurrency=1 mcp-auth.test.ts mcp-auth-flow.test.ts mcp-callback-server.test.ts mcp-oauth-provider.test.ts",
|
|
@@ -76,9 +76,9 @@
|
|
|
76
76
|
"LICENSE"
|
|
77
77
|
],
|
|
78
78
|
"dependencies": {
|
|
79
|
-
"@modelcontextprotocol/client": "2.
|
|
80
|
-
"@modelcontextprotocol/core": "2.
|
|
81
|
-
"@modelcontextprotocol/ext-apps": "2.0.
|
|
79
|
+
"@modelcontextprotocol/client": "2.2.0",
|
|
80
|
+
"@modelcontextprotocol/core": "2.2.0",
|
|
81
|
+
"@modelcontextprotocol/ext-apps": "2.0.3",
|
|
82
82
|
"@napi-rs/keyring": "2.1.0",
|
|
83
83
|
"cross-spawn": "7.0.6",
|
|
84
84
|
"minisearch": "7.2.0",
|
|
@@ -108,16 +108,16 @@
|
|
|
108
108
|
}
|
|
109
109
|
},
|
|
110
110
|
"devDependencies": {
|
|
111
|
-
"@earendil-works/pi-ai": "0.
|
|
112
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
113
|
-
"@earendil-works/pi-tui": "0.
|
|
114
|
-
"@modelcontextprotocol/server": "2.
|
|
111
|
+
"@earendil-works/pi-ai": "1.0.0",
|
|
112
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
113
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
114
|
+
"@modelcontextprotocol/server": "2.2.0",
|
|
115
115
|
"@types/cross-spawn": "6.0.6",
|
|
116
116
|
"@types/node": "24.19.0",
|
|
117
117
|
"esbuild": "0.28.2",
|
|
118
|
-
"typebox": "1.3.
|
|
118
|
+
"typebox": "1.3.27",
|
|
119
119
|
"typescript": "7.0.2",
|
|
120
|
-
"vitest": "5.0.
|
|
120
|
+
"vitest": "5.0.3"
|
|
121
121
|
},
|
|
122
|
-
"packageManager": "npm@12.
|
|
122
|
+
"packageManager": "npm@12.2.0"
|
|
123
123
|
}
|
package/dist/checkpoint.d.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { McpExtensionState } from "./state.ts";
|
|
3
|
-
/** Optional host event. Published Pi hosts without this event simply never emit it.
|
|
4
|
-
* Keep its structural typing local; do not require a private host package/version. */
|
|
5
|
-
export interface McpCheckpointEvent {
|
|
6
|
-
readonly boundary: "turn" | "settled";
|
|
7
|
-
readonly signal: AbortSignal;
|
|
8
|
-
invalidate(): void;
|
|
9
|
-
}
|
|
10
|
-
export type McpCheckpointResult = {
|
|
11
|
-
sleepReady: true;
|
|
12
|
-
} | {
|
|
13
|
-
sleepReady: false;
|
|
14
|
-
reason: string;
|
|
15
|
-
};
|
|
16
|
-
export declare function onSessionCheckpoint(pi: ExtensionAPI, handler: (event: McpCheckpointEvent) => Promise<McpCheckpointResult>): void;
|
|
17
|
-
/** No shutdown, credential copying, connection replay or permanent owner cancellation. */
|
|
18
|
-
export declare function prepareMcpCheckpoint(state: McpExtensionState, event: McpCheckpointEvent): Promise<McpCheckpointResult>;
|
package/dist/checkpoint.js
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { flushMetadataCache } from "./init.js";
|
|
2
|
-
import { getOAuthCheckpointBlocker, holdOAuthCheckpoint } from "./mcp-auth-flow.js";
|
|
3
|
-
import { isServerDisabled } from "./types.js";
|
|
4
|
-
export function onSessionCheckpoint(pi, handler) {
|
|
5
|
-
pi.on("session_checkpoint", handler);
|
|
6
|
-
}
|
|
7
|
-
/** No shutdown, credential copying, connection replay or permanent owner cancellation. */
|
|
8
|
-
export async function prepareMcpCheckpoint(state, event) {
|
|
9
|
-
event.signal.throwIfAborted();
|
|
10
|
-
const releases = [];
|
|
11
|
-
const release = () => { for (const undo of releases.splice(0).reverse())
|
|
12
|
-
undo(); };
|
|
13
|
-
// Fence all owned sources synchronously, before the first await (including trace flush).
|
|
14
|
-
event.signal.addEventListener("abort", release, { once: true });
|
|
15
|
-
const undo = () => { release(); event.signal.removeEventListener("abort", release); };
|
|
16
|
-
const blocked = (reason) => { undo(); return { sleepReady: false, reason }; };
|
|
17
|
-
try {
|
|
18
|
-
releases.push(state.owner.holdCheckpoint(event));
|
|
19
|
-
releases.push(state.manager.holdCheckpoint(event));
|
|
20
|
-
releases.push(holdOAuthCheckpoint(state.oauthRuntime, event));
|
|
21
|
-
releases.push(state.lifecycle.pauseForCheckpoint());
|
|
22
|
-
if (state.lifecycle.hasActiveHealthCheck())
|
|
23
|
-
return blocked("MCP health check is active");
|
|
24
|
-
for (const [name, definition] of Object.entries(state.config.mcpServers)) {
|
|
25
|
-
if (!isServerDisabled(definition) && !definition.url)
|
|
26
|
-
return blocked(`MCP ${name}: stdio/Unix server state is not reconstructible`);
|
|
27
|
-
}
|
|
28
|
-
const callback = state.owner.getCheckpointBlocker();
|
|
29
|
-
if (callback)
|
|
30
|
-
return blocked(callback);
|
|
31
|
-
if (state.activeScripts)
|
|
32
|
-
return blocked("MCP script is active");
|
|
33
|
-
if (state.uiServer || state.completedUiSessions.length)
|
|
34
|
-
return blocked("MCP UI session/messages require the running runtime");
|
|
35
|
-
if (state.approvedToolCalls.size || state.consentManager.hasDecisions())
|
|
36
|
-
return blocked("MCP session approvals are not persisted");
|
|
37
|
-
const oauth = getOAuthCheckpointBlocker(state.oauthRuntime);
|
|
38
|
-
if (oauth)
|
|
39
|
-
return blocked(oauth);
|
|
40
|
-
const manager = state.manager.getCheckpointBlocker();
|
|
41
|
-
if (manager)
|
|
42
|
-
return blocked(manager);
|
|
43
|
-
flushMetadataCache(state);
|
|
44
|
-
await state.manager.flushForCheckpoint();
|
|
45
|
-
event.signal.throwIfAborted();
|
|
46
|
-
// Release/cancellation is host-owned. Existing clients/timers remain usable afterwards.
|
|
47
|
-
return { sleepReady: true };
|
|
48
|
-
}
|
|
49
|
-
catch (error) {
|
|
50
|
-
undo();
|
|
51
|
-
throw error;
|
|
52
|
-
}
|
|
53
|
-
}
|