@docyrus/docyrus 0.0.20 → 0.0.22
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/agent-loader.js +32 -1
- package/agent-loader.js.map +2 -2
- package/main.js +325 -71
- package/main.js.map +4 -4
- package/package.json +16 -3
- package/resources/chrome-tools/browser-content.js +103 -0
- package/resources/chrome-tools/browser-cookies.js +35 -0
- package/resources/chrome-tools/browser-eval.js +53 -0
- package/resources/chrome-tools/browser-hn-scraper.js +108 -0
- package/resources/chrome-tools/browser-nav.js +44 -0
- package/resources/chrome-tools/browser-pick.js +162 -0
- package/resources/chrome-tools/browser-screenshot.js +34 -0
- package/resources/chrome-tools/browser-start.js +86 -0
- package/resources/pi-agent/extensions/answer.ts +532 -0
- package/resources/pi-agent/extensions/context.ts +578 -0
- package/resources/pi-agent/extensions/control.ts +1779 -0
- package/resources/pi-agent/extensions/diff.ts +218 -0
- package/resources/pi-agent/extensions/files.ts +199 -0
- package/resources/pi-agent/extensions/loop.ts +446 -0
- package/resources/pi-agent/extensions/multi-edit.ts +835 -0
- package/resources/pi-agent/extensions/notify.ts +88 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/LICENSE +21 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/README.md +19 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/index.ts +52 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/package.json +61 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/pty-execute.ts +97 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/pty-kill.ts +25 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/pty-session.ts +143 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/spawn-helper.ts +31 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/terminal-emulator.ts +439 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/truncate.ts +68 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/widget.ts +114 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/CHANGELOG.md +192 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/LICENSE +21 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/README.md +296 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/app-bridge.bundle.js +67 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/cli.js +108 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/commands.ts +211 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/config.ts +227 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/consent-manager.ts +64 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/direct-tools.ts +301 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/errors.ts +219 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/glimpse-ui.ts +80 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/host-html-template.ts +427 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/index.ts +232 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/init.ts +319 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/lifecycle.ts +93 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/logger.ts +169 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/mcp-panel.ts +713 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/metadata-cache.ts +191 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/npx-resolver.ts +419 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/oauth-handler.ts +56 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/package.json +85 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/paths.ts +29 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/proxy-modes.ts +635 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/resource-tools.ts +17 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/server-manager.ts +330 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/state.ts +41 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/tool-metadata.ts +144 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/tool-registrar.ts +46 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/types.ts +367 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-resource-handler.ts +145 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-server.ts +623 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-session.ts +384 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-stream-types.ts +89 -0
- package/resources/pi-agent/extensions/pi-mcp-adapter/utils.ts +75 -0
- package/resources/pi-agent/extensions/prompt-editor.ts +1315 -0
- package/resources/pi-agent/extensions/prompt-url-widget.ts +158 -0
- package/resources/pi-agent/extensions/redraws.ts +24 -0
- package/resources/pi-agent/extensions/review.ts +2160 -0
- package/resources/pi-agent/extensions/todos.ts +2076 -0
- package/resources/pi-agent/extensions/tps.ts +47 -0
- package/resources/pi-agent/extensions/whimsical.ts +474 -0
- package/resources/pi-agent/skills/changelog-generator/SKILL.md +425 -0
- package/resources/pi-agent/skills/docyrus-chrome-devtools-cli/SKILL.md +80 -0
- package/resources/pi-agent/skills/docyrus-platform/references/docyrus-cli-usage.md +51 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import https from "node:https";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const REPO_URL = "https://raw.githubusercontent.com/nicobailon/pi-mcp-adapter/main";
|
|
10
|
+
const EXT_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", "pi-mcp-adapter");
|
|
11
|
+
const SETTINGS_FILE = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
12
|
+
const EXT_PATH = "~/.pi/agent/extensions/pi-mcp-adapter/index.ts";
|
|
13
|
+
|
|
14
|
+
const FILES = [
|
|
15
|
+
"index.ts",
|
|
16
|
+
"types.ts",
|
|
17
|
+
"config.ts",
|
|
18
|
+
"server-manager.ts",
|
|
19
|
+
"tool-registrar.ts",
|
|
20
|
+
"resource-tools.ts",
|
|
21
|
+
"lifecycle.ts",
|
|
22
|
+
"metadata-cache.ts",
|
|
23
|
+
"npx-resolver.ts",
|
|
24
|
+
"oauth-handler.ts",
|
|
25
|
+
"package.json",
|
|
26
|
+
"tsconfig.json",
|
|
27
|
+
"README.md",
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function download(url) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
https.get(url, (res) => {
|
|
35
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
36
|
+
return download(res.headers.location).then(resolve).catch(reject);
|
|
37
|
+
}
|
|
38
|
+
if (res.statusCode !== 200) {
|
|
39
|
+
return reject(new Error(`Failed to download ${url}: ${res.statusCode}`));
|
|
40
|
+
}
|
|
41
|
+
let data = "";
|
|
42
|
+
res.on("data", (chunk) => (data += chunk));
|
|
43
|
+
res.on("end", () => resolve(data));
|
|
44
|
+
res.on("error", reject);
|
|
45
|
+
}).on("error", reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function main() {
|
|
50
|
+
console.log("Installing pi-mcp-adapter...\n");
|
|
51
|
+
|
|
52
|
+
fs.mkdirSync(EXT_DIR, { recursive: true });
|
|
53
|
+
console.log(`Created directory: ${EXT_DIR}`);
|
|
54
|
+
|
|
55
|
+
for (const file of FILES) {
|
|
56
|
+
console.log(`Downloading ${file}...`);
|
|
57
|
+
const content = await download(`${REPO_URL}/${file}`);
|
|
58
|
+
fs.writeFileSync(path.join(EXT_DIR, file), content);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log("\nInstalling dependencies...");
|
|
62
|
+
try {
|
|
63
|
+
execSync("npm install --omit=dev", { cwd: EXT_DIR, stdio: "inherit" });
|
|
64
|
+
} catch {
|
|
65
|
+
console.error("Warning: npm install failed. You may need to run it manually.");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.log(`\nUpdating settings: ${SETTINGS_FILE}`);
|
|
69
|
+
|
|
70
|
+
let settings = {};
|
|
71
|
+
if (fs.existsSync(SETTINGS_FILE)) {
|
|
72
|
+
try {
|
|
73
|
+
settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(`Warning: Could not parse existing settings.json: ${err.message}`);
|
|
76
|
+
console.error("Creating new settings file...");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!Array.isArray(settings.extensions)) {
|
|
81
|
+
settings.extensions = [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const hasMcpExt = settings.extensions.some(p =>
|
|
85
|
+
p === EXT_PATH ||
|
|
86
|
+
p.includes("/extensions/pi-mcp-adapter/index.ts") ||
|
|
87
|
+
p.includes("/extensions/pi-mcp-adapter")
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (!hasMcpExt) {
|
|
91
|
+
settings.extensions.push(EXT_PATH);
|
|
92
|
+
console.log(`Added "${EXT_PATH}" to extensions array`);
|
|
93
|
+
} else {
|
|
94
|
+
console.log("Extension already configured in settings.json");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
|
|
98
|
+
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
|
|
99
|
+
|
|
100
|
+
console.log("\nInstallation complete!");
|
|
101
|
+
console.log("\nCreate ~/.pi/agent/mcp.json to configure MCP servers.");
|
|
102
|
+
console.log("Restart pi to load the extension.");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
main().catch((err) => {
|
|
106
|
+
console.error(`\nInstallation failed: ${err.message}`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { McpExtensionState } from "./state.js";
|
|
3
|
+
import type { McpConfig, ServerEntry, McpPanelCallbacks, McpPanelResult } from "./types.js";
|
|
4
|
+
import { getServerProvenance, writeDirectToolsConfig } from "./config.js";
|
|
5
|
+
import { lazyConnect, updateMetadataCache, updateStatusBar, getFailureAgeSeconds } from "./init.js";
|
|
6
|
+
import { loadMetadataCache } from "./metadata-cache.js";
|
|
7
|
+
import { getStoredTokens } from "./oauth-handler.js";
|
|
8
|
+
import { getScopedMcpOAuthTokensPath } from "./paths.js";
|
|
9
|
+
import { buildToolMetadata } from "./tool-metadata.js";
|
|
10
|
+
|
|
11
|
+
export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
|
12
|
+
if (!ctx.hasUI) return;
|
|
13
|
+
|
|
14
|
+
const lines: string[] = ["MCP Server Status:", ""];
|
|
15
|
+
|
|
16
|
+
for (const name of Object.keys(state.config.mcpServers)) {
|
|
17
|
+
const connection = state.manager.getConnection(name);
|
|
18
|
+
const metadata = state.toolMetadata.get(name);
|
|
19
|
+
const toolCount = metadata?.length ?? 0;
|
|
20
|
+
const failedAgo = getFailureAgeSeconds(state, name);
|
|
21
|
+
let status = "not connected";
|
|
22
|
+
let statusIcon = "○";
|
|
23
|
+
let failed = false;
|
|
24
|
+
|
|
25
|
+
if (connection?.status === "connected") {
|
|
26
|
+
status = "connected";
|
|
27
|
+
statusIcon = "✓";
|
|
28
|
+
} else if (failedAgo !== null) {
|
|
29
|
+
status = `failed ${failedAgo}s ago`;
|
|
30
|
+
statusIcon = "✗";
|
|
31
|
+
failed = true;
|
|
32
|
+
} else if (metadata !== undefined) {
|
|
33
|
+
status = "cached";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const toolSuffix = failed ? "" : ` (${toolCount} tools${status === "cached" ? ", cached" : ""})`;
|
|
37
|
+
lines.push(`${statusIcon} ${name}: ${status}${toolSuffix}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (Object.keys(state.config.mcpServers).length === 0) {
|
|
41
|
+
lines.push("No MCP servers configured");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
|
48
|
+
if (!ctx.hasUI) return;
|
|
49
|
+
|
|
50
|
+
const allTools = [...state.toolMetadata.values()].flat().map(m => m.name);
|
|
51
|
+
|
|
52
|
+
if (allTools.length === 0) {
|
|
53
|
+
ctx.ui.notify("No MCP tools available", "info");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const lines = [
|
|
58
|
+
"MCP Tools:",
|
|
59
|
+
"",
|
|
60
|
+
...allTools.map(t => ` ${t}`),
|
|
61
|
+
"",
|
|
62
|
+
`Total: ${allTools.length} tools`,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function reconnectServers(
|
|
69
|
+
state: McpExtensionState,
|
|
70
|
+
ctx: ExtensionContext,
|
|
71
|
+
targetServer?: string
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
if (targetServer && !state.config.mcpServers[targetServer]) {
|
|
74
|
+
if (ctx.hasUI) {
|
|
75
|
+
ctx.ui.notify(`Server "${targetServer}" not found in config`, "error");
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const entries = targetServer
|
|
81
|
+
? [[targetServer, state.config.mcpServers[targetServer]] as [string, ServerEntry]]
|
|
82
|
+
: Object.entries(state.config.mcpServers);
|
|
83
|
+
|
|
84
|
+
for (const [name, definition] of entries) {
|
|
85
|
+
try {
|
|
86
|
+
await state.manager.close(name);
|
|
87
|
+
|
|
88
|
+
const connection = await state.manager.connect(name, definition);
|
|
89
|
+
const prefix = state.config.settings?.toolPrefix ?? "server";
|
|
90
|
+
|
|
91
|
+
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
92
|
+
state.toolMetadata.set(name, metadata);
|
|
93
|
+
updateMetadataCache(state, name);
|
|
94
|
+
state.failureTracker.delete(name);
|
|
95
|
+
|
|
96
|
+
if (ctx.hasUI) {
|
|
97
|
+
ctx.ui.notify(
|
|
98
|
+
`MCP: Reconnected to ${name} (${connection.tools.length} tools, ${connection.resources.length} resources)`,
|
|
99
|
+
"info"
|
|
100
|
+
);
|
|
101
|
+
if (failedTools.length > 0) {
|
|
102
|
+
ctx.ui.notify(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
107
|
+
state.failureTracker.set(name, Date.now());
|
|
108
|
+
if (ctx.hasUI) {
|
|
109
|
+
ctx.ui.notify(`MCP: Failed to reconnect to ${name}: ${message}`, "error");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
updateStatusBar(state);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function authenticateServer(
|
|
118
|
+
serverName: string,
|
|
119
|
+
config: McpConfig,
|
|
120
|
+
ctx: ExtensionContext
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
if (!ctx.hasUI) return;
|
|
123
|
+
|
|
124
|
+
const definition = config.mcpServers[serverName];
|
|
125
|
+
if (!definition) {
|
|
126
|
+
ctx.ui.notify(`Server "${serverName}" not found in config`, "error");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (definition.auth !== "oauth") {
|
|
131
|
+
ctx.ui.notify(
|
|
132
|
+
`Server "${serverName}" does not use OAuth authentication.\n` +
|
|
133
|
+
`Current auth mode: ${definition.auth ?? "none"}`,
|
|
134
|
+
"error"
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!definition.url) {
|
|
140
|
+
ctx.ui.notify(
|
|
141
|
+
`Server "${serverName}" has no URL configured (OAuth requires HTTP transport)`,
|
|
142
|
+
"error"
|
|
143
|
+
);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const tokenPath = getScopedMcpOAuthTokensPath(serverName);
|
|
148
|
+
|
|
149
|
+
ctx.ui.notify(
|
|
150
|
+
`OAuth setup for "${serverName}":\n\n` +
|
|
151
|
+
`1. Obtain an access token from your OAuth provider\n` +
|
|
152
|
+
`2. Create the token file:\n` +
|
|
153
|
+
` ${tokenPath}\n\n` +
|
|
154
|
+
`3. Add your token:\n` +
|
|
155
|
+
` {\n` +
|
|
156
|
+
` "access_token": "your-token-here",\n` +
|
|
157
|
+
` "token_type": "bearer"\n` +
|
|
158
|
+
` }\n\n` +
|
|
159
|
+
`4. Run /mcp reconnect to connect with the token`,
|
|
160
|
+
"info"
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function openMcpPanel(
|
|
165
|
+
state: McpExtensionState,
|
|
166
|
+
pi: ExtensionAPI,
|
|
167
|
+
ctx: ExtensionContext,
|
|
168
|
+
configOverridePath?: string,
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
const config = state.config;
|
|
171
|
+
const cache = loadMetadataCache();
|
|
172
|
+
const provenanceMap = getServerProvenance(pi.getFlag("mcp-config") as string | undefined ?? configOverridePath);
|
|
173
|
+
|
|
174
|
+
const callbacks: McpPanelCallbacks = {
|
|
175
|
+
reconnect: async (serverName: string) => {
|
|
176
|
+
return lazyConnect(state, serverName);
|
|
177
|
+
},
|
|
178
|
+
getConnectionStatus: (serverName: string) => {
|
|
179
|
+
const definition = config.mcpServers[serverName];
|
|
180
|
+
if (definition?.auth === "oauth" && getStoredTokens(serverName) === undefined) {
|
|
181
|
+
return "needs-auth";
|
|
182
|
+
}
|
|
183
|
+
const connection = state.manager.getConnection(serverName);
|
|
184
|
+
if (connection?.status === "connected") return "connected";
|
|
185
|
+
if (getFailureAgeSeconds(state, serverName) !== null) return "failed";
|
|
186
|
+
return "idle";
|
|
187
|
+
},
|
|
188
|
+
refreshCacheAfterReconnect: (serverName: string) => {
|
|
189
|
+
const freshCache = loadMetadataCache();
|
|
190
|
+
return freshCache?.servers?.[serverName] ?? null;
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const { createMcpPanel } = await import("./mcp-panel.js");
|
|
195
|
+
|
|
196
|
+
return new Promise<void>((resolve) => {
|
|
197
|
+
ctx.ui.custom(
|
|
198
|
+
(tui, _theme, _keybindings, done) => {
|
|
199
|
+
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, (result: McpPanelResult) => {
|
|
200
|
+
if (!result.cancelled && result.changes.size > 0) {
|
|
201
|
+
writeDirectToolsConfig(result.changes, provenanceMap, config);
|
|
202
|
+
ctx.ui.notify("Direct tools updated. Restart pi to apply.", "info");
|
|
203
|
+
}
|
|
204
|
+
done();
|
|
205
|
+
resolve();
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: 82 } },
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// config.ts - Config loading with import support
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve, dirname } from "node:path";
|
|
5
|
+
import type { McpConfig, ServerEntry, McpSettings, ImportKind, ServerProvenance } from "./types.js";
|
|
6
|
+
import { getScopedMcpConfigPath } from "./paths.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG_PATH = getScopedMcpConfigPath();
|
|
9
|
+
const PROJECT_CONFIG_NAME = ".pi/mcp.json";
|
|
10
|
+
|
|
11
|
+
// Import source paths for other tools
|
|
12
|
+
const IMPORT_PATHS: Record<ImportKind, string> = {
|
|
13
|
+
"cursor": join(homedir(), ".cursor", "mcp.json"),
|
|
14
|
+
"claude-code": join(homedir(), ".claude", "claude_desktop_config.json"),
|
|
15
|
+
"claude-desktop": join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json"),
|
|
16
|
+
"codex": join(homedir(), ".codex", "config.json"),
|
|
17
|
+
"windsurf": join(homedir(), ".windsurf", "mcp.json"),
|
|
18
|
+
"vscode": ".vscode/mcp.json", // Relative to project
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function loadMcpConfig(overridePath?: string): McpConfig {
|
|
22
|
+
const configPath = overridePath ? resolve(overridePath) : DEFAULT_CONFIG_PATH;
|
|
23
|
+
|
|
24
|
+
// Load base config
|
|
25
|
+
let config: McpConfig = { mcpServers: {} };
|
|
26
|
+
|
|
27
|
+
if (existsSync(configPath)) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
30
|
+
config = validateConfig(raw);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.warn(`Failed to load MCP config from ${configPath}:`, error);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Process imports from other tools
|
|
37
|
+
if (config.imports?.length) {
|
|
38
|
+
for (const importKind of config.imports) {
|
|
39
|
+
const importPath = IMPORT_PATHS[importKind];
|
|
40
|
+
if (!importPath) continue;
|
|
41
|
+
|
|
42
|
+
const fullPath = importPath.startsWith(".")
|
|
43
|
+
? resolve(process.cwd(), importPath)
|
|
44
|
+
: importPath;
|
|
45
|
+
|
|
46
|
+
if (!existsSync(fullPath)) continue;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const imported = JSON.parse(readFileSync(fullPath, "utf-8"));
|
|
50
|
+
const servers = extractServers(imported, importKind);
|
|
51
|
+
|
|
52
|
+
// Merge - local config takes precedence over imports
|
|
53
|
+
for (const [name, def] of Object.entries(servers)) {
|
|
54
|
+
if (!config.mcpServers[name]) {
|
|
55
|
+
config.mcpServers[name] = def;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.warn(`Failed to import MCP config from ${importKind}:`, error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check for project-local config (skip if it's the same as the main config)
|
|
65
|
+
const projectPath = resolve(process.cwd(), PROJECT_CONFIG_NAME);
|
|
66
|
+
if (existsSync(projectPath) && projectPath !== configPath) {
|
|
67
|
+
try {
|
|
68
|
+
const projectConfig = JSON.parse(readFileSync(projectPath, "utf-8"));
|
|
69
|
+
const validated = validateConfig(projectConfig);
|
|
70
|
+
|
|
71
|
+
// Project config overrides everything
|
|
72
|
+
config.mcpServers = { ...config.mcpServers, ...validated.mcpServers };
|
|
73
|
+
if (validated.settings) {
|
|
74
|
+
config.settings = { ...config.settings, ...validated.settings };
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.warn(`Failed to load project MCP config:`, error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return config;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateConfig(raw: unknown): McpConfig {
|
|
85
|
+
if (!raw || typeof raw !== "object") {
|
|
86
|
+
return { mcpServers: {} };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const obj = raw as Record<string, unknown>;
|
|
90
|
+
const servers = obj.mcpServers ?? obj["mcp-servers"] ?? {};
|
|
91
|
+
|
|
92
|
+
// Must be a plain object, not an array or null
|
|
93
|
+
if (typeof servers !== "object" || servers === null || Array.isArray(servers)) {
|
|
94
|
+
return { mcpServers: {} };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
mcpServers: servers as Record<string, ServerEntry>,
|
|
99
|
+
imports: Array.isArray(obj.imports) ? obj.imports as ImportKind[] : undefined,
|
|
100
|
+
settings: obj.settings as McpSettings | undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function extractServers(config: unknown, kind: ImportKind): Record<string, ServerEntry> {
|
|
105
|
+
if (!config || typeof config !== "object") return {};
|
|
106
|
+
|
|
107
|
+
const obj = config as Record<string, unknown>;
|
|
108
|
+
|
|
109
|
+
let servers: unknown;
|
|
110
|
+
switch (kind) {
|
|
111
|
+
case "claude-desktop":
|
|
112
|
+
case "claude-code":
|
|
113
|
+
case "codex":
|
|
114
|
+
servers = obj.mcpServers;
|
|
115
|
+
break;
|
|
116
|
+
case "cursor":
|
|
117
|
+
case "windsurf":
|
|
118
|
+
case "vscode":
|
|
119
|
+
servers = obj.mcpServers ?? obj["mcp-servers"];
|
|
120
|
+
break;
|
|
121
|
+
default:
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return servers as Record<string, ServerEntry>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getServerProvenance(overridePath?: string): Map<string, ServerProvenance> {
|
|
133
|
+
const provenance = new Map<string, ServerProvenance>();
|
|
134
|
+
const userPath = overridePath ? resolve(overridePath) : DEFAULT_CONFIG_PATH;
|
|
135
|
+
|
|
136
|
+
let userConfig: McpConfig = { mcpServers: {} };
|
|
137
|
+
if (existsSync(userPath)) {
|
|
138
|
+
try {
|
|
139
|
+
userConfig = validateConfig(JSON.parse(readFileSync(userPath, "utf-8")));
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
for (const name of Object.keys(userConfig.mcpServers)) {
|
|
143
|
+
provenance.set(name, { path: userPath, kind: "user" });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (userConfig.imports?.length) {
|
|
147
|
+
for (const importKind of userConfig.imports) {
|
|
148
|
+
const importPath = IMPORT_PATHS[importKind];
|
|
149
|
+
if (!importPath) continue;
|
|
150
|
+
const fullPath = importPath.startsWith(".")
|
|
151
|
+
? resolve(process.cwd(), importPath)
|
|
152
|
+
: importPath;
|
|
153
|
+
if (!existsSync(fullPath)) continue;
|
|
154
|
+
try {
|
|
155
|
+
const imported = JSON.parse(readFileSync(fullPath, "utf-8"));
|
|
156
|
+
const servers = extractServers(imported, importKind);
|
|
157
|
+
for (const name of Object.keys(servers)) {
|
|
158
|
+
if (!provenance.has(name)) {
|
|
159
|
+
provenance.set(name, { path: userPath, kind: "import", importKind });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const projectPath = resolve(process.cwd(), PROJECT_CONFIG_NAME);
|
|
167
|
+
if (existsSync(projectPath) && projectPath !== userPath) {
|
|
168
|
+
try {
|
|
169
|
+
const projectConfig = validateConfig(JSON.parse(readFileSync(projectPath, "utf-8")));
|
|
170
|
+
for (const name of Object.keys(projectConfig.mcpServers)) {
|
|
171
|
+
provenance.set(name, { path: projectPath, kind: "project" });
|
|
172
|
+
}
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return provenance;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function writeDirectToolsConfig(
|
|
180
|
+
changes: Map<string, true | string[] | false>,
|
|
181
|
+
provenance: Map<string, ServerProvenance>,
|
|
182
|
+
fullConfig: McpConfig,
|
|
183
|
+
): void {
|
|
184
|
+
const byPath = new Map<string, { name: string; value: true | string[] | false; prov: ServerProvenance }[]>();
|
|
185
|
+
|
|
186
|
+
for (const [serverName, value] of changes) {
|
|
187
|
+
const prov = provenance.get(serverName);
|
|
188
|
+
if (!prov) continue;
|
|
189
|
+
|
|
190
|
+
const targetPath = prov.path;
|
|
191
|
+
|
|
192
|
+
if (!byPath.has(targetPath)) byPath.set(targetPath, []);
|
|
193
|
+
byPath.get(targetPath)!.push({ name: serverName, value, prov });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const [filePath, entries] of byPath) {
|
|
197
|
+
let raw: Record<string, unknown> = {};
|
|
198
|
+
if (existsSync(filePath)) {
|
|
199
|
+
try {
|
|
200
|
+
raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
if (!raw || typeof raw !== "object") raw = {};
|
|
204
|
+
|
|
205
|
+
const servers = (raw.mcpServers ?? raw["mcp-servers"] ?? {}) as Record<string, ServerEntry>;
|
|
206
|
+
if (typeof servers !== "object" || Array.isArray(servers)) continue;
|
|
207
|
+
|
|
208
|
+
for (const { name, value, prov } of entries) {
|
|
209
|
+
if (prov.kind === "import") {
|
|
210
|
+
const fullDef = fullConfig.mcpServers[name];
|
|
211
|
+
if (fullDef) {
|
|
212
|
+
servers[name] = { ...fullDef, directTools: value };
|
|
213
|
+
}
|
|
214
|
+
} else if (servers[name]) {
|
|
215
|
+
servers[name] = { ...servers[name], directTools: value };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const key = raw["mcp-servers"] && !raw.mcpServers ? "mcp-servers" : "mcpServers";
|
|
220
|
+
raw[key] = servers;
|
|
221
|
+
|
|
222
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
223
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
224
|
+
writeFileSync(tmpPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
|
|
225
|
+
renameSync(tmpPath, filePath);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ConsentError } from "./errors.js";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
|
+
|
|
4
|
+
export type ToolConsentMode = "never" | "once-per-server" | "always";
|
|
5
|
+
|
|
6
|
+
export class ConsentManager {
|
|
7
|
+
private approvedServers = new Set<string>();
|
|
8
|
+
private deniedServers = new Set<string>();
|
|
9
|
+
private log = logger.child({ component: "ConsentManager" });
|
|
10
|
+
|
|
11
|
+
constructor(private mode: ToolConsentMode = "once-per-server") {
|
|
12
|
+
this.log.debug("Initialized", { mode });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
requiresPrompt(serverName: string): boolean {
|
|
16
|
+
if (this.mode === "never") return false;
|
|
17
|
+
if (this.deniedServers.has(serverName)) return true;
|
|
18
|
+
if (this.mode === "always") return true;
|
|
19
|
+
return !this.approvedServers.has(serverName);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
shouldCacheConsent(): boolean {
|
|
23
|
+
return this.mode !== "always";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
registerDecision(serverName: string, approved: boolean): void {
|
|
27
|
+
this.deniedServers.delete(serverName);
|
|
28
|
+
this.approvedServers.delete(serverName);
|
|
29
|
+
|
|
30
|
+
if (approved) {
|
|
31
|
+
this.approvedServers.add(serverName);
|
|
32
|
+
this.log.debug("Consent granted", { server: serverName });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.deniedServers.add(serverName);
|
|
37
|
+
this.log.debug("Consent denied", { server: serverName });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
ensureApproved(serverName: string): void {
|
|
41
|
+
if (this.mode === "never") return;
|
|
42
|
+
if (this.deniedServers.has(serverName)) {
|
|
43
|
+
throw new ConsentError(serverName, { denied: true });
|
|
44
|
+
}
|
|
45
|
+
if (!this.approvedServers.has(serverName)) {
|
|
46
|
+
throw new ConsentError(serverName, { requiresApproval: true });
|
|
47
|
+
}
|
|
48
|
+
if (this.mode === "always") {
|
|
49
|
+
this.approvedServers.delete(serverName);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
clear(serverName?: string): void {
|
|
54
|
+
if (serverName) {
|
|
55
|
+
this.approvedServers.delete(serverName);
|
|
56
|
+
this.deniedServers.delete(serverName);
|
|
57
|
+
this.log.debug("Cleared consent for server", { server: serverName });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.approvedServers.clear();
|
|
61
|
+
this.deniedServers.clear();
|
|
62
|
+
this.log.debug("Cleared all consent records");
|
|
63
|
+
}
|
|
64
|
+
}
|