Skip to content
Closed
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
3 changes: 2 additions & 1 deletion src/bridge-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
* (Cursor bridge-pool pattern).
*/
import type { ClaudeQueryHandle } from "./query.js";
import type { McpToolResultContent } from "./prompt.js";

export type ParkedToolCall = {
id: string;
name: string;
arguments: string;
resolve: (result: string) => void;
resolve: (result: McpToolResultContent[]) => void;
reject: (error: Error) => void;
};

Expand Down
29 changes: 29 additions & 0 deletions src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export type SdkUserPrompt = {
parent_tool_use_id: null;
};

export type McpToolResultContent =
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string };

function parseDataUrl(url: string): {
mediaType: string;
data: string;
Expand Down Expand Up @@ -390,6 +394,31 @@ export function openaiContentToAnthropicBlocks(
return blocks;
}

/**
* Convert OpenAI-compatible tool result content into MCP result blocks.
* OpenCode's read tool returns images as file attachments on the tool result;
* those must stay attached when the parked Claude SDK tool call resumes.
*/
export function openaiToolResultToMcpContent(
content: unknown,
): McpToolResultContent[] {
const result: McpToolResultContent[] = [];
for (const block of openaiContentToAnthropicBlocks(content)) {
if (block.type === "text") {
result.push(block);
continue;
}
if (block.type === "image" && block.source.type === "base64") {
result.push({
type: "image",
data: block.source.data,
mimeType: block.source.media_type,
});
}
}
return result;
}

/**
* Latest user turn as a Claude Agent SDK prompt (string when text-only).
*/
Expand Down
23 changes: 15 additions & 8 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ import {
buildConversationTranscript,
extractTextContent,
latestUserPrompt,
openaiToolResultToMcpContent,
priorMessagesOf,
promptAsStream,
withConversationContext,
type SdkUserPrompt,
type McpToolResultContent,
} from "./prompt.js";
import {
detectMetaRequestKind,
Expand Down Expand Up @@ -325,11 +327,14 @@ async function handleRequest(req: Request): Promise<Response> {

function collectToolResults(
messages: OpenAIMessage[],
): Map<string, string> {
const results = new Map<string, string>();
): Map<string, McpToolResultContent[]> {
const results = new Map<string, McpToolResultContent[]>();
for (const msg of messages) {
if (msg.role !== "tool" || !msg.tool_call_id) continue;
results.set(msg.tool_call_id, extractTextContent(msg.content));
results.set(
msg.tool_call_id,
openaiToolResultToMcpContent(msg.content),
);
}
return results;
}
Expand Down Expand Up @@ -869,16 +874,18 @@ async function buildOpenCodeMcpServer(
resolve: () => {},
reject: () => {},
};
const resultPromise = new Promise<string>((resolve, reject) => {
pending.resolve = resolve;
pending.reject = reject;
});
const resultPromise = new Promise<McpToolResultContent[]>(
(resolve, reject) => {
pending.resolve = resolve;
pending.reject = reject;
},
);
// Register before notifying so the stream consumer sees the tool.
pendingTools.set(id, pending);
onPark();
const result = await resultPromise;
return {
content: [{ type: "text", text: result }],
content: result,
};
},
{ alwaysLoad: true },
Expand Down
19 changes: 19 additions & 0 deletions test/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ async function main() {
// Multimodal prompt conversion
const {
openaiContentToAnthropicBlocks,
openaiToolResultToMcpContent,
latestUserPrompt: buildPrompt,
contentHasAttachments,
} = await import("../src/prompt.ts");
Expand Down Expand Up @@ -448,6 +449,24 @@ async function main() {
]);
assert.equal(typeof multi === "object" && multi !== null && multi.type === "user", true);

// OpenCode read-tool attachments must survive the parked Claude SDK tool
// bridge. The MCP result shape uses { data, mimeType }, not Anthropic's
// message-level { source } shape.
const mcpToolResult = openaiToolResultToMcpContent([
{ type: "text", text: "Image read successfully" },
{
type: "file",
file: {
filename: "pixel.png",
file_data: `data:image/png;base64,${png}`,
},
},
]);
assert.deepEqual(mcpToolResult, [
{ type: "text", text: "Image read successfully" },
{ type: "image", data: png, mimeType: "image/png" },
]);

// Conversation key stability
const key = conversationKeyFromMessages([
{ role: "user", content: "hello world" },
Expand Down