sync-project-mcps 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,8 +61,11 @@ npx -y sync-project-mcps@latest -v
61
61
  | **Cursor** | `.cursor/mcp.json` | Project |
62
62
  | **Claude Code** | `.mcp.json` | Project |
63
63
  | **Windsurf** | `.windsurf/mcp.json` | Project |
64
- | **Cline** | VS Code globalStorage | Global |
64
+ | **Cline** | `.cline/mcp.json` | Project |
65
65
  | **Roo Code** | `.roo/mcp.json` | Project |
66
+ | **Gemini CLI** | `.gemini/settings.json` | Project |
67
+ | **Codex** | `.codex/config.toml` | Project |
68
+ | **OpenCode** | `.opencode/opencode.jsonc` | Project |
66
69
 
67
70
  ---
68
71
 
@@ -176,19 +179,34 @@ Done!
176
179
  sync-project-mcps [options]
177
180
 
178
181
  Options:
182
+ -s, --source Use specific client as source of truth (cursor, claude, windsurf, cline, roo, gemini, codex, opencode)
179
183
  --dry-run Show what would be synced without writing files
180
184
  -v, --verbose Show detailed information about each server
181
185
  -h, --help Show help message
182
186
  --version Show version
183
187
  ```
184
188
 
189
+ ### Source Mode
190
+
191
+ By default, the tool **merges** all configs (add-only). Use `--source` to pick one client as the source of truth:
192
+
193
+ ```bash
194
+ # Use Cursor's config as the canonical source
195
+ npx -y sync-project-mcps@latest -s cursor
196
+
197
+ # Preview what would be removed
198
+ npx -y sync-project-mcps@latest -s cursor --dry-run -v
199
+ ```
200
+
201
+ This will sync **only** the servers from the source client to all others, removing servers that don't exist in the source.
202
+
185
203
  ---
186
204
 
187
205
  ## FAQ
188
206
 
189
207
  ### Does it delete servers?
190
208
 
191
- No. It only adds missing servers. If a server exists in Cursor but not Claude Code, it gets added to Claude Code. Servers are never removed.
209
+ By default, no. It only adds missing servers. Use `--source` to sync from a specific client, which will also remove servers that don't exist in the source.
192
210
 
193
211
  ### What if the same server has different configs?
194
212
 
@@ -0,0 +1,3 @@
1
+ import type { ClientConfig, McpConfig } from "../types.js";
2
+ export declare function getCodexConfig(projectRoot: string): ClientConfig;
3
+ export declare function serializeCodexConfig(config: McpConfig, existingContent: string): string;
@@ -0,0 +1,66 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseToml, stringifyToml } from "../parsers/toml.js";
4
+ const CONFIG_PATH = ".codex/config.toml";
5
+ const MCP_PREFIX = "mcp_servers.";
6
+ export function getCodexConfig(projectRoot) {
7
+ const configPath = join(projectRoot, CONFIG_PATH);
8
+ const exists = existsSync(configPath);
9
+ if (!exists) {
10
+ return {
11
+ name: "Codex",
12
+ path: configPath,
13
+ config: null,
14
+ exists: false,
15
+ };
16
+ }
17
+ try {
18
+ const content = readFileSync(configPath, "utf-8");
19
+ const parsed = parseToml(content);
20
+ const mcpServers = {};
21
+ for (const [section, values] of Object.entries(parsed)) {
22
+ if (!section.startsWith(MCP_PREFIX))
23
+ continue;
24
+ const serverName = section.slice(MCP_PREFIX.length);
25
+ const command = values.command;
26
+ const args = values.args;
27
+ const env = values.env;
28
+ if (command) {
29
+ mcpServers[serverName] = { command, args, env };
30
+ }
31
+ }
32
+ return {
33
+ name: "Codex",
34
+ path: configPath,
35
+ config: { mcpServers },
36
+ exists: true,
37
+ };
38
+ }
39
+ catch (error) {
40
+ const msg = error instanceof Error ? error.message : "Unknown error";
41
+ console.error(`Warning: Failed to parse ${configPath}: ${msg}`);
42
+ return {
43
+ name: "Codex",
44
+ path: configPath,
45
+ config: null,
46
+ exists: true,
47
+ };
48
+ }
49
+ }
50
+ export function serializeCodexConfig(config, existingContent) {
51
+ const existing = parseToml(existingContent);
52
+ for (const key of Object.keys(existing)) {
53
+ if (key.startsWith(MCP_PREFIX)) {
54
+ delete existing[key];
55
+ }
56
+ }
57
+ for (const [name, server] of Object.entries(config.mcpServers)) {
58
+ const section = `${MCP_PREFIX}${name}`;
59
+ existing[section] = {
60
+ command: server.command,
61
+ ...(server.args && { args: server.args }),
62
+ ...(server.env && { env: server.env }),
63
+ };
64
+ }
65
+ return stringifyToml(existing);
66
+ }
@@ -0,0 +1,2 @@
1
+ import type { ClientConfig } from "../types.js";
2
+ export declare function getGeminiConfig(projectRoot: string): ClientConfig;
@@ -0,0 +1,38 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const CONFIG_PATH = ".gemini/settings.json";
4
+ export function getGeminiConfig(projectRoot) {
5
+ const configPath = join(projectRoot, CONFIG_PATH);
6
+ const exists = existsSync(configPath);
7
+ if (!exists) {
8
+ return {
9
+ name: "Gemini CLI",
10
+ path: configPath,
11
+ config: null,
12
+ exists: false,
13
+ };
14
+ }
15
+ try {
16
+ const content = readFileSync(configPath, "utf-8");
17
+ const parsed = JSON.parse(content);
18
+ const config = {
19
+ mcpServers: (parsed.mcpServers ?? {}),
20
+ };
21
+ return {
22
+ name: "Gemini CLI",
23
+ path: configPath,
24
+ config,
25
+ exists: true,
26
+ };
27
+ }
28
+ catch (error) {
29
+ const msg = error instanceof Error ? error.message : "Unknown error";
30
+ console.error(`Warning: Failed to parse ${configPath}: ${msg}`);
31
+ return {
32
+ name: "Gemini CLI",
33
+ path: configPath,
34
+ config: null,
35
+ exists: true,
36
+ };
37
+ }
38
+ }
@@ -0,0 +1,3 @@
1
+ import type { ClientConfig, McpConfig } from "../types.js";
2
+ export declare function getOpenCodeConfig(projectRoot: string): ClientConfig;
3
+ export declare function serializeOpenCodeConfig(config: McpConfig, existingContent: string): string;
@@ -0,0 +1,72 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseJsonc } from "../parsers/jsonc.js";
4
+ const CONFIG_PATH = ".opencode/opencode.jsonc";
5
+ export function getOpenCodeConfig(projectRoot) {
6
+ const configPath = join(projectRoot, CONFIG_PATH);
7
+ const exists = existsSync(configPath);
8
+ if (!exists) {
9
+ return {
10
+ name: "OpenCode",
11
+ path: configPath,
12
+ config: null,
13
+ exists: false,
14
+ };
15
+ }
16
+ try {
17
+ const content = readFileSync(configPath, "utf-8");
18
+ const parsed = parseJsonc(content);
19
+ const mcpServers = {};
20
+ if (parsed.mcp) {
21
+ for (const [name, server] of Object.entries(parsed.mcp)) {
22
+ if (server.type !== "local")
23
+ continue;
24
+ if (server.enabled === false)
25
+ continue;
26
+ const [command, ...args] = server.command;
27
+ mcpServers[name] = {
28
+ command,
29
+ ...(args.length > 0 && { args }),
30
+ ...(server.environment && { env: server.environment }),
31
+ };
32
+ }
33
+ }
34
+ return {
35
+ name: "OpenCode",
36
+ path: configPath,
37
+ config: { mcpServers },
38
+ exists: true,
39
+ };
40
+ }
41
+ catch (error) {
42
+ const msg = error instanceof Error ? error.message : "Unknown error";
43
+ console.error(`Warning: Failed to parse ${configPath}: ${msg}`);
44
+ return {
45
+ name: "OpenCode",
46
+ path: configPath,
47
+ config: null,
48
+ exists: true,
49
+ };
50
+ }
51
+ }
52
+ export function serializeOpenCodeConfig(config, existingContent) {
53
+ const existing = parseJsonc(existingContent);
54
+ const remoteMcp = {};
55
+ if (existing.mcp) {
56
+ for (const [name, server] of Object.entries(existing.mcp)) {
57
+ if (server.type === "remote") {
58
+ remoteMcp[name] = server;
59
+ }
60
+ }
61
+ }
62
+ const newMcp = { ...remoteMcp };
63
+ for (const [name, server] of Object.entries(config.mcpServers)) {
64
+ const command = [server.command, ...(server.args ?? [])];
65
+ newMcp[name] = {
66
+ type: "local",
67
+ command,
68
+ ...(server.env && { environment: server.env }),
69
+ };
70
+ }
71
+ return JSON.stringify({ ...existing, mcp: newMcp }, null, 2) + "\n";
72
+ }
package/dist/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
- import { dirname } from "node:path";
2
+ import { readFileSync, writeFileSync } from "node:fs";
4
3
  import { parseArgs } from "node:util";
5
4
  import { getCursorConfig } from "./clients/cursor.js";
6
5
  import { getClaudeCodeConfig } from "./clients/claude-code.js";
7
6
  import { getWindsurfConfig } from "./clients/windsurf.js";
8
7
  import { getClineConfig } from "./clients/cline.js";
9
8
  import { getRooCodeConfig } from "./clients/roo-code.js";
9
+ import { getGeminiConfig } from "./clients/gemini.js";
10
+ import { getCodexConfig, serializeCodexConfig } from "./clients/codex.js";
11
+ import { getOpenCodeConfig, serializeOpenCodeConfig } from "./clients/opencode.js";
10
12
  import { mergeConfigs, getChanges } from "./merge.js";
11
13
  const COLORS = {
12
14
  reset: "\x1b[0m",
@@ -21,12 +23,26 @@ const COLORS = {
21
23
  function c(color, text) {
22
24
  return `${COLORS[color]}${text}${COLORS.reset}`;
23
25
  }
26
+ const CLIENT_ALIASES = {
27
+ cursor: "Cursor",
28
+ claude: "Claude Code",
29
+ "claude-code": "Claude Code",
30
+ windsurf: "Windsurf",
31
+ cline: "Cline",
32
+ roo: "Roo Code",
33
+ "roo-code": "Roo Code",
34
+ gemini: "Gemini CLI",
35
+ "gemini-cli": "Gemini CLI",
36
+ codex: "Codex",
37
+ opencode: "OpenCode",
38
+ };
24
39
  const { values: args } = parseArgs({
25
40
  options: {
26
41
  "dry-run": { type: "boolean", default: false },
27
42
  verbose: { type: "boolean", short: "v", default: false },
28
43
  help: { type: "boolean", short: "h", default: false },
29
44
  version: { type: "boolean", default: false },
45
+ source: { type: "string", short: "s" },
30
46
  },
31
47
  });
32
48
  function printHelp() {
@@ -37,6 +53,7 @@ ${c("bold", "USAGE")}
37
53
  npx sync-project-mcps [options]
38
54
 
39
55
  ${c("bold", "OPTIONS")}
56
+ -s, --source Use specific client as source of truth (cursor, claude, windsurf, cline, roo, gemini, codex, opencode)
40
57
  --dry-run Show what would be synced without writing files
41
58
  -v, --verbose Show detailed information
42
59
  -h, --help Show this help message
@@ -48,10 +65,14 @@ ${c("bold", "SUPPORTED CLIENTS")}
48
65
  - Windsurf ${c("dim", ".windsurf/mcp.json")}
49
66
  - Cline ${c("dim", ".cline/mcp.json")}
50
67
  - Roo Code ${c("dim", ".roo/mcp.json")}
68
+ - Gemini CLI ${c("dim", ".gemini/settings.json")}
69
+ - Codex ${c("dim", ".codex/config.toml")}
70
+ - OpenCode ${c("dim", ".opencode/opencode.jsonc")}
51
71
 
52
72
  ${c("bold", "EXAMPLES")}
53
- npx sync-project-mcps Sync all MCP configurations
54
- npx sync-project-mcps --dry-run Preview changes without writing
73
+ npx sync-project-mcps Merge all configs (add-only)
74
+ npx sync-project-mcps -s cursor Use Cursor as source of truth
75
+ npx sync-project-mcps -s cursor --dry-run Preview changes
55
76
  `);
56
77
  }
57
78
  function run() {
@@ -66,7 +87,17 @@ function run() {
66
87
  const projectRoot = process.cwd();
67
88
  const dryRun = args["dry-run"];
68
89
  const verbose = args.verbose;
90
+ const sourceArg = args.source?.toLowerCase();
91
+ const sourceName = sourceArg ? CLIENT_ALIASES[sourceArg] : null;
92
+ if (sourceArg && !sourceName) {
93
+ console.error(c("red", `Unknown source: ${sourceArg}`));
94
+ console.error(`Valid sources: ${Object.keys(CLIENT_ALIASES).join(", ")}`);
95
+ process.exit(1);
96
+ }
69
97
  console.log(c("bold", "\nSync MCP Configurations\n"));
98
+ if (sourceName) {
99
+ console.log(c("cyan", `Source: ${sourceName}\n`));
100
+ }
70
101
  if (dryRun) {
71
102
  console.log(c("yellow", "DRY RUN - no files will be modified\n"));
72
103
  }
@@ -76,6 +107,9 @@ function run() {
76
107
  getWindsurfConfig(projectRoot),
77
108
  getClineConfig(projectRoot),
78
109
  getRooCodeConfig(projectRoot),
110
+ getGeminiConfig(projectRoot),
111
+ getCodexConfig(projectRoot),
112
+ getOpenCodeConfig(projectRoot),
79
113
  ];
80
114
  const existingClients = clients.filter((c) => c.exists && c.config);
81
115
  const missingClients = clients.filter((c) => !c.exists);
@@ -99,19 +133,34 @@ function run() {
99
133
  }
100
134
  }
101
135
  if (missingClients.length > 0 && verbose) {
102
- console.log(c("dim", "\nNot found (will be created):"));
136
+ console.log(c("dim", "\nNot found (skipped):"));
103
137
  for (const client of missingClients) {
104
138
  console.log(c("dim", ` - ${client.name}`));
105
139
  }
106
140
  }
107
- const merged = mergeConfigs(existingClients);
141
+ let merged;
142
+ if (sourceName) {
143
+ const sourceClient = existingClients.find((cl) => cl.name === sourceName);
144
+ if (!sourceClient) {
145
+ console.error(c("red", `\nSource "${sourceName}" not found in project.`));
146
+ console.error("Available configs:");
147
+ for (const client of existingClients) {
148
+ console.error(c("dim", ` - ${client.name}`));
149
+ }
150
+ process.exit(1);
151
+ }
152
+ merged = { mcpServers: { ...sourceClient.config.mcpServers } };
153
+ }
154
+ else {
155
+ merged = mergeConfigs(existingClients);
156
+ }
108
157
  const mergedCount = Object.keys(merged.mcpServers).length;
109
158
  console.log(`\n${c("cyan", "Merged result:")} ${mergedCount} unique server(s)`);
110
159
  for (const name of Object.keys(merged.mcpServers).sort()) {
111
160
  console.log(` ${c("blue", "-")} ${name}`);
112
161
  }
113
162
  console.log(`\n${c("cyan", "Syncing to clients...")}`);
114
- for (const client of clients) {
163
+ for (const client of existingClients) {
115
164
  const changes = getChanges(client, merged);
116
165
  const parts = [];
117
166
  if (changes.added.length > 0) {
@@ -120,20 +169,35 @@ function run() {
120
169
  if (changes.removed.length > 0) {
121
170
  parts.push(c("red", `-${changes.removed.length}`));
122
171
  }
123
- const changeInfo = parts.length > 0 ? ` (${parts.join(", ")})` : c("dim", " (no changes)");
124
- const status = client.exists ? c("green", "update") : c("yellow", "create");
172
+ const hasChanges = parts.length > 0;
173
+ const changeInfo = hasChanges ? ` (${parts.join(", ")})` : "";
174
+ const status = hasChanges ? c("green", "sync") : c("dim", "skip");
125
175
  console.log(` [${status}] ${client.name}${changeInfo}`);
126
- if (verbose && changes.added.length > 0) {
176
+ if (verbose) {
127
177
  for (const name of changes.added) {
128
178
  console.log(c("green", ` + ${name}`));
129
179
  }
180
+ for (const name of changes.removed) {
181
+ console.log(c("red", ` - ${name}`));
182
+ }
130
183
  }
131
- if (!dryRun) {
132
- const dir = dirname(client.path);
133
- if (!existsSync(dir)) {
134
- mkdirSync(dir, { recursive: true });
184
+ if (!dryRun && hasChanges) {
185
+ const existingContent = readFileSync(client.path, "utf-8");
186
+ let output;
187
+ if (client.name === "Gemini CLI") {
188
+ const existing = JSON.parse(existingContent);
189
+ output = JSON.stringify({ ...existing, mcpServers: merged.mcpServers }, null, 2) + "\n";
190
+ }
191
+ else if (client.name === "Codex") {
192
+ output = serializeCodexConfig(merged, existingContent);
193
+ }
194
+ else if (client.name === "OpenCode") {
195
+ output = serializeOpenCodeConfig(merged, existingContent);
196
+ }
197
+ else {
198
+ output = JSON.stringify(merged, null, 2) + "\n";
135
199
  }
136
- writeFileSync(client.path, JSON.stringify(merged, null, 2) + "\n");
200
+ writeFileSync(client.path, output);
137
201
  }
138
202
  }
139
203
  console.log(`\n${c("green", "Done!")} ${dryRun ? "(dry run)" : ""}\n`);
@@ -0,0 +1 @@
1
+ export declare function parseJsonc<T>(content: string): T;
@@ -0,0 +1,35 @@
1
+ export function parseJsonc(content) {
2
+ let result = "";
3
+ let inString = false;
4
+ let i = 0;
5
+ while (i < content.length) {
6
+ const char = content[i];
7
+ const next = content[i + 1];
8
+ if (char === '"' && (i === 0 || content[i - 1] !== "\\")) {
9
+ inString = !inString;
10
+ result += char;
11
+ i++;
12
+ continue;
13
+ }
14
+ if (!inString) {
15
+ if (char === "/" && next === "/") {
16
+ while (i < content.length && content[i] !== "\n" && content[i] !== "\r") {
17
+ i++;
18
+ }
19
+ continue;
20
+ }
21
+ if (char === "/" && next === "*") {
22
+ i += 2;
23
+ while (i < content.length - 1 && !(content[i] === "*" && content[i + 1] === "/")) {
24
+ i++;
25
+ }
26
+ i += 2;
27
+ continue;
28
+ }
29
+ }
30
+ result += char;
31
+ i++;
32
+ }
33
+ const cleaned = result.replace(/,(\s*[}\]])/g, "$1");
34
+ return JSON.parse(cleaned);
35
+ }
@@ -0,0 +1,5 @@
1
+ type TomlValue = string | string[] | Record<string, string>;
2
+ type TomlSection = Record<string, TomlValue>;
3
+ export declare function parseToml(content: string): Record<string, TomlSection>;
4
+ export declare function stringifyToml(data: Record<string, TomlSection>): string;
5
+ export {};
@@ -0,0 +1,100 @@
1
+ export function parseToml(content) {
2
+ const result = {};
3
+ let currentSection = "";
4
+ const lines = content.split("\n");
5
+ for (const rawLine of lines) {
6
+ const line = rawLine.trim();
7
+ if (!line || line.startsWith("#"))
8
+ continue;
9
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
10
+ if (sectionMatch) {
11
+ currentSection = sectionMatch[1];
12
+ result[currentSection] = {};
13
+ continue;
14
+ }
15
+ if (!currentSection)
16
+ continue;
17
+ const keyValueMatch = line.match(/^(\w+)\s*=\s*(.+)$/);
18
+ if (!keyValueMatch)
19
+ continue;
20
+ const [, key, rawValue] = keyValueMatch;
21
+ result[currentSection][key] = parseValue(rawValue.trim());
22
+ }
23
+ return result;
24
+ }
25
+ function parseValue(value) {
26
+ if (value.startsWith('"') && value.endsWith('"')) {
27
+ return value.slice(1, -1);
28
+ }
29
+ if (value.startsWith("[") && value.endsWith("]")) {
30
+ return parseArray(value);
31
+ }
32
+ if (value.startsWith("{") && value.endsWith("}")) {
33
+ return parseInlineTable(value);
34
+ }
35
+ return value;
36
+ }
37
+ function parseArray(value) {
38
+ const inner = value.slice(1, -1).trim();
39
+ if (!inner)
40
+ return [];
41
+ const result = [];
42
+ let current = "";
43
+ let inString = false;
44
+ for (let i = 0; i < inner.length; i++) {
45
+ const char = inner[i];
46
+ if (char === '"' && inner[i - 1] !== "\\") {
47
+ inString = !inString;
48
+ continue;
49
+ }
50
+ if (char === "," && !inString) {
51
+ if (current.trim())
52
+ result.push(current.trim());
53
+ current = "";
54
+ continue;
55
+ }
56
+ if (!inString && (char === " " || char === "\t"))
57
+ continue;
58
+ current += char;
59
+ }
60
+ if (current.trim())
61
+ result.push(current.trim());
62
+ return result;
63
+ }
64
+ function parseInlineTable(value) {
65
+ const inner = value.slice(1, -1).trim();
66
+ if (!inner)
67
+ return {};
68
+ const result = {};
69
+ const pairs = inner.split(",");
70
+ for (const pair of pairs) {
71
+ const match = pair.trim().match(/^"?(\w+)"?\s*=\s*"([^"]*)"$/);
72
+ if (match) {
73
+ result[match[1]] = match[2];
74
+ }
75
+ }
76
+ return result;
77
+ }
78
+ export function stringifyToml(data) {
79
+ const lines = [];
80
+ for (const [section, values] of Object.entries(data)) {
81
+ lines.push(`[${section}]`);
82
+ for (const [key, value] of Object.entries(values)) {
83
+ lines.push(`${key} = ${stringifyValue(value)}`);
84
+ }
85
+ lines.push("");
86
+ }
87
+ return lines.join("\n");
88
+ }
89
+ function stringifyValue(value) {
90
+ if (typeof value === "string") {
91
+ return `"${value}"`;
92
+ }
93
+ if (Array.isArray(value)) {
94
+ return `[${value.map((v) => `"${v}"`).join(", ")}]`;
95
+ }
96
+ const pairs = Object.entries(value)
97
+ .map(([k, v]) => `"${k}" = "${v}"`)
98
+ .join(", ");
99
+ return `{ ${pairs} }`;
100
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sync-project-mcps",
3
- "version": "1.0.2",
4
- "description": "Sync project-level MCP configurations across AI coding assistants (Cursor, Claude Code, Windsurf, Cline)",
3
+ "version": "1.2.0",
4
+ "description": "Sync project-level MCP configurations across AI coding assistants (Cursor, Claude Code, Windsurf, Cline, Gemini CLI, Codex, OpenCode)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sync-project-mcps": "./dist/index.js"
@@ -10,10 +10,12 @@
10
10
  "dist"
11
11
  ],
12
12
  "scripts": {
13
- "build": "tsc",
13
+ "clean": "rm -rf dist",
14
+ "build": "npm run clean && tsc",
14
15
  "dev": "tsc --watch",
15
16
  "start": "node dist/index.js",
16
- "prepublishOnly": "npm run build"
17
+ "test": "node --test --experimental-strip-types src/**/*.test.ts",
18
+ "prepublishOnly": "npm run build && npm test"
17
19
  },
18
20
  "keywords": ["mcp", "cursor", "claude", "sync", "cli", "windsurf", "cline", "project"],
19
21
  "author": "Vlad Tansky",