decorated-pi 0.5.5 → 0.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/README.md +69 -71
- package/commands/dp-model.ts +23 -0
- package/commands/dp-settings.ts +28 -0
- package/commands/mcp-status.ts +62 -0
- package/commands/retry.ts +19 -0
- package/commands/usage.ts +544 -0
- package/hooks/compaction.ts +204 -0
- package/hooks/externalize.ts +70 -0
- package/hooks/image-vision.ts +132 -0
- package/hooks/inject-agents-md.ts +164 -0
- package/hooks/mcp.ts +392 -0
- package/hooks/normalize-codeblocks.ts +88 -0
- package/hooks/pi-tool-filter.ts +28 -0
- package/{extensions/safety → hooks/redact}/types.ts +1 -8
- package/hooks/redact.ts +104 -0
- package/{extensions → hooks}/rtk.ts +92 -115
- package/hooks/session-title.ts +78 -0
- package/hooks/skeleton.ts +212 -0
- package/hooks/smart-at.ts +318 -0
- package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
- package/{extensions → hooks}/wakatime.ts +120 -122
- package/index.ts +166 -1
- package/package.json +8 -6
- package/settings.ts +341 -0
- package/tools/ask/index.ts +93 -0
- package/{extensions → tools}/lsp/client.ts +0 -25
- package/{extensions → tools}/lsp/format.ts +2 -99
- package/{extensions → tools}/lsp/index.ts +1 -1
- package/{extensions → tools}/lsp/servers.ts +1 -1
- package/{extensions → tools}/lsp/tools.ts +1 -66
- package/{extensions → tools}/lsp/types.ts +0 -11
- package/tools/mcp/builtin/codegraph.ts +21 -0
- package/tools/mcp/builtin/context7.ts +10 -0
- package/tools/mcp/builtin/exa.ts +10 -0
- package/tools/mcp/builtin/index.ts +19 -0
- package/tools/mcp/cache.ts +124 -0
- package/{extensions → tools}/mcp/client.ts +2 -2
- package/tools/mcp/config.ts +288 -0
- package/tools/mcp/index.ts +44 -0
- package/tools/mcp/tool-definition.ts +112 -0
- package/{extensions/patch.ts → tools/patch/core.ts} +135 -43
- package/{extensions/io.ts → tools/patch/index.ts} +24 -206
- package/tsconfig.json +10 -1
- package/ui/ask.ts +443 -0
- package/ui/mcp-status.ts +202 -0
- package/ui/model-picker.ts +162 -0
- package/ui/module-settings.ts +188 -0
- package/ui/usage.ts +396 -0
- package/extensions/index.ts +0 -154
- package/extensions/io-tool-output.ts +0 -33
- package/extensions/mcp/builtin.ts +0 -376
- package/extensions/mcp/index.ts +0 -435
- package/extensions/model-integration.ts +0 -531
- package/extensions/providers/ark-coding.ts +0 -75
- package/extensions/providers/index.ts +0 -9
- package/extensions/providers/ollama-cloud.ts +0 -101
- package/extensions/providers/qianfan-coding.ts +0 -71
- package/extensions/safety/index.ts +0 -102
- package/extensions/session-title.ts +0 -65
- package/extensions/settings.ts +0 -170
- package/extensions/slash.ts +0 -458
- package/extensions/smart-at.ts +0 -481
- package/extensions/subdir-agents.ts +0 -151
- /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
- /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
- /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
- /package/{extensions → tools}/lsp/env.ts +0 -0
- /package/{extensions → tools}/lsp/manager.ts +0 -0
- /package/{extensions → tools}/lsp/prompt.ts +0 -0
- /package/{extensions → tools}/lsp/protocol.ts +0 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Text } from "@earendil-works/pi-tui";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
|
-
import { LspServerManager,
|
|
7
|
+
import { LspServerManager, formatToolError } from "./manager.js";
|
|
8
8
|
|
|
9
9
|
// ─── TUI rendering ─────────────────────────────────────────────────────────
|
|
10
10
|
|
|
@@ -53,24 +53,6 @@ function err(details: any): ToolResult {
|
|
|
53
53
|
return ok(formatToolError(details), { ok: false, error: details });
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
async function withFile(
|
|
57
|
-
manager: LspServerManager,
|
|
58
|
-
file: string,
|
|
59
|
-
fn: (s: { abs: string; uri: string; state: any }) => Promise<string>,
|
|
60
|
-
options: { timeoutMs?: number } = {},
|
|
61
|
-
): Promise<ToolResult> {
|
|
62
|
-
const resolved = await manager.resolveFileState(file, { timeoutMs: options.timeoutMs });
|
|
63
|
-
if (!resolved.ok) return err(resolved.error);
|
|
64
|
-
const { result } = resolved;
|
|
65
|
-
try {
|
|
66
|
-
const text = await fn(result);
|
|
67
|
-
return ok(text, { ok: true, language: result.state.language, workspace_root: result.state.workspaceRoot });
|
|
68
|
-
} catch (error) {
|
|
69
|
-
if (error instanceof LspToolError) return err(error.details);
|
|
70
|
-
return err({ kind: "tool_execution_failed", file: result.abs, language: result.state.language, workspace_root: result.state.workspaceRoot, command: result.state.command, install_hint: result.state.installHint, message: error instanceof Error ? error.message : String(error) });
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
56
|
function withTimeout(promise: Promise<ToolResult>, ms: number, label: string): Promise<ToolResult> {
|
|
75
57
|
return Promise.race([
|
|
76
58
|
promise,
|
|
@@ -84,25 +66,6 @@ function severityLabel(s: number): string {
|
|
|
84
66
|
return s === 1 ? "error" : s === 2 ? "warning" : s === 3 ? "info" : "hint";
|
|
85
67
|
}
|
|
86
68
|
|
|
87
|
-
function symbolKindLabel(kind: number): string {
|
|
88
|
-
const labels: Record<number, string> = { 2:"module",3:"namespace",5:"class",6:"method",7:"property",8:"field",9:"constructor",11:"interface",12:"function",13:"variable",14:"constant",23:"struct",24:"event" };
|
|
89
|
-
return labels[kind] ?? "symbol";
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function formatDocumentSymbols(file: string, symbols: any[]): string {
|
|
93
|
-
if (symbols.length === 0) return `${file}: no symbols`;
|
|
94
|
-
const lines = [`${file}: ${symbols.length} top-level symbol(s)`];
|
|
95
|
-
const append = (syms: any[], depth: number) => {
|
|
96
|
-
for (const s of syms) {
|
|
97
|
-
const detail = s.detail ? ` — ${s.detail}` : "";
|
|
98
|
-
lines.push(`${" ".repeat(depth)}${symbolKindLabel(s.kind)} ${s.name}${detail} @ ${s.range.start.line + 1}:${s.range.start.character + 1}`);
|
|
99
|
-
if (s.children?.length) append(s.children, depth + 1);
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
append(symbols, 1);
|
|
103
|
-
return lines.join("\n");
|
|
104
|
-
}
|
|
105
|
-
|
|
106
69
|
// ─── Register tools ───────────────────────────────────────────────────────
|
|
107
70
|
|
|
108
71
|
export function registerLspTools(pi: ExtensionAPI, manager: LspServerManager) {
|
|
@@ -156,34 +119,6 @@ export function registerLspTools(pi: ExtensionAPI, manager: LspServerManager) {
|
|
|
156
119
|
})(), totalTimeout, "LSP diagnostics");
|
|
157
120
|
},
|
|
158
121
|
});
|
|
159
|
-
|
|
160
|
-
// ── lsp_document_symbols ───────────────────────────────────────────────
|
|
161
|
-
pi.registerTool({
|
|
162
|
-
name: "lsp_document_symbols",
|
|
163
|
-
label: "LSP: document symbols",
|
|
164
|
-
description: "List symbols in a file (functions, classes, variables) using the language server.",
|
|
165
|
-
promptSnippet: "List functions, classes, and variables in a file",
|
|
166
|
-
promptGuidelines: [
|
|
167
|
-
"Prefer lsp_document_symbols to find functions, classes, or variables in code instead of grep.",
|
|
168
|
-
"Supported languages: typescript, c, cpp, python, rust, go, ruby, java, lua, svelte.",
|
|
169
|
-
],
|
|
170
|
-
renderResult: renderLspResult,
|
|
171
|
-
parameters: Type.Object({
|
|
172
|
-
path: Type.String({ description: "Path to the file (relative or absolute)." }),
|
|
173
|
-
timeout_ms: Type.Optional(Type.Number({ description: "Overall max ms including server startup. Default 10000." })),
|
|
174
|
-
}),
|
|
175
|
-
execute: async (_id, params, _signal, _update, _ctx): Promise<ToolResult> => {
|
|
176
|
-
const timeoutMs = params.timeout_ms ?? 10_000;
|
|
177
|
-
return withTimeout(
|
|
178
|
-
withFile(manager, params.path, async (result) => {
|
|
179
|
-
const symbols = await result.state.client.documentSymbols(result.uri, timeoutMs);
|
|
180
|
-
return formatDocumentSymbols(result.abs, symbols);
|
|
181
|
-
}, { timeoutMs }),
|
|
182
|
-
timeoutMs,
|
|
183
|
-
"LSP document symbols",
|
|
184
|
-
);
|
|
185
|
-
},
|
|
186
|
-
});
|
|
187
122
|
}
|
|
188
123
|
|
|
189
124
|
// ─── Formatting ────────────────────────────────────────────────────────────
|
|
@@ -29,14 +29,3 @@ export interface LspHover {
|
|
|
29
29
|
contents: unknown;
|
|
30
30
|
range?: LspRange;
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
export interface LspDocumentSymbol {
|
|
34
|
-
name: string;
|
|
35
|
-
kind: number;
|
|
36
|
-
range: LspRange;
|
|
37
|
-
selectionRange?: LspRange;
|
|
38
|
-
containerName?: string;
|
|
39
|
-
detail?: string;
|
|
40
|
-
children?: LspDocumentSymbol[];
|
|
41
|
-
uri?: string;
|
|
42
|
-
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codegraph builtin MCP server — config + project artefact check.
|
|
3
|
+
*
|
|
4
|
+
* Enabled state is controlled like any other MCP server: through the
|
|
5
|
+
* MCP config (global `~/.pi/agent/mcp.json` under `mcpServers`, or
|
|
6
|
+
* project `.pi/agent/mcp.json`) or via the `/mcp` command. There is no
|
|
7
|
+
* separate /dp-settings toggle; codegraph is just one MCP server.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import type { McpServerConfig } from "../config.js";
|
|
12
|
+
|
|
13
|
+
export const CODEGRAPH_BUILTIN: Omit<McpServerConfig, "source"> = {
|
|
14
|
+
name: "codegraph",
|
|
15
|
+
command: "codegraph",
|
|
16
|
+
args: ["serve", "--mcp"],
|
|
17
|
+
enabled: false,
|
|
18
|
+
description:
|
|
19
|
+
"Local code knowledge graph (colbymchenry/codegraph). Enable via /mcp.",
|
|
20
|
+
canUseInProject: (cwd: string) => fs.existsSync(path.join(cwd, ".codegraph")),
|
|
21
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builtin MCP servers — aggregate. Each server has its own file; this
|
|
3
|
+
* module re-exports them and the all-server list.
|
|
4
|
+
*/
|
|
5
|
+
import type { McpServerConfig } from "../config.js";
|
|
6
|
+
import { CONTEXT7_BUILTIN } from "./context7.js";
|
|
7
|
+
import { EXA_BUILTIN } from "./exa.js";
|
|
8
|
+
import { CODEGRAPH_BUILTIN } from "./codegraph.js";
|
|
9
|
+
|
|
10
|
+
export { CONTEXT7_BUILTIN } from "./context7.js";
|
|
11
|
+
export { EXA_BUILTIN } from "./exa.js";
|
|
12
|
+
export { CODEGRAPH_BUILTIN } from "./codegraph.js";
|
|
13
|
+
|
|
14
|
+
/** All builtin servers — flat list for `resolveMcpConfigs` to merge. */
|
|
15
|
+
export const BUILTIN_MCP_SERVERS: Omit<McpServerConfig, "source">[] = [
|
|
16
|
+
CONTEXT7_BUILTIN,
|
|
17
|
+
EXA_BUILTIN,
|
|
18
|
+
CODEGRAPH_BUILTIN,
|
|
19
|
+
];
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server metadata cache — persisted tool descriptions and schemas.
|
|
3
|
+
* Stored at `~/.pi/agent/mcp-cache.json` (global) and `<cwd>/.pi/agent/mcp-cache.json` (project).
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import type { McpServerConfig } from "./config.js";
|
|
9
|
+
|
|
10
|
+
export interface McpToolCache {
|
|
11
|
+
name: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
inputSchema: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface McpServerCache {
|
|
17
|
+
description?: string;
|
|
18
|
+
tools: McpToolCache[];
|
|
19
|
+
cachedAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface McpCache {
|
|
23
|
+
servers: Record<string, McpServerCache>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function globalCachePath(): string {
|
|
27
|
+
return path.join(os.homedir(), ".pi/agent/mcp-cache.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function projectCachePath(cwd: string): string {
|
|
31
|
+
return path.join(cwd, ".pi/agent/mcp-cache.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function scopedCachePath(scope: "global" | "project", cwd?: string): string {
|
|
35
|
+
return scope === "project" && cwd ? projectCachePath(cwd) : globalCachePath();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readCacheFile(p: string): McpCache | null {
|
|
39
|
+
try {
|
|
40
|
+
if (!fs.existsSync(p)) return null;
|
|
41
|
+
return JSON.parse(fs.readFileSync(p, "utf-8")) as McpCache;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeCacheFile(p: string, cache: McpCache): void {
|
|
48
|
+
const dir = path.dirname(p);
|
|
49
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
50
|
+
const tmp = `${p}.tmp`;
|
|
51
|
+
fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf-8");
|
|
52
|
+
fs.renameSync(tmp, p);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Load merged cache: global + project. */
|
|
56
|
+
export function loadMcpCache(cwd?: string): McpCache | null {
|
|
57
|
+
const merged: McpCache = { servers: {} };
|
|
58
|
+
|
|
59
|
+
const globalCache = readCacheFile(globalCachePath());
|
|
60
|
+
if (globalCache) {
|
|
61
|
+
merged.servers = { ...merged.servers, ...globalCache.servers };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (cwd) {
|
|
65
|
+
const projectCache = readCacheFile(projectCachePath(cwd));
|
|
66
|
+
if (projectCache) {
|
|
67
|
+
merged.servers = { ...merged.servers, ...projectCache.servers };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return merged;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function loadScopedMcpCache(scope: "global" | "project", cwd?: string): McpCache | null {
|
|
75
|
+
return readCacheFile(scopedCachePath(scope, cwd));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Save cache to global or project scope. */
|
|
79
|
+
export function saveMcpCache(cache: McpCache, scope: "global" | "project", cwd?: string): void {
|
|
80
|
+
writeCacheFile(scopedCachePath(scope, cwd), cache);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Update a single server's entry in the appropriate cache. */
|
|
84
|
+
export function updateServerCache(
|
|
85
|
+
serverName: string,
|
|
86
|
+
entry: McpServerCache,
|
|
87
|
+
scope: "global" | "project",
|
|
88
|
+
cwd?: string,
|
|
89
|
+
): void {
|
|
90
|
+
const p = scopedCachePath(scope, cwd);
|
|
91
|
+
const existing = readCacheFile(p) || { servers: {} };
|
|
92
|
+
existing.servers[serverName] = entry;
|
|
93
|
+
writeCacheFile(p, existing);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cleanupOneCache(p: string, names: Set<string>): void {
|
|
97
|
+
const cache = readCacheFile(p);
|
|
98
|
+
if (!cache) return;
|
|
99
|
+
let changed = false;
|
|
100
|
+
for (const name of Object.keys(cache.servers)) {
|
|
101
|
+
if (!names.has(name)) {
|
|
102
|
+
delete cache.servers[name];
|
|
103
|
+
changed = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (changed) writeCacheFile(p, cache);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function cleanupStaleCache(configs: McpServerConfig[], cwd?: string): void {
|
|
110
|
+
const names = new Set(configs.map(c => c.name));
|
|
111
|
+
cleanupOneCache(globalCachePath(), names);
|
|
112
|
+
if (cwd) {
|
|
113
|
+
const projectCache = projectCachePath(cwd);
|
|
114
|
+
const projectMcpJson = path.join(cwd, ".pi/agent/mcp.json");
|
|
115
|
+
// If project mcp.json doesn't exist, remove project cache entirely
|
|
116
|
+
if (!fs.existsSync(projectMcpJson)) {
|
|
117
|
+
if (fs.existsSync(projectCache)) {
|
|
118
|
+
fs.unlinkSync(projectCache);
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
cleanupOneCache(projectCache, names);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -2,8 +2,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
2
2
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
3
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
4
4
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
5
|
-
import type { McpServerConfig } from "./
|
|
6
|
-
import { isSseUrl } from "./
|
|
5
|
+
import type { McpServerConfig } from "./config.js";
|
|
6
|
+
import { isSseUrl } from "./config.js";
|
|
7
7
|
|
|
8
8
|
export interface McpToolSpec {
|
|
9
9
|
name: string;
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server configuration — type, discovery, resolution, toggle.
|
|
3
|
+
*
|
|
4
|
+
* Sources (in priority order): builtin → global → project. The first
|
|
5
|
+
* hit sets defaults; later sources can override specific fields.
|
|
6
|
+
*
|
|
7
|
+
* Persistence locations:
|
|
8
|
+
* - global: `~/.pi/agent/mcp.json` (under `mcpServers`)
|
|
9
|
+
* - project: `<cwd>/.pi/agent/mcp.json` (under `mcpServers`)
|
|
10
|
+
*
|
|
11
|
+
* Legacy: global MCP servers used to live in `~/.pi/agent/decorated-pi.json`
|
|
12
|
+
* under `mcpServers`. `loadGlobalMcpConfigs()` automatically migrates that
|
|
13
|
+
* data to `~/.pi/agent/mcp.json` on first read.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as os from "node:os";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { isModuleEnabled } from "../../settings.js";
|
|
20
|
+
import type { DependencyStatus } from "../../hooks/skeleton.js";
|
|
21
|
+
import { BUILTIN_MCP_SERVERS } from "./builtin/index.js";
|
|
22
|
+
|
|
23
|
+
export { BUILTIN_MCP_SERVERS } from "./builtin/index.js";
|
|
24
|
+
|
|
25
|
+
export interface McpServerConfig {
|
|
26
|
+
name: string;
|
|
27
|
+
url?: string;
|
|
28
|
+
command?: string;
|
|
29
|
+
args?: string[];
|
|
30
|
+
env?: Record<string, string>;
|
|
31
|
+
description?: string;
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
source: "builtin" | "global" | "project";
|
|
34
|
+
/** Optional predicate: return false if this server cannot be used in the given project. */
|
|
35
|
+
canUseInProject?: (cwd: string) => boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function globalMcpJsonPath(): string {
|
|
39
|
+
return path.join(os.homedir(), ".pi", "agent", "mcp.json");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readMcpJson(filePath: string): Record<string, { url?: string; command?: string; args?: string[]; env?: Record<string, string>; enabled?: boolean; description?: string }> | null {
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
45
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
46
|
+
const servers = raw.mcpServers ?? raw["mcp-servers"];
|
|
47
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) return null;
|
|
48
|
+
return servers as Record<string, { url?: string; command?: string; args?: string[]; env?: Record<string, string>; enabled?: boolean }>;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One-time migration: older versions stored global MCP servers in
|
|
56
|
+
* `~/.pi/agent/decorated-pi.json` under `mcpServers`. Move that data to
|
|
57
|
+
* the dedicated `~/.pi/agent/mcp.json` file so global and project configs
|
|
58
|
+
* are symmetric.
|
|
59
|
+
*
|
|
60
|
+
* Rules:
|
|
61
|
+
* - Only runs when the legacy file has at least one `mcpServers` entry.
|
|
62
|
+
* - For each legacy server name, if `mcp.json` already has an entry with
|
|
63
|
+
* the same name, the legacy entry is skipped (new file wins).
|
|
64
|
+
* - Idempotent: after running once, the legacy file no longer has
|
|
65
|
+
* `mcpServers`, so a second call is a no-op.
|
|
66
|
+
*/
|
|
67
|
+
export function migrateLegacyGlobalMcpConfig(): void {
|
|
68
|
+
const legacyPath = path.join(os.homedir(), ".pi", "agent", "decorated-pi.json");
|
|
69
|
+
const newPath = globalMcpJsonPath();
|
|
70
|
+
|
|
71
|
+
let legacy: Record<string, any> | null = null;
|
|
72
|
+
try {
|
|
73
|
+
legacy = JSON.parse(fs.readFileSync(legacyPath, "utf-8"));
|
|
74
|
+
} catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!legacy || !legacy.mcpServers || typeof legacy.mcpServers !== "object") return;
|
|
78
|
+
if (Object.keys(legacy.mcpServers).length === 0) return;
|
|
79
|
+
|
|
80
|
+
// Load existing new file (if any). If it is corrupt, leave everything
|
|
81
|
+
// alone — safer than clobbering.
|
|
82
|
+
let newConfig: Record<string, any> = { mcpServers: {} };
|
|
83
|
+
if (fs.existsSync(newPath)) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(fs.readFileSync(newPath, "utf-8"));
|
|
86
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
87
|
+
newConfig = parsed;
|
|
88
|
+
if (!newConfig.mcpServers || typeof newConfig.mcpServers !== "object") {
|
|
89
|
+
newConfig.mcpServers = {};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Merge: only add legacy entries that don't already exist in the new file.
|
|
98
|
+
for (const [name, entry] of Object.entries(legacy.mcpServers)) {
|
|
99
|
+
if (!(name in newConfig.mcpServers)) {
|
|
100
|
+
newConfig.mcpServers[name] = entry;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const dir = path.dirname(newPath);
|
|
105
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
106
|
+
fs.writeFileSync(newPath, JSON.stringify(newConfig, null, 2) + "\n", "utf-8");
|
|
107
|
+
|
|
108
|
+
delete legacy.mcpServers;
|
|
109
|
+
fs.writeFileSync(legacyPath, JSON.stringify(legacy, null, 2) + "\n", "utf-8");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Load project-level MCP configs from cwd only. */
|
|
113
|
+
export function loadProjectMcpConfigs(cwd: string): McpServerConfig[] {
|
|
114
|
+
const configs: McpServerConfig[] = [];
|
|
115
|
+
const seen = new Set<string>();
|
|
116
|
+
|
|
117
|
+
const filePath = path.join(cwd, ".pi/agent/mcp.json");
|
|
118
|
+
if (!fs.existsSync(filePath)) return [];
|
|
119
|
+
const servers = readMcpJson(filePath);
|
|
120
|
+
if (!servers) return [];
|
|
121
|
+
|
|
122
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
123
|
+
if (seen.has(name)) continue;
|
|
124
|
+
seen.add(name);
|
|
125
|
+
configs.push({
|
|
126
|
+
name,
|
|
127
|
+
url: entry.url,
|
|
128
|
+
command: entry.command,
|
|
129
|
+
args: entry.args,
|
|
130
|
+
env: entry.env,
|
|
131
|
+
enabled: entry.enabled !== false,
|
|
132
|
+
description: entry.description,
|
|
133
|
+
source: "project",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return configs;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Load global MCP configs from ~/.pi/agent/mcp.json. */
|
|
141
|
+
export function loadGlobalMcpConfigs(): McpServerConfig[] {
|
|
142
|
+
const servers = readMcpJson(globalMcpJsonPath());
|
|
143
|
+
if (!servers) return [];
|
|
144
|
+
|
|
145
|
+
return Object.entries(servers).map(([name, entry]) => ({
|
|
146
|
+
name,
|
|
147
|
+
url: entry.url,
|
|
148
|
+
command: entry.command,
|
|
149
|
+
args: entry.args,
|
|
150
|
+
env: entry.env,
|
|
151
|
+
description: entry.description,
|
|
152
|
+
enabled: entry.enabled !== false,
|
|
153
|
+
source: "global" as const,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Returns true if the URL should use SSE transport (path ends with /sse). */
|
|
158
|
+
export function isSseUrl(url: string): boolean {
|
|
159
|
+
return url.endsWith("/sse") || url.endsWith("/sse/");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Merge all MCP configs: builtin → global → project.
|
|
164
|
+
* Later sources override earlier ones for the same server name.
|
|
165
|
+
*/
|
|
166
|
+
export function resolveMcpConfigs(cwd: string): McpServerConfig[] {
|
|
167
|
+
// The mcp module switch is the master switch. When it is off, no MCP
|
|
168
|
+
// server config is considered active — this keeps the chain simple:
|
|
169
|
+
// mcp off → all servers off → no codegraph guidance.
|
|
170
|
+
if (!isModuleEnabled("mcp")) return [];
|
|
171
|
+
|
|
172
|
+
const byName = new Map<string, McpServerConfig>();
|
|
173
|
+
|
|
174
|
+
// Builtin (lowest priority). Use the builtin's own `enabled` default.
|
|
175
|
+
// Servers can be enabled via the `/mcp` command or by editing mcp.json.
|
|
176
|
+
for (const s of BUILTIN_MCP_SERVERS) {
|
|
177
|
+
byName.set(s.name, { ...s, source: "builtin" });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Global — preserve url/command/description from builtin if not overridden
|
|
181
|
+
for (const s of loadGlobalMcpConfigs()) {
|
|
182
|
+
const existing = byName.get(s.name);
|
|
183
|
+
if (existing) {
|
|
184
|
+
byName.set(s.name, {
|
|
185
|
+
...existing,
|
|
186
|
+
...s,
|
|
187
|
+
url: s.url ?? existing.url,
|
|
188
|
+
command: s.command ?? existing.command,
|
|
189
|
+
args: s.args ?? existing.args,
|
|
190
|
+
env: s.env ?? existing.env,
|
|
191
|
+
description: s.description ?? existing.description,
|
|
192
|
+
source: "global",
|
|
193
|
+
canUseInProject: s.canUseInProject ?? existing.canUseInProject,
|
|
194
|
+
});
|
|
195
|
+
} else {
|
|
196
|
+
byName.set(s.name, s);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Project (highest priority) — same preservation logic
|
|
201
|
+
for (const s of loadProjectMcpConfigs(cwd)) {
|
|
202
|
+
const existing = byName.get(s.name);
|
|
203
|
+
if (existing) {
|
|
204
|
+
byName.set(s.name, {
|
|
205
|
+
...existing,
|
|
206
|
+
...s,
|
|
207
|
+
url: s.url ?? existing.url,
|
|
208
|
+
command: s.command ?? existing.command,
|
|
209
|
+
args: s.args ?? existing.args,
|
|
210
|
+
env: s.env ?? existing.env,
|
|
211
|
+
description: s.description ?? existing.description,
|
|
212
|
+
source: "project",
|
|
213
|
+
canUseInProject: s.canUseInProject ?? existing.canUseInProject,
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
byName.set(s.name, s);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return [...byName.values()].filter((s) => s.url || s.command);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function collectMcpDependencyStatuses(cwd: string): DependencyStatus[] {
|
|
224
|
+
const seen = new Set<string>();
|
|
225
|
+
const statuses: DependencyStatus[] = [];
|
|
226
|
+
for (const cfg of resolveMcpConfigs(cwd)) {
|
|
227
|
+
if (!cfg.enabled || !cfg.command || seen.has(cfg.command)) continue;
|
|
228
|
+
seen.add(cfg.command);
|
|
229
|
+
statuses.push({
|
|
230
|
+
module: `mcp:${cfg.name}`,
|
|
231
|
+
label: cfg.command,
|
|
232
|
+
state: commandExists(cfg.command) ? "ok" : "missing",
|
|
233
|
+
detail: `Install the MCP server command for \"${cfg.name}\" or update its config.`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return statuses;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function commandExists(command: string): boolean {
|
|
240
|
+
if (path.isAbsolute(command) || command.includes("/") || command.includes("\\")) {
|
|
241
|
+
return fs.existsSync(command);
|
|
242
|
+
}
|
|
243
|
+
const result = process.platform === "win32"
|
|
244
|
+
? spawnSync("where", [command], { encoding: "utf-8" })
|
|
245
|
+
: spawnSync(process.env.SHELL || "sh", ["-lc", `command -v '${command.replace(/'/g, `'"'"'`)}'`], { encoding: "utf-8" });
|
|
246
|
+
return result.status === 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function readMcpJsonSafe(filePath: string): Record<string, any> | null {
|
|
250
|
+
try {
|
|
251
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Toggle a server's enabled state in the appropriate config file. */
|
|
258
|
+
export function toggleMcpServerEnabled(
|
|
259
|
+
serverName: string,
|
|
260
|
+
enabled: boolean,
|
|
261
|
+
scope: "global" | "project",
|
|
262
|
+
cwd?: string,
|
|
263
|
+
): boolean {
|
|
264
|
+
try {
|
|
265
|
+
if (scope === "project" && cwd) {
|
|
266
|
+
const filePath = path.join(cwd, ".pi/agent/mcp.json");
|
|
267
|
+
const raw = readMcpJsonSafe(filePath) || { mcpServers: {} };
|
|
268
|
+
const servers = raw.mcpServers ?? {};
|
|
269
|
+
servers[serverName] = { ...(servers[serverName] || {}), enabled };
|
|
270
|
+
raw.mcpServers = servers;
|
|
271
|
+
const dir = path.dirname(filePath);
|
|
272
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
273
|
+
fs.writeFileSync(filePath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
|
|
274
|
+
} else {
|
|
275
|
+
const filePath = globalMcpJsonPath();
|
|
276
|
+
const raw = readMcpJsonSafe(filePath) || { mcpServers: {} };
|
|
277
|
+
const servers = raw.mcpServers ?? {};
|
|
278
|
+
servers[serverName] = { ...(servers[serverName] || {}), enabled };
|
|
279
|
+
raw.mcpServers = servers;
|
|
280
|
+
const dir = path.dirname(filePath);
|
|
281
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
282
|
+
fs.writeFileSync(filePath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp tool registration — reads cache, registers tools dynamically.
|
|
3
|
+
* Connection lifecycle is in hooks/mcp.ts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { loadMcpCache, type McpCache } from "./cache.js";
|
|
8
|
+
import { resolveMcpConfigs, type McpServerConfig } from "./config.js";
|
|
9
|
+
import { buildMcpTool, __mcpToolDefinitionTest } from "./tool-definition.js";
|
|
10
|
+
import { getActiveMcpConnections } from "../../hooks/mcp.js";
|
|
11
|
+
|
|
12
|
+
export function registerMcpToolsFromCache(pi: ExtensionAPI, cache: McpCache, configs: McpServerConfig[]): void {
|
|
13
|
+
for (const config of configs) {
|
|
14
|
+
if (!config.enabled) continue;
|
|
15
|
+
const entry = cache.servers[config.name];
|
|
16
|
+
if (!entry || entry.tools.length === 0) continue;
|
|
17
|
+
for (const t of entry.tools) {
|
|
18
|
+
try {
|
|
19
|
+
pi.registerTool(buildMcpTool(config, t, (name) => getActiveMcpConnections().find(c => c.serverName === name)) as any);
|
|
20
|
+
} catch {
|
|
21
|
+
// pi-core may throw on duplicate name (e.g. on /reload when the
|
|
22
|
+
// same tool is re-registered). The previous registration is
|
|
23
|
+
// still in effect, so silently ignore.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerMcpTools(pi: ExtensionAPI, cwd: string): void {
|
|
30
|
+
const cache = loadMcpCache(cwd);
|
|
31
|
+
if (!cache) return;
|
|
32
|
+
const configs = resolveMcpConfigs(cwd).filter(s => s.enabled);
|
|
33
|
+
registerMcpToolsFromCache(pi, cache, configs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Re-exports
|
|
37
|
+
export { McpConnection } from "./client.js";
|
|
38
|
+
export { buildMcpTool } from "./tool-definition.js";
|
|
39
|
+
export { getActiveMcpConnections, getCachedMcpConfigs, getMcpStatus, updateConfigEnabled, refreshServerCache } from "../../hooks/mcp.js";
|
|
40
|
+
export type { McpServerConfig } from "./config.js";
|
|
41
|
+
export type { McpCache } from "./cache.js";
|
|
42
|
+
|
|
43
|
+
// Test exports
|
|
44
|
+
export const __mcpIndexTest = __mcpToolDefinitionTest;
|