Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ command, and no credentials in the config (Linear authorizes over OAuth):

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

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

### Authorization on first run

The first time Corbits Code connects to an http server that requires auth, the TUI
Expand Down
2 changes: 1 addition & 1 deletion src/auth/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { type } from "arktype";

import { withMockedModule } from "../../tests/helpers/mock-module.js";
import { withMockedModule } from "../../testkit/mock-module.js";
import type { BaseTokens } from "./store.js";

let failingUnlinkPath: string | undefined;
Expand Down
128 changes: 128 additions & 0 deletions src/mcp/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,134 @@ describe("mcpClientToAgentTools", () => {
});
});

describe("mcpClientToAgentTools Linear result projection", () => {
const fullIssue = {
id: "issue-uuid",
identifier: "CL-9495",
url: "https://linear.app/corbits/issue/CL-9495",
title: "Project MCP results",
status: "In Progress",
description: "# long body that must not echo",
};

function linearClient(
toolName: string,
envelope: ScriptedMcpEnvelope,
): MCPClient {
const client: MCPClient = {
serverName: "linear",
tools: [
{
name: toolName,
description: toolName,
inputSchema: { type: "object", properties: {} },
},
],
call: async () => "",
callBlocks: async () => envelope.blocks,
close: async () => undefined,
};
Object.assign(client, {
callResult: async () => envelope,
});
return client;
}

async function runLinear(
toolName: string,
envelope: ScriptedMcpEnvelope,
args: Record<string, unknown> = {},
spillOptions?: Parameters<typeof mcpClientToAgentTools>[2],
) {
const [tool] = mcpClientToAgentTools(
linearClient(toolName, envelope),
skipGate(),
spillOptions,
);
if (tool?.kind !== "full") throw new Error("expected full tool");
return tool.handler(
{ id: "c-linear", name: `mcp__linear__${toolName}`, arguments: args },
new AbortController().signal,
);
}

test("save_issue returns id url status title and not the body just sent", async () => {
const result = await runLinear("save_issue", {
blocks: [{ type: "text", text: JSON.stringify(fullIssue) }],
});
const parsed = JSON.parse(String(result.content)) as Record<
string,
unknown
>;
expect(parsed).toEqual({
id: "issue-uuid",
identifier: "CL-9495",
url: "https://linear.app/corbits/issue/CL-9495",
status: "In Progress",
title: "Project MCP results",
});
expect(String(result.content)).not.toContain("long body");
});

test("list_issues is short by default and expands when fields asks", async () => {
const envelope: ScriptedMcpEnvelope = {
blocks: [
{
type: "text",
text: JSON.stringify({ issues: [fullIssue], hasNextPage: false }),
},
],
};
const shortResult = await runLinear("list_issues", envelope, {});
const shortParsed = JSON.parse(String(shortResult.content)) as {
issues: Record<string, unknown>[];
hasNextPage: boolean;
};
expect(shortParsed.hasNextPage).toBe(false);
expect(shortParsed.issues[0]).toEqual({
id: "issue-uuid",
identifier: "CL-9495",
url: "https://linear.app/corbits/issue/CL-9495",
status: "In Progress",
title: "Project MCP results",
});
expect(String(shortResult.content)).not.toContain("long body");

const expanded = await runLinear("list_issues", envelope, {
fields: ["id", "description"],
});
expect(JSON.parse(String(expanded.content))).toEqual({
issues: [
{
id: "issue-uuid",
description: "# long body that must not echo",
},
],
hasNextPage: false,
});
});

test("archives the full entity and returns the projection to the model", async () => {
const { archive } = memoryEvidenceArchive();
const result = await runLinear(
"save_issue",
{
blocks: [{ type: "text", text: JSON.stringify(fullIssue) }],
structuredContent: fullIssue,
},
{},
{ getEvidenceArchive: () => archive },
);
expect(String(result.content)).not.toContain("long body");
const [occurrence] = await archive.listOccurrences();
if (occurrence === undefined) throw new Error("missing archive occurrence");
const payload = await archive.readAuthorizedPayload(
occurrence.occurrenceId,
);
expect(payload).toContain("long body");
});
});

function makeFakeClient(serverName: string, toolNames: string[]): MCPClient {
return {
serverName,
Expand Down
39 changes: 28 additions & 11 deletions src/mcp/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ import {
type SpillBlobWriter,
} from "../plugins/result-truncation-plugin.js";
import type { CompactionArchive } from "../session/compaction-archive.js";
import type {
MCPClient,
MCPContentBlock,
MCPToolResultEnvelope,
import {
unwrapToolContent,
type MCPClient,
type MCPContentBlock,
type MCPToolResultEnvelope,
} from "./client.js";
import { applyMcpResultProjection } from "./result-projection.js";
import { mcpToolName } from "./tool-name.js";
import { unwrapToolContent } from "./client.js";

export const MCP_RECONNECTING_TOOL_ERROR =
"MCP server is reconnecting; retry the call once it reports connected.";
Expand Down Expand Up @@ -138,10 +139,6 @@ export function mcpClientTools(
unknown
>);
const isError = envelope.isError === true;
const serializedStructured =
scrubbedStructured === undefined
? undefined
: serializeStructuredContent(scrubbedStructured);
const archive = getEvidenceArchive?.();
let archivedFullEnvelope = false;
if (archive !== undefined) {
Expand All @@ -163,13 +160,33 @@ export function mcpClientTools(
// Archive write must not fail a successful tool result.
}
}
const flattened = unwrapToolContent(authorizedBlocks);
const projected = isError
? {
blocks: authorizedBlocks,
...(scrubbedStructured !== undefined
? { structuredContent: scrubbedStructured }
: {}),
}
: applyMcpResultProjection({
serverName: client.serverName,
toolName: tool.name,
args: call.arguments,
blocks: authorizedBlocks,
...(scrubbedStructured !== undefined
? { structuredContent: scrubbedStructured }
: {}),
});
const serializedStructured =
projected.structuredContent === undefined
? undefined
: serializeStructuredContent(projected.structuredContent);
const flattened = unwrapToolContent(projected.blocks);
const baseContent =
flattened !== ""
? flattened
: serializedStructured !== undefined
? `${MCP_STRUCTURED_CONTENT_MARKER}\n${serializedStructured.serialized}`
: scrubbedStructured !== undefined
: projected.structuredContent !== undefined
? `${MCP_STRUCTURED_CONTENT_MARKER}\n[structured content unavailable]`
: isError
? `MCP tool ${client.serverName}/${tool.name} reported an error with empty content.`
Expand Down
Loading
Loading