pi-mcp-adapter 2.21.0 → 2.21.2

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,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.21.2] - 2026-08-09
11
+
12
+ ### Fixed
13
+ - Reported MCP servers still connecting after a zero-result tool search, so agents retry instead of treating the result as definitive. Thanks @Leon69924 for issue #316.
14
+ - Rejected malformed MCP config server entries and persisted OAuth credential records at their trust boundaries, so invalid local state fails before it reaches runtime connection or token code.
15
+ - Sized OAuth credential chunks below the Windows Credential Manager per-value limit, so oversized OAuth records persist on Windows instead of failing at every payload size. The previous 1800-character chunk size exceeded the 1280-character ceiling, which left the chunking added in #246 ineffective on Windows. Thanks @CrazyCoder for PR #318.
16
+
17
+ ## [2.21.1] - 2026-08-08
18
+
19
+ ### Changed
20
+ - Refined MCP elicitation and sampling handler TypeScript contracts without changing runtime behavior.
21
+
22
+ ### Fixed
23
+ - Rendered closed JSON Schema object shapes that use `additionalProperties: false`. Thanks @giuseppecrj for PR #313.
24
+ - Stopped app-only MCP tool calls from triggering model turns or persisting as UI intents. Thanks @VikashLoomba for issue #314.
25
+ - Restored MCP sampling builds with current Pi AI releases by using its compatibility entry point. Thanks @eric-kansas for issue #308.
26
+ - Simplified empty MCP form elicitation to one confirmation dialog. Thanks @shardulbee for issue #309.
27
+
10
28
  ## [2.21.0] - 2026-08-06
11
29
 
12
30
  ### 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/config.ts CHANGED
@@ -638,22 +638,32 @@ function readValidatedConfig(path: string, label: string): McpConfig | null {
638
638
  }
639
639
 
640
640
  function validateConfig(raw: unknown): McpConfig {
641
- if (!raw || typeof raw !== "object") {
641
+ if (!isRecord(raw)) {
642
642
  return { mcpServers: {} };
643
643
  }
644
644
 
645
- const obj = raw as Record<string, unknown>;
646
- const servers = obj.mcpServers ?? obj["mcp-servers"] ?? {};
645
+ return {
646
+ mcpServers: toServerEntries(raw.mcpServers ?? raw["mcp-servers"]),
647
+ ...(Array.isArray(raw.imports) ? { imports: raw.imports as ImportKind[] } : {}),
648
+ ...(raw.settings !== undefined ? { settings: raw.settings as McpSettings } : {}),
649
+ };
650
+ }
647
651
 
648
- if (typeof servers !== "object" || servers === null || Array.isArray(servers)) {
649
- return { mcpServers: {} };
652
+ function toServerEntries(servers: unknown): Record<string, ServerEntry> {
653
+ if (!isRecord(servers)) return {};
654
+ const entries: Record<string, ServerEntry> = {};
655
+ for (const [name, entry] of Object.entries(servers)) {
656
+ if (isServerEntry(entry)) entries[name] = entry;
650
657
  }
658
+ return entries;
659
+ }
651
660
 
652
- return {
653
- mcpServers: servers as Record<string, ServerEntry>,
654
- ...(Array.isArray(obj.imports) ? { imports: obj.imports as ImportKind[] } : {}),
655
- ...(obj.settings !== undefined ? { settings: obj.settings as McpSettings } : {}),
656
- };
661
+ function isServerEntry(value: unknown): value is ServerEntry {
662
+ return isRecord(value);
663
+ }
664
+
665
+ function isRecord(value: unknown): value is Record<string, unknown> {
666
+ return typeof value === "object" && value !== null && !Array.isArray(value);
657
667
  }
658
668
 
659
669
  function mergeOpenCodeConfigs(base: Record<string, unknown>, next: Record<string, unknown>): Record<string, unknown> {
@@ -787,12 +797,13 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
787
797
  continue;
788
798
  }
789
799
 
790
- if (kind !== "codex" || !entry || typeof entry !== "object" || Array.isArray(entry)) {
791
- mappedServers[name] = entry as ServerEntry;
800
+ if (!isRecord(entry)) continue;
801
+ if (kind !== "codex") {
802
+ mappedServers[name] = entry;
792
803
  continue;
793
804
  }
794
805
 
795
- const mapped = { ...(entry as Record<string, unknown>) };
806
+ const mapped = { ...entry };
796
807
  const bearerTokenEnv = mapped.bearer_token_env_var;
797
808
  const httpHeaders = mapped.http_headers;
798
809
  const envHttpHeaders = mapped.env_http_headers;
@@ -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/mcp-auth-flow.ts CHANGED
@@ -667,14 +667,12 @@ export async function authenticate(
667
667
  }
668
668
 
669
669
  const operation = (async (): Promise<AuthStatus> => {
670
- // Start auth flow
671
670
  const { authorizationUrl } = await startAuth(serverName, serverUrl, definition, {
672
671
  ...options,
673
672
  ...(signal ? { signal } : {}),
674
673
  runtime,
675
674
  })
676
675
 
677
- // If no auth URL needed, already authenticated
678
676
  if (!authorizationUrl) {
679
677
  return "authenticated"
680
678
  }
@@ -708,13 +706,11 @@ export async function authenticate(
708
706
  console.warn(`MCP Auth: Failed to open browser for ${serverName}; waiting for manual callback`, { error })
709
707
  }
710
708
 
711
- // Wait for callback
712
709
  const callbackResult = await abortable(callbackPromise, signal)
713
710
 
714
711
  // The callback server accepted only the flow-local reserved state.
715
712
  throwIfAborted(signal)
716
713
 
717
- // Complete the auth
718
714
  return await completeAuth(serverName, callbackResult, {
719
715
  ...options,
720
716
  ...(signal ? { signal } : {}),
@@ -758,25 +754,21 @@ export async function getValidToken(
758
754
  const authStorageOptions = options.authStorageOptions ?? {}
759
755
  const signal = combineAbortSignals(runtime.signal, options.signal)
760
756
  throwIfAborted(signal)
761
- // Check if we have valid tokens
762
757
  const entry = await getAuthForUrl(serverName, serverUrl, authStorageOptions)
763
758
  throwIfAborted(signal)
764
759
  if (!entry?.tokens) {
765
760
  return null
766
761
  }
767
762
 
768
- // Check expiration
769
763
  const expired = await isTokenExpired(serverName, authStorageOptions)
770
764
  if (expired === false) {
771
765
  return entry.tokens
772
766
  }
773
767
 
774
768
  if (expired === true && entry.tokens.refreshToken) {
775
- // Token is expired, try to refresh
776
769
  console.log(`MCP Auth: Token expired for ${serverName}, attempting refresh`)
777
770
 
778
771
  try {
779
- // Create auth provider for token refresh
780
772
  const authProvider = new McpOAuthProvider(serverName, serverUrl, {}, {
781
773
  onRedirect: async () => {},
782
774
  }, authStorageOptions, runtime.signal)
package/mcp-auth.ts CHANGED
@@ -22,7 +22,16 @@ import { resolveConfiguredOAuthDir } from './config.ts';
22
22
  const require = createRequire(import.meta.url);
23
23
  const AUTH_SECRET_SERVICE = 'pi-mcp-adapter.oauth';
24
24
  const TEST_AUTH_STORE_ENV = 'PI_MCP_ADAPTER_TEST_AUTH_STORE';
25
- const AUTH_SECRET_CHUNK_SIZE = 1800;
25
+ /**
26
+ * Windows Credential Manager caps one value at CRED_MAX_CREDENTIAL_BLOB_SIZE
27
+ * (2560 bytes) and stores it as UTF-16, so the real ceiling is
28
+ * AUTH_SECRET_VALUE_LIMIT characters. Chunks must stay below that, and so must
29
+ * the threshold that decides whether to chunk at all, or oversized records still
30
+ * fail to persist on Windows.
31
+ */
32
+ const AUTH_SECRET_CHUNK_SIZE = 1000;
33
+ /** Largest single value the strictest supported credential store accepts. */
34
+ const AUTH_SECRET_VALUE_LIMIT = 1280;
26
35
  const KEYRING_RECOVERY_DISABLED_ENV = 'PI_MCP_ADAPTER_DISABLE_KEYRING_RECOVERY';
27
36
  const KEYRING_RECOVERY_KEYCTL_ENV = 'PI_MCP_ADAPTER_KEYRING_RECOVERY_KEYCTL';
28
37
  const KEYRING_RECOVERY_NODE_ENV = 'PI_MCP_ADAPTER_KEYRING_RECOVERY_NODE';
@@ -163,6 +172,22 @@ const keyringAuthSecretStore: AuthSecretStore = {
163
172
  },
164
173
  };
165
174
 
175
+ /** Mimics the Windows Credential Manager per-value ceiling for tests. */
176
+ const sizeLimitedAuthSecretStore: AuthSecretStore = {
177
+ read(account) {
178
+ return memoryAuthEntries.get(account);
179
+ },
180
+ write(account, payload) {
181
+ if (payload.length > AUTH_SECRET_VALUE_LIMIT) {
182
+ throw new Error(`Value of 'password encoded as UTF-16' is longer than the platform limit of ${AUTH_SECRET_VALUE_LIMIT * 2} chars`);
183
+ }
184
+ memoryAuthEntries.set(account, payload);
185
+ },
186
+ remove(account) {
187
+ memoryAuthEntries.delete(account);
188
+ },
189
+ };
190
+
166
191
  const unavailableAuthSecretStore: AuthSecretStore = {
167
192
  read() {
168
193
  throw new Error('simulated secure credential store unavailable');
@@ -205,6 +230,7 @@ export function removeTestAuthSecretStoreEntry(account: string): void {
205
230
 
206
231
  function getAuthSecretStore(): AuthSecretStore {
207
232
  if (process.env[TEST_AUTH_STORE_ENV] === 'memory') return memoryAuthSecretStore;
233
+ if (process.env[TEST_AUTH_STORE_ENV] === 'sizelimited') return sizeLimitedAuthSecretStore;
208
234
  if (process.env[TEST_AUTH_STORE_ENV] === 'unavailable') return unavailableAuthSecretStore;
209
235
  if (process.env[TEST_AUTH_STORE_ENV] === 'keyrevoked') return keyRevokedAuthSecretStore;
210
236
  return keyringAuthSecretStore;
@@ -401,7 +427,99 @@ function parseJsonPayload(serverName: string, payload: string, source: string):
401
427
  }
402
428
 
403
429
  function parseAuthEntryPayload(serverName: string, payload: string, source: string): AuthEntry {
404
- return parseJsonPayload(serverName, payload, source) as AuthEntry;
430
+ const parsed = parseJsonPayload(serverName, payload, source);
431
+ const entry = toAuthEntry(parsed);
432
+ if (!entry) {
433
+ throw new Error(`Failed to parse OAuth credentials for ${serverName} from ${source}: invalid credential shape`);
434
+ }
435
+ return entry;
436
+ }
437
+
438
+ function toAuthEntry(value: unknown): AuthEntry | undefined {
439
+ const entry = toRecord(value);
440
+ if (!entry) return undefined;
441
+
442
+ const codeVerifier = optionalString(entry.codeVerifier);
443
+ const oauthState = optionalString(entry.oauthState);
444
+ const serverUrl = optionalString(entry.serverUrl);
445
+ if (codeVerifier === null || oauthState === null || serverUrl === null) return undefined;
446
+
447
+ const tokens = entry.tokens === undefined ? undefined : toStoredTokens(entry.tokens);
448
+ const clientInfo = entry.clientInfo === undefined ? undefined : toStoredClientInfo(entry.clientInfo);
449
+ if ((entry.tokens !== undefined && !tokens) || (entry.clientInfo !== undefined && !clientInfo)) return undefined;
450
+
451
+ const authEntry: AuthEntry = {};
452
+ if (tokens) authEntry.tokens = tokens;
453
+ if (clientInfo) authEntry.clientInfo = clientInfo;
454
+ if (codeVerifier !== undefined) authEntry.codeVerifier = codeVerifier;
455
+ if (oauthState !== undefined) authEntry.oauthState = oauthState;
456
+ if (serverUrl !== undefined) authEntry.serverUrl = serverUrl;
457
+ return authEntry;
458
+ }
459
+
460
+ function toStoredTokens(value: unknown): StoredTokens | undefined {
461
+ const tokens = toRecord(value);
462
+ if (!tokens || typeof tokens.accessToken !== 'string') return undefined;
463
+
464
+ const refreshToken = optionalString(tokens.refreshToken);
465
+ const scope = optionalString(tokens.scope);
466
+ const issuer = optionalString(tokens.issuer);
467
+ const expiresAt = optionalNumber(tokens.expiresAt);
468
+ if (refreshToken === null || scope === null || issuer === null || expiresAt === null) return undefined;
469
+
470
+ const storedTokens: StoredTokens = { accessToken: tokens.accessToken };
471
+ if (refreshToken !== undefined) storedTokens.refreshToken = refreshToken;
472
+ if (expiresAt !== undefined) storedTokens.expiresAt = expiresAt;
473
+ if (scope !== undefined) storedTokens.scope = scope;
474
+ if (issuer !== undefined) storedTokens.issuer = issuer;
475
+ return storedTokens;
476
+ }
477
+
478
+ function toStoredClientInfo(value: unknown): StoredClientInfo | undefined {
479
+ const clientInfo = toRecord(value);
480
+ if (!clientInfo || typeof clientInfo.clientId !== 'string') return undefined;
481
+
482
+ const clientSecret = optionalString(clientInfo.clientSecret);
483
+ const issuer = optionalString(clientInfo.issuer);
484
+ const clientIdIssuedAt = optionalNumber(clientInfo.clientIdIssuedAt);
485
+ const clientSecretExpiresAt = optionalNumber(clientInfo.clientSecretExpiresAt);
486
+ const configPreRegistered = optionalBoolean(clientInfo.configPreRegistered);
487
+ if (clientSecret === null || issuer === null || clientIdIssuedAt === null || clientSecretExpiresAt === null || configPreRegistered === null) return undefined;
488
+
489
+ const storedClient: StoredClientInfo = { clientId: clientInfo.clientId };
490
+ const redirectUris = stringArray(clientInfo.redirectUris);
491
+ if (clientSecret !== undefined) storedClient.clientSecret = clientSecret;
492
+ if (clientIdIssuedAt !== undefined) storedClient.clientIdIssuedAt = clientIdIssuedAt;
493
+ if (clientSecretExpiresAt !== undefined) storedClient.clientSecretExpiresAt = clientSecretExpiresAt;
494
+ if (redirectUris !== undefined) storedClient.redirectUris = redirectUris;
495
+ if (issuer !== undefined) storedClient.issuer = issuer;
496
+ if (configPreRegistered !== undefined) storedClient.configPreRegistered = configPreRegistered;
497
+ return storedClient;
498
+ }
499
+
500
+ function toRecord(value: unknown): Record<string, unknown> | undefined {
501
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
502
+ ? value as Record<string, unknown>
503
+ : undefined;
504
+ }
505
+
506
+ function optionalString(value: unknown): string | null | undefined {
507
+ if (value === undefined) return undefined;
508
+ return typeof value === 'string' ? value : null;
509
+ }
510
+
511
+ function optionalNumber(value: unknown): number | null | undefined {
512
+ if (value === undefined) return undefined;
513
+ return typeof value === 'number' ? value : null;
514
+ }
515
+
516
+ function optionalBoolean(value: unknown): boolean | null | undefined {
517
+ if (value === undefined) return undefined;
518
+ return typeof value === 'boolean' ? value : null;
519
+ }
520
+
521
+ function stringArray(value: unknown): string[] | undefined {
522
+ return Array.isArray(value) && value.every(uri => typeof uri === 'string') ? value : undefined;
405
523
  }
406
524
 
407
525
  function isAuthEntryChunkManifest(value: unknown): value is AuthEntryChunkManifest {
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.2",
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",
package/proxy-modes.ts CHANGED
@@ -525,10 +525,28 @@ export function executeSearch(
525
525
 
526
526
  const page = paginate(matches, offset, limit);
527
527
  if (page.total === 0) {
528
+ const connectingServers = server
529
+ ? state.config.mcpServers[server] && state.manager.isConnecting(server) ? [server] : []
530
+ : Object.keys(state.config.mcpServers)
531
+ .filter(name => !isServerDisabled(state.config.mcpServers[name]) && state.manager.isConnecting(name))
532
+ .sort((a, b) => a.localeCompare(b));
528
533
  const msg = server ? `No tools matching "${query}" in "${server}"` : `No tools matching "${query}"`;
534
+ const connectingMessage = connectingServers.length === 1
535
+ ? ` Server "${connectingServers[0]}" is still connecting; retry in a moment.`
536
+ : connectingServers.length > 1
537
+ ? ` Servers ${connectingServers.map(name => `"${name}"`).join(", ")} are still connecting; retry in a moment.`
538
+ : "";
529
539
  return {
530
- content: [{ type: "text" as const, text: msg }],
531
- details: { mode: "search", matches: [], count: 0, hasMore: false, nextOffset: null, query },
540
+ content: [{ type: "text" as const, text: `${msg}${connectingMessage}` }],
541
+ details: {
542
+ mode: "search",
543
+ matches: [],
544
+ count: 0,
545
+ hasMore: false,
546
+ nextOffset: null,
547
+ query,
548
+ ...(connectingServers.length > 0 ? { connectingServers } : {}),
549
+ },
532
550
  };
533
551
  }
534
552
 
@@ -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/server-manager.ts CHANGED
@@ -1030,6 +1030,10 @@ export class McpServerManager {
1030
1030
  return false;
1031
1031
  }
1032
1032
 
1033
+ isConnecting(name: string): boolean {
1034
+ return this.connectPromises.has(name);
1035
+ }
1036
+
1033
1037
  getConnection(name: string): ServerConnection | undefined {
1034
1038
  return this.connections.get(name);
1035
1039
  }
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 {
@@ -179,10 +179,10 @@ function extractUiMeta(meta: Record<string, unknown> | undefined): UiResourceMet
179
179
 
180
180
  const ui = isRecord(meta.ui) ? meta.ui : undefined;
181
181
  const out: UiResourceMeta = {};
182
- const openAiCsp = hasOwnProperty(meta, "openai/widgetCSP")
182
+ const openAiCsp = Object.hasOwn(meta, "openai/widgetCSP")
183
183
  ? normalizeOpenAiWidgetCsp(meta["openai/widgetCSP"])
184
184
  : undefined;
185
- const hasStandardCsp = !!ui && hasOwnProperty(ui, "csp");
185
+ const hasStandardCsp = !!ui && Object.hasOwn(ui, "csp");
186
186
  const standardCspValue = hasStandardCsp ? ui.csp : undefined;
187
187
 
188
188
  if (hasStandardCsp && !isRecord(standardCspValue)) {
@@ -196,7 +196,7 @@ function extractUiMeta(meta: Record<string, unknown> | undefined): UiResourceMet
196
196
  out.csp = { ...openAiCsp, ...standardCsp };
197
197
  if (isRecord(standardCspValue)) {
198
198
  for (const [, standardField] of OPENAI_CSP_FIELD_MAPPINGS) {
199
- if (hasOwnProperty(standardCspValue, standardField) && !copyStringArray(standardCspValue[standardField])) {
199
+ if (Object.hasOwn(standardCspValue, standardField) && !copyStringArray(standardCspValue[standardField])) {
200
200
  delete out.csp[standardField];
201
201
  }
202
202
  }
@@ -245,10 +245,6 @@ function copyStringArray(value: unknown): string[] | undefined {
245
245
  : undefined;
246
246
  }
247
247
 
248
- function hasOwnProperty(record: Record<string, unknown>, property: string): boolean {
249
- return Object.prototype.hasOwnProperty.call(record, property);
250
- }
251
-
252
248
  function isRecord(value: unknown): value is Record<string, unknown> {
253
249
  return typeof value === "object" && value !== null && !Array.isArray(value);
254
250
  }
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
  }