pi-mcp-adapter 2.0.1 → 2.1.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 +11 -1
- package/README.md +62 -1
- package/config.ts +100 -3
- package/index.ts +373 -15
- package/mcp-panel.ts +727 -0
- package/package.json +2 -1
- package/types.ts +29 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,17 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [2.1.0] - 2026-02-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Direct tool registration** - Promote specific MCP tools to first-class Pi tools via `directTools` config (per-server or global). Direct tools appear in the agent's tool list alongside builtins, so the LLM uses them without needing to search through the proxy first. Registers from cached metadata at startup — no server connections needed.
|
|
12
|
+
- **`/mcp` interactive panel** - New TUI overlay replacing the text-based status dump. Shows server connection status, tool lists with direct/proxy toggles, token cost estimates, inline reconnect, and auth notices. Changes written to config on save.
|
|
13
|
+
- **Auto-enriched proxy description** - The `mcp` proxy tool description now includes server names and tool counts from the metadata cache, so the LLM knows what's available without a search call (~30 extra tokens).
|
|
14
|
+
- **`MCP_DIRECT_TOOLS` env var** - Subagent processes receive their direct tool configuration via environment variable, keeping subagents lean by default.
|
|
15
|
+
- **First-run bootstrap** - Servers with `directTools` configured but no cache entry are connected during `session_start` to populate the cache. Direct tools become available after restart.
|
|
16
|
+
- Config provenance tracking for correct write-back to user/project/import sources
|
|
17
|
+
- Builtin name collision guard (skips direct tools that would shadow `read`, `write`, etc.)
|
|
18
|
+
- Cross-server name deduplication for `prefix: "none"` and `prefix: "short"` modes
|
|
9
19
|
|
|
10
20
|
## [2.0.1] - 2026-02-01
|
|
11
21
|
|
package/README.md
CHANGED
|
@@ -89,6 +89,7 @@ Two calls instead of 26 tools cluttering the context.
|
|
|
89
89
|
| `lifecycle` | `"lazy"` (default), `"eager"`, or `"keep-alive"` |
|
|
90
90
|
| `idleTimeout` | Minutes before idle disconnect (overrides global) |
|
|
91
91
|
| `exposeResources` | Expose MCP resources as tools (default: true) |
|
|
92
|
+
| `directTools` | `true`, `string[]`, or `false` — register tools individually instead of through proxy |
|
|
92
93
|
| `debug` | Show server stderr (default: false) |
|
|
93
94
|
|
|
94
95
|
### Lifecycle Modes
|
|
@@ -113,9 +114,68 @@ Two calls instead of 26 tools cluttering the context.
|
|
|
113
114
|
|---------|-------------|
|
|
114
115
|
| `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), or `"none"` |
|
|
115
116
|
| `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
|
|
117
|
+
| `directTools` | Global default for all servers (default: false). Per-server overrides this. |
|
|
116
118
|
|
|
117
119
|
Per-server `idleTimeout` overrides the global setting.
|
|
118
120
|
|
|
121
|
+
### Direct Tools
|
|
122
|
+
|
|
123
|
+
By default, all MCP tools are accessed through the single `mcp` proxy tool. This keeps context small but means the LLM has to discover tools via search. If you want specific tools to show up directly in the agent's tool list — alongside `read`, `bash`, `edit`, etc. — add `directTools` to your config.
|
|
124
|
+
|
|
125
|
+
Per-server:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"mcpServers": {
|
|
130
|
+
"chrome-devtools": {
|
|
131
|
+
"command": "npx",
|
|
132
|
+
"args": ["-y", "chrome-devtools-mcp@latest"],
|
|
133
|
+
"directTools": true
|
|
134
|
+
},
|
|
135
|
+
"github": {
|
|
136
|
+
"command": "npx",
|
|
137
|
+
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
138
|
+
"directTools": ["search_repositories", "get_file_contents"]
|
|
139
|
+
},
|
|
140
|
+
"huge-server": {
|
|
141
|
+
"command": "npx",
|
|
142
|
+
"args": ["-y", "mega-mcp@latest"]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
| Value | Behavior |
|
|
149
|
+
|-------|----------|
|
|
150
|
+
| `true` | Register all tools from this server as individual Pi tools |
|
|
151
|
+
| `["tool_a", "tool_b"]` | Register only these tools (use original MCP names) |
|
|
152
|
+
| Omitted or `false` | Proxy only (default) |
|
|
153
|
+
|
|
154
|
+
To set a global default for all servers:
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"settings": {
|
|
159
|
+
"directTools": true
|
|
160
|
+
},
|
|
161
|
+
"mcpServers": {
|
|
162
|
+
"huge-server": {
|
|
163
|
+
"directTools": false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Per-server `directTools` overrides the global setting. The example above registers direct tools for every server except `huge-server`.
|
|
170
|
+
|
|
171
|
+
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[]`.
|
|
172
|
+
|
|
173
|
+
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.
|
|
174
|
+
|
|
175
|
+
**Interactive configuration:** Run `/mcp` to open an interactive panel showing all servers with connection status, tools, and direct/proxy toggles. You can reconnect servers, initiate OAuth, and toggle tools between direct and proxy — all from one overlay. Changes are written to your config file; restart Pi to apply.
|
|
176
|
+
|
|
177
|
+
**Subagent integration:** If you use the subagent extension, agents can request direct MCP tools in their frontmatter with `mcp:server-name` syntax. See the subagent README for details.
|
|
178
|
+
|
|
119
179
|
### Import Existing Configs
|
|
120
180
|
|
|
121
181
|
Already have MCP set up elsewhere? Import it:
|
|
@@ -152,7 +212,7 @@ Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_li
|
|
|
152
212
|
|
|
153
213
|
| Command | What it does |
|
|
154
214
|
|---------|--------------|
|
|
155
|
-
| `/mcp` |
|
|
215
|
+
| `/mcp` | Interactive panel (server status, tool toggles, reconnect) |
|
|
156
216
|
| `/mcp tools` | List all tools |
|
|
157
217
|
| `/mcp reconnect` | Reconnect all servers |
|
|
158
218
|
| `/mcp reconnect <server>` | Connect or reconnect a single server |
|
|
@@ -169,6 +229,7 @@ See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full picture. Short version:
|
|
|
169
229
|
- npx-based servers resolve to direct binary paths, skipping the ~143 MB npm parent process
|
|
170
230
|
- MCP server validates arguments, not the adapter
|
|
171
231
|
- Keep-alive servers get health checks and auto-reconnect
|
|
232
|
+
- Specific tools can be promoted from the proxy to first-class Pi tools via `directTools` config, so the LLM sees them directly instead of having to search
|
|
172
233
|
|
|
173
234
|
## Limitations
|
|
174
235
|
|
package/config.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// config.ts - Config loading with import support
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
5
|
-
import type { McpConfig, ServerEntry, McpSettings, ImportKind } from "./types.js";
|
|
4
|
+
import { join, resolve, dirname } from "node:path";
|
|
5
|
+
import type { McpConfig, ServerEntry, McpSettings, ImportKind, ServerProvenance } from "./types.js";
|
|
6
6
|
|
|
7
7
|
const DEFAULT_CONFIG_PATH = join(homedir(), ".pi", "agent", "mcp.json");
|
|
8
8
|
const PROJECT_CONFIG_NAME = ".pi/mcp.json";
|
|
@@ -127,3 +127,100 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
|
|
|
127
127
|
|
|
128
128
|
return servers as Record<string, ServerEntry>;
|
|
129
129
|
}
|
|
130
|
+
|
|
131
|
+
export function getServerProvenance(overridePath?: string): Map<string, ServerProvenance> {
|
|
132
|
+
const provenance = new Map<string, ServerProvenance>();
|
|
133
|
+
const userPath = overridePath ? resolve(overridePath) : DEFAULT_CONFIG_PATH;
|
|
134
|
+
|
|
135
|
+
let userConfig: McpConfig = { mcpServers: {} };
|
|
136
|
+
if (existsSync(userPath)) {
|
|
137
|
+
try {
|
|
138
|
+
userConfig = validateConfig(JSON.parse(readFileSync(userPath, "utf-8")));
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
for (const name of Object.keys(userConfig.mcpServers)) {
|
|
142
|
+
provenance.set(name, { path: userPath, kind: "user" });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (userConfig.imports?.length) {
|
|
146
|
+
for (const importKind of userConfig.imports) {
|
|
147
|
+
const importPath = IMPORT_PATHS[importKind];
|
|
148
|
+
if (!importPath) continue;
|
|
149
|
+
const fullPath = importPath.startsWith(".")
|
|
150
|
+
? resolve(process.cwd(), importPath)
|
|
151
|
+
: importPath;
|
|
152
|
+
if (!existsSync(fullPath)) continue;
|
|
153
|
+
try {
|
|
154
|
+
const imported = JSON.parse(readFileSync(fullPath, "utf-8"));
|
|
155
|
+
const servers = extractServers(imported, importKind);
|
|
156
|
+
for (const name of Object.keys(servers)) {
|
|
157
|
+
if (!provenance.has(name)) {
|
|
158
|
+
provenance.set(name, { path: userPath, kind: "import", importKind });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const projectPath = resolve(process.cwd(), PROJECT_CONFIG_NAME);
|
|
166
|
+
if (existsSync(projectPath) && projectPath !== userPath) {
|
|
167
|
+
try {
|
|
168
|
+
const projectConfig = validateConfig(JSON.parse(readFileSync(projectPath, "utf-8")));
|
|
169
|
+
for (const name of Object.keys(projectConfig.mcpServers)) {
|
|
170
|
+
provenance.set(name, { path: projectPath, kind: "project" });
|
|
171
|
+
}
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return provenance;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function writeDirectToolsConfig(
|
|
179
|
+
changes: Map<string, true | string[] | false>,
|
|
180
|
+
provenance: Map<string, ServerProvenance>,
|
|
181
|
+
fullConfig: McpConfig,
|
|
182
|
+
): void {
|
|
183
|
+
const byPath = new Map<string, { name: string; value: true | string[] | false; prov: ServerProvenance }[]>();
|
|
184
|
+
|
|
185
|
+
for (const [serverName, value] of changes) {
|
|
186
|
+
const prov = provenance.get(serverName);
|
|
187
|
+
if (!prov) continue;
|
|
188
|
+
|
|
189
|
+
const targetPath = prov.path;
|
|
190
|
+
|
|
191
|
+
if (!byPath.has(targetPath)) byPath.set(targetPath, []);
|
|
192
|
+
byPath.get(targetPath)!.push({ name: serverName, value, prov });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const [filePath, entries] of byPath) {
|
|
196
|
+
let raw: Record<string, unknown> = {};
|
|
197
|
+
if (existsSync(filePath)) {
|
|
198
|
+
try {
|
|
199
|
+
raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
if (!raw || typeof raw !== "object") raw = {};
|
|
203
|
+
|
|
204
|
+
const servers = (raw.mcpServers ?? raw["mcp-servers"] ?? {}) as Record<string, ServerEntry>;
|
|
205
|
+
if (typeof servers !== "object" || Array.isArray(servers)) continue;
|
|
206
|
+
|
|
207
|
+
for (const { name, value, prov } of entries) {
|
|
208
|
+
if (prov.kind === "import") {
|
|
209
|
+
const fullDef = fullConfig.mcpServers[name];
|
|
210
|
+
if (fullDef) {
|
|
211
|
+
servers[name] = { ...fullDef, directTools: value };
|
|
212
|
+
}
|
|
213
|
+
} else if (servers[name]) {
|
|
214
|
+
servers[name] = { ...servers[name], directTools: value };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const key = raw["mcp-servers"] && !raw.mcpServers ? "mcp-servers" : "mcpServers";
|
|
219
|
+
raw[key] = servers;
|
|
220
|
+
|
|
221
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
222
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
223
|
+
writeFileSync(tmpPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
|
|
224
|
+
renameSync(tmpPath, filePath);
|
|
225
|
+
}
|
|
226
|
+
}
|
package/index.ts
CHANGED
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-coding-agent";
|
|
3
3
|
import { Type } from "@sinclair/typebox";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
import { loadMcpConfig } from "./config.js";
|
|
6
|
-
import { formatToolName, getServerPrefix, type McpConfig, type McpContent, type ToolMetadata, type McpTool, type McpResource, type ServerEntry } from "./types.js";
|
|
5
|
+
import { loadMcpConfig, getServerProvenance, writeDirectToolsConfig } from "./config.js";
|
|
6
|
+
import { formatToolName, getServerPrefix, type McpConfig, type McpContent, type ToolMetadata, type McpTool, type McpResource, type ServerEntry, type DirectToolSpec, type McpPanelCallbacks, type McpPanelResult } from "./types.js";
|
|
7
7
|
import { McpServerManager } from "./server-manager.js";
|
|
8
8
|
import { McpLifecycleManager } from "./lifecycle.js";
|
|
9
9
|
import { transformMcpContent } from "./tool-registrar.js";
|
|
10
10
|
import { resourceNameToToolName } from "./resource-tools.js";
|
|
11
|
+
import { getStoredTokens } from "./oauth-handler.js";
|
|
11
12
|
import {
|
|
12
13
|
computeServerHash,
|
|
13
14
|
getMetadataCachePath,
|
|
14
15
|
isServerCacheValid,
|
|
15
16
|
loadMetadataCache,
|
|
17
|
+
type MetadataCache,
|
|
16
18
|
reconstructToolMetadata,
|
|
17
19
|
saveMetadataCache,
|
|
18
20
|
serializeResources,
|
|
@@ -66,10 +68,284 @@ async function parallelLimit<T, R>(
|
|
|
66
68
|
return results;
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
|
|
72
|
+
|
|
73
|
+
function getConfigPathFromArgv(): string | undefined {
|
|
74
|
+
const idx = process.argv.indexOf("--mcp-config");
|
|
75
|
+
if (idx >= 0 && idx + 1 < process.argv.length) {
|
|
76
|
+
return process.argv[idx + 1];
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveDirectTools(
|
|
82
|
+
config: McpConfig,
|
|
83
|
+
cache: MetadataCache | null,
|
|
84
|
+
prefix: "server" | "none" | "short",
|
|
85
|
+
envOverride?: string[],
|
|
86
|
+
): DirectToolSpec[] {
|
|
87
|
+
const specs: DirectToolSpec[] = [];
|
|
88
|
+
if (!cache) return specs;
|
|
89
|
+
|
|
90
|
+
const seenNames = new Set<string>();
|
|
91
|
+
|
|
92
|
+
const envServers = new Set<string>();
|
|
93
|
+
const envTools = new Map<string, Set<string>>();
|
|
94
|
+
if (envOverride) {
|
|
95
|
+
for (let item of envOverride) {
|
|
96
|
+
item = item.replace(/\/+$/, "");
|
|
97
|
+
if (item.includes("/")) {
|
|
98
|
+
const [server, tool] = item.split("/", 2);
|
|
99
|
+
if (server && tool) {
|
|
100
|
+
if (!envTools.has(server)) envTools.set(server, new Set());
|
|
101
|
+
envTools.get(server)!.add(tool);
|
|
102
|
+
} else if (server) {
|
|
103
|
+
envServers.add(server);
|
|
104
|
+
}
|
|
105
|
+
} else if (item) {
|
|
106
|
+
envServers.add(item);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const globalDirect = config.settings?.directTools;
|
|
112
|
+
|
|
113
|
+
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
|
114
|
+
const serverCache = cache.servers[serverName];
|
|
115
|
+
if (!serverCache || !isServerCacheValid(serverCache, definition)) continue;
|
|
116
|
+
|
|
117
|
+
let toolFilter: true | string[] | false = false;
|
|
118
|
+
|
|
119
|
+
if (envOverride) {
|
|
120
|
+
if (envServers.has(serverName)) {
|
|
121
|
+
toolFilter = true;
|
|
122
|
+
} else if (envTools.has(serverName)) {
|
|
123
|
+
toolFilter = [...envTools.get(serverName)!];
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
if (definition.directTools !== undefined) {
|
|
127
|
+
toolFilter = definition.directTools;
|
|
128
|
+
} else if (globalDirect) {
|
|
129
|
+
toolFilter = globalDirect;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!toolFilter) continue;
|
|
134
|
+
|
|
135
|
+
for (const tool of serverCache.tools ?? []) {
|
|
136
|
+
if (toolFilter !== true && !toolFilter.includes(tool.name)) continue;
|
|
137
|
+
const prefixedName = formatToolName(tool.name, serverName, prefix);
|
|
138
|
+
if (BUILTIN_NAMES.has(prefixedName)) {
|
|
139
|
+
console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (seenNames.has(prefixedName)) {
|
|
143
|
+
console.warn(`MCP: skipping duplicate direct tool "${prefixedName}" from "${serverName}"`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
seenNames.add(prefixedName);
|
|
147
|
+
specs.push({
|
|
148
|
+
serverName,
|
|
149
|
+
originalName: tool.name,
|
|
150
|
+
prefixedName,
|
|
151
|
+
description: tool.description ?? "",
|
|
152
|
+
inputSchema: tool.inputSchema,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (definition.exposeResources !== false) {
|
|
157
|
+
for (const resource of serverCache.resources ?? []) {
|
|
158
|
+
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
|
159
|
+
if (toolFilter !== true && !toolFilter.includes(baseName)) continue;
|
|
160
|
+
const prefixedName = formatToolName(baseName, serverName, prefix);
|
|
161
|
+
if (BUILTIN_NAMES.has(prefixedName)) {
|
|
162
|
+
console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (seenNames.has(prefixedName)) {
|
|
166
|
+
console.warn(`MCP: skipping duplicate direct resource tool "${prefixedName}" from "${serverName}"`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
seenNames.add(prefixedName);
|
|
170
|
+
specs.push({
|
|
171
|
+
serverName,
|
|
172
|
+
originalName: baseName,
|
|
173
|
+
prefixedName,
|
|
174
|
+
description: resource.description ?? `Read resource: ${resource.uri}`,
|
|
175
|
+
resourceUri: resource.uri,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return specs;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildProxyDescription(
|
|
185
|
+
config: McpConfig,
|
|
186
|
+
cache: MetadataCache | null,
|
|
187
|
+
directSpecs: DirectToolSpec[],
|
|
188
|
+
): string {
|
|
189
|
+
let desc = `MCP gateway - connect to MCP servers and call their tools.\n`;
|
|
190
|
+
|
|
191
|
+
const directByServer = new Map<string, number>();
|
|
192
|
+
for (const spec of directSpecs) {
|
|
193
|
+
directByServer.set(spec.serverName, (directByServer.get(spec.serverName) ?? 0) + 1);
|
|
194
|
+
}
|
|
195
|
+
if (directByServer.size > 0) {
|
|
196
|
+
const parts = [...directByServer.entries()].map(
|
|
197
|
+
([server, count]) => `${server} (${count})`,
|
|
198
|
+
);
|
|
199
|
+
desc += `\nDirect tools available (call as normal tools): ${parts.join(", ")}\n`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const serverSummaries: string[] = [];
|
|
203
|
+
for (const serverName of Object.keys(config.mcpServers)) {
|
|
204
|
+
const entry = cache?.servers?.[serverName];
|
|
205
|
+
const definition = config.mcpServers[serverName];
|
|
206
|
+
const toolCount = entry?.tools?.length ?? 0;
|
|
207
|
+
const resourceCount = definition?.exposeResources !== false ? (entry?.resources?.length ?? 0) : 0;
|
|
208
|
+
const totalItems = toolCount + resourceCount;
|
|
209
|
+
if (totalItems === 0) continue;
|
|
210
|
+
const directCount = directByServer.get(serverName) ?? 0;
|
|
211
|
+
const proxyCount = totalItems - directCount;
|
|
212
|
+
if (proxyCount > 0) {
|
|
213
|
+
serverSummaries.push(`${serverName} (${proxyCount} tools)`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (serverSummaries.length > 0) {
|
|
218
|
+
desc += `\nServers: ${serverSummaries.join(", ")}\n`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
desc += `\nUsage:\n`;
|
|
222
|
+
desc += ` mcp({ }) → Show server status\n`;
|
|
223
|
+
desc += ` mcp({ server: "name" }) → List tools from server\n`;
|
|
224
|
+
desc += ` mcp({ search: "query" }) → Search for tools (MCP + pi, space-separated words OR'd)\n`;
|
|
225
|
+
desc += ` mcp({ describe: "tool_name" }) → Show tool details and parameters\n`;
|
|
226
|
+
desc += ` mcp({ connect: "server-name" }) → Connect to a server and refresh metadata\n`;
|
|
227
|
+
desc += ` mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)\n`;
|
|
228
|
+
desc += `\nMode: tool (call) > connect > describe > search > server (list) > nothing (status)`;
|
|
229
|
+
|
|
230
|
+
return desc;
|
|
231
|
+
}
|
|
232
|
+
|
|
69
233
|
export default function mcpAdapter(pi: ExtensionAPI) {
|
|
70
234
|
let state: McpExtensionState | null = null;
|
|
71
235
|
let initPromise: Promise<McpExtensionState> | null = null;
|
|
72
|
-
|
|
236
|
+
|
|
237
|
+
const earlyConfigPath = getConfigPathFromArgv();
|
|
238
|
+
const earlyConfig = loadMcpConfig(earlyConfigPath);
|
|
239
|
+
const earlyCache = loadMetadataCache();
|
|
240
|
+
const prefix = earlyConfig.settings?.toolPrefix ?? "server";
|
|
241
|
+
|
|
242
|
+
const envRaw = process.env.MCP_DIRECT_TOOLS;
|
|
243
|
+
const directSpecs = envRaw === "__none__"
|
|
244
|
+
? []
|
|
245
|
+
: resolveDirectTools(
|
|
246
|
+
earlyConfig,
|
|
247
|
+
earlyCache,
|
|
248
|
+
prefix,
|
|
249
|
+
envRaw?.split(",").map(s => s.trim()).filter(Boolean),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
for (const spec of directSpecs) {
|
|
253
|
+
pi.registerTool({
|
|
254
|
+
name: spec.prefixedName,
|
|
255
|
+
label: `MCP: ${spec.originalName}`,
|
|
256
|
+
description: spec.description || "(no description)",
|
|
257
|
+
parameters: Type.Unsafe<Record<string, unknown>>(spec.inputSchema || { type: "object", properties: {} }),
|
|
258
|
+
async execute(_toolCallId, params) {
|
|
259
|
+
if (!state && initPromise) {
|
|
260
|
+
try { state = await initPromise; } catch {
|
|
261
|
+
return {
|
|
262
|
+
content: [{ type: "text" as const, text: "MCP initialization failed" }],
|
|
263
|
+
details: { error: "init_failed" },
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!state) {
|
|
268
|
+
return {
|
|
269
|
+
content: [{ type: "text" as const, text: "MCP not initialized" }],
|
|
270
|
+
details: { error: "not_initialized" },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const s = state;
|
|
275
|
+
const connected = await lazyConnect(s, spec.serverName);
|
|
276
|
+
if (!connected) {
|
|
277
|
+
const failedAgo = getFailureAgeSeconds(s, spec.serverName);
|
|
278
|
+
return {
|
|
279
|
+
content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not available${failedAgo !== null ? ` (failed ${failedAgo}s ago)` : ""}` }],
|
|
280
|
+
details: { error: "server_unavailable", server: spec.serverName },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const connection = s.manager.getConnection(spec.serverName);
|
|
285
|
+
if (!connection || connection.status !== "connected") {
|
|
286
|
+
return {
|
|
287
|
+
content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not connected` }],
|
|
288
|
+
details: { error: "not_connected", server: spec.serverName },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
s.manager.touch(spec.serverName);
|
|
294
|
+
s.manager.incrementInFlight(spec.serverName);
|
|
295
|
+
|
|
296
|
+
if (spec.resourceUri) {
|
|
297
|
+
const result = await connection.client.readResource({ uri: spec.resourceUri });
|
|
298
|
+
const content = (result.contents ?? []).map(c => ({
|
|
299
|
+
type: "text" as const,
|
|
300
|
+
text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
|
|
301
|
+
}));
|
|
302
|
+
return {
|
|
303
|
+
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
|
|
304
|
+
details: { server: spec.serverName, resourceUri: spec.resourceUri },
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const result = await connection.client.callTool({
|
|
309
|
+
name: spec.originalName,
|
|
310
|
+
arguments: params ?? {},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const mcpContent = (result.content ?? []) as McpContent[];
|
|
314
|
+
const content = transformMcpContent(mcpContent);
|
|
315
|
+
|
|
316
|
+
if (result.isError) {
|
|
317
|
+
let errorText = content.filter(c => c.type === "text").map(c => (c as { text: string }).text).join("\n") || "Tool execution failed";
|
|
318
|
+
if (spec.inputSchema) {
|
|
319
|
+
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
content: [{ type: "text" as const, text: `Error: ${errorText}` }],
|
|
323
|
+
details: { error: "tool_error", server: spec.serverName },
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
|
|
329
|
+
details: { server: spec.serverName, tool: spec.originalName },
|
|
330
|
+
};
|
|
331
|
+
} catch (error) {
|
|
332
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
333
|
+
let errorText = `Failed to call tool: ${message}`;
|
|
334
|
+
if (spec.inputSchema) {
|
|
335
|
+
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
content: [{ type: "text" as const, text: errorText }],
|
|
339
|
+
details: { error: "call_failed", server: spec.serverName },
|
|
340
|
+
};
|
|
341
|
+
} finally {
|
|
342
|
+
s.manager.decrementInFlight(spec.serverName);
|
|
343
|
+
s.manager.touch(spec.serverName);
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
73
349
|
// Capture pi tool accessor (closure) for unified search
|
|
74
350
|
const getPiTools = (): ToolInfo[] => pi.getAllTools();
|
|
75
351
|
|
|
@@ -140,7 +416,11 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
140
416
|
case "status":
|
|
141
417
|
case "":
|
|
142
418
|
default:
|
|
143
|
-
|
|
419
|
+
if (ctx.hasUI) {
|
|
420
|
+
await openMcpPanel(state, pi, ctx, earlyConfigPath);
|
|
421
|
+
} else {
|
|
422
|
+
await showStatus(state, ctx);
|
|
423
|
+
}
|
|
144
424
|
break;
|
|
145
425
|
}
|
|
146
426
|
},
|
|
@@ -178,17 +458,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
178
458
|
pi.registerTool({
|
|
179
459
|
name: "mcp",
|
|
180
460
|
label: "MCP",
|
|
181
|
-
description:
|
|
182
|
-
|
|
183
|
-
Usage:
|
|
184
|
-
mcp({ }) → Show server status
|
|
185
|
-
mcp({ server: "name" }) → List tools from server
|
|
186
|
-
mcp({ search: "query" }) → Search for tools (MCP + pi, space-separated words OR'd)
|
|
187
|
-
mcp({ describe: "tool_name" }) → Show tool details and parameters
|
|
188
|
-
mcp({ connect: "server-name" }) → Connect to a server and refresh metadata
|
|
189
|
-
mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)
|
|
190
|
-
|
|
191
|
-
Mode: tool (call) > connect > describe > search > server (list) > nothing (status)`,
|
|
461
|
+
description: buildProxyDescription(earlyConfig, earlyCache, directSpecs),
|
|
192
462
|
parameters: Type.Object({
|
|
193
463
|
// Call mode
|
|
194
464
|
tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
|
|
@@ -988,6 +1258,45 @@ async function initializeMcp(
|
|
|
988
1258
|
ctx.ui.notify(msg, "info");
|
|
989
1259
|
}
|
|
990
1260
|
|
|
1261
|
+
const envDirect = process.env.MCP_DIRECT_TOOLS;
|
|
1262
|
+
if (envDirect !== "__none__") {
|
|
1263
|
+
const missingCacheServers: string[] = [];
|
|
1264
|
+
const currentCache = loadMetadataCache();
|
|
1265
|
+
for (const [name, definition] of serverEntries) {
|
|
1266
|
+
const hasDirect = definition.directTools !== undefined
|
|
1267
|
+
? !!definition.directTools
|
|
1268
|
+
: !!config.settings?.directTools;
|
|
1269
|
+
if (!hasDirect) continue;
|
|
1270
|
+
const entry = currentCache?.servers?.[name];
|
|
1271
|
+
if (!entry || !isServerCacheValid(entry, definition)) {
|
|
1272
|
+
missingCacheServers.push(name);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (missingCacheServers.length > 0) {
|
|
1277
|
+
const bootstrapResults = await parallelLimit(
|
|
1278
|
+
missingCacheServers.filter(name => !results.some(r => r.name === name && r.connection)),
|
|
1279
|
+
10,
|
|
1280
|
+
async (name) => {
|
|
1281
|
+
const definition = config.mcpServers[name];
|
|
1282
|
+
try {
|
|
1283
|
+
const connection = await manager.connect(name, definition);
|
|
1284
|
+
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
1285
|
+
toolMetadata.set(name, metadata);
|
|
1286
|
+
updateMetadataCache(state, name);
|
|
1287
|
+
return { name, ok: true };
|
|
1288
|
+
} catch {
|
|
1289
|
+
return { name, ok: false };
|
|
1290
|
+
}
|
|
1291
|
+
},
|
|
1292
|
+
);
|
|
1293
|
+
const bootstrapped = bootstrapResults.filter(r => r.ok).map(r => r.name);
|
|
1294
|
+
if (bootstrapped.length > 0 && ctx.hasUI) {
|
|
1295
|
+
ctx.ui.notify(`MCP: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info");
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
991
1300
|
lifecycle.setReconnectCallback((serverName) => {
|
|
992
1301
|
updateServerMetadata(state, serverName);
|
|
993
1302
|
updateMetadataCache(state, serverName);
|
|
@@ -1330,6 +1639,55 @@ async function authenticateServer(
|
|
|
1330
1639
|
);
|
|
1331
1640
|
}
|
|
1332
1641
|
|
|
1642
|
+
async function openMcpPanel(
|
|
1643
|
+
state: McpExtensionState,
|
|
1644
|
+
pi: ExtensionAPI,
|
|
1645
|
+
ctx: ExtensionContext,
|
|
1646
|
+
configOverridePath?: string,
|
|
1647
|
+
): Promise<void> {
|
|
1648
|
+
const config = state.config;
|
|
1649
|
+
const cache = loadMetadataCache();
|
|
1650
|
+
const provenanceMap = getServerProvenance(pi.getFlag("mcp-config") as string | undefined ?? configOverridePath);
|
|
1651
|
+
|
|
1652
|
+
const callbacks: McpPanelCallbacks = {
|
|
1653
|
+
reconnect: async (serverName: string) => {
|
|
1654
|
+
return lazyConnect(state, serverName);
|
|
1655
|
+
},
|
|
1656
|
+
getConnectionStatus: (serverName: string) => {
|
|
1657
|
+
const definition = config.mcpServers[serverName];
|
|
1658
|
+
if (definition?.auth === "oauth" && getStoredTokens(serverName) === undefined) {
|
|
1659
|
+
return "needs-auth";
|
|
1660
|
+
}
|
|
1661
|
+
const connection = state.manager.getConnection(serverName);
|
|
1662
|
+
if (connection?.status === "connected") return "connected";
|
|
1663
|
+
if (getFailureAgeSeconds(state, serverName) !== null) return "failed";
|
|
1664
|
+
return "idle";
|
|
1665
|
+
},
|
|
1666
|
+
refreshCacheAfterReconnect: (serverName: string) => {
|
|
1667
|
+
const freshCache = loadMetadataCache();
|
|
1668
|
+
return freshCache?.servers?.[serverName] ?? null;
|
|
1669
|
+
},
|
|
1670
|
+
};
|
|
1671
|
+
|
|
1672
|
+
const { createMcpPanel } = await import("./mcp-panel.js");
|
|
1673
|
+
|
|
1674
|
+
return new Promise<void>((resolve) => {
|
|
1675
|
+
ctx.ui.custom(
|
|
1676
|
+
(tui, _theme, _keybindings, done) => {
|
|
1677
|
+
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, (result: McpPanelResult) => {
|
|
1678
|
+
if (!result.cancelled && result.changes.size > 0) {
|
|
1679
|
+
writeDirectToolsConfig(result.changes, provenanceMap, config);
|
|
1680
|
+
ctx.ui.notify("Direct tools updated. Restart pi to apply.", "info");
|
|
1681
|
+
}
|
|
1682
|
+
done();
|
|
1683
|
+
resolve();
|
|
1684
|
+
});
|
|
1685
|
+
},
|
|
1686
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: 82 } },
|
|
1687
|
+
);
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1333
1691
|
/**
|
|
1334
1692
|
* Truncate text at word boundary, aiming for target length.
|
|
1335
1693
|
*/
|