Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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.
- `createAgent()` agents can pause for approval (LOU-D21): a `needsApproval` tool no longer fails the run with "requires approval but no approvalStore". The run pauses (`finishReason: 'awaiting-approval'`) in a per-agent `InMemoryApprovalStore` (or the new `approvalStore` option), and `agent.approvals.list()` / `agent.approvals.resolve({ id, approved, note? })` continue it, in its session if it paused in one. The `approve` option decides calls in code without pausing.

### Changed
Expand Down
48 changes: 46 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, McpServerSpec>` | 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`.
Expand Down Expand Up @@ -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 <token>
```

```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`):

Expand Down
48 changes: 46 additions & 2 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2239,6 +2239,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<string, McpServerSpec>` | 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`.
Expand Down Expand Up @@ -2266,10 +2267,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 <token>
```

```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`):

Expand Down
6 changes: 2 additions & 4 deletions src/cli/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
5 changes: 0 additions & 5 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down
40 changes: 22 additions & 18 deletions src/cli/doctorCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ function makeEnv(overrides: Partial<DoctorEnvironment> = {}): DoctorEnvironment
loadSpec: () => {
throw new Error('no spec');
},
readRawSpec: () => undefined,
resolveTool: () => ({}),
commandExists: () => true,
fetch: async () => ({ ok: true, status: 200 }),
Expand Down Expand Up @@ -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');
});
});

Expand Down
50 changes: 8 additions & 42 deletions src/cli/doctorSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* `loushy doctor <agent.yaml>`: 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';
Expand All @@ -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<string, unknown> {
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' };
Expand Down Expand Up @@ -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)) {
Expand All @@ -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.`,
};
}

Expand All @@ -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;
Expand All @@ -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),
Expand Down
2 changes: 0 additions & 2 deletions src/cli/doctorTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
44 changes: 44 additions & 0 deletions src/spec/loadSpec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
Loading
Loading