From aa77cbbb820277aa3c4c0b2b5fd9aa8d9321e776 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 16 Apr 2026 15:59:53 +0200 Subject: [PATCH] feat(appkit): add FunctionTool, HostedTool types and MCP client Add explicit tool type definitions and a lightweight MCP client: - FunctionTool: user-defined tools with JSON Schema parameters + execute - HostedTool: Genie, VectorSearch, custom/external MCP endpoint configs - AppKitMcpClient: zero-dependency JSON-RPC 2.0 client over HTTP fetch - Type guards (isFunctionTool, isHostedTool) and definition converters - resolveHostedTools maps HostedTool configs to MCP endpoint URLs - Test coverage for type guards, definition builders, and URL resolution Also add ergonomic helpers for defining tools at the app layer: - tool(): Zod-powered factory for function tools. Generates JSON Schema via z.toJSONSchema(), infers execute() arg types from the schema, and wraps execution with safeParse so invalid args return an LLM-friendly error string instead of throwing. - mcpServer(name, url): concise factory for custom MCP server tools, replacing the verbose { type, custom_mcp_server: { app_name, app_url } } wrapper. - Promote zod from optional peer dep to runtime dep (^4.0.0) so tool() and z.toJSONSchema() are always available. Signed-off-by: MarioCadenas --- knip.json | 2 +- packages/appkit/package.json | 12 +- packages/appkit/src/index.ts | 7 + .../plugins/agent/tests/function-tool.test.ts | 110 +++++++ .../plugins/agent/tests/hosted-tools.test.ts | 131 +++++++++ .../agent/tests/mcp-server-helper.test.ts | 34 +++ .../src/plugins/agent/tests/tool.test.ts | 110 +++++++ .../src/plugins/agent/tools/function-tool.ts | 33 +++ .../src/plugins/agent/tools/hosted-tools.ts | 102 +++++++ .../appkit/src/plugins/agent/tools/index.ts | 13 + .../src/plugins/agent/tools/mcp-client.ts | 278 ++++++++++++++++++ .../appkit/src/plugins/agent/tools/tool.ts | 52 ++++ pnpm-lock.yaml | 40 +-- 13 files changed, 895 insertions(+), 29 deletions(-) create mode 100644 packages/appkit/src/plugins/agent/tests/function-tool.test.ts create mode 100644 packages/appkit/src/plugins/agent/tests/hosted-tools.test.ts create mode 100644 packages/appkit/src/plugins/agent/tests/mcp-server-helper.test.ts create mode 100644 packages/appkit/src/plugins/agent/tests/tool.test.ts create mode 100644 packages/appkit/src/plugins/agent/tools/function-tool.ts create mode 100644 packages/appkit/src/plugins/agent/tools/hosted-tools.ts create mode 100644 packages/appkit/src/plugins/agent/tools/index.ts create mode 100644 packages/appkit/src/plugins/agent/tools/mcp-client.ts create mode 100644 packages/appkit/src/plugins/agent/tools/tool.ts diff --git a/knip.json b/knip.json index 8b34bb18..5743fa18 100644 --- a/knip.json +++ b/knip.json @@ -8,7 +8,7 @@ ], "workspaces": { "packages/appkit": { - "ignoreDependencies": ["ai", "@langchain/core", "zod"], + "ignoreDependencies": ["ai", "@langchain/core"], "entry": ["src/agents/*.ts"] }, "packages/appkit-ui": {} diff --git a/packages/appkit/package.json b/packages/appkit/package.json index b3211b31..409527ac 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -89,12 +89,12 @@ "semver": "7.7.3", "shared": "workspace:*", "vite": "npm:rolldown-vite@7.1.14", - "ws": "8.18.3" + "ws": "8.18.3", + "zod": "^4.0.0" }, "peerDependencies": { "@langchain/core": ">=0.3.0", - "ai": ">=4.0.0", - "zod": ">=3.0.0" + "ai": ">=4.0.0" }, "peerDependenciesMeta": { "ai": { @@ -102,9 +102,6 @@ }, "@langchain/core": { "optional": true - }, - "zod": { - "optional": true } }, "devDependencies": { @@ -115,8 +112,7 @@ "@types/pg": "8.16.0", "@types/ws": "8.18.1", "@vitejs/plugin-react": "5.1.1", - "ai": "7.0.0-beta.76", - "zod": "^4.3.6" + "ai": "7.0.0-beta.76" }, "overrides": { "vite": "npm:rolldown-vite@7.1.14" diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index c0c9fa0b..b5155834 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -63,6 +63,13 @@ export { toPlugin, } from "./plugin"; export { analytics, files, genie, lakebase, server, serving } from "./plugins"; +export { + type FunctionTool, + type HostedTool, + mcpServer, + type ToolConfig, + tool, +} from "./plugins/agent/tools"; export type { EndpointConfig, ServingEndpointEntry, diff --git a/packages/appkit/src/plugins/agent/tests/function-tool.test.ts b/packages/appkit/src/plugins/agent/tests/function-tool.test.ts new file mode 100644 index 00000000..8e668d69 --- /dev/null +++ b/packages/appkit/src/plugins/agent/tests/function-tool.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "vitest"; +import { + functionToolToDefinition, + isFunctionTool, +} from "../tools/function-tool"; + +describe("isFunctionTool", () => { + test("returns true for valid FunctionTool", () => { + expect( + isFunctionTool({ + type: "function", + name: "greet", + execute: async () => "hello", + }), + ).toBe(true); + }); + + test("returns true for minimal FunctionTool", () => { + expect( + isFunctionTool({ + type: "function", + name: "x", + execute: () => "y", + }), + ).toBe(true); + }); + + test("returns false for null", () => { + expect(isFunctionTool(null)).toBe(false); + }); + + test("returns false for non-object", () => { + expect(isFunctionTool("function")).toBe(false); + }); + + test("returns false for wrong type", () => { + expect( + isFunctionTool({ + type: "genie-space", + name: "x", + execute: () => "y", + }), + ).toBe(false); + }); + + test("returns false when execute is missing", () => { + expect(isFunctionTool({ type: "function", name: "x" })).toBe(false); + }); + + test("returns false when name is missing", () => { + expect(isFunctionTool({ type: "function", execute: () => "y" })).toBe( + false, + ); + }); +}); + +describe("functionToolToDefinition", () => { + test("converts a FunctionTool with all fields", () => { + const def = functionToolToDefinition({ + type: "function", + name: "getWeather", + description: "Get current weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + execute: async () => "sunny", + }); + + expect(def.name).toBe("getWeather"); + expect(def.description).toBe("Get current weather"); + expect(def.parameters).toEqual({ + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }); + }); + + test("uses name as fallback description", () => { + const def = functionToolToDefinition({ + type: "function", + name: "myTool", + execute: async () => "result", + }); + + expect(def.description).toBe("myTool"); + }); + + test("uses empty object schema when parameters are null", () => { + const def = functionToolToDefinition({ + type: "function", + name: "noParams", + parameters: null, + execute: async () => "ok", + }); + + expect(def.parameters).toEqual({ type: "object", properties: {} }); + }); + + test("uses empty object schema when parameters are omitted", () => { + const def = functionToolToDefinition({ + type: "function", + name: "noParams", + execute: async () => "ok", + }); + + expect(def.parameters).toEqual({ type: "object", properties: {} }); + }); +}); diff --git a/packages/appkit/src/plugins/agent/tests/hosted-tools.test.ts b/packages/appkit/src/plugins/agent/tests/hosted-tools.test.ts new file mode 100644 index 00000000..d62b266b --- /dev/null +++ b/packages/appkit/src/plugins/agent/tests/hosted-tools.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "vitest"; +import { isHostedTool, resolveHostedTools } from "../tools/hosted-tools"; + +describe("isHostedTool", () => { + test("returns true for genie-space", () => { + expect( + isHostedTool({ type: "genie-space", genie_space: { id: "abc" } }), + ).toBe(true); + }); + + test("returns true for vector_search_index", () => { + expect( + isHostedTool({ + type: "vector_search_index", + vector_search_index: { name: "cat.schema.idx" }, + }), + ).toBe(true); + }); + + test("returns true for custom_mcp_server", () => { + expect( + isHostedTool({ + type: "custom_mcp_server", + custom_mcp_server: { app_name: "my-app", app_url: "my-app-url" }, + }), + ).toBe(true); + }); + + test("returns true for external_mcp_server", () => { + expect( + isHostedTool({ + type: "external_mcp_server", + external_mcp_server: { connection_name: "conn1" }, + }), + ).toBe(true); + }); + + test("returns false for FunctionTool", () => { + expect( + isHostedTool({ type: "function", name: "x", execute: () => "y" }), + ).toBe(false); + }); + + test("returns false for null", () => { + expect(isHostedTool(null)).toBe(false); + }); + + test("returns false for unknown type", () => { + expect(isHostedTool({ type: "unknown" })).toBe(false); + }); + + test("returns false for non-object", () => { + expect(isHostedTool(42)).toBe(false); + }); +}); + +describe("resolveHostedTools", () => { + test("resolves genie-space to correct MCP endpoint", () => { + const configs = resolveHostedTools([ + { type: "genie-space", genie_space: { id: "space123" } }, + ]); + + expect(configs).toHaveLength(1); + expect(configs[0].name).toBe("genie-space123"); + expect(configs[0].url).toBe("/api/2.0/mcp/genie/space123"); + }); + + test("resolves vector_search_index with 3-part name", () => { + const configs = resolveHostedTools([ + { + type: "vector_search_index", + vector_search_index: { name: "catalog.schema.my_index" }, + }, + ]); + + expect(configs).toHaveLength(1); + expect(configs[0].name).toBe("vs-catalog-schema-my_index"); + expect(configs[0].url).toBe( + "/api/2.0/mcp/vector-search/catalog/schema/my_index", + ); + }); + + test("throws for invalid vector_search_index name", () => { + expect(() => + resolveHostedTools([ + { + type: "vector_search_index", + vector_search_index: { name: "bad.name" }, + }, + ]), + ).toThrow("3-part dotted"); + }); + + test("resolves custom_mcp_server", () => { + const configs = resolveHostedTools([ + { + type: "custom_mcp_server", + custom_mcp_server: { app_name: "my-app", app_url: "my-app-endpoint" }, + }, + ]); + + expect(configs[0].name).toBe("my-app"); + expect(configs[0].url).toBe("my-app-endpoint"); + }); + + test("resolves external_mcp_server", () => { + const configs = resolveHostedTools([ + { + type: "external_mcp_server", + external_mcp_server: { connection_name: "conn1" }, + }, + ]); + + expect(configs[0].name).toBe("conn1"); + expect(configs[0].url).toBe("/api/2.0/mcp/external/conn1"); + }); + + test("resolves multiple tools preserving order", () => { + const configs = resolveHostedTools([ + { type: "genie-space", genie_space: { id: "g1" } }, + { + type: "external_mcp_server", + external_mcp_server: { connection_name: "e1" }, + }, + ]); + + expect(configs).toHaveLength(2); + expect(configs[0].name).toBe("genie-g1"); + expect(configs[1].name).toBe("e1"); + }); +}); diff --git a/packages/appkit/src/plugins/agent/tests/mcp-server-helper.test.ts b/packages/appkit/src/plugins/agent/tests/mcp-server-helper.test.ts new file mode 100644 index 00000000..96ad8e38 --- /dev/null +++ b/packages/appkit/src/plugins/agent/tests/mcp-server-helper.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; +import { + isHostedTool, + mcpServer, + resolveHostedTools, +} from "../tools/hosted-tools"; + +describe("mcpServer()", () => { + test("returns a CustomMcpServerTool with correct shape", () => { + const result = mcpServer("my-app", "https://example.com/mcp"); + + expect(result).toEqual({ + type: "custom_mcp_server", + custom_mcp_server: { + app_name: "my-app", + app_url: "https://example.com/mcp", + }, + }); + }); + + test("isHostedTool recognizes mcpServer() output", () => { + expect(isHostedTool(mcpServer("x", "y"))).toBe(true); + }); + + test("resolveHostedTools resolves mcpServer() output to an endpoint config", () => { + const configs = resolveHostedTools([ + mcpServer("vector-search", "https://host/mcp/vs"), + ]); + + expect(configs).toHaveLength(1); + expect(configs[0].name).toBe("vector-search"); + expect(configs[0].url).toBe("https://host/mcp/vs"); + }); +}); diff --git a/packages/appkit/src/plugins/agent/tests/tool.test.ts b/packages/appkit/src/plugins/agent/tests/tool.test.ts new file mode 100644 index 00000000..3d47f3a9 --- /dev/null +++ b/packages/appkit/src/plugins/agent/tests/tool.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "vitest"; +import { z } from "zod"; +import { formatZodError, tool } from "../tools/tool"; + +describe("tool()", () => { + test("produces a FunctionTool with JSON Schema parameters from the Zod schema", () => { + const weather = tool({ + name: "get_weather", + description: "Get the current weather for a city", + schema: z.object({ + city: z.string().describe("City name"), + }), + execute: async ({ city }) => `Sunny in ${city}`, + }); + + expect(weather.type).toBe("function"); + expect(weather.name).toBe("get_weather"); + expect(weather.description).toBe("Get the current weather for a city"); + expect(weather.parameters).toMatchObject({ + type: "object", + properties: { + city: { type: "string", description: "City name" }, + }, + required: ["city"], + }); + }); + + test("execute receives typed args on valid input", async () => { + const echo = tool({ + name: "echo", + schema: z.object({ message: z.string() }), + execute: async ({ message }) => { + const _typed: string = message; + return `got ${_typed}`; + }, + }); + + const result = await echo.execute({ message: "hi" }); + expect(result).toBe("got hi"); + }); + + test("returns formatted error string (does not throw) when args are invalid", async () => { + const weather = tool({ + name: "get_weather", + schema: z.object({ city: z.string() }), + execute: async ({ city }) => `Sunny in ${city}`, + }); + + const result = await weather.execute({}); + expect(typeof result).toBe("string"); + expect(result).toContain("Invalid arguments for get_weather"); + expect(result).toContain("city"); + }); + + test("joins multiple validation errors with '; '", async () => { + const t = tool({ + name: "multi", + schema: z.object({ a: z.string(), b: z.number() }), + execute: async () => "ok", + }); + + const result = await t.execute({}); + expect(result).toContain("a:"); + expect(result).toContain("b:"); + expect(result).toContain(";"); + }); + + test("optional fields validate when absent", async () => { + const t = tool({ + name: "opt", + schema: z.object({ note: z.string().optional() }), + execute: async ({ note }) => note ?? "(no note)", + }); + + expect(await t.execute({})).toBe("(no note)"); + expect(await t.execute({ note: "hello" })).toBe("hello"); + }); + + test("description falls back to the tool name when omitted", () => { + const t = tool({ + name: "my_tool", + schema: z.object({}), + execute: async () => "ok", + }); + + expect(t.description).toBe("my_tool"); + expect(t.parameters).toBeDefined(); + }); +}); + +describe("formatZodError", () => { + test("formats a single issue with the tool name", () => { + const schema = z.object({ city: z.string() }); + const result = schema.safeParse({}); + if (result.success) throw new Error("expected failure"); + + const msg = formatZodError(result.error, "get_weather"); + expect(msg).toMatch(/^Invalid arguments for get_weather: /); + expect(msg).toContain("city:"); + }); + + test("joins multiple issues with '; '", () => { + const schema = z.object({ a: z.string(), b: z.number() }); + const result = schema.safeParse({}); + if (result.success) throw new Error("expected failure"); + + const msg = formatZodError(result.error, "t"); + expect(msg.split(";").length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/appkit/src/plugins/agent/tools/function-tool.ts b/packages/appkit/src/plugins/agent/tools/function-tool.ts new file mode 100644 index 00000000..8ce634e0 --- /dev/null +++ b/packages/appkit/src/plugins/agent/tools/function-tool.ts @@ -0,0 +1,33 @@ +import type { AgentToolDefinition } from "shared"; + +export interface FunctionTool { + type: "function"; + name: string; + description?: string | null; + parameters?: Record | null; + strict?: boolean | null; + execute: (args: Record) => Promise | string; +} + +export function isFunctionTool(value: unknown): value is FunctionTool { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return ( + obj.type === "function" && + typeof obj.name === "string" && + typeof obj.execute === "function" + ); +} + +export function functionToolToDefinition( + tool: FunctionTool, +): AgentToolDefinition { + return { + name: tool.name, + description: tool.description ?? tool.name, + parameters: (tool.parameters as AgentToolDefinition["parameters"]) ?? { + type: "object", + properties: {}, + }, + }; +} diff --git a/packages/appkit/src/plugins/agent/tools/hosted-tools.ts b/packages/appkit/src/plugins/agent/tools/hosted-tools.ts new file mode 100644 index 00000000..bce70c4f --- /dev/null +++ b/packages/appkit/src/plugins/agent/tools/hosted-tools.ts @@ -0,0 +1,102 @@ +export interface GenieTool { + type: "genie-space"; + genie_space: { id: string }; +} + +export interface VectorSearchIndexTool { + type: "vector_search_index"; + vector_search_index: { name: string }; +} + +export interface CustomMcpServerTool { + type: "custom_mcp_server"; + custom_mcp_server: { app_name: string; app_url: string }; +} + +export interface ExternalMcpServerTool { + type: "external_mcp_server"; + external_mcp_server: { connection_name: string }; +} + +export type HostedTool = + | GenieTool + | VectorSearchIndexTool + | CustomMcpServerTool + | ExternalMcpServerTool; + +const HOSTED_TOOL_TYPES = new Set([ + "genie-space", + "vector_search_index", + "custom_mcp_server", + "external_mcp_server", +]); + +export function isHostedTool(value: unknown): value is HostedTool { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return typeof obj.type === "string" && HOSTED_TOOL_TYPES.has(obj.type); +} + +export interface McpEndpointConfig { + name: string; + /** Absolute URL or path relative to workspace host */ + url: string; +} + +/** + * Resolves HostedTool configs into MCP endpoint configurations + * that the MCP client can connect to. + */ +function resolveHostedTool(tool: HostedTool): McpEndpointConfig { + switch (tool.type) { + case "genie-space": + return { + name: `genie-${tool.genie_space.id}`, + url: `/api/2.0/mcp/genie/${tool.genie_space.id}`, + }; + case "vector_search_index": { + const parts = tool.vector_search_index.name.split("."); + if (parts.length !== 3) { + throw new Error( + `vector_search_index name must be 3-part dotted (catalog.schema.index), got: ${tool.vector_search_index.name}`, + ); + } + return { + name: `vs-${parts.join("-")}`, + url: `/api/2.0/mcp/vector-search/${parts[0]}/${parts[1]}/${parts[2]}`, + }; + } + case "custom_mcp_server": + return { + name: tool.custom_mcp_server.app_name, + url: tool.custom_mcp_server.app_url, + }; + case "external_mcp_server": + return { + name: tool.external_mcp_server.connection_name, + url: `/api/2.0/mcp/external/${tool.external_mcp_server.connection_name}`, + }; + } +} + +export function resolveHostedTools(tools: HostedTool[]): McpEndpointConfig[] { + return tools.map(resolveHostedTool); +} + +/** + * Factory for declaring a custom MCP server tool. + * + * Replaces the verbose `{ type: "custom_mcp_server", custom_mcp_server: { app_name, app_url } }` + * wrapper with a concise positional call. + * + * Example: + * ```ts + * mcpServer("my-app", "https://my-app.databricksapps.com/mcp") + * ``` + */ +export function mcpServer(name: string, url: string): CustomMcpServerTool { + return { + type: "custom_mcp_server", + custom_mcp_server: { app_name: name, app_url: url }, + }; +} diff --git a/packages/appkit/src/plugins/agent/tools/index.ts b/packages/appkit/src/plugins/agent/tools/index.ts new file mode 100644 index 00000000..042f1958 --- /dev/null +++ b/packages/appkit/src/plugins/agent/tools/index.ts @@ -0,0 +1,13 @@ +export { + type FunctionTool, + functionToolToDefinition, + isFunctionTool, +} from "./function-tool"; +export { + type HostedTool, + isHostedTool, + mcpServer, + resolveHostedTools, +} from "./hosted-tools"; +export { AppKitMcpClient } from "./mcp-client"; +export { type ToolConfig, tool } from "./tool"; diff --git a/packages/appkit/src/plugins/agent/tools/mcp-client.ts b/packages/appkit/src/plugins/agent/tools/mcp-client.ts new file mode 100644 index 00000000..bd96d348 --- /dev/null +++ b/packages/appkit/src/plugins/agent/tools/mcp-client.ts @@ -0,0 +1,278 @@ +import type { AgentToolDefinition } from "shared"; +import { createLogger } from "../../../logging/logger"; +import type { McpEndpointConfig } from "./hosted-tools"; + +const logger = createLogger("agent:mcp"); + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params?: Record; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +interface McpToolSchema { + name: string; + description?: string; + inputSchema?: Record; +} + +interface McpToolCallResult { + content: Array<{ type: string; text?: string }>; + isError?: boolean; +} + +interface McpServerConnection { + config: McpEndpointConfig; + resolvedUrl: string; + tools: Map; +} + +/** + * Lightweight MCP client for Databricks-hosted MCP servers. + * + * Uses raw fetch() with JSON-RPC 2.0 over HTTP — no @modelcontextprotocol/sdk + * or LangChain dependency. Supports the Streamable HTTP transport (POST with + * JSON-RPC request, single JSON-RPC response). + */ +export class AppKitMcpClient { + private connections = new Map(); + private sessionIds = new Map(); + private requestId = 0; + private closed = false; + + constructor( + private workspaceHost: string, + private authenticate: () => Promise>, + ) {} + + async connectAll(endpoints: McpEndpointConfig[]): Promise { + const results = await Promise.allSettled( + endpoints.map((ep) => this.connect(ep)), + ); + for (let i = 0; i < results.length; i++) { + if (results[i].status === "rejected") { + logger.error( + "Failed to connect MCP server %s: %O", + endpoints[i].name, + (results[i] as PromiseRejectedResult).reason, + ); + } + } + } + + private resolveUrl(endpoint: McpEndpointConfig): string { + if ( + endpoint.url.startsWith("http://") || + endpoint.url.startsWith("https://") + ) { + return endpoint.url; + } + return `${this.workspaceHost}${endpoint.url}`; + } + + async connect(endpoint: McpEndpointConfig): Promise { + const url = this.resolveUrl(endpoint); + logger.info("Connecting to MCP server: %s at %s", endpoint.name, url); + + const initResponse = await this.sendRpc(url, "initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "appkit-agent", version: "0.1.0" }, + }); + + if (initResponse.sessionId) { + this.sessionIds.set(endpoint.name, initResponse.sessionId); + } + const sessionId = this.sessionIds.get(endpoint.name); + + await this.sendNotification(url, "notifications/initialized", sessionId); + + const listResponse = await this.sendRpc( + url, + "tools/list", + {}, + { sessionId }, + ); + const toolList = + (listResponse.result as { tools?: McpToolSchema[] })?.tools ?? []; + + const tools = new Map(); + for (const tool of toolList) { + tools.set(tool.name, tool); + } + + this.connections.set(endpoint.name, { + config: endpoint, + resolvedUrl: url, + tools, + }); + logger.info( + "Connected to MCP server %s: %d tools available", + endpoint.name, + tools.size, + ); + } + + getAllToolDefinitions(): AgentToolDefinition[] { + const defs: AgentToolDefinition[] = []; + for (const [serverName, conn] of this.connections) { + for (const [toolName, schema] of conn.tools) { + defs.push({ + name: `mcp.${serverName}.${toolName}`, + description: schema.description ?? toolName, + parameters: + (schema.inputSchema as AgentToolDefinition["parameters"]) ?? { + type: "object", + properties: {}, + }, + }); + } + } + return defs; + } + + async callTool( + qualifiedName: string, + args: unknown, + authHeaders?: Record, + ): Promise { + const parts = qualifiedName.split("."); + if (parts.length < 3 || parts[0] !== "mcp") { + throw new Error(`Invalid MCP tool name: ${qualifiedName}`); + } + const serverName = parts[1]; + const toolName = parts.slice(2).join("."); + + const conn = this.connections.get(serverName); + if (!conn) { + throw new Error(`MCP server not connected: ${serverName}`); + } + + const sessionId = this.sessionIds.get(serverName); + const rpcResult = await this.sendRpc( + conn.resolvedUrl, + "tools/call", + { name: toolName, arguments: args }, + { authOverride: authHeaders, sessionId }, + ); + const result = rpcResult.result as McpToolCallResult; + + if (result.isError) { + const errText = result.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + throw new Error(errText || "MCP tool call failed"); + } + + return result.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + } + + async close(): Promise { + this.closed = true; + this.connections.clear(); + } + + private async sendRpc( + url: string, + method: string, + params?: Record, + options?: { + authOverride?: Record; + sessionId?: string; + }, + ): Promise<{ result: unknown; sessionId?: string }> { + if (this.closed) throw new Error("MCP client is closed"); + + const request: JsonRpcRequest = { + jsonrpc: "2.0", + id: ++this.requestId, + method, + ...(params && { params }), + }; + + const authHeaders = options?.authOverride ?? (await this.authenticate()); + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...authHeaders, + }; + if (options?.sessionId) { + headers["Mcp-Session-Id"] = options.sessionId; + } + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(request), + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + throw new Error( + `MCP request to ${method} failed: ${response.status} ${response.statusText}`, + ); + } + + const contentType = response.headers.get("content-type") ?? ""; + let json: JsonRpcResponse; + + if (contentType.includes("text/event-stream")) { + const text = await response.text(); + const lastData = text + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice(6)) + .pop(); + if (!lastData) { + throw new Error(`MCP SSE response for ${method} contained no data`); + } + json = JSON.parse(lastData) as JsonRpcResponse; + } else { + json = (await response.json()) as JsonRpcResponse; + } + + if (json.error) { + throw new Error(`MCP error (${json.error.code}): ${json.error.message}`); + } + + const sid = response.headers.get("mcp-session-id") ?? undefined; + return { result: json.result, sessionId: sid }; + } + + private async sendNotification( + url: string, + method: string, + sessionId?: string, + ): Promise { + if (this.closed) return; + + const authHeaders = await this.authenticate(); + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...authHeaders, + }; + if (sessionId) { + headers["Mcp-Session-Id"] = sessionId; + } + + await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ jsonrpc: "2.0", method }), + signal: AbortSignal.timeout(30_000), + }); + } +} diff --git a/packages/appkit/src/plugins/agent/tools/tool.ts b/packages/appkit/src/plugins/agent/tools/tool.ts new file mode 100644 index 00000000..18b04485 --- /dev/null +++ b/packages/appkit/src/plugins/agent/tools/tool.ts @@ -0,0 +1,52 @@ +import { toJSONSchema, type z } from "zod"; +import type { FunctionTool } from "./function-tool"; + +export interface ToolConfig { + name: string; + description?: string; + schema: S; + execute: (args: z.infer) => Promise | string; +} + +/** + * Factory for defining function tools with Zod schemas. + * + * - Generates JSON Schema (for the LLM) from the Zod schema via `z.toJSONSchema()`. + * - Infers the `execute` argument type from the schema. + * - Validates tool call arguments at runtime. On validation failure, returns + * a formatted error string to the LLM instead of throwing, so the model + * can self-correct on its next turn. + */ +export function tool(config: ToolConfig): FunctionTool { + const parameters = toJSONSchema(config.schema) as unknown as Record< + string, + unknown + >; + + return { + type: "function", + name: config.name, + description: config.description ?? config.name, + parameters, + execute: async (args: Record) => { + const parsed = config.schema.safeParse(args); + if (!parsed.success) { + return formatZodError(parsed.error, config.name); + } + return config.execute(parsed.data as z.infer); + }, + }; +} + +/** + * Formats a Zod validation error into an LLM-friendly string. + * + * Example: `Invalid arguments for get_weather: city: Invalid input: expected string, received undefined` + */ +export function formatZodError(error: z.ZodError, toolName: string): string { + const parts = error.issues.map((issue) => { + const field = issue.path.length > 0 ? issue.path.join(".") : "(root)"; + return `${field}: ${issue.message}`; + }); + return `Invalid arguments for ${toolName}: ${parts.join("; ")}`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f5bd273..ed083b4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: ws: specifier: 8.18.3 version: 8.18.3(bufferutil@4.0.9) + zod: + specifier: ^4.0.0 + version: 4.3.6 devDependencies: '@ai-sdk/openai': specifier: 4.0.0-beta.27 @@ -351,9 +354,6 @@ importers: ai: specifier: 7.0.0-beta.76 version: 7.0.0-beta.76(zod@4.3.6) - zod: - specifier: ^4.3.6 - version: 4.3.6 packages/appkit-ui: dependencies: @@ -12019,12 +12019,12 @@ packages: snapshots: - '@ai-sdk/gateway@2.0.21(zod@4.1.13)': + '@ai-sdk/gateway@2.0.21(zod@4.3.6)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.19(zod@4.1.13) + '@ai-sdk/provider-utils': 3.0.19(zod@4.3.6) '@vercel/oidc': 3.0.5 - zod: 4.1.13 + zod: 4.3.6 '@ai-sdk/gateway@4.0.0-beta.43(zod@4.3.6)': dependencies: @@ -12039,12 +12039,12 @@ snapshots: '@ai-sdk/provider-utils': 5.0.0-beta.16(zod@4.3.6) zod: 4.3.6 - '@ai-sdk/provider-utils@3.0.19(zod@4.1.13)': + '@ai-sdk/provider-utils@3.0.19(zod@4.3.6)': dependencies: '@ai-sdk/provider': 2.0.0 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 4.1.13 + zod: 4.3.6 '@ai-sdk/provider-utils@5.0.0-beta.16(zod@4.3.6)': dependencies: @@ -12061,15 +12061,15 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@2.0.115(react@19.2.0)(zod@4.1.13)': + '@ai-sdk/react@2.0.115(react@19.2.0)(zod@4.3.6)': dependencies: - '@ai-sdk/provider-utils': 3.0.19(zod@4.1.13) - ai: 5.0.113(zod@4.1.13) + '@ai-sdk/provider-utils': 3.0.19(zod@4.3.6) + ai: 5.0.113(zod@4.3.6) react: 19.2.0 swr: 2.3.8(react@19.2.0) throttleit: 2.1.0 optionalDependencies: - zod: 4.1.13 + zod: 4.3.6 '@algolia/abtesting@1.12.0': dependencies: @@ -13732,14 +13732,14 @@ snapshots: '@docsearch/react@4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3)': dependencies: - '@ai-sdk/react': 2.0.115(react@19.2.0)(zod@4.1.13) + '@ai-sdk/react': 2.0.115(react@19.2.0)(zod@4.3.6) '@algolia/autocomplete-core': 1.19.2(@algolia/client-search@5.46.0)(algoliasearch@5.46.0)(search-insights@2.17.3) '@docsearch/core': 4.3.1(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@docsearch/css': 4.3.2 - ai: 5.0.113(zod@4.1.13) + ai: 5.0.113(zod@4.3.6) algoliasearch: 5.46.0 marked: 16.4.2 - zod: 4.1.13 + zod: 4.3.6 optionalDependencies: '@types/react': 19.2.7 react: 19.2.0 @@ -17908,13 +17908,13 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ai@5.0.113(zod@4.1.13): + ai@5.0.113(zod@4.3.6): dependencies: - '@ai-sdk/gateway': 2.0.21(zod@4.1.13) + '@ai-sdk/gateway': 2.0.21(zod@4.3.6) '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.19(zod@4.1.13) + '@ai-sdk/provider-utils': 3.0.19(zod@4.3.6) '@opentelemetry/api': 1.9.0 - zod: 4.1.13 + zod: 4.3.6 ai@7.0.0-beta.76(zod@4.3.6): dependencies: @@ -21167,7 +21167,7 @@ snapshots: typescript: 5.9.3 unbash: 2.2.0 yaml: 2.8.2 - zod: 4.1.13 + zod: 4.3.6 langium@3.3.1: dependencies: