Skip to content

Commit 6c56b5d

Browse files
author
lsc
committed
fix(mcp): echo unmodeled state on full-replace connector/skill writes
Both full-replace write paths now preserve stored state the form doesn't model, matching the skill metadata-edit path (and the admin fix): - Connector edit: toPluginUpsert seeds the modeled server from the stored raw server (keeps cwd/disabled/timeout/autoApprove/…), re-emits other mcpServers entries, and re-emits non-modeled attachments verbatim. updateMcpReal pulls these from the freshly-fetched current record. A metadata edit (e.g. slogan) no longer destroys a Cline `disabled:true`, a second server, or a sixth file. - Skill re-upload: EditSkillModal (no visibility control) now threads the skill's current visibility, and importBody fails closed to `private` when absent — a re-upload can no longer let a backend default widen a private skill. Adds regression tests for both (connector unmodeled-field/second-server/extra- attachment survival; skill re-upload fail-closed + explicit-visibility).
1 parent a879f3d commit 6c56b5d

6 files changed

Lines changed: 168 additions & 5 deletions

File tree

packages/dmworkmcp/src/api/mcpService.connector.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,74 @@ describe("updateMcpReal — icon write intent uses an undefined sentinel (P1-1 /
243243
expect(upsertIcon()).toBe("icons/fresh-upload.png");
244244
});
245245
});
246+
247+
describe("updateMcpReal — full-replace echoes unmodeled connector state", () => {
248+
// A stored connector whose mcp.json carries fields the form doesn't model
249+
// (cwd/disabled/timeout), a SECOND server, and a non-modeled attachment.
250+
function richDetail() {
251+
return detailPlugin({
252+
plugin_json: {
253+
$schema: "cowork-plugin-package-2.0.json",
254+
connector: { type: "mcp", source: "connector.github-mcp" },
255+
attachments: [
256+
{
257+
path: "mcp.json",
258+
content_type: "raw",
259+
raw_content: JSON.stringify({
260+
mcpServers: {
261+
"github-mcp": {
262+
type: "streamable-http",
263+
url: "https://mcp.example.com/github",
264+
cwd: "/srv/app",
265+
disabled: true,
266+
timeout: 60,
267+
},
268+
"other-server": { command: "node", args: ["x.js"] },
269+
},
270+
}),
271+
},
272+
{ path: "connector/tools.json", content_type: "raw", raw_content: "[]" },
273+
{ path: "connector/custom.json", content_type: "raw", raw_content: '{"kept":true}' },
274+
],
275+
},
276+
});
277+
}
278+
279+
function writtenUpsert() {
280+
const call = mock.instance.post.mock.calls.find((c) =>
281+
(c[0] as string).endsWith("/plugins/upsert")
282+
) as [string, { plugin: { plugin_json: { attachments: { path: string; raw_content: string }[] } } }];
283+
const atts = call[1].plugin.plugin_json.attachments;
284+
const mcp = atts.find((a) => a.path === "mcp.json")!;
285+
return {
286+
servers: (JSON.parse(mcp.raw_content) as { mcpServers: Record<string, Record<string, unknown>> })
287+
.mcpServers,
288+
paths: atts.map((a) => a.path),
289+
};
290+
}
291+
292+
it("preserves unmodeled server fields, a second server, and a non-modeled attachment on a metadata edit", async () => {
293+
mock.instance.get.mockImplementation((url: string) => {
294+
if (url.includes("/plugin_categories")) return Promise.resolve(categoriesOk());
295+
if (url.includes("/plugins/detail")) return Promise.resolve({ data: { data: richDetail() } });
296+
throw new Error(`unexpected GET ${url}`);
297+
});
298+
mock.instance.post.mockResolvedValue({ data: { data: detailPlugin() } });
299+
300+
// A metadata edit (new slogan); everything else the form re-derives.
301+
await updateMcp("p-1", baseParams({ slogan: "Renamed" }));
302+
303+
const { servers, paths } = writtenUpsert();
304+
// Unmodeled keys on the modeled server survive.
305+
expect(servers["github-mcp"].cwd).toBe("/srv/app");
306+
expect(servers["github-mcp"].disabled).toBe(true);
307+
expect(servers["github-mcp"].timeout).toBe(60);
308+
// Modeled fields still written from the form.
309+
expect(servers["github-mcp"].url).toBe("https://mcp.example.com/github");
310+
// The second server is not collapsed.
311+
expect(servers["other-server"]).toEqual({ command: "node", args: ["x.js"] });
312+
// The non-modeled attachment survives alongside the rebuilt five.
313+
expect(paths).toContain("connector/custom.json");
314+
expect(paths).toContain("mcp.json");
315+
});
316+
});

packages/dmworkmcp/src/api/mcpService.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,22 @@ async function updateMcpReal(
843843
// sides come from the same fetch), so the service does no display comparison.
844844
const canonicalIcon =
845845
params.icon === undefined ? current.plugin.icon ?? "" : params.icon;
846+
// The upsert replaces plugin_json wholesale and the form only models six
847+
// server fields + five attachments. Extract the rest from the freshly-fetched
848+
// current record so the write echoes it back instead of destroying it: the raw
849+
// modeled-server object (keeps cwd/disabled/timeout/autoApprove/…), any other
850+
// mcpServers entry, and any non-modeled attachment.
851+
const currentServers =
852+
jsonAttachment<McpJSONWire>(current.plugin.plugin_json, "mcp.json")
853+
?.mcpServers ?? {};
854+
const currentServerName = Object.keys(currentServers)[0] ?? "";
855+
const rawServer = currentServers[currentServerName] as
856+
| Record<string, unknown>
857+
| undefined;
858+
const extraServers: Record<string, unknown> = {};
859+
for (const [k, v] of Object.entries(currentServers)) {
860+
if (k !== currentServerName) extraServers[k] = v;
861+
}
846862
const detail = await post<PluginDetailWire>(
847863
"/plugins/upsert",
848864
toPluginUpsert(
@@ -851,6 +867,10 @@ async function updateMcpReal(
851867
pluginId: id,
852868
categoryId,
853869
visibility: current.plugin.visibility,
870+
rawServer,
871+
extraServers,
872+
// toPluginUpsert drops the five modeled paths, keeping only the extras.
873+
extraAttachments: current.plugin.plugin_json?.attachments,
854874
}
855875
)
856876
);

packages/dmworkmcp/src/api/mcpWireParams.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { slugifyServerName } from "../utils/constants";
33
import {
44
SECRET_PLACEHOLDER,
55
goCanonicalJSON,
6+
type PluginAttachmentWire,
67
type PluginManifestWire,
78
type PluginVisibilityWire,
89
} from "./pluginWire";
@@ -19,6 +20,23 @@ interface PluginAttachmentBody {
1920
raw_content: string;
2021
}
2122

23+
/** The connector-package attachments this form fully models and rebuilds from
24+
* the current form on every write. Any OTHER stored attachment is preserved
25+
* verbatim (opts.extraAttachments): the upsert replaces plugin_json wholesale,
26+
* so a path we neither model nor re-emit would be silently dropped on edit. */
27+
const MODELED_ATTACHMENT_PATHS = new Set([
28+
"mcp.json",
29+
"connector/tools.json",
30+
"connector/examples.json",
31+
"connector/faqs.json",
32+
"connector/notes.json",
33+
]);
34+
35+
/** Modeled server-object keys the form owns; everything else on the stored
36+
* server (cwd, disabled, timeout, autoApprove, …) is seeded back from
37+
* opts.rawServer so a metadata edit doesn't destroy it. */
38+
const MODELED_SERVER_KEYS = ["type", "url", "command", "args", "env", "headers"];
39+
2240
export interface PluginUpsertBody {
2341
plugin: {
2442
plugin_id?: string;
@@ -32,7 +50,7 @@ export interface PluginUpsertBody {
3250
plugin_json: {
3351
$schema: string;
3452
connector: { type: "mcp"; source: string };
35-
attachments: PluginAttachmentBody[];
53+
attachments: (PluginAttachmentBody | PluginAttachmentWire)[];
3654
};
3755
};
3856
relations: [];
@@ -42,6 +60,14 @@ export interface PluginUpsertOptions {
4260
pluginId?: string;
4361
categoryId?: string;
4462
visibility: PluginVisibilityWire;
63+
/** The RAW stored modeled-server object, seeded into the write so keys this
64+
* form doesn't model (cwd/disabled/timeout/autoApprove/…) survive an edit. */
65+
rawServer?: Record<string, unknown>;
66+
/** Other mcpServers entries, re-emitted verbatim so a multi-server document
67+
* isn't collapsed to one on a metadata edit. */
68+
extraServers?: Record<string, unknown>;
69+
/** Stored attachments outside MODELED_ATTACHMENT_PATHS, re-emitted verbatim. */
70+
extraAttachments?: PluginAttachmentWire[];
4571
}
4672

4773
export function toPluginUpsert(
@@ -76,7 +102,12 @@ export function toPluginUpsert(
76102
// client therefore never SENDS secret values — user-supplied env/header keys
77103
// are emitted as ${KEY} placeholders (filled locally at install time), and a
78104
// redaction sentinel echoed from a read is blanked before write.
79-
const server: Record<string, unknown> = {};
105+
// Seed from the RAW stored modeled-server object so keys this form does not
106+
// model (cwd, disabled, timeout, autoApprove, …) survive a metadata edit; then
107+
// drop the modeled keys and overlay the form, so clearing a field (deleting
108+
// env/headers, clearing args) doesn't leave a stale seeded value behind.
109+
const server: Record<string, unknown> = { ...(opts.rawServer ?? {}) };
110+
for (const k of MODELED_SERVER_KEYS) delete server[k];
80111
if (params.transport) server.type = params.transport;
81112
if (params.url) server.url = params.url;
82113
if (params.command) server.command = params.command;
@@ -96,8 +127,15 @@ export function toPluginUpsert(
96127
// mapDetail reads serverName back from this key, so a display-name key would
97128
// regress the detail snippet too.
98129
const serverKey = slug || name;
99-
const attachments: PluginAttachmentBody[] = [
100-
rawAtt("mcp.json", goCanonicalJSON({ mcpServers: { [serverKey]: server } })),
130+
// Re-emit any other stored servers verbatim (minus the one we're writing) so a
131+
// multi-server document isn't collapsed on a metadata edit.
132+
const mcpServers: Record<string, unknown> = {};
133+
for (const [k, v] of Object.entries(opts.extraServers ?? {})) {
134+
if (k !== serverKey) mcpServers[k] = v;
135+
}
136+
mcpServers[serverKey] = server;
137+
const attachments: (PluginAttachmentBody | PluginAttachmentWire)[] = [
138+
rawAtt("mcp.json", goCanonicalJSON({ mcpServers })),
101139
rawAtt("connector/tools.json", goCanonicalJSON(params.tools ?? [])),
102140
rawAtt("connector/examples.json", goCanonicalJSON(usage)),
103141
rawAtt(
@@ -109,6 +147,11 @@ export function toPluginUpsert(
109147
goCanonicalJSON((params.notes ?? []).map((s) => s.trim()).filter(Boolean))
110148
),
111149
];
150+
// Preserve any stored attachment this form doesn't model (guard against a
151+
// stale extra colliding with a modeled path — the modeled rebuild wins).
152+
for (const att of opts.extraAttachments ?? []) {
153+
if (!MODELED_ATTACHMENT_PATHS.has(att.path)) attachments.push(att);
154+
}
112155
return {
113156
plugin: {
114157
...(opts.pluginId ? { plugin_id: opts.pluginId } : {}),

packages/dmworkskillmarket/src/api/skillApiReal.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,28 @@ describe("skillApiReal", () => {
512512
expect(body.visibility).toBe("private");
513513
});
514514

515+
it("re-upload fails CLOSED to private when the caller omits visibility (full replace)", async () => {
516+
mockFetch.mockReturnValueOnce(
517+
jsonResponse({ plugin: pluginSkillWire(), relations: [] })
518+
);
519+
// EditSkillModal has no visibility control; if it ever omits the field, the
520+
// full-replace import must not let a backend default widen a private skill.
521+
await updateSkill("new-skill", { parseTaskId: "task-3" });
522+
const [, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as [string, RequestInit];
523+
const body = JSON.parse(init.body as string);
524+
expect(body.visibility).toBe("private");
525+
});
526+
527+
it("re-upload preserves an explicit visibility the modal threads through", async () => {
528+
mockFetch.mockReturnValueOnce(
529+
jsonResponse({ plugin: pluginSkillWire(), relations: [] })
530+
);
531+
await updateSkill("new-skill", { parseTaskId: "task-4", visibility: "space" });
532+
const [, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as [string, RequestInit];
533+
const body = JSON.parse(init.body as string);
534+
expect(body.visibility).toBe("space");
535+
});
536+
515537
it("updateSkill without a parse task merges onto the current documents and upserts", async () => {
516538
const current = pluginSkillWire({
517539
plugin_json: {

packages/dmworkskillmarket/src/api/skillApiReal.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,10 @@ function importBody(
543543
description: form.description,
544544
category_id: form.categoryId || undefined,
545545
tags: form.tags,
546-
visibility: form.visibility,
546+
// A re-upload is a full replace: never send an absent visibility (JSON drops
547+
// `undefined`), which would let a backend default decide whether a private
548+
// skill stays private. Fail closed to the most restrictive value.
549+
visibility: form.visibility ?? "private",
547550
version: form.version,
548551
changelog: form.changelog,
549552
};

packages/dmworkskillmarket/src/components/EditSkillModal.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,10 @@ export default function EditSkillModal({ skill, categories, onClose, onUpdated }
391391
description,
392392
categoryId,
393393
tags: submittedTags,
394+
// This modal has no visibility control, so preserve the skill's current
395+
// visibility explicitly — a re-upload is a full replace and would
396+
// otherwise send no visibility, leaving it to a backend default.
397+
visibility: skill.visibility,
394398
...(iconUrl !== undefined ? { iconUrl } : {}),
395399
});
396400
onUpdated(updated);

0 commit comments

Comments
 (0)