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 +9 -0
- package/index.ts +3 -1
- package/mcp-auth.ts +13 -9
- package/oauth-handler.ts +4 -7
- package/package.json +2 -1
- package/proxy-modes.ts +31 -0
- package/tool-result-renderer.ts +96 -0
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.
|
|
6
|
-
* per-server directory structure.
|
|
5
|
+
* and PKCE state for MCP servers.
|
|
7
6
|
*
|
|
8
|
-
* Token storage location: $MCP_OAUTH_DIR
|
|
9
|
-
* otherwise <Pi agent dir>/mcp-oauth
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
19
|
-
* otherwise <Pi agent dir>/mcp-oauth
|
|
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.
|
|
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) {
|
package/tool-result-renderer.ts
CHANGED
|
@@ -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");
|