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 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
 
@@ -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 (!("value" in value)) return { action: "cancel" };
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
- properties.length > 0 ? ["Submit", "Edit", "Decline"] : ["Submit", "Decline"],
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 (!("value" in value)) return { action: "cancel" };
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<{ cancelled: true } | { cancelled: false; value: ElicitationValue }> {
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 (!("value" in result)) return result;
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<{ cancelled: true } | { cancelled: false; value: ElicitationValue }> {
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 { cancelled: true };
139
- if (action === useDefault) return { cancelled: false, value: schema.default };
140
- if (action === omit) return { cancelled: false, value: undefined };
141
- return { cancelled: false, value: choices[displays.indexOf(action)]?.value };
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 { cancelled: true };
150
- if (action === "Use default") return { cancelled: false, value: schema.default };
151
- if (action === "Omit") return { cancelled: false, value: undefined };
152
- return { cancelled: false, value: action === "Yes" };
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 { cancelled: true };
161
- if (action === "Use default") return { cancelled: false, value: schema.default };
162
- if (action === "Omit") return { cancelled: false, value: undefined };
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 { cancelled: true };
171
- if (picked === done) return { cancelled: false, value: [...selected] };
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 { cancelled: true };
184
- if (action === "Use default") return { cancelled: false, value: schema.default };
185
- if (action === "Omit") return { cancelled: false, value: undefined };
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 ? { cancelled: true } : { cancelled: false, value: entered };
189
+ return entered === undefined ? { status: "cancelled" } : { status: "collected", value: entered };
188
190
  }
189
191
 
190
192
  export function coerceAndValidateFormValues(
@@ -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/message", {
230
- type: "intent",
231
- intent: "call_tool",
232
- params: { tool: params.name, arguments: params.arguments, isError: result.isError }
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.0",
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.74.2",
160
- "@earendil-works/pi-coding-agent": "0.79.10",
161
- "@earendil-works/pi-tui": "0.74.2",
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",
@@ -1,4 +1,5 @@
1
- import { complete, type Api, type AssistantMessage, type Message, type Model, type TextContent } from "@earendil-works/pi-ai";
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?: ExtensionUIContext;
18
- modelRegistry: 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?: Record<string, string>;
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 => Object.hasOwn(schema, 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/message") {
480
- const msgParams = params as UiMessageParams;
481
- const promptText = extractUiPromptText(msgParams);
482
-
483
- // Track messages by type (order: prompt → intent → notify)
484
- // Must match the order in index.ts onMessage handler
485
- if (promptText) {
486
- sessionMessages.prompts.push(promptText);
487
- log.debug("UI prompt received", { prompt: promptText.slice(0, 100) });
488
- } else if (msgParams.type === "intent" || msgParams.intent) {
489
- const intentName = msgParams.intent ?? "";
490
- if (intentName) {
491
- sessionMessages.intents.push({
492
- intent: intentName,
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
- await options.onMessage?.(msgParams);
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
  }