mcpsmgr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +1631 -0
  5. package/dist/index.js.map +1 -0
  6. package/docs/README_zh-CN.md +99 -0
  7. package/openspec/changes/archive/2026-03-12-fix-global-mcp-default-selection/.openspec.yaml +2 -0
  8. package/openspec/changes/archive/2026-03-12-fix-global-mcp-default-selection/design.md +41 -0
  9. package/openspec/changes/archive/2026-03-12-fix-global-mcp-default-selection/proposal.md +28 -0
  10. package/openspec/changes/archive/2026-03-12-fix-global-mcp-default-selection/specs/project-operations/spec.md +53 -0
  11. package/openspec/changes/archive/2026-03-12-fix-global-mcp-default-selection/tasks.md +9 -0
  12. package/openspec/changes/archive/2026-03-12-fix-init-server-detection/.openspec.yaml +2 -0
  13. package/openspec/changes/archive/2026-03-12-fix-init-server-detection/design.md +40 -0
  14. package/openspec/changes/archive/2026-03-12-fix-init-server-detection/proposal.md +25 -0
  15. package/openspec/changes/archive/2026-03-12-fix-init-server-detection/specs/project-operations/spec.md +25 -0
  16. package/openspec/changes/archive/2026-03-12-fix-init-server-detection/tasks.md +10 -0
  17. package/openspec/changes/archive/2026-03-12-graceful-exit-on-interrupt/.openspec.yaml +2 -0
  18. package/openspec/changes/archive/2026-03-12-graceful-exit-on-interrupt/design.md +32 -0
  19. package/openspec/changes/archive/2026-03-12-graceful-exit-on-interrupt/proposal.md +25 -0
  20. package/openspec/changes/archive/2026-03-12-graceful-exit-on-interrupt/specs/project-operations/spec.md +30 -0
  21. package/openspec/changes/archive/2026-03-12-graceful-exit-on-interrupt/specs/server-management/spec.md +15 -0
  22. package/openspec/changes/archive/2026-03-12-graceful-exit-on-interrupt/tasks.md +17 -0
  23. package/openspec/changes/archive/2026-03-12-mcps-manager-cli/.openspec.yaml +2 -0
  24. package/openspec/changes/archive/2026-03-12-mcps-manager-cli/design.md +104 -0
  25. package/openspec/changes/archive/2026-03-12-mcps-manager-cli/proposal.md +34 -0
  26. package/openspec/changes/archive/2026-03-12-mcps-manager-cli/specs/agent-adapters/spec.md +110 -0
  27. package/openspec/changes/archive/2026-03-12-mcps-manager-cli/specs/central-storage/spec.md +38 -0
  28. package/openspec/changes/archive/2026-03-12-mcps-manager-cli/specs/glm-integration/spec.md +66 -0
  29. package/openspec/changes/archive/2026-03-12-mcps-manager-cli/specs/project-operations/spec.md +76 -0
  30. package/openspec/changes/archive/2026-03-12-mcps-manager-cli/specs/server-management/spec.md +75 -0
  31. package/openspec/changes/archive/2026-03-12-mcps-manager-cli/tasks.md +60 -0
  32. package/openspec/config.yaml +20 -0
  33. package/openspec/specs/agent-adapters/spec.md +148 -0
  34. package/openspec/specs/central-storage/spec.md +42 -0
  35. package/openspec/specs/glm-integration/spec.md +70 -0
  36. package/openspec/specs/project-operations/spec.md +138 -0
  37. package/openspec/specs/server-management/spec.md +93 -0
  38. package/package.json +33 -0
  39. package/src/__tests__/integration.test.ts +200 -0
  40. package/src/adapters/__tests__/adapters.test.ts +274 -0
  41. package/src/adapters/antigravity.ts +114 -0
  42. package/src/adapters/claude-code.ts +114 -0
  43. package/src/adapters/codex-cli.ts +135 -0
  44. package/src/adapters/env-args.ts +51 -0
  45. package/src/adapters/gemini-cli.ts +110 -0
  46. package/src/adapters/index.ts +32 -0
  47. package/src/adapters/json-file.ts +24 -0
  48. package/src/adapters/opencode.ts +114 -0
  49. package/src/commands/add.ts +68 -0
  50. package/src/commands/init.ts +136 -0
  51. package/src/commands/list.ts +77 -0
  52. package/src/commands/remove.ts +61 -0
  53. package/src/commands/server-add.ts +211 -0
  54. package/src/commands/server-list.ts +24 -0
  55. package/src/commands/server-remove.ts +12 -0
  56. package/src/commands/setup.ts +71 -0
  57. package/src/commands/sync.ts +98 -0
  58. package/src/index.ts +100 -0
  59. package/src/services/glm-client.ts +190 -0
  60. package/src/services/system-prompt.ts +61 -0
  61. package/src/services/web-reader.ts +130 -0
  62. package/src/types.ts +59 -0
  63. package/src/utils/config.ts +22 -0
  64. package/src/utils/paths.ts +11 -0
  65. package/src/utils/prompt.ts +3 -0
  66. package/src/utils/resolve-config.ts +13 -0
  67. package/src/utils/server-store.ts +56 -0
  68. package/tsconfig.json +17 -0
  69. package/tsup.config.ts +13 -0
  70. package/vitest.config.ts +8 -0
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, readFile, mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import type { ServerDefinition, StdioConfig } from "../types.js";
6
+ import { claudeCodeAdapter } from "../adapters/claude-code.js";
7
+ import { codexCliAdapter } from "../adapters/codex-cli.js";
8
+ import { geminiCliAdapter } from "../adapters/gemini-cli.js";
9
+ import { opencodeAdapter } from "../adapters/opencode.js";
10
+ import { resolveConfig } from "../utils/resolve-config.js";
11
+ import {
12
+ isGitHubRepo,
13
+ isValidInput,
14
+ buildUserMessage,
15
+ } from "../services/glm-client.js";
16
+
17
+ let tmpDir: string;
18
+
19
+ beforeEach(async () => {
20
+ tmpDir = await mkdtemp(join(tmpdir(), "mcpsmgr-e2e-"));
21
+ });
22
+
23
+ afterEach(async () => {
24
+ await rm(tmpDir, { recursive: true });
25
+ });
26
+
27
+ const sampleDefinition: ServerDefinition = {
28
+ name: "brave-search",
29
+ source: "https://github.com/anthropics/mcp-brave-search",
30
+ default: {
31
+ transport: "stdio",
32
+ command: "npx",
33
+ args: ["-y", "@anthropic/mcp-brave-search"],
34
+ env: { BRAVE_API_KEY: "test-key-123" },
35
+ },
36
+ overrides: {},
37
+ };
38
+
39
+ const definitionWithOverrides: ServerDefinition = {
40
+ name: "custom-mcp",
41
+ source: "https://example.com",
42
+ default: {
43
+ transport: "stdio",
44
+ command: "npx",
45
+ args: ["-y", "custom-mcp"],
46
+ env: { API_KEY: "default-key" },
47
+ },
48
+ overrides: {
49
+ "claude-code": {
50
+ transport: "http",
51
+ url: "https://example.com/mcp",
52
+ headers: { Authorization: "Bearer token" },
53
+ },
54
+ },
55
+ };
56
+
57
+ describe("E2E: server add -> deploy to agents -> list -> sync -> remove", () => {
58
+ it("deploys a server definition to all project-level adapters", async () => {
59
+ const adapters = [
60
+ claudeCodeAdapter,
61
+ codexCliAdapter,
62
+ geminiCliAdapter,
63
+ opencodeAdapter,
64
+ ];
65
+
66
+ for (const adapter of adapters) {
67
+ const config = resolveConfig(sampleDefinition, adapter);
68
+ await adapter.write(tmpDir, sampleDefinition.name, config);
69
+ }
70
+
71
+ const ccServers = await claudeCodeAdapter.read(tmpDir);
72
+ expect(ccServers["brave-search"]).toBeTruthy();
73
+
74
+ const codexServers = await codexCliAdapter.read(tmpDir);
75
+ expect(codexServers["brave-search"]).toBeTruthy();
76
+
77
+ const geminiServers = await geminiCliAdapter.read(tmpDir);
78
+ expect(geminiServers["brave-search"]).toBeTruthy();
79
+
80
+ const ocServers = await opencodeAdapter.read(tmpDir);
81
+ expect(ocServers["brave-search"]).toBeTruthy();
82
+
83
+ const ocConfig = ocServers["brave-search"] as Record<string, unknown>;
84
+ expect(ocConfig["type"]).toBe("local");
85
+ expect(ocConfig["command"]).toEqual([
86
+ "env",
87
+ "BRAVE_API_KEY=test-key-123",
88
+ "npx",
89
+ "-y",
90
+ "@anthropic/mcp-brave-search",
91
+ ]);
92
+ });
93
+
94
+ it("respects overrides per agent", async () => {
95
+ const ccConfig = resolveConfig(definitionWithOverrides, claudeCodeAdapter);
96
+ expect(ccConfig.transport).toBe("http");
97
+
98
+ const codexConfig = resolveConfig(definitionWithOverrides, codexCliAdapter);
99
+ expect(codexConfig.transport).toBe("stdio");
100
+ });
101
+
102
+ it("detects conflicts and prevents double-write", async () => {
103
+ await claudeCodeAdapter.write(tmpDir, "brave-search", sampleDefinition.default);
104
+ await expect(
105
+ claudeCodeAdapter.write(tmpDir, "brave-search", sampleDefinition.default),
106
+ ).rejects.toThrow("Conflict");
107
+ });
108
+
109
+ it("removes server from all adapters", async () => {
110
+ const adapters = [
111
+ claudeCodeAdapter,
112
+ codexCliAdapter,
113
+ geminiCliAdapter,
114
+ opencodeAdapter,
115
+ ];
116
+
117
+ for (const adapter of adapters) {
118
+ await adapter.write(tmpDir, "brave-search", sampleDefinition.default);
119
+ }
120
+
121
+ for (const adapter of adapters) {
122
+ await adapter.remove(tmpDir, "brave-search");
123
+ const has = await adapter.has(tmpDir, "brave-search");
124
+ expect(has).toBe(false);
125
+ }
126
+ });
127
+
128
+ it("sync: updates config after central repo change", async () => {
129
+ await claudeCodeAdapter.write(tmpDir, "brave-search", sampleDefinition.default);
130
+
131
+ const updatedConfig: StdioConfig = {
132
+ transport: "stdio",
133
+ command: "npx",
134
+ args: ["-y", "@anthropic/mcp-brave-search@2.0"],
135
+ env: { BRAVE_API_KEY: "new-key-456" },
136
+ };
137
+
138
+ await claudeCodeAdapter.remove(tmpDir, "brave-search");
139
+ await claudeCodeAdapter.write(tmpDir, "brave-search", updatedConfig);
140
+
141
+ const servers = await claudeCodeAdapter.read(tmpDir);
142
+ const config = servers["brave-search"] as Record<string, unknown>;
143
+ expect(config["command"]).toBe("env");
144
+ expect(config["args"]).toEqual([
145
+ "BRAVE_API_KEY=new-key-456",
146
+ "npx",
147
+ "-y",
148
+ "@anthropic/mcp-brave-search@2.0",
149
+ ]);
150
+ });
151
+
152
+ it("preserves other content in config files", async () => {
153
+ const geminiPath = join(tmpDir, ".gemini", "settings.json");
154
+ await mkdir(join(tmpDir, ".gemini"), { recursive: true });
155
+ await writeFile(
156
+ geminiPath,
157
+ JSON.stringify({ theme: "dark", language: "en" }),
158
+ );
159
+
160
+ await geminiCliAdapter.write(tmpDir, "brave-search", sampleDefinition.default);
161
+
162
+ const raw = JSON.parse(await readFile(geminiPath, "utf-8"));
163
+ expect(raw["theme"]).toBe("dark");
164
+ expect(raw["language"]).toBe("en");
165
+ expect(raw["mcpServers"]["brave-search"]).toBeTruthy();
166
+ });
167
+ });
168
+
169
+ describe("Input validation", () => {
170
+ it("recognizes GitHub owner/repo format", () => {
171
+ expect(isGitHubRepo("anthropics/mcp-brave-search")).toBe(true);
172
+ expect(isGitHubRepo("https://github.com/foo/bar")).toBe(false);
173
+ expect(isGitHubRepo("@scope/package")).toBe(false);
174
+ expect(isGitHubRepo("bare-name")).toBe(false);
175
+ });
176
+
177
+ it("validates input formats", () => {
178
+ expect(isValidInput("https://example.com")).toEqual({ valid: true });
179
+ expect(isValidInput("anthropics/mcp")).toEqual({ valid: true });
180
+ expect(isValidInput("@scope/pkg")).toEqual({
181
+ valid: false,
182
+ reason: expect.any(String),
183
+ });
184
+ expect(isValidInput("bare-name")).toEqual({
185
+ valid: false,
186
+ reason: expect.any(String),
187
+ });
188
+ });
189
+
190
+ it("builds correct user message for GitHub repos", () => {
191
+ const msg = buildUserMessage("anthropics/mcp-brave-search");
192
+ expect(msg).toContain("https://github.com/anthropics/mcp-brave-search");
193
+ expect(msg).toContain("README");
194
+ });
195
+
196
+ it("builds correct user message for URLs", () => {
197
+ const msg = buildUserMessage("https://docs.example.com/mcp");
198
+ expect(msg).toContain("https://docs.example.com/mcp");
199
+ });
200
+ });
@@ -0,0 +1,274 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import type { StdioConfig, HttpConfig } from "../../types.js";
6
+ import { claudeCodeAdapter } from "../claude-code.js";
7
+ import { codexCliAdapter } from "../codex-cli.js";
8
+ import { geminiCliAdapter } from "../gemini-cli.js";
9
+ import { opencodeAdapter } from "../opencode.js";
10
+
11
+ const stdioConfig: StdioConfig = {
12
+ transport: "stdio",
13
+ command: "npx",
14
+ args: ["-y", "@anthropic/mcp-brave-search"],
15
+ env: { BRAVE_API_KEY: "test-key" },
16
+ };
17
+
18
+ const stdioConfigWithRef: StdioConfig = {
19
+ transport: "stdio",
20
+ command: "npx",
21
+ args: [
22
+ "-y",
23
+ "mcp-remote",
24
+ "https://mcp.example.com/v1",
25
+ "--header",
26
+ "Authorization: Bearer ${API_KEY}",
27
+ ],
28
+ env: { API_KEY: "sk-test-123" },
29
+ };
30
+
31
+ const stdioConfigMixed: StdioConfig = {
32
+ transport: "stdio",
33
+ command: "npx",
34
+ args: [
35
+ "-y",
36
+ "mcp-remote",
37
+ "--header",
38
+ "Authorization: Bearer ${API_KEY}",
39
+ ],
40
+ env: { API_KEY: "sk-test-123", DEBUG: "true" },
41
+ };
42
+
43
+ const httpConfig: HttpConfig = {
44
+ transport: "http",
45
+ url: "https://example.com/mcp",
46
+ headers: { Authorization: "Bearer test-token" },
47
+ };
48
+
49
+ let tmpDir: string;
50
+
51
+ beforeEach(async () => {
52
+ tmpDir = await mkdtemp(join(tmpdir(), "mcpsmgr-test-"));
53
+ });
54
+
55
+ afterEach(async () => {
56
+ await rm(tmpDir, { recursive: true });
57
+ });
58
+
59
+ describe("Claude Code Adapter", () => {
60
+ it("writes and reads stdio config", async () => {
61
+ await claudeCodeAdapter.write(tmpDir, "brave-search", stdioConfig);
62
+ const servers = await claudeCodeAdapter.read(tmpDir);
63
+ expect(servers["brave-search"]).toEqual({
64
+ type: "stdio",
65
+ command: "env",
66
+ args: ["BRAVE_API_KEY=test-key", "npx", "-y", "@anthropic/mcp-brave-search"],
67
+ });
68
+ });
69
+
70
+ it("writes and reads http config", async () => {
71
+ await claudeCodeAdapter.write(tmpDir, "my-mcp", httpConfig);
72
+ const servers = await claudeCodeAdapter.read(tmpDir);
73
+ expect(servers["my-mcp"]).toEqual({
74
+ type: "http",
75
+ url: "https://example.com/mcp",
76
+ headers: { Authorization: "Bearer test-token" },
77
+ });
78
+ });
79
+
80
+ it("throws on conflict", async () => {
81
+ await claudeCodeAdapter.write(tmpDir, "brave-search", stdioConfig);
82
+ await expect(
83
+ claudeCodeAdapter.write(tmpDir, "brave-search", stdioConfig),
84
+ ).rejects.toThrow("Conflict");
85
+ });
86
+
87
+ it("removes a server", async () => {
88
+ await claudeCodeAdapter.write(tmpDir, "brave-search", stdioConfig);
89
+ await claudeCodeAdapter.remove(tmpDir, "brave-search");
90
+ const has = await claudeCodeAdapter.has(tmpDir, "brave-search");
91
+ expect(has).toBe(false);
92
+ });
93
+
94
+ it("preserves other servers on write", async () => {
95
+ await claudeCodeAdapter.write(tmpDir, "first", stdioConfig);
96
+ await claudeCodeAdapter.write(tmpDir, "second", httpConfig);
97
+ const servers = await claudeCodeAdapter.read(tmpDir);
98
+ expect(Object.keys(servers)).toEqual(["first", "second"]);
99
+ });
100
+
101
+ it("converts from new agent format (env command)", () => {
102
+ const result = claudeCodeAdapter.fromAgentFormat("test", {
103
+ type: "stdio",
104
+ command: "env",
105
+ args: ["KEY=val", "npx", "-y", "pkg"],
106
+ });
107
+ expect(result).toEqual({
108
+ transport: "stdio",
109
+ command: "npx",
110
+ args: ["-y", "pkg"],
111
+ env: { KEY: "val" },
112
+ });
113
+ });
114
+
115
+ it("converts from legacy agent format (env field)", () => {
116
+ const result = claudeCodeAdapter.fromAgentFormat("test", {
117
+ type: "stdio",
118
+ command: "npx",
119
+ args: ["-y", "pkg"],
120
+ env: { KEY: "val" },
121
+ });
122
+ expect(result).toEqual({
123
+ transport: "stdio",
124
+ command: "npx",
125
+ args: ["-y", "pkg"],
126
+ env: { KEY: "val" },
127
+ });
128
+ });
129
+
130
+ it("substitutes ${VAR} references in args and removes env", async () => {
131
+ await claudeCodeAdapter.write(tmpDir, "jina", stdioConfigWithRef);
132
+ const servers = await claudeCodeAdapter.read(tmpDir);
133
+ expect(servers["jina"]).toEqual({
134
+ type: "stdio",
135
+ command: "npx",
136
+ args: [
137
+ "-y",
138
+ "mcp-remote",
139
+ "https://mcp.example.com/v1",
140
+ "--header",
141
+ "Authorization: Bearer sk-test-123",
142
+ ],
143
+ });
144
+ });
145
+
146
+ it("substitutes ${VAR} in args and wraps remaining env vars", async () => {
147
+ await claudeCodeAdapter.write(tmpDir, "mixed", stdioConfigMixed);
148
+ const servers = await claudeCodeAdapter.read(tmpDir);
149
+ expect(servers["mixed"]).toEqual({
150
+ type: "stdio",
151
+ command: "env",
152
+ args: [
153
+ "DEBUG=true",
154
+ "npx",
155
+ "-y",
156
+ "mcp-remote",
157
+ "--header",
158
+ "Authorization: Bearer sk-test-123",
159
+ ],
160
+ });
161
+ });
162
+ });
163
+
164
+ describe("Codex CLI Adapter", () => {
165
+ it("writes and reads stdio config as TOML", async () => {
166
+ await codexCliAdapter.write(tmpDir, "brave-search", stdioConfig);
167
+ const servers = await codexCliAdapter.read(tmpDir);
168
+ expect(servers["brave-search"]).toBeTruthy();
169
+
170
+ const raw = await readFile(
171
+ join(tmpDir, ".codex", "config.toml"),
172
+ "utf-8",
173
+ );
174
+ expect(raw).toContain("[mcp_servers.brave-search]");
175
+ });
176
+
177
+ it("throws on conflict", async () => {
178
+ await codexCliAdapter.write(tmpDir, "brave-search", stdioConfig);
179
+ await expect(
180
+ codexCliAdapter.write(tmpDir, "brave-search", stdioConfig),
181
+ ).rejects.toThrow("Conflict");
182
+ });
183
+
184
+ it("removes a server", async () => {
185
+ await codexCliAdapter.write(tmpDir, "brave-search", stdioConfig);
186
+ await codexCliAdapter.remove(tmpDir, "brave-search");
187
+ const has = await codexCliAdapter.has(tmpDir, "brave-search");
188
+ expect(has).toBe(false);
189
+ });
190
+ });
191
+
192
+ describe("Gemini CLI Adapter", () => {
193
+ it("writes and reads stdio config", async () => {
194
+ await geminiCliAdapter.write(tmpDir, "brave-search", stdioConfig);
195
+ const servers = await geminiCliAdapter.read(tmpDir);
196
+ expect(servers["brave-search"]).toEqual({
197
+ command: "env",
198
+ args: ["BRAVE_API_KEY=test-key", "npx", "-y", "@anthropic/mcp-brave-search"],
199
+ });
200
+ });
201
+
202
+ it("preserves non-MCP fields", async () => {
203
+ const { writeJsonFile } = await import("../json-file.js");
204
+ const filePath = join(tmpDir, ".gemini", "settings.json");
205
+ await writeJsonFile(filePath, { theme: "dark", mcpServers: {} });
206
+
207
+ await geminiCliAdapter.write(tmpDir, "brave-search", stdioConfig);
208
+
209
+ const raw = JSON.parse(await readFile(filePath, "utf-8"));
210
+ expect(raw["theme"]).toBe("dark");
211
+ expect(raw["mcpServers"]["brave-search"]).toBeTruthy();
212
+ });
213
+ });
214
+
215
+ describe("OpenCode Adapter", () => {
216
+ it("writes stdio config with array command", async () => {
217
+ await opencodeAdapter.write(tmpDir, "brave-search", stdioConfig);
218
+ const servers = await opencodeAdapter.read(tmpDir);
219
+ expect(servers["brave-search"]).toEqual({
220
+ type: "local",
221
+ command: ["env", "BRAVE_API_KEY=test-key", "npx", "-y", "@anthropic/mcp-brave-search"],
222
+ });
223
+ });
224
+
225
+ it("writes http config with remote type", async () => {
226
+ await opencodeAdapter.write(tmpDir, "my-mcp", httpConfig);
227
+ const servers = await opencodeAdapter.read(tmpDir);
228
+ expect(servers["my-mcp"]).toEqual({
229
+ type: "remote",
230
+ url: "https://example.com/mcp",
231
+ headers: { Authorization: "Bearer test-token" },
232
+ });
233
+ });
234
+
235
+ it("converts from new agent format (env command)", () => {
236
+ const result = opencodeAdapter.fromAgentFormat("test", {
237
+ type: "local",
238
+ command: ["env", "KEY=val", "npx", "-y", "pkg"],
239
+ });
240
+ expect(result).toEqual({
241
+ transport: "stdio",
242
+ command: "npx",
243
+ args: ["-y", "pkg"],
244
+ env: { KEY: "val" },
245
+ });
246
+ });
247
+
248
+ it("converts from legacy agent format (environment field)", () => {
249
+ const result = opencodeAdapter.fromAgentFormat("test", {
250
+ type: "local",
251
+ command: ["npx", "-y", "pkg"],
252
+ environment: { KEY: "val" },
253
+ });
254
+ expect(result).toEqual({
255
+ transport: "stdio",
256
+ command: "npx",
257
+ args: ["-y", "pkg"],
258
+ env: { KEY: "val" },
259
+ });
260
+ });
261
+
262
+ it("converts from agent format (remote)", () => {
263
+ const result = opencodeAdapter.fromAgentFormat("test", {
264
+ type: "remote",
265
+ url: "https://example.com",
266
+ headers: {},
267
+ });
268
+ expect(result).toEqual({
269
+ transport: "http",
270
+ url: "https://example.com",
271
+ headers: {},
272
+ });
273
+ });
274
+ });
@@ -0,0 +1,114 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import type { AgentAdapter, DefaultConfig } from "../types.js";
4
+ import { readJsonFile, writeJsonFile } from "./json-file.js";
5
+ import { buildEnvArgs, parseEnvArgs, resolveEnvInArgs } from "./env-args.js";
6
+
7
+ const GLOBAL_CONFIG_PATH = join(
8
+ homedir(),
9
+ ".gemini",
10
+ "antigravity",
11
+ "mcp_config.json",
12
+ );
13
+
14
+ function toAgentFormat(config: DefaultConfig): Record<string, unknown> {
15
+ if (config.transport === "stdio") {
16
+ const { resolvedArgs, remainingEnv } = resolveEnvInArgs(
17
+ config.args,
18
+ config.env,
19
+ );
20
+ const envArgs = buildEnvArgs(remainingEnv);
21
+ if (envArgs.length > 0) {
22
+ return {
23
+ command: "env",
24
+ args: [...envArgs, config.command, ...resolvedArgs],
25
+ };
26
+ }
27
+ return {
28
+ command: config.command,
29
+ args: resolvedArgs,
30
+ };
31
+ }
32
+ return {
33
+ serverUrl: config.url,
34
+ headers: { ...config.headers },
35
+ };
36
+ }
37
+
38
+ function fromAgentFormat(
39
+ _name: string,
40
+ raw: Record<string, unknown>,
41
+ ): DefaultConfig | undefined {
42
+ if (raw["command"]) {
43
+ const command = raw["command"] as string;
44
+ const rawArgs = (raw["args"] as string[]) ?? [];
45
+ const legacyEnv = raw["env"] as Record<string, string> | undefined;
46
+
47
+ if (legacyEnv && Object.keys(legacyEnv).length > 0) {
48
+ return { transport: "stdio", command, args: rawArgs, env: legacyEnv };
49
+ }
50
+
51
+ if (command === "env") {
52
+ const { env, commandIndex } = parseEnvArgs(rawArgs);
53
+ return {
54
+ transport: "stdio",
55
+ command: rawArgs[commandIndex] ?? "",
56
+ args: rawArgs.slice(commandIndex + 1),
57
+ env,
58
+ };
59
+ }
60
+
61
+ return { transport: "stdio", command, args: rawArgs, env: {} };
62
+ }
63
+ if (raw["serverUrl"]) {
64
+ return {
65
+ transport: "http",
66
+ url: raw["serverUrl"] as string,
67
+ headers: (raw["headers"] as Record<string, string>) ?? {},
68
+ };
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ export const antigravityAdapter: AgentAdapter = {
74
+ id: "antigravity",
75
+ name: "Antigravity",
76
+ configPath: () => GLOBAL_CONFIG_PATH,
77
+ isGlobal: true,
78
+
79
+ toAgentFormat,
80
+ fromAgentFormat,
81
+
82
+ async read() {
83
+ const data = await readJsonFile(GLOBAL_CONFIG_PATH);
84
+ return (data["mcpServers"] as Record<string, unknown>) ?? {};
85
+ },
86
+
87
+ async write(_projectDir, serverName, config) {
88
+ const data = await readJsonFile(GLOBAL_CONFIG_PATH);
89
+ const servers = (data["mcpServers"] as Record<string, unknown>) ?? {};
90
+ if (serverName in servers) {
91
+ throw new Error(
92
+ `Conflict: "${serverName}" already exists in Antigravity config`,
93
+ );
94
+ }
95
+ const updated = {
96
+ ...data,
97
+ mcpServers: { ...servers, [serverName]: toAgentFormat(config) },
98
+ };
99
+ await writeJsonFile(GLOBAL_CONFIG_PATH, updated);
100
+ },
101
+
102
+ async remove(_projectDir, serverName) {
103
+ const data = await readJsonFile(GLOBAL_CONFIG_PATH);
104
+ const servers = (data["mcpServers"] as Record<string, unknown>) ?? {};
105
+ const { [serverName]: _, ...rest } = servers;
106
+ await writeJsonFile(GLOBAL_CONFIG_PATH, { ...data, mcpServers: rest });
107
+ },
108
+
109
+ async has(_projectDir, serverName) {
110
+ const data = await readJsonFile(GLOBAL_CONFIG_PATH);
111
+ const servers = (data["mcpServers"] as Record<string, unknown>) ?? {};
112
+ return serverName in servers;
113
+ },
114
+ };
@@ -0,0 +1,114 @@
1
+ import { join } from "node:path";
2
+ import type { AgentAdapter, DefaultConfig } from "../types.js";
3
+ import { readJsonFile, writeJsonFile } from "./json-file.js";
4
+ import { buildEnvArgs, parseEnvArgs, resolveEnvInArgs } from "./env-args.js";
5
+
6
+ function toAgentFormat(config: DefaultConfig): Record<string, unknown> {
7
+ if (config.transport === "stdio") {
8
+ const { resolvedArgs, remainingEnv } = resolveEnvInArgs(
9
+ config.args,
10
+ config.env,
11
+ );
12
+ const envArgs = buildEnvArgs(remainingEnv);
13
+ if (envArgs.length > 0) {
14
+ return {
15
+ type: "stdio",
16
+ command: "env",
17
+ args: [...envArgs, config.command, ...resolvedArgs],
18
+ };
19
+ }
20
+ return {
21
+ type: "stdio",
22
+ command: config.command,
23
+ args: resolvedArgs,
24
+ };
25
+ }
26
+ return {
27
+ type: "http",
28
+ url: config.url,
29
+ headers: { ...config.headers },
30
+ };
31
+ }
32
+
33
+ function fromAgentFormat(
34
+ _name: string,
35
+ raw: Record<string, unknown>,
36
+ ): DefaultConfig | undefined {
37
+ const type = raw["type"] as string | undefined;
38
+ if (type === "stdio") {
39
+ const command = raw["command"] as string;
40
+ const rawArgs = (raw["args"] as string[]) ?? [];
41
+ const legacyEnv = raw["env"] as Record<string, string> | undefined;
42
+
43
+ if (legacyEnv && Object.keys(legacyEnv).length > 0) {
44
+ return { transport: "stdio", command, args: rawArgs, env: legacyEnv };
45
+ }
46
+
47
+ if (command === "env") {
48
+ const { env, commandIndex } = parseEnvArgs(rawArgs);
49
+ return {
50
+ transport: "stdio",
51
+ command: rawArgs[commandIndex] ?? "",
52
+ args: rawArgs.slice(commandIndex + 1),
53
+ env,
54
+ };
55
+ }
56
+
57
+ return { transport: "stdio", command, args: rawArgs, env: {} };
58
+ }
59
+ if (type === "http") {
60
+ return {
61
+ transport: "http",
62
+ url: raw["url"] as string,
63
+ headers: (raw["headers"] as Record<string, string>) ?? {},
64
+ };
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ export const claudeCodeAdapter: AgentAdapter = {
70
+ id: "claude-code",
71
+ name: "Claude Code",
72
+ configPath: (projectDir) => join(projectDir, ".mcp.json"),
73
+ isGlobal: false,
74
+
75
+ toAgentFormat,
76
+ fromAgentFormat,
77
+
78
+ async read(projectDir) {
79
+ const filePath = join(projectDir, ".mcp.json");
80
+ const data = await readJsonFile(filePath);
81
+ return (data["mcpServers"] as Record<string, unknown>) ?? {};
82
+ },
83
+
84
+ async write(projectDir, serverName, config) {
85
+ const filePath = join(projectDir, ".mcp.json");
86
+ const data = await readJsonFile(filePath);
87
+ const servers = (data["mcpServers"] as Record<string, unknown>) ?? {};
88
+ if (serverName in servers) {
89
+ throw new Error(
90
+ `Conflict: "${serverName}" already exists in Claude Code config`,
91
+ );
92
+ }
93
+ const updated = {
94
+ ...data,
95
+ mcpServers: { ...servers, [serverName]: toAgentFormat(config) },
96
+ };
97
+ await writeJsonFile(filePath, updated);
98
+ },
99
+
100
+ async remove(projectDir, serverName) {
101
+ const filePath = join(projectDir, ".mcp.json");
102
+ const data = await readJsonFile(filePath);
103
+ const servers = (data["mcpServers"] as Record<string, unknown>) ?? {};
104
+ const { [serverName]: _, ...rest } = servers;
105
+ await writeJsonFile(filePath, { ...data, mcpServers: rest });
106
+ },
107
+
108
+ async has(projectDir, serverName) {
109
+ const filePath = join(projectDir, ".mcp.json");
110
+ const data = await readJsonFile(filePath);
111
+ const servers = (data["mcpServers"] as Record<string, unknown>) ?? {};
112
+ return serverName in servers;
113
+ },
114
+ };