Skip to content

Commit ede7d65

Browse files
feat(skills): route MCP CRUD + skills-create scope to the bridge on the alt harness
- registerMcpServer/setMcpServerEnabled/deleteMcpServer: when ew.altHarness is set, read-modify-write the bridge's overrides mcp_servers array instead of the DA config sheet (personal scope); AO/DA path unchanged when off - uploadSkillFileToAo takes an optional scope, forwarding scope=org to the bridge create endpoint - org-scope selector UI is a flagged TODO (personal scope for now) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e5b6742 commit ede7d65

2 files changed

Lines changed: 164 additions & 1 deletion

File tree

‎blocks/skills/skills-editor-api.js‎

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,8 @@ export async function fetchSkillFileFromAo(skillName, path = 'SKILL.md', skillId
622622
* skill server-side. A 409 with needs_confirmation means a skill of that name
623623
* already exists for this user (not auto-overwritten here).
624624
*/
625-
export async function uploadSkillFileToAo(file) {
625+
// TODO: org-scope create needs an admin-gated scope selector in the UI; defaults to owner.
626+
export async function uploadSkillFileToAo(file, scope = 'owner') {
626627
const ctx = await aoAuthContext();
627628
if (!ctx) return { ok: false, error: 'Not signed in' };
628629

@@ -631,6 +632,7 @@ export async function uploadSkillFileToAo(file) {
631632
const form = new FormData();
632633
form.append('file', file, file.name);
633634
form.append('display_title', file.name.replace(/\.md$/i, ''));
635+
if (scope === 'org') form.append('scope', 'org');
634636
const resp = await fetch(`${ctx.base}/api/v1/skills`, {
635637
method: 'POST',
636638
headers: {
@@ -1078,6 +1080,37 @@ function mcpKeyMatch(serverKey) {
10781080
return (r) => normaliseRowKey(r) === serverKey;
10791081
}
10801082

1083+
// ─── MCP servers on the CMA bridge (AO `overrides.settings.mcp_servers`) ─────
1084+
// On the alt harness, MCP servers live in the bridge's per-user overrides
1085+
// instead of the DA config sheet. Each entry keeps the same shape as an
1086+
// `mcp-servers` config row ({ key, url, description?, headers?, enabled? }) so
1087+
// the editor UI round-trips unchanged.
1088+
// TODO: org-scope MCP needs an admin-gated scope selector in the UI; personal only for now.
1089+
function bridgeHeaders(ctx) {
1090+
return {
1091+
authorization: `Bearer ${ctx.token}`,
1092+
'x-tenant-id': ctx.orgId,
1093+
'x-user-id': ctx.userId || '',
1094+
};
1095+
}
1096+
1097+
async function bridgeGetMcpServers(ctx) {
1098+
const resp = await fetch(`${ctx.base}/api/v1/overrides/user`, { headers: bridgeHeaders(ctx) });
1099+
if (!resp.ok) throw new Error(`Could not load overrides (${resp.status})`);
1100+
const json = await resp.json();
1101+
const servers = json?.settings?.mcp_servers;
1102+
return Array.isArray(servers) ? servers : [];
1103+
}
1104+
1105+
async function bridgePutMcpServers(ctx, servers) {
1106+
const resp = await fetch(`${ctx.base}/api/v1/overrides/user/settings/mcp_servers`, {
1107+
method: 'PUT',
1108+
headers: { ...bridgeHeaders(ctx), 'content-type': 'application/json' },
1109+
body: JSON.stringify({ value: servers }),
1110+
});
1111+
if (!resp.ok) throw new Error(`Save failed (${resp.status})`);
1112+
}
1113+
10811114
export async function registerMcpServer(
10821115
org,
10831116
site,
@@ -1095,6 +1128,28 @@ export async function registerMcpServer(
10951128
value: String(h?.value || '').trim(),
10961129
}))
10971130
.filter((h) => h.name && h.value);
1131+
1132+
if (altHarnessEnabled) {
1133+
const ctx = await aoAuthContext();
1134+
if (!ctx) return { ok: false, error: 'Not signed in' };
1135+
try {
1136+
const servers = await bridgeGetMcpServers(ctx);
1137+
const entry = { key: serverKey, url: serverUrl };
1138+
if (description) entry.description = String(description).trim();
1139+
if (safeHeaders.length) entry.headers = safeHeaders;
1140+
const idx = servers.findIndex((s) => s?.key === serverKey);
1141+
const prevEnabled = idx >= 0 ? servers[idx]?.enabled : undefined;
1142+
if (prevEnabled !== undefined) entry.enabled = prevEnabled;
1143+
const next = idx >= 0
1144+
? servers.map((s, i) => (i === idx ? entry : s))
1145+
: [...servers, entry];
1146+
await bridgePutMcpServers(ctx, next);
1147+
return { ok: true };
1148+
} catch (err) {
1149+
return { ok: false, error: String(err?.message ?? err) };
1150+
}
1151+
}
1152+
10981153
return upsertSheetRow(
10991154
org,
11001155
site,
@@ -1117,6 +1172,21 @@ export async function setMcpServerEnabled(org, site, key, enabled) {
11171172
const serverKey = String(key || '').trim();
11181173
if (!serverKey) return { ok: false, error: 'Server id required' };
11191174

1175+
if (altHarnessEnabled) {
1176+
const ctx = await aoAuthContext();
1177+
if (!ctx) return { ok: false, error: 'Not signed in' };
1178+
try {
1179+
const servers = await bridgeGetMcpServers(ctx);
1180+
const idx = servers.findIndex((s) => s?.key === serverKey);
1181+
if (idx < 0) return { ok: false, error: 'Server not found' };
1182+
const next = servers.map((s, i) => (i === idx ? { ...s, enabled: !!enabled } : s));
1183+
await bridgePutMcpServers(ctx, next);
1184+
return { ok: true };
1185+
} catch (err) {
1186+
return { ok: false, error: String(err?.message ?? err) };
1187+
}
1188+
}
1189+
11201190
const loaded = await fetchDaConfigSheets(org, site);
11211191
if (!loaded.ok) return { ok: false, error: 'Could not load config' };
11221192

@@ -1137,6 +1207,20 @@ export async function setMcpServerEnabled(org, site, key, enabled) {
11371207
export async function deleteMcpServer(org, site, key) {
11381208
const serverKey = String(key || '').trim();
11391209
if (!serverKey) return { ok: false, error: 'Key required' };
1210+
1211+
if (altHarnessEnabled) {
1212+
const ctx = await aoAuthContext();
1213+
if (!ctx) return { ok: false, error: 'Not signed in' };
1214+
try {
1215+
const servers = await bridgeGetMcpServers(ctx);
1216+
const next = servers.filter((s) => s?.key !== serverKey);
1217+
await bridgePutMcpServers(ctx, next);
1218+
return { ok: true };
1219+
} catch (err) {
1220+
return { ok: false, error: String(err?.message ?? err) };
1221+
}
1222+
}
1223+
11401224
return deleteSheetRow(org, site, MCP_SHEET, mcpKeyMatch(serverKey), 'MCP server');
11411225
}
11421226

‎test/unit/skills-editor-api.test.js‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import {
1414
fetchSkillFileFromAo,
1515
uploadSkillFileToAo,
1616
removePersonalSkillSource,
17+
registerMcpServer,
18+
setMcpServerEnabled,
19+
deleteMcpServer,
1720
} from '../../blocks/skills/skills-editor-api.js';
1821
import { initAuth } from '../../blocks/skills/utils/da-fetch.js';
1922

@@ -466,5 +469,81 @@ describe('AO / bridge backend switch', () => {
466469
expect(calls).to.have.length(1);
467470
expect(calls[0].url).to.equal('https://aem-sites-claudebridge-va6.adobe.io/api/v1/skills/skill-uuid-9');
468471
});
472+
473+
it('uploadSkillFileToAo passes scope:org in the multipart body when requested', async () => {
474+
const file = new File(['# Body'], 'my-skill.md', { type: 'text/markdown' });
475+
const calls = trackFetch(() => ({ ok: true, json: async () => ({ id: 'skill-uuid-3' }) }));
476+
const result = await uploadSkillFileToAo(file, 'org');
477+
expect(result.ok).to.be.true;
478+
expect(calls[0].opts.body.get('scope')).to.equal('org');
479+
});
480+
481+
// MCP servers: GET the overrides bag, mutate mcp_servers, PUT it back.
482+
const OVERRIDES_BAG = (servers) => ({
483+
settings: { skills: { sources: [], disabled_skills: [] }, mcp_servers: servers, plugins: { installed: [] } },
484+
});
485+
486+
it('registerMcpServer GETs overrides then PUTs the appended server', async () => {
487+
const calls = trackFetch((url, opts) => {
488+
if (opts.method === undefined) return { ok: true, json: async () => OVERRIDES_BAG([]) };
489+
return { ok: true, json: async () => ({}) };
490+
});
491+
const result = await registerMcpServer('o', 's', 'my-mcp', 'https://mcp.example', 'desc', [{ name: 'H', value: 'v' }]);
492+
expect(result.ok).to.be.true;
493+
expect(calls[0].url).to.equal('https://aem-sites-claudebridge-va6.adobe.io/api/v1/overrides/user');
494+
expect(calls[1].url).to.equal('https://aem-sites-claudebridge-va6.adobe.io/api/v1/overrides/user/settings/mcp_servers');
495+
expect(calls[1].opts.method).to.equal('PUT');
496+
expect(calls[1].opts.headers['x-user-id']).to.equal('user-123');
497+
const sent = JSON.parse(calls[1].opts.body).value;
498+
expect(sent).to.have.length(1);
499+
expect(sent[0]).to.deep.include({ key: 'my-mcp', url: 'https://mcp.example', description: 'desc' });
500+
expect(sent[0].headers).to.deep.equal([{ name: 'H', value: 'v' }]);
501+
});
502+
503+
it('registerMcpServer replaces an existing entry by key, preserving enabled', async () => {
504+
const calls = trackFetch((url, opts) => {
505+
if (opts.method === undefined) {
506+
return { ok: true, json: async () => OVERRIDES_BAG([{ key: 'my-mcp', url: 'old', enabled: false }]) };
507+
}
508+
return { ok: true, json: async () => ({}) };
509+
});
510+
await registerMcpServer('o', 's', 'my-mcp', 'https://new.example');
511+
const sent = JSON.parse(calls[1].opts.body).value;
512+
expect(sent).to.have.length(1);
513+
expect(sent[0]).to.deep.include({ key: 'my-mcp', url: 'https://new.example', enabled: false });
514+
});
515+
516+
it('setMcpServerEnabled toggles enabled on the matching entry', async () => {
517+
const calls = trackFetch((url, opts) => {
518+
if (opts.method === undefined) {
519+
return { ok: true, json: async () => OVERRIDES_BAG([{ key: 'my-mcp', url: 'u', enabled: false }]) };
520+
}
521+
return { ok: true, json: async () => ({}) };
522+
});
523+
const result = await setMcpServerEnabled('o', 's', 'my-mcp', true);
524+
expect(result.ok).to.be.true;
525+
expect(JSON.parse(calls[1].opts.body).value[0].enabled).to.be.true;
526+
});
527+
528+
it('deleteMcpServer removes the entry by key and PUTs the rest', async () => {
529+
const calls = trackFetch((url, opts) => {
530+
if (opts.method === undefined) {
531+
return { ok: true, json: async () => OVERRIDES_BAG([{ key: 'a', url: 'u' }, { key: 'my-mcp', url: 'u' }]) };
532+
}
533+
return { ok: true, json: async () => ({}) };
534+
});
535+
const result = await deleteMcpServer('o', 's', 'my-mcp');
536+
expect(result.ok).to.be.true;
537+
const sent = JSON.parse(calls[1].opts.body).value;
538+
expect(sent.map((x) => x.key)).to.deep.equal(['a']);
539+
});
540+
});
541+
542+
describe('MCP altHarness off (DA config, unchanged)', () => {
543+
it('deleteMcpServer does not call the bridge overrides endpoint', async () => {
544+
const calls = trackFetch(() => ({ ok: true, text: async () => '{}', json: async () => ({}) }));
545+
await deleteMcpServer('exp-workspace', 'frescopa', 'my-mcp');
546+
expect(calls.every((c) => !String(c.url).includes('claudebridge'))).to.be.true;
547+
});
469548
});
470549
});

0 commit comments

Comments
 (0)