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
54 changes: 43 additions & 11 deletions packages/argent-mcp/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,30 +117,62 @@ function isRecord(value: unknown): value is Record<string, unknown> {

// ── screenshot-diff adapter ──────────────────────────────────────────

/**
* `diffPath` / `contextDiffPath` are artifact handles on current tool-servers
* and raw host-path strings on older ones; both shapes render here.
*/
export interface ScreenshotDiffResult {
summary: string;
diffPath?: string;
contextDiffPath?: string;
diffPath?: unknown;
contextDiffPath?: unknown;
}

export function isScreenshotDiffResult(value: unknown): value is ScreenshotDiffResult {
if (!isRecord(value)) return false;
return typeof value.summary === "string";
}

// Render a screenshot-diff tool result as MCP content blocks.
// Render a screenshot-diff tool result as MCP content blocks: the downscaled
// context-diff image inline, then the textual summary.
export async function screenshotDiffToMcpContent(
result: ScreenshotDiffResult
result: ScreenshotDiffResult,
ctx?: ContentContext
): Promise<ContentBlock[]> {
const blocks: ContentBlock[] = [];

if (typeof result.contextDiffPath === "string") {
const buf = await readFile(result.contextDiffPath);
blocks.push({
type: "image" as const,
data: buf.toString("base64"),
mimeType: "image/png" as const,
});
// Resolve artifact handles to local files first; the context diff's bytes
// come back in `images` whether the file was already on this machine or was
// downloaded from a remote tool-server.
let contextDiffPath = result.contextDiffPath;
let materializedImages: { localPath: string; data: Buffer; mimeType: string }[] = [];
if (ctx) {
const { result: rewritten, images } = await materializeArtifacts(result, ctx);
contextDiffPath = (rewritten as ScreenshotDiffResult).contextDiffPath;
materializedImages = images;
}

if (typeof contextDiffPath === "string") {
const fromMaterializer = materializedImages.find((img) => img.localPath === contextDiffPath);
if (fromMaterializer) {
blocks.push({
type: "image" as const,
data: fromMaterializer.data.toString("base64"),
mimeType: fromMaterializer.mimeType,
});
} else {
// Legacy tool-server: a raw host path the materializer passed through.
// Only readable when co-located — exactly the old behavior.
try {
const buf = await readFile(contextDiffPath);
blocks.push({
type: "image" as const,
data: buf.toString("base64"),
mimeType: "image/png" as const,
});
} catch {
// Image unavailable; the summary below still renders.
}
}
}

blocks.push({ type: "text" as const, text: result.summary });
Expand Down
27 changes: 24 additions & 3 deletions packages/argent-mcp/src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
getResolvedToolsUrl,
isRemoteRouted,
getDeviceIdFromArgs,
prepareFileInputs,
applyClientFileDirectives,
type ToolMeta,
type ToolsServerPaths,
} from "@argent/tools-client";
Expand Down Expand Up @@ -161,11 +163,26 @@ export async function startMcpServer(options: StartMcpServerOptions): Promise<vo
): Promise<{ result: unknown; outputHint?: string; note?: string }> {
const tools = await fetchTools();
const meta = tools.find((t) => t.name === name);

// File boundary, outbound: wrap declared file-path args so the tool-server
// can read them in place (co-located) or from inlined content (remote).
// Metadata-driven: an older server that doesn't declare fileInputs gets
// the args verbatim.
let finalArgs = args;
if (meta?.fileInputs?.length) {
finalArgs = await prepareFileInputs(meta.fileInputs, args ?? {}, {
// `resolved` is the startup routing decision that picked TOOLS_URL —
// an external target means this process may not share the server's
// filesystem, so file bytes must ride along.
includeContent: resolved.url !== null,
});
}

const res = await fetchWithReconnect(() => `${TOOLS_URL}/tools/${name}`, reconnect, {
init: {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeader() },
body: JSON.stringify(args ?? {}),
body: JSON.stringify(finalArgs ?? {}),
},
fetchTimeoutMs: meta?.longRunning ? null : FETCH_TIMEOUT_MS,
});
Expand All @@ -174,7 +191,11 @@ export async function startMcpServer(options: StartMcpServerOptions): Promise<vo

if (!res.ok) throw new Error(json.error ?? json.message ?? res.statusText);

return { result: json.data, outputHint: meta?.outputHint, note: json.note };
// File boundary, inbound: persist any client-write directives (files that
// belong in the agent's project, e.g. recorded flow YAMLs) and rewrite
// them to the written paths.
const { result: data } = await applyClientFileDirectives(json.data);
return { result: data, outputHint: meta?.outputHint, note: json.note };
}

const server = new Server(
Expand Down Expand Up @@ -242,7 +263,7 @@ export async function startMcpServer(options: StartMcpServerOptions): Promise<vo
) {
content = await flowRunToMcpContent(result as FlowExecuteResult, ctx);
} else if (params.name === "screenshot-diff" && isScreenshotDiffResult(result)) {
content = await screenshotDiffToMcpContent(result);
content = await screenshotDiffToMcpContent(result, ctx);
} else {
content = await toMcpContent(result, outputHint, ctx, params.arguments);
}
Expand Down
220 changes: 220 additions & 0 deletions packages/argent-tools-client/src/file-inputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/**
* Client half of the INPUT-side file boundary (the OUTPUT side is
* `artifacts.ts`'s materializer).
*
* Tools that read caller-local files declare them as `fileInputs` in their
* `GET /tools` metadata: `{ target, path-template, kind }`. Before sending a
* call, {@link prepareFileInputs} interpolates each template from the args,
* stats the file on THIS machine, and replaces the target arg with a
* `__argentFileInput` wrapper. The tool-server resolves the wrapper back to a
* path on ITS filesystem — in place when co-located, or materialized from the
* inlined base64 content when remote. Content is inlined only when the client
* is routed to an external tool-server, so plain local sessions never pay for
* encoding.
*
* {@link applyClientFileDirectives} is the reverse direction: a tool whose
* output belongs in the agent's project (e.g. a recorded flow YAML) returns a
* `__argentClientFile` directive, and this client writes the content to the
* directive's path — constrained to `.argent/flows/*.yaml` so a misbehaving
* tool-server cannot direct writes anywhere else on the client machine.
*/

import { mkdir, stat, writeFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import * as path from "node:path";

/** Must match the tool-server's wire contract (`@argent/registry` file-inputs.ts). */
export const FILE_INPUT_MARKER = "__argentFileInput" as const;
export const CLIENT_FILE_MARKER = "__argentClientFile" as const;

export type FileInputKind = "file" | "directory" | "probe";

/** One declared file-boundary arg, as advertised by `GET /tools`. */
export interface FileInputSpec {
target: string;
path: string;
kind: FileInputKind;
optional?: boolean;
}

export interface FileInputWire {
[FILE_INPUT_MARKER]: true;
path: string;
size?: number;
mtimeMs?: number;
content?: string;
/** Why readable content was deliberately not inlined ("size-limit" = over MAX_CONTENT_BYTES). */
contentOmitted?: "size-limit";
}

export interface ClientFileDirective {
[CLIENT_FILE_MARKER]: true;
path: string;
content: string;
}

/**
* Hard ceiling on inlined content, mirroring the server's decoded-upload
* limit. A larger file is sent as a stat-only wrapper marked
* `contentOmitted: "size-limit"`: it still resolves in place co-located, and
* a remote server without the file answers with a precise "exceeds the
* transfer limit" error instead of this client dying on a huge encode.
*/
const MAX_CONTENT_BYTES = 32 * 1024 * 1024;

export interface PrepareFileInputsOptions {
/**
* Inline file bytes for `kind: "file"` wrappers. True when the client is
* routed to an external tool-server (link / ARGENT_TOOLS_URL); false keeps
* the wrapper path-only for the co-located fast path.
*/
includeContent: boolean;
}

/**
* Interpolate a spec's `${param}` path template from string args. Returns
* null when any referenced param is absent — the spec simply doesn't apply
* to this call (required-param errors belong to the tool's own validation).
*/
function interpolatePath(template: string, args: Record<string, unknown>): string | null {
let missing = false;
const out = template.replace(/\$\{([A-Za-z0-9_]+)\}/g, (_m, name: string) => {
const v = args[name];
if (typeof v !== "string" || v.length === 0) {
missing = true;
return "";
}
return v;
});
return missing ? null : out;
}

/**
* Replace declared file-path args with boundary wrappers. Returns the args
* untouched (same reference) when no spec applies, so callers can cheaply
* pass everything through here.
*/
export async function prepareFileInputs(
specs: FileInputSpec[] | undefined,
args: unknown,
opts: PrepareFileInputsOptions
): Promise<unknown> {
if (!specs || specs.length === 0 || typeof args !== "object" || args === null) {
return args;
}
const record = args as Record<string, unknown>;
let out: Record<string, unknown> | null = null;

for (const spec of specs) {
// A target the agent already filled in (e.g. an explicit server-side
// flow_file override) is respected — wrapping it would second-guess the
// caller with a client-side path that may not exist.
if (spec.target in record && typeof record[spec.target] !== "string") continue;
const filePath = interpolatePath(spec.path, record);
if (filePath === null) continue;
// When the target IS a source param, the interpolated path equals its
// value; when it's a derived param (flow_file), only wrap if unset.
if (spec.target in record && record[spec.target] !== filePath) continue;

const wire: FileInputWire = { [FILE_INPUT_MARKER]: true, path: filePath };
if (spec.kind === "file") {
try {
const st = await stat(filePath);
if (st.isFile()) {
wire.size = st.size;
wire.mtimeMs = st.mtimeMs;
if (opts.includeContent && st.size <= MAX_CONTENT_BYTES) {
wire.content = (await readFile(filePath)).toString("base64");
} else if (opts.includeContent) {
// Too big to ride in the call — say so instead of sending a bare
// wrapper, so an absent-on-server path gets a "transfer limit"
// error rather than misleading "file not found" guidance. The
// stat fields stay, so a co-located copy still resolves in place.
wire.contentOmitted = "size-limit";
}
}
} catch {
// Unreadable here — send the path-only wrapper; the server may still
// find it on its own filesystem, and otherwise errors precisely.
}
}

out = out ?? { ...record };
out[spec.target] = wire;
}

return out ?? args;
}

// ── Client-write directives ──────────────────────────────────────────

export interface AppliedClientFiles {
/** The result with every directive replaced by the written path (or null). */
result: unknown;
/** Paths actually written on this machine. */
written: string[];
}

/**
* Trust boundary: the directive path is authored by the tool-server. Today
* the only producer is flow recording, so writes are constrained to flow
* files — an absolute path whose final segments are `.argent/flows/<name>.yaml`
* with a conservative name charset and no `..` anywhere. Widen deliberately
* (and equally conservatively) if another tool ever needs this channel.
*/
function isAllowedClientFilePath(p: string): boolean {
if (!path.isAbsolute(p)) return false;
const segments = p.split(/[\\/]+/);
if (segments.includes("..")) return false;
const file = segments[segments.length - 1] ?? "";
if (!/^[A-Za-z0-9_-]+\.yaml$/.test(file)) return false;
return segments[segments.length - 3] === ".argent" && segments[segments.length - 2] === "flows";
}

function isClientFileDirective(value: unknown): value is ClientFileDirective {
return (
!!value &&
typeof value === "object" &&
(value as Record<string, unknown>)[CLIENT_FILE_MARKER] === true &&
typeof (value as ClientFileDirective).path === "string" &&
typeof (value as ClientFileDirective).content === "string"
);
}

/**
* Deep-walk a tool result, writing every client-file directive to disk and
* rewriting it to the written path. A directive that fails validation or the
* write resolves to null, mirroring how the artifact materializer signals a
* missing file. Results without directives pass through untouched.
*/
export async function applyClientFileDirectives(result: unknown): Promise<AppliedClientFiles> {
const written: string[] = [];

async function walk(value: unknown): Promise<unknown> {
if (isClientFileDirective(value)) {
if (!isAllowedClientFilePath(value.path)) return null;
try {
await mkdir(path.dirname(value.path), { recursive: true });
await writeFile(value.path, value.content, "utf8");
written.push(value.path);
return value.path;
} catch {
return null;
}
}
if (Array.isArray(value)) {
return Promise.all(value.map(walk));
}
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
out[k] = await walk(v);
}
return out;
}
return value;
}

const rewritten = await walk(result);
return { result: rewritten, written };
}
13 changes: 13 additions & 0 deletions packages/argent-tools-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,16 @@ export {
type MaterializeContext,
type MaterializedImage,
} from "./artifacts.js";

export {
prepareFileInputs,
applyClientFileDirectives,
FILE_INPUT_MARKER,
CLIENT_FILE_MARKER,
type FileInputSpec,
type FileInputKind,
type FileInputWire,
type ClientFileDirective,
type PrepareFileInputsOptions,
type AppliedClientFiles,
} from "./file-inputs.js";
Loading