@pi-archimedes/mcp 2.3.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/package.json +39 -0
  4. package/src/auth-flow.test.ts +583 -0
  5. package/src/auth-flow.ts +310 -0
  6. package/src/auth-run.test.ts +309 -0
  7. package/src/auth-run.ts +146 -0
  8. package/src/auth-storage.test.ts +338 -0
  9. package/src/auth-storage.ts +330 -0
  10. package/src/auto-auth.test.ts +231 -0
  11. package/src/auto-auth.ts +135 -0
  12. package/src/callback-server.test.ts +446 -0
  13. package/src/callback-server.ts +538 -0
  14. package/src/commands-auth.test.ts +320 -0
  15. package/src/commands-auth.ts +128 -0
  16. package/src/commands.test.ts +834 -0
  17. package/src/commands.ts +424 -0
  18. package/src/config-write.test.ts +213 -0
  19. package/src/config-write.ts +207 -0
  20. package/src/config.test.ts +468 -0
  21. package/src/config.ts +278 -0
  22. package/src/direct-tools.test.ts +473 -0
  23. package/src/direct-tools.ts +250 -0
  24. package/src/host-configs.test.ts +231 -0
  25. package/src/host-configs.ts +106 -0
  26. package/src/index.test.ts +689 -0
  27. package/src/index.ts +146 -0
  28. package/src/lifecycle.test.ts +274 -0
  29. package/src/lifecycle.ts +77 -0
  30. package/src/metadata-cache.test.ts +383 -0
  31. package/src/metadata-cache.ts +231 -0
  32. package/src/npx-resolver.test.ts +142 -0
  33. package/src/npx-resolver.ts +126 -0
  34. package/src/oauth-provider.test.ts +404 -0
  35. package/src/oauth-provider.ts +197 -0
  36. package/src/oauth-types.ts +54 -0
  37. package/src/panel-rows.ts +210 -0
  38. package/src/panel.test.ts +298 -0
  39. package/src/panel.ts +742 -0
  40. package/src/proxy-tool.ts +524 -0
  41. package/src/renderer.test.ts +326 -0
  42. package/src/renderer.ts +239 -0
  43. package/src/schema-validator.test.ts +56 -0
  44. package/src/schema-validator.ts +42 -0
  45. package/src/server-client.test.ts +1001 -0
  46. package/src/server-client.ts +576 -0
  47. package/src/server-manager.ts +139 -0
  48. package/src/setup-panel.test.ts +162 -0
  49. package/src/setup-panel.ts +715 -0
  50. package/src/tool-naming.test.ts +168 -0
  51. package/src/tool-naming.ts +114 -0
  52. package/src/types.ts +162 -0
package/src/config.ts ADDED
@@ -0,0 +1,278 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { cwd } from "node:process";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { loadConfig, saveConfig } from "@pi-archimedes/core/settings-io";
7
+ import {
8
+ DEFAULT_MCP_CONFIG,
9
+ MCP_NAMESPACE,
10
+ OAUTH_CONFIG_FIELDS,
11
+ type McpConfig,
12
+ type McpFileConfig,
13
+ type ServerDef,
14
+ type HttpServerDef,
15
+ type StdioServerDef,
16
+ type ToolPrefix,
17
+ } from "./types.js";
18
+
19
+ /** Load archimedes.mcp section from settings.json */
20
+ export function loadMcpConfig(): McpConfig {
21
+ return loadConfig(MCP_NAMESPACE, DEFAULT_MCP_CONFIG);
22
+ }
23
+
24
+ export function saveMcpConfig(config: McpConfig): void {
25
+ saveConfig(MCP_NAMESPACE, config);
26
+ }
27
+
28
+ /**
29
+ * Auth-related HTTP fields that are bound to a server's url.
30
+ * When a higher-precedence layer points the server at a different url,
31
+ * inherited values of these fields are dropped so credentials are never
32
+ * sent to an endpoint the user did not explicitly configure for them.
33
+ */
34
+ export const URL_BOUND_AUTH_FIELDS = ["auth", "headers", "bearerTokenEnv"] as const;
35
+
36
+ /**
37
+ * Strip line (//) and block comments and trailing commas from JSON text.
38
+ * String literals are left untouched (e.g. "http://x" is preserved).
39
+ * Zero dependencies — a single character scan.
40
+ */
41
+ export function stripJsonComments(text: string): string {
42
+ let out = "";
43
+ let inString = false;
44
+ let i = 0;
45
+ const n = text.length;
46
+ while (i < n) {
47
+ const c = text[i]!;
48
+ if (inString) {
49
+ out += c;
50
+ if (c === "\\") {
51
+ i++;
52
+ if (i < n) out += text[i]!;
53
+ } else if (c === '"') {
54
+ inString = false;
55
+ }
56
+ i++;
57
+ continue;
58
+ }
59
+ if (c === '"') {
60
+ inString = true;
61
+ out += c;
62
+ i++;
63
+ continue;
64
+ }
65
+ if (c === "/" && text[i + 1] === "/") {
66
+ i += 2;
67
+ while (i < n && text[i] !== "\n") i++;
68
+ continue;
69
+ }
70
+ if (c === "/" && text[i + 1] === "*") {
71
+ i += 2;
72
+ while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++;
73
+ i += 2; // skip closing */
74
+ continue;
75
+ }
76
+ // Drop a trailing comma: if the next non-whitespace char outside a
77
+ // string is `}` or `]` and the last emitted non-whitespace char is `,`,
78
+ // back it out.
79
+ if (c === "}" || c === "]") {
80
+ let k = out.length - 1;
81
+ while (k >= 0 && (out[k] === " " || out[k] === "\t" || out[k] === "\n" || out[k] === "\r")) k--;
82
+ if (k >= 0 && out[k] === ",") out = out.slice(0, k);
83
+ }
84
+ out += c;
85
+ i++;
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** Parse one mcp.json file (JSON with comments/trailing commas), returning null on error */
91
+ export function parseFile(path: string): McpFileConfig | null {
92
+ if (!existsSync(path)) return null;
93
+ try {
94
+ return JSON.parse(stripJsonComments(readFileSync(path, "utf-8"))) as McpFileConfig;
95
+ } catch (e) {
96
+ console.warn(`[archimedes/mcp] Failed to parse ${path}:`, e instanceof Error ? e.message : e);
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * The SINGLE transport classification predicate: a def with a string `url`
103
+ * is an HTTP server (connected via StreamableHTTP, SSE fallback); anything
104
+ * else is stdio. The optional `type` field is informational only and never
105
+ * participates here — the standard mcpServers shape omits it on url servers.
106
+ */
107
+ export function isHttpDef(def: ServerDef): def is HttpServerDef {
108
+ return "url" in def && typeof (def as HttpServerDef).url === "string";
109
+ }
110
+
111
+ /**
112
+ * Field-level merge of two server defs (override wins per field).
113
+ * Security rule: if both are HTTP defs and the override changes the url,
114
+ * inherited URL_BOUND_AUTH_FIELDS from the base are dropped.
115
+ */
116
+ function mergeServerDef(base: ServerDef, override: ServerDef): ServerDef {
117
+ const merged: ServerDef = { ...base, ...override };
118
+ if (isHttpDef(base) && isHttpDef(override) && override.url !== base.url) {
119
+ const record = merged as unknown as Record<string, unknown>;
120
+ for (const field of URL_BOUND_AUTH_FIELDS) {
121
+ if (!(field in override)) delete record[field];
122
+ }
123
+ }
124
+ return merged;
125
+ }
126
+
127
+ /**
128
+ * Merge server definition layers from lowest to highest precedence.
129
+ * Later layers override earlier ones per server (field-level merge).
130
+ */
131
+ export function mergeServerDefs(layers: Array<Record<string, ServerDef>>): Record<string, ServerDef> {
132
+ const merged: Record<string, ServerDef> = {};
133
+ for (const layer of layers) {
134
+ for (const [name, def] of Object.entries(layer)) {
135
+ const existing = merged[name];
136
+ merged[name] = existing ? mergeServerDef(existing, def) : def;
137
+ }
138
+ }
139
+ return merged;
140
+ }
141
+
142
+ /**
143
+ * Effective settings for one server: per-server SharedServerSettings
144
+ * resolved over the global archimedes.mcp config defaults.
145
+ */
146
+ export interface EffectiveServerSettings {
147
+ lifecycle: "keep-alive" | "lazy" | "lazy-keep-alive" | "eager";
148
+ /** Idle timeout in minutes; 0 disables */
149
+ idleTimeout: number;
150
+ toolPrefix: ToolPrefix;
151
+ /** Expose direct tools; true = all, string[] = subset of tool names */
152
+ directTools: boolean | string[];
153
+ includeTools?: string[];
154
+ excludeTools?: string[];
155
+ requestTimeoutMs?: number;
156
+ exposeResources?: boolean;
157
+ debug?: boolean;
158
+ protocolVersion?: string;
159
+ }
160
+
161
+ /** Resolve per-server settings over the global McpConfig defaults */
162
+ export function resolveServerSettings(def: ServerDef, globalConfig: McpConfig): EffectiveServerSettings {
163
+ const result: EffectiveServerSettings = {
164
+ lifecycle: def.lifecycle ?? "lazy",
165
+ idleTimeout: def.idleTimeout ?? globalConfig.idleTimeout,
166
+ toolPrefix: def.toolPrefix ?? globalConfig.toolPrefix,
167
+ directTools: def.directTools ?? globalConfig.directTools,
168
+ };
169
+ if (def.includeTools !== undefined) result.includeTools = def.includeTools;
170
+ if (def.excludeTools !== undefined) result.excludeTools = def.excludeTools;
171
+ if (def.requestTimeoutMs !== undefined) result.requestTimeoutMs = def.requestTimeoutMs;
172
+ if (def.exposeResources !== undefined) result.exposeResources = def.exposeResources;
173
+ if (def.debug !== undefined) result.debug = def.debug;
174
+ if (def.protocolVersion !== undefined) result.protocolVersion = def.protocolVersion;
175
+ return result;
176
+ }
177
+
178
+ export interface LoadServerDefsOptions {
179
+ /** Override the home directory (`~`) — for tests */
180
+ homeDir?: string;
181
+ /** Override the agent directory (`<agentDir>`) — for tests */
182
+ agentDir?: string;
183
+ }
184
+
185
+ /**
186
+ * Config file paths in precedence order (lowest → highest):
187
+ * ~/.config/mcp/mcp.json
188
+ * ~/.agents/mcp.json
189
+ * ~/.agents/mcp/mcp.json
190
+ * <agentDir>/mcp.json
191
+ * <cwd>/.mcp.json
192
+ * <cwd>/.pi/mcp.json
193
+ */
194
+ export function getConfigPaths(options?: LoadServerDefsOptions & { workingDir?: string }): string[] {
195
+ const home = options?.homeDir ?? homedir();
196
+ const agent = options?.agentDir ?? getAgentDir();
197
+ const wd = options?.workingDir ?? cwd();
198
+ return [
199
+ join(home, ".config", "mcp", "mcp.json"), // lowest precedence
200
+ join(home, ".agents", "mcp.json"),
201
+ join(home, ".agents", "mcp", "mcp.json"),
202
+ join(agent, "mcp.json"),
203
+ join(wd, ".mcp.json"),
204
+ join(wd, ".pi", "mcp.json"), // highest precedence
205
+ ];
206
+ }
207
+
208
+ /**
209
+ * Runtime check for the valid auth shapes of an HTTP server def:
210
+ * `{ token: string }` (bearer), the `"oauth"` string, or a plain object
211
+ * containing at least one known `McpOAuthConfig` field. Everything else
212
+ * (other strings, numbers, booleans, null, arrays, or objects with only
213
+ * unknown fields) is unknown and gets a warning.
214
+ */
215
+ function supportsAuthShape(auth: unknown): boolean {
216
+ if (typeof auth === "string") return auth === "oauth";
217
+ if (typeof auth !== "object" || auth === null || Array.isArray(auth)) return false;
218
+ const record = auth as Record<string, unknown>;
219
+ if (typeof record.token === "string") return true; // { token } bearer
220
+ // A valid OAuth config object references at least one known field
221
+ return OAUTH_CONFIG_FIELDS.some((field) => record[field] !== undefined);
222
+ }
223
+
224
+ /**
225
+ * Load and merge all MCP server definitions from the standard config
226
+ * locations, including disabled servers (their `disabled: true` flag is
227
+ * intact). Higher-precedence files override lower ones per server
228
+ * (field-level merge, with the url-bound credential drop rule).
229
+ */
230
+ export function loadAllServerDefs(workingDir?: string, options?: LoadServerDefsOptions): Record<string, ServerDef> {
231
+ const opts: LoadServerDefsOptions & { workingDir?: string } = { ...options };
232
+ if (workingDir !== undefined) opts.workingDir = workingDir;
233
+ const paths = getConfigPaths(opts);
234
+
235
+ const layers: Array<Record<string, ServerDef>> = [];
236
+ for (const p of paths) {
237
+ const parsed = parseFile(p);
238
+ if (parsed?.mcpServers) layers.push(parsed.mcpServers);
239
+ }
240
+ return mergeServerDefs(layers);
241
+ }
242
+
243
+ /**
244
+ * Load and merge all MCP server definitions from the standard config locations.
245
+ * Higher-precedence files override lower ones per server (field-level merge,
246
+ * with the url-bound credential drop rule). Disabled servers are excluded.
247
+ * Mangled defs (neither a string `url` nor a string `command`) are skipped
248
+ * with a warning — they could never connect.
249
+ */
250
+ export function loadServerDefs(workingDir?: string, options?: LoadServerDefsOptions): Record<string, ServerDef> {
251
+ const merged = loadAllServerDefs(workingDir, options);
252
+
253
+ // Filter out disabled and mangled servers, and warn on unsupported auth types
254
+ return Object.fromEntries(
255
+ Object.entries(merged).filter(([name, def]) => {
256
+ if (def.disabled === true) return false;
257
+ // Mangled def: neither a string url (http) nor a string command (stdio)
258
+ // — it could never connect, so skip it with a clear warning instead of
259
+ // crashing at connect time.
260
+ if (!isHttpDef(def) && typeof (def as StdioServerDef).command !== "string") {
261
+ console.warn(
262
+ `[archimedes/mcp] Server "${name}" has neither a "url" (http) nor a "command" (stdio) field — skipping it. ` +
263
+ `Add a "url" for an HTTP server or a "command" for a stdio server.`,
264
+ );
265
+ return false;
266
+ }
267
+ // Warn on genuinely-unknown auth shapes (valid: { token } bearer,
268
+ // the "oauth" string, or an OAuth config object)
269
+ if ("auth" in def && def.auth !== undefined && !supportsAuthShape(def.auth)) {
270
+ console.warn(
271
+ `[archimedes/mcp] Server "${name}" uses unsupported auth type "${String(def.auth)}". ` +
272
+ `Supported: { token: string } (bearer), "oauth", or an OAuth config object. The server will connect without auth.`
273
+ );
274
+ }
275
+ return true;
276
+ })
277
+ );
278
+ }