pi-mcp-adapter 2.6.0 → 2.7.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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.7.0] - 2026-05-22
11
+
12
+ ### Added
13
+ - Added TUI call rendering for MCP proxy and direct tool inputs. Thanks @dmmulroy for PR #102.
14
+
15
+ ### Fixed
16
+ - Hardened OAuth credential storage paths against server-name path traversal without rejecting valid configured server names.
17
+ - Rejected unsafe regex-mode MCP search patterns before executing them.
18
+
19
+ ## [2.6.1] - 2026-05-13
20
+
21
+ ### Added
22
+ - Added `/mcp logout <server>` to clear stored OAuth credentials and disconnect the server. Thanks @mattzcarey for PR #96.
23
+
24
+ ### Fixed
25
+ - Cancel pending OAuth callbacks when logging out of an MCP server.
26
+
10
27
  ## [2.6.0] - 2026-05-10
11
28
 
12
29
  ### Added
package/README.md CHANGED
@@ -347,6 +347,7 @@ Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_li
347
347
  | `/mcp tools` | List all tools |
348
348
  | `/mcp reconnect` | Reconnect all servers |
349
349
  | `/mcp reconnect <server>` | Connect or reconnect a single server |
350
+ | `/mcp logout <server>` | Clear stored OAuth credentials for a server and disconnect it |
350
351
  | `/mcp-auth` | Open an OAuth server picker in interactive UI sessions |
351
352
  | `/mcp-auth <server>` | OAuth setup for a specific server |
352
353
 
package/commands.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  import { lazyConnect, updateMetadataCache, updateStatusBar, getFailureAgeSeconds } from "./init.ts";
16
16
  import { loadMetadataCache } from "./metadata-cache.ts";
17
17
  import { buildToolMetadata } from "./tool-metadata.ts";
18
- import { supportsOAuth, authenticate } from "./mcp-auth-flow.ts";
18
+ import { supportsOAuth, authenticate, removeAuth } from "./mcp-auth-flow.ts";
19
19
  import { getAuthForUrl } from "./mcp-auth.ts";
20
20
  import { loadOnboardingState, markSetupCompleted as persistSetupCompleted, markSharedConfigHintShown } from "./onboarding-state.ts";
21
21
  import { openPath } from "./utils.ts";
@@ -192,6 +192,27 @@ export async function authenticateServer(
192
192
  }
193
193
  }
194
194
 
195
+ export async function logoutServer(
196
+ serverName: string,
197
+ state: McpExtensionState,
198
+ ctx: ExtensionContext
199
+ ): Promise<{ ok: boolean; message: string }> {
200
+ const definition = state.config.mcpServers[serverName];
201
+ if (!definition) {
202
+ const message = `Server "${serverName}" not found in config`;
203
+ if (ctx.hasUI) ctx.ui.notify(message, "error");
204
+ return { ok: false, message };
205
+ }
206
+
207
+ await removeAuth(serverName);
208
+ await state.manager.close(serverName);
209
+ updateStatusBar(state);
210
+
211
+ const message = `OAuth credentials cleared for "${serverName}". Run /mcp-auth ${serverName} to authenticate again.`;
212
+ if (ctx.hasUI) ctx.ui.notify(message, "info");
213
+ return { ok: true, message };
214
+ }
215
+
195
216
  export interface PanelFlowResult {
196
217
  configChanged: boolean;
197
218
  }
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ToolInfo } from "@earendil-works/pi-coding-agent";
2
2
  import type { McpExtensionState } from "./state.ts";
3
3
  import { Type } from "typebox";
4
- import { showStatus, showTools, reconnectServers, authenticateServer, openMcpAuthPanel, openMcpPanel, openMcpSetup } from "./commands.ts";
4
+ import { showStatus, showTools, reconnectServers, authenticateServer, logoutServer, openMcpAuthPanel, openMcpPanel, openMcpSetup } from "./commands.ts";
5
5
  import { loadMcpConfig } from "./config.ts";
6
6
  import { buildProxyDescription, createDirectToolExecutor, getMissingConfiguredDirectToolServers, resolveDirectTools } from "./direct-tools.ts";
7
7
  import { flushMetadataCache, initializeMcp, updateStatusBar } from "./init.ts";
@@ -9,7 +9,7 @@ import { loadMetadataCache } from "./metadata-cache.ts";
9
9
  import { executeCall, executeConnect, executeDescribe, executeList, executeSearch, executeStatus, executeUiMessages } from "./proxy-modes.ts";
10
10
  import { getConfigPathFromArgv, truncateAtWord } from "./utils.ts";
11
11
  import { initializeOAuth, shutdownOAuth } from "./mcp-auth-flow.ts";
12
- import { renderMcpToolResult } from "./tool-result-renderer.ts";
12
+ import { createMcpDirectToolCallRenderer, renderMcpProxyToolCall, renderMcpToolResult } from "./tool-result-renderer.ts";
13
13
 
14
14
  export default function mcpAdapter(pi: ExtensionAPI) {
15
15
  let state: McpExtensionState | null = null;
@@ -74,6 +74,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
74
74
  promptSnippet: truncateAtWord(spec.description, 100) || `MCP tool from ${spec.serverName}`,
75
75
  parameters: Type.Unsafe((spec.inputSchema || { type: "object", properties: {} }) as never),
76
76
  execute: createDirectToolExecutor(() => state, () => initPromise, spec),
77
+ renderCall: createMcpDirectToolCallRenderer(spec.prefixedName),
77
78
  renderResult: renderMcpToolResult,
78
79
  });
79
80
  }
@@ -172,6 +173,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
172
173
  const parts = args?.trim()?.split(/\s+/) ?? [];
173
174
  const subcommand = parts[0] ?? "";
174
175
  const targetServer = parts[1];
176
+ const rest = parts.slice(1).join(" ");
175
177
 
176
178
  switch (subcommand) {
177
179
  case "reconnect":
@@ -188,6 +190,15 @@ export default function mcpAdapter(pi: ExtensionAPI) {
188
190
  }
189
191
  break;
190
192
  }
193
+ case "logout": {
194
+ const serverName = rest;
195
+ if (!serverName) {
196
+ if (ctx.hasUI) ctx.ui.notify("Usage: /mcp logout <server>", "error");
197
+ return;
198
+ }
199
+ await logoutServer(serverName, state, ctx);
200
+ break;
201
+ }
191
202
  case "status":
192
203
  case "":
193
204
  default:
@@ -242,6 +253,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
242
253
  label: "MCP",
243
254
  description: buildProxyDescription(earlyConfig, earlyCache, directSpecs),
244
255
  promptSnippet: "MCP gateway - connect to MCP servers and call their tools",
256
+ renderCall: renderMcpProxyToolCall,
245
257
  parameters: Type.Object({
246
258
  tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
247
259
  args: Type.Optional(Type.String({ description: "Arguments as JSON string (e.g., '{\"key\": \"value\"}')" })),
package/mcp-auth.ts CHANGED
@@ -2,13 +2,13 @@
2
2
  * MCP Auth Storage Module
3
3
  *
4
4
  * Handles secure storage of OAuth credentials, tokens, client information,
5
- * and PKCE state for MCP servers. Maintains backward compatibility with
6
- * per-server directory structure.
5
+ * and PKCE state for MCP servers.
7
6
  *
8
- * Token storage location: $MCP_OAUTH_DIR/<server>/tokens.json when set,
9
- * otherwise <Pi agent dir>/mcp-oauth/<server>/tokens.json
7
+ * Token storage location: $MCP_OAUTH_DIR/sha256-<server-hash>/tokens.json when set,
8
+ * otherwise <Pi agent dir>/mcp-oauth/sha256-<server-hash>/tokens.json
10
9
  */
11
10
 
11
+ import { createHash } from 'crypto';
12
12
  import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'fs';
13
13
  import { join } from 'path';
14
14
  import { getAgentPath } from './agent-dir.ts';
@@ -48,13 +48,17 @@ function getAuthBaseDir(): string {
48
48
  * Get the server-specific directory path.
49
49
  */
50
50
  function getServerDir(serverName: string): string {
51
- return join(getAuthBaseDir(), serverName);
51
+ if (typeof serverName !== 'string') {
52
+ throw new Error(`Invalid MCP server name: ${JSON.stringify(serverName)}`);
53
+ }
54
+ const storageKey = createHash('sha256').update(serverName, 'utf8').digest('hex');
55
+ return join(getAuthBaseDir(), `sha256-${storageKey}`);
52
56
  }
53
57
 
54
58
  /**
55
59
  * Get the tokens file path for a server.
56
60
  */
57
- function getTokensFilePath(serverName: string): string {
61
+ export function getAuthEntryFilePath(serverName: string): string {
58
62
  return join(getServerDir(serverName), 'tokens.json');
59
63
  }
60
64
 
@@ -73,8 +77,8 @@ function ensureServerDir(serverName: string): void {
73
77
  * Returns undefined if file doesn't exist.
74
78
  */
75
79
  function readAuthEntry(serverName: string): AuthEntry | undefined {
80
+ const filePath = getAuthEntryFilePath(serverName);
76
81
  try {
77
- const filePath = getTokensFilePath(serverName);
78
82
  if (!existsSync(filePath)) {
79
83
  return undefined;
80
84
  }
@@ -91,7 +95,7 @@ function readAuthEntry(serverName: string): AuthEntry | undefined {
91
95
  */
92
96
  function writeAuthEntry(serverName: string, entry: AuthEntry): void {
93
97
  ensureServerDir(serverName);
94
- const filePath = getTokensFilePath(serverName);
98
+ const filePath = getAuthEntryFilePath(serverName);
95
99
  writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 0o600 });
96
100
  }
97
101
 
@@ -136,7 +140,7 @@ export function saveAuthEntry(serverName: string, entry: AuthEntry, serverUrl?:
136
140
  */
137
141
  export function removeAuthEntry(serverName: string): void {
138
142
  try {
139
- const filePath = getTokensFilePath(serverName);
143
+ const filePath = getAuthEntryFilePath(serverName);
140
144
  if (existsSync(filePath)) {
141
145
  writeFileSync(filePath, '{}', { mode: 0o600 });
142
146
  }
package/oauth-handler.ts CHANGED
@@ -1,22 +1,19 @@
1
1
  // oauth-handler.ts - OAuth token management for MCP servers
2
2
  import { existsSync, readFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { getAgentPath } from "./agent-dir.ts";
5
3
  import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
4
+ import { getAuthEntryFilePath } from "./mcp-auth.ts";
6
5
 
7
6
  // Token storage path for a server
8
7
  function getTokensPath(serverName: string): string {
9
- const override = process.env.MCP_OAUTH_DIR?.trim();
10
- const authDir = override ? override : getAgentPath("mcp-oauth");
11
- return join(authDir, serverName, "tokens.json");
8
+ return getAuthEntryFilePath(serverName);
12
9
  }
13
10
 
14
11
  /**
15
12
  * Get stored OAuth tokens for a server (if any).
16
13
  * Returns undefined if no tokens or tokens are expired.
17
14
  *
18
- * Token file location: $MCP_OAUTH_DIR/<server>/tokens.json when set,
19
- * otherwise <Pi agent dir>/mcp-oauth/<server>/tokens.json
15
+ * Token file location: $MCP_OAUTH_DIR/sha256-<server-hash>/tokens.json when set,
16
+ * otherwise <Pi agent dir>/mcp-oauth/sha256-<server-hash>/tokens.json
20
17
  *
21
18
  * Expected format:
22
19
  * {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -85,6 +85,7 @@
85
85
  "@modelcontextprotocol/ext-apps": "^1.2.2",
86
86
  "@modelcontextprotocol/sdk": "^1.25.1",
87
87
  "open": "^10.2.0",
88
+ "recheck": "^4.5.0",
88
89
  "typebox": "^1.1.24",
89
90
  "zod": "^3.25.0 || ^4.0.0"
90
91
  },
package/proxy-modes.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AgentToolResult, ToolInfo } from "@earendil-works/pi-coding-agent";
2
+ import { checkSync } from "recheck";
2
3
  import type { McpExtensionState } from "./state.ts";
3
4
  import type { ToolMetadata, McpContent } from "./types.ts";
4
5
  import { getServerPrefix, parseUiPromptHandoff } from "./types.ts";
@@ -11,6 +12,13 @@ import { authenticate, supportsOAuth } from "./mcp-auth-flow.ts";
11
12
 
12
13
  type ProxyToolResult = AgentToolResult<Record<string, unknown>>;
13
14
 
15
+ const MAX_REGEX_SEARCH_QUERY_LENGTH = 256;
16
+ const REGEX_SAFETY_CHECK_PARAMS = {
17
+ attackTimeout: 50,
18
+ incubationTimeout: 50,
19
+ timeout: 250,
20
+ } as const;
21
+
14
22
  type AutoAuthResult =
15
23
  | { status: "skipped" }
16
24
  | { status: "success" }
@@ -258,7 +266,30 @@ export function executeSearch(
258
266
  let pattern: RegExp;
259
267
  try {
260
268
  if (regex) {
269
+ if (query.length > MAX_REGEX_SEARCH_QUERY_LENGTH) {
270
+ return {
271
+ content: [{ type: "text" as const, text: `Regex query is too long; maximum length is ${MAX_REGEX_SEARCH_QUERY_LENGTH} characters.` }],
272
+ details: { mode: "search", error: "query_too_long", query, maxLength: MAX_REGEX_SEARCH_QUERY_LENGTH },
273
+ };
274
+ }
275
+
261
276
  pattern = new RegExp(query, "i");
277
+ let safety;
278
+ try {
279
+ safety = checkSync(query, "i", REGEX_SAFETY_CHECK_PARAMS);
280
+ } catch (error) {
281
+ const message = error instanceof Error ? error.message : String(error);
282
+ return {
283
+ content: [{ type: "text" as const, text: "Regex query rejected because safety analysis failed." }],
284
+ details: { mode: "search", error: "unsafe_pattern", query, reason: message },
285
+ };
286
+ }
287
+ if (safety.status !== "safe") {
288
+ return {
289
+ content: [{ type: "text" as const, text: `Regex query rejected as unsafe (${safety.status}).` }],
290
+ details: { mode: "search", error: "unsafe_pattern", query, safetyStatus: safety.status },
291
+ };
292
+ }
262
293
  } else {
263
294
  const terms = query.trim().split(/\s+/).filter(t => t.length > 0);
264
295
  if (terms.length === 0) {
@@ -6,6 +6,19 @@ type McpToolContentBlock = AgentToolResult<McpToolResultDetails>["content"][numb
6
6
 
7
7
  interface RenderTheme {
8
8
  fg: (name: string, text: string) => string;
9
+ bold?: (text: string) => string;
10
+ }
11
+
12
+ export interface McpProxyToolCallInput {
13
+ tool?: string;
14
+ args?: string;
15
+ connect?: string;
16
+ describe?: string;
17
+ search?: string;
18
+ regex?: boolean;
19
+ includeSchemas?: boolean;
20
+ server?: string;
21
+ action?: string;
9
22
  }
10
23
 
11
24
  interface McpToolRenderContext {
@@ -17,6 +30,89 @@ export interface McpToolResultDisplay {
17
30
  truncated: boolean;
18
31
  }
19
32
 
33
+ const DEFAULT_MAX_CALL_INPUT_CHARS = 1500;
34
+
35
+ function truncateText(value: string, maxChars: number): string {
36
+ if (value.length <= maxChars) return value;
37
+ return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
38
+ }
39
+
40
+ function formatJsonish(value: unknown, maxChars: number): string {
41
+ if (typeof value === "string") {
42
+ try {
43
+ return truncateText(JSON.stringify(JSON.parse(value), null, 2), maxChars);
44
+ } catch {
45
+ return truncateText(value, maxChars);
46
+ }
47
+ }
48
+
49
+ try {
50
+ return truncateText(JSON.stringify(value, null, 2), maxChars);
51
+ } catch {
52
+ return truncateText(String(value), maxChars);
53
+ }
54
+ }
55
+
56
+ function hasUsefulObjectContent(value: unknown): boolean {
57
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
58
+ }
59
+
60
+ export function formatMcpProxyToolCallLines(
61
+ args: McpProxyToolCallInput,
62
+ maxInputChars = DEFAULT_MAX_CALL_INPUT_CHARS,
63
+ ): string[] {
64
+ if (args.action === "ui-messages") return [`mcp ${args.action}`];
65
+
66
+ if (args.tool) {
67
+ const target = args.server ? `${args.tool} @ ${args.server}` : args.tool;
68
+ const lines = [`mcp call ${target}`];
69
+ if (args.args) lines.push(formatJsonish(args.args, maxInputChars));
70
+ return lines;
71
+ }
72
+
73
+ if (args.connect) return [`mcp connect ${args.connect}`];
74
+ if (args.describe) return [`mcp describe ${args.describe}`];
75
+
76
+ if (args.search) {
77
+ let line = `mcp search ${args.search}`;
78
+ if (args.server) line += ` @ ${args.server}`;
79
+ if (args.regex === true) line += " (regex)";
80
+ if (args.includeSchemas === false) line += " (schemas hidden)";
81
+ return [line];
82
+ }
83
+
84
+ if (args.server) return [`mcp list ${args.server}`];
85
+ if (args.action) return [`mcp ${args.action}`];
86
+
87
+ return ["mcp status"];
88
+ }
89
+
90
+ export function formatMcpDirectToolCallLines(
91
+ displayName: string,
92
+ args: Record<string, unknown>,
93
+ maxInputChars = DEFAULT_MAX_CALL_INPUT_CHARS,
94
+ ): string[] {
95
+ if (!hasUsefulObjectContent(args)) return [displayName];
96
+ return [displayName, formatJsonish(args, maxInputChars)];
97
+ }
98
+
99
+ function renderToolCallLines(lines: string[], theme: RenderTheme) {
100
+ const [title = "mcp", ...rest] = lines;
101
+ const styledTitle = theme.fg("toolTitle", theme.bold ? theme.bold(title) : title);
102
+ const styledRest = rest.map(line => theme.fg("muted", line));
103
+ return new Text([styledTitle, ...styledRest].join("\n"), 0, 0);
104
+ }
105
+
106
+ export function renderMcpProxyToolCall(args: McpProxyToolCallInput, theme: RenderTheme) {
107
+ return renderToolCallLines(formatMcpProxyToolCallLines(args), theme);
108
+ }
109
+
110
+ export function createMcpDirectToolCallRenderer(displayName: string) {
111
+ return (args: Record<string, unknown>, theme: RenderTheme) => {
112
+ return renderToolCallLines(formatMcpDirectToolCallLines(displayName, args), theme);
113
+ };
114
+ }
115
+
20
116
  function blockToLines(block: McpToolContentBlock): string[] {
21
117
  if (block.type === "text") {
22
118
  return block.text.split("\n");