pi-mcp-adapter 2.21.0 → 2.21.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 +11 -0
- package/README.md +1 -1
- package/elicitation-handler.ts +27 -25
- package/host-html-template.ts +4 -4
- package/package.json +5 -5
- package/sampling-handler.ts +8 -4
- package/ts-shape.ts +5 -1
- package/ui-server.ts +56 -27
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.21.1] - 2026-08-08
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Refined MCP elicitation and sampling handler TypeScript contracts without changing runtime behavior.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Rendered closed JSON Schema object shapes that use `additionalProperties: false`. Thanks @giuseppecrj for PR #313.
|
|
17
|
+
- Stopped app-only MCP tool calls from triggering model turns or persisting as UI intents. Thanks @VikashLoomba for issue #314.
|
|
18
|
+
- Restored MCP sampling builds with current Pi AI releases by using its compatibility entry point. Thanks @eric-kansas for issue #308.
|
|
19
|
+
- Simplified empty MCP form elicitation to one confirmation dialog. Thanks @shardulbee for issue #309.
|
|
20
|
+
|
|
10
21
|
## [2.21.0] - 2026-08-06
|
|
11
22
|
|
|
12
23
|
### Added
|
package/README.md
CHANGED
|
@@ -438,7 +438,7 @@ Prompt results are flattened into one user message, preserving `[user]` and `[as
|
|
|
438
438
|
|
|
439
439
|
### MCP Elicitation
|
|
440
440
|
|
|
441
|
-
When Pi exposes dialog-capable UI, the adapter advertises form elicitation support. Forms use Pi's stock `select()` and `input()` dialogs, validate the response, and provide a review/edit step before submission. Explicit refusal maps to MCP `decline`; dismissing a dialog maps to `cancel`.
|
|
441
|
+
When Pi exposes dialog-capable UI, the adapter advertises form elicitation support. Forms use Pi's stock `select()` and `input()` dialogs, validate the response, and provide a review/edit step before submission. Empty forms use one confirmation dialog. Explicit refusal maps to MCP `decline`; dismissing a dialog maps to `cancel`.
|
|
442
442
|
|
|
443
443
|
URL mode is advertised only in TUI mode. The adapter displays the requesting server, target host, and full URL, and always requires consent before opening the browser. It also handles URL-required tool errors (`-32042`) and completion notifications; after completing the browser interaction, retry the original tool call.
|
|
444
444
|
|
package/elicitation-handler.ts
CHANGED
|
@@ -14,8 +14,9 @@ import open from "open";
|
|
|
14
14
|
|
|
15
15
|
export type ElicitationValue = string | number | boolean | string[] | undefined;
|
|
16
16
|
type FormProperty = ElicitRequestFormParams["requestedSchema"]["properties"][string];
|
|
17
|
+
type FieldCollectionResult = { status: "cancelled" } | { status: "collected"; value: ElicitationValue };
|
|
17
18
|
|
|
18
|
-
export type ElicitationUIContext = ExtensionUIContext
|
|
19
|
+
export type ElicitationUIContext = Pick<ExtensionUIContext, "select" | "input" | "notify">;
|
|
19
20
|
|
|
20
21
|
export interface ElicitationHandlerOptions {
|
|
21
22
|
serverName: string;
|
|
@@ -44,18 +45,19 @@ export async function handleFormElicitation(
|
|
|
44
45
|
options: ElicitationHandlerOptions,
|
|
45
46
|
params: ElicitRequestFormParams,
|
|
46
47
|
): Promise<ElicitResult> {
|
|
48
|
+
const properties = Object.entries(params.requestedSchema.properties);
|
|
47
49
|
const decision = await options.ui.select(
|
|
48
50
|
`MCP Input Request\nServer: ${options.serverName}\n\n${params.message}`,
|
|
49
51
|
["Continue", "Decline"],
|
|
50
52
|
);
|
|
51
53
|
if (decision === undefined) return { action: "cancel" };
|
|
52
54
|
if (decision === "Decline") return { action: "decline" };
|
|
55
|
+
if (properties.length === 0) return { action: "accept", content: {} };
|
|
53
56
|
|
|
54
57
|
const values: Record<string, ElicitationValue> = {};
|
|
55
|
-
const properties = Object.entries(params.requestedSchema.properties);
|
|
56
58
|
for (const [name, schema] of properties) {
|
|
57
59
|
const value = await collectValidField(options.ui, params, name, schema);
|
|
58
|
-
if (
|
|
60
|
+
if (value.status === "cancelled") return { action: "cancel" };
|
|
59
61
|
values[name] = value.value;
|
|
60
62
|
}
|
|
61
63
|
|
|
@@ -63,7 +65,7 @@ export async function handleFormElicitation(
|
|
|
63
65
|
const content = coerceAndValidateFormValues(params, values);
|
|
64
66
|
const action = await options.ui.select(
|
|
65
67
|
formatReview(options.serverName, properties, content),
|
|
66
|
-
|
|
68
|
+
["Submit", "Edit", "Decline"],
|
|
67
69
|
);
|
|
68
70
|
if (action === undefined) return { action: "cancel" };
|
|
69
71
|
if (action === "Decline") return { action: "decline" };
|
|
@@ -76,7 +78,7 @@ export async function handleFormElicitation(
|
|
|
76
78
|
if (!property) continue;
|
|
77
79
|
const [name, schema] = property;
|
|
78
80
|
const value = await collectValidField(options.ui, params, name, schema, values[name]);
|
|
79
|
-
if (
|
|
81
|
+
if (value.status === "cancelled") return { action: "cancel" };
|
|
80
82
|
values[name] = value.value;
|
|
81
83
|
}
|
|
82
84
|
}
|
|
@@ -87,11 +89,11 @@ async function collectValidField(
|
|
|
87
89
|
name: string,
|
|
88
90
|
schema: FormProperty,
|
|
89
91
|
current?: ElicitationValue,
|
|
90
|
-
): Promise<
|
|
92
|
+
): Promise<FieldCollectionResult> {
|
|
91
93
|
const required = params.requestedSchema.required?.includes(name) === true;
|
|
92
94
|
while (true) {
|
|
93
95
|
const result = await collectField(ui, params, name, schema, current);
|
|
94
|
-
if (
|
|
96
|
+
if (result.status === "cancelled") return result;
|
|
95
97
|
try {
|
|
96
98
|
coerceAndValidateFormValues({
|
|
97
99
|
...params,
|
|
@@ -115,7 +117,7 @@ async function collectField(
|
|
|
115
117
|
name: string,
|
|
116
118
|
schema: FormProperty,
|
|
117
119
|
current?: ElicitationValue,
|
|
118
|
-
): Promise<
|
|
120
|
+
): Promise<FieldCollectionResult> {
|
|
119
121
|
const required = params.requestedSchema.required?.includes(name) === true;
|
|
120
122
|
const title = [schema.title ?? humanizeName(name), required ? "(required)" : "", schema.description]
|
|
121
123
|
.filter(Boolean)
|
|
@@ -135,10 +137,10 @@ async function collectField(
|
|
|
135
137
|
const omit = required ? undefined : uniqueAction("Omit", actions);
|
|
136
138
|
if (omit) actions.push(omit);
|
|
137
139
|
const action = await ui.select(title, actions);
|
|
138
|
-
if (action === undefined) return {
|
|
139
|
-
if (action === useDefault) return {
|
|
140
|
-
if (action === omit) return {
|
|
141
|
-
return {
|
|
140
|
+
if (action === undefined) return { status: "cancelled" };
|
|
141
|
+
if (action === useDefault) return { status: "collected", value: schema.default };
|
|
142
|
+
if (action === omit) return { status: "collected", value: undefined };
|
|
143
|
+
return { status: "collected", value: choices[displays.indexOf(action)]?.value };
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
if (schema.type === "boolean") {
|
|
@@ -146,10 +148,10 @@ async function collectField(
|
|
|
146
148
|
if (schema.default !== undefined) actions.push("Use default");
|
|
147
149
|
if (!required) actions.push("Omit");
|
|
148
150
|
const action = await ui.select(title, actions);
|
|
149
|
-
if (action === undefined) return {
|
|
150
|
-
if (action === "Use default") return {
|
|
151
|
-
if (action === "Omit") return {
|
|
152
|
-
return {
|
|
151
|
+
if (action === undefined) return { status: "cancelled" };
|
|
152
|
+
if (action === "Use default") return { status: "collected", value: schema.default };
|
|
153
|
+
if (action === "Omit") return { status: "collected", value: undefined };
|
|
154
|
+
return { status: "collected", value: action === "Yes" };
|
|
153
155
|
}
|
|
154
156
|
|
|
155
157
|
if (schema.type === "array") {
|
|
@@ -157,9 +159,9 @@ async function collectField(
|
|
|
157
159
|
if (schema.default !== undefined) actions.push("Use default");
|
|
158
160
|
if (!required) actions.push("Omit");
|
|
159
161
|
const action = await ui.select(title, actions);
|
|
160
|
-
if (action === undefined) return {
|
|
161
|
-
if (action === "Use default") return {
|
|
162
|
-
if (action === "Omit") return {
|
|
162
|
+
if (action === undefined) return { status: "cancelled" };
|
|
163
|
+
if (action === "Use default") return { status: "collected", value: schema.default };
|
|
164
|
+
if (action === "Omit") return { status: "collected", value: undefined };
|
|
163
165
|
|
|
164
166
|
const choices = extractMultiSelectOptions(schema);
|
|
165
167
|
const selected = new Set(Array.isArray(current) ? current : []);
|
|
@@ -167,8 +169,8 @@ async function collectField(
|
|
|
167
169
|
const displays = uniqueLabels(choices.map(choice => selected.has(choice.value) ? `✓ ${choice.display}` : choice.display));
|
|
168
170
|
const done = uniqueAction("Done", displays);
|
|
169
171
|
const picked = await ui.select(title, [...displays, done]);
|
|
170
|
-
if (picked === undefined) return {
|
|
171
|
-
if (picked === done) return {
|
|
172
|
+
if (picked === undefined) return { status: "cancelled" };
|
|
173
|
+
if (picked === done) return { status: "collected", value: [...selected] };
|
|
172
174
|
const choice = choices[displays.indexOf(picked)];
|
|
173
175
|
if (!choice) continue;
|
|
174
176
|
if (selected.has(choice.value)) selected.delete(choice.value);
|
|
@@ -180,11 +182,11 @@ async function collectField(
|
|
|
180
182
|
if (schema.default !== undefined) actions.push("Use default");
|
|
181
183
|
if (!required) actions.push("Omit");
|
|
182
184
|
const action = await ui.select(title, actions);
|
|
183
|
-
if (action === undefined) return {
|
|
184
|
-
if (action === "Use default") return {
|
|
185
|
-
if (action === "Omit") return {
|
|
185
|
+
if (action === undefined) return { status: "cancelled" };
|
|
186
|
+
if (action === "Use default") return { status: "collected", value: schema.default };
|
|
187
|
+
if (action === "Omit") return { status: "collected", value: undefined };
|
|
186
188
|
const entered = await ui.input(title, current === undefined ? undefined : String(current));
|
|
187
|
-
return entered === undefined ? {
|
|
189
|
+
return entered === undefined ? { status: "cancelled" } : { status: "collected", value: entered };
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
export function coerceAndValidateFormValues(
|
package/host-html-template.ts
CHANGED
|
@@ -226,10 +226,10 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
|
|
|
226
226
|
}
|
|
227
227
|
const result = await post("/proxy/tools/call", params);
|
|
228
228
|
// Notify agent about the tool call
|
|
229
|
-
await post("/proxy/ui/
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
229
|
+
await post("/proxy/ui/generated-tool-call-intent", {
|
|
230
|
+
tool: params.name,
|
|
231
|
+
arguments: params.arguments,
|
|
232
|
+
isError: result.isError
|
|
233
233
|
}).catch(() => {});
|
|
234
234
|
return result;
|
|
235
235
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mcp-adapter",
|
|
3
|
-
"version": "2.21.
|
|
3
|
+
"version": "2.21.1",
|
|
4
4
|
"description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.ts",
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
"zod": "^3.25.0 || ^4.0.0"
|
|
140
140
|
},
|
|
141
141
|
"peerDependencies": {
|
|
142
|
-
"@earendil-works/pi-ai": "
|
|
142
|
+
"@earendil-works/pi-ai": "^0.84.1",
|
|
143
143
|
"@earendil-works/pi-tui": "*",
|
|
144
144
|
"typebox": "*",
|
|
145
145
|
"zod": "^3.25.0 || ^4.0.0"
|
|
@@ -156,9 +156,9 @@
|
|
|
156
156
|
}
|
|
157
157
|
},
|
|
158
158
|
"devDependencies": {
|
|
159
|
-
"@earendil-works/pi-ai": "0.
|
|
160
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
161
|
-
"@earendil-works/pi-tui": "0.
|
|
159
|
+
"@earendil-works/pi-ai": "0.84.1",
|
|
160
|
+
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
161
|
+
"@earendil-works/pi-tui": "0.84.1",
|
|
162
162
|
"@modelcontextprotocol/conformance": "0.1.16",
|
|
163
163
|
"@types/bun": "^1.0.0",
|
|
164
164
|
"@types/cross-spawn": "6.0.6",
|
package/sampling-handler.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { complete
|
|
1
|
+
import { complete } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
import type { Api, AssistantMessage, Message, Model, ProviderHeaders, TextContent } from "@earendil-works/pi-ai";
|
|
2
3
|
import { truncateAtWord } from "./utils.ts";
|
|
3
4
|
import { throwIfAborted } from "./abort.ts";
|
|
4
5
|
import type { ExtensionUIContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
@@ -11,11 +12,14 @@ import {
|
|
|
11
12
|
type SamplingMessageContentBlock,
|
|
12
13
|
} from "@modelcontextprotocol/client";
|
|
13
14
|
|
|
15
|
+
export type SamplingUIContext = Pick<ExtensionUIContext, "confirm">;
|
|
16
|
+
export type SamplingModelRegistry = Pick<ModelRegistry, "getAvailable" | "getApiKeyAndHeaders">;
|
|
17
|
+
|
|
14
18
|
export interface SamplingHandlerOptions {
|
|
15
19
|
serverName: string;
|
|
16
20
|
autoApprove: boolean;
|
|
17
|
-
ui?:
|
|
18
|
-
modelRegistry:
|
|
21
|
+
ui?: SamplingUIContext;
|
|
22
|
+
modelRegistry: SamplingModelRegistry;
|
|
19
23
|
getCurrentModel: () => Model<Api> | undefined;
|
|
20
24
|
getSignal: () => AbortSignal | undefined;
|
|
21
25
|
}
|
|
@@ -126,7 +130,7 @@ async function resolveSamplingModel(
|
|
|
126
130
|
): Promise<{
|
|
127
131
|
model: Model<Api>;
|
|
128
132
|
apiKey?: string;
|
|
129
|
-
headers?:
|
|
133
|
+
headers?: ProviderHeaders;
|
|
130
134
|
}> {
|
|
131
135
|
const candidates: Model<Api>[] = [];
|
|
132
136
|
const availableModels = options.modelRegistry.getAvailable();
|
package/ts-shape.ts
CHANGED
|
@@ -111,7 +111,11 @@ function isSchema(value: unknown): value is Schema {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
function hasUnsupportedKeyword(schema: Schema): boolean {
|
|
114
|
-
return UNSUPPORTED_KEYWORDS.some(keyword =>
|
|
114
|
+
return UNSUPPORTED_KEYWORDS.some(keyword => {
|
|
115
|
+
if (!Object.hasOwn(schema, keyword)) return false;
|
|
116
|
+
// `additionalProperties: false` is a closed-object constraint, not a shape that this renderer needs to understand.
|
|
117
|
+
return keyword !== "additionalProperties" || schema.additionalProperties !== false;
|
|
118
|
+
});
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
function decodePointerToken(token: string): string {
|
package/ui-server.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { McpServerManager } from "./server-manager.ts";
|
|
|
17
17
|
import type { McpExtensionState } from "./state.ts";
|
|
18
18
|
import { SessionRecoveryAuthRequiredError, withSessionRecovery, type SessionRecoveryDeps } from "./session-recovery.ts";
|
|
19
19
|
import { ensureToolCallApproved, isToolCallApprovalRequired } from "./tool-approval.ts";
|
|
20
|
-
import { extractUiToolVisibility, isUiToolCallableByApp } from "./ui-tool-visibility.ts";
|
|
20
|
+
import { extractUiToolVisibility, isUiToolCallableByApp, isUiToolVisibleToModel } from "./ui-tool-visibility.ts";
|
|
21
21
|
import {
|
|
22
22
|
createUiModelContextUpdate,
|
|
23
23
|
extractUiPromptText,
|
|
@@ -145,6 +145,41 @@ export async function startUiServer(options: UiServerOptions): Promise<UiServerH
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
const isAppOnlyTool = (toolName: string): boolean => {
|
|
149
|
+
const toolDefinition = options.manager.getConnection(options.serverName)?.tools?.find((tool) => tool.name === toolName);
|
|
150
|
+
if (!toolDefinition) return false;
|
|
151
|
+
const visibility = extractUiToolVisibility(toolDefinition._meta);
|
|
152
|
+
return isUiToolCallableByApp(visibility) && !isUiToolVisibleToModel(visibility);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const recordUiMessage = async (msgParams: UiMessageParams): Promise<void> => {
|
|
156
|
+
const promptText = extractUiPromptText(msgParams);
|
|
157
|
+
|
|
158
|
+
// Track messages by type (order: prompt → intent → notify)
|
|
159
|
+
// Must match the order in index.ts onMessage handler
|
|
160
|
+
if (promptText) {
|
|
161
|
+
sessionMessages.prompts.push(promptText);
|
|
162
|
+
log.debug("UI prompt received", { prompt: promptText.slice(0, 100) });
|
|
163
|
+
} else if (msgParams.type === "intent" || msgParams.intent) {
|
|
164
|
+
const intentName = msgParams.intent ?? "";
|
|
165
|
+
if (intentName) {
|
|
166
|
+
sessionMessages.intents.push({
|
|
167
|
+
intent: intentName,
|
|
168
|
+
...(msgParams.params !== undefined ? { params: msgParams.params } : {}),
|
|
169
|
+
});
|
|
170
|
+
log.debug("UI intent received", { intent: intentName });
|
|
171
|
+
}
|
|
172
|
+
} else if (msgParams.type === "notify" || msgParams.message) {
|
|
173
|
+
const notifyText = msgParams.message ?? "";
|
|
174
|
+
if (notifyText) {
|
|
175
|
+
sessionMessages.notifications.push(notifyText);
|
|
176
|
+
log.debug("UI notification", { message: notifyText.slice(0, 100) });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
await options.onMessage?.(msgParams);
|
|
181
|
+
};
|
|
182
|
+
|
|
148
183
|
const touchHeartbeat = () => {
|
|
149
184
|
lastHeartbeatAt = Date.now();
|
|
150
185
|
};
|
|
@@ -476,33 +511,27 @@ export async function startUiServer(options: UiServerOptions): Promise<UiServerH
|
|
|
476
511
|
return;
|
|
477
512
|
}
|
|
478
513
|
|
|
479
|
-
if (url.pathname === "/proxy/ui/
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
...(msgParams.params !== undefined ? { params: msgParams.params } : {}),
|
|
494
|
-
});
|
|
495
|
-
log.debug("UI intent received", { intent: intentName });
|
|
496
|
-
}
|
|
497
|
-
} else if (msgParams.type === "notify" || msgParams.message) {
|
|
498
|
-
const notifyText = msgParams.message ?? "";
|
|
499
|
-
if (notifyText) {
|
|
500
|
-
sessionMessages.notifications.push(notifyText);
|
|
501
|
-
log.debug("UI notification", { message: notifyText.slice(0, 100) });
|
|
502
|
-
}
|
|
514
|
+
if (url.pathname === "/proxy/ui/generated-tool-call-intent") {
|
|
515
|
+
const tool = typeof params.tool === "string" ? params.tool : undefined;
|
|
516
|
+
if (tool && isAppOnlyTool(tool)) {
|
|
517
|
+
log.debug("Ignored generated app-only tool call intent", { tool });
|
|
518
|
+
} else {
|
|
519
|
+
await recordUiMessage({
|
|
520
|
+
type: "intent",
|
|
521
|
+
intent: "call_tool",
|
|
522
|
+
params: {
|
|
523
|
+
...(tool !== undefined ? { tool } : {}),
|
|
524
|
+
...(params.arguments !== undefined ? { arguments: params.arguments } : {}),
|
|
525
|
+
...(params.isError !== undefined ? { isError: params.isError } : {}),
|
|
526
|
+
},
|
|
527
|
+
});
|
|
503
528
|
}
|
|
504
|
-
|
|
505
|
-
|
|
529
|
+
sendJson(res, 200, { ok: true, result: {} });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (url.pathname === "/proxy/ui/message") {
|
|
534
|
+
await recordUiMessage(params as UiMessageParams);
|
|
506
535
|
sendJson(res, 200, { ok: true, result: {} });
|
|
507
536
|
return;
|
|
508
537
|
}
|