pi-mcp-adapter 2.6.1 → 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,15 @@ 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
+
10
19
  ## [2.6.1] - 2026-05-13
11
20
 
12
21
  ### Added
package/index.ts CHANGED
@@ -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
  }
@@ -252,6 +253,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
252
253
  label: "MCP",
253
254
  description: buildProxyDescription(earlyConfig, earlyCache, directSpecs),
254
255
  promptSnippet: "MCP gateway - connect to MCP servers and call their tools",
256
+ renderCall: renderMcpProxyToolCall,
255
257
  parameters: Type.Object({
256
258
  tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
257
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.1",
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");