pi-mcp-adapter 2.37.0 → 2.38.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,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.38.0] - 2026-09-26
11
+
12
+ ### Highlights
13
+
14
+ - Search-mode tools become full direct tools after a successful proxy call, without requiring a separate search first.
15
+ - Runtime-registered keep-alive servers now publish their tools even when Pi starts with no enabled MCP servers.
16
+ - Compact `mcpScript` results show which tools ran, how often they ran, and how many calls failed.
17
+ - Stdio configurations support home-relative paths, and MCP UI windows can open in Orca.
18
+ - OpenCode v2 imports, OAuth credential access, and Rust MCP schemas are more reliable.
19
+
20
+ ### Added
21
+
22
+ - A successful `mcp({ tool })` call now activates a held `directTools: "search"` tool, so later calls use its full schema even if the model skipped search. Thanks to [@chiptoe-svg](https://github.com/chiptoe-svg) for [PR #670](https://github.com/nicobailon/pi-mcp-adapter/pull/670).
23
+ - MCP stdio server commands, arguments, and working directories now support home-relative paths. Thanks to [@FRFlo](https://github.com/FRFlo) for [PR #655](https://github.com/nicobailon/pi-mcp-adapter/pull/655).
24
+ - Set `MCP_UI_VIEWER=orca` to open MCP UI windows in Orca. Thanks to [@jaesimio](https://github.com/jaesimio) for [PR #654](https://github.com/nicobailon/pi-mcp-adapter/pull/654).
25
+
26
+ ### Fixed
27
+
28
+ - Keep-alive servers registered at runtime now connect and publish their tools even when no configured servers are enabled at startup. Thanks to [@ahodges22](https://github.com/ahodges22) for [issue #671](https://github.com/nicobailon/pi-mcp-adapter/issues/671).
29
+ - Valid `ancestorConfigRoots` entries that do not contain the current working directory are ignored without warnings. Invalid entries still warn. Thanks to [@TheEdgeOfRage](https://github.com/TheEdgeOfRage) for [issue #668](https://github.com/nicobailon/pi-mcp-adapter/issues/668) and [PR #669](https://github.com/nicobailon/pi-mcp-adapter/pull/669).
30
+ - Collapsed `mcpScript` results now show the tools called, repeat counts, and a visible failure count instead of only the first output line. Unsafe or ambiguous tool names are quoted and escaped, and script code remains hidden. Thanks to [@sargismarkosyan](https://github.com/sargismarkosyan) for [PR #666](https://github.com/nicobailon/pi-mcp-adapter/pull/666).
31
+ - Metadata refreshes no longer reactivate an `mcp` gateway tool removed by the host. On hosts without `unregisterTool`, the adapter can still hide the gateway when direct tools cover the server and restore it when needed. Thanks to [@xulongwu4](https://github.com/xulongwu4) for [PR #665](https://github.com/nicobailon/pi-mcp-adapter/pull/665).
32
+ - `mcpScript` no longer asks models to load its intentionally hidden manual skill. Thanks to [@k03mad](https://github.com/k03mad) for [#659](https://github.com/nicobailon/pi-mcp-adapter/issues/659).
33
+ - OAuth credential reads now reuse a healthy keyring Entry without retaining secret values, avoiding repeated native sessions while still observing external updates. Thanks to [@mmarabel](https://github.com/mmarabel) for [#657](https://github.com/nicobailon/pi-mcp-adapter/issues/657).
34
+ - Suppressing MCP UI windows with `MCP_UI_VIEWER=none` / `off` / `disabled` no longer prints raw output into the TUI. Thanks to [@andreafspeziale](https://github.com/andreafspeziale) for [#656](https://github.com/nicobailon/pi-mcp-adapter/issues/656).
35
+ - The published package now includes the OAuth guide linked from the README. Thanks to [@dajiaohuang](https://github.com/dajiaohuang) for [PR #653](https://github.com/nicobailon/pi-mcp-adapter/pull/653).
36
+ - OpenCode v2 configs now import. Servers under `mcp.servers` are picked up, `disabled: true` servers are skipped, and the snake_case OAuth fields `client_id`, `client_secret`, and `auth_server_metadata_url` are mapped. OpenCode v1 configs keep working. Thanks to [@sleroq](https://github.com/sleroq) for [PR #650](https://github.com/nicobailon/pi-mcp-adapter/pull/650).
37
+ - Tools from Rust MCP servers, such as DBX, no longer print Ajv `unknown format "uint64" ignored` warnings on every call. Number formats like `uint64`, `uint32`, `uint`, and `uint8` are now recognized, and `type`/`minimum` still validate the values. Thanks to [@nightlitten](https://github.com/nightlitten) for [#649](https://github.com/nicobailon/pi-mcp-adapter/issues/649).
38
+
10
39
  ## [2.37.0] - 2026-09-23
11
40
 
12
41
  ### Highlights
package/OAUTH.md ADDED
@@ -0,0 +1,369 @@
1
+ # OAuth 2.1 Authentication for MCP
2
+
3
+ This document describes the OAuth 2.1 + PKCE authentication implementation for the Pi MCP Adapter using the official MCP SDK.
4
+
5
+ ## Overview
6
+
7
+ The Pi MCP Adapter uses the official MCP SDK's built-in OAuth implementation, which provides:
8
+
9
+ - **Automatic OAuth endpoint discovery** (RFC 9728) - No manual configuration needed
10
+ - **Dynamic Client Registration** (RFC 7591) - The default for URL-only servers when no pre-registered `clientId` is configured and the server supports registration
11
+ - **Client ID Metadata Documents** (SEP-991) - An advanced opt-in that uses an operator-supplied public HTTPS metadata URL as `client_id` when the authorization server opts in
12
+ - **Automatic callback handling** - Built-in HTTP server handles callbacks automatically
13
+ - **Automatic token refresh** - SDK handles token refresh transparently
14
+
15
+ ## Features
16
+
17
+ - **PKCE (S256)** - Mandatory code challenge method for OAuth 2.1
18
+ - **Automatic Callback Server** - Local browser redirects automatically when available
19
+ - **Manual Remote Flow** - Copy auth URLs and pasted redirect URLs/codes for headless SSH sessions
20
+ - **Dynamic Client Registration** - Registers by default when no pre-registered client is configured and the server supports registration
21
+ - **Client ID Metadata Documents** - Opts into a configured, operator-hosted URL-based client ID when the authorization server advertises support
22
+ - **Auto-Discovery** - Discovers OAuth endpoints from server metadata
23
+ - **Automatic Token Refresh** - SDK handles expired tokens automatically
24
+ - **State Parameter Validation** - CSRF protection
25
+ - **Secure Token Storage** - Persistent OAuth entries are stored in the operating system credential store
26
+
27
+ ## Configuration
28
+
29
+ ### Minimal Configuration (Recommended)
30
+
31
+ For most MCP servers, you only need the URL:
32
+
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "my-oauth-server": {
37
+ "url": "https://api.example.com/mcp"
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ OAuth is automatically enabled for HTTP servers. The SDK will:
44
+ - Auto-detect if the server requires OAuth
45
+ - Discover OAuth endpoints from the server
46
+ - Use Dynamic Client Registration when no pre-registered client is configured and the server supports it
47
+ - Handle the entire OAuth flow including callback
48
+
49
+ Pi does not configure or host a Client ID Metadata Document by default. URL-only configurations continue to use Dynamic Client Registration. CIMD is available only through the advanced, operator-supplied `oauth.clientMetadataUrl` option below.
50
+
51
+ ### Optional Configuration
52
+
53
+ You can optionally provide a pre-registered client:
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "my-oauth-server": {
59
+ "url": "https://api.example.com/mcp",
60
+ "auth": "oauth",
61
+ "oauth": {
62
+ "clientId": "your-client-id",
63
+ "clientSecret": "your-client-secret",
64
+ "scope": "read write",
65
+ "authorizationParams": { "access_type": "offline", "prompt": "consent" },
66
+ "redirectUri": "http://localhost:3118/callback"
67
+ }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+
74
+
75
+ ### Configuration Options
76
+
77
+ - `url` - The MCP server URL (required)
78
+ - `auth` - Set to `"oauth"` to force OAuth, `false` to disable, or omit to auto-detect
79
+ - `oauth.grantType` - `"authorization_code"` (default, browser flow) or `"client_credentials"` (non-interactive)
80
+ - `oauth.clientId` - Pre-registered client ID. Takes precedence over `oauth.clientMetadataUrl` when both are configured.
81
+ - `oauth.clientSecret` - Client secret for confidential clients (optional). When `oauth.clientMetadataUrl` is also set, an explicit `oauth.clientId` is required; the explicit client takes precedence.
82
+ - `oauth.clientMetadataUrl` - Advanced opt-in for an operator-supplied public HTTPS Client ID Metadata Document URL with a non-root path. The URL is used as `client_id` when the authorization server advertises `client_id_metadata_document_supported: true`; otherwise Dynamic Client Registration remains the fallback. The document must be publicly fetchable and match this client's metadata, especially `redirect_uris`. The adapter does not provide a default URL or host this document.
83
+ - `oauth.scope` - Requested OAuth scopes (optional)
84
+ - `oauth.authorizationParams` - Extra authorization URL parameters for provider-specific extensions, such as Google's `{ "access_type": "offline", "prompt": "consent" }`. Flow-owned parameters like `client_id`, `redirect_uri`, `scope`, `state`, `code_challenge`, `response_type`, and `resource` cannot be overridden.
85
+ - `oauth.redirectUri` - Browser callback URI to advertise and bind, such as `http://localhost:3118/callback`. Use `{port}` in a loopback URI when the provider permits an OS-assigned RFC 8252 port, for example `http://127.0.0.1:{port}/callback` (optional)
86
+ - `oauth.clientName` - Client display name used for Dynamic Client Registration fallback (optional, defaults to `Pi Coding Agent`)
87
+ - `oauth.clientUri` - Client homepage URI used for Dynamic Client Registration fallback (optional)
88
+ - `oauth.authServerMetadataUrl` - HTTPS OAuth/OIDC authorization-server metadata document to use authoritatively when MCP protected-resource discovery is unavailable (optional; issuer validation remains enabled)
89
+ - `oauth.skipIssuerMetadataValidation` - Set `true` only for a known-misconfigured authorization server whose metadata issuer cannot be fixed immediately. This weakens OAuth issuer validation.
90
+
91
+ Dynamic fallback clients normally omit `oauth.redirectUri`; the adapter starts the callback server lazily on the default loopback host (`localhost`) and asks the OS for an available local port when auth begins. Use `oauth.redirectUri` when the provider requires a pre-registered callback, such as Slack MCP's Claude-compatible `http://localhost:3118/callback`. A loopback URI must use `http://` with `localhost`, `127.0.0.1`, or `[::1]`. It may contain an explicit port, which is bound exactly, or `{port}`, which is replaced with the OS-assigned port in the authorization and token requests.
92
+
93
+ ### Non-Interactive `client_credentials`
94
+
95
+ For machine-to-machine OAuth, configure `grantType: "client_credentials"`.
96
+
97
+ ```json
98
+ {
99
+ "mcpServers": {
100
+ "my-service": {
101
+ "url": "https://api.example.com/mcp",
102
+ "auth": "oauth",
103
+ "oauth": {
104
+ "grantType": "client_credentials",
105
+ "clientId": "service-client-id",
106
+ "clientSecret": "service-client-secret",
107
+ "scope": "read write"
108
+ }
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ This flow does not open a browser or use callback handling. `oauth.redirectUri` is ignored for `client_credentials`; `oauth.clientName` and `oauth.clientUri` still apply to dynamic client registration metadata.
115
+
116
+ ## Usage
117
+
118
+ ### Step 1: Authenticate
119
+
120
+ Run the `/mcp-auth` command with the server name:
121
+
122
+ ```
123
+ /mcp-auth my-oauth-server
124
+ ```
125
+
126
+ Manual `/mcp-auth` is the default flow. If you set `settings.autoAuth: true`, proxy/direct tool execution will trigger OAuth automatically when a server returns `needs-auth`, then retry the original operation once.
127
+
128
+ This will:
129
+ 1. Start the callback server lazily on an OS-assigned local port, on an OS-assigned host-specific `{port}` callback, or on the exact `oauth.redirectUri` port for fixed callbacks
130
+ 2. Discover OAuth endpoints automatically
131
+ 3. Use Dynamic Client Registration by default, or the explicitly configured Client ID Metadata Document when supported
132
+ 4. Open your browser for authentication
133
+ 5. Wait for the automatic callback
134
+ 6. Complete the OAuth flow
135
+ 7. Store tokens securely
136
+
137
+ ### Remote/headless authentication
138
+
139
+ When Pi runs over SSH or in a headless environment, use the proxy tool to retrieve the authorization URL instead of relying on OS browser launch:
140
+
141
+ ```
142
+ mcp({ action: "auth-start", server: "my-oauth-server" })
143
+ ```
144
+
145
+ Open the returned URL in your local browser. After approval, copy the full redirected localhost URL from the browser address bar (the page may fail to load locally) and complete the same pending auth flow:
146
+
147
+ ```
148
+ mcp({
149
+ action: "auth-complete",
150
+ server: "my-oauth-server",
151
+ args: { redirectUrl: "http://localhost:19876/callback?code=...&state=..." }
152
+ })
153
+ ```
154
+
155
+ You can also pass only the `code` query parameter with `args: { code: "..." }`. JSON-string args remain supported. Redirect URL completion validates the saved OAuth state; raw code completion is available for providers that display a code directly.
156
+
157
+ ### Step 2: Use the Server
158
+
159
+ Once authenticated, use the server normally:
160
+
161
+ ```
162
+ mcp({ server: "my-oauth-server" })
163
+ mcp({ tool: "my-tool", args: { key: "value" } })
164
+ ```
165
+
166
+ The SDK automatically:
167
+ - Adds the access token to requests
168
+ - Refreshes expired tokens automatically
169
+ - Re-authenticates if tokens are invalid
170
+
171
+ To clear stored OAuth credentials and force a fresh authorization:
172
+
173
+ ```
174
+ /mcp logout my-oauth-server
175
+ ```
176
+
177
+ Within one Pi process, logout is a linearizable boundary: OAuth work that began
178
+ before logout cannot recreate credentials after they are cleared, and new OAuth
179
+ work is admitted after logout finishes. Explicit token updates made through the
180
+ public API retain last-writer semantics. Separate Pi processes are intentionally
181
+ not coordinated, so concurrent cross-process credential operations can still race.
182
+
183
+ ## How It Works
184
+
185
+ ### Authentication Flow
186
+
187
+ ```
188
+ ┌─────────┐ ┌──────────────┐ ┌─────────────────┐
189
+ │ Pi │────▶│ MCP Server │────▶│ OAuth Server │
190
+ │ │ │ │ │ │
191
+ │ 1. Init │ │ 2. Discovery │ │ 3. Register │
192
+ │ │ │ │ │ │
193
+ │ │◀────│ │◀────│ 4. Auth URL │
194
+ │ │ │ │ │ │
195
+ │ │────▶│ Callback │◀────│ 5. Browser │
196
+ │ │ │ Server │ │ Redirect │
197
+ │ │ │ │ │ │
198
+ │ │◀────│ │◀────│ 6. Code │
199
+ │ │ │ │ │ │
200
+ │ │────▶│ │────▶│ 7. Exchange │
201
+ │ │ │ │ │ │
202
+ │ │◀────│ │◀────│ 8. Tokens │
203
+ └─────────┘ └──────────────┘ └─────────────────┘
204
+ ```
205
+
206
+ ### Auto-Discovery
207
+
208
+ The SDK attempts to discover OAuth endpoints using:
209
+
210
+ 1. **RFC 9728 Metadata** - Fetches `/.well-known/oauth-protected-resource`
211
+ 2. **WWW-Authenticate Header** - Parses `resource_metadata` from 401 responses
212
+
213
+ ### Client ID Metadata Documents and Dynamic Registration
214
+
215
+ CIMD is an advanced, explicit opt-in. Set `oauth.clientMetadataUrl` to a stable, operator-supplied public HTTPS URL that serves this client's OAuth metadata. When authorization-server discovery advertises `client_id_metadata_document_supported: true`, the adapter and SDK use that URL directly as `client_id` and skip Dynamic Client Registration. The URL must have a non-root path. An explicit `oauth.clientId` takes precedence. Combining `oauth.clientMetadataUrl` with `oauth.clientSecret` without an explicit `oauth.clientId` is rejected.
216
+
217
+ The adapter does not provide a default URL or host the public document: operators must publish it and keep fields such as `redirect_uris`, `grant_types`, `response_types`, and `token_endpoint_auth_method` consistent with the configured flow. URL-only/default Pi configurations therefore continue to use Dynamic Client Registration. When the server does not advertise CIMD support, or no metadata URL is configured, the SDK uses Dynamic Client Registration if the server supports it:
218
+
219
+ 1. Discovers the registration endpoint from OAuth metadata
220
+ 2. Registers a new client with:
221
+ - `client_name`: configured `oauth.clientName` or "Pi Coding Agent"
222
+ - `client_uri`: configured `oauth.clientUri` or the adapter repository URL
223
+ - `redirect_uris`: `["http://localhost:<active-callback-port>/callback"]`, or the configured `oauth.redirectUri`
224
+ - `grant_types`: `["authorization_code", "refresh_token"]`
225
+ 3. Stores the registered client credentials and the redirect URIs returned by the authorization server
226
+
227
+ When a fresh browser auth starts, cached dynamic fallback client info with tokens is re-registered if its stored redirect URIs are missing or do not include the current redirect URI. Token refresh does not perform this redirect check, so existing refresh-token grants keep working even after a callback setting changes.
228
+
229
+ ### Callback Server
230
+
231
+ A Node.js HTTP server runs on a loopback callback endpoint and handles the active callback path:
232
+
233
+ - Dynamic registration starts the callback server only when auth begins, binds the default host `localhost`, and asks the OS for an available local port
234
+ - Pre-registered clients (`oauth.clientId`) without `oauth.redirectUri` require the exact configured callback port from `MCP_OAUTH_CALLBACK_PORT` or the default `19876` on `localhost`
235
+ - `oauth.redirectUri` binds the exact loopback host and path. An explicit port is bound exactly; `{port}` asks the OS for a port and is replaced with that assigned value before the URI is advertised to the provider
236
+
237
+ - Handles `code`, `state`, and `error` parameters
238
+ - Displays success/error HTML pages
239
+ - Validates state parameter for CSRF protection
240
+ - Has a 5-minute timeout for pending authorizations
241
+
242
+ ## Token Storage
243
+
244
+ Persistent OAuth entries are stored per configured server name in the operating system credential store, using macOS Keychain, Windows Credential Manager, or Linux Secret Service/libsecret through `@napi-rs/keyring`. The stored entry contains tokens, dynamic client information, legacy verifier/state fields when present, and the server URL binding.
245
+
246
+ Windows Credential Manager cannot hold a typical large OAuth JSON blob in one value, so payloads over 1,000 UTF-16 code units are stored as a manifest plus chunks; the size-limited test store uses the same representation. macOS Keychain and Linux Secret Service keep each record in one item, including ordinary 8–10 KiB records; if an unusually large record exceeds the native store's actual limit, that store error is surfaced. This avoids separate prompts for digest-addressed Keychain chunks. On macOS and Linux, the first ordinary read of a previously chunked record validates and rewrites it as one item before cleaning up its old chunks. Windows and the size-limited test store retain the chunked representation. Status inspection reads the existing representation but does not compact it; existing secure-record and legacy-plaintext cleanup semantics still apply.
247
+
248
+ The adapter fails closed when the OS credential store is unavailable. On headless Linux, configure an unlocked Secret Service-compatible keyring before using persistent OAuth; the adapter does not silently fall back to plaintext token files. Windows OpenSSH network logons may report `ERROR_NO_SUCH_LOGON_SESSION` (1312) because Credential Manager has no credential set for that logon.
249
+
250
+ For Windows OpenSSH/headless use, `settings.oauthCredentialStore: "encrypted-file"` explicitly selects an AES-256-GCM file store. It requires `PI_MCP_ADAPTER_OAUTH_FILE_KEY` to contain canonical base64 for exactly 32 random bytes; generate one with `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"` and inject it through an external secret mechanism. Never place the key in config or beside the ciphertext. One authenticated, versioned envelope is stored per hashed server account at `<Pi agent directory>/mcp-oauth-encrypted/sha256-<server-hash>/credentials.json`. The server URL and secrets appear only inside ciphertext, and account-bound authenticated data prevents copying an envelope to another account.
251
+
252
+ Encrypted-file writes use private directory/file modes where supported and same-directory atomic replacement. POSIX modes are checked on reads, but they do not establish a Windows ACL; encryption with the external key is the confidentiality boundary. This backend never falls back to the OS store, reads `oauthDir` / `MCP_OAUTH_DIR`, or imports legacy plaintext. Reauthenticate after opting in. Key loss or rotation makes existing envelopes unreadable; remove them and reauthenticate with the new key.
253
+
254
+ On Linux, if credential access fails because Pi inherited a revoked session keyring, the adapter makes one best-effort retry through `keyctl session - node <packaged helper>`. This lets explicit re-authentication write fresh credentials from a new session keyring without restarting a long-lived tmux or server process. The recovery path requires `keyctl` and `node` on `PATH`; missing, locked, or otherwise unavailable credential stores still fail closed.
255
+
256
+ Complete credential entries are held in memory for the lifetime of the Pi process on every supported credential-store platform. The MCP SDK reads the access token before every outbound request, so caching avoids a credential-store lookup on each tool call; on Linux this specifically avoids overloading the Secret Service daemon. The cache is filled on the first read for a server and covers both present and absent entries. Authenticating, refreshing, and logging out all update it immediately, so credential changes made through Pi take effect at once. Status-panel inspection deliberately bypasses it and still reads the store directly.
257
+
258
+ A credential changed or deleted by another process while Pi is running is not observed immediately. The affected server picks it up after the first credential-backed authentication failure in that `needs-auth` episode: Pi discards the cached entry, and the following read reloads from the credential store. Restarting Pi also clears the cache. Set `PI_MCP_ADAPTER_DISABLE_AUTH_CACHE=1` to turn the cache off entirely and restore a credential-store read per request.
259
+
260
+ Older versions stored plaintext entries at `~/.pi/agent/mcp-oauth/sha256-<server-hash>/tokens.json`, or under `settings.oauthDir` / `MCP_OAUTH_DIR`. With the default OS backend, the first read after upgrade imports a valid legacy entry and removes the plaintext `tokens.json`. The encrypted-file backend leaves legacy files untouched. These directories are legacy import locations, not persistent credential stores or isolation namespaces.
261
+
262
+ The stored `serverUrl` field ensures credentials are invalidated if the server URL changes.
263
+
264
+ ## Security Considerations
265
+
266
+ ### PKCE
267
+
268
+ All OAuth flows use PKCE with the S256 method, preventing authorization code interception attacks.
269
+
270
+ ### State Parameter
271
+
272
+ A cryptographically secure random state parameter is generated for each flow and validated on callback.
273
+
274
+ ### Issuer Metadata Validation
275
+
276
+ OAuth authorization-server metadata normally must echo the expected issuer. This protects against authorization-server mix-up. The adapter keeps this check on by default.
277
+
278
+ For private servers with known-broken metadata, `oauth.skipIssuerMetadataValidation: true` forwards the SDK's issuer-validation opt-out for that server only. Use it only as a temporary workaround while the server metadata is fixed. Do not use it for public or untrusted servers.
279
+
280
+ When an MCP server does not publish usable protected-resource metadata, configure `oauth.authServerMetadataUrl` with the HTTPS URL of its OAuth/OIDC authorization-server metadata document. That document is authoritative instead of MCP protected-resource discovery, and its issuer is still checked by default. Treat this as trusted configuration and point it only at a metadata endpoint you explicitly trust.
281
+
282
+ ### Credential Stores
283
+
284
+ Persistent OAuth credentials are written to the OS credential store by default, or only to the encrypted file store when explicitly selected. Legacy plaintext files are read only for one-way migration to the default OS store and are removed after successful import. On Linux, revoked session-keyring errors can be retried once through a fresh `keyctl session` helper during explicit re-authentication.
285
+
286
+ Credential entries reside in process memory for the lifetime of the Pi process rather than being re-read per request, and the process-memory copy is discarded on exit.
287
+
288
+ ### URL Validation
289
+
290
+ Credentials are tied to a specific server URL. If the URL changes, the credentials are invalidated and re-authentication is required.
291
+
292
+ ## Troubleshooting
293
+
294
+ ### "No OAuth tokens found"
295
+
296
+ Run `/mcp-auth <server>` to authenticate.
297
+
298
+ ### "Failed to discover OAuth endpoints"
299
+
300
+ The SDK automatically discovers OAuth endpoints from the MCP server. If discovery fails, the server may require a pre-registered client ID:
301
+
302
+ ```json
303
+ {
304
+ "mcpServers": {
305
+ "server": {
306
+ "url": "https://api.example.com/mcp",
307
+ "auth": "oauth",
308
+ "oauth": {
309
+ "clientId": "your-client-id",
310
+ "scope": "read"
311
+ }
312
+ }
313
+ }
314
+ }
315
+ ```
316
+
317
+ ### "Dynamic client registration not supported"
318
+
319
+ Some servers require pre-registered clients. Obtain a client ID from your OAuth provider and add it to the config.
320
+
321
+ ### Callback server already in use
322
+
323
+ Dynamic fallback browser OAuth uses a lazy OS-assigned port on the default loopback host (`localhost`), so the configured default port being busy should not block fallback registration.
324
+
325
+ For pre-registered OAuth clients (`oauth.clientId`), the callback redirect URI must match the provider registration policy. Set `oauth.redirectUri` to the full fixed callback, such as Slack MCP's Claude-compatible `http://localhost:3118/callback`, use a loopback `{port}` URI when the provider explicitly permits dynamic RFC 8252 ports, or free/set `MCP_OAUTH_CALLBACK_PORT` when you rely on the default `/callback` path without an explicit redirect URI.
326
+
327
+ ### Browser doesn't open
328
+
329
+ If the browser fails to open (e.g., in SSH sessions), the authorization URL will be displayed. Copy it manually to your browser.
330
+
331
+ ## Architecture
332
+
333
+ The OAuth implementation uses the following modules:
334
+
335
+ - `mcp-auth.ts` - Auth storage and retrieval through the OS credential store, with one-way legacy `tokens.json` import
336
+ - `mcp-oauth-provider.ts` - SDK OAuthClientProvider implementation
337
+ - `mcp-callback-server.ts` - Node.js HTTP callback server
338
+ - `mcp-auth-flow.ts` - High-level auth flow using SDK transport
339
+
340
+ ## SDK Integration
341
+
342
+ The implementation uses the stable modular MCP client and OAuth APIs:
343
+
344
+ ```typescript
345
+ import {
346
+ auth,
347
+ StreamableHTTPClientTransport,
348
+ UnauthorizedError,
349
+ type OAuthClientProvider,
350
+ } from "@modelcontextprotocol/client"
351
+ ```
352
+
353
+ The `McpOAuthProvider` class implements `OAuthClientProvider` and is passed to `StreamableHTTPClientTransport`:
354
+
355
+ ```typescript
356
+ const transport = new StreamableHTTPClientTransport(url, {
357
+ authProvider: new McpOAuthProvider(serverName, serverUrl, config, callbacks),
358
+ })
359
+ ```
360
+
361
+ ## References
362
+
363
+ - [MCP SDK Documentation](https://github.com/modelcontextprotocol/typescript-sdk)
364
+ - [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
365
+ - [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11)
366
+ - [PKCE (RFC 7636)](https://datatracker.ietf.org/doc/html/rfc7636)
367
+ - [OAuth Client ID Metadata Document](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00)
368
+ - [Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591)
369
+ - [OAuth Protected Resource Metadata (RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728)
package/README.md CHANGED
@@ -71,7 +71,7 @@ Precedence is (later entries win):
71
71
  5. `.mcp.json`
72
72
  6. `.pi/mcp.json`
73
73
 
74
- Ancestor discovery is off by default. To opt in, set `settings.ancestorConfigRoots` in a user-global config above, or in the explicitly selected `--mcp-config`/`configPath` file, for example `"ancestorConfigRoots": ["~/work/team"]`. Each root must be an explicit absolute path or `~/...`, resolve to an existing directory under `$HOME`, and contain the canonical cwd. If several roots match, only the nearest (deepest) is used. Project `.mcp.json` and `.pi/mcp.json` files cannot enable discovery or extend the boundary.
74
+ Ancestor discovery is off by default. To opt in, set `settings.ancestorConfigRoots` in a user-global config above, or in the explicitly selected `--mcp-config`/`configPath` file, for example `"ancestorConfigRoots": ["~/work/team"]`. Each root must be an explicit absolute path or `~/...` and resolve to an existing directory under `$HOME`. Roots that do not contain the canonical cwd are ignored. If several roots match, only the nearest (deepest) is used. Project `.mcp.json` and `.pi/mcp.json` files cannot enable discovery or extend the boundary.
75
75
 
76
76
  Within the selected root, existing `.mcp.json` and `<configDir>/mcp.json` (normally `.pi/mcp.json`) files load between steps 4 and 5, from the root through parent(cwd), farthest first. Nearer directories override farther ones, Pi overrides shared config within each directory, and cwd files win over ancestors. Search never goes above the configured root or `$HOME`; the boundary limits discovery but is not a file-ownership or symlink-target sandbox. Only configure roots whose project files you trust. `/mcp setup` write targets and project-local `/mcp disable` and `/mcp enable` overrides are unchanged.
77
77
 
@@ -113,6 +113,13 @@ Use the shared MCP files when you want one setup to work across hosts, and Pi-ow
113
113
  | `<Pi agent dir>/mcp.json` | Pi global override and compatibility imports (`~/.pi/agent/mcp.json` by default) |
114
114
  | `.pi/mcp.json` | Pi project override |
115
115
 
116
+ For local stdio servers, a leading `~/` is expanded to the current user's home
117
+ directory in `command`, `args`, and `cwd`. On Windows, the equivalent `~\\`
118
+ form is supported too; on POSIX, backslashes remain literal filename
119
+ characters. Bare commands such as
120
+ `node`, `bunx`, or `git` continue to resolve through `PATH`.
121
+ Built-in Agent Plugin arguments remain literal; this path expansion applies to native and shared MCP configuration.
122
+
116
123
  Pi-specific files are the write targets for imported or shared global servers when Pi needs to persist adapter-only settings such as `directTools`.
117
124
 
118
125
  ### Agent Plugins
@@ -508,7 +515,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
508
515
  | `collapsedResultLines` | Number of result text lines to show before expansion: `1`, `2`, or `3`. Defaults to `1` in compact mode and `3` in boxed mode. |
509
516
  | `notifyOnStartupConnect` | Show successful startup connection notices (default: `true`). Set to `false` to suppress routine `MCP: N servers connected (M tools)` notices. Connection errors and authentication warnings remain visible. |
510
517
  | `hostConfigDiscovery` | Host-specific config policy: `"off"` (default), `"prompt"` (detect/report only), or `"on"` (explicitly load detected host configs as the lowest-precedence fallback) |
511
- | `ancestorConfigRoots` | Trusted absolute or `~/...` roots for opt-in ancestor config discovery. Only user-global or explicitly selected config may set it; the deepest root containing cwd is used. |
518
+ | `ancestorConfigRoots` | Trusted absolute or `~/...` roots for opt-in ancestor config discovery. Only user-global or explicitly selected config may set it; roots outside cwd are ignored and the deepest matching root is used. |
512
519
  | `agentPluginPaths` | Agent Plugins package directories to load MCP servers from. Relative paths resolve from the active project cwd. |
513
520
  | `approveTools` | `true` to require approval before every MCP tool call, or an array of glob patterns such as `["github_delete_*", "notion_update_*"]`. Per-server `approveTools` overrides this. |
514
521
  | `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. |
@@ -523,7 +530,7 @@ When any enabled server uses `eager` or `keep-alive`, initialization also starts
523
530
  | `scriptMode` | Register the MCP-only `mcpScript` plain-JavaScript tool (default: true). Set to `false` to hide it. |
524
531
  | `exposeResources` | Expose MCP resources as tools (default: `true`). Set to `false` to disable globally across all servers. Per-server `exposeResources` overrides this. |
525
532
  | `jev` | Optional System One Jev settings. A valid System One key enables semantic search across every enabled MCP server by default; `semanticSearch: false` disables it. `scriptEvaluation` remains disabled by default and requires an `allowedServers` source allowlist when enabled. `jev: false` disables both. Run `/mcp jev setup` for guided configuration. |
526
- | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. Ignored while any server uses `directTools: "search"`, whose tools are registered inactive and can only be activated through `mcp({ search })`. |
533
+ | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. Ignored while any server uses `directTools: "search"`, whose tools are registered inactive and can only be activated through the gateway (`mcp({ search })` or a successful `mcp({ tool })` call). |
527
534
  | `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
528
535
  | `sampling` | Allow MCP servers to sample through Pi models, honoring `modelPreferences.hints` before current/default fallback (default: true when UI approval is available). |
529
536
  | `samplingAutoApprove` | Skip sampling confirmation prompts. Required for sampling in non-UI sessions (default: false). |
@@ -784,7 +791,7 @@ Per-server `directTools` overrides the global setting. The example above registe
784
791
  }
785
792
  ```
786
793
 
787
- A successful `mcp({ search })` activates matching search-mode tools additively for the process lifetime and reports newly activated names in `addedToolNames`; no other operation activates them. A restart or resumed session starts with them inactive again. Selecting `directTools: true` activates held tools, while switching back to `"search"` holds them again. Search-mode tools do not count toward the 75-tool advisory.
794
+ A successful `mcp({ search })` activates matching search-mode tools additively for the rest of the session and reports newly activated names in `addedToolNames`. A successful `mcp({ tool })` call for a held search-mode tool activates it the same way, so the next call uses its real schema; a failed call (lookup, approval, or tool error) activates nothing. A restart or resumed session starts with them inactive again. Selecting `directTools: true` activates held tools, while switching back to `"search"` holds them again. Search-mode tools do not count toward the 75-tool advisory.
788
795
 
789
796
  To expose only a subset of a noisy server, add `includeTools` on the server. Values can be exact original names, generated resource names such as `read_<resource>`, prefixed names, or simple glob patterns:
790
797
 
@@ -847,7 +854,7 @@ MCP servers can ship interactive UIs via the [MCP UI](https://github.com/MCP-UI-
847
854
  3. pi-mcp-adapter fetches the UI HTML and opens it in an iframe
848
855
  4. The UI can call MCP tools and send messages back to the agent
849
856
 
850
- **Native rendering:** On macOS, if [Glimpse](https://github.com/hazat/glimpse) is installed (`pi install npm:glimpseui`), UIs open in a native WKWebView window instead of a browser tab. Set `MCP_UI_VIEWER=browser` to force the browser, `MCP_UI_VIEWER=glimpse` to require native rendering, or `MCP_UI_VIEWER=none` (also accepts `off` / `disabled`) to suppress the window entirely — the tool still runs and its inline result is returned to the agent, but no browser or native window opens. This is useful for headless setups, CI, or users who want the tool output delivered inline as text only. When suppressed, a one-line info notification shows the UI URL so it can still be opened manually if needed.
857
+ **Native rendering:** On macOS, if [Glimpse](https://github.com/hazat/glimpse) is installed (`pi install npm:glimpseui`), UIs open in a native WKWebView window instead of a browser tab. Set `MCP_UI_VIEWER=browser` to force the browser, `MCP_UI_VIEWER=glimpse` to require native rendering, `MCP_UI_VIEWER=orca` to open in the [Orca](https://github.com/orca) built-in browser (falls back to the system browser if Orca is unavailable), or `MCP_UI_VIEWER=none` (also accepts `off` / `disabled`) to suppress the window entirely — the tool still runs and its inline result is returned to the agent, but no browser or native window opens. This is useful for headless setups, CI, or users who want the tool output delivered inline as text only. When suppressed, a one-line info notification shows the UI URL so it can still be opened manually if needed.
851
858
 
852
859
  **Bidirectional communication:** The UI talks back. When it sends a prompt or intent, the message is stored and `triggerTurn()` wakes the agent. The agent retrieves messages via `mcp({ action: "ui-messages" })` and responds, enabling conversational UIs where the app and agent collaborate in real-time.
853
860
 
package/config.ts CHANGED
@@ -621,10 +621,10 @@ function getConfiguredAncestorRoot(globalSources: ConfigSourceSpec[], cwd: strin
621
621
  }
622
622
  try {
623
623
  const root = realpathSync(expanded);
624
- if (!statSync(root).isDirectory() || !isWithin(home, root) || !isWithin(root, canonicalCwd)) throw new Error();
625
- valid.push(root);
624
+ if (!statSync(root).isDirectory() || !isWithin(home, root)) throw new Error();
625
+ if (isWithin(root, canonicalCwd)) valid.push(root);
626
626
  } catch {
627
- console.warn(`Invalid settings.ancestorConfigRoots entry ${JSON.stringify(entry)}: expected an existing directory under HOME containing cwd`);
627
+ console.warn(`Invalid settings.ancestorConfigRoots entry ${JSON.stringify(entry)}: expected an existing directory under HOME`);
628
628
  }
629
629
  }
630
630
  return valid.sort((left, right) => right.length - left.length)[0];
@@ -806,7 +806,27 @@ function loadImportedConfig(
806
806
  try {
807
807
  const value = readImportedConfig(path);
808
808
  if (value && typeof value === "object" && !Array.isArray(value)) {
809
- merged = mergeOpenCodeConfigs(merged, value as Record<string, unknown>);
809
+ const imported = value as Record<string, unknown>;
810
+ const mcp = isRecord(imported.mcp) ? imported.mcp : {};
811
+ // OpenCode v2 nests definitions under mcp.servers; normalize before
812
+ // merging so project overrides retain the existing merge semantics.
813
+ const entries = isRecord(mcp.servers)
814
+ ? { ...Object.fromEntries(Object.entries(mcp).filter(([name]) => name !== "servers" && name !== "timeout")), ...mcp.servers }
815
+ : mcp;
816
+ const normalized = Object.fromEntries(Object.entries(entries).map(([name, entry]) => {
817
+ if (!isRecord(entry) || !isRecord(entry.oauth)) return [name, entry];
818
+ const { client_id, client_secret, auth_server_metadata_url, ...oauth } = entry.oauth;
819
+ return [name, {
820
+ ...entry,
821
+ oauth: {
822
+ ...oauth,
823
+ ...(client_id !== undefined ? { clientId: client_id } : {}),
824
+ ...(client_secret !== undefined ? { clientSecret: client_secret } : {}),
825
+ ...(auth_server_metadata_url !== undefined ? { authServerMetadataUrl: auth_server_metadata_url } : {}),
826
+ },
827
+ }];
828
+ }));
829
+ merged = mergeOpenCodeConfigs(merged, { ...imported, mcp: normalized });
810
830
  highestPrecedencePath = path;
811
831
  }
812
832
  } catch (error) {
@@ -1006,7 +1026,7 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
1006
1026
  if (kind === "opencode") {
1007
1027
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
1008
1028
  const raw = entry as Record<string, unknown>;
1009
- if (raw.enabled === false) continue;
1029
+ if (raw.enabled === false || raw.disabled === true) continue;
1010
1030
 
1011
1031
  if (raw.type === "local" && Array.isArray(raw.command) && raw.command.length > 0 && raw.command.every((value): value is string => typeof value === "string")) {
1012
1032
  const env = toStringRecord(raw.environment);
@@ -1033,12 +1053,15 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
1033
1053
  } else if (raw.oauth && typeof raw.oauth === "object" && !Array.isArray(raw.oauth)) {
1034
1054
  const oauth = raw.oauth as Record<string, unknown>;
1035
1055
  mapped.auth = "oauth";
1056
+ const clientId = oauth.clientId;
1057
+ const clientSecret = oauth.clientSecret;
1058
+ const authServerMetadataUrl = oauth.authServerMetadataUrl;
1036
1059
  mapped.oauth = {
1037
- ...(typeof oauth.clientId === "string" ? { clientId: oauth.clientId } : {}),
1038
- ...(typeof oauth.clientSecret === "string" ? { clientSecret: oauth.clientSecret } : {}),
1060
+ ...(typeof clientId === "string" ? { clientId } : {}),
1061
+ ...(typeof clientSecret === "string" ? { clientSecret } : {}),
1039
1062
  ...(typeof oauth.clientMetadataUrl === "string" ? { clientMetadataUrl: oauth.clientMetadataUrl } : {}),
1040
1063
  ...(typeof oauth.scope === "string" ? { scope: oauth.scope } : {}),
1041
- ...(typeof oauth.authServerMetadataUrl === "string" ? { authServerMetadataUrl: oauth.authServerMetadataUrl } : {}),
1064
+ ...(typeof authServerMetadataUrl === "string" ? { authServerMetadataUrl } : {}),
1042
1065
  ...(typeof oauth.skipIssuerMetadataValidation === "boolean"
1043
1066
  ? { skipIssuerMetadataValidation: oauth.skipIssuerMetadataValidation }
1044
1067
  : {}),
@@ -216,7 +216,7 @@ export function buildProxyDescription(config: McpConfig): string {
216
216
  return selected === "search";
217
217
  });
218
218
  if (searchModeServers.length > 0) {
219
- desc += `\nSearch-mode servers (${searchModeServers.join(", ")}): their tools become real, schema-backed tools the first time mcp({ search }) matches them — after that, call them directly by name.\n`;
219
+ desc += `\nSearch-mode servers (${searchModeServers.join(", ")}): their tools become real, schema-backed tools the first time mcp({ search }) matches them or mcp({ tool }) calls them — after that, call them directly by name.\n`;
220
220
  }
221
221
 
222
222
  const disabledServers = Object.entries(config.mcpServers)
package/dist/config.js CHANGED
@@ -482,12 +482,13 @@ function getConfiguredAncestorRoot(globalSources, cwd) {
482
482
  }
483
483
  try {
484
484
  const root = realpathSync(expanded);
485
- if (!statSync(root).isDirectory() || !isWithin(home, root) || !isWithin(root, canonicalCwd))
485
+ if (!statSync(root).isDirectory() || !isWithin(home, root))
486
486
  throw new Error();
487
- valid.push(root);
487
+ if (isWithin(root, canonicalCwd))
488
+ valid.push(root);
488
489
  }
489
490
  catch {
490
- console.warn(`Invalid settings.ancestorConfigRoots entry ${JSON.stringify(entry)}: expected an existing directory under HOME containing cwd`);
491
+ console.warn(`Invalid settings.ancestorConfigRoots entry ${JSON.stringify(entry)}: expected an existing directory under HOME`);
491
492
  }
492
493
  }
493
494
  return valid.sort((left, right) => right.length - left.length)[0];
@@ -659,7 +660,28 @@ function loadImportedConfig(importKind, cwd, warningPrefix) {
659
660
  try {
660
661
  const value = readImportedConfig(path);
661
662
  if (value && typeof value === "object" && !Array.isArray(value)) {
662
- merged = mergeOpenCodeConfigs(merged, value);
663
+ const imported = value;
664
+ const mcp = isRecord(imported.mcp) ? imported.mcp : {};
665
+ // OpenCode v2 nests definitions under mcp.servers; normalize before
666
+ // merging so project overrides retain the existing merge semantics.
667
+ const entries = isRecord(mcp.servers)
668
+ ? { ...Object.fromEntries(Object.entries(mcp).filter(([name]) => name !== "servers" && name !== "timeout")), ...mcp.servers }
669
+ : mcp;
670
+ const normalized = Object.fromEntries(Object.entries(entries).map(([name, entry]) => {
671
+ if (!isRecord(entry) || !isRecord(entry.oauth))
672
+ return [name, entry];
673
+ const { client_id, client_secret, auth_server_metadata_url, ...oauth } = entry.oauth;
674
+ return [name, {
675
+ ...entry,
676
+ oauth: {
677
+ ...oauth,
678
+ ...(client_id !== undefined ? { clientId: client_id } : {}),
679
+ ...(client_secret !== undefined ? { clientSecret: client_secret } : {}),
680
+ ...(auth_server_metadata_url !== undefined ? { authServerMetadataUrl: auth_server_metadata_url } : {}),
681
+ },
682
+ }];
683
+ }));
684
+ merged = mergeOpenCodeConfigs(merged, { ...imported, mcp: normalized });
663
685
  highestPrecedencePath = path;
664
686
  }
665
687
  }
@@ -844,7 +866,7 @@ function extractServers(config, kind) {
844
866
  if (!entry || typeof entry !== "object" || Array.isArray(entry))
845
867
  continue;
846
868
  const raw = entry;
847
- if (raw.enabled === false)
869
+ if (raw.enabled === false || raw.disabled === true)
848
870
  continue;
849
871
  if (raw.type === "local" && Array.isArray(raw.command) && raw.command.length > 0 && raw.command.every((value) => typeof value === "string")) {
850
872
  const env = toStringRecord(raw.environment);
@@ -872,12 +894,15 @@ function extractServers(config, kind) {
872
894
  else if (raw.oauth && typeof raw.oauth === "object" && !Array.isArray(raw.oauth)) {
873
895
  const oauth = raw.oauth;
874
896
  mapped.auth = "oauth";
897
+ const clientId = oauth.clientId;
898
+ const clientSecret = oauth.clientSecret;
899
+ const authServerMetadataUrl = oauth.authServerMetadataUrl;
875
900
  mapped.oauth = {
876
- ...(typeof oauth.clientId === "string" ? { clientId: oauth.clientId } : {}),
877
- ...(typeof oauth.clientSecret === "string" ? { clientSecret: oauth.clientSecret } : {}),
901
+ ...(typeof clientId === "string" ? { clientId } : {}),
902
+ ...(typeof clientSecret === "string" ? { clientSecret } : {}),
878
903
  ...(typeof oauth.clientMetadataUrl === "string" ? { clientMetadataUrl: oauth.clientMetadataUrl } : {}),
879
904
  ...(typeof oauth.scope === "string" ? { scope: oauth.scope } : {}),
880
- ...(typeof oauth.authServerMetadataUrl === "string" ? { authServerMetadataUrl: oauth.authServerMetadataUrl } : {}),
905
+ ...(typeof authServerMetadataUrl === "string" ? { authServerMetadataUrl } : {}),
881
906
  ...(typeof oauth.skipIssuerMetadataValidation === "boolean"
882
907
  ? { skipIssuerMetadataValidation: oauth.skipIssuerMetadataValidation }
883
908
  : {}),