@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
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ BUILTIN_NAMES,
4
+ findFormattingCollisions,
5
+ formatToolName,
6
+ getServerPrefix,
7
+ matchRawToolName,
8
+ resolveServerFromToolName,
9
+ sanitizeServerPrefix,
10
+ } from "./tool-naming.js";
11
+
12
+ describe("sanitizeServerPrefix", () => {
13
+ it("keeps alphanumerics, underscores, and dashes untouched", () => {
14
+ expect(sanitizeServerPrefix("my-server_1")).toBe("my-server_1");
15
+ });
16
+
17
+ it("hex-encodes disallowed characters with _<hex>_ wrappers", () => {
18
+ expect(sanitizeServerPrefix("a.b")).toBe("a_2e_b");
19
+ });
20
+
21
+ it("hex-encodes multiple disallowed characters", () => {
22
+ // "!" = 0x21, "." = 0x2e
23
+ expect(sanitizeServerPrefix("a!b.c")).toBe("a_21_b_2e_c");
24
+ });
25
+ });
26
+
27
+ describe("getServerPrefix", () => {
28
+ it("server mode sanitizes the raw name", () => {
29
+ expect(getServerPrefix("my.server", "server")).toBe("my_2e_server");
30
+ });
31
+
32
+ it("none mode returns an empty prefix", () => {
33
+ expect(getServerPrefix("anything", "none")).toBe("");
34
+ });
35
+
36
+ it("short mode strips a trailing -mcp suffix before sanitizing", () => {
37
+ expect(getServerPrefix("filesystem-mcp", "short")).toBe("filesystem");
38
+ });
39
+
40
+ it("short mode strips a bare mcp suffix (case-insensitive)", () => {
41
+ expect(getServerPrefix("GitHubMCP", "short")).toBe("GitHub");
42
+ });
43
+
44
+ it("mcp mode wraps the sanitized name in mcp__", () => {
45
+ expect(getServerPrefix("my.server", "mcp")).toBe("mcp__my_2e_server");
46
+ });
47
+ });
48
+
49
+ describe("formatToolName", () => {
50
+ it("builds <prefix>_<tool> for server mode", () => {
51
+ expect(formatToolName("search", "github", "server")).toBe("github_search");
52
+ });
53
+
54
+ it("returns the bare tool name in none mode", () => {
55
+ expect(formatToolName("search", "github", "none")).toBe("search");
56
+ });
57
+
58
+ it("converts dots in tool names to underscores", () => {
59
+ expect(formatToolName("a.b.c", "srv", "server")).toBe("srv_a_b_c");
60
+ });
61
+
62
+ it("combines mcp mode with dot conversion", () => {
63
+ expect(formatToolName("a.b", "fs-mcp", "mcp")).toBe("mcp__fs-mcp_a_b");
64
+ });
65
+ });
66
+
67
+ describe("resolveServerFromToolName", () => {
68
+ it("resolves the owning server", () => {
69
+ const servers = [
70
+ { name: "github", prefix: "server" as const },
71
+ { name: "jira", prefix: "server" as const },
72
+ ];
73
+ expect(resolveServerFromToolName("github_search", servers)).toBe("github");
74
+ });
75
+
76
+ it("computes each server's prefix using ITS OWN mode", () => {
77
+ // In "short" mode, "x-mcp" has prefix "x", so "x_tool" resolves to it.
78
+ const servers = [{ name: "x-mcp", prefix: "short" as const }];
79
+ expect(resolveServerFromToolName("x_tool", servers)).toBe("x-mcp");
80
+ // In "server" mode the same server would be "x-mcp", so "x_tool" must NOT match.
81
+ const servers2 = [{ name: "x-mcp", prefix: "server" as const }];
82
+ expect(resolveServerFromToolName("x_tool", servers2)).toBeUndefined();
83
+ });
84
+
85
+ it("longest matching prefix wins", () => {
86
+ const servers = [
87
+ { name: "a", prefix: "server" as const },
88
+ { name: "a_b", prefix: "server" as const },
89
+ ];
90
+ // "a_b_x" matches both "a_" (len 1) and "a_b_" (len 3) → "a_b" wins
91
+ expect(resolveServerFromToolName("a_b_x", servers)).toBe("a_b");
92
+ });
93
+
94
+ it("returns undefined when two servers tie on the longest matching prefix", () => {
95
+ const servers = [
96
+ { name: "github", prefix: "server" as const },
97
+ { name: "github-mcp", prefix: "short" as const }, // also resolves to "github"
98
+ ];
99
+ expect(resolveServerFromToolName("github_search", servers)).toBeUndefined();
100
+ });
101
+
102
+ it("returns undefined when no server matches", () => {
103
+ const servers = [{ name: "github", prefix: "server" as const }];
104
+ expect(resolveServerFromToolName("read", servers)).toBeUndefined();
105
+ expect(resolveServerFromToolName("nonesrv_tool", [{ name: "s", prefix: "none" as const }])).toBeUndefined();
106
+ });
107
+ });
108
+
109
+ describe("matchRawToolName", () => {
110
+ it("resolves the raw dotted tool name from its sanitized prefixed name", () => {
111
+ const tools = [{ name: "a.b" }, { name: "x.y" }, { name: "plain" }];
112
+ expect(matchRawToolName("srv_a_b", "srv", "server", tools)).toBe("a.b");
113
+ expect(matchRawToolName("srv_plain", "srv", "server", tools)).toBe("plain");
114
+ });
115
+
116
+ it("uses the server's OWN prefix mode", () => {
117
+ const tools = [{ name: "a.b" }];
118
+ // short mode: "github-mcp" → "github"
119
+ expect(matchRawToolName("github_a_b", "github-mcp", "short", tools)).toBe("a.b");
120
+ // mcp mode: "mcp__srv"
121
+ expect(matchRawToolName("mcp__srv_a_b", "srv", "mcp", tools)).toBe("a.b");
122
+ // none mode: bare (but dot-sanitized) name
123
+ expect(matchRawToolName("a_b", "srv", "none", tools)).toBe("a.b");
124
+ // A name formatted under one mode must not match under another
125
+ expect(matchRawToolName("github_a_b", "github-mcp", "server", tools)).toBeUndefined();
126
+ });
127
+
128
+ it("returns undefined when no tool formats to the given name", () => {
129
+ expect(matchRawToolName("srv_nope", "srv", "server", [{ name: "a.b" }])).toBeUndefined();
130
+ expect(matchRawToolName("srv_a_b", "srv", "server", [])).toBeUndefined();
131
+ });
132
+ });
133
+
134
+ describe("findFormattingCollisions", () => {
135
+ it("detects raw names that format to the same final name", () => {
136
+ const tools = [{ name: "a.b" }, { name: "a_b" }, { name: "plain" }];
137
+ expect(findFormattingCollisions("srv", "server", tools)).toEqual([
138
+ { finalName: "srv_a_b", rawNames: ["a.b", "a_b"] },
139
+ ]);
140
+ });
141
+
142
+ it("returns [] when all formatted names are unique", () => {
143
+ const tools = [{ name: "a.b" }, { name: "x.y" }, { name: "plain" }];
144
+ expect(findFormattingCollisions("srv", "server", tools)).toEqual([]);
145
+ });
146
+
147
+ it("detects collisions in none mode (bare sanitized names)", () => {
148
+ expect(findFormattingCollisions("srv", "none", [{ name: "a.b" }, { name: "a_b" }])).toEqual([
149
+ { finalName: "a_b", rawNames: ["a.b", "a_b"] },
150
+ ]);
151
+ });
152
+
153
+ it("excludes raw names that format distinctly from the collision", () => {
154
+ const tools = [{ name: "a.b" }, { name: "a_b" }, { name: "aB" }];
155
+ // "aB" formats to "mcp__srv_aB" — NOT part of the collision group
156
+ expect(findFormattingCollisions("srv", "mcp", tools)).toEqual([
157
+ { finalName: "mcp__srv_a_b", rawNames: ["a.b", "a_b"] },
158
+ ]);
159
+ });
160
+ });
161
+
162
+ describe("BUILTIN_NAMES", () => {
163
+ it("contains the pi builtin tool names including mcp", () => {
164
+ for (const name of ["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]) {
165
+ expect(BUILTIN_NAMES.has(name)).toBe(true);
166
+ }
167
+ });
168
+ });
@@ -0,0 +1,114 @@
1
+ import type { ToolPrefix } from "./types.js";
2
+
3
+ /** Sanitize a server name: keep [A-Za-z0-9_-], replace others with _<hex>_ */
4
+ export function sanitizeServerPrefix(name: string): string {
5
+ return name.replace(/[^A-Za-z0-9_-]/g, (c) => `_${c.codePointAt(0)!.toString(16)}_`);
6
+ }
7
+
8
+ export function getServerPrefix(serverName: string, prefix: ToolPrefix): string {
9
+ switch (prefix) {
10
+ case "none": return "";
11
+ case "short": return sanitizeServerPrefix(serverName.replace(/-?mcp$/i, ""));
12
+ case "mcp": return `mcp__${sanitizeServerPrefix(serverName)}`;
13
+ case "server":
14
+ default: return sanitizeServerPrefix(serverName);
15
+ }
16
+ }
17
+
18
+ /** Build prefixed tool name: <prefix>_<toolName with . → _> */
19
+ export function formatToolName(toolName: string, serverName: string, prefix: ToolPrefix): string {
20
+ const p = getServerPrefix(serverName, prefix);
21
+ const sanitized = toolName.replace(/\./g, "_");
22
+ return p ? `${p}_${sanitized}` : sanitized;
23
+ }
24
+
25
+ /**
26
+ * Inverse: find server owning a prefixed tool name by longest matching prefix.
27
+ * Returns undefined if no match OR if two servers tie for the longest matching prefix (ambiguous).
28
+ * IMPORTANT: compute each server's prefix using ITS OWN mode, not a hardcoded "server".
29
+ */
30
+ export function resolveServerFromToolName(
31
+ prefixedName: string,
32
+ servers: Array<{ name: string; prefix: ToolPrefix }>,
33
+ ): string | undefined {
34
+ const matches: Array<{ name: string; prefixLen: number }> = [];
35
+ for (const { name, prefix } of servers) {
36
+ const p = getServerPrefix(name, prefix);
37
+ if (p && prefixedName.startsWith(`${p}_`)) matches.push({ name, prefixLen: p.length });
38
+ }
39
+ if (matches.length === 0) return undefined;
40
+ matches.sort((a, b) => b.prefixLen - a.prefixLen);
41
+ // Ambiguous if the top two share the winning prefix length
42
+ if (matches.length > 1 && matches[0]!.prefixLen === matches[1]!.prefixLen) return undefined;
43
+ return matches[0]!.name;
44
+ }
45
+
46
+ /**
47
+ * Group a server's raw tool names by their final formatted name, returning
48
+ * only the groups with 2+ members. formatToolName is NOT injective: the
49
+ * `.`→`_` sanitization means e.g. raw names "a.b" and "a_b" both format to
50
+ * "srv_a_b" — such groups are genuinely ambiguous, and name resolution is
51
+ * first-match-by-list-order.
52
+ */
53
+ export function findFormattingCollisions(
54
+ serverName: string,
55
+ prefix: ToolPrefix,
56
+ tools: Array<{ name: string }>,
57
+ ): Array<{ finalName: string; rawNames: string[] }> {
58
+ const byFinal = new Map<string, string[]>();
59
+ for (const t of tools) {
60
+ const finalName = formatToolName(t.name, serverName, prefix);
61
+ const names = byFinal.get(finalName);
62
+ if (names) names.push(t.name);
63
+ else byFinal.set(finalName, [t.name]);
64
+ }
65
+ return [...byFinal.entries()]
66
+ .filter(([, rawNames]) => rawNames.length > 1)
67
+ .map(([finalName, rawNames]) => ({ finalName, rawNames }));
68
+ }
69
+
70
+ /**
71
+ * Given a final (possibly prefixed) tool name, find the raw server tool name
72
+ * that formats to it under the server's own prefix mode. Returns undefined
73
+ * when no tool formats to the given name (e.g. the tool list is stale) —
74
+ * callers may then fall back to prefix-stripping, which is only lossless for
75
+ * dot-free tool names (`a.b` → `a_b` is NOT reversible by slicing).
76
+ *
77
+ * NOTE: because formatToolName is not injective ("a.b" and "a_b" format to
78
+ * the same final name), a matching final name may correspond to MULTIPLE
79
+ * raw names; the FIRST match in the provided tool list wins, silently. Use
80
+ * findFormattingCollisions to detect and surface such ambiguity.
81
+ */
82
+ export function matchRawToolName(
83
+ finalName: string,
84
+ serverName: string,
85
+ prefix: ToolPrefix,
86
+ tools: Array<{ name: string }>,
87
+ ): string | undefined {
88
+ for (const t of tools) {
89
+ if (formatToolName(t.name, serverName, prefix) === finalName) return t.name;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ /**
95
+ * Resolve a server reference to a configured server name. Accepts an exact
96
+ * server name, a final prefixed tool name (delegated to
97
+ * resolveServerFromToolName), or a bare tool-name prefix (e.g. "github" for
98
+ * server "github-mcp" under "short" mode).
99
+ */
100
+ export function resolveServerRef(
101
+ ref: string,
102
+ servers: Array<{ name: string; prefix: ToolPrefix }>,
103
+ ): string | undefined {
104
+ const byToolName = resolveServerFromToolName(ref, servers);
105
+ if (byToolName) return byToolName;
106
+ // Exact bare-prefix match (only unambiguous single matches are accepted)
107
+ const exact = servers.filter((s) => getServerPrefix(s.name, s.prefix) === ref);
108
+ if (exact.length === 1) return exact[0]!.name;
109
+ return undefined;
110
+ }
111
+
112
+ export const BUILTIN_NAMES = new Set([
113
+ "read", "bash", "edit", "write", "grep", "find", "ls", "mcp",
114
+ ]);
package/src/types.ts ADDED
@@ -0,0 +1,162 @@
1
+ /** Strategy for prefixing exposed tool names */
2
+ export type ToolPrefix = "server" | "none" | "short" | "mcp";
3
+
4
+ /**
5
+ * Lifecycle & tooling settings shared by both stdio and HTTP server defs.
6
+ * These can appear per-server in mcp.json files.
7
+ */
8
+ export interface SharedServerSettings {
9
+ /** Connection lifecycle (default: keep-alive) */
10
+ lifecycle?: "keep-alive" | "lazy" | "lazy-keep-alive" | "eager";
11
+ /** Idle timeout in minutes; 0 disables */
12
+ idleTimeout?: number;
13
+ /** Per-request timeout in milliseconds */
14
+ requestTimeoutMs?: number;
15
+ /** Expose direct tools; true = all, string[] = subset of tool names */
16
+ directTools?: boolean | string[];
17
+ /** Only expose these tools (whitelist) */
18
+ includeTools?: string[];
19
+ /** Never expose these tools (blacklist) */
20
+ excludeTools?: string[];
21
+ /** Tool name prefix strategy (default: "server") */
22
+ toolPrefix?: ToolPrefix;
23
+ /** Expose resources as a list_resources tool */
24
+ exposeResources?: boolean;
25
+ /** Verbose logging for this server */
26
+ debug?: boolean;
27
+ /** Pin the MCP protocol version used for this server */
28
+ protocolVersion?: string;
29
+ }
30
+
31
+ /** A stdio-based MCP server (spawns a child process) */
32
+ export interface StdioServerDef extends SharedServerSettings {
33
+ type?: "stdio";
34
+ command: string;
35
+ args?: string[];
36
+ env?: Record<string, string>;
37
+ cwd?: string;
38
+ disabled?: boolean;
39
+ }
40
+
41
+ /** OAuth 2.1 settings for an HTTP MCP server (see plan-026 / ADR 0001) */
42
+ export interface McpOAuthConfig {
43
+ grantType?: "authorization_code" | "client_credentials"; // default authorization_code
44
+ clientId?: string;
45
+ clientSecret?: string; // literal only (no "!command" resolution — see ADR/scope)
46
+ scope?: string;
47
+ redirectUri?: string; // pre-registered clients only
48
+ clientName?: string;
49
+ authorizationServerUrl?: string;
50
+ }
51
+
52
+ /** The full set of known `McpOAuthConfig` fields, in no particular order. */
53
+ export const OAUTH_CONFIG_FIELDS = [
54
+ "grantType",
55
+ "clientId",
56
+ "clientSecret",
57
+ "scope",
58
+ "redirectUri",
59
+ "clientName",
60
+ "authorizationServerUrl",
61
+ ] as const;
62
+
63
+ /** An HTTP-based MCP server (Streamable HTTP or SSE) */
64
+ export interface HttpServerDef extends SharedServerSettings {
65
+ /** Informational only — the transport is chosen by def shape: a `url` server
66
+ * connects via StreamableHTTP (with SSE fallback for legacy servers). */
67
+ type?: "http" | "sse";
68
+ url: string;
69
+ /** Bearer token, the "oauth" string (grant-type defaults), or a full OAuth config */
70
+ auth?: { token: string } | "oauth" | McpOAuthConfig;
71
+ /** Extra HTTP headers sent with every request */
72
+ headers?: Record<string, string>;
73
+ /** Name of an environment variable holding the bearer token */
74
+ bearerTokenEnv?: string;
75
+ disabled?: boolean;
76
+ }
77
+
78
+ export type ServerDef = StdioServerDef | HttpServerDef;
79
+
80
+ export interface McpFileConfig {
81
+ mcpServers?: Record<string, ServerDef>;
82
+ settings?: McpFileSettings;
83
+ }
84
+
85
+ /** Settings that can appear in the mcp config files (not archimedes.mcp) */
86
+ export interface McpFileSettings {
87
+ [key: string]: unknown;
88
+ }
89
+
90
+ /** Settings read from archimedes.mcp in settings.json */
91
+ export interface McpConfig {
92
+ /** Show direct tools per server in the tool list (default: true) */
93
+ directTools: boolean;
94
+ /** Tool name prefix strategy (default: "server") */
95
+ toolPrefix: ToolPrefix;
96
+ /** Idle timeout in minutes (default: 10) */
97
+ idleTimeout: number;
98
+ /** Warn when a server exposes many direct tools (default: true) */
99
+ warnOnLargeDirectTools: boolean;
100
+ /**
101
+ * When a tool call reaches a server in the `needs-auth` state, trigger the
102
+ * interactive OAuth flow inline (single entry point: ServerClient
103
+ * .authenticate) instead of returning guidance to run `/mcp auth`.
104
+ * (default: false — guidance only)
105
+ */
106
+ autoAuth: boolean;
107
+ }
108
+
109
+ export const DEFAULT_MCP_CONFIG: McpConfig = {
110
+ directTools: true,
111
+ toolPrefix: "server",
112
+ idleTimeout: 10,
113
+ warnOnLargeDirectTools: true,
114
+ autoAuth: false,
115
+ };
116
+
117
+ export const MCP_NAMESPACE = "archimedes.mcp";
118
+
119
+ /** A single cached tool definition from a server */
120
+ export interface CachedTool {
121
+ name: string;
122
+ description?: string;
123
+ inputSchema: unknown;
124
+ }
125
+
126
+ /** Cached metadata for one server */
127
+ export interface ServerCacheEntry {
128
+ /** Hash of the server definition at cache time */
129
+ configHash: string;
130
+ tools: CachedTool[];
131
+ resources: Array<{ uri: string; name?: string; description?: string; mimeType?: string }>;
132
+ prompts?: Array<{ name: string; description?: string }>;
133
+ /** Server-level instructions from initialize */
134
+ instructions?: string;
135
+ /** Epoch milliseconds when the entry was written */
136
+ cachedAt: number;
137
+ }
138
+
139
+ /** Persisted connection outcome for one server (ADR 0004). */
140
+ export interface ServerOutcomeRecord {
141
+ status: "connected" | "needs-auth" | "error";
142
+ /** Error text for needs-auth/error outcomes (first line). */
143
+ error?: string;
144
+ /** Epoch milliseconds when the outcome was recorded. */
145
+ at: number;
146
+ }
147
+
148
+ /** On-disk shape of the metadata cache */
149
+ export interface MetadataCache {
150
+ version: number;
151
+ servers: Record<string, ServerCacheEntry>;
152
+ /**
153
+ * Last connection outcome per server (ADR 0004). Additive: old cache
154
+ * files lack the field — a missing key means "not verified", so there is
155
+ * no CACHE_VERSION bump. `loadMetadataCache` must round-trip it: dropping
156
+ * the field would silence every `saveServerCache` rewrite.
157
+ */
158
+ serverStatuses?: Record<string, ServerOutcomeRecord>;
159
+ }
160
+
161
+ export const CACHE_VERSION = 1;
162
+ export const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days