Skip to content

Commit 0d134cc

Browse files
committed
feat(mcp): project Linear write and list results
Write and list tools echo the full entity, including the body just sent, into model context. Default to id, url, status, and title; expand list rows only when the call passes fields.
1 parent 4365a78 commit 0d134cc

5 files changed

Lines changed: 662 additions & 11 deletions

File tree

‎docs/MCP.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,12 @@ command, and no credentials in the config (Linear authorizes over OAuth):
226226

227227
Because only `url` is set, `type` defaults to `http`.
228228

229+
Write tools (`save_issue`, `save_comment`, `save_issue_label`) return `id`,
230+
`url`, `status`, and `title` (plus `identifier` when Linear sends it) — not the
231+
body just written. List tools (`list_issues`, `list_comments`, …) use the same
232+
short field set per row unless the call passes `fields`, which expands each row
233+
to those keys, or `fields: "all"` / `["*"]` for the full payload.
234+
229235
### Authorization on first run
230236

231237
The first time Corbits Code connects to an http server that requires auth, the TUI

‎src/mcp/plugin.test.ts‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,134 @@ describe("mcpClientToAgentTools", () => {
669669
});
670670
});
671671

672+
describe("mcpClientToAgentTools Linear result projection", () => {
673+
const fullIssue = {
674+
id: "issue-uuid",
675+
identifier: "CL-9495",
676+
url: "https://linear.app/corbits/issue/CL-9495",
677+
title: "Project MCP results",
678+
status: "In Progress",
679+
description: "# long body that must not echo",
680+
};
681+
682+
function linearClient(
683+
toolName: string,
684+
envelope: ScriptedMcpEnvelope,
685+
): MCPClient {
686+
const client: MCPClient = {
687+
serverName: "linear",
688+
tools: [
689+
{
690+
name: toolName,
691+
description: toolName,
692+
inputSchema: { type: "object", properties: {} },
693+
},
694+
],
695+
call: async () => "",
696+
callBlocks: async () => envelope.blocks,
697+
close: async () => undefined,
698+
};
699+
Object.assign(client, {
700+
callResult: async () => envelope,
701+
});
702+
return client;
703+
}
704+
705+
async function runLinear(
706+
toolName: string,
707+
envelope: ScriptedMcpEnvelope,
708+
args: Record<string, unknown> = {},
709+
spillOptions?: Parameters<typeof mcpClientToAgentTools>[2],
710+
) {
711+
const [tool] = mcpClientToAgentTools(
712+
linearClient(toolName, envelope),
713+
skipGate(),
714+
spillOptions,
715+
);
716+
if (tool?.kind !== "full") throw new Error("expected full tool");
717+
return tool.handler(
718+
{ id: "c-linear", name: `mcp__linear__${toolName}`, arguments: args },
719+
new AbortController().signal,
720+
);
721+
}
722+
723+
test("save_issue returns id url status title and not the body just sent", async () => {
724+
const result = await runLinear("save_issue", {
725+
blocks: [{ type: "text", text: JSON.stringify(fullIssue) }],
726+
});
727+
const parsed = JSON.parse(String(result.content)) as Record<
728+
string,
729+
unknown
730+
>;
731+
expect(parsed).toEqual({
732+
id: "issue-uuid",
733+
identifier: "CL-9495",
734+
url: "https://linear.app/corbits/issue/CL-9495",
735+
status: "In Progress",
736+
title: "Project MCP results",
737+
});
738+
expect(String(result.content)).not.toContain("long body");
739+
});
740+
741+
test("list_issues is short by default and expands when fields asks", async () => {
742+
const envelope: ScriptedMcpEnvelope = {
743+
blocks: [
744+
{
745+
type: "text",
746+
text: JSON.stringify({ issues: [fullIssue], hasNextPage: false }),
747+
},
748+
],
749+
};
750+
const shortResult = await runLinear("list_issues", envelope, {});
751+
const shortParsed = JSON.parse(String(shortResult.content)) as {
752+
issues: Record<string, unknown>[];
753+
hasNextPage: boolean;
754+
};
755+
expect(shortParsed.hasNextPage).toBe(false);
756+
expect(shortParsed.issues[0]).toEqual({
757+
id: "issue-uuid",
758+
identifier: "CL-9495",
759+
url: "https://linear.app/corbits/issue/CL-9495",
760+
status: "In Progress",
761+
title: "Project MCP results",
762+
});
763+
expect(String(shortResult.content)).not.toContain("long body");
764+
765+
const expanded = await runLinear("list_issues", envelope, {
766+
fields: ["id", "description"],
767+
});
768+
expect(JSON.parse(String(expanded.content))).toEqual({
769+
issues: [
770+
{
771+
id: "issue-uuid",
772+
description: "# long body that must not echo",
773+
},
774+
],
775+
hasNextPage: false,
776+
});
777+
});
778+
779+
test("archives the full entity and returns the projection to the model", async () => {
780+
const { archive } = memoryEvidenceArchive();
781+
const result = await runLinear(
782+
"save_issue",
783+
{
784+
blocks: [{ type: "text", text: JSON.stringify(fullIssue) }],
785+
structuredContent: fullIssue,
786+
},
787+
{},
788+
{ getEvidenceArchive: () => archive },
789+
);
790+
expect(String(result.content)).not.toContain("long body");
791+
const [occurrence] = await archive.listOccurrences();
792+
if (occurrence === undefined) throw new Error("missing archive occurrence");
793+
const payload = await archive.readAuthorizedPayload(
794+
occurrence.occurrenceId,
795+
);
796+
expect(payload).toContain("long body");
797+
});
798+
});
799+
672800
function makeFakeClient(serverName: string, toolNames: string[]): MCPClient {
673801
return {
674802
serverName,

‎src/mcp/plugin.ts‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ import {
1212
type SpillBlobWriter,
1313
} from "../plugins/result-truncation-plugin.js";
1414
import type { CompactionArchive } from "../session/compaction-archive.js";
15-
import type {
16-
MCPClient,
17-
MCPContentBlock,
18-
MCPToolResultEnvelope,
15+
import {
16+
unwrapToolContent,
17+
type MCPClient,
18+
type MCPContentBlock,
19+
type MCPToolResultEnvelope,
1920
} from "./client.js";
21+
import { applyMcpResultProjection } from "./result-projection.js";
2022
import { mcpToolName } from "./tool-name.js";
21-
import { unwrapToolContent } from "./client.js";
2223

2324
export const MCP_RECONNECTING_TOOL_ERROR =
2425
"MCP server is reconnecting; retry the call once it reports connected.";
@@ -138,10 +139,6 @@ export function mcpClientTools(
138139
unknown
139140
>);
140141
const isError = envelope.isError === true;
141-
const serializedStructured =
142-
scrubbedStructured === undefined
143-
? undefined
144-
: serializeStructuredContent(scrubbedStructured);
145142
const archive = getEvidenceArchive?.();
146143
let archivedFullEnvelope = false;
147144
if (archive !== undefined) {
@@ -163,13 +160,33 @@ export function mcpClientTools(
163160
// Archive write must not fail a successful tool result.
164161
}
165162
}
166-
const flattened = unwrapToolContent(authorizedBlocks);
163+
const projected = isError
164+
? {
165+
blocks: authorizedBlocks,
166+
...(scrubbedStructured !== undefined
167+
? { structuredContent: scrubbedStructured }
168+
: {}),
169+
}
170+
: applyMcpResultProjection({
171+
serverName: client.serverName,
172+
toolName: tool.name,
173+
args: call.arguments,
174+
blocks: authorizedBlocks,
175+
...(scrubbedStructured !== undefined
176+
? { structuredContent: scrubbedStructured }
177+
: {}),
178+
});
179+
const serializedStructured =
180+
projected.structuredContent === undefined
181+
? undefined
182+
: serializeStructuredContent(projected.structuredContent);
183+
const flattened = unwrapToolContent(projected.blocks);
167184
const baseContent =
168185
flattened !== ""
169186
? flattened
170187
: serializedStructured !== undefined
171188
? `${MCP_STRUCTURED_CONTENT_MARKER}\n${serializedStructured.serialized}`
172-
: scrubbedStructured !== undefined
189+
: projected.structuredContent !== undefined
173190
? `${MCP_STRUCTURED_CONTENT_MARKER}\n[structured content unavailable]`
174191
: isError
175192
? `MCP tool ${client.serverName}/${tool.name} reported an error with empty content.`

0 commit comments

Comments
 (0)