Skip to content

Commit 23b4e21

Browse files
Theaxiomclaude
andcommitted
feat(mcp): add chat-uploads capability for ADR-113 attachment-aware modes
- Extend getZaruInit() to recognize chat-uploads capability and augment agentic and workflow mode system prompts with attachment pass-through teaching when present - Track per-session client capabilities in createMcpServerForUser, set via zaru.init and refreshable on zaru.mode - Reject attachment-bearing tool calls (aegis.task.execute, aegis.agent.generate, aegis.execute.intent) from non-capable clients in mcp/streamable-http.ts as defence-in-depth - Add prompts.test.ts coverage for chat-uploads gating across all modes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 45989eb commit 23b4e21

3 files changed

Lines changed: 221 additions & 2 deletions

File tree

zaru-mcp-server/src/mcp/streamable-http.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,46 @@ function normalizeToolResult(result: unknown): {
187187
};
188188
}
189189

190+
/**
191+
* Tool calls that may carry an `attachments` array per ADR-113. Only clients
192+
* that declare the "chat-uploads" capability are permitted to forward
193+
* attachments to these tools — defence-in-depth on top of the orchestrator and
194+
* the Zaru web client gates.
195+
*/
196+
const ATTACHMENT_CAPABLE_TOOLS = new Set([
197+
"aegis.task.execute",
198+
"aegis.agent.generate",
199+
"aegis.execute.intent",
200+
]);
201+
202+
/**
203+
* Returns true if a tool call payload includes a non-empty `attachments` field
204+
* — either at the top level or nested under `input` (the conventional shape
205+
* for `aegis.task.execute` / `aegis.agent.generate` / `aegis.execute.intent`).
206+
*/
207+
function hasAttachments(args: unknown): boolean {
208+
if (!args || typeof args !== "object") return false;
209+
const a = args as Record<string, unknown>;
210+
if (Array.isArray(a.attachments) && a.attachments.length > 0) return true;
211+
const input = a.input;
212+
if (input && typeof input === "object") {
213+
const i = input as Record<string, unknown>;
214+
if (Array.isArray(i.attachments) && i.attachments.length > 0) return true;
215+
}
216+
const inputs = a.inputs;
217+
if (inputs && typeof inputs === "object") {
218+
const i = inputs as Record<string, unknown>;
219+
if (Array.isArray(i.attachments) && i.attachments.length > 0) return true;
220+
}
221+
return false;
222+
}
223+
190224
function createMcpServerForUser(user: ZaruUser): McpServer {
225+
// Per-session capability state, populated by the client via `zaru.init` /
226+
// `zaru.mode`. Used to enforce the chat-uploads gate on subsequent tool
227+
// calls within this session.
228+
const sessionCapabilities = new Set<string>();
229+
191230
const mcpServer = new McpServer(
192231
{
193232
name: "zaru-mcp-server",
@@ -288,6 +327,18 @@ Available modes:
288327
description:
289328
"Short explanation of why the mode switch is appropriate",
290329
},
330+
client: {
331+
type: "object",
332+
description:
333+
"Optional client descriptor — re-asserts runtime and capabilities on mode switch. If omitted, the capabilities recorded at zaru.init time are preserved.",
334+
properties: {
335+
runtime: { type: "string" },
336+
capabilities: {
337+
type: "array",
338+
items: { type: "string" },
339+
},
340+
},
341+
},
291342
},
292343
required: ["mode"],
293344
},
@@ -348,6 +399,12 @@ Available modes:
348399
const client = (args as Record<string, unknown>)?.client as
349400
| { runtime?: string; capabilities?: string[] }
350401
| undefined;
402+
// Record the client's declared capabilities for this session so
403+
// downstream tool calls can be gated (e.g. chat-uploads / attachments).
404+
sessionCapabilities.clear();
405+
for (const cap of client?.capabilities ?? []) {
406+
sessionCapabilities.add(cap);
407+
}
351408
const result = getZaruInit(mode, client);
352409
if (!result) {
353410
return {
@@ -398,7 +455,23 @@ Available modes:
398455
const reason = (args as Record<string, unknown>)?.reason as
399456
| string
400457
| undefined;
401-
const result = getZaruInit(targetMode);
458+
const client = (args as Record<string, unknown>)?.client as
459+
| { runtime?: string; capabilities?: string[] }
460+
| undefined;
461+
// If the client re-asserts capabilities on mode switch, refresh the
462+
// session record. Otherwise preserve what was set at zaru.init time.
463+
if (client?.capabilities) {
464+
sessionCapabilities.clear();
465+
for (const cap of client.capabilities) {
466+
sessionCapabilities.add(cap);
467+
}
468+
}
469+
const result = getZaruInit(
470+
targetMode,
471+
client ?? {
472+
capabilities: Array.from(sessionCapabilities),
473+
},
474+
);
402475
if (!result) {
403476
return {
404477
content: [
@@ -433,6 +506,29 @@ Available modes:
433506
return handleZaruScriptTool(orchestratorClient, user, name, args);
434507
}
435508

509+
// ADR-113 defence-in-depth: reject `attachments` from any client that has
510+
// not declared the "chat-uploads" capability for this session. The
511+
// orchestrator and the Zaru web client also gate this — the MCP server
512+
// must not silently forward attachments from a non-capable client.
513+
if (
514+
ATTACHMENT_CAPABLE_TOOLS.has(name) &&
515+
hasAttachments(args) &&
516+
!sessionCapabilities.has("chat-uploads")
517+
) {
518+
return {
519+
content: [
520+
{
521+
type: "text",
522+
text: JSON.stringify({
523+
error:
524+
"attachments are only accepted from clients that declare the 'chat-uploads' capability via zaru.init.",
525+
}),
526+
},
527+
],
528+
isError: true,
529+
};
530+
}
531+
436532
try {
437533
const result = await orchestratorClient.invokeTool(
438534
user,

zaru-mcp-server/src/prompts/index.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,28 @@ const PROMPTS: Record<string, string> = {
452452
operator: OPERATOR_PROMPT,
453453
};
454454

455+
// ---------------------------------------------------------------------------
456+
// chat-uploads capability — additive system prompt teaching for attachment-
457+
// aware modes (agentic, workflow). When a client declares the "chat-uploads"
458+
// capability, the user may attach files in chat which arrive as an
459+
// `attachments` array on tool call inputs. The LLM must pass these through
460+
// faithfully to downstream agent / workflow dispatches.
461+
// ---------------------------------------------------------------------------
462+
463+
const CHAT_UPLOADS_TEACHING = `
464+
465+
# CHAT ATTACHMENTS — PASS-THROUGH RULE
466+
467+
The user may attach files to their messages. When attachments are present, your tool call inputs will include an \`attachments\` field — an array of \`{volume_id, path, name, mime_type, size}\` references that point at files staged in a sandbox-readable volume. Zaru handles file references deterministically; you do not need to construct, paraphrase, rename, or modify them.
468+
469+
- When dispatching an agent (\`aegis.task.execute\`, \`aegis.agent.generate\`) or running a workflow, pass the same \`attachments\` array through verbatim on the call. The downstream agent or workflow needs the references to read the files inside its sandbox.
470+
- Do NOT read, summarise, or describe the file contents yourself before dispatching — the 100monkeys read the files in their sandbox. You just hand off the references.
471+
- Do NOT fabricate \`attachments\` entries. Only forward what the client supplied.
472+
- If the user's intent involves processing an attached file, ensure every agent or workflow you dispatch receives the same \`attachments\` array on its input.`;
473+
474+
/** Modes that accept and forward `attachments` when the chat-uploads capability is active. */
475+
const CHAT_UPLOADS_MODES = new Set(["agentic", "workflow"]);
476+
455477
const TOOL_SCOPES: Record<string, string[]> = {
456478
chat: ["zaru.mode", "zaru.docs"],
457479
agentic: [
@@ -545,9 +567,19 @@ export function getZaruInit(
545567
const prompt = PROMPTS[effectiveMode];
546568
const tools = TOOL_SCOPES[effectiveMode];
547569
if (!prompt || !tools) return null;
570+
571+
// When the client declares the "chat-uploads" capability and is in an
572+
// attachment-aware mode, augment the system prompt with pass-through
573+
// teaching for the `attachments` field on tool call inputs.
574+
const augmentedPrompt =
575+
client?.capabilities?.includes("chat-uploads") &&
576+
CHAT_UPLOADS_MODES.has(effectiveMode)
577+
? prompt + CHAT_UPLOADS_TEACHING
578+
: prompt;
579+
548580
return {
549581
mode: effectiveMode,
550-
system_prompt: prompt,
582+
system_prompt: augmentedPrompt,
551583
available_tools: tools,
552584
version: ZARU_VERSION,
553585
};

zaru-mcp-server/test/prompts.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,94 @@ test("getZaruInit('nonexistent') returns null", () => {
109109
const result = getZaruInit("nonexistent");
110110
assert.equal(result, null);
111111
});
112+
113+
// ---------------------------------------------------------------------------
114+
// chat-uploads capability — additive system prompt teaching for agentic /
115+
// workflow modes (ADR-113).
116+
// ---------------------------------------------------------------------------
117+
118+
const CHAT_UPLOADS_MARKER = "CHAT ATTACHMENTS — PASS-THROUGH RULE";
119+
120+
test("getZaruInit('agentic') with the 'chat-uploads' capability augments the system prompt with attachment teaching", () => {
121+
const withCap = getZaruInit("agentic", {
122+
capabilities: ["chat-uploads"],
123+
});
124+
assert.notEqual(withCap, null);
125+
assert.ok(
126+
withCap!.system_prompt.includes(CHAT_UPLOADS_MARKER),
127+
"expected agentic prompt to include attachment pass-through teaching",
128+
);
129+
assert.ok(
130+
withCap!.system_prompt.includes("attachments"),
131+
"expected the prompt to mention the `attachments` field",
132+
);
133+
});
134+
135+
test("getZaruInit('agentic') WITHOUT the 'chat-uploads' capability returns the base prompt", () => {
136+
const withoutCap = getZaruInit("agentic");
137+
assert.notEqual(withoutCap, null);
138+
assert.ok(
139+
!withoutCap!.system_prompt.includes(CHAT_UPLOADS_MARKER),
140+
"expected base agentic prompt to omit attachment teaching",
141+
);
142+
143+
const emptyCaps = getZaruInit("agentic", { capabilities: [] });
144+
assert.notEqual(emptyCaps, null);
145+
assert.ok(!emptyCaps!.system_prompt.includes(CHAT_UPLOADS_MARKER));
146+
147+
const otherCap = getZaruInit("agentic", { capabilities: ["live"] });
148+
assert.notEqual(otherCap, null);
149+
assert.ok(!otherCap!.system_prompt.includes(CHAT_UPLOADS_MARKER));
150+
});
151+
152+
test("getZaruInit('workflow') with the 'chat-uploads' capability augments the system prompt", () => {
153+
const withCap = getZaruInit("workflow", {
154+
capabilities: ["chat-uploads"],
155+
});
156+
assert.notEqual(withCap, null);
157+
assert.ok(withCap!.system_prompt.includes(CHAT_UPLOADS_MARKER));
158+
});
159+
160+
test("getZaruInit('workflow') WITHOUT the 'chat-uploads' capability returns the base prompt", () => {
161+
const withoutCap = getZaruInit("workflow");
162+
assert.notEqual(withoutCap, null);
163+
assert.ok(!withoutCap!.system_prompt.includes(CHAT_UPLOADS_MARKER));
164+
});
165+
166+
test("getZaruInit('chat') with 'chat-uploads' does NOT inject attachment teaching (chat is non-dispatching)", () => {
167+
const result = getZaruInit("chat", { capabilities: ["chat-uploads"] });
168+
assert.notEqual(result, null);
169+
assert.ok(!result!.system_prompt.includes(CHAT_UPLOADS_MARKER));
170+
});
171+
172+
test("getZaruInit('execute') with 'chat-uploads' does NOT inject attachment teaching", () => {
173+
const result = getZaruInit("execute", { capabilities: ["chat-uploads"] });
174+
assert.notEqual(result, null);
175+
assert.ok(!result!.system_prompt.includes(CHAT_UPLOADS_MARKER));
176+
});
177+
178+
test("getZaruInit('live') with 'chat-uploads' + 'live' does NOT inject attachment teaching", () => {
179+
const result = getZaruInit("live", {
180+
runtime: "browser",
181+
capabilities: ["live", "chat-uploads"],
182+
});
183+
assert.notEqual(result, null);
184+
assert.ok(!result!.system_prompt.includes(CHAT_UPLOADS_MARKER));
185+
});
186+
187+
test("getZaruInit('vibecode') with 'chat-uploads' + 'vibecode' does NOT inject attachment teaching", () => {
188+
const result = getZaruInit("vibecode", {
189+
runtime: "browser",
190+
capabilities: ["vibecode", "chat-uploads"],
191+
});
192+
assert.notEqual(result, null);
193+
assert.ok(!result!.system_prompt.includes(CHAT_UPLOADS_MARKER));
194+
});
195+
196+
test("getZaruInit('operator') with 'chat-uploads' does NOT inject attachment teaching", () => {
197+
const result = getZaruInit("operator", {
198+
capabilities: ["chat-uploads"],
199+
});
200+
assert.notEqual(result, null);
201+
assert.ok(!result!.system_prompt.includes(CHAT_UPLOADS_MARKER));
202+
});

0 commit comments

Comments
 (0)