Skip to content
2 changes: 2 additions & 0 deletions packages/harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"@effect/platform-node": "4.0.5",
"@effect/platform-node-shared": "4.0.5",
"@effect/sql-sqlite-node": "4.0.5",
"@modelcontextprotocol/client": "2.3.1",
"@npmcli/arborist": "^10.0.2",
"@npmcli/config": "^11.0.1",
"@platformatic/vfs": "^0.4.0",
Expand All @@ -98,6 +99,7 @@
},
"devDependencies": {
"@daytona/sdk": "^0.211.2",
"@modelcontextprotocol/server": "2.3.1",
"@types/node": "catalog:",
"@types/npm-package-arg": "^6.1.4",
"@vercel/sandbox": "^3.3.0",
Expand Down
484 changes: 484 additions & 0 deletions packages/harness/src/mcp/client.ts

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions packages/harness/src/mcp/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Message } from "@codeworksh/aikit";
import type { CallToolResult, ContentBlock } from "@modelcontextprotocol/client";

export type Content = Message.TextContent | Message.ImageContent;

const fromBlock = (block: ContentBlock): Content => {
switch (block.type) {
case "text":
return { type: "text", text: block.text };
case "image":
return { type: "image", data: block.data, mimeType: block.mimeType };
case "audio":
return { type: "text", text: `[audio ${block.mimeType} omitted]` };
case "resource_link":
return { type: "text", text: `${block.name}: ${block.uri}` };
case "resource": {
const resource = block.resource;
if ("text" in resource) return { type: "text", text: resource.text };
if (resource.mimeType?.startsWith("image/")) {
return { type: "image", data: resource.blob, mimeType: resource.mimeType };
}
return {
type: "text",
text: `[binary resource ${resource.uri} (${resource.mimeType ?? "unknown type"}) omitted]`,
};
}
default:
return { type: "text", text: `[unsupported MCP content ${(block as { type: string }).type}]` };
}
};

/**
* A tool result as model content. A result without content blocks but with `structuredContent`
* becomes its JSON, since servers should, but do not always, mirror structured results as text.
*/
export const fromResult = (result: Pick<CallToolResult, "content" | "structuredContent">): Content[] => {
const content = (result.content ?? []).map(fromBlock);
if (content.length === 0 && result.structuredContent !== undefined) {
content.push({ type: "text", text: JSON.stringify(result.structuredContent, null, 2) });
}
return content;
};

export * as McpContent from "./content.ts";
36 changes: 36 additions & 0 deletions packages/harness/src/mcp/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Schema } from "effect";

/** The server could not be started, reached or initialized. */
export class ConnectError extends Schema.TaggedError<ConnectError>()("Mcp.ConnectError", {
server: Schema.String,
message: Schema.String,
}) {}

/** The server rejected the connection until the user signs in. */
export class NeedsAuthError extends Schema.TaggedError<NeedsAuthError>()("Mcp.NeedsAuthError", {
server: Schema.String,
message: Schema.String,
}) {}

/** A Streamable HTTP server no longer knows this connection's session; the caller reconnects. */
export class SessionExpiredError extends Schema.TaggedError<SessionExpiredError>()("Mcp.SessionExpiredError", {
server: Schema.String,
}) {
override get message(): string {
return `mcp server session expired: ${this.server}`;
}
}

/** A request on a connected server failed. */
export class RequestError extends Schema.TaggedError<RequestError>()("Mcp.RequestError", {
server: Schema.String,
method: Schema.String,
message: Schema.String,
}) {}

/** A failure inside the stdio transport, reported to the SDK through `onerror` or a rejected promise. */
export class StdioError extends Schema.TaggedError<StdioError>()("Mcp.StdioError", {
message: Schema.String,
}) {}

export * as McpError from "./error.ts";
57 changes: 57 additions & 0 deletions packages/harness/src/mcp/log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { LoggingMessageNotificationParams } from "@modelcontextprotocol/client";
import { DateTime, Effect, Semaphore } from "effect";
import { fileSystem, hostPath } from "../host.ts";

const MAX_LOG_BYTES = 5 * 1024 * 1024;

const formatData = (data: unknown): string => {
if (typeof data === "string") return data;
try {
return JSON.stringify(data) ?? String(data);
} catch {
return String(data);
}
};

/** One `notifications/message` from `server` as a log line; continuation lines are indented. */
export const format = (server: string, params: LoggingMessageNotificationParams, now: DateTime.Utc): string => {
const logger = params.logger ? ` ${params.logger}:` : "";
const text = formatData(params.data).replace(/\r?\n/g, "\n ");
return `${DateTime.formatIso(now)} [${server}] ${params.level}${logger} ${text}\n`;
};

/**
* Appends server log messages to one file, rotated to `<path>.1` once it grows past 5 MB. Several
* processes may write to the same file, so the size is re-read before rotating. Write errors are
* ignored: logging must not break tools.
*/
export const make = Effect.fn("McpLog.make")(function* (path: string) {
const lock = yield* Semaphore.make(1);
let size: number | undefined;
const current = fileSystem.stat(path).pipe(
Effect.map((info) => Number(info.size)),
Effect.orElseSucceed(() => 0),
);

const write = (server: string, params: LoggingMessageNotificationParams) =>
lock.withPermits(1)(
Effect.gen(function* () {
const line = format(server, params, yield* DateTime.now);
if (size === undefined) {
yield* fileSystem.makeDirectory(hostPath.dirname(path), { recursive: true });
size = yield* current;
}
if (size > MAX_LOG_BYTES) {
// Another process may have rotated it already; check before renaming.
if ((yield* current) > MAX_LOG_BYTES) yield* fileSystem.rename(path, `${path}.1`);
size = yield* current;
}
yield* fileSystem.writeFileString(path, line, { flag: "a" });
size += Buffer.byteLength(line);
}).pipe(Effect.ignore),
);

return { path, write };
});

export * as McpLog from "./log.ts";
Loading
Loading