pi-mcp-adapter 2.3.4 → 2.4.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,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.4.0] - 2026-04-13
11
+
12
+ ### Added
13
+ - `settings.disableProxyTool` to hide the `mcp` proxy tool once configured direct tools are fully available from cache. Thanks @tanavamsikrishna for PR #41.
14
+ - Per-server `excludeTools` to hide specific MCP tools/resources by original or prefixed name across direct tools, proxy discovery, and the `/mcp` panel. Thanks @ahmadaccino for issue #36.
15
+ - `settings.autoAuth` to optionally trigger OAuth automatically from proxy/direct tool usage, then rerun the original blocked connect/tool operation once after authentication succeeds. Thanks @unimonkiez for issue #34.
16
+
17
+ ### Fixed
18
+ - Regenerated `package-lock.json` so the root lockfile metadata matches `package.json` again, including the declared `open`, `@types/bun`, `@types/open`, and `tsx` entries.
19
+ - Kept the `mcp` proxy tool available as a first-session fallback when configured direct tools are still missing cache metadata, avoiding no-tool startup gaps.
20
+
21
+ ## [2.3.5] - 2026-04-13
22
+
23
+ ### Fixed
24
+ - Session lifecycle now always tears down OAuth callback state on restart and shutdown, preventing callback-server leaks across session transitions.
25
+ - OAuth callback server now calls `unref()` after successful bind so it no longer keeps sub-agent processes alive by itself.
26
+ - Strict OAuth port mode now rebinds to the configured callback port when safe, while refusing to switch ports when authorizations are still pending.
27
+ - Added focused lifecycle/callback-server regression coverage for teardown, `unref()`, strict rebinding, and pending-auth guardrails.
28
+ - Thanks @blai for the investigation and PR #43 that surfaced the sub-agent hang/root lifecycle issues.
29
+
10
30
  ## [2.3.4] - 2026-04-12
11
31
 
12
32
  ### Fixed
package/README.md CHANGED
@@ -91,6 +91,7 @@ Two calls instead of 26 tools cluttering the context.
91
91
  | `idleTimeout` | Minutes before idle disconnect (overrides global) |
92
92
  | `exposeResources` | Expose MCP resources as tools (default: true) |
93
93
  | `directTools` | `true`, `string[]`, or `false` — register tools individually instead of through proxy |
94
+ | `excludeTools` | `string[]` of tool names to hide (matches original names like `get_screenshot` and prefixed names like `figma_get_screenshot`) |
94
95
  | `debug` | Show server stderr (default: false) |
95
96
 
96
97
  ### Lifecycle Modes
@@ -116,6 +117,8 @@ Two calls instead of 26 tools cluttering the context.
116
117
  | `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), or `"none"` |
117
118
  | `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
118
119
  | `directTools` | Global default for all servers (default: false). Per-server overrides this. |
120
+ | `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
121
+ | `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
119
122
 
120
123
  Per-server `idleTimeout` overrides the global setting.
121
124
 
@@ -169,6 +172,22 @@ To set a global default for all servers:
169
172
 
170
173
  Per-server `directTools` overrides the global setting. The example above registers direct tools for every server except `huge-server`.
171
174
 
175
+ To exclude specific tools while still using `directTools: true`, add `excludeTools` on the server:
176
+
177
+ ```json
178
+ {
179
+ "mcpServers": {
180
+ "figma": {
181
+ "url": "http://localhost:3845/mcp",
182
+ "directTools": true,
183
+ "excludeTools": ["get_figjam", "figma_get_code_connect_map"]
184
+ }
185
+ }
186
+ }
187
+ ```
188
+
189
+ `excludeTools` filters direct tools, proxy search/list/describe, and the `/mcp` panel view.
190
+
172
191
  Each direct tool costs ~150-300 tokens in the system prompt (name + description + schema). Good for targeted sets of 5-20 tools. For servers with 75+ tools, stick with the proxy or pick specific tools with a `string[]`.
173
192
 
174
193
  Direct tools register from the metadata cache (`~/.pi/agent/mcp-cache.json`), so no server connections are needed at startup. On the first session after adding `directTools` to a new server, the cache won't exist yet — tools fall back to proxy-only and the cache populates in the background. Restart Pi and they'll be available. To force it: `/mcp reconnect <server>` then restart.
@@ -279,6 +298,8 @@ Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_li
279
298
  | `/mcp reconnect <server>` | Connect or reconnect a single server |
280
299
  | `/mcp-auth <server>` | OAuth setup |
281
300
 
301
+ If `settings.autoAuth` is `true`, `mcp({ connect: ... })`, `mcp({ tool: ... })`, and direct tool calls will automatically run OAuth when needed and retry once. In non-interactive sessions, browser-based OAuth still requires running `/mcp-auth <server>` manually.
302
+
282
303
  ## How It Works
283
304
 
284
305
  - One `mcp` tool in context (~200 tokens) instead of hundreds
package/direct-tools.ts CHANGED
@@ -7,11 +7,50 @@ import { isServerCacheValid } from "./metadata-cache.js";
7
7
  import { formatSchema } from "./tool-metadata.js";
8
8
  import { transformMcpContent } from "./tool-registrar.js";
9
9
  import { maybeStartUiSession, type UiSessionRuntime } from "./ui-session.js";
10
- import { formatToolName } from "./types.js";
10
+ import { formatToolName, isToolExcluded } from "./types.js";
11
11
  import { resourceNameToToolName } from "./resource-tools.js";
12
+ import { authenticate, supportsOAuth } from "./mcp-auth-flow.js";
12
13
 
13
14
  const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
14
15
 
16
+ type DirectAutoAuthResult =
17
+ | { status: "skipped" }
18
+ | { status: "success" }
19
+ | { status: "failed"; message: string };
20
+
21
+ async function attemptDirectAutoAuth(
22
+ state: McpExtensionState,
23
+ serverName: string,
24
+ ): Promise<DirectAutoAuthResult> {
25
+ if (state.config.settings?.autoAuth !== true) {
26
+ return { status: "skipped" };
27
+ }
28
+
29
+ const definition = state.config.mcpServers[serverName];
30
+ if (!definition || !supportsOAuth(definition) || !definition.url) {
31
+ return { status: "skipped" };
32
+ }
33
+
34
+ const grantType = definition.oauth?.grantType ?? "authorization_code";
35
+ if (!state.ui && grantType !== "client_credentials") {
36
+ return {
37
+ status: "failed",
38
+ message: `MCP server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} in an interactive session.`,
39
+ };
40
+ }
41
+
42
+ try {
43
+ await authenticate(serverName, definition.url, definition);
44
+ return { status: "success" };
45
+ } catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ return {
48
+ status: "failed",
49
+ message: `OAuth authentication failed for "${serverName}": ${message}. Run /mcp-auth ${serverName} first.`,
50
+ };
51
+ }
52
+ }
53
+
15
54
  export function resolveDirectTools(
16
55
  config: McpConfig,
17
56
  cache: MetadataCache | null,
@@ -68,6 +107,7 @@ export function resolveDirectTools(
68
107
 
69
108
  for (const tool of serverCache.tools ?? []) {
70
109
  if (toolFilter !== true && !toolFilter.includes(tool.name)) continue;
110
+ if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) continue;
71
111
  const prefixedName = formatToolName(tool.name, serverName, prefix);
72
112
  if (BUILTIN_NAMES.has(prefixedName)) {
73
113
  console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
@@ -93,6 +133,7 @@ export function resolveDirectTools(
93
133
  for (const resource of serverCache.resources ?? []) {
94
134
  const baseName = `get_${resourceNameToToolName(resource.name)}`;
95
135
  if (toolFilter !== true && !toolFilter.includes(baseName)) continue;
136
+ if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) continue;
96
137
  const prefixedName = formatToolName(baseName, serverName, prefix);
97
138
  if (BUILTIN_NAMES.has(prefixedName)) {
98
139
  console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
@@ -117,11 +158,35 @@ export function resolveDirectTools(
117
158
  return specs;
118
159
  }
119
160
 
161
+ export function getMissingConfiguredDirectToolServers(
162
+ config: McpConfig,
163
+ cache: MetadataCache | null,
164
+ ): string[] {
165
+ const missing: string[] = [];
166
+ const globalDirect = config.settings?.directTools;
167
+
168
+ for (const [serverName, definition] of Object.entries(config.mcpServers)) {
169
+ const hasDirectTools = definition.directTools !== undefined
170
+ ? !!definition.directTools
171
+ : !!globalDirect;
172
+
173
+ if (!hasDirectTools) continue;
174
+
175
+ const serverCache = cache?.servers?.[serverName];
176
+ if (!serverCache || !isServerCacheValid(serverCache, definition)) {
177
+ missing.push(serverName);
178
+ }
179
+ }
180
+
181
+ return missing;
182
+ }
183
+
120
184
  export function buildProxyDescription(
121
185
  config: McpConfig,
122
186
  cache: MetadataCache | null,
123
187
  directSpecs: DirectToolSpec[],
124
188
  ): string {
189
+ const prefix = config.settings?.toolPrefix ?? "server";
125
190
  let desc = `MCP gateway - connect to MCP servers and call their tools.\n`;
126
191
 
127
192
  const directByServer = new Map<string, number>();
@@ -139,8 +204,15 @@ export function buildProxyDescription(
139
204
  for (const serverName of Object.keys(config.mcpServers)) {
140
205
  const entry = cache?.servers?.[serverName];
141
206
  const definition = config.mcpServers[serverName];
142
- const toolCount = entry?.tools?.length ?? 0;
143
- const resourceCount = definition?.exposeResources !== false ? (entry?.resources?.length ?? 0) : 0;
207
+ const toolCount = (entry?.tools ?? []).filter(
208
+ (tool) => !isToolExcluded(tool.name, serverName, prefix, definition.excludeTools),
209
+ ).length;
210
+ const resourceCount = definition?.exposeResources !== false
211
+ ? (entry?.resources ?? []).filter((resource) => {
212
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
213
+ return !isToolExcluded(baseName, serverName, prefix, definition.excludeTools);
214
+ }).length
215
+ : 0;
144
216
  const totalItems = toolCount + resourceCount;
145
217
  if (totalItems === 0) continue;
146
218
  const directCount = directByServer.get(serverName) ?? 0;
@@ -196,13 +268,32 @@ export function createDirectToolExecutor(
196
268
  };
197
269
  }
198
270
 
199
- const connected = await lazyConnect(state, spec.serverName);
271
+ let connected = await lazyConnect(state, spec.serverName);
272
+ let autoAuthAttempted = false;
273
+
274
+ if (!connected && state.manager.getConnection(spec.serverName)?.status === "needs-auth") {
275
+ autoAuthAttempted = true;
276
+ const autoAuth = await attemptDirectAutoAuth(state, spec.serverName);
277
+ if (autoAuth.status === "failed") {
278
+ return {
279
+ content: [{ type: "text" as const, text: autoAuth.message }],
280
+ details: { error: "auth_required", server: spec.serverName, message: autoAuth.message },
281
+ };
282
+ }
283
+ if (autoAuth.status === "success") {
284
+ await state.manager.close(spec.serverName);
285
+ state.failureTracker.delete(spec.serverName);
286
+ connected = await lazyConnect(state, spec.serverName);
287
+ }
288
+ }
289
+
200
290
  if (!connected) {
201
291
  const authConnection = state.manager.getConnection(spec.serverName);
202
292
  if (authConnection?.status === "needs-auth") {
293
+ const message = `MCP server "${spec.serverName}" requires OAuth authentication. Run /mcp-auth ${spec.serverName} first.`;
203
294
  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 },
295
+ content: [{ type: "text" as const, text: message }],
296
+ details: { error: "auth_required", server: spec.serverName, message, autoAuthAttempted },
206
297
  };
207
298
  }
208
299
  const failedAgo = getFailureAgeSeconds(state, spec.serverName);
package/index.ts CHANGED
@@ -3,12 +3,12 @@ import type { McpExtensionState } from "./state.js";
3
3
  import { Type } from "@sinclair/typebox";
4
4
  import { showStatus, showTools, reconnectServers, authenticateServer, openMcpPanel } from "./commands.js";
5
5
  import { loadMcpConfig } from "./config.js";
6
- import { buildProxyDescription, createDirectToolExecutor, resolveDirectTools } from "./direct-tools.js";
6
+ import { buildProxyDescription, createDirectToolExecutor, getMissingConfiguredDirectToolServers, resolveDirectTools } from "./direct-tools.js";
7
7
  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
+ import { initializeOAuth, shutdownOAuth } from "./mcp-auth-flow.js";
12
12
 
13
13
  export default function mcpAdapter(pi: ExtensionAPI) {
14
14
  let state: McpExtensionState | null = null;
@@ -59,6 +59,11 @@ export default function mcpAdapter(pi: ExtensionAPI) {
59
59
  prefix,
60
60
  envRaw?.split(",").map(s => s.trim()).filter(Boolean),
61
61
  );
62
+ const missingConfiguredDirectToolServers = getMissingConfiguredDirectToolServers(earlyConfig, earlyCache);
63
+ const shouldRegisterProxyTool =
64
+ earlyConfig.settings?.disableProxyTool !== true
65
+ || directSpecs.length === 0
66
+ || missingConfiguredDirectToolServers.length > 0;
62
67
 
63
68
  for (const spec of directSpecs) {
64
69
  pi.registerTool({
@@ -85,7 +90,10 @@ export default function mcpAdapter(pi: ExtensionAPI) {
85
90
  initPromise = null;
86
91
 
87
92
  try {
88
- await shutdownState(previousState, "session_restart");
93
+ await Promise.all([
94
+ shutdownState(previousState, "session_restart"),
95
+ shutdownOAuth(),
96
+ ]);
89
97
  } catch (error) {
90
98
  console.error("MCP: failed to shut down previous session state", error);
91
99
  }
@@ -133,7 +141,10 @@ export default function mcpAdapter(pi: ExtensionAPI) {
133
141
  initPromise = null;
134
142
 
135
143
  try {
136
- await shutdownState(currentState, "session_shutdown");
144
+ await Promise.all([
145
+ shutdownState(currentState, "session_shutdown"),
146
+ shutdownOAuth(),
147
+ ]);
137
148
  } catch (error) {
138
149
  console.error("MCP: session shutdown cleanup failed", error);
139
150
  }
@@ -207,86 +218,88 @@ export default function mcpAdapter(pi: ExtensionAPI) {
207
218
  },
208
219
  });
209
220
 
210
- pi.registerTool({
211
- name: "mcp",
212
- label: "MCP",
213
- description: buildProxyDescription(earlyConfig, earlyCache, directSpecs),
214
- promptSnippet: "MCP gateway - connect to MCP servers and call their tools",
215
- parameters: Type.Object({
216
- tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
217
- args: Type.Optional(Type.String({ description: "Arguments as JSON string (e.g., '{\"key\": \"value\"}')" })),
218
- connect: Type.Optional(Type.String({ description: "Server name to connect (lazy connect + metadata refresh)" })),
219
- describe: Type.Optional(Type.String({ description: "Tool name to describe (shows parameters)" })),
220
- search: Type.Optional(Type.String({ description: "Search tools by name/description" })),
221
- regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
222
- includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
223
- server: Type.Optional(Type.String({ description: "Filter to specific server (also disambiguates tool calls)" })),
224
- action: Type.Optional(Type.String({ description: "Action: 'ui-messages' to retrieve prompts/intents from UI sessions" })),
225
- }),
226
- async execute(_toolCallId, params: {
227
- tool?: string;
228
- args?: string;
229
- connect?: string;
230
- describe?: string;
231
- search?: string;
232
- regex?: boolean;
233
- includeSchemas?: boolean;
234
- server?: string;
235
- action?: string;
236
- }, _signal, _onUpdate, _ctx) {
237
- let parsedArgs: Record<string, unknown> | undefined;
238
- if (params.args) {
239
- try {
240
- parsedArgs = JSON.parse(params.args);
241
- if (typeof parsedArgs !== "object" || parsedArgs === null || Array.isArray(parsedArgs)) {
242
- const gotType = Array.isArray(parsedArgs) ? "array" : parsedArgs === null ? "null" : typeof parsedArgs;
243
- throw new Error(`Invalid args: expected a JSON object, got ${gotType}`);
244
- }
245
- } catch (error) {
246
- if (error instanceof SyntaxError) {
247
- throw new Error(`Invalid args JSON: ${error.message}`, { cause: error });
221
+ if (shouldRegisterProxyTool) {
222
+ pi.registerTool({
223
+ name: "mcp",
224
+ label: "MCP",
225
+ description: buildProxyDescription(earlyConfig, earlyCache, directSpecs),
226
+ promptSnippet: "MCP gateway - connect to MCP servers and call their tools",
227
+ parameters: Type.Object({
228
+ tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
229
+ args: Type.Optional(Type.String({ description: "Arguments as JSON string (e.g., '{\"key\": \"value\"}')" })),
230
+ connect: Type.Optional(Type.String({ description: "Server name to connect (lazy connect + metadata refresh)" })),
231
+ describe: Type.Optional(Type.String({ description: "Tool name to describe (shows parameters)" })),
232
+ search: Type.Optional(Type.String({ description: "Search tools by name/description" })),
233
+ regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
234
+ includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
235
+ server: Type.Optional(Type.String({ description: "Filter to specific server (also disambiguates tool calls)" })),
236
+ action: Type.Optional(Type.String({ description: "Action: 'ui-messages' to retrieve prompts/intents from UI sessions" })),
237
+ }),
238
+ async execute(_toolCallId, params: {
239
+ tool?: string;
240
+ args?: string;
241
+ connect?: string;
242
+ describe?: string;
243
+ search?: string;
244
+ regex?: boolean;
245
+ includeSchemas?: boolean;
246
+ server?: string;
247
+ action?: string;
248
+ }, _signal, _onUpdate, _ctx) {
249
+ let parsedArgs: Record<string, unknown> | undefined;
250
+ if (params.args) {
251
+ try {
252
+ parsedArgs = JSON.parse(params.args);
253
+ if (typeof parsedArgs !== "object" || parsedArgs === null || Array.isArray(parsedArgs)) {
254
+ const gotType = Array.isArray(parsedArgs) ? "array" : parsedArgs === null ? "null" : typeof parsedArgs;
255
+ throw new Error(`Invalid args: expected a JSON object, got ${gotType}`);
256
+ }
257
+ } catch (error) {
258
+ if (error instanceof SyntaxError) {
259
+ throw new Error(`Invalid args JSON: ${error.message}`, { cause: error });
260
+ }
261
+ throw error;
248
262
  }
249
- throw error;
250
263
  }
251
- }
252
264
 
253
- if (!state && initPromise) {
254
- try {
255
- state = await initPromise;
256
- } catch (error) {
257
- const message = error instanceof Error ? error.message : String(error);
265
+ if (!state && initPromise) {
266
+ try {
267
+ state = await initPromise;
268
+ } catch (error) {
269
+ const message = error instanceof Error ? error.message : String(error);
270
+ return {
271
+ content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }],
272
+ details: { error: "init_failed", message },
273
+ };
274
+ }
275
+ }
276
+ if (!state) {
258
277
  return {
259
- content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }],
260
- details: { error: "init_failed", message },
278
+ content: [{ type: "text" as const, text: "MCP not initialized" }],
279
+ details: { error: "not_initialized" },
261
280
  };
262
281
  }
263
- }
264
- if (!state) {
265
- return {
266
- content: [{ type: "text" as const, text: "MCP not initialized" }],
267
- details: { error: "not_initialized" },
268
- };
269
- }
270
282
 
271
- if (params.action === "ui-messages") {
272
- return executeUiMessages(state);
273
- }
274
- if (params.tool) {
275
- return executeCall(state, params.tool, parsedArgs, params.server);
276
- }
277
- if (params.connect) {
278
- return executeConnect(state, params.connect);
279
- }
280
- if (params.describe) {
281
- return executeDescribe(state, params.describe);
282
- }
283
- if (params.search) {
284
- return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas, getPiTools);
285
- }
286
- if (params.server) {
287
- return executeList(state, params.server);
288
- }
289
- return executeStatus(state);
290
- },
291
- });
283
+ if (params.action === "ui-messages") {
284
+ return executeUiMessages(state);
285
+ }
286
+ if (params.tool) {
287
+ return executeCall(state, params.tool, parsedArgs, params.server);
288
+ }
289
+ if (params.connect) {
290
+ return executeConnect(state, params.connect);
291
+ }
292
+ if (params.describe) {
293
+ return executeDescribe(state, params.describe);
294
+ }
295
+ if (params.search) {
296
+ return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas, getPiTools);
297
+ }
298
+ if (params.server) {
299
+ return executeList(state, params.server);
300
+ }
301
+ return executeStatus(state);
302
+ },
303
+ });
304
+ }
292
305
  }
package/init.ts CHANGED
@@ -21,6 +21,7 @@ import { buildToolMetadata, totalToolCount } from "./tool-metadata.js";
21
21
  import { UiResourceHandler } from "./ui-resource-handler.js";
22
22
  import { openUrl, parallelLimit } from "./utils.js";
23
23
  import { logger } from "./logger.js";
24
+ import { getMissingConfiguredDirectToolServers } from "./direct-tools.js";
24
25
 
25
26
  const FAILURE_BACKOFF_MS = 60 * 1000;
26
27
 
@@ -89,7 +90,7 @@ export async function initializeMcp(
89
90
  }
90
91
 
91
92
  if (cache?.servers?.[name] && isServerCacheValid(cache.servers[name], definition)) {
92
- const metadata = reconstructToolMetadata(name, cache.servers[name], prefix, definition.exposeResources);
93
+ const metadata = reconstructToolMetadata(name, cache.servers[name], prefix, definition);
93
94
  toolMetadata.set(name, metadata);
94
95
  }
95
96
  }
@@ -151,18 +152,8 @@ export async function initializeMcp(
151
152
 
152
153
  const envDirect = process.env.MCP_DIRECT_TOOLS;
153
154
  if (envDirect !== "__none__") {
154
- const missingCacheServers: string[] = [];
155
155
  const currentCache = loadMetadataCache();
156
- for (const [name, definition] of serverEntries) {
157
- const hasDirect = definition.directTools !== undefined
158
- ? !!definition.directTools
159
- : !!config.settings?.directTools;
160
- if (!hasDirect) continue;
161
- const entry = currentCache?.servers?.[name];
162
- if (!entry || !isServerCacheValid(entry, definition)) {
163
- missingCacheServers.push(name);
164
- }
165
- }
156
+ const missingCacheServers = getMissingConfiguredDirectToolServers(config, currentCache);
166
157
 
167
158
  if (missingCacheServers.length > 0) {
168
159
  const bootstrapResults = await parallelLimit(
package/mcp-auth-flow.ts CHANGED
@@ -36,6 +36,9 @@ export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
36
36
  // Track pending transports for auth completion
37
37
  const pendingTransports = new Map<string, StreamableHTTPClientTransport>()
38
38
 
39
+ // Deduplicate concurrent authenticate() calls per server.
40
+ const pendingAuthentications = new Map<string, Promise<AuthStatus>>()
41
+
39
42
  /**
40
43
  * Generate a cryptographically secure random state parameter.
41
44
  */
@@ -182,56 +185,74 @@ export async function authenticate(
182
185
  serverUrl: string,
183
186
  definition?: ServerEntry,
184
187
  ): Promise<AuthStatus> {
185
- // Start auth flow
186
- const { authorizationUrl } = await startAuth(serverName, serverUrl, definition)
187
-
188
- // If no auth URL needed, already authenticated
189
- if (!authorizationUrl) {
190
- return "authenticated"
188
+ const inFlight = pendingAuthentications.get(serverName)
189
+ if (inFlight) {
190
+ return inFlight
191
191
  }
192
192
 
193
- // Get the state that was already generated and stored in startAuth()
194
- const oauthState = await getOAuthState(serverName)
195
- if (!oauthState) {
196
- throw new Error("OAuth state not found - this should not happen")
197
- }
193
+ const operation = (async (): Promise<AuthStatus> => {
194
+ // Start auth flow
195
+ const { authorizationUrl } = await startAuth(serverName, serverUrl, definition)
198
196
 
199
- // Register the callback BEFORE opening the browser
200
- const callbackPromise = waitForCallback(oauthState)
197
+ // If no auth URL needed, already authenticated
198
+ if (!authorizationUrl) {
199
+ return "authenticated"
200
+ }
201
201
 
202
- // Open browser
203
- console.log(`MCP Auth: Opening browser for ${serverName}`)
204
- try {
205
- await open(authorizationUrl)
206
- } catch (error) {
207
- console.warn(`MCP Auth: Failed to open browser for ${serverName}`, { error })
208
- throw new Error(
209
- `Could not open browser. Please open this URL manually: ${authorizationUrl}`
210
- )
211
- }
202
+ // Get the state that was already generated and stored in startAuth()
203
+ const oauthState = await getOAuthState(serverName)
204
+ if (!oauthState) {
205
+ throw new Error("OAuth state not found - this should not happen")
206
+ }
212
207
 
213
- try {
214
- // Wait for callback
215
- const code = await callbackPromise
208
+ // Register the callback BEFORE opening the browser
209
+ const callbackPromise = waitForCallback(oauthState)
210
+
211
+ // Open browser
212
+ console.log(`MCP Auth: Opening browser for ${serverName}`)
213
+ try {
214
+ await open(authorizationUrl)
215
+ } catch (error) {
216
+ console.warn(`MCP Auth: Failed to open browser for ${serverName}`, { error })
217
+ throw new Error(
218
+ `Could not open browser. Please open this URL manually: ${authorizationUrl}`,
219
+ { cause: error },
220
+ )
221
+ }
216
222
 
217
- // Validate state
218
- const storedState = await getOAuthState(serverName)
219
- if (storedState !== oauthState) {
223
+ try {
224
+ // Wait for callback
225
+ const code = await callbackPromise
226
+
227
+ // Validate state
228
+ const storedState = await getOAuthState(serverName)
229
+ if (storedState !== oauthState) {
230
+ await clearOAuthState(serverName)
231
+ throw new Error("OAuth state mismatch - potential CSRF attack")
232
+ }
220
233
  await clearOAuthState(serverName)
221
- throw new Error("OAuth state mismatch - potential CSRF attack")
234
+
235
+ // Complete the auth
236
+ return await completeAuth(serverName, code)
237
+ } catch (error) {
238
+ cancelPendingCallback(oauthState)
239
+ const pendingTransport = pendingTransports.get(serverName)
240
+ if (pendingTransport) {
241
+ pendingTransports.delete(serverName)
242
+ await pendingTransport.close().catch(() => {})
243
+ }
244
+ throw error
222
245
  }
223
- await clearOAuthState(serverName)
246
+ })()
224
247
 
225
- // Complete the auth
226
- return await completeAuth(serverName, code)
227
- } catch (error) {
228
- cancelPendingCallback(oauthState)
229
- const pendingTransport = pendingTransports.get(serverName)
230
- if (pendingTransport) {
231
- pendingTransports.delete(serverName)
232
- await pendingTransport.close().catch(() => {})
248
+ pendingAuthentications.set(serverName, operation)
249
+
250
+ try {
251
+ return await operation
252
+ } finally {
253
+ if (pendingAuthentications.get(serverName) === operation) {
254
+ pendingAuthentications.delete(serverName)
233
255
  }
234
- throw error
235
256
  }
236
257
  }
237
258
 
@@ -155,9 +155,13 @@ export async function ensureCallbackServer(options: EnsureCallbackServerOptions
155
155
  if (server) {
156
156
  if (!strictPort || getOAuthCallbackPort() === configuredPort) return
157
157
 
158
- throw new Error(
159
- `OAuth callback server is already running on port ${getOAuthCallbackPort()}, but strict callback port ${configuredPort} is required`
160
- )
158
+ if (pendingAuths.size > 0) {
159
+ throw new Error(
160
+ `OAuth callback server is running on port ${getOAuthCallbackPort()}, but strict callback port ${configuredPort} is required and cannot be switched while authorizations are pending`
161
+ )
162
+ }
163
+
164
+ await stopCallbackServer()
161
165
  }
162
166
 
163
167
  const preferredPort = configuredPort
@@ -180,6 +184,7 @@ export async function ensureCallbackServer(options: EnsureCallbackServerOptions
180
184
  })
181
185
 
182
186
  server = candidateServer
187
+ server.unref()
183
188
  setOAuthCallbackPort(candidatePort)
184
189
  return
185
190
  } catch (error) {
@@ -257,7 +262,7 @@ export async function stopCallbackServer(): Promise<void> {
257
262
  const pendingList = Array.from(pendingAuths.entries())
258
263
  pendingAuths.clear()
259
264
  setTimeout(() => {
260
- for (const [state, pending] of pendingList) {
265
+ for (const [, pending] of pendingList) {
261
266
  clearTimeout(pending.timeout)
262
267
  pending.reject(new Error("OAuth callback server stopped"))
263
268
  }
package/mcp-panel.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
2
+ import { isToolExcluded } from "./types.js";
2
3
  import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance } from "./types.js";
3
4
  import { resourceNameToToolName } from "./resource-tools.js";
4
5
  import type { MetadataCache, ServerCacheEntry, CachedTool } from "./metadata-cache.js";
@@ -93,6 +94,8 @@ interface ServerState {
93
94
  expanded: boolean;
94
95
  source: "user" | "project" | "import";
95
96
  importKind?: string;
97
+ excludeTools?: string[];
98
+ exposeResources: boolean;
96
99
  connectionStatus: ConnectionStatus;
97
100
  tools: ToolState[];
98
101
  hasCachedData: boolean;
@@ -105,6 +108,7 @@ interface VisibleItem {
105
108
  }
106
109
 
107
110
  class McpPanel {
111
+ private prefix: "server" | "none" | "short";
108
112
  private servers: ServerState[] = [];
109
113
  private cursorIndex = 0;
110
114
  private nameQuery = "";
@@ -132,6 +136,7 @@ class McpPanel {
132
136
  private done: (result: McpPanelResult) => void,
133
137
  ) {
134
138
  this.tui = tui;
139
+ this.prefix = config.settings?.toolPrefix ?? "server";
135
140
 
136
141
  for (const [serverName, definition] of Object.entries(config.mcpServers)) {
137
142
  const prov = provenance.get(serverName);
@@ -148,6 +153,10 @@ class McpPanel {
148
153
  const tools: ToolState[] = [];
149
154
  if (serverCache) {
150
155
  for (const tool of serverCache.tools ?? []) {
156
+ if (isToolExcluded(tool.name, serverName, this.prefix, definition.excludeTools)) {
157
+ continue;
158
+ }
159
+
151
160
  const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(tool.name));
152
161
  tools.push({
153
162
  name: tool.name,
@@ -160,6 +169,10 @@ class McpPanel {
160
169
  if (definition.exposeResources !== false) {
161
170
  for (const resource of serverCache.resources ?? []) {
162
171
  const baseName = `get_${resourceNameToToolName(resource.name)}`;
172
+ if (isToolExcluded(baseName, serverName, this.prefix, definition.excludeTools)) {
173
+ continue;
174
+ }
175
+
163
176
  const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(baseName));
164
177
  const ct: CachedTool = { name: baseName, description: resource.description };
165
178
  tools.push({
@@ -180,6 +193,8 @@ class McpPanel {
180
193
  expanded: false,
181
194
  source: prov?.kind ?? "user",
182
195
  importKind: prov?.importKind,
196
+ excludeTools: definition.excludeTools,
197
+ exposeResources: definition.exposeResources !== false,
183
198
  connectionStatus: status,
184
199
  tools,
185
200
  hasCachedData: !!serverCache,
@@ -385,8 +400,10 @@ class McpPanel {
385
400
  server.hasCachedData = true;
386
401
  }
387
402
  this.tui.requestRender();
388
- }).catch(() => {
403
+ }).catch((error) => {
389
404
  server.connectionStatus = "failed";
405
+ const message = error instanceof Error ? error.message : String(error);
406
+ this.authNotice = `Reconnect failed for ${server.name}: ${message}`;
390
407
  this.tui.requestRender();
391
408
  });
392
409
  return;
@@ -477,6 +494,10 @@ class McpPanel {
477
494
 
478
495
  const newTools: ToolState[] = [];
479
496
  for (const tool of entry.tools ?? []) {
497
+ if (isToolExcluded(tool.name, server.name, this.prefix, server.excludeTools)) {
498
+ continue;
499
+ }
500
+
480
501
  const prev = existingState.get(tool.name);
481
502
  const isDirect = prev !== undefined ? prev : false;
482
503
  newTools.push({
@@ -488,18 +509,24 @@ class McpPanel {
488
509
  });
489
510
  }
490
511
 
491
- for (const resource of entry.resources ?? []) {
492
- const baseName = `get_${resourceNameToToolName(resource.name)}`;
493
- const prev = existingState.get(baseName);
494
- const isDirect = prev !== undefined ? prev : false;
495
- const ct: CachedTool = { name: baseName, description: resource.description };
496
- newTools.push({
497
- name: baseName,
498
- description: resource.description ?? `Read resource: ${resource.uri}`,
499
- isDirect,
500
- wasDirect: prev !== undefined ? server.tools.find((t) => t.name === baseName)?.wasDirect ?? false : false,
501
- estimatedTokens: estimateTokens(ct),
502
- });
512
+ if (server.exposeResources) {
513
+ for (const resource of entry.resources ?? []) {
514
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
515
+ if (isToolExcluded(baseName, server.name, this.prefix, server.excludeTools)) {
516
+ continue;
517
+ }
518
+
519
+ const prev = existingState.get(baseName);
520
+ const isDirect = prev !== undefined ? prev : false;
521
+ const ct: CachedTool = { name: baseName, description: resource.description };
522
+ newTools.push({
523
+ name: baseName,
524
+ description: resource.description ?? `Read resource: ${resource.uri}`,
525
+ isDirect,
526
+ wasDirect: prev !== undefined ? server.tools.find((t) => t.name === baseName)?.wasDirect ?? false : false,
527
+ estimatedTokens: estimateTokens(ct),
528
+ });
529
+ }
503
530
  }
504
531
 
505
532
  server.tools = newTools;
package/metadata-cache.ts CHANGED
@@ -5,7 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import { createHash } from "node:crypto";
6
6
  import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
7
7
  import type { McpTool, McpResource, ServerEntry, ToolMetadata } from "./types.js";
8
- import { formatToolName } from "./types.js";
8
+ import { formatToolName, isToolExcluded } from "./types.js";
9
9
  import { resourceNameToToolName } from "./resource-tools.js";
10
10
  import { extractToolUiStreamMode } from "./utils.js";
11
11
 
@@ -95,6 +95,7 @@ export function computeServerHash(definition: ServerEntry): string {
95
95
  bearerToken: definition.bearerToken,
96
96
  bearerTokenEnv: definition.bearerTokenEnv,
97
97
  exposeResources: definition.exposeResources,
98
+ excludeTools: definition.excludeTools,
98
99
  };
99
100
  const normalized = stableStringify(identity);
100
101
  return createHash("sha256").update(normalized).digest("hex");
@@ -115,12 +116,16 @@ export function reconstructToolMetadata(
115
116
  serverName: string,
116
117
  entry: ServerCacheEntry,
117
118
  prefix: "server" | "none" | "short",
118
- exposeResources?: boolean
119
+ definition: Pick<ServerEntry, "exposeResources" | "excludeTools">
119
120
  ): ToolMetadata[] {
120
121
  const metadata: ToolMetadata[] = [];
121
122
 
122
123
  for (const tool of entry.tools ?? []) {
123
124
  if (!tool?.name) continue;
125
+ if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) {
126
+ continue;
127
+ }
128
+
124
129
  metadata.push({
125
130
  name: formatToolName(tool.name, serverName, prefix),
126
131
  originalName: tool.name,
@@ -131,10 +136,14 @@ export function reconstructToolMetadata(
131
136
  });
132
137
  }
133
138
 
134
- if (exposeResources !== false) {
139
+ if (definition.exposeResources !== false) {
135
140
  for (const resource of entry.resources ?? []) {
136
141
  if (!resource?.name || !resource?.uri) continue;
137
142
  const baseName = `get_${resourceNameToToolName(resource.name)}`;
143
+ if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) {
144
+ continue;
145
+ }
146
+
138
147
  metadata.push({
139
148
  name: formatToolName(baseName, serverName, prefix),
140
149
  originalName: baseName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.3.4",
3
+ "version": "2.4.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/proxy-modes.ts CHANGED
@@ -7,9 +7,52 @@ import { buildToolMetadata, getToolNames, findToolByName, formatSchema } from ".
7
7
  import { transformMcpContent } from "./tool-registrar.js";
8
8
  import { maybeStartUiSession, type UiSessionRuntime } from "./ui-session.js";
9
9
  import { truncateAtWord } from "./utils.js";
10
+ import { authenticate, supportsOAuth } from "./mcp-auth-flow.js";
10
11
 
11
12
  type ProxyToolResult = AgentToolResult<Record<string, unknown>>;
12
13
 
14
+ type AutoAuthResult =
15
+ | { status: "skipped" }
16
+ | { status: "success" }
17
+ | { status: "failed"; message: string };
18
+
19
+ function getAuthRequiredMessage(serverName: string): string {
20
+ return `Server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} first.`;
21
+ }
22
+
23
+ async function attemptAutoAuth(
24
+ state: McpExtensionState,
25
+ serverName: string,
26
+ ): Promise<AutoAuthResult> {
27
+ if (state.config.settings?.autoAuth !== true) {
28
+ return { status: "skipped" };
29
+ }
30
+
31
+ const definition = state.config.mcpServers[serverName];
32
+ if (!definition || !supportsOAuth(definition) || !definition.url) {
33
+ return { status: "skipped" };
34
+ }
35
+
36
+ const grantType = definition.oauth?.grantType ?? "authorization_code";
37
+ if (!state.ui && grantType !== "client_credentials") {
38
+ return {
39
+ status: "failed",
40
+ message: `Server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} in an interactive session.`,
41
+ };
42
+ }
43
+
44
+ try {
45
+ await authenticate(serverName, definition.url, definition);
46
+ return { status: "success" };
47
+ } catch (error) {
48
+ const message = error instanceof Error ? error.message : String(error);
49
+ return {
50
+ status: "failed",
51
+ message: `OAuth authentication failed for "${serverName}": ${message}. Run /mcp-auth ${serverName} first.`,
52
+ };
53
+ }
54
+ }
55
+
13
56
  export function executeUiMessages(state: McpExtensionState): ProxyToolResult {
14
57
  const sessions = state.completedUiSessions;
15
58
 
@@ -376,12 +419,26 @@ export async function executeConnect(state: McpExtensionState, serverName: strin
376
419
  if (state.ui) {
377
420
  state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
378
421
  }
379
- const connection = await state.manager.connect(serverName, definition);
422
+ let connection = await state.manager.connect(serverName, definition);
380
423
  if (connection.status === "needs-auth") {
381
- return {
382
- content: [{ type: "text" as const, text: `Server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} first.` }],
383
- details: { mode: "connect", error: "auth_required", server: serverName },
384
- };
424
+ const autoAuth = await attemptAutoAuth(state, serverName);
425
+ if (autoAuth.status === "failed") {
426
+ return {
427
+ content: [{ type: "text" as const, text: autoAuth.message }],
428
+ details: { mode: "connect", error: "auth_required", server: serverName, message: autoAuth.message },
429
+ };
430
+ }
431
+ if (autoAuth.status === "success") {
432
+ await state.manager.close(serverName);
433
+ connection = await state.manager.connect(serverName, definition);
434
+ }
435
+ if (connection.status === "needs-auth") {
436
+ const message = getAuthRequiredMessage(serverName);
437
+ return {
438
+ content: [{ type: "text" as const, text: message }],
439
+ details: { mode: "connect", error: "auth_required", server: serverName, message },
440
+ };
441
+ }
385
442
  }
386
443
  const prefix = state.config.settings?.toolPrefix ?? "server";
387
444
  const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
@@ -409,6 +466,7 @@ export async function executeCall(
409
466
  ): Promise<ProxyToolResult> {
410
467
  let serverName: string | undefined = serverOverride;
411
468
  let toolMeta: ToolMetadata | undefined;
469
+ let autoAuthAttempted = false;
412
470
  const prefixMode = state.config.settings?.toolPrefix ?? "server";
413
471
 
414
472
  if (serverName && !state.config.mcpServers[serverName]) {
@@ -438,17 +496,48 @@ export async function executeCall(
438
496
  } else {
439
497
  const needsAuthConnection = state.manager.getConnection(serverName);
440
498
  if (needsAuthConnection?.status === "needs-auth") {
441
- return {
442
- content: [{ type: "text" as const, text: `Server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} first.` }],
443
- details: { mode: "call", error: "auth_required", server: serverName },
444
- };
499
+ if (!autoAuthAttempted) {
500
+ autoAuthAttempted = true;
501
+ const autoAuth = await attemptAutoAuth(state, serverName);
502
+ if (autoAuth.status === "failed") {
503
+ return {
504
+ content: [{ type: "text" as const, text: autoAuth.message }],
505
+ details: { mode: "call", error: "auth_required", server: serverName, message: autoAuth.message },
506
+ };
507
+ }
508
+ if (autoAuth.status === "success") {
509
+ await state.manager.close(serverName);
510
+ state.failureTracker.delete(serverName);
511
+ const connectedAfterAuth = await lazyConnect(state, serverName);
512
+ if (connectedAfterAuth) {
513
+ toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
514
+ if (!toolMeta) {
515
+ return {
516
+ content: [{ type: "text" as const, text: `Tool "${toolName}" not found on "${serverName}" after reconnect.` }],
517
+ details: { mode: "call", error: "tool_not_found_after_reconnect", requestedTool: toolName },
518
+ };
519
+ }
520
+ }
521
+ }
522
+ }
523
+
524
+ if (!toolMeta && state.manager.getConnection(serverName)?.status === "needs-auth") {
525
+ const message = getAuthRequiredMessage(serverName);
526
+ return {
527
+ content: [{ type: "text" as const, text: message }],
528
+ details: { mode: "call", error: "auth_required", server: serverName, message },
529
+ };
530
+ }
445
531
  }
446
- const failedAgo = getFailureAgeSeconds(state, serverName);
447
- if (failedAgo !== null) {
448
- return {
449
- content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
450
- details: { mode: "call", error: "server_backoff", server: serverName },
451
- };
532
+
533
+ if (!toolMeta) {
534
+ const failedAgo = getFailureAgeSeconds(state, serverName);
535
+ if (failedAgo !== null) {
536
+ return {
537
+ content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
538
+ details: { mode: "call", error: "server_backoff", server: serverName },
539
+ };
540
+ }
452
541
  }
453
542
  }
454
543
  }
@@ -462,9 +551,27 @@ export async function executeCall(
462
551
  .sort((a, b) => b.prefix.length - a.prefix.length);
463
552
 
464
553
  for (const { name: configuredServer } of candidates) {
554
+ const existingConnection = state.manager.getConnection(configuredServer);
465
555
  const failedAgo = getFailureAgeSeconds(state, configuredServer);
466
- if (failedAgo !== null) continue;
467
- const connected = await lazyConnect(state, configuredServer);
556
+ if (failedAgo !== null && existingConnection?.status !== "needs-auth") continue;
557
+
558
+ let connected = await lazyConnect(state, configuredServer);
559
+ if (!connected && state.manager.getConnection(configuredServer)?.status === "needs-auth" && !autoAuthAttempted) {
560
+ autoAuthAttempted = true;
561
+ const autoAuth = await attemptAutoAuth(state, configuredServer);
562
+ if (autoAuth.status === "failed") {
563
+ return {
564
+ content: [{ type: "text" as const, text: autoAuth.message }],
565
+ details: { mode: "call", error: "auth_required", server: configuredServer, message: autoAuth.message },
566
+ };
567
+ }
568
+ if (autoAuth.status === "success") {
569
+ await state.manager.close(configuredServer);
570
+ state.failureTracker.delete(configuredServer);
571
+ connected = await lazyConnect(state, configuredServer);
572
+ }
573
+ }
574
+
468
575
  if (!connected) continue;
469
576
  if (!prefixMatchedServer) prefixMatchedServer = configuredServer;
470
577
  toolMeta = findToolByName(state.toolMetadata.get(configuredServer), toolName);
@@ -492,10 +599,29 @@ export async function executeCall(
492
599
 
493
600
  let connection = state.manager.getConnection(serverName);
494
601
  if (connection?.status === "needs-auth") {
495
- return {
496
- content: [{ type: "text" as const, text: `Server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} first.` }],
497
- details: { mode: "call", error: "auth_required", server: serverName },
498
- };
602
+ if (!autoAuthAttempted) {
603
+ autoAuthAttempted = true;
604
+ const autoAuth = await attemptAutoAuth(state, serverName);
605
+ if (autoAuth.status === "failed") {
606
+ return {
607
+ content: [{ type: "text" as const, text: autoAuth.message }],
608
+ details: { mode: "call", error: "auth_required", server: serverName, message: autoAuth.message },
609
+ };
610
+ }
611
+ if (autoAuth.status === "success") {
612
+ await state.manager.close(serverName);
613
+ state.failureTracker.delete(serverName);
614
+ connection = state.manager.getConnection(serverName);
615
+ }
616
+ }
617
+
618
+ if (connection?.status === "needs-auth") {
619
+ const message = getAuthRequiredMessage(serverName);
620
+ return {
621
+ content: [{ type: "text" as const, text: message }],
622
+ details: { mode: "call", error: "auth_required", server: serverName, message },
623
+ };
624
+ }
499
625
  }
500
626
  if (!connection || connection.status !== "connected") {
501
627
  const failedAgo = getFailureAgeSeconds(state, serverName);
@@ -520,10 +646,28 @@ export async function executeCall(
520
646
  }
521
647
  connection = await state.manager.connect(serverName, definition);
522
648
  if (connection.status === "needs-auth") {
523
- return {
524
- content: [{ type: "text" as const, text: `Server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} first.` }],
525
- details: { mode: "call", error: "auth_required", server: serverName },
526
- };
649
+ if (!autoAuthAttempted) {
650
+ autoAuthAttempted = true;
651
+ const autoAuth = await attemptAutoAuth(state, serverName);
652
+ if (autoAuth.status === "failed") {
653
+ return {
654
+ content: [{ type: "text" as const, text: autoAuth.message }],
655
+ details: { mode: "call", error: "auth_required", server: serverName, message: autoAuth.message },
656
+ };
657
+ }
658
+ if (autoAuth.status === "success") {
659
+ await state.manager.close(serverName);
660
+ connection = await state.manager.connect(serverName, definition);
661
+ }
662
+ }
663
+
664
+ if (connection.status === "needs-auth") {
665
+ const message = getAuthRequiredMessage(serverName);
666
+ return {
667
+ content: [{ type: "text" as const, text: message }],
668
+ details: { mode: "call", error: "auth_required", server: serverName, message },
669
+ };
670
+ }
527
671
  }
528
672
  state.failureTracker.delete(serverName);
529
673
  updateServerMetadata(state, serverName);
package/tool-metadata.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
2
2
  import type { McpExtensionState } from "./state.js";
3
3
  import type { ToolMetadata, McpTool, McpResource, ServerEntry } from "./types.js";
4
- import { formatToolName } from "./types.js";
4
+ import { formatToolName, isToolExcluded } from "./types.js";
5
5
  import { resourceNameToToolName } from "./resource-tools.js";
6
6
  import { extractToolUiStreamMode } from "./utils.js";
7
7
 
@@ -20,6 +20,10 @@ export function buildToolMetadata(
20
20
  failedTools.push("(unnamed)");
21
21
  continue;
22
22
  }
23
+ if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) {
24
+ continue;
25
+ }
26
+
23
27
  let uiResourceUri: string | undefined;
24
28
  try {
25
29
  uiResourceUri = getToolUiResourceUri({ _meta: tool._meta });
@@ -39,6 +43,10 @@ export function buildToolMetadata(
39
43
  if (definition.exposeResources !== false) {
40
44
  for (const resource of resources) {
41
45
  const baseName = `get_${resourceNameToToolName(resource.name)}`;
46
+ if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) {
47
+ continue;
48
+ }
49
+
42
50
  metadata.push({
43
51
  name: formatToolName(baseName, serverName, prefix),
44
52
  originalName: baseName,
package/types.ts CHANGED
@@ -305,6 +305,8 @@ export interface ServerEntry {
305
305
  exposeResources?: boolean;
306
306
  // Direct tool registration
307
307
  directTools?: boolean | string[];
308
+ // Exclude specific MCP tools/resources by original or prefixed name
309
+ excludeTools?: string[];
308
310
  // Debug
309
311
  debug?: boolean; // Show server stderr (default: false)
310
312
  }
@@ -314,6 +316,8 @@ export interface McpSettings {
314
316
  toolPrefix?: "server" | "none" | "short";
315
317
  idleTimeout?: number; // minutes, default 10, 0 to disable
316
318
  directTools?: boolean;
319
+ disableProxyTool?: boolean;
320
+ autoAuth?: boolean;
317
321
  }
318
322
 
319
323
  // Root config
@@ -391,3 +395,32 @@ export function formatToolName(
391
395
  const p = getServerPrefix(serverName, prefix);
392
396
  return p ? `${p}_${toolName}` : toolName;
393
397
  }
398
+
399
+ function normalizeToolName(value: string): string {
400
+ return value.replace(/-/g, "_");
401
+ }
402
+
403
+ export function isToolExcluded(
404
+ toolName: string,
405
+ serverName: string,
406
+ prefix: "server" | "none" | "short",
407
+ excludeTools?: unknown
408
+ ): boolean {
409
+ if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false;
410
+
411
+ const candidates = new Set<string>([
412
+ normalizeToolName(toolName),
413
+ normalizeToolName(formatToolName(toolName, serverName, prefix)),
414
+ normalizeToolName(formatToolName(toolName, serverName, "server")),
415
+ normalizeToolName(formatToolName(toolName, serverName, "short")),
416
+ ]);
417
+
418
+ for (const excluded of excludeTools) {
419
+ if (typeof excluded !== "string") continue;
420
+ if (candidates.has(normalizeToolName(excluded))) {
421
+ return true;
422
+ }
423
+ }
424
+
425
+ return false;
426
+ }