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
2 changes: 1 addition & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "./node_modules/fallow/schema.json",
"entry": [
"src/cli/{dev,build,studio}.ts",
"src/cli/{dev,build,studio,mcp}.ts",
"src/data/index.ts",
"**/*.test-d.ts",
"**/*.{test,spec}.{ts,tsx}",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dashboard — any provider, any deploy target, no lock-in.
- **Pre/post hooks** — a `HookRegistry` of `AgentHook`s that can inspect or mutate a tool call/LLM generate step, or throw to abort it, run sandboxed by Agent Forge's hook editor
- **Guardrails** — fail-closed, concurrently-run checks (secret scan, diff size, test/lint commands) that gate a fixer agent's patch before it's used
- **MCP client** — `loadMcpTools()` turns any Model Context Protocol server's tools into `ToolDescriptor`s
- **MCP server** — `serveMcp({ agent, name })` (or `loushy mcp agent.yaml`) exposes an agent as an MCP tool for Claude Code, Cursor and other agents
- **Sandboxed tools** — opt a tool into `requiresSandbox` to route it through a Docker-backed `SandboxAdapter` instead of in-process
- **Tracing & evals** — `withSpan()`/`TraceExporter` (with an OpenTelemetry bridge at `/otel`) for observability, and `defineEval()` for agent-behavior regression tests under `vitest`
- **Any provider, any deploy target** — OpenAI, Anthropic, Ollama, OpenRouter, or a deterministic mock provider for tests; `loushy build` ships to a Node server, Docker, or Cloudflare Workers
Expand Down
9 changes: 9 additions & 0 deletions bin/loushy.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const USAGE = [
' loushy dev <config.yaml|config.json> [--port N] [--host H]',
' loushy build --target=<name> --agent=<path> [--out=<dir>]',
' loushy studio [--port N] [--host H] [--prod|--dev]',
' loushy mcp <agent.yaml|json> [--http --port N --host H]',
].join('\n');

/**
Expand Down Expand Up @@ -77,10 +78,18 @@ async function runStudio(rest) {
}
}

// Serves an agent spec over MCP (stdio by default). The server keeps the
// process alive; see src/cli/mcp.ts. Only the exit code is set on failure.
async function runMcp(rest) {
const { runMcp: start } = require(path.join(__dirname, '..', 'dist', 'cli', 'mcp.js'));
process.exitCode = await start(rest);
}

const COMMANDS = new Map([
['dev', runDev],
['build', runBuildCommand],
['studio', runStudio],
['mcp', runMcp],
]);

async function main() {
Expand Down
61 changes: 61 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,67 @@ const agent = createAgent({ prompt: '...', provider, tools });
`loadMcpTools` is also available from the package root and from
`@loushy/build-ai-agent/tools`.

### Serve an agent over MCP

`serveMcp()` is the reverse of `loadMcpTools()`: it exposes an agent (and,
optionally, some of its tools) as an MCP server, so Claude Code, Cursor and
other MCP clients can call it.

```ts
import { createAgent, defineTool } from '@loushy/build-ai-agent';
import { serveMcp } from '@loushy/build-ai-agent/mcp';
import { z } from 'zod';

const searchDocs = defineTool({
name: 'search_docs',
description: 'Search the docs',
input: z.object({ query: z.string() }),
execute: ({ query }) => `results for ${query}`,
});

const supportAgent = createAgent({ prompt: 'You answer support questions.', provider, tools: [searchDocs] });

const server = await serveMcp({
agent: supportAgent, // exposed as ONE tool taking { message: string }
name: 'support-bot', // server name; the tool name defaults to a sanitized version
description: 'Ask the support agent a question',
tools: [searchDocs], // optional: also expose these tools directly
transport: { type: 'http', port: 3920, host: '127.0.0.1', path: '/mcp' }, // default: 'stdio'
});
await server.close();
```

- **Stateless.** Every call to the agent tool is a fresh conversation.
- **Cancellation.** Cancelling the MCP request aborts the agent run
(`agent.send(message, { signal })`).
- **Errors.** An agent failure comes back as an MCP result with `isError: true`.
- **Approvals.** Approval-gated tools cannot be approved over MCP. A run that
pauses for approval returns `isError: true` with a message saying so. Tools
flagged `needsApproval` are not exposed directly unless you pass
`allowApprovalTools: true`; if you do, clients run them with **no human gate**.
- **stdio.** Nothing but the MCP protocol is written to stdout; warnings go to stderr.
- **HTTP.** Binds `127.0.0.1` by default. Add `auth: { type: 'bearer', token }`
to require an `Authorization: Bearer` header; binding a non-loopback host
without `auth` logs a warning.

From the command line, `loushy mcp` serves an agent spec file (stdio by default):

```sh
npx loushy mcp agent.yaml
npx loushy mcp agent.yaml --http --port 3920 --host 127.0.0.1
```

To use it from an MCP client, add it to the client's MCP config (for example
`.mcp.json` for Claude Code):

```json
{
"mcpServers": {
"support-bot": { "command": "npx", "args": ["loushy", "mcp", "agent.yaml"] }
}
}
```

## Provider credentials

Real providers are resolved by `resolveProvider('<provider>/<model>')`
Expand Down
62 changes: 62 additions & 0 deletions src/cli/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { parseMcpArgs, runMcp, startMcpServer } from './mcp';
import { LLMProviderRegistry } from '../providers/llm';
import { createMockProvider } from '../providers/mock';

beforeAll(() => {
LLMProviderRegistry.register('mock', () => createMockProvider({ responses: ['hello'] }));
});

function writeSpec(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'loushy-mcp-test-'));
const file = path.join(dir, 'agent.json');
fs.writeFileSync(
file,
JSON.stringify({ name: 'cli agent', prompt: 'p', provider: { type: 'mock', model: 'm' } })
);
return file;
}

describe('parseMcpArgs', () => {
it('defaults to stdio-less defaults with the spec path', () => {
expect(parseMcpArgs(['agent.yaml'])).toEqual({ configPath: 'agent.yaml', http: false, port: 3920, host: '127.0.0.1' });
});

it('reads --http, --port and --host in both flag styles', () => {
expect(parseMcpArgs(['--http', '--port', '4000', '--host=0.0.0.0', 'a.json'])).toEqual({
configPath: 'a.json',
http: true,
port: 4000,
host: '0.0.0.0',
});
});

it('explains how to fix a missing path or bad port', () => {
expect(() => parseMcpArgs([])).toThrow(/spec file path is required/);
expect(() => parseMcpArgs(['a.json', '--port', 'abc'])).toThrow(/--port must be an integer/);
});
});

describe('startMcpServer', () => {
it('serves a spec over HTTP', async () => {
const server = await startMcpServer({ configPath: writeSpec(), http: true, port: 0, host: '127.0.0.1' });
try {
expect(server.agentToolName).toBe('cli_agent');
expect(server.url).toMatch(/\/mcp$/);
} finally {
await server.close();
}
});
});

describe('runMcp', () => {
it('returns 1 with a message when the spec is missing', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(await runMcp(['/does/not/exist.json', '--http', '--port', '0'])).toBe(1);
expect(error).toHaveBeenCalled();
error.mockRestore();
});
});
70 changes: 70 additions & 0 deletions src/cli/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* `loushy mcp <agent.yaml|json> [--http --port N --host H]` - serve an agent
* spec over MCP (LOU-Z3). stdio by default; stdout carries only the MCP
* protocol there, so every message from this command goes to stderr.
*/
import * as path from 'node:path';
import { loadSpec } from '../spec/loadSpec';
import { specToAgent } from '../spec/specToAgent';
import { serveMcp, type ServeMcpHandle } from '../tools/mcp/server/serveMcp';

const USAGE = 'Usage: loushy mcp <agent.yaml|json> [--http --port N --host H]';

/** Parsed `loushy mcp` arguments. */
export interface McpCliArgs {
configPath: string;
http: boolean;
port: number;
host: string;
}

function readValue(rest: string[], name: string): string | undefined {
const index = rest.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
if (index === -1) return undefined;
const inline = rest[index].split('=')[1];
return inline ?? rest[index + 1];
}

/** Parses `loushy mcp` arguments; throws an Error that says how to fix a bad invocation. */
export function parseMcpArgs(rest: string[]): McpCliArgs {
const valueIndexes = ['--port', '--host'].map((flag) => rest.indexOf(flag)).filter((i) => i >= 0);
const configPath = rest.find(
(arg, index) => !arg.startsWith('--') && !valueIndexes.some((i) => i + 1 === index)
);
if (!configPath) throw new Error(`loushy mcp: an agent spec file path is required. ${USAGE}`);

const port = Number(readValue(rest, 'port') ?? 3920);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`loushy mcp: --port must be an integer between 0 and 65535. ${USAGE}`);
}
return {
configPath,
http: rest.includes('--http'),
port,
host: readValue(rest, 'host') ?? '127.0.0.1',
};
}

/** Loads the spec and starts serving it. Resolves with the running server. */
export async function startMcpServer(args: McpCliArgs): Promise<ServeMcpHandle> {
const spec = loadSpec(path.resolve(args.configPath));
const agent = specToAgent(spec);
return serveMcp({
agent,
name: spec.name,
transport: args.http ? { type: 'http', port: args.port, host: args.host } : 'stdio',
warn: (message) => console.error(message),
});
}

/** CLI entry point. Resolves with the process exit code once the server is up. */
export async function runMcp(rest: string[]): Promise<number> {
try {
const server = await startMcpServer(parseMcpArgs(rest));
if (server.url) console.error(`loushy mcp: serving on ${server.url}`);
return 0;
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
return 1;
}
}
1 change: 1 addition & 0 deletions src/tools/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
export * from './McpToolLoader';
export * from './result';
export * from './schema';
export * from './server/serveMcp';
120 changes: 120 additions & 0 deletions src/tools/mcp/server/buildServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Builds the `McpServer` that fronts an agent (and optionally some of its
* tools). Transport-agnostic: `serveMcp` connects the result to stdio or HTTP.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import type { SimpleAgent } from '../../../createAgent';
import type { ExecutionResult } from '../../../execution/AgentExecutor';
import type { DefinedTool } from '../../defineTool';

/** What {@link buildServer} needs, already validated by `serveMcp`. */
export interface ServerSpec {
agent: SimpleAgent;
name: string;
version: string;
description?: string;
/** Name of the tool that runs the agent. */
agentToolName: string;
tools: readonly DefinedTool[];
allowApprovalTools: boolean;
}

const TOOL_NAME_MAX = 64;

/** Turns a server name into a valid MCP tool name (`[A-Za-z0-9_-]`, at most 64 characters). */
export function sanitizeToolName(name: string): string {
const cleaned = name.trim().replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '');
return cleaned.slice(0, TOOL_NAME_MAX) || 'agent';
}

/** True when a tool pauses for a human before running (a flag or a predicate). */
export function needsApprovalGate(tool: DefinedTool): boolean {
return tool.needsApproval !== undefined && tool.needsApproval !== false;
}

function textResult(text: string, isError = false): CallToolResult {
return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) };
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

/** Maps a finished agent run to an MCP tool result. */
function agentResultToMcp(result: ExecutionResult): CallToolResult {
if (result.finishReason === 'awaiting-approval') {
return textResult(
'The agent paused because one of its tools needs human approval. ' +
'Approval-gated tools cannot be approved over MCP, so this call cannot finish. ' +
'Remove needsApproval from the tool, or run the agent where approvals can be given.',
true
);
}
if (result.finishReason === 'aborted') return textResult('The agent run was cancelled.', true);
if (result.finishReason === 'error') {
return textResult(`The agent failed: ${result.text || 'the model reported an error'}`, true);
}
return textResult(result.text);
}

function registerAgentTool(server: McpServer, spec: ServerSpec): void {
server.registerTool(
spec.agentToolName,
{
description: spec.description ?? `Send a message to the ${spec.name} agent and get its reply.`,
inputSchema: { message: z.string().describe('The message to send to the agent.') },
},
async ({ message }, extra) => {
try {
// Each call is a fresh conversation; the MCP request signal cancels the run.
return agentResultToMcp(await spec.agent.send(message, { signal: extra.signal }));
} catch (error) {
return textResult(`The agent failed: ${errorMessage(error)}`, true);
}
}
);
}

function stringifyOutput(output: unknown): string {
if (typeof output === 'string') return output;
return JSON.stringify(output ?? null) ?? 'null';
}

type ToolExecute = (
args: unknown,
options: { toolCallId: string; messages: never[]; abortSignal: AbortSignal }
) => unknown;

function registerDirectTool(server: McpServer, tool: DefinedTool): void {
server.registerTool(
tool.name,
{ description: tool.description, inputSchema: tool.input },
async (args: unknown, extra) => {
try {
const execute = tool.tool.execute as ToolExecute | undefined;
if (!execute) return textResult(`Tool '${tool.name}' has no execute function.`, true);
const output = await execute(args, {
toolCallId: String(extra.requestId),
messages: [],
abortSignal: extra.signal,
});
return textResult(stringifyOutput(output));
} catch (error) {
return textResult(`Tool '${tool.name}' failed: ${errorMessage(error)}`, true);
}
}
);
}

/** Builds an `McpServer` exposing the agent tool plus the directly exposed tools. */
export function buildServer(spec: ServerSpec): McpServer {
const server = new McpServer({ name: spec.name, version: spec.version });
registerAgentTool(server, spec);
for (const tool of spec.tools) {
if (needsApprovalGate(tool) && !spec.allowApprovalTools) continue;
registerDirectTool(server, tool);
}
return server;
}
Loading
Loading