From 6e17050cdf2e311e3db4dcc27945645ac4396057 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Fri, 12 Jun 2026 22:46:01 +0100 Subject: [PATCH] feat: add mcp source primitive --- bun.lock | 10 + packages/freya-source-mcp/package.json | 14 + packages/freya-source-mcp/src/index.ts | 26 + .../freya-source-mcp/src/mcp-client.test.ts | 275 ++++++++ packages/freya-source-mcp/src/mcp-client.ts | 276 ++++++++ .../freya-source-mcp/src/mcp-source.test.ts | 355 ++++++++++ packages/freya-source-mcp/src/mcp-source.ts | 624 ++++++++++++++++++ packages/freya-source-mcp/tsconfig.json | 4 + 8 files changed, 1584 insertions(+) create mode 100644 packages/freya-source-mcp/package.json create mode 100644 packages/freya-source-mcp/src/index.ts create mode 100644 packages/freya-source-mcp/src/mcp-client.test.ts create mode 100644 packages/freya-source-mcp/src/mcp-client.ts create mode 100644 packages/freya-source-mcp/src/mcp-source.test.ts create mode 100644 packages/freya-source-mcp/src/mcp-source.ts create mode 100644 packages/freya-source-mcp/tsconfig.json diff --git a/bun.lock b/bun.lock index 4a9631b..6485db7 100644 --- a/bun.lock +++ b/bun.lock @@ -200,6 +200,14 @@ "arktype": "^2.1.0", }, }, + "packages/freya-source-mcp": { + "name": "@freya/source-mcp", + "version": "0.0.0", + "dependencies": { + "@freya/core": "workspace:*", + "@modelcontextprotocol/sdk": "^1.27.1", + }, + }, "packages/freya-source-tfl": { "name": "@freya/source-tfl", "version": "0.0.0", @@ -241,6 +249,8 @@ "@freya/source-location": ["@freya/source-location@workspace:packages/freya-source-location"], + "@freya/source-mcp": ["@freya/source-mcp@workspace:packages/freya-source-mcp"], + "@freya/source-tfl": ["@freya/source-tfl@workspace:packages/freya-source-tfl"], "@freya/source-weatherkit": ["@freya/source-weatherkit@workspace:packages/freya-source-weatherkit"], diff --git a/packages/freya-source-mcp/package.json b/packages/freya-source-mcp/package.json new file mode 100644 index 0000000..8db0830 --- /dev/null +++ b/packages/freya-source-mcp/package.json @@ -0,0 +1,14 @@ +{ + "name": "@freya/source-mcp", + "version": "0.0.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "bun test src/" + }, + "dependencies": { + "@freya/core": "workspace:*", + "@modelcontextprotocol/sdk": "^1.27.1" + } +} diff --git a/packages/freya-source-mcp/src/index.ts b/packages/freya-source-mcp/src/index.ts new file mode 100644 index 0000000..8b61513 --- /dev/null +++ b/packages/freya-source-mcp/src/index.ts @@ -0,0 +1,26 @@ +export { + McpSource, + type McpActionMapping, + type McpContextResource, + type McpContextTool, + type McpFeedItem, + type McpFeedItemMapping, + type McpSourceOptions, +} from "./mcp-source" + +export { + StreamableHttpMcpClient, + type McpCallToolParams, + type McpCallToolResult, + type McpClient, + type McpHttpHeaders, + type McpListToolsParams, + type McpListToolsResult, + type McpReadResourceParams, + type McpReadResourceResult, + type McpResourceContent, + type McpRequestOptions, + type McpTool, + type McpToolContent, + type StreamableHttpMcpClientOptions, +} from "./mcp-client" diff --git a/packages/freya-source-mcp/src/mcp-client.test.ts b/packages/freya-source-mcp/src/mcp-client.test.ts new file mode 100644 index 0000000..1a21dfb --- /dev/null +++ b/packages/freya-source-mcp/src/mcp-client.test.ts @@ -0,0 +1,275 @@ +import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js" +import { describe, expect, test } from "bun:test" + +import { StreamableHttpMcpClient, type StreamableHttpMcpClientOptions } from "./mcp-client" + +type JsonRpcId = string | number + +type FetchLike = NonNullable< + NonNullable["fetch"] +> + +describe("StreamableHttpMcpClient", () => { + test("retries connection after initial connection failure", async () => { + const methods: string[] = [] + let initializeAttempts = 0 + const fetch: FetchLike = async (_url, init) => { + const method = requestMethod(init) + + if (init?.method === "GET") { + return new Response(null, { status: 405, statusText: "Method Not Allowed" }) + } + + methods.push(method) + switch (method) { + case "initialize": + initializeAttempts += 1 + if (initializeAttempts === 1) { + throw new Error("Transient connection failure") + } + return jsonRpcResponse(requestId(init), { + protocolVersion: "2025-06-18", + capabilities: { + tools: {}, + }, + serverInfo: { + name: "test-mcp", + version: "1.0.0", + }, + }) + case "notifications/initialized": + return new Response(null, { status: 202, statusText: "Accepted" }) + case "tools/list": + return jsonRpcResponse(requestId(init), { + tools: [], + }) + default: + throw new Error(`Unexpected MCP method: ${method}`) + } + } + + const client = new StreamableHttpMcpClient({ + url: "https://example.test/mcp", + transportOptions: { fetch }, + }) + + await expectRejectedMessage(client.listTools(), "Transient connection failure") + + const result = await client.listTools() + await client.close() + + expect(result.tools).toEqual([]) + expect(initializeAttempts).toBe(2) + expect(methods).toEqual(["initialize", "initialize", "notifications/initialized", "tools/list"]) + }) + + test("applies timeout to initial connection request", async () => { + const methods: string[] = [] + let initializeAttempts = 0 + const fetch: FetchLike = async (_url, init) => { + if (init?.method === "GET") { + return new Response(null, { status: 405, statusText: "Method Not Allowed" }) + } + + const method = requestMethod(init) + methods.push(method) + + switch (method) { + case "initialize": + initializeAttempts += 1 + if (initializeAttempts === 1) { + return new Promise(() => {}) + } + return jsonRpcResponse(requestId(init), { + protocolVersion: "2025-06-18", + capabilities: { + tools: {}, + }, + serverInfo: { + name: "test-mcp", + version: "1.0.0", + }, + }) + case "notifications/cancelled": + case "notifications/initialized": + return new Response(null, { status: 202, statusText: "Accepted" }) + case "tools/list": + return jsonRpcResponse(requestId(init), { + tools: [], + }) + default: + throw new Error(`Unexpected MCP method: ${method}`) + } + } + + const client = new StreamableHttpMcpClient({ + url: "https://example.test/mcp", + timeoutMs: 1, + transportOptions: { fetch }, + }) + + await expectMcpErrorCode(client.listTools(), ErrorCode.RequestTimeout) + + const result = await client.listTools() + await client.close() + + expect(result.tools).toEqual([]) + expect(initializeAttempts).toBe(2) + expect(methods).toEqual([ + "initialize", + "notifications/cancelled", + "initialize", + "notifications/initialized", + "tools/list", + ]) + }) + + test("applies caller signal to initial connection request", async () => { + const methods: string[] = [] + const fetch = createSuccessfulFetch(methods) + const controller = new AbortController() + controller.abort(new Error("Caller aborted")) + + const client = new StreamableHttpMcpClient({ + url: "https://example.test/mcp", + transportOptions: { fetch }, + }) + + await expectRejectedMessageContaining( + client.listTools(undefined, { signal: controller.signal }), + "Caller aborted", + ) + expect(methods).toEqual([]) + + const result = await client.listTools() + await client.close() + + expect(result.tools).toEqual([]) + expect(methods).toEqual(["initialize", "notifications/initialized", "tools/list"]) + }) +}) + +function createSuccessfulFetch(methods: string[]): FetchLike { + return async (_url, init) => { + if (init?.method === "GET") { + return new Response(null, { status: 405, statusText: "Method Not Allowed" }) + } + + const method = requestMethod(init) + methods.push(method) + + switch (method) { + case "initialize": + return jsonRpcResponse(requestId(init), { + protocolVersion: "2025-06-18", + capabilities: { + tools: {}, + }, + serverInfo: { + name: "test-mcp", + version: "1.0.0", + }, + }) + case "notifications/initialized": + return new Response(null, { status: 202, statusText: "Accepted" }) + case "tools/list": + return jsonRpcResponse(requestId(init), { + tools: [], + }) + default: + throw new Error(`Unexpected MCP method: ${method}`) + } + } +} + +function jsonRpcResponse(id: JsonRpcId, result: Record): Response { + return Response.json({ + jsonrpc: "2.0", + id, + result, + }) +} + +function requestMethod(init: RequestInit | undefined): string { + const request = requestBody(init) + const method = request.method + if (typeof method !== "string") { + throw new Error("Expected JSON-RPC request method") + } + return method +} + +function requestId(init: RequestInit | undefined): JsonRpcId { + const request = requestBody(init) + const id = request.id + if (typeof id !== "string" && typeof id !== "number") { + throw new Error("Expected JSON-RPC request id") + } + return id +} + +function requestBody(init: RequestInit | undefined): Record { + const body = init?.body + if (typeof body !== "string") { + throw new Error("Expected string request body") + } + + const value: unknown = JSON.parse(body) + if (!isRecord(value)) { + throw new Error("Expected object request body") + } + + return value +} + +async function expectRejectedMessage(promise: Promise, message: string): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(Error) + if (error instanceof Error) { + expect(error.message).toBe(message) + return + } + throw new Error("Expected promise to reject with an Error") + } + + throw new Error(`Expected promise to reject with message: ${message}`) +} + +async function expectMcpErrorCode(promise: Promise, code: ErrorCode): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(McpError) + if (error instanceof McpError) { + expect(error.code).toBe(code) + return + } + throw new Error("Expected promise to reject with an McpError") + } + + throw new Error(`Expected promise to reject with MCP error code: ${code}`) +} + +async function expectRejectedMessageContaining( + promise: Promise, + message: string, +): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(Error) + if (error instanceof Error) { + expect(error.message).toContain(message) + return + } + throw new Error("Expected promise to reject with an Error") + } + + throw new Error(`Expected promise to reject with message containing: ${message}`) +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} diff --git a/packages/freya-source-mcp/src/mcp-client.ts b/packages/freya-source-mcp/src/mcp-client.ts new file mode 100644 index 0000000..b97aeb7 --- /dev/null +++ b/packages/freya-source-mcp/src/mcp-client.ts @@ -0,0 +1,276 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { + StreamableHTTPClientTransport, + type StreamableHTTPClientTransportOptions, +} from "@modelcontextprotocol/sdk/client/streamableHttp.js" + +export interface McpRequestOptions { + readonly signal?: AbortSignal + readonly timeout?: number +} + +export interface McpListToolsParams { + readonly cursor?: string +} + +export interface McpReadResourceParams { + readonly uri: string +} + +export interface McpCallToolParams { + readonly name: string + readonly arguments?: Record +} + +export interface McpTool { + readonly name: string + readonly title?: string + readonly description?: string + readonly inputSchema?: { + readonly type: "object" + readonly properties?: Record + readonly required?: string[] + readonly [key: string]: unknown + } + readonly outputSchema?: { + readonly type: "object" + readonly properties?: Record + readonly required?: string[] + readonly [key: string]: unknown + } + readonly annotations?: { + readonly title?: string + readonly readOnlyHint?: boolean + readonly destructiveHint?: boolean + readonly idempotentHint?: boolean + readonly openWorldHint?: boolean + } + readonly _meta?: Record +} + +export interface McpListToolsResult { + readonly tools: readonly McpTool[] + readonly nextCursor?: string +} + +export type McpResourceContent = McpTextResourceContent | McpBlobResourceContent + +export interface McpTextResourceContent { + readonly uri: string + readonly mimeType?: string + readonly text: string + readonly _meta?: Record +} + +export interface McpBlobResourceContent { + readonly uri: string + readonly mimeType?: string + readonly blob: string + readonly _meta?: Record +} + +export interface McpReadResourceResult { + readonly contents: readonly McpResourceContent[] + readonly _meta?: Record +} + +export type McpToolContent = + | McpToolTextContent + | McpToolImageContent + | McpToolAudioContent + | McpToolResourceContent + | McpToolResourceLinkContent + +export interface McpToolTextContent { + readonly type: "text" + readonly text: string + readonly _meta?: Record +} + +export interface McpToolImageContent { + readonly type: "image" + readonly data: string + readonly mimeType: string + readonly _meta?: Record +} + +export interface McpToolAudioContent { + readonly type: "audio" + readonly data: string + readonly mimeType: string + readonly _meta?: Record +} + +export interface McpToolResourceContent { + readonly type: "resource" + readonly resource: McpResourceContent + readonly _meta?: Record +} + +export interface McpToolResourceLinkContent { + readonly type: "resource_link" + readonly uri: string + readonly name: string + readonly title?: string + readonly description?: string + readonly mimeType?: string + readonly _meta?: Record +} + +export interface McpCallToolResult { + readonly content?: readonly McpToolContent[] + readonly structuredContent?: Record + readonly toolResult?: unknown + readonly isError?: boolean + readonly _meta?: Record + readonly [key: string]: unknown +} + +export interface McpClient { + listTools(params?: McpListToolsParams, options?: McpRequestOptions): Promise + readResource( + params: McpReadResourceParams, + options?: McpRequestOptions, + ): Promise + callTool(params: McpCallToolParams, options?: McpRequestOptions): Promise + close?(): Promise +} + +export type McpHttpHeaders = + | Headers + | Record + | readonly (readonly [string, string])[] + +export interface StreamableHttpMcpClientOptions { + readonly url: string | URL + readonly name?: string + readonly version?: string + readonly timeoutMs?: number + readonly headers?: McpHttpHeaders | (() => Promise) + readonly requestInit?: RequestInit + readonly transportOptions?: Omit +} + +export class StreamableHttpMcpClient implements McpClient { + private clientPromise: Promise | null = null + + constructor(private readonly options: StreamableHttpMcpClientOptions) {} + + async listTools( + params?: McpListToolsParams, + options?: McpRequestOptions, + ): Promise { + const request = requestOptions(this.options.timeoutMs, options) + const client = await this.client(request) + return client.listTools(params, request) + } + + async readResource( + params: McpReadResourceParams, + options?: McpRequestOptions, + ): Promise { + const request = requestOptions(this.options.timeoutMs, options) + const client = await this.client(request) + return client.readResource(params, request) + } + + async callTool( + params: McpCallToolParams, + options?: McpRequestOptions, + ): Promise { + const request = requestOptions(this.options.timeoutMs, options) + const client = await this.client(request) + return client.callTool(params, undefined, request) + } + + async close(): Promise { + if (!this.clientPromise) return + const client = await this.clientPromise + this.clientPromise = null + await client.close() + } + + private client(options?: McpRequestOptions): Promise { + if (!this.clientPromise) { + const promise = this.connect(options) + this.clientPromise = promise + void promise.catch(() => { + if (this.clientPromise === promise) { + this.clientPromise = null + } + }) + } + return this.clientPromise + } + + private async connect(options?: McpRequestOptions): Promise { + const client = new Client({ + name: this.options.name ?? "freya-source-mcp", + version: this.options.version ?? "0.0.0", + }) + + const transport = new StreamableHTTPClientTransport(toUrl(this.options.url), { + ...this.options.transportOptions, + requestInit: await mergeRequestInit(this.options.requestInit, this.options.headers), + }) + + await client.connect(transport, options) + return client + } +} + +function requestOptions( + defaultTimeoutMs: number | undefined, + options: McpRequestOptions | undefined, +): McpRequestOptions | undefined { + if (defaultTimeoutMs === undefined && options === undefined) { + return undefined + } + return { + ...(defaultTimeoutMs === undefined ? {} : { timeout: defaultTimeoutMs }), + ...options, + } +} + +function toUrl(value: string | URL): URL { + if (value instanceof URL) return value + return new URL(value) +} + +async function mergeRequestInit( + requestInit: RequestInit | undefined, + headers: McpHttpHeaders | (() => Promise) | undefined, +): Promise { + if (!requestInit && !headers) return undefined + + const mergedHeaders = new Headers(requestInit?.headers) + const extraHeaders = typeof headers === "function" ? await headers() : headers + if (extraHeaders) { + applyHeaders(mergedHeaders, extraHeaders) + } + + return { + ...requestInit, + headers: mergedHeaders, + } +} + +function applyHeaders(target: Headers, headers: McpHttpHeaders): void { + if (headers instanceof Headers) { + headers.forEach((value, key) => { + target.set(key, value) + }) + return + } + + if (Array.isArray(headers)) { + for (const [key, value] of headers) { + target.set(key, value) + } + return + } + + for (const [key, value] of Object.entries(headers)) { + target.set(key, value) + } +} diff --git a/packages/freya-source-mcp/src/mcp-source.test.ts b/packages/freya-source-mcp/src/mcp-source.test.ts new file mode 100644 index 0000000..4b340fa --- /dev/null +++ b/packages/freya-source-mcp/src/mcp-source.test.ts @@ -0,0 +1,355 @@ +import { Context, UnknownActionError, contextKey, type ActionDefinition } from "@freya/core" +import { describe, expect, test } from "bun:test" + +import type { + McpCallToolParams, + McpCallToolResult, + McpClient, + McpListToolsParams, + McpListToolsResult, + McpReadResourceParams, + McpReadResourceResult, + McpTool, +} from "./mcp-client" + +import { McpSource } from "./mcp-source" + +class FakeMcpClient implements McpClient { + tools: readonly McpTool[] = [] + readonly resources = new Map() + readonly toolResults = new Map() + readonly listToolParams: Array = [] + readonly readResourceParams: McpReadResourceParams[] = [] + readonly callToolParams: McpCallToolParams[] = [] + + async listTools(params?: McpListToolsParams): Promise { + this.listToolParams.push(params) + return { tools: this.tools } + } + + async readResource(params: McpReadResourceParams): Promise { + this.readResourceParams.push(params) + const result = this.resources.get(params.uri) + if (!result) { + throw new Error(`Missing resource: ${params.uri}`) + } + return result + } + + async callTool(params: McpCallToolParams): Promise { + this.callToolParams.push(params) + const result = this.toolResults.get(params.name) + if (!result) { + throw new Error(`Missing tool result: ${params.name}`) + } + return result + } +} + +describe("McpSource", () => { + test("reads configured MCP resources into context", async () => { + const NotificationsKey = contextKey<{ unread: number }>("com.example.mcp", "notifications") + const client = new FakeMcpClient() + client.resources.set("mcp://notifications", { + contents: [ + { + uri: "mcp://notifications", + mimeType: "application/json", + text: JSON.stringify({ unread: 3 }), + }, + ], + }) + + const source = new McpSource({ + id: "com.example.mcp", + client, + resources: [ + { + uri: "mcp://notifications", + contextKey: NotificationsKey, + }, + ], + }) + + const context = new Context() + const entries = await source.fetchContext(context) + context.set(entries ?? []) + + expect(context.get(NotificationsKey)).toEqual({ unread: 3 }) + expect(client.readResourceParams).toEqual([{ uri: "mcp://notifications" }]) + }) + + test("calls configured MCP tools into context", async () => { + const ViewerKey = contextKey<{ name: string }>("com.example.mcp", "viewer") + const client = new FakeMcpClient() + client.toolResults.set("viewer", { + structuredContent: { name: "Kenneth" }, + }) + + const source = new McpSource({ + id: "com.example.mcp", + client, + contextTools: [ + { + tool: "viewer", + contextKey: ViewerKey, + }, + ], + }) + + const context = new Context() + const entries = await source.fetchContext(context) + context.set(entries ?? []) + + expect(context.get(ViewerKey)).toEqual({ name: "Kenneth" }) + expect(client.callToolParams).toEqual([{ name: "viewer", arguments: {} }]) + }) + + test("projects configured MCP resources into feed items", async () => { + const client = new FakeMcpClient() + client.resources.set("mcp://alerts", { + contents: [ + { + uri: "mcp://alerts", + text: JSON.stringify([{ title: "Build failed" }]), + }, + ], + }) + + const source = new McpSource({ + id: "com.example.mcp", + client, + feedItems: [ + { + kind: "resource", + uri: "mcp://alerts", + type: "mcp-alerts", + }, + ], + }) + + const context = new Context(new Date("2026-01-01T00:00:00.000Z")) + const items = await source.fetchItems(context) + + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ + sourceId: "com.example.mcp", + type: "mcp-alerts", + timestamp: context.time, + data: { + kind: "mcp-resource", + uri: "mcp://alerts", + value: [{ title: "Build failed" }], + }, + }) + }) + + test("lists allowlisted MCP tools as Freya actions", async () => { + const client = new FakeMcpClient() + client.tools = [ + { + name: "github.create_issue", + description: "Create a GitHub issue", + inputSchema: { type: "object" }, + }, + { + name: "github.delete_repo", + description: "Delete a repository", + inputSchema: { type: "object" }, + }, + ] + + const source = new McpSource({ + id: "com.example.github", + client, + actions: { + "create-issue": { + tool: "github.create_issue", + }, + }, + }) + + const actions = await source.listActions() + + expect(Object.keys(actions)).toEqual(["create-issue"]) + expect(actions["create-issue"]).toMatchObject({ + id: "create-issue", + description: "Create a GitHub issue", + }) + }) + + test("executes allowlisted MCP tools as Freya actions", async () => { + const client = new FakeMcpClient() + client.toolResults.set("github.create_issue", { + structuredContent: { issueNumber: 42 }, + }) + + const source = new McpSource({ + id: "com.example.github", + client, + actions: { + "create-issue": { + tool: "github.create_issue", + }, + }, + }) + + const result = await source.executeAction("create-issue", { title: "Bug" }) + + expect(result).toEqual({ issueNumber: 42 }) + expect(client.callToolParams).toEqual([ + { + name: "github.create_issue", + arguments: { title: "Bug" }, + }, + ]) + }) + + test("validates mapped action input before calling MCP tools", async () => { + const client = new FakeMcpClient() + client.toolResults.set("github.create_issue", { + structuredContent: { issueNumber: 42 }, + }) + + const source = new McpSource({ + id: "com.example.github", + client, + actions: { + "create-issue": { + tool: "github.create_issue", + input: createIssueInputSchema(), + }, + }, + }) + + await expectRejectedMessage( + source.executeAction("create-issue", { title: 42 }), + 'Invalid MCP action "create-issue" params: title: Expected string', + ) + expect(client.callToolParams).toEqual([]) + }) + + test("rejects MCP tools that are not allowlisted as actions", async () => { + const client = new FakeMcpClient() + client.tools = [ + { + name: "github.create_issue", + description: "Create a GitHub issue", + inputSchema: { type: "object" }, + }, + { + name: "github.delete_repo", + description: "Delete a repository", + inputSchema: { type: "object" }, + }, + ] + client.toolResults.set("github.delete_repo", { + structuredContent: { deleted: true }, + }) + + const source = new McpSource({ + id: "com.example.github", + client, + actions: { + "create-issue": { + tool: "github.create_issue", + }, + }, + }) + + const actions = await source.listActions() + + expect(Object.keys(actions)).toEqual(["create-issue"]) + await expectUnknownActionError(source.executeAction("github.delete_repo", {})) + expect(client.callToolParams).toEqual([]) + }) + + test("rejects unknown actions", async () => { + const source = new McpSource({ + id: "com.example.mcp", + client: new FakeMcpClient(), + actions: { + "known-action": { + tool: "known_tool", + }, + }, + }) + + await expectUnknownActionError(source.executeAction("unknown-action", {})) + }) + + test("requires object params for default action argument mapping", async () => { + const source = new McpSource({ + id: "com.example.mcp", + client: new FakeMcpClient(), + actions: { + "known-action": { + tool: "known_tool", + }, + }, + }) + + await expectRejectedMessage( + source.executeAction("known-action", "bad params"), + 'MCP action "known-action" requires object params', + ) + }) +}) + +async function expectUnknownActionError(promise: Promise): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(UnknownActionError) + return + } + + throw new Error("Expected promise to reject with UnknownActionError") +} + +async function expectRejectedMessage(promise: Promise, message: string): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(Error) + if (error instanceof Error) { + expect(error.message).toBe(message) + return + } + throw new Error("Expected promise to reject with an Error") + } + + throw new Error(`Expected promise to reject with message: ${message}`) +} + +function createIssueInputSchema(): NonNullable { + return { + "~standard": { + version: 1, + vendor: "freya-test", + validate(value: unknown) { + if (!isRecord(value)) { + return { + issues: [{ message: "Expected object" }], + } + } + + if (typeof value.title !== "string") { + return { + issues: [{ message: "Expected string", path: ["title"] }], + } + } + + return { + value: { + title: value.title.trim(), + }, + } + }, + }, + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} diff --git a/packages/freya-source-mcp/src/mcp-source.ts b/packages/freya-source-mcp/src/mcp-source.ts new file mode 100644 index 0000000..3e51ccc --- /dev/null +++ b/packages/freya-source-mcp/src/mcp-source.ts @@ -0,0 +1,624 @@ +import type { + ActionDefinition, + ContextEntry, + ContextKey, + FeedItem, + FeedItemSignals, + FeedSource, + Slot, +} from "@freya/core" + +import { Context, UnknownActionError } from "@freya/core" + +import { + StreamableHttpMcpClient, + type McpCallToolResult, + type McpClient, + type McpHttpHeaders, + type McpResourceContent, + type McpTool, + type McpToolContent, + type StreamableHttpMcpClientOptions, +} from "./mcp-client" + +export type McpFeedItem = FeedItem> + +/** + * Configuration for an MCP-backed `FeedSource`. + * + * The source is intentionally projection-based: remote MCP resources/tools are + * only exposed to Freya when listed here as context entries, feed items, or + * allowlisted actions. + */ +export interface McpSourceOptions { + /** Stable Freya source identifier, for example `freya.github` or `freya.discord`. */ + readonly id: string + /** Streamable HTTP MCP endpoint. Required unless `client` or `clientFactory` is provided. */ + readonly url?: string | URL + /** Client name advertised during MCP initialization. */ + readonly clientName?: string + /** Client version advertised during MCP initialization. */ + readonly clientVersion?: string + /** Default timeout, in milliseconds, for MCP connection and request calls. */ + readonly timeoutMs?: number + /** Static or lazily-resolved HTTP headers for the MCP transport. */ + readonly headers?: McpHttpHeaders | (() => Promise) + /** Additional `fetch` options merged into the MCP transport request init. */ + readonly requestInit?: RequestInit + /** Additional transport options forwarded to the MCP SDK streamable HTTP transport. */ + readonly transportOptions?: StreamableHttpMcpClientOptions["transportOptions"] + /** Preconfigured MCP client, primarily useful for tests or custom transports. */ + readonly client?: McpClient + /** Lazy MCP client factory, useful when client construction depends on runtime state. */ + readonly clientFactory?: () => McpClient | Promise + /** Freya source dependencies used by the context graph scheduler. */ + readonly dependencies?: readonly string[] + /** MCP resources to read and write into Freya context keys. */ + readonly resources?: readonly McpContextResource[] + /** MCP tools to call and write into Freya context keys. */ + readonly contextTools?: readonly McpContextTool[] + /** MCP resources or tools to project into feed items. */ + readonly feedItems?: readonly McpFeedItemMapping[] + /** Freya action IDs mapped to explicit, allowlisted MCP tools. */ + readonly actions?: Record +} + +export interface McpContextResource { + readonly uri: string + readonly contextKey: ContextKey + readonly map?: (contents: readonly McpResourceContent[], context: Context) => T | null +} + +export type McpToolArguments = + | Record + | ((context: Context) => Record) + +export interface McpContextTool { + readonly tool: string + readonly arguments?: McpToolArguments + readonly contextKey: ContextKey + readonly map?: (result: McpCallToolResult, context: Context) => T | null +} + +/** + * Mapping from a Freya action ID to an MCP tool call. + * + * Only actions declared in `McpSourceOptions.actions` can be executed through + * the source. The map is keyed by Freya action ID, while `tool` names the + * remote MCP tool to call. + */ +export interface McpActionMapping { + /** Remote MCP tool name to call when the Freya action is executed. */ + readonly tool: string + /** Optional action description; falls back to the MCP tool description/title when omitted. */ + readonly description?: string + /** Optional Standard Schema input validator exposed on the Freya action and checked locally. */ + readonly input?: ActionDefinition["input"] + /** Static MCP arguments or a mapper from validated Freya action params to MCP arguments. */ + readonly arguments?: Record | ((params: unknown) => Record) + /** Optional mapper from raw MCP tool result to the Freya action return value. */ + readonly mapResult?: (result: McpCallToolResult) => unknown +} + +export type McpFeedItemMapping = McpResourceFeedItemMapping | McpToolFeedItemMapping + +export type McpFeedPayload = McpResourceFeedPayload | McpToolFeedPayload + +export interface McpFeedItemBaseMapping { + readonly type: string + readonly id?: string | ((payload: McpFeedPayload, context: Context) => string) + readonly mapData?: (payload: McpFeedPayload, context: Context) => Record | null + readonly signals?: + | FeedItemSignals + | ((payload: McpFeedPayload, context: Context) => FeedItemSignals | undefined) + readonly slots?: + | Record + | ((payload: McpFeedPayload, context: Context) => Record) +} + +export interface McpResourceFeedItemMapping extends McpFeedItemBaseMapping { + readonly kind: "resource" + readonly uri: string +} + +export interface McpToolFeedItemMapping extends McpFeedItemBaseMapping { + readonly kind: "tool" + readonly tool: string + readonly arguments?: McpToolArguments +} + +export interface McpResourceFeedPayload { + readonly kind: "resource" + readonly uri: string + readonly contents: readonly McpResourceContent[] + readonly value: unknown +} + +export interface McpToolFeedPayload { + readonly kind: "tool" + readonly tool: string + readonly result: McpCallToolResult + readonly value: unknown +} + +/** + * FeedSource backed by a remote MCP server. + * + * The source intentionally uses explicit projections. A remote MCP server can + * expose many resources and tools, but only configured resources/tools enter the + * Freya context graph or action surface. + */ +export class McpSource implements FeedSource { + readonly id: string + readonly dependencies: readonly string[] | undefined + + private clientPromise: Promise | null = null + + constructor(private readonly options: McpSourceOptions) { + this.id = options.id + this.dependencies = options.dependencies + + if (!options.client && !options.clientFactory && !options.url) { + throw new Error("McpSource requires either a client, clientFactory, or remote url") + } + } + + async listActions(): Promise> { + const actionMappings = this.options.actions + if (!actionMappings) { + return {} + } + + const tools = await this.toolsByName() + const actions: Record = {} + + for (const [actionId, mapping] of Object.entries(actionMappings)) { + const tool = tools.get(mapping.tool) + if (!tool) { + throw new Error( + `Configured MCP action "${actionId}" maps to missing tool "${mapping.tool}"`, + ) + } + + const description = mapping.description ?? tool.description ?? tool.title + actions[actionId] = { + id: actionId, + ...(description ? { description } : {}), + ...(mapping.input ? { input: mapping.input } : {}), + } + } + + return actions + } + + async executeAction(actionId: string, params: unknown): Promise { + const mapping = this.options.actions?.[actionId] + if (!mapping) { + throw new UnknownActionError(actionId) + } + const validatedParams = await validateActionInput(actionId, params, mapping) + + const client = await this.client() + const result = await client.callTool( + { + name: mapping.tool, + arguments: resolveActionArguments(actionId, validatedParams, mapping), + }, + this.requestOptions(), + ) + + if (result.isError) { + throw new Error(`MCP tool "${mapping.tool}" returned an error: ${toolResultText(result)}`) + } + + return mapping.mapResult ? mapping.mapResult(result) : toolResultValue(result) + } + + async fetchContext(context: Context): Promise { + const resources = this.options.resources ?? [] + const contextTools = this.options.contextTools ?? [] + if (resources.length === 0 && contextTools.length === 0) { + return null + } + + const entries: ContextEntry[] = [] + const client = await this.client() + + for (const resource of resources) { + const result = await client.readResource({ uri: resource.uri }, this.requestOptions()) + const value = resource.map + ? resource.map(result.contents, context) + : resourceContentsValue(result.contents) + + if (value !== null) { + entries.push([resource.contextKey, value]) + } + } + + for (const tool of contextTools) { + const result = await client.callTool( + { + name: tool.tool, + arguments: resolveToolArguments(tool.arguments, context), + }, + this.requestOptions(), + ) + + if (result.isError) { + throw new Error(`MCP tool "${tool.tool}" returned an error: ${toolResultText(result)}`) + } + + const value = tool.map ? tool.map(result, context) : toolResultValue(result) + if (value !== null) { + entries.push([tool.contextKey, value]) + } + } + + return entries.length > 0 ? entries : null + } + + async fetchItems(context: Context): Promise { + const mappings = this.options.feedItems ?? [] + if (mappings.length === 0) { + return [] + } + + const client = await this.client() + const items: McpFeedItem[] = [] + + for (const mapping of mappings) { + const payload = await this.fetchFeedPayload(client, mapping, context) + const data = mapping.mapData + ? mapping.mapData(payload, context) + : defaultFeedItemData(payload) + + if (data === null) { + continue + } + + items.push({ + id: resolveFeedItemId(this.id, mapping, payload, context), + sourceId: this.id, + type: mapping.type, + timestamp: context.time, + data, + ...resolveSignals(mapping, payload, context), + ...resolveSlots(mapping, payload, context), + }) + } + + return items + } + + async close(): Promise { + if (!this.clientPromise) return + const client = await this.clientPromise + this.clientPromise = null + await client.close?.() + } + + private async fetchFeedPayload( + client: McpClient, + mapping: McpFeedItemMapping, + context: Context, + ): Promise { + switch (mapping.kind) { + case "resource": { + const result = await client.readResource({ uri: mapping.uri }, this.requestOptions()) + return { + kind: "resource", + uri: mapping.uri, + contents: result.contents, + value: resourceContentsValue(result.contents), + } + } + case "tool": { + const result = await client.callTool( + { + name: mapping.tool, + arguments: resolveToolArguments(mapping.arguments, context), + }, + this.requestOptions(), + ) + if (result.isError) { + throw new Error(`MCP tool "${mapping.tool}" returned an error: ${toolResultText(result)}`) + } + return { + kind: "tool", + tool: mapping.tool, + result, + value: toolResultValue(result), + } + } + } + } + + private async toolsByName(): Promise> { + const client = await this.client() + const tools = new Map() + let cursor: string | undefined + + do { + const result = await client.listTools(cursor ? { cursor } : undefined, this.requestOptions()) + for (const tool of result.tools) { + tools.set(tool.name, tool) + } + cursor = result.nextCursor + } while (cursor) + + return tools + } + + private client(): Promise { + if (!this.clientPromise) { + this.clientPromise = this.createClient() + } + return this.clientPromise + } + + private async createClient(): Promise { + if (this.options.client) { + return this.options.client + } + + if (this.options.clientFactory) { + return this.options.clientFactory() + } + + return new StreamableHttpMcpClient({ + url: this.options.url!, + name: this.options.clientName, + version: this.options.clientVersion, + timeoutMs: this.options.timeoutMs, + headers: this.options.headers, + requestInit: this.options.requestInit, + transportOptions: this.options.transportOptions, + }) + } + + private requestOptions(): { timeout?: number } | undefined { + if (this.options.timeoutMs === undefined) { + return undefined + } + return { timeout: this.options.timeoutMs } + } +} + +async function validateActionInput( + actionId: string, + params: unknown, + mapping: McpActionMapping, +): Promise { + if (!mapping.input) { + return params + } + + const result = await mapping.input["~standard"].validate(params) + if (result.issues) { + throw new Error( + `Invalid MCP action "${actionId}" params: ${formatStandardSchemaIssues(result.issues)}`, + ) + } + + return result.value +} + +function resolveToolArguments( + args: McpToolArguments | undefined, + context: Context, +): Record { + if (!args) return {} + if (typeof args === "function") { + return args(context) + } + return args +} + +function resolveActionArguments( + actionId: string, + params: unknown, + mapping: McpActionMapping, +): Record { + if (mapping.arguments) { + if (typeof mapping.arguments === "function") { + return mapping.arguments(params) + } + return mapping.arguments + } + + if (params === undefined || params === null) { + return {} + } + + if (!isRecord(params)) { + throw new Error(`MCP action "${actionId}" requires object params`) + } + + return params +} + +function resolveFeedItemId( + sourceId: string, + mapping: McpFeedItemMapping, + payload: McpFeedPayload, + context: Context, +): string { + if (typeof mapping.id === "function") { + return mapping.id(payload, context) + } + if (mapping.id) { + return mapping.id + } + + const identifier = payload.kind === "resource" ? payload.uri : payload.tool + return `${sourceId}-${mapping.type}-${slug(identifier)}` +} + +function resolveSignals( + mapping: McpFeedItemMapping, + payload: McpFeedPayload, + context: Context, +): { signals?: FeedItemSignals } { + if (!mapping.signals) return {} + const signals = + typeof mapping.signals === "function" ? mapping.signals(payload, context) : mapping.signals + return signals ? { signals } : {} +} + +function resolveSlots( + mapping: McpFeedItemMapping, + payload: McpFeedPayload, + context: Context, +): { slots?: Record } { + if (!mapping.slots) return {} + const slots = + typeof mapping.slots === "function" ? mapping.slots(payload, context) : mapping.slots + return { slots } +} + +function defaultFeedItemData(payload: McpFeedPayload): Record { + switch (payload.kind) { + case "resource": + return { + kind: "mcp-resource", + uri: payload.uri, + value: payload.value, + } + case "tool": + return { + kind: "mcp-tool", + tool: payload.tool, + value: payload.value, + } + } +} + +function resourceContentsValue(contents: readonly McpResourceContent[]): unknown { + const values = contents.map(resourceContentValue) + if (values.length === 1) { + return values[0] + } + return values +} + +function resourceContentValue(content: McpResourceContent): unknown { + if ("text" in content) { + return parseTextValue(content.text, content.mimeType) + } + + return { + uri: content.uri, + ...(content.mimeType ? { mimeType: content.mimeType } : {}), + blob: content.blob, + } +} + +function toolResultValue(result: McpCallToolResult): unknown { + if (result.structuredContent) { + return result.structuredContent + } + + if ("toolResult" in result) { + return result.toolResult + } + + if (result.content) { + const values = result.content.map(toolContentValue) + if (values.length === 1) { + return values[0] + } + return values + } + + return result +} + +function toolContentValue(content: McpToolContent): unknown { + switch (content.type) { + case "text": + return parseTextValue(content.text) + case "resource": + return resourceContentValue(content.resource) + case "resource_link": + return { + type: content.type, + uri: content.uri, + name: content.name, + ...(content.title ? { title: content.title } : {}), + ...(content.description ? { description: content.description } : {}), + ...(content.mimeType ? { mimeType: content.mimeType } : {}), + } + case "image": + case "audio": + return { + type: content.type, + data: content.data, + mimeType: content.mimeType, + } + } +} + +function toolResultText(result: McpCallToolResult): string { + const value = toolResultValue(result) + if (typeof value === "string") { + return value + } + return JSON.stringify(value) +} + +function parseTextValue(text: string, mimeType?: string): unknown { + if (shouldParseJson(text, mimeType)) { + try { + return JSON.parse(text) + } catch { + return text + } + } + return text +} + +function shouldParseJson(text: string, mimeType?: string): boolean { + if (mimeType?.includes("json")) { + return true + } + + const trimmed = text.trim() + return trimmed.startsWith("{") || trimmed.startsWith("[") +} + +function slug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function formatStandardSchemaIssues( + issues: readonly { + readonly message: string + readonly path?: readonly (PropertyKey | { readonly key: PropertyKey })[] + }[], +): string { + return issues.map(formatStandardSchemaIssue).join("; ") +} + +function formatStandardSchemaIssue(issue: { + readonly message: string + readonly path?: readonly (PropertyKey | { readonly key: PropertyKey })[] +}): string { + const path = issue.path?.map(formatStandardSchemaPathSegment).join(".") + return path ? `${path}: ${issue.message}` : issue.message +} + +function formatStandardSchemaPathSegment( + segment: PropertyKey | { readonly key: PropertyKey }, +): string { + if (typeof segment === "object" && segment !== null && "key" in segment) { + return String(segment.key) + } + return String(segment) +} diff --git a/packages/freya-source-mcp/tsconfig.json b/packages/freya-source-mcp/tsconfig.json new file mode 100644 index 0000000..0c91d62 --- /dev/null +++ b/packages/freya-source-mcp/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +}