pi-mcp-adapter 2.16.0 → 2.17.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 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.17.0] - 2026-07-31
11
+
12
+ ### Added
13
+ - Added `settings.mcpFooterStatus` to compact or hide the persistent MCP footer status. Thanks @jwintz for issue #5.
14
+ - Added per-server OAuth `authorizationParams` for provider-specific authorization URL parameters such as Google's `access_type=offline`, while rejecting OAuth flow-owned parameter overrides. Thanks @hank-warren for issue #238.
15
+
16
+ ### Fixed
17
+ - Added a best-effort absolute-path fallback for loading the `@napi-rs/keyring` native binding when compiled Pi/Bun cannot resolve the package loader. Thanks @sgiath for issue #230.
18
+ - Bound collapsed MCP tool result rendering by character count as well as line count, preventing huge single-line results from slowing long TUI sessions. Thanks @Whisperfall for issue #249.
19
+ - Let configured `oauth.scope` override OAuth discovery scopes during authorization flows. Thanks @viggy28 for issue #225 and @adity982 for PR #226.
20
+
10
21
  ## [2.16.0] - 2026-07-30
11
22
 
12
23
  ### Added
package/README.md CHANGED
@@ -258,6 +258,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
258
258
  "idleTimeout": 10,
259
259
  "requestTimeoutMs": 30000,
260
260
  "showStatusIcon": true,
261
+ "mcpFooterStatus": "full",
261
262
  "hostConfigDiscovery": "off",
262
263
  "oauthDir": ".pi/mcp-oauth",
263
264
  "trace": {
@@ -277,8 +278,10 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
277
278
  | `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
278
279
  | `requestTimeoutMs` | Global request timeout in milliseconds for live MCP calls (if omitted or `<= 0`, the MCP SDK default timeout is used) |
279
280
  | `showStatusIcon` | Show the plug icon in MCP status and connection text (default: `true`). Set to `false` for plain `MCP: ...` text. |
281
+ | `mcpFooterStatus` | MCP footer verbosity: `"full"` (default), `"compact"` for `MCP connected/enabled`, or `"off"` to clear the persistent footer status. `/mcp status` remains available. |
280
282
  | `hostConfigDiscovery` | Host-specific config policy: `"off"` (default), `"prompt"` (detect/report only), or `"on"` (explicitly load detected host configs as the lowest-precedence fallback) |
281
283
  | `oauthDir` | Legacy OAuth `tokens.json` import directory for this MCP config. Relative paths resolve from the active project cwd. `MCP_OAUTH_DIR` still wins when set. Persistent OAuth credentials are stored in the OS credential store, not this directory. |
284
+ | `mcpServers.<name>.oauth.authorizationParams` | Extra authorization URL parameters for provider-specific OAuth extensions. Flow-owned parameters such as `client_id`, `redirect_uri`, `scope`, `state`, `code_challenge`, `response_type`, and `resource` cannot be overridden. |
282
285
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
283
286
  | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
284
287
  | `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
package/init.ts CHANGED
@@ -261,7 +261,8 @@ export async function initializeMcp(
261
261
  });
262
262
 
263
263
  if (ui && startupServers.length > 0) {
264
- ui.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${startupServers.length} servers...`));
264
+ const status = formatMcpStatus(state.config, `connecting to ${startupServers.length} servers...`);
265
+ ui.setStatus("mcp", status);
265
266
  }
266
267
 
267
268
  const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
@@ -399,6 +400,9 @@ export async function initializeMcp(
399
400
 
400
401
  owner.throwIfInactive();
401
402
  lifecycle.startHealthChecks(runtimeSignal);
403
+ if (config.settings?.mcpFooterStatus === "off") {
404
+ ui?.setStatus("mcp", undefined);
405
+ }
402
406
  publishMcpStatusSnapshot(state);
403
407
 
404
408
  return state;
@@ -524,10 +528,24 @@ export function updateStatusBar(state: McpExtensionState): void {
524
528
  const definition = state.config.mcpServers[name];
525
529
  return connection.status === "connected" && definition !== undefined && !isServerDisabled(definition);
526
530
  }).length;
527
- let status = `${enabledCount} ${enabledCount === 1 ? "server" : "servers"} enabled`;
528
- if (connectedCount > 0) status += ` (${connectedCount} connected)`;
529
- if (disabledCount > 0) status += ` (${disabledCount} disabled)`;
530
- const formattedStatus = formatMcpStatus(state.config, status);
531
+ const footerStatus = state.config.settings?.mcpFooterStatus ?? "full";
532
+ if (footerStatus === "off") {
533
+ ui.setStatus("mcp", undefined);
534
+ return;
535
+ }
536
+
537
+ let status = footerStatus === "compact"
538
+ ? `MCP ${connectedCount}/${enabledCount}`
539
+ : `${enabledCount} ${enabledCount === 1 ? "server" : "servers"} enabled`;
540
+ if (footerStatus === "full") {
541
+ if (connectedCount > 0) status += ` (${connectedCount} connected)`;
542
+ if (disabledCount > 0) status += ` (${disabledCount} disabled)`;
543
+ }
544
+ const formattedStatus = footerStatus === "compact" ? status : formatMcpStatus(state.config, status);
545
+ if (formattedStatus === undefined) {
546
+ ui.setStatus("mcp", undefined);
547
+ return;
548
+ }
531
549
  ui.setStatus("mcp", ui.theme ? ui.theme.fg("accent", formattedStatus) : formattedStatus);
532
550
  }
533
551
 
@@ -565,7 +583,8 @@ export async function lazyConnect(state: McpExtensionState, serverName: string,
565
583
 
566
584
  try {
567
585
  if (state.ui) {
568
- state.ui.setStatus("mcp", formatMcpStatus(state.config, `connecting to ${serverName}...`));
586
+ const status = formatMcpStatus(state.config, `connecting to ${serverName}...`);
587
+ state.ui.setStatus("mcp", status);
569
588
  }
570
589
  const newConnection = await state.manager.connect(serverName, definition, ownedSignal);
571
590
  if (newConnection.status === "needs-auth") {
package/mcp-auth-flow.ts CHANGED
@@ -57,6 +57,10 @@ type AuthDiscovery = {
57
57
  scope?: string
58
58
  }
59
59
 
60
+ function applyConfiguredScope(discovery: AuthDiscovery, config: McpOAuthConfig): AuthDiscovery {
61
+ return config.scope !== undefined ? { ...discovery, scope: config.scope } : discovery
62
+ }
63
+
60
64
  type PendingAuth = {
61
65
  serverName: string
62
66
  authProvider: McpOAuthProvider
@@ -162,6 +166,18 @@ export function extractOAuthConfig(definition: ServerEntry): McpOAuthConfig {
162
166
  if (typeof definition.oauth.scope !== "string") throw new Error("OAuth scope must be a string")
163
167
  config.scope = interpolateEnvVars(definition.oauth.scope)
164
168
  }
169
+ if (definition.oauth?.authorizationParams !== undefined) {
170
+ const params = definition.oauth.authorizationParams
171
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
172
+ throw new Error("OAuth authorizationParams must be an object")
173
+ }
174
+ config.authorizationParams = {}
175
+ for (const [key, value] of Object.entries(params)) {
176
+ if (!key) throw new Error("OAuth authorizationParams keys must not be empty")
177
+ if (typeof value !== "string") throw new Error(`OAuth authorizationParams.${key} must be a string`)
178
+ config.authorizationParams[key] = interpolateEnvVars(value)
179
+ }
180
+ }
165
181
  if (definition.oauth?.redirectUri !== undefined) {
166
182
  if (typeof definition.oauth.redirectUri !== "string") {
167
183
  throw new Error("OAuth redirectUri must be a string")
@@ -304,7 +320,7 @@ export async function startAuth(
304
320
  },
305
321
  }, authStorageOptions, runtime.signal)
306
322
  try {
307
- const discovery = await probeAuthDiscovery(serverUrl, definition, signal)
323
+ const discovery = applyConfiguredScope(await probeAuthDiscovery(serverUrl, definition, signal), config)
308
324
  throwIfAborted(signal)
309
325
  const result = await abortable(runSdkAuth(authProvider, { serverUrl, ...discovery }), signal)
310
326
  throwIfAborted(signal)
@@ -370,7 +386,7 @@ export async function startAuth(
370
386
 
371
387
  throwIfAborted(signal)
372
388
 
373
- const discovery = await probeAuthDiscovery(serverUrl, definition, signal)
389
+ const discovery = applyConfiguredScope(await probeAuthDiscovery(serverUrl, definition, signal), config)
374
390
  throwIfAborted(signal)
375
391
  const result = await abortable(runSdkAuth(authProvider, { serverUrl, ...discovery }), signal)
376
392
  throwIfAborted(signal)
package/mcp-auth.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import { createHash } from 'crypto';
14
14
  import { createRequire } from 'module';
15
15
  import { readFileSync, existsSync, rmSync } from 'fs';
16
- import { join } from 'path';
16
+ import { dirname, join } from 'path';
17
17
  import { getAgentPath } from './agent-dir.ts';
18
18
  import { resolveConfiguredOAuthDir } from './config.ts';
19
19
 
@@ -113,6 +113,8 @@ interface KeyringEntry {
113
113
  }
114
114
 
115
115
  type KeyringEntryConstructor = new (service: string, account: string) => KeyringEntry;
116
+ type KeyringModule = { Entry: KeyringEntryConstructor };
117
+ type KeyringRequire = ((id: string) => unknown) & { resolve(id: string): string };
116
118
 
117
119
  interface AuthSecretStore {
118
120
  read(account: string): string | undefined;
@@ -185,13 +187,81 @@ function getAuthSecretStore(): AuthSecretStore {
185
187
 
186
188
  function getKeyringEntry(account: string): KeyringEntry {
187
189
  try {
188
- KeyringEntryClass ??= (require('@napi-rs/keyring') as { Entry: KeyringEntryConstructor }).Entry;
190
+ KeyringEntryClass ??= loadKeyringEntryClass();
189
191
  return new KeyringEntryClass(AUTH_SECRET_SERVICE, account);
190
192
  } catch (error) {
191
193
  throw new Error('OAuth secure credential storage is unavailable. Configure the OS credential store and retry authentication.', { cause: error });
192
194
  }
193
195
  }
194
196
 
197
+ function loadKeyringEntryClass(keyringRequire: KeyringRequire = require, platform: NodeJS.Platform = process.platform, arch: NodeJS.Architecture = process.arch): KeyringEntryConstructor {
198
+ try {
199
+ return (keyringRequire('@napi-rs/keyring') as KeyringModule).Entry;
200
+ } catch (loaderError) {
201
+ try {
202
+ return loadKeyringNativeBindingFallback(keyringRequire, platform, arch).Entry;
203
+ } catch (fallbackError) {
204
+ throw new Error(`Failed to load @napi-rs/keyring; absolute-path native binding fallback also failed: ${formatErrorMessage(fallbackError)}`, {
205
+ cause: loaderError,
206
+ });
207
+ }
208
+ }
209
+ }
210
+
211
+ function loadKeyringNativeBindingFallback(keyringRequire: KeyringRequire, platform: NodeJS.Platform, arch: NodeJS.Architecture): KeyringModule {
212
+ const targets = getKeyringNativeBindingTargets(platform, arch);
213
+ if (targets.length === 0) {
214
+ throw new Error(`Unsupported @napi-rs/keyring native binding target: ${platform}-${arch}`);
215
+ }
216
+
217
+ let lastError: unknown;
218
+ for (const target of targets) {
219
+ try {
220
+ const packageJsonPath = keyringRequire.resolve(`${target.packageName}/package.json`);
221
+ return keyringRequire(join(dirname(packageJsonPath), target.bindingFile)) as KeyringModule;
222
+ } catch (error) {
223
+ lastError = error;
224
+ }
225
+ }
226
+
227
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
228
+ }
229
+
230
+ function getKeyringNativeBindingTargets(platform: NodeJS.Platform, arch: NodeJS.Architecture): { packageName: string; bindingFile: string }[] {
231
+ return getKeyringNativeBindingSuffixes(platform, arch).map(suffix => ({
232
+ packageName: `@napi-rs/keyring-${suffix}`,
233
+ bindingFile: `keyring.${suffix}.node`,
234
+ }));
235
+ }
236
+
237
+ function getKeyringNativeBindingSuffixes(platform: NodeJS.Platform, arch: NodeJS.Architecture): string[] {
238
+ if (platform === 'darwin') {
239
+ if (arch === 'arm64') return ['darwin-arm64'];
240
+ if (arch === 'x64') return ['darwin-x64'];
241
+ }
242
+ if (platform === 'win32') {
243
+ if (arch === 'arm64') return ['win32-arm64-msvc'];
244
+ if (arch === 'x64') return ['win32-x64-msvc'];
245
+ if (arch === 'ia32') return ['win32-ia32-msvc'];
246
+ }
247
+ if (platform === 'linux') {
248
+ if (arch === 'arm64') return ['linux-arm64-gnu', 'linux-arm64-musl'];
249
+ if (arch === 'arm') return ['linux-arm-gnueabihf'];
250
+ if (arch === 'riscv64') return ['linux-riscv64-gnu'];
251
+ if (arch === 'x64') return ['linux-x64-gnu', 'linux-x64-musl'];
252
+ }
253
+ if (platform === 'freebsd' && arch === 'x64') return ['freebsd-x64'];
254
+ return [];
255
+ }
256
+
257
+ function formatErrorMessage(error: unknown): string {
258
+ return error instanceof Error ? error.message : String(error);
259
+ }
260
+
261
+ export function loadTestKeyringEntryClass(keyringRequire: KeyringRequire, platform: NodeJS.Platform, arch: NodeJS.Architecture): KeyringEntryConstructor {
262
+ return loadKeyringEntryClass(keyringRequire, platform, arch);
263
+ }
264
+
195
265
  export function getAuthStorageOptions(oauthDir: unknown, cwd = process.cwd()): AuthStorageOptions {
196
266
  const baseDir = resolveConfiguredOAuthDir(oauthDir, cwd);
197
267
  return baseDir ? { baseDir } : {};
@@ -82,11 +82,35 @@ export interface McpOAuthConfig {
82
82
  clientId?: string
83
83
  clientSecret?: string
84
84
  scope?: string
85
+ authorizationParams?: Record<string, string>
85
86
  redirectUri?: string
86
87
  clientName?: string
87
88
  clientUri?: string
88
89
  }
89
90
 
91
+ const reservedAuthorizationParams = new Set([
92
+ "client_id",
93
+ "code_challenge",
94
+ "code_challenge_method",
95
+ "redirect_uri",
96
+ "resource",
97
+ "response_type",
98
+ "scope",
99
+ "state",
100
+ ])
101
+
102
+ function addAuthorizationParams(authorizationUrl: URL, params: Record<string, string> | undefined): URL {
103
+ if (!params) return authorizationUrl
104
+ const nextUrl = new URL(authorizationUrl.toString())
105
+ for (const [key, value] of Object.entries(params)) {
106
+ if (reservedAuthorizationParams.has(key) || nextUrl.searchParams.has(key)) {
107
+ throw new Error(`OAuth authorizationParams.${key} cannot override an authorization flow parameter`)
108
+ }
109
+ nextUrl.searchParams.set(key, value)
110
+ }
111
+ return nextUrl
112
+ }
113
+
90
114
  /** Callbacks for OAuth flow interactions */
91
115
  export interface McpOAuthCallbacks {
92
116
  onRedirect: (url: URL) => void | Promise<void>
@@ -386,7 +410,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
386
410
  )
387
411
  }
388
412
  // URL is passed to callback, not logged (may contain sensitive params)
389
- await this.callbacks.onRedirect(authorizationUrl)
413
+ await this.callbacks.onRedirect(addAuthorizationParams(authorizationUrl, this.config.authorizationParams))
390
414
  }
391
415
 
392
416
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -34,6 +34,7 @@ export interface McpToolResultDisplay {
34
34
 
35
35
  const DEFAULT_MAX_CALL_INPUT_CHARS = 1500;
36
36
  const DEFAULT_MAX_COLLAPSED_LINES = 3;
37
+ const DEFAULT_MAX_COLLAPSED_CHARS = 8000;
37
38
  const COLLAPSED_RENDER_CHAR_SLACK = 8;
38
39
 
39
40
  class CollapsibleText implements Component {
@@ -47,6 +48,7 @@ class CollapsibleText implements Component {
47
48
  private readonly maxCollapsedLines: number,
48
49
  private readonly ellipsis: string,
49
50
  private readonly expandHint: string,
51
+ private readonly preTruncated = false,
50
52
  ) {
51
53
  this.fullText = new Text(text, 0, 0);
52
54
  this.footerText = new Text(`${ellipsis}\n${expandHint}`, 0, 0);
@@ -71,7 +73,7 @@ class CollapsibleText implements Component {
71
73
  }
72
74
 
73
75
  const lines = this.collapsedText.text.render(width);
74
- if (this.collapsedText.fullyIncluded && lines.length <= this.maxCollapsedLines) return lines;
76
+ if (!this.preTruncated && this.collapsedText.fullyIncluded && lines.length <= this.maxCollapsedLines) return lines;
75
77
 
76
78
  return [
77
79
  ...lines.slice(0, this.maxCollapsedLines),
@@ -171,6 +173,58 @@ function blockToLines(block: McpToolContentBlock): string[] {
171
173
  return [`[image: ${block.mimeType}]`];
172
174
  }
173
175
 
176
+ function collectCollapsedResultLines(
177
+ content: AgentToolResult<McpToolResultDetails>["content"],
178
+ maxLines: number,
179
+ maxChars: number,
180
+ ): McpToolResultDisplay {
181
+ if (content.length === 0) return { lines: ["(empty result)"], truncated: false };
182
+
183
+ const lines: string[] = [];
184
+ let remainingChars = maxChars;
185
+ let truncated = false;
186
+
187
+ const appendLine = (line: string) => {
188
+ if (lines.length >= maxLines || remainingChars <= 0) {
189
+ truncated = true;
190
+ return false;
191
+ }
192
+
193
+ if (line.length > remainingChars) {
194
+ lines.push(line.slice(0, remainingChars));
195
+ truncated = true;
196
+ remainingChars = 0;
197
+ return false;
198
+ }
199
+
200
+ lines.push(line);
201
+ remainingChars -= line.length + 1;
202
+ return true;
203
+ };
204
+
205
+ for (const block of content) {
206
+ if (block.type !== "text") {
207
+ if (!appendLine(`[image: ${block.mimeType}]`)) break;
208
+ continue;
209
+ }
210
+
211
+ let start = 0;
212
+ while (start <= block.text.length) {
213
+ const newline = block.text.indexOf("\n", start);
214
+ const line = newline === -1 ? block.text.slice(start) : block.text.slice(start, newline);
215
+ if (!appendLine(line)) break;
216
+ if (newline === -1) break;
217
+ start = newline + 1;
218
+ }
219
+
220
+ if (truncated) break;
221
+ }
222
+
223
+ if (lines.length === 0) lines.push("");
224
+ if (truncated && lines.length >= maxLines) lines.push("…");
225
+ return { lines, truncated };
226
+ }
227
+
174
228
  export function formatMcpToolResultIdentity(details: McpToolResultDetails | undefined): string | null {
175
229
  if (details?.mode !== "call") return null;
176
230
  const server = typeof details.server === "string"
@@ -189,18 +243,15 @@ export function formatMcpToolResultLines(
189
243
  result: Pick<AgentToolResult<McpToolResultDetails>, "content">,
190
244
  expanded: boolean,
191
245
  maxCollapsedLines = 3,
246
+ maxCollapsedChars = DEFAULT_MAX_COLLAPSED_CHARS,
192
247
  ): McpToolResultDisplay {
193
- const allLines = result.content.flatMap(blockToLines);
194
- const lines = allLines.length > 0 ? allLines : ["(empty result)"];
195
-
196
- if (expanded || lines.length <= maxCollapsedLines) {
197
- return { lines, truncated: false };
248
+ if (!expanded) {
249
+ return collectCollapsedResultLines(result.content, maxCollapsedLines, maxCollapsedChars);
198
250
  }
199
251
 
200
- return {
201
- lines: [...lines.slice(0, maxCollapsedLines), "…"],
202
- truncated: true,
203
- };
252
+ const allLines = result.content.flatMap(blockToLines);
253
+ const lines = allLines.length > 0 ? allLines : ["(empty result)"];
254
+ return { lines, truncated: false };
204
255
  }
205
256
 
206
257
  export function renderMcpToolResult(
@@ -229,5 +280,6 @@ export function renderMcpToolResult(
229
280
  DEFAULT_MAX_COLLAPSED_LINES + (identity ? 1 : 0),
230
281
  activeTheme.fg("muted", "…"),
231
282
  activeTheme.fg("muted", "(Ctrl+O to expand)"),
283
+ display.truncated,
232
284
  );
233
285
  }
package/types.ts CHANGED
@@ -307,6 +307,8 @@ export interface OAuthConfig {
307
307
  clientSecret?: string;
308
308
  /** Requested OAuth scopes */
309
309
  scope?: string;
310
+ /** Extra authorization URL parameters for provider-specific extensions. Flow-owned parameters cannot be overridden. */
311
+ authorizationParams?: Record<string, string>;
310
312
  /** Exact authorization-code redirect URI for pre-registered clients */
311
313
  redirectUri?: string;
312
314
  /** Client display name for dynamic registration */
@@ -380,6 +382,7 @@ export interface McpOutputGuardSettings {
380
382
  // Settings
381
383
  export type ToolPrefix = "server" | "none" | "short" | "mcp";
382
384
  export type HostConfigDiscovery = "off" | "prompt" | "on";
385
+ export type McpFooterStatus = "full" | "compact" | "off";
383
386
 
384
387
  export interface McpTraceSettings {
385
388
  /** Enable tracing for all servers unless a server sets trace to false. */
@@ -396,6 +399,8 @@ export interface McpSettings {
396
399
  toolPrefix?: ToolPrefix;
397
400
  /** Show the plug prefix in MCP status and connection text (default: true). Set to false to disable it. */
398
401
  showStatusIcon?: boolean;
402
+ /** Footer status verbosity: full details, compact connected/enabled count, or no footer status. Defaults to full. */
403
+ mcpFooterStatus?: McpFooterStatus;
399
404
  /** Discover detected host-specific MCP configs only when explicitly enabled. */
400
405
  hostConfigDiscovery?: HostConfigDiscovery;
401
406
  idleTimeout?: number; // minutes, default 10, 0 to disable
package/utils.ts CHANGED
@@ -277,7 +277,8 @@ export function formatAuthRequiredMessage(
277
277
  return template ? template.replaceAll("${server}", serverName) : defaultMessage;
278
278
  }
279
279
 
280
- export function formatMcpStatus(config: Pick<McpConfig, "settings">, message: string): string {
280
+ export function formatMcpStatus(config: Pick<McpConfig, "settings">, message: string): string | undefined {
281
+ if (config.settings?.mcpFooterStatus === "off") return undefined;
281
282
  return `${config.settings?.showStatusIcon === false ? "MCP: " : "🔌 MCP: "}${message}`;
282
283
  }
283
284