Skip to content

Commit 49b8116

Browse files
committed
feat: implement MCP secret scrubbing and optional sync
1 parent 2e87ed8 commit 49b8116

File tree

10 files changed

+410
-20
lines changed

10 files changed

+410
-20
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Create `~/.config/opencode/opencode-synced.jsonc`:
7878
"branch": "main",
7979
},
8080
"includeSecrets": false,
81+
"includeMcpSecrets": false,
8182
"includeSessions": false,
8283
"includePromptStash": false,
8384
"extraSecretPaths": [],
@@ -100,6 +101,9 @@ Enable secrets with `/sync-enable-secrets` or set `"includeSecrets": true`:
100101
- `~/.local/share/opencode/mcp-auth.json`
101102
- Any extra paths in `extraSecretPaths` (allowlist)
102103

104+
MCP API keys stored inside `opencode.json(c)` are **not** committed by default. To allow them
105+
in a private repo, set `"includeMcpSecrets": true` (requires `includeSecrets`).
106+
103107
### Sessions (private repos only)
104108

105109
Sync your OpenCode sessions (conversation history from `/sessions`) across machines by setting `"includeSessions": true`. This requires `includeSecrets` to also be enabled since sessions may contain sensitive data.
@@ -146,6 +150,22 @@ Create a local-only overrides file at:
146150

147151
Overrides are merged into the runtime config and re-applied to `opencode.json(c)` after pull.
148152

153+
### MCP secret scrubbing
154+
155+
If your `opencode.json(c)` contains MCP secrets (for example `mcp.*.headers` or `mcp.*.oauth.clientSecret`), opencode-synced will automatically:
156+
157+
1. Move the secret values into `opencode-synced.overrides.jsonc` (local-only).
158+
2. Replace the values in the synced config with `{env:...}` placeholders.
159+
160+
This keeps secrets out of the repo while preserving local behavior. On other machines, set the matching environment variables (or add local overrides).
161+
If you want MCP secrets committed (private repos only), set `"includeMcpSecrets": true` alongside `"includeSecrets": true`.
162+
163+
Env var naming rules:
164+
165+
- If the header name already looks like an env var (e.g. `CONTEXT7_API_KEY`), it is used directly.
166+
- Otherwise: `OPENCODE_MCP_<SERVER>_<HEADER>` (uppercase, non-alphanumerics become `_`).
167+
- OAuth client secrets use `OPENCODE_MCP_<SERVER>_OAUTH_CLIENT_SECRET`.
168+
149169
## Usage
150170

151171
| Command | Description |

src/command/sync-enable-secrets.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ description: Enable secrets sync (private repo required)
44

55
Use the opencode_sync tool with command "enable-secrets".
66
If the user supplies extra secret paths, pass them via extraSecretPaths.
7+
If they want MCP secrets committed in a private repo, pass includeMcpSecrets: true.

src/command/sync-init.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ If the user wants a custom repo name, pass name="custom-name".
99
If the user wants an org-owned repo, pass owner="org-name".
1010
If the user wants a public repo, pass private=false.
1111
Include includeSecrets if the user explicitly opts in.
12+
Include includeMcpSecrets only if they want MCP secrets committed to a private repo.

src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ export const OpencodeConfigSync: Plugin = async (ctx) => {
125125
url: tool.schema.string().optional().describe('Repo URL'),
126126
branch: tool.schema.string().optional().describe('Repo branch'),
127127
includeSecrets: tool.schema.boolean().optional().describe('Enable secrets sync'),
128+
includeMcpSecrets: tool.schema
129+
.boolean()
130+
.optional()
131+
.describe('Allow MCP secrets to be committed (requires includeSecrets)'),
128132
includeSessions: tool.schema
129133
.boolean()
130134
.optional()
@@ -151,6 +155,7 @@ export const OpencodeConfigSync: Plugin = async (ctx) => {
151155
url: args.url,
152156
branch: args.branch,
153157
includeSecrets: args.includeSecrets,
158+
includeMcpSecrets: args.includeMcpSecrets,
154159
includeSessions: args.includeSessions,
155160
includePromptStash: args.includePromptStash,
156161
create: args.create,
@@ -171,7 +176,10 @@ export const OpencodeConfigSync: Plugin = async (ctx) => {
171176
return await service.push();
172177
}
173178
if (args.command === 'enable-secrets') {
174-
return await service.enableSecrets(args.extraSecretPaths);
179+
return await service.enableSecrets({
180+
extraSecretPaths: args.extraSecretPaths,
181+
includeMcpSecrets: args.includeMcpSecrets,
182+
});
175183
}
176184
if (args.command === 'resolve') {
177185
return await service.resolve();

src/sync/apply.ts

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { promises as fs } from 'node:fs';
22
import path from 'node:path';
33

44
import { deepMerge, parseJsonc, pathExists, stripOverrides, writeJsonFile } from './config.js';
5+
import {
6+
extractMcpSecrets,
7+
hasOverrides,
8+
mergeOverrides,
9+
stripOverrideKeys,
10+
} from './mcp-secrets.js';
511
import type { SyncItem, SyncPlan } from './paths.js';
612
import { normalizePath } from './paths.js';
713

@@ -32,11 +38,44 @@ export async function syncRepoToLocal(
3238

3339
export async function syncLocalToRepo(
3440
plan: SyncPlan,
35-
overrides: Record<string, unknown> | null
41+
overrides: Record<string, unknown> | null,
42+
options: { overridesPath?: string; allowMcpSecrets?: boolean } = {}
3643
): Promise<void> {
44+
const configItems = plan.items.filter((item) => item.isConfigFile);
45+
const sanitizedConfigs = new Map<string, Record<string, unknown>>();
46+
let secretOverrides: Record<string, unknown> = {};
47+
const allowMcpSecrets = Boolean(options.allowMcpSecrets);
48+
49+
for (const item of configItems) {
50+
if (!(await pathExists(item.localPath))) continue;
51+
52+
const content = await fs.readFile(item.localPath, 'utf8');
53+
const parsed = parseJsonc<Record<string, unknown>>(content);
54+
const { sanitizedConfig, secretOverrides: extracted } = extractMcpSecrets(parsed);
55+
if (!allowMcpSecrets) {
56+
sanitizedConfigs.set(item.localPath, sanitizedConfig);
57+
}
58+
if (hasOverrides(extracted)) {
59+
secretOverrides = mergeOverrides(secretOverrides, extracted);
60+
}
61+
}
62+
63+
let overridesForStrip = overrides;
64+
if (hasOverrides(secretOverrides)) {
65+
if (!allowMcpSecrets) {
66+
const baseOverrides = overrides ?? {};
67+
const mergedOverrides = mergeOverrides(baseOverrides, secretOverrides);
68+
if (options.overridesPath && !isDeepEqual(baseOverrides, mergedOverrides)) {
69+
await writeJsonFile(options.overridesPath, mergedOverrides, { jsonc: true });
70+
}
71+
}
72+
overridesForStrip = overrides ? stripOverrideKeys(overrides, secretOverrides) : overrides;
73+
}
74+
3775
for (const item of plan.items) {
38-
if (item.isConfigFile && overrides && Object.keys(overrides).length > 0) {
39-
await copyConfigForRepo(item, overrides, plan.repoRoot);
76+
if (item.isConfigFile) {
77+
const sanitized = sanitizedConfigs.get(item.localPath);
78+
await copyConfigForRepo(item, overridesForStrip, plan.repoRoot, sanitized);
4079
continue;
4180
}
4281

@@ -70,24 +109,27 @@ async function copyItem(
70109

71110
async function copyConfigForRepo(
72111
item: SyncItem,
73-
overrides: Record<string, unknown>,
74-
repoRoot: string
112+
overrides: Record<string, unknown> | null,
113+
repoRoot: string,
114+
configOverride?: Record<string, unknown>
75115
): Promise<void> {
76116
if (!(await pathExists(item.localPath))) {
77117
await removePath(item.repoPath);
78118
return;
79119
}
80120

81-
const localContent = await fs.readFile(item.localPath, 'utf8');
82-
const localConfig = parseJsonc<Record<string, unknown>>(localContent);
121+
const localConfig =
122+
configOverride ??
123+
parseJsonc<Record<string, unknown>>(await fs.readFile(item.localPath, 'utf8'));
83124
const baseConfig = await readRepoConfig(item, repoRoot);
125+
const effectiveOverrides = overrides ?? {};
84126
if (baseConfig) {
85-
const expectedLocal = deepMerge(baseConfig, overrides) as Record<string, unknown>;
127+
const expectedLocal = deepMerge(baseConfig, effectiveOverrides) as Record<string, unknown>;
86128
if (isDeepEqual(localConfig, expectedLocal)) {
87129
return;
88130
}
89131
}
90-
const stripped = stripOverrides(localConfig, overrides, baseConfig);
132+
const stripped = stripOverrides(localConfig, effectiveOverrides, baseConfig);
91133
const stat = await fs.stat(item.localPath);
92134
await fs.mkdir(path.dirname(item.repoPath), { recursive: true });
93135
await writeJsonFile(item.repoPath, stripped, {

src/sync/config.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { deepMerge, stripOverrides } from './config.js';
3+
import { canCommitMcpSecrets, deepMerge, normalizeSyncConfig, stripOverrides } from './config.js';
44

55
describe('deepMerge', () => {
66
it('merges nested objects and replaces arrays', () => {
@@ -44,3 +44,29 @@ describe('stripOverrides', () => {
4444
expect(stripped).toEqual({ theme: 'opencode', other: true });
4545
});
4646
});
47+
48+
describe('normalizeSyncConfig', () => {
49+
it('disables MCP secrets when secrets are disabled', () => {
50+
const normalized = normalizeSyncConfig({
51+
includeSecrets: false,
52+
includeMcpSecrets: true,
53+
});
54+
expect(normalized.includeMcpSecrets).toBe(false);
55+
});
56+
57+
it('allows MCP secrets when secrets are enabled', () => {
58+
const normalized = normalizeSyncConfig({
59+
includeSecrets: true,
60+
includeMcpSecrets: true,
61+
});
62+
expect(normalized.includeMcpSecrets).toBe(true);
63+
});
64+
});
65+
66+
describe('canCommitMcpSecrets', () => {
67+
it('requires includeSecrets and includeMcpSecrets', () => {
68+
expect(canCommitMcpSecrets({ includeSecrets: false, includeMcpSecrets: true })).toBe(false);
69+
expect(canCommitMcpSecrets({ includeSecrets: true, includeMcpSecrets: false })).toBe(false);
70+
expect(canCommitMcpSecrets({ includeSecrets: true, includeMcpSecrets: true })).toBe(true);
71+
});
72+
});

src/sync/config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface SyncConfig {
1414
repo?: SyncRepoConfig;
1515
localRepoPath?: string;
1616
includeSecrets?: boolean;
17+
includeMcpSecrets?: boolean;
1718
includeSessions?: boolean;
1819
includePromptStash?: boolean;
1920
extraSecretPaths?: string[];
@@ -35,8 +36,10 @@ export async function pathExists(filePath: string): Promise<boolean> {
3536
}
3637

3738
export function normalizeSyncConfig(config: SyncConfig): SyncConfig {
39+
const includeSecrets = Boolean(config.includeSecrets);
3840
return {
39-
includeSecrets: Boolean(config.includeSecrets),
41+
includeSecrets,
42+
includeMcpSecrets: includeSecrets ? Boolean(config.includeMcpSecrets) : false,
4043
includeSessions: Boolean(config.includeSessions),
4144
includePromptStash: Boolean(config.includePromptStash),
4245
extraSecretPaths: Array.isArray(config.extraSecretPaths) ? config.extraSecretPaths : [],
@@ -45,6 +48,10 @@ export function normalizeSyncConfig(config: SyncConfig): SyncConfig {
4548
};
4649
}
4750

51+
export function canCommitMcpSecrets(config: SyncConfig): boolean {
52+
return Boolean(config.includeSecrets) && Boolean(config.includeMcpSecrets);
53+
}
54+
4855
export async function loadSyncConfig(locations: SyncLocations): Promise<SyncConfig | null> {
4956
if (!(await pathExists(locations.syncConfigPath))) {
5057
return null;

src/sync/mcp-secrets.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { extractMcpSecrets } from './mcp-secrets.js';
4+
5+
describe('extractMcpSecrets', () => {
6+
it('moves MCP header secrets into overrides and adds env placeholders', () => {
7+
const input = {
8+
mcp: {
9+
context7: {
10+
type: 'remote',
11+
url: 'https://mcp.context7.com/mcp',
12+
headers: {
13+
CONTEXT7_API_KEY: 'ctx7-secret',
14+
},
15+
},
16+
},
17+
};
18+
19+
const { sanitizedConfig, secretOverrides } = extractMcpSecrets(input);
20+
21+
expect(secretOverrides).toEqual({
22+
mcp: {
23+
context7: {
24+
headers: {
25+
CONTEXT7_API_KEY: 'ctx7-secret',
26+
},
27+
},
28+
},
29+
});
30+
31+
expect(sanitizedConfig).toEqual({
32+
mcp: {
33+
context7: {
34+
type: 'remote',
35+
url: 'https://mcp.context7.com/mcp',
36+
headers: {
37+
CONTEXT7_API_KEY: '{env:CONTEXT7_API_KEY}',
38+
},
39+
},
40+
},
41+
});
42+
});
43+
44+
it('leaves env placeholders intact and skips overrides', () => {
45+
const input = {
46+
mcp: {
47+
context7: {
48+
headers: {
49+
CONTEXT7_API_KEY: '{env:CONTEXT7_API_KEY}',
50+
},
51+
},
52+
},
53+
};
54+
55+
const { sanitizedConfig, secretOverrides } = extractMcpSecrets(input);
56+
57+
expect(secretOverrides).toEqual({});
58+
expect(sanitizedConfig).toEqual(input);
59+
});
60+
61+
it('handles bearer authorization and oauth client secrets', () => {
62+
const input = {
63+
mcp: {
64+
github: {
65+
headers: {
66+
Authorization: 'Bearer ghp_example',
67+
},
68+
oauth: {
69+
clientId: 'public',
70+
clientSecret: 'super-secret',
71+
},
72+
},
73+
},
74+
};
75+
76+
const { sanitizedConfig, secretOverrides } = extractMcpSecrets(input);
77+
78+
expect(secretOverrides).toEqual({
79+
mcp: {
80+
github: {
81+
headers: {
82+
Authorization: 'Bearer ghp_example',
83+
},
84+
oauth: {
85+
clientSecret: 'super-secret',
86+
},
87+
},
88+
},
89+
});
90+
91+
expect(sanitizedConfig).toEqual({
92+
mcp: {
93+
github: {
94+
headers: {
95+
Authorization: 'Bearer {env:OPENCODE_MCP_GITHUB_AUTHORIZATION}',
96+
},
97+
oauth: {
98+
clientId: 'public',
99+
clientSecret: '{env:OPENCODE_MCP_GITHUB_OAUTH_CLIENT_SECRET}',
100+
},
101+
},
102+
},
103+
});
104+
});
105+
});

0 commit comments

Comments
 (0)