pi-mcp-adapter 2.2.1 → 2.3.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,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.3.2] - 2026-04-11
11
+
12
+ ### Added
13
+ - Optional `oauth.grantType: "client_credentials"` for non-interactive machine-to-machine OAuth on HTTP MCP servers.
14
+
15
+ ### Fixed
16
+ - `/mcp-auth <server>` now handles `client_credentials` without browser/callback flow.
17
+ - MCP panel status no longer marks `client_credentials` servers as auth-blocked solely because no stored user tokens exist yet.
18
+ - OAuth auth flow now closes temporary transports consistently on success, refresh, and auth removal paths.
19
+ - Init paths now preserve debug-level context for previously silent direct-tool bootstrap and lazy-connect failures.
20
+
21
+ ## [2.3.1] - 2026-04-11
22
+
23
+ ### Fixed
24
+ - Removed `/mcp-auth-callback`. OAuth auth now hard-cuts to `/mcp-auth <server>` only.
25
+
26
+ ## [2.3.0] - 2026-04-11
27
+
28
+ ### Added
29
+ - OAuth callback server initialization on session start and a deprecated `/mcp-auth-callback` command that now points users to `/mcp-auth <server>`.
30
+
31
+ ### Fixed
32
+ - OAuth `needs-auth` handling across `/mcp` status/panel, `mcp({ connect })`, `mcp({ tool })`, reconnect flow, lazy/direct tool execution, and startup bootstrap.
33
+ - OAuth callback cleanup now cancels by stored OAuth state and closes pending transports on failure/cancel paths.
34
+ - Callback server now fails fast when the OAuth callback port is occupied by another process.
35
+ - Package manifest test now ignores root `*.test.ts` files.
36
+
37
+ ## [2.2.2] - 2026-04-03
38
+
39
+ ### Fixed
40
+ - Session lifecycle teardown now handles repeated `session_start` transitions safely and prevents stale async init results from replacing newer state.
41
+ - Shutdown now still runs `gracefulShutdown()` even if metadata cache flushing throws, avoiding leaked MCP processes.
42
+ - Proxy/direct tool init error paths now preserve and surface underlying error messages instead of returning generic failures.
43
+ - Invalid `mcp` tool `args` now fail by throwing with parse/type context instead of returning non-failing tool payloads.
44
+ - Added focused lifecycle regressions tests for stale init cleanup and init-error visibility.
45
+
10
46
  ## [2.2.1] - 2026-03-23
11
47
 
12
48
  ### Fixed
package/README.md CHANGED
@@ -85,6 +85,7 @@ Two calls instead of 26 tools cluttering the context.
85
85
  | `cwd` | Working directory |
86
86
  | `url` | HTTP endpoint (StreamableHTTP with SSE fallback) |
87
87
  | `auth` | `"bearer"` or `"oauth"` |
88
+ | `oauth.grantType` | `"authorization_code"` (default) or `"client_credentials"` for non-interactive machine auth |
88
89
  | `bearerToken` / `bearerTokenEnv` | Token or env var name |
89
90
  | `lifecycle` | `"lazy"` (default), `"eager"`, or `"keep-alive"` |
90
91
  | `idleTimeout` | Minutes before idle disconnect (overrides global) |
@@ -291,6 +292,4 @@ Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_li
291
292
 
292
293
  ## Limitations
293
294
 
294
- - OAuth tokens obtained externally (no browser flow)
295
- - No automatic token refresh
296
295
  - Cross-session server sharing not yet implemented (each Pi session runs its own server processes)
package/commands.ts CHANGED
@@ -4,8 +4,9 @@ import type { McpConfig, ServerEntry, McpPanelCallbacks, McpPanelResult } from "
4
4
  import { getServerProvenance, writeDirectToolsConfig } from "./config.js";
5
5
  import { lazyConnect, updateMetadataCache, updateStatusBar, getFailureAgeSeconds } from "./init.js";
6
6
  import { loadMetadataCache } from "./metadata-cache.js";
7
- import { getStoredTokens } from "./oauth-handler.js";
8
7
  import { buildToolMetadata } from "./tool-metadata.js";
8
+ import { supportsOAuth, authenticate } from "./mcp-auth-flow.js";
9
+ import { hasStoredTokens } from "./mcp-auth.js";
9
10
 
10
11
  export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
11
12
  if (!ctx.hasUI) return;
@@ -24,6 +25,9 @@ export async function showStatus(state: McpExtensionState, ctx: ExtensionContext
24
25
  if (connection?.status === "connected") {
25
26
  status = "connected";
26
27
  statusIcon = "✓";
28
+ } else if (connection?.status === "needs-auth") {
29
+ status = "needs auth";
30
+ statusIcon = "⚠";
27
31
  } else if (failedAgo !== null) {
28
32
  status = `failed ${failedAgo}s ago`;
29
33
  statusIcon = "✗";
@@ -85,6 +89,12 @@ export async function reconnectServers(
85
89
  await state.manager.close(name);
86
90
 
87
91
  const connection = await state.manager.connect(name, definition);
92
+ if (connection.status === "needs-auth") {
93
+ if (ctx.hasUI) {
94
+ ctx.ui.notify(`MCP: ${name} requires OAuth. Run /mcp-auth ${name} first.`, "warning");
95
+ }
96
+ continue;
97
+ }
88
98
  const prefix = state.config.settings?.toolPrefix ?? "server";
89
99
 
90
100
  const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
@@ -126,10 +136,10 @@ export async function authenticateServer(
126
136
  return;
127
137
  }
128
138
 
129
- if (definition.auth !== "oauth") {
139
+ if (!supportsOAuth(definition)) {
130
140
  ctx.ui.notify(
131
141
  `Server "${serverName}" does not use OAuth authentication.\n` +
132
- `Current auth mode: ${definition.auth ?? "none"}`,
142
+ `Set "auth": "oauth" or omit auth for auto-detection.`,
133
143
  "error"
134
144
  );
135
145
  return;
@@ -143,21 +153,31 @@ export async function authenticateServer(
143
153
  return;
144
154
  }
145
155
 
146
- const tokenPath = `~/.pi/agent/mcp-oauth/${serverName}/tokens.json`;
147
-
148
- ctx.ui.notify(
149
- `OAuth setup for "${serverName}":\n\n` +
150
- `1. Obtain an access token from your OAuth provider\n` +
151
- `2. Create the token file:\n` +
152
- ` ${tokenPath}\n\n` +
153
- `3. Add your token:\n` +
154
- ` {\n` +
155
- ` "access_token": "your-token-here",\n` +
156
- ` "token_type": "bearer"\n` +
157
- ` }\n\n` +
158
- `4. Run /mcp reconnect to connect with the token`,
159
- "info"
160
- );
156
+ // Full automatic OAuth flow using SDK
157
+ try {
158
+ ctx.ui.setStatus("mcp-auth", `Authenticating ${serverName}...`);
159
+
160
+ // Runs the configured OAuth flow (interactive browser or non-interactive client_credentials)
161
+ const status = await authenticate(serverName, definition.url, definition);
162
+
163
+ if (status === "authenticated") {
164
+ ctx.ui.notify(
165
+ `OAuth authentication successful for "${serverName}"!\n` +
166
+ `Run /mcp reconnect ${serverName} to connect with the new token.`,
167
+ "success"
168
+ );
169
+ } else {
170
+ ctx.ui.notify(
171
+ `OAuth authentication failed for "${serverName}".`,
172
+ "error"
173
+ );
174
+ }
175
+ } catch (error) {
176
+ const message = error instanceof Error ? error.message : String(error);
177
+ ctx.ui.notify(`Failed to authenticate "${serverName}": ${message}`, "error");
178
+ } finally {
179
+ ctx.ui.setStatus("mcp-auth", undefined);
180
+ }
161
181
  }
162
182
 
163
183
  export async function openMcpPanel(
@@ -176,10 +196,18 @@ export async function openMcpPanel(
176
196
  },
177
197
  getConnectionStatus: (serverName: string) => {
178
198
  const definition = config.mcpServers[serverName];
179
- if (definition?.auth === "oauth" && getStoredTokens(serverName) === undefined) {
199
+ const connection = state.manager.getConnection(serverName);
200
+ if (connection?.status === "needs-auth") {
201
+ return "needs-auth";
202
+ }
203
+ if (
204
+ definition?.auth === "oauth"
205
+ && definition.oauth !== false
206
+ && definition.oauth?.grantType !== "client_credentials"
207
+ && !hasStoredTokens(serverName)
208
+ ) {
180
209
  return "needs-auth";
181
210
  }
182
- const connection = state.manager.getConnection(serverName);
183
211
  if (connection?.status === "connected") return "connected";
184
212
  if (getFailureAgeSeconds(state, serverName) !== null) return "failed";
185
213
  return "idle";
package/direct-tools.ts CHANGED
@@ -181,10 +181,11 @@ export function createDirectToolExecutor(
181
181
  if (!state && initPromise) {
182
182
  try {
183
183
  state = await initPromise;
184
- } catch {
184
+ } catch (error) {
185
+ const message = error instanceof Error ? error.message : String(error);
185
186
  return {
186
- content: [{ type: "text" as const, text: "MCP initialization failed" }],
187
- details: { error: "init_failed" },
187
+ content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }],
188
+ details: { error: "init_failed", message },
188
189
  };
189
190
  }
190
191
  }
@@ -197,6 +198,13 @@ export function createDirectToolExecutor(
197
198
 
198
199
  const connected = await lazyConnect(state, spec.serverName);
199
200
  if (!connected) {
201
+ const authConnection = state.manager.getConnection(spec.serverName);
202
+ if (authConnection?.status === "needs-auth") {
203
+ return {
204
+ content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" requires OAuth authentication. Run /mcp-auth ${spec.serverName} first.` }],
205
+ details: { error: "auth_required", server: spec.serverName },
206
+ };
207
+ }
200
208
  const failedAgo = getFailureAgeSeconds(state, spec.serverName);
201
209
  return {
202
210
  content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not available${failedAgo !== null ? ` (failed ${failedAgo}s ago)` : ""}` }],
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ToolInfo } from "@mariozechner/pi-coding-agent";
2
2
  import type { McpExtensionState } from "./state.js";
3
3
  import { Type } from "@sinclair/typebox";
4
4
  import { showStatus, showTools, reconnectServers, authenticateServer, openMcpPanel } from "./commands.js";
@@ -8,10 +8,42 @@ import { flushMetadataCache, initializeMcp, updateStatusBar } from "./init.js";
8
8
  import { loadMetadataCache } from "./metadata-cache.js";
9
9
  import { executeCall, executeConnect, executeDescribe, executeList, executeSearch, executeStatus, executeUiMessages } from "./proxy-modes.js";
10
10
  import { getConfigPathFromArgv, truncateAtWord } from "./utils.js";
11
+ import { initializeOAuth } from "./mcp-auth-flow.js";
11
12
 
12
13
  export default function mcpAdapter(pi: ExtensionAPI) {
13
14
  let state: McpExtensionState | null = null;
14
15
  let initPromise: Promise<McpExtensionState> | null = null;
16
+ let lifecycleGeneration = 0;
17
+
18
+ async function shutdownState(currentState: McpExtensionState | null, reason: string): Promise<void> {
19
+ if (!currentState) return;
20
+
21
+ if (currentState.uiServer) {
22
+ currentState.uiServer.close(reason);
23
+ currentState.uiServer = null;
24
+ }
25
+
26
+ let flushError: unknown;
27
+ try {
28
+ flushMetadataCache(currentState);
29
+ } catch (error) {
30
+ flushError = error;
31
+ }
32
+
33
+ try {
34
+ await currentState.lifecycle.gracefulShutdown();
35
+ } catch (error) {
36
+ if (flushError) {
37
+ console.error("MCP: graceful shutdown failed after metadata flush error", error);
38
+ } else {
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ if (flushError) {
44
+ throw flushError;
45
+ }
46
+ }
15
47
 
16
48
  const earlyConfigPath = getConfigPathFromArgv();
17
49
  const earlyConfig = loadMcpConfig(earlyConfigPath);
@@ -47,35 +79,63 @@ export default function mcpAdapter(pi: ExtensionAPI) {
47
79
  });
48
80
 
49
81
  pi.on("session_start", async (_event, ctx) => {
50
- initPromise = initializeMcp(pi, ctx);
82
+ const generation = ++lifecycleGeneration;
83
+ const previousState = state;
84
+ state = null;
85
+ initPromise = null;
86
+
87
+ try {
88
+ await shutdownState(previousState, "session_restart");
89
+ } catch (error) {
90
+ console.error("MCP: failed to shut down previous session state", error);
91
+ }
92
+
93
+ if (generation !== lifecycleGeneration) {
94
+ return;
95
+ }
96
+
97
+ await initializeOAuth().catch(err => {
98
+ console.error("MCP OAuth initialization failed:", err);
99
+ });
51
100
 
52
- initPromise.then(s => {
53
- state = s;
101
+ const promise = initializeMcp(pi, ctx);
102
+ initPromise = promise;
103
+
104
+ promise.then(async (nextState) => {
105
+ if (generation !== lifecycleGeneration || initPromise !== promise) {
106
+ try {
107
+ await shutdownState(nextState, "stale_session_start");
108
+ } catch (error) {
109
+ console.error("MCP: failed to clean stale session state", error);
110
+ }
111
+ return;
112
+ }
113
+
114
+ state = nextState;
115
+ updateStatusBar(nextState);
54
116
  initPromise = null;
55
- updateStatusBar(s);
56
117
  }).catch(err => {
118
+ if (generation !== lifecycleGeneration) {
119
+ return;
120
+ }
121
+ if (initPromise !== promise && initPromise !== null) {
122
+ return;
123
+ }
57
124
  console.error("MCP initialization failed:", err);
58
125
  initPromise = null;
59
126
  });
60
127
  });
61
128
 
62
129
  pi.on("session_shutdown", async () => {
63
- if (initPromise) {
64
- try {
65
- state = await initPromise;
66
- } catch {
67
- // Initialization failed, nothing to clean up
68
- }
69
- }
130
+ ++lifecycleGeneration;
131
+ const currentState = state;
132
+ state = null;
133
+ initPromise = null;
70
134
 
71
- if (state) {
72
- if (state.uiServer) {
73
- state.uiServer.close("session_shutdown");
74
- state.uiServer = null;
75
- }
76
- flushMetadataCache(state);
77
- await state.lifecycle.gracefulShutdown();
78
- state = null;
135
+ try {
136
+ await shutdownState(currentState, "session_shutdown");
137
+ } catch (error) {
138
+ console.error("MCP: session shutdown cleanup failed", error);
79
139
  }
80
140
  });
81
141
 
@@ -85,8 +145,9 @@ export default function mcpAdapter(pi: ExtensionAPI) {
85
145
  if (!state && initPromise) {
86
146
  try {
87
147
  state = await initPromise;
88
- } catch {
89
- if (ctx.hasUI) ctx.ui.notify("MCP initialization failed", "error");
148
+ } catch (error) {
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ if (ctx.hasUI) ctx.ui.notify(`MCP initialization failed: ${message}`, "error");
90
151
  return;
91
152
  }
92
153
  }
@@ -131,8 +192,9 @@ export default function mcpAdapter(pi: ExtensionAPI) {
131
192
  if (!state && initPromise) {
132
193
  try {
133
194
  state = await initPromise;
134
- } catch {
135
- if (ctx.hasUI) ctx.ui.notify("MCP initialization failed", "error");
195
+ } catch (error) {
196
+ const message = error instanceof Error ? error.message : String(error);
197
+ if (ctx.hasUI) ctx.ui.notify(`MCP initialization failed: ${message}`, "error");
136
198
  return;
137
199
  }
138
200
  }
@@ -178,28 +240,24 @@ export default function mcpAdapter(pi: ExtensionAPI) {
178
240
  parsedArgs = JSON.parse(params.args);
179
241
  if (typeof parsedArgs !== "object" || parsedArgs === null || Array.isArray(parsedArgs)) {
180
242
  const gotType = Array.isArray(parsedArgs) ? "array" : parsedArgs === null ? "null" : typeof parsedArgs;
181
- return {
182
- content: [{ type: "text" as const, text: `Invalid args: expected a JSON object, got ${gotType}` }],
183
- isError: true,
184
- details: { error: "invalid_args_type" },
185
- };
243
+ throw new Error(`Invalid args: expected a JSON object, got ${gotType}`);
186
244
  }
187
- } catch (e) {
188
- return {
189
- content: [{ type: "text" as const, text: `Invalid args JSON: ${e instanceof Error ? e.message : e}` }],
190
- isError: true,
191
- details: { error: "invalid_args" },
192
- };
245
+ } catch (error) {
246
+ if (error instanceof SyntaxError) {
247
+ throw new Error(`Invalid args JSON: ${error.message}`, { cause: error });
248
+ }
249
+ throw error;
193
250
  }
194
251
  }
195
252
 
196
253
  if (!state && initPromise) {
197
254
  try {
198
255
  state = await initPromise;
199
- } catch {
256
+ } catch (error) {
257
+ const message = error instanceof Error ? error.message : String(error);
200
258
  return {
201
- content: [{ type: "text" as const, text: "MCP initialization failed" }],
202
- details: { error: "init_failed" },
259
+ content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }],
260
+ details: { error: "init_failed", message },
203
261
  };
204
262
  }
205
263
  }
package/init.ts CHANGED
@@ -108,6 +108,9 @@ export async function initializeMcp(
108
108
  const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
109
109
  try {
110
110
  const connection = await manager.connect(name, definition);
111
+ if (connection.status === "needs-auth") {
112
+ return { name, definition, connection: null, error: `OAuth authentication required. Run /mcp-auth ${name}.` };
113
+ }
111
114
  return { name, definition, connection, error: null };
112
115
  } catch (error) {
113
116
  const message = error instanceof Error ? error.message : String(error);
@@ -169,11 +172,16 @@ export async function initializeMcp(
169
172
  const definition = config.mcpServers[name];
170
173
  try {
171
174
  const connection = await manager.connect(name, definition);
175
+ if (connection.status === "needs-auth") {
176
+ return { name, ok: false };
177
+ }
172
178
  const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
173
179
  toolMetadata.set(name, metadata);
174
180
  updateMetadataCache(state, name);
175
181
  return { name, ok: true };
176
- } catch {
182
+ } catch (error) {
183
+ const message = error instanceof Error ? error.message : String(error);
184
+ logger.debug(`MCP: direct-tools bootstrap failed for ${name}: ${message}`);
177
185
  return { name, ok: false };
178
186
  }
179
187
  },
@@ -279,6 +287,9 @@ export function getFailureAgeSeconds(state: McpExtensionState, serverName: strin
279
287
 
280
288
  export async function lazyConnect(state: McpExtensionState, serverName: string): Promise<boolean> {
281
289
  const connection = state.manager.getConnection(serverName);
290
+ if (connection?.status === "needs-auth") {
291
+ return false;
292
+ }
282
293
  if (connection?.status === "connected") {
283
294
  updateServerMetadata(state, serverName);
284
295
  return true;
@@ -294,14 +305,19 @@ export async function lazyConnect(state: McpExtensionState, serverName: string):
294
305
  if (state.ui) {
295
306
  state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
296
307
  }
297
- await state.manager.connect(serverName, definition);
308
+ const newConnection = await state.manager.connect(serverName, definition);
309
+ if (newConnection.status === "needs-auth") {
310
+ return false;
311
+ }
298
312
  state.failureTracker.delete(serverName);
299
313
  updateServerMetadata(state, serverName);
300
314
  updateMetadataCache(state, serverName);
301
315
  updateStatusBar(state);
302
316
  return true;
303
- } catch {
317
+ } catch (error) {
304
318
  state.failureTracker.set(serverName, Date.now());
319
+ const message = error instanceof Error ? error.message : String(error);
320
+ logger.debug(`MCP: lazy connect failed for ${serverName}: ${message}`);
305
321
  updateStatusBar(state);
306
322
  return false;
307
323
  }