From f33aa14caba6ba132aa084b93a91a0a8b1083fd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:32:57 +0000 Subject: [PATCH] LOU-D20: mcpServers is a first-class, validated AgentSpec field Adds AgentSpec.mcpServers (stdio command/args/env or HTTP url/headers) with zod validation and entry-named errors, makes `loushy doctor` read the validated field instead of the raw file, and exposes the parsed servers on specToAgent()'s result (connecting is TODO(LOU-D20.2)). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016bY29ibQgv3MDdjAbUT4YR --- CHANGELOG.md | 3 ++ docs/configuration.md | 48 +++++++++++++++++- llms-full.txt | 48 +++++++++++++++++- src/cli/doctor.test.ts | 6 +-- src/cli/doctor.ts | 5 -- src/cli/doctorCore.test.ts | 40 ++++++++------- src/cli/doctorSpec.ts | 50 +++---------------- src/cli/doctorTypes.ts | 2 - src/spec/loadSpec.test.ts | 44 ++++++++++++++++ src/spec/schema.test.ts | 67 +++++++++++++++++++++++++ src/spec/schema.ts | 97 ++++++++++++++++++++++++++++++++++++ src/spec/specToAgent.test.ts | 10 ++++ src/spec/specToAgent.ts | 19 +++++-- 13 files changed, 361 insertions(+), 78 deletions(-) create mode 100644 src/spec/schema.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 58cc7dd5a..6e384a9c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - 2026-09-28 +### Added +- `AgentSpec.mcpServers` (LOU-D20): a validated map of MCP servers (stdio `command`/`args`/`env` or HTTP `url`/`headers`) in agent spec files. `loadSpec()` reports a bad entry with its name, `loushy doctor` reads the validated field instead of the raw file, and `specToAgent()` exposes the parsed servers as `agent.mcpServers` (connecting them is TODO(LOU-D20.2)). Existing specs are unaffected. + ### Changed - BREAKING: `encrypt()` now generates a random salt per call instead of a hardcoded one. Ciphertext produced before this change cannot be decrypted with the new code and must be re-encrypted. - BREAKING: The mock repositories (`MockAgentRepository`, `MockSessionRepository`, `MockResultRepository`, `MockMemoryRepository`, `MockAttachmentRepository` — previously re-exported from `src/data/mocks.ts` via the package root/`./data` subpath; this package has no `createMockRepositories` factory) are no longer exported from the package root. Import them from `@loushy/build-ai-agent/testing` instead. diff --git a/docs/configuration.md b/docs/configuration.md index 51e212d7e..7cd10fedf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,6 +13,7 @@ validated with zod by `loadSpec()` (`src/spec/schema.ts`). It is the format | `provider.type` | `string` | yes | `openai`, `anthropic`, `ollama`, `openrouter` or `mock`. | | `provider.model` | `string` | yes | Model id every call uses (the provider's configured model; an agent's own `settings.model` would override it). | | `tools` | `string[]` | no | Built-in tool names (see below). | +| `mcpServers` | `Record` | no | MCP servers the agent uses, keyed by name (see below). | A missing or invalid field fails with an error naming the exact field, e.g. `'prompt': Required`. @@ -40,10 +41,53 @@ tools: them throws an error telling you to build the agent with `createAgent()` and pass a configured tool instead. +### MCP servers (`mcpServers`) + +`mcpServers` declares the MCP servers an agent uses, as a map from a server +name (it namespaces that server's tools) to either a stdio server (`command`, +optional `args` and `env`) or an HTTP server (`url`, optional `headers`). +Each entry sets exactly one of `command` / `url`; `args`/`env` apply only to +stdio and `headers` only to HTTP. The field is validated by `loadSpec()`, and +an invalid entry fails with the entry name in the message, e.g. +`'mcpServers.files': AgentSpec validation failed: missing 'command' (stdio server) or 'url' (HTTP server)`. +`loushy doctor` checks each stdio `command` is resolvable. + +```yaml +mcpServers: + filesystem: + command: npx + args: [-y, '@modelcontextprotocol/server-filesystem', ./data] + env: + LOG_LEVEL: warn + docs: + url: https://example.com/mcp + headers: + Authorization: Bearer +``` + +```ts +import { agentSpecSchema, specToAgent, type McpServerSpec } from '@loushy/build-ai-agent'; + +const filesystem: McpServerSpec = { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'] }; +const spec = agentSpecSchema.parse({ + name: 'support-bot', + prompt: 'You are a friendly support agent.', + provider: { type: 'mock', model: 'mock-1' }, + mcpServers: { filesystem, docs: { url: 'https://example.com/mcp' } }, +}); + +// specToAgent() does not connect the servers yet (TODO(LOU-D20.2)); it +// exposes the validated entries so a host can connect them. +const agent = specToAgent(spec); +console.log(Object.keys(agent.mcpServers)); // ['filesystem', 'docs'] +``` + +Connecting the servers and registering their tools automatically is tracked +as LOU-D20.2; until then use `loadMcpTools()` as described next. + ### MCP (Model Context Protocol) tools -Tools advertised by a remote MCP server aren't referenced by name in a spec -file - connect a `Client` from `@modelcontextprotocol/sdk` yourself and load +Tools advertised by a remote MCP server can also be loaded by hand - connect a `Client` from `@modelcontextprotocol/sdk` yourself and load its tools with `loadMcpTools()`, then pass the result to `createAgent()` (or register it on a `ToolRegistry`): diff --git a/llms-full.txt b/llms-full.txt index 88d5d9d41..3e0029d5b 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2168,6 +2168,7 @@ validated with zod by `loadSpec()` (`src/spec/schema.ts`). It is the format | `provider.type` | `string` | yes | `openai`, `anthropic`, `ollama`, `openrouter` or `mock`. | | `provider.model` | `string` | yes | Model id every call uses (the provider's configured model; an agent's own `settings.model` would override it). | | `tools` | `string[]` | no | Built-in tool names (see below). | +| `mcpServers` | `Record` | no | MCP servers the agent uses, keyed by name (see below). | A missing or invalid field fails with an error naming the exact field, e.g. `'prompt': Required`. @@ -2195,10 +2196,53 @@ tools: them throws an error telling you to build the agent with `createAgent()` and pass a configured tool instead. +### MCP servers (`mcpServers`) + +`mcpServers` declares the MCP servers an agent uses, as a map from a server +name (it namespaces that server's tools) to either a stdio server (`command`, +optional `args` and `env`) or an HTTP server (`url`, optional `headers`). +Each entry sets exactly one of `command` / `url`; `args`/`env` apply only to +stdio and `headers` only to HTTP. The field is validated by `loadSpec()`, and +an invalid entry fails with the entry name in the message, e.g. +`'mcpServers.files': AgentSpec validation failed: missing 'command' (stdio server) or 'url' (HTTP server)`. +`loushy doctor` checks each stdio `command` is resolvable. + +```yaml +mcpServers: + filesystem: + command: npx + args: [-y, '@modelcontextprotocol/server-filesystem', ./data] + env: + LOG_LEVEL: warn + docs: + url: https://example.com/mcp + headers: + Authorization: Bearer +``` + +```ts +import { agentSpecSchema, specToAgent, type McpServerSpec } from '@loushy/build-ai-agent'; + +const filesystem: McpServerSpec = { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'] }; +const spec = agentSpecSchema.parse({ + name: 'support-bot', + prompt: 'You are a friendly support agent.', + provider: { type: 'mock', model: 'mock-1' }, + mcpServers: { filesystem, docs: { url: 'https://example.com/mcp' } }, +}); + +// specToAgent() does not connect the servers yet (TODO(LOU-D20.2)); it +// exposes the validated entries so a host can connect them. +const agent = specToAgent(spec); +console.log(Object.keys(agent.mcpServers)); // ['filesystem', 'docs'] +``` + +Connecting the servers and registering their tools automatically is tracked +as LOU-D20.2; until then use `loadMcpTools()` as described next. + ### MCP (Model Context Protocol) tools -Tools advertised by a remote MCP server aren't referenced by name in a spec -file - connect a `Client` from `@modelcontextprotocol/sdk` yourself and load +Tools advertised by a remote MCP server can also be loaded by hand - connect a `Client` from `@modelcontextprotocol/sdk` yourself and load its tools with `loadMcpTools()`, then pass the result to `createAgent()` (or register it on a `ToolRegistry`): diff --git a/src/cli/doctor.test.ts b/src/cli/doctor.test.ts index a75bce2ef..87b60ff21 100644 --- a/src/cli/doctor.test.ts +++ b/src/cli/doctor.test.ts @@ -50,18 +50,16 @@ describe('real environment', () => { expect(env.commandExists('definitely-not-a-command-xyz')).toBe(false); }); - it('loads a spec file and its raw mcpServers', () => { + it('loads a spec file with its validated mcpServers', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'loushy-doctor-')); fs.writeFileSync( path.join(dir, 'a.yaml'), 'name: bot\nprompt: hi\nprovider:\n type: mock\n model: m\nmcpServers:\n fs:\n command: npx\n' ); - fs.writeFileSync(path.join(dir, 'a.json'), '{"mcpServers":{}}'); const env = buildEnvironment({ json: false, specPath: 'a.yaml' }, dir); expect(env.specPath).toBe(path.join(dir, 'a.yaml')); expect(env.loadSpec(env.specPath as string).name).toBe('bot'); - expect(env.readRawSpec(env.specPath as string)).toMatchObject({ mcpServers: { fs: { command: 'npx' } } }); - expect(env.readRawSpec(path.join(dir, 'a.json'))).toEqual({ mcpServers: {} }); + expect(env.loadSpec(env.specPath as string).mcpServers).toEqual({ fs: { command: 'npx' } }); expect(env.resolveTool('http')).toBeTruthy(); expect(() => env.resolveTool('nope')).toThrow(); }); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index a632d2cca..344440052 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -8,7 +8,6 @@ import * as fs from 'node:fs'; import { createRequire } from 'node:module'; import * as path from 'node:path'; -import { parse as parseYaml } from 'yaml'; import { loadSpec } from '../spec/loadSpec'; import { resolveSpecTool } from '../spec/specToAgent'; import { runDoctor } from './doctorCore'; @@ -87,10 +86,6 @@ export function buildEnvironment(args: DoctorArgs, cwd: string = process.cwd()): resolvePackageVersion: (name) => resolveVersion(cwd, name), specPath: args.specPath ? resolveSpecFile(args.specPath) : undefined, loadSpec, - readRawSpec: (file) => { - const raw = fs.readFileSync(file, 'utf8'); - return path.extname(file).toLowerCase() === '.json' ? JSON.parse(raw) : parseYaml(raw); - }, resolveTool: resolveSpecTool, commandExists: (command) => commandExists(command, process.env), fetch: (url, init) => fetch(url, init), diff --git a/src/cli/doctorCore.test.ts b/src/cli/doctorCore.test.ts index af1a491da..e1341b88e 100644 --- a/src/cli/doctorCore.test.ts +++ b/src/cli/doctorCore.test.ts @@ -32,7 +32,6 @@ function makeEnv(overrides: Partial = {}): DoctorEnvironment loadSpec: () => { throw new Error('no spec'); }, - readRawSpec: () => undefined, resolveTool: () => ({}), commandExists: () => true, fetch: async () => ({ ok: true, status: 200 }), @@ -202,28 +201,33 @@ describe('spec checks', () => { expect(bad.status).toBe('fail'); expect(bad.fix).toContain("Remove 'nope'"); }); - it('checks MCP server commands from a list or a map', async () => { + it('checks MCP server commands from the validated spec field (LOU-D20)', async () => { const commandExists = (command: string) => command === 'npx'; - const list = withSpec(spec(), { - commandExists, - readRawSpec: () => ({ mcpServers: [{ name: 'fs', command: 'npx' }, { name: 'gone', command: 'nonexistent' }, { url: 'https://x' }] }), - }); - expect((await check(list, 'spec.mcp.fs')).status).toBe('ok'); - expect((await check(list, 'spec.mcp.gone')).status).toBe('fail'); - expect((await check(list, 'spec.mcp.#3')).status).toBe('ok'); - const map = withSpec(spec(), { - commandExists, - readRawSpec: () => ({ mcpServers: { git: { command: 'uvx' } } }), - }); - expect((await check(map, 'spec.mcp.git')).status).toBe('fail'); + const env = withSpec( + spec({ + mcpServers: { + fs: { command: 'npx' }, + gone: { command: 'nonexistent' }, + docs: { url: 'https://example.com/mcp' }, + }, + }), + { commandExists } + ); + expect((await check(env, 'spec.mcp.fs')).status).toBe('ok'); + const gone = await check(env, 'spec.mcp.gone'); + expect(gone.status).toBe('fail'); + expect(gone.fix).toContain('mcpServers.gone.command'); + expect((await check(env, 'spec.mcp.docs')).status).toBe('ok'); }); - it('survives an unreadable raw spec', async () => { + it('reports an invalid mcpServers entry as a spec failure', async () => { const env = withSpec(spec(), { - readRawSpec: () => { - throw new Error('boom'); + loadSpec: () => { + throw new Error("'mcpServers.fs': AgentSpec validation failed: missing 'command'"); }, }); - expect((await check(env, 'spec')).status).toBe('ok'); + const result = await check(env, 'spec'); + expect(result.status).toBe('fail'); + expect(result.finding).toContain('mcpServers.fs'); }); }); diff --git a/src/cli/doctorSpec.ts b/src/cli/doctorSpec.ts index 859c6c116..c64a687c2 100644 --- a/src/cli/doctorSpec.ts +++ b/src/cli/doctorSpec.ts @@ -2,7 +2,7 @@ * `loushy doctor `: validates the spec with the SDK's own loader * and checks what the spec references (provider, built-in tools, MCP servers). */ -import type { AgentSpec } from '../spec/schema'; +import type { AgentSpec, McpServerSpec } from '../spec/schema'; import { listProviders } from '../providers/providerSpec'; import { NO_SPEC_NEEDS, type SpecNeeds } from './doctorChecks'; import type { DoctorCheck, DoctorEnvironment } from './doctorTypes'; @@ -12,38 +12,10 @@ export interface SpecInspection { needs: SpecNeeds; } -interface McpReference { - name: string; - command?: string; -} - function message(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function toReference(name: string, value: unknown): McpReference { - const command = isRecord(value) && typeof value.command === 'string' ? value.command : undefined; - return { name, command }; -} - -/** Reads `mcpServers` from a raw spec, as either a list or a name -> config map. */ -function mcpReferences(raw: unknown): McpReference[] { - const servers = isRecord(raw) ? raw.mcpServers : undefined; - if (Array.isArray(servers)) { - return servers.map((server, index) => - toReference(isRecord(server) && typeof server.name === 'string' ? server.name : `#${index + 1}`, server) - ); - } - if (isRecord(servers)) { - return Object.entries(servers).map(([name, server]) => toReference(name, server)); - } - return []; -} - function checkProvider(spec: AgentSpec): DoctorCheck { const type = spec.provider.type.toLowerCase(); const base = { id: 'spec.provider', title: 'Spec provider' }; @@ -79,9 +51,9 @@ function checkTool(env: DoctorEnvironment, name: string): { check: DoctorCheck; } } -function checkMcpServer(env: DoctorEnvironment, server: McpReference): DoctorCheck { - const base = { id: `spec.mcp.${server.name}`, title: `MCP server '${server.name}'` }; - if (!server.command) { +function checkMcpServer(env: DoctorEnvironment, name: string, server: McpServerSpec): DoctorCheck { + const base = { id: `spec.mcp.${name}`, title: `MCP server '${name}'` }; + if (!('command' in server)) { return { ...base, status: 'ok', finding: 'no command to resolve (remote or unspecified)' }; } if (env.commandExists(server.command)) { @@ -91,7 +63,7 @@ function checkMcpServer(env: DoctorEnvironment, server: McpReference): DoctorChe ...base, status: 'fail', finding: `command '${server.command}' not found on PATH`, - fix: `Install '${server.command}' or fix mcpServers.${server.name}.command in the spec.`, + fix: `Install '${server.command}' or fix mcpServers.${name}.command in the spec.`, }; } @@ -106,14 +78,6 @@ function loadFailure(path: string, error: unknown): SpecInspection { return { checks: [check], needs: NO_SPEC_NEEDS }; } -function safeRaw(env: DoctorEnvironment, path: string): unknown { - try { - return env.readRawSpec(path); - } catch { - return undefined; - } -} - /** Loads the spec (if a path was given) and checks everything it references. */ export function inspectSpec(env: DoctorEnvironment): SpecInspection { const path = env.specPath; @@ -127,7 +91,9 @@ export function inspectSpec(env: DoctorEnvironment): SpecInspection { } const tools = (spec.tools ?? []).map((name) => checkTool(env, name)); - const mcp = mcpReferences(safeRaw(env, path)).map((server) => checkMcpServer(env, server)); + const mcp = Object.entries(spec.mcpServers ?? {}).map(([name, server]) => + checkMcpServer(env, name, server) + ); const checks: DoctorCheck[] = [ { id: 'spec', status: 'ok', title: 'Agent spec', finding: `'${spec.name}' is valid` }, checkProvider(spec), diff --git a/src/cli/doctorTypes.ts b/src/cli/doctorTypes.ts index 8cfc66e73..d0829c465 100644 --- a/src/cli/doctorTypes.ts +++ b/src/cli/doctorTypes.ts @@ -42,8 +42,6 @@ export interface DoctorEnvironment { specPath?: string; /** Loads and validates a spec (the SDK's own loader); throws with field paths on invalid input. */ loadSpec(path: string): AgentSpec; - /** The unvalidated spec document, for fields the schema does not model (e.g. `mcpServers`). */ - readRawSpec(path: string): unknown; /** Resolves a built-in tool name; throws if unknown. */ resolveTool(name: string): { requiresSandbox?: boolean }; /** True when `command` is an existing file or is found on PATH. */ diff --git a/src/spec/loadSpec.test.ts b/src/spec/loadSpec.test.ts index df8a48b26..1097ccd4d 100644 --- a/src/spec/loadSpec.test.ts +++ b/src/spec/loadSpec.test.ts @@ -63,6 +63,50 @@ tools: expect(() => loadSpec(filePath)).toThrow(/'prompt'/); }); + it('loads mcpServers from YAML (LOU-D20)', () => { + const filePath = tmpFile( + 'mcp.yaml', + ` +name: mcp-agent +prompt: hi +provider: + type: mock + model: m +mcpServers: + fs: + command: npx + args: [-y, "@modelcontextprotocol/server-filesystem"] + env: + ROOT: /tmp + docs: + url: https://example.com/mcp +` + ); + expect(loadSpec(filePath).mcpServers).toEqual({ + fs: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem'], + env: { ROOT: '/tmp' }, + }, + docs: { url: 'https://example.com/mcp' }, + }); + }); + + it('names the mcpServers entry in the error for a bad entry (LOU-D20)', () => { + const filePath = tmpFile( + 'bad-mcp.json', + JSON.stringify({ + name: 'a', + prompt: 'b', + provider: { type: 'mock', model: 'm' }, + mcpServers: { files: { args: ['x'] }, ok: { command: 'npx' } }, + }) + ); + expect(() => loadSpec(filePath)).toThrow( + /'mcpServers\.files': AgentSpec validation failed: missing 'command'/ + ); + }); + it('throws for an unsupported extension', () => { const filePath = tmpFile('agent.txt', 'not a spec'); expect(() => loadSpec(filePath)).toThrow(/unsupported extension/); diff --git a/src/spec/schema.test.ts b/src/spec/schema.test.ts new file mode 100644 index 000000000..4b62533fe --- /dev/null +++ b/src/spec/schema.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { agentSpecSchema, type AgentSpec } from './schema'; + +const base = { name: 'bot', prompt: 'hi', provider: { type: 'mock', model: 'm' } }; + +function issues(mcpServers: unknown): string[] { + const result = agentSpecSchema.safeParse({ ...base, mcpServers }); + if (result.success) throw new Error('expected validation to fail'); + return result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`); +} + +describe('AgentSpec.mcpServers (LOU-D20)', () => { + it('accepts stdio and HTTP entries', () => { + const mcpServers: AgentSpec['mcpServers'] = { + fs: { command: 'npx', args: ['-y', 'server-fs'], env: { ROOT: '/tmp' } }, + git: { command: 'uvx' }, + docs: { url: 'https://example.com/mcp', headers: { Authorization: 'Bearer x' } }, + }; + expect(agentSpecSchema.parse({ ...base, mcpServers }).mcpServers).toEqual(mcpServers); + }); + + it('still validates specs without the field', () => { + expect(agentSpecSchema.parse(base)).toEqual(base); + }); + + it('rejects an entry with neither command nor url, naming the entry', () => { + expect(issues({ broken: { args: ['x'] } })).toEqual([ + "mcpServers.broken: AgentSpec validation failed: missing 'command' (stdio server) or 'url' (HTTP server)", + ]); + }); + + it('rejects an entry with both command and url', () => { + expect(issues({ both: { command: 'npx', url: 'https://example.com' } })[0]).toMatch( + /^mcpServers\.both: AgentSpec validation failed: set either 'command'/ + ); + }); + + it('rejects stdio-only fields on an HTTP server and headers on a stdio server', () => { + expect(issues({ a: { url: 'https://example.com', env: { K: 'v' } } })[0]).toMatch( + /'env' does not apply to an HTTP/ + ); + expect(issues({ b: { command: 'npx', headers: { K: 'v' } } })[0]).toMatch( + /'headers' does not apply to a stdio/ + ); + }); + + it('rejects a bad env type and a bad url', () => { + expect(issues({ fs: { command: 'npx', env: ['A=1'] } })).toEqual([ + "mcpServers.fs.env: AgentSpec validation failed: 'env' must be a map of string to string", + ]); + expect(issues({ fs: { command: 'npx', env: { A: 1 } } })[0]).toMatch( + /^mcpServers\.fs\.env\.A: / + ); + expect(issues({ web: { url: 'not a url' } })[0]).toMatch(/'url' must be a valid URL/); + }); + + it('accepts the legacy list form that loushy doctor used to read', () => { + const parsed = agentSpecSchema.parse({ + ...base, + mcpServers: [{ name: 'fs', command: 'npx' }, { url: 'https://example.com' }], + }); + expect(parsed.mcpServers).toEqual({ + fs: { command: 'npx' }, + '#2': { url: 'https://example.com' }, + }); + }); +}); diff --git a/src/spec/schema.ts b/src/spec/schema.ts index 342e69265..ba4008d90 100644 --- a/src/spec/schema.ts +++ b/src/spec/schema.ts @@ -41,6 +41,26 @@ export interface AgentSpecTrigger { [key: string]: unknown; } +/** An MCP server launched as a child process and spoken to over stdio. */ +export interface McpStdioServerSpec { + command: string; + args?: string[]; + env?: Record; +} + +/** An MCP server reached over HTTP. */ +export interface McpHttpServerSpec { + url: string; + headers?: Record; +} + +/** + * One entry of `AgentSpec.mcpServers` (LOU-D20): stdio (`command`, optional + * `args`/`env`) or HTTP (`url`, optional `headers`). Exactly one of + * `command` / `url` is set. + */ +export type McpServerSpec = McpStdioServerSpec | McpHttpServerSpec; + export interface AgentSpec { name: string; prompt: string; @@ -54,6 +74,11 @@ export interface AgentSpec { policy?: AgentSpecPolicy; /** Optional (LOU-J1+), same backward-compatibility note as `policy`. */ triggers?: AgentSpecTrigger[]; + /** + * Optional (LOU-D20), same backward-compatibility note as `policy`: MCP + * servers this agent uses, keyed by a name that namespaces their tools. + */ + mcpServers?: Record; } export const agentSpecProviderSchema = z.object({ @@ -80,6 +105,77 @@ export const agentSpecTriggerSchema = z }) .passthrough(); +const MCP_PREFIX = 'AgentSpec validation failed:'; + +const mcpStringMap = (field: string) => + z.record(z.string(), { + invalid_type_error: `${MCP_PREFIX} '${field}' must be a map of string to string`, + }); + +/** What is wrong with a loosely-parsed `mcpServers` entry, if anything. */ +function mcpServerProblem(server: Record): string | undefined { + const stdio = server.command !== undefined; + if (stdio === (server.url !== undefined)) { + return stdio + ? "set either 'command' (stdio) or 'url' (HTTP), not both" + : "missing 'command' (stdio server) or 'url' (HTTP server)"; + } + const stray = (stdio ? ['headers'] : ['args', 'env']).find((key) => server[key] !== undefined); + return stray && `'${stray}' does not apply to ${stdio ? "a stdio ('command')" : "an HTTP ('url')"} server`; +} + +/** + * One `mcpServers` entry. A plain z.discriminatedUnion cannot word the + * mix-ups well (neither/both of command and url, stdio-only fields on an HTTP + * server and vice versa), so this validates a loose object and then narrows it + * to McpServerSpec. Issue paths include the entry name (`mcpServers.fs.env`). + */ +export const mcpServerSpecSchema = z + .object( + { + command: z + .string({ invalid_type_error: `${MCP_PREFIX} 'command' must be a string` }) + .min(1) + .optional(), + args: z + .array(z.string(), { invalid_type_error: `${MCP_PREFIX} 'args' must be a list of strings` }) + .optional(), + env: mcpStringMap('env').optional(), + url: z + .string({ invalid_type_error: `${MCP_PREFIX} 'url' must be a string` }) + .url(`${MCP_PREFIX} 'url' must be a valid URL`) + .optional(), + headers: mcpStringMap('headers').optional(), + }, + { + invalid_type_error: `${MCP_PREFIX} each mcpServers entry must be an object with 'command' or 'url'`, + } + ) + .superRefine((server, ctx) => { + const problem = mcpServerProblem(server); + if (problem) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${MCP_PREFIX} ${problem}` }); + } + }) + // superRefine has verified exactly one of command/url and no cross-shape fields. + .transform((server): McpServerSpec => server as McpServerSpec); + +/** Also accepts the pre-LOU-D20 list form `[{ name, command | url }]` that `loushy doctor` read. */ +const mcpServersSchema = z.preprocess( + (value) => + Array.isArray(value) + ? Object.fromEntries( + value.map((entry: unknown, index) => { + const { name, ...rest } = (entry ?? {}) as Record; + return [typeof name === 'string' ? name : `#${index + 1}`, rest]; + }) + ) + : value, + z.record(mcpServerSpecSchema, { + invalid_type_error: `${MCP_PREFIX} 'mcpServers' must be a map of server name to config`, + }) +); + export const agentSpecSchema = z.object({ name: z.string({ required_error: "AgentSpec validation failed: missing required field 'name'", @@ -91,4 +187,5 @@ export const agentSpecSchema = z.object({ tools: z.array(z.string()).optional(), policy: agentSpecPolicySchema.optional(), triggers: z.array(agentSpecTriggerSchema).optional(), + mcpServers: mcpServersSchema.optional(), }); diff --git a/src/spec/specToAgent.test.ts b/src/spec/specToAgent.test.ts index edebd52b5..70e0323cb 100644 --- a/src/spec/specToAgent.test.ts +++ b/src/spec/specToAgent.test.ts @@ -98,3 +98,13 @@ describe('specToAgent model selection (LOU-U1)', () => { expect(received).toEqual(['spec-model-7']); }); }); + +describe('specToAgent mcpServers (LOU-D20)', () => { + it('exposes the parsed servers on the result (connecting is TODO(LOU-D20.2))', () => { + const mcpServers = { fs: { command: 'npx' }, docs: { url: 'https://example.com/mcp' } }; + const provider = { type: 'mock', model: 'm' }; + const agent = specToAgent({ name: 'mcp-agent', prompt: 'x', provider, mcpServers }); + expect(agent.mcpServers).toEqual(mcpServers); + expect(specToAgent({ name: 'n', prompt: 'x', provider }).mcpServers).toEqual({}); + }); +}); diff --git a/src/spec/specToAgent.ts b/src/spec/specToAgent.ts index 05bfb349b..2a8494b78 100644 --- a/src/spec/specToAgent.ts +++ b/src/spec/specToAgent.ts @@ -1,4 +1,4 @@ -import { AgentSpec } from './schema'; +import { AgentSpec, McpServerSpec } from './schema'; import { createAgent, SimpleAgent, CreateAgentConfig } from '../createAgent'; import { resolveProvider } from '../providers/resolveProvider'; import { LLMProvider, LLMProviderRegistry } from '../providers/llm'; @@ -86,11 +86,23 @@ export function resolveSpecTool(name: string): ToolDescriptor { ); } +/** The agent specToAgent() builds, plus the spec's validated MCP servers for a host to connect. */ +export type SpecAgent = SimpleAgent & { + readonly mcpServers: Readonly>; +}; + /** * Maps a validated AgentSpec to a live agent via createAgent() (LOU-H1), * resolving each spec tool name against the SDK's real built-in tools. + * + * TODO(LOU-D20.2): `spec.mcpServers` are validated but NOT connected here. + * specToAgent() is synchronous and the SDK has no helper that opens an MCP + * client transport (stdio spawn / HTTP) yet - only loadMcpTools(client, ...) + * for an already-connected client. Until that lands, the parsed servers are + * exposed as `agent.mcpServers` so a host can connect them and register + * the resulting tools itself. */ -export function specToAgent(spec: AgentSpec): SimpleAgent { +export function specToAgent(spec: AgentSpec): SpecAgent { const provider = resolveSpecProvider(spec.provider.type, spec.provider.model); const tools: CreateAgentConfig['tools'] = {}; @@ -98,10 +110,11 @@ export function specToAgent(spec: AgentSpec): SimpleAgent { tools[name] = resolveSpecTool(name); } - return createAgent({ + const agent = createAgent({ name: spec.name, prompt: spec.prompt, provider, tools: Object.keys(tools).length > 0 ? tools : undefined, }); + return Object.assign(agent, { mcpServers: spec.mcpServers ?? {} }); }