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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json

# Claude Code internal config (developer tooling, not part of OSS repo)
.claude
.environments/collateral-test/
75 changes: 75 additions & 0 deletions src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,81 @@ export async function handleResourceProxy(
});
}

/**
* Handle POST /v1/resources/read — MCP resources/read proxy.
*
* Body: { server, uri }
* Returns: MCP ReadResourceResult — { contents: [{ uri, mimeType?, text?, blob? }] }.
* Binary payloads are returned as base64-encoded `blob` strings per spec.
*/
export async function handleReadResource(
request: Request,
runtime: Runtime,
options?: { workspaceId?: string },
): Promise<Response> {
const body = await parseJsonBody(request);
if (body instanceof Response) return body;

const { server, uri } = body as { server?: string; uri?: string };
if (!server || typeof server !== "string") {
return apiError(400, "bad_request", "'server' is required");
}
if (!uri || typeof uri !== "string") {
return apiError(400, "bad_request", "'uri' is required");
}

const workspaceId = options?.workspaceId;
if (!workspaceId) {
return apiError(400, "bad_request", "Workspace ID required");
}

// Workspace scoping — reject servers not in the active workspace.
const wsRegistry = await runtime.ensureWorkspaceRegistry(workspaceId);
if (!wsRegistry.hasSource(server)) {
return apiError(
403,
"workspace_access_denied",
`Server "${server}" is not available in this workspace`,
{ server },
);
}

const resource = await runtime.readAppResource(server, uri, workspaceId);
if (resource === null) {
return apiError(404, "resource_not_found", `Resource "${uri}" not found`, {
server,
uri,
});
}

const entry: Record<string, unknown> = { uri };
if (resource.mimeType) entry.mimeType = resource.mimeType;
if (resource.blob) {
entry.blob = bytesToBase64(resource.blob);
} else {
entry.text = resource.text ?? "";
}

return json({ contents: [entry] });
}

/**
* Base64-encode a Uint8Array. Prefers Bun/Node's native Buffer (single C++
* call, significantly faster on large binaries than the chunked btoa path).
* Falls back to a stack-safe btoa loop for runtimes without Buffer.
*/
export function bytesToBase64(bytes: Uint8Array): string {
if (typeof Buffer !== "undefined") {
return Buffer.from(bytes).toString("base64");
}
const CHUNK = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}

/** Handle POST /v1/tools/call — direct tool invocation. */
export async function handleToolCall(
request: Request,
Expand Down
7 changes: 5 additions & 2 deletions src/api/routes/resources.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { handleResourceProxy } from "../handlers.ts";
import { handleReadResource, handleResourceProxy } from "../handlers.ts";
import { requireAuth } from "../middleware/auth.ts";
import { errorLog } from "../middleware/error-log.ts";
import { requireWorkspace } from "../middleware/workspace.ts";
Expand All @@ -10,12 +10,15 @@ export function resourceRoutes(ctx: AppContext) {
.use("*", requireAuth(ctx.authOptions))
.use("*", requireWorkspace(ctx.workspaceStore))
.use("*", errorLog(ctx))
.post("/v1/resources/read", (c) =>
handleReadResource(c.req.raw, ctx.runtime, { workspaceId: c.var.workspaceId }),
)
.get("/v1/apps/:name/resources/*", (c) => {
const name = decodeURIComponent(c.req.param("name"));
// Extract the full resource path after /resources/
const url = new URL(c.req.url);
const prefix = `/v1/apps/${c.req.param("name")}/resources/`;
const resourcePath = url.pathname.slice(prefix.length);
const resourcePath = decodeURIComponent(url.pathname.slice(prefix.length));
return handleResourceProxy(name, resourcePath, ctx.runtime, c.var.workspaceId);
});
}
6 changes: 6 additions & 0 deletions src/conversation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ export type StoredMessage = LanguageModelV3Message & {
ok: boolean;
ms: number;
resourceUri?: string;
resourceLinks?: Array<{
uri: string;
name?: string;
mimeType?: string;
description?: string;
}>;
}>;
inputTokens?: number;
outputTokens?: number;
Expand Down
32 changes: 32 additions & 0 deletions src/engine/content-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
import type { ContentBlock, TextContent } from "./types.ts";

/** A resource_link content block surfaced from an MCP tool result. */
export interface ResourceLinkInfo {
uri: string;
name?: string;
mimeType?: string;
description?: string;
}

/**
* Collect `resource_link` content blocks from a ContentBlock array.
*
* Per the MCP spec (2025-11-25), tools may return `resource_link` blocks that
* point to resources fetched separately via `resources/read`. We surface the
* bare metadata so UIs can render viewers without pulling the full payload
* through the agent loop.
*/
export function extractResourceLinks(blocks: ContentBlock[]): ResourceLinkInfo[] {
const links: ResourceLinkInfo[] = [];
for (const block of blocks) {
if ((block as { type?: string }).type !== "resource_link") continue;
const b = block as Record<string, unknown>;
const uri = typeof b.uri === "string" ? b.uri : undefined;
if (!uri) continue;
const link: ResourceLinkInfo = { uri };
if (typeof b.name === "string") link.name = b.name;
if (typeof b.mimeType === "string") link.mimeType = b.mimeType;
if (typeof b.description === "string") link.description = b.description;
links.push(link);
}
return links;
}

/** Wrap a plain string in a single TextContent block. */
export function textContent(text: string): ContentBlock[] {
return [{ type: "text" as const, text }];
Expand Down
24 changes: 21 additions & 3 deletions src/engine/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import type {
import { MAX_ITERATIONS, MAX_TOOL_RESULT_CHARS } from "../limits.ts";
import { callModel, type StreamResult } from "../model/stream.ts";
import { validateToolInput } from "../tools/validate-input.ts";
import { estimateContentSize, extractTextForModel, textContent } from "./content-helpers.ts";
import {
estimateContentSize,
extractResourceLinks,
extractTextForModel,
textContent,
} from "./content-helpers.ts";
import { withRetry } from "./retry.ts";
import { ActiveTaskTracker, getImmediateResponse, type McpTask, pollTask } from "./tasks.ts";
import type {
Expand Down Expand Up @@ -436,6 +441,11 @@ export class AgentEngine {
// text output is always needed for conversation history reconstruction.
const outputText = extractTextForModel(finalResult.content);

// Per-call resource_link blocks (MCP 2025-11-25). Distinct from the
// static `resourceUri` tool annotation used for inline UI binding —
// resource_link points at a file/resource the client should fetch.
const resourceLinks = extractResourceLinks(finalResult.content);

this.events.emit({
type: "tool.done",
data: {
Expand All @@ -447,10 +457,11 @@ export class AgentEngine {
resourceUri,
output: outputText,
result: resourceUri ? finalResult : undefined,
...(resourceLinks.length > 0 ? { resourceLinks } : {}),
},
});

return { toolCall, result: finalResult, ms, resourceUri };
return { toolCall, result: finalResult, ms, resourceUri, resourceLinks };
}),
);

Expand All @@ -460,7 +471,13 @@ export class AgentEngine {
// The full result is still available to the inline UI via tool.done event.
const toolResultParts: LanguageModelV3ToolResultPart[] = [];

for (const { toolCall, result, ms, resourceUri: uri } of toolResults) {
for (const {
toolCall,
result,
ms,
resourceUri: uri,
resourceLinks: links,
} of toolResults) {
let llmText = extractTextForModel(result.content);

if (uri && llmText.length > MAX_TOOL_RESULT_CHARS) {
Expand Down Expand Up @@ -490,6 +507,7 @@ export class AgentEngine {
ok: !result.isError,
ms,
...(uri ? { resourceUri: uri } : {}),
...(links && links.length > 0 ? { resourceLinks: links } : {}),
});

toolResultParts.push({
Expand Down
11 changes: 11 additions & 0 deletions src/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,15 @@ export interface ToolCallRecord {
ok: boolean;
ms: number;
resourceUri?: string;
/**
* MCP `resource_link` content blocks surfaced by the tool result.
* Distinct from `resourceUri`: this is a per-call, spec-defined pointer
* to resources the client should fetch via `resources/read`.
*/
resourceLinks?: Array<{
uri: string;
name?: string;
mimeType?: string;
description?: string;
}>;
}
6 changes: 4 additions & 2 deletions src/runtime/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1356,8 +1356,10 @@ export class Runtime {
const source = registry.getSources().find((s) => s.name === appName);
if (!source || !isResourceReader(source)) return null;

// Try the exact URI first (ui://path as registered by the server),
// then namespaced fallback (ui://appName/path) for backwards compat
if (resourcePath.includes("://")) {
return source.readResource(resourcePath);
}

const exactUri = `ui://${resourcePath}`;
const namespacedUri = `ui://${appName}/${resourcePath}`;

Expand Down
Loading