Skip to content
Open
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
114 changes: 114 additions & 0 deletions apps/connector/src/image-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";

import { imageFileArguments, MAX_IMAGE_FILE_BYTES } from "./image-file";
import { implementationBridge } from "./implementation";

type BridgeApi = Parameters<typeof implementationBridge>[0];

const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);

let base: string;
let root: string;
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "chopin-image-"));
root = join(base, "worktree");
await mkdir(join(root, "shots"), { recursive: true });
});
afterEach(() => rm(base, { recursive: true, force: true }));

test("reads an image inside the worktree as upload arguments", async () => {
await writeFile(join(root, "shots", "a.png"), PNG);
expect(await imageFileArguments(root, "shots/a.png")).toEqual({
data: Buffer.from(PNG).toString("base64"),
mimeType: "image/png",
});
expect((await imageFileArguments(root, join(root, "shots", "a.png"))).mimeType).toBe(
"image/png",
);
});

test("refuses paths outside the worktree, including through a symlink", async () => {
await writeFile(join(base, "outside.png"), PNG);
await symlink(join(base, "outside.png"), join(root, "link.png"));
await expect(imageFileArguments(root, "../outside.png")).rejects.toThrow("inside this run");
await expect(imageFileArguments(root, join(base, "outside.png"))).rejects.toThrow(
"inside this run",
);
await expect(imageFileArguments(root, "link.png")).rejects.toThrow("inside this run");
await expect(imageFileArguments(root, "missing.png")).rejects.toThrow("No file");
});

test("refuses non-images and images over 1 MiB with a smaller-screenshot hint", async () => {
await writeFile(join(root, "notes.txt"), "hello");
await expect(imageFileArguments(root, "notes.txt")).rejects.toThrow("PNG, JPEG or WebP");
let large = new Uint8Array(MAX_IMAGE_FILE_BYTES + 1);
large.set(PNG);
await writeFile(join(root, "large.png"), large);
await expect(imageFileArguments(root, "large.png")).rejects.toThrow("1280x800");
});

test("the spike bridge serves upload_image_file by forwarding the file's bytes", async () => {
await writeFile(join(root, "shot.png"), PNG);
let invoked: Array<{ name: string; args: Record<string, unknown> }> = [];
let api = {
tools: async () => ({
tools: [{
name: "upload_investigation_image",
inputSchema: { type: "object" as const },
}],
}),
invoke: async (name: string, args: Record<string, unknown> = {}) => {
invoked.push({ name, args });
return { content: [{ type: "text", text: JSON.stringify({ path: "/images/x.png" }) }] };
},
};
let server = await implementationBridge(api as unknown as BridgeApi, root);
let [client, transport] = InMemoryTransport.createLinkedPair();
await server.connect(transport);
let mcp = new Client({ name: "test", version: "1" });
await mcp.connect(client);
try {
let names = (await mcp.listTools()).tools.map(tool => tool.name);
expect(names).toEqual(["upload_investigation_image", "upload_image_file"]);
let result = await mcp.callTool({ name: "upload_image_file", arguments: { path: "shot.png" } });
expect(result.isError).toBeFalsy();
expect(invoked).toEqual([{
name: "upload_investigation_image",
args: { data: Buffer.from(PNG).toString("base64"), mimeType: "image/png" },
}]);
let refused = await mcp.callTool({
name: "upload_image_file",
arguments: { path: "../escape.png" },
});
expect(refused.isError).toBe(true);
expect(invoked).toHaveLength(1);
} finally {
await mcp.close();
await server.close();
}
});

test("bridges without a worktree or image upload offer no local tool", async () => {
let api = {
tools: async () => ({
tools: [{ name: "read_rebuild", inputSchema: { type: "object" as const } }],
}),
invoke: async () => ({ content: [] }),
};
let server = await implementationBridge(api as unknown as BridgeApi, root);
let [client, transport] = InMemoryTransport.createLinkedPair();
await server.connect(transport);
let mcp = new Client({ name: "test", version: "1" });
await mcp.connect(client);
try {
expect((await mcp.listTools()).tools.map(tool => tool.name)).toEqual(["read_rebuild"]);
} finally {
await mcp.close();
await server.close();
}
});
44 changes: 44 additions & 0 deletions apps/connector/src/image-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { readFile, realpath } from "node:fs/promises";
import { isAbsolute, relative, resolve } from "node:path";

/** Chopin hosts images of at most 1 MiB. */
export const MAX_IMAGE_FILE_BYTES = 1024 * 1024;

const TOO_LARGE =
"Take a smaller screenshot (a 1280x800 viewport PNG of the running prototype) and upload that.";

function mimeType(bytes: Uint8Array): string | undefined {
let ascii = (start: number, end: number) => String.fromCharCode(...bytes.subarray(start, end));
if (bytes[0] === 0x89 && ascii(1, 4) === "PNG") return "image/png";
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") return "image/webp";
}

/**
* Read a PNG, JPEG or WebP inside the run's worktree as upload_investigation_image arguments, so
* the agent passes a path instead of emitting the image's base64 as tokens.
*/
export async function imageFileArguments(
root: string,
path: string,
): Promise<{ data: string; mimeType: string }> {
let base = await realpath(root);
let target: string;
try {
target = await realpath(resolve(base, path));
} catch {
throw new Error(`No file at ${path} in this worktree.`);
}
let inside = relative(base, target);
if (!inside || inside.startsWith("..") || isAbsolute(inside)) {
throw new Error("Upload only image files inside this run's worktree.");
}
let bytes = new Uint8Array(await readFile(target));
let type = mimeType(bytes);
if (!type) throw new Error("Upload a PNG, JPEG or WebP image.");
if (bytes.byteLength > MAX_IMAGE_FILE_BYTES) {
let size = Math.ceil(bytes.byteLength / 1024);
throw new Error(`${path} is ${size} KiB; images are limited to 1 MiB. ${TOO_LARGE}`);
}
return { data: Buffer.from(bytes).toString("base64"), mimeType: type };
}
83 changes: 70 additions & 13 deletions apps/connector/src/implementation.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,78 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { imageFileArguments } from "./image-file";
import type { remote } from "./mcp";

export async function implementationBridge(api: Awaited<ReturnType<typeof remote>>) {
let tools = await api.tools();
let names = new Set(tools.tools.map(tool => tool.name));
let server = new Server({ name: "chopin-implementation", version: "0.1.0" }, {
capabilities: { tools: {} },
});
server.setRequestHandler(ListToolsRequestSchema, () => tools);
server.setRequestHandler(CallToolRequestSchema, async request => {
if (!names.has(request.params.name)) {
return { isError: true, content: [{ type: "text", text: "Tool unavailable for this run." }] };
/**
* Relay a run's server tools. With a worktree `root`, a run that may upload images also gets the
* connector-local upload_image_file, which reads the file here and forwards its bytes.
*/
export async function implementationBridge(
api: Pick<Awaited<ReturnType<typeof remote>>, "tools" | "invoke">,
root?: string,
) {
return (await relay(api, root))();
}

/** List the run's tools once and return a factory for relaying servers, one per transport. */
export async function relay(
api: Pick<Awaited<ReturnType<typeof remote>>, "tools" | "invoke">,
root?: string,
) {
let listed = await api.tools();
let names = new Set(listed.tools.map(tool => tool.name));
let local = !!root && names.has("upload_investigation_image");
let tools = local
? {
...listed,
tools: [...listed.tools, {
name: "upload_image_file",
description:
"Upload a PNG, JPEG or WebP screenshot (at most 1 MiB) saved in this worktree and "
+ "return its image path for submit_spike_result.",
inputSchema: {
type: "object" as const,
properties: {
path: {
type: "string",
description: "The image file, relative to the worktree root.",
},
},
required: ["path"],
additionalProperties: false,
},
}],
}
return api.invoke(request.params.name, request.params.arguments ?? {});
});
return server;
: listed;
return () => {
let server = new Server({ name: "chopin-implementation", version: "0.1.0" }, {
capabilities: { tools: {} },
});
server.setRequestHandler(ListToolsRequestSchema, () => tools);
server.setRequestHandler(CallToolRequestSchema, async request => {
let args = request.params.arguments ?? {};
if (local && request.params.name === "upload_image_file") {
try {
if (typeof args.path !== "string" || !args.path) throw new Error("Give an image path.");
return await api.invoke(
"upload_investigation_image",
await imageFileArguments(root!, args.path),
);
} catch (error) {
let text = error instanceof Error ? error.message : "Image upload failed.";
return { isError: true, content: [{ type: "text", text }] };
}
}
if (!names.has(request.params.name)) {
return {
isError: true,
content: [{ type: "text", text: "Tool unavailable for this run." }],
};
}
return api.invoke(request.params.name, args);
});
return server;
};
}

export let implementationPrompt = [
Expand Down
42 changes: 42 additions & 0 deletions apps/connector/src/local-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";

/**
* Serve run-scoped MCP tools to a local ACP agent over loopback streamable HTTP. Some agents (Copilot
* CLI in ACP mode) reject client-provided stdio servers, so connector-local tools need an HTTP URL.
* Each request gets a fresh stateless server; a per-run bearer token keeps other local processes
* and pages out.
*/
export function serveLocalBridge(open: () => Promise<Server>) {
let token = randomBytes(32).toString("base64url");
let expected = Buffer.from(`Bearer ${token}`);
let http = Bun.serve({
hostname: "127.0.0.1",
port: 0,
idleTimeout: 255,
async fetch(request) {
let given = Buffer.from(request.headers.get("authorization") ?? "");
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
return new Response(null, { status: 401 });
}
if (new URL(request.url).pathname !== "/mcp") return new Response(null, { status: 404 });
let server = await open();
let transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
await server.connect(transport);
try {
return await transport.handleRequest(request);
} finally {
void server.close();
}
},
});
return {
url: `http://127.0.0.1:${http.port}/mcp`,
token,
close: () => http.stop(true),
};
}
6 changes: 4 additions & 2 deletions apps/connector/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ async function bridge() {
process.env.CHOPIN_BRIDGE_TOKEN!,
);
let server;
if (process.env.CHOPIN_BRIDGE_KIND === "implementation") server = await implementationBridge(api);
else {
// A spike's tools are the server's run-scoped list, relayed as they are.
if (["implementation", "spike"].includes(process.env.CHOPIN_BRIDGE_KIND ?? "")) {
server = await implementationBridge(api, process.env.CHOPIN_BRIDGE_ROOT);
} else {
let context = await api.call("read_experiment") as { input: unknown };
server = createBridge(requestSchema.parse(context.input), async result => {
await api.call("submit_experiment_result", { result });
Expand Down
Loading
Loading