diff --git a/README.md b/README.md
index f2e6dd26..fa105828 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,3 @@
-
@@ -25,18 +24,20 @@
[](https://nodejs.org) [](https://github.com/agentlang-ai/agentlang/actions/workflows/ci.yml) [](https://www.npmjs.com/package/agentlang)
+
## Agentlang - Team as Code
+
Agentlang is a declarative DSL (built on TypeScript) for creating AI Agents and full-stack Agentic Apps. With Agentlang, you define, version, run, mentor, and monitor teams of AI agents, along with the app infrastructure they need: data model, workflows, RBAC, integrations, and UI. We refer to this approach - bringing together AI agent development and App development into a single coherent discipline - as Team-as-Code (our riff on IaC).
-* **For Devs and Non-Devs:** Code and vibe-code in your IDE, focusing on the business-logic of your app, not wiring. Alternatively, you can build, run, mentor and monitor your AI Team in Studio - our visual-builder (coming soon). Switch back-and-forth between the two modes seamlessly.
+- **For Devs and Non-Devs:** Code and vibe-code in your IDE, focusing on the business-logic of your app, not wiring. Alternatively, you can build, run, mentor and monitor your AI Team in Studio - our visual-builder (coming soon). Switch back-and-forth between the two modes seamlessly.
-* **Robust Integrations:** The Agentlang runtime ships with native integrations for LLMs, databases, vector DBs, and auth providers. Our connector architecture is built for the enterprise, with a rapidly growing catalog for systems like Salesforce, ServiceNow, HubSpot, Snowflake, and more. Also, because Agentlang compiles to Node.js (and runs in the browser), you can use any existing JavaScript library out of the box.
+- **Robust Integrations:** The Agentlang runtime ships with native integrations for LLMs, databases, vector DBs, and auth providers. Our connector architecture is built for the enterprise, with a rapidly growing catalog for systems like Salesforce, ServiceNow, HubSpot, Snowflake, and more. Also, because Agentlang compiles to Node.js (and runs in the browser), you can use any existing JavaScript library out of the box.
-* **Production-grade**: Under the hood, it’s all modern TypeScript—strong typing, tooling, testing, and CI/CD-friendly workflows—built for enterprise-class reliability, governance, and scale.
+- **Production-grade**: Under the hood, it’s all modern TypeScript—strong typing, tooling, testing, and CI/CD-friendly workflows—built for enterprise-class reliability, governance, and scale.
Agentlang introduces two foundational innovations: [Agentic Reliability Modeling](#-agentic-reliability-modeling) and [AgentLang Ontology](#agentlang-ontology)
@@ -133,7 +134,6 @@ workflow ticketInProgress {
}
```
-
### ✨ First-class AI Agents
Agents and many concepts agents use are built-in language constructs.
@@ -334,8 +334,8 @@ What makes this model special is how seamlessly an agent can interact with it
To get started with Agentlang Ontology, please see the [Agentlang Tutorial](https://docs.fractl.io/app) or explore the following example applications:
- * [Car Dealership](https://github.com/agentlang-ai/agentlang/tree/main/example/car_dealership)
- * [Customer Support System](https://github.com/agentlang-ai/agentlang/tree/main/example/customer_support_system)
+- [Car Dealership](https://github.com/agentlang-ai/agentlang/tree/main/example/car_dealership)
+- [Customer Support System](https://github.com/agentlang-ai/agentlang/tree/main/example/customer_support_system)
## 🚀 Getting Started
@@ -360,6 +360,10 @@ agent parseAndValidate blog.al
agent run
```
+### Exposing your app as an MCP server
+
+Set `mcpServer.enabled: true` in your app config to expose every `@public` event and every entity as Model Context Protocol tools and resources at `/mcp`. MCP clients like Claude Desktop or Cursor can then call your agents and read your data over HTTP. See [docs/mcp-server.md](./docs/mcp-server.md).
+
## 👨💻 Development
For contributors who want to build and develop Agentlang itself:
@@ -376,6 +380,7 @@ OR
# Use pnpm
pnpm install
```
+
**Note**: If pnpm shows build script warnings, run `pnpm approve-builds` and approve esbuild and sqlite3.
### ⚡ Build
diff --git a/docs/mcp-server.md b/docs/mcp-server.md
new file mode 100644
index 00000000..7476f66b
--- /dev/null
+++ b/docs/mcp-server.md
@@ -0,0 +1,271 @@
+# MCP Server: Expose Your Agentlang App as Model Context Protocol
+
+This guide shows how to turn an agentlang application into an [MCP](https://modelcontextprotocol.io) server so that MCP clients (Claude Desktop, Cursor, custom agents, etc.) can call your agents, run your workflows, and read your data.
+
+## Overview
+
+When `mcpServer.enabled` is set in your app config, the runtime mounts a [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) MCP transport on the same Express app that already serves the agentlang HTTP API. No extra process; same port; same auth.
+
+What gets exposed:
+
+- **Every `@public` event becomes a tool** named `module__event`. This includes agents, since agents in agentlang are invoked as events.
+- **Every entity becomes four CRUD tools**: `module__Entity__create`, `module__Entity__list`, `module__Entity__get`, `module__Entity__delete`.
+- **Every entity is also exposed as a resource** at `agentlang://module/Entity`. `resources/read` returns all rows as JSON.
+- **A built-in `agentlang_search_tools` tool** lets clients discover tools by free-text query, ranked over name, module, and description.
+
+The tool list is rebuilt per `tools/list` request, so dynamically interned modules show up without a restart.
+
+## Quick start
+
+### 1. Enable in your config
+
+```jsonc
+{
+ "service": { "port": 8080 },
+ "mcpServer": {
+ "enabled": true,
+ "path": "/mcp",
+ },
+}
+```
+
+### 2. Run your app
+
+```bash
+agent run my-app.al
+# Application my-app version 0.0.1 started on port 8080
+# MCP server 'my-app' v0.0.1 ready (path=/mcp, stateless=true)
+```
+
+### 3. Point an MCP client at it
+
+Most clients accept a `url` in their `mcpServers` block. For Claude Desktop:
+
+```jsonc
+{
+ "mcpServers": {
+ "my-agentlang-app": {
+ "url": "http://localhost:8080/mcp",
+ },
+ },
+}
+```
+
+If your app has `auth.enabled: true`, add a Bearer token:
+
+```jsonc
+{
+ "mcpServers": {
+ "my-agentlang-app": {
+ "url": "http://localhost:8080/mcp",
+ "headers": { "Authorization": "Bearer " },
+ },
+ },
+}
+```
+
+## Worked example
+
+Given this module:
+
+```typescript
+module shop
+
+entity Item {
+ id Int @id,
+ name String,
+ price Float,
+ inStock Boolean
+}
+
+@public event MakeItem {
+ id Int,
+ name String,
+ price Float
+}
+
+workflow MakeItem {
+ {Item {id MakeItem.id, name MakeItem.name, price MakeItem.price, inStock true}}
+}
+```
+
+`tools/list` returns:
+
+```json
+{
+ "tools": [
+ {
+ "name": "agentlang_search_tools",
+ "description": "Search the available agentlang MCP tools..."
+ },
+ { "name": "shop__MakeItem", "description": "Invoke agentlang event shop/MakeItem." },
+ {
+ "name": "shop__Item__create",
+ "description": "Create a shop/Item record. Pass attributes as JSON."
+ },
+ {
+ "name": "shop__Item__list",
+ "description": "List shop/Item records. Optional filter attributes are matched as exact equality."
+ },
+ { "name": "shop__Item__get", "description": "Fetch a single shop/Item record by primary key." },
+ { "name": "shop__Item__delete", "description": "Delete a shop/Item record by primary key." }
+ ]
+}
+```
+
+`resources/list` returns:
+
+```json
+{
+ "resources": [
+ {
+ "uri": "agentlang://shop/Item",
+ "name": "shop/Item",
+ "description": "All records of shop/Item."
+ }
+ ]
+}
+```
+
+Calling the event tool:
+
+```json
+{ "name": "shop__MakeItem", "arguments": { "id": 1, "name": "Pen", "price": 2.5 } }
+```
+
+returns text content with the workflow's last result (the new `Item` instance as JSON).
+
+## Tool naming
+
+Tool names cannot contain `/`, so agentlang separators (`module/Entity`) are remapped to `__`:
+
+| Source | Tool name |
+| ------------------------ | ------------------------ |
+| `@public event` X in M | `M__X` |
+| Entity X in M, create | `M__X__create` |
+| Entity X in M, list | `M__X__list` |
+| Entity X in M, get by id | `M__X__get` |
+| Entity X in M, delete | `M__X__delete` |
+| Built-in search | `agentlang_search_tools` |
+
+The search tool is intentionally single-segment (no `__`) so it can never collide with module/entry tool names.
+
+## Input schemas
+
+The tool's JSON Schema is derived from the corresponding agentlang record:
+
+| Agentlang type | JSON Schema |
+| --------------------------------------------------------- | ------------------------------------------------ |
+| `String` / `Email` / `UUID` / `URL` / `Path` / `Password` | `{ type: "string" }` |
+| `Date` | `{ type: "string", format: "date" }` |
+| `Time` | `{ type: "string", format: "time" }` |
+| `DateTime` | `{ type: "string", format: "date-time" }` |
+| `Int` | `{ type: "integer" }` |
+| `Number` / `Float` / `Decimal` | `{ type: "number" }` |
+| `Boolean` | `{ type: "boolean" }` |
+| `Map` | `{ type: "object", additionalProperties: true }` |
+| `Any` | `{}` (any value) |
+| Custom object types | `{ type: "object", additionalProperties: true }` |
+| `String @array` (or any array) | `{ type: "array", items: }` |
+| `String @enum("a", "b")` | `{ type: "string", enum: ["a", "b"] }` |
+
+Attributes that are not marked `@optional` and have no `@default` or expression are listed in `required`. The `list` tool is an exception: all filter attributes are optional so clients can pass any subset.
+
+## Discovering tools at runtime: `agentlang_search_tools`
+
+When a client connects to a large agentlang app, listing every tool is noisy. Use the search tool instead:
+
+```json
+{
+ "name": "agentlang_search_tools",
+ "arguments": {
+ "query": "invoice",
+ "kind": "event",
+ "limit": 5
+ }
+}
+```
+
+Response (text JSON):
+
+```json
+{
+ "matches": [
+ {
+ "name": "shop__SendInvoice",
+ "description": "Send an invoice...",
+ "kind": "event",
+ "score": 10
+ }
+ ]
+}
+```
+
+Arguments:
+
+| Field | Type | Default | Notes |
+| ------- | ---------------------------------- | ------- | --------------------------------------------------------------------------------------------------------- |
+| `query` | `string` (required) | | Whitespace-split into terms; each term matched (case-insensitive) against tool name, module, description. |
+| `limit` | `integer` | `20` | Clamped to `[1, 100]`. |
+| `kind` | `"any"` \| `"event"` \| `"entity"` | `"any"` | Narrow by surface. `event` = `@public` events; `entity` = entity CRUD tools. |
+
+Scoring weights tool-name and entry-name matches highest, then module, then suffix (`create`/`list`/...), then description. Results sort by score desc with name as tiebreak. Tools disabled by `expose.events` / `expose.entities` are not included.
+
+## Auth
+
+When `auth.enabled` is true in your config, `/mcp` requires `Authorization: Bearer `. The same `verifyAuth` path used by the rest of the agentlang HTTP API checks the token, and the resulting session is threaded into every tool/resource handler via `AsyncLocalStorage`. RBAC rules on entities and events apply transparently — a tool call only sees data the caller is authorized to see.
+
+Requests without a valid bearer get `401 Authorization required`.
+
+## Configuration reference
+
+```jsonc
+{
+ "mcpServer": {
+ "enabled": false, // turn the MCP server on
+ "path": "/mcp", // mount path on the existing Express app
+ "name": "my-app", // server name advertised to clients (defaults to appSpec.name)
+ "version": "0.0.1", // server version (defaults to appSpec.version)
+ "stateless": true, // see "Stateless vs stateful" below
+ "expose": {
+ "events": true, // expose @public events as tools
+ "entities": true, // expose entity CRUD tools
+ "resources": true, // expose entities as MCP resources
+ },
+ },
+}
+```
+
+`expose.*` defaults to `true`. The `agentlang_search_tools` tool is always available regardless of these toggles.
+
+## Stateless vs stateful
+
+By default the server runs stateless (`stateless: true`): each HTTP request creates a fresh MCP `Server` and transport, handles one request, then closes. This matches the [SDK's recommended pattern for simple API-style servers](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/examples/server/simpleStatelessStreamableHttp.ts) and works well with HTTP-based MCP clients.
+
+Set `stateless: false` to opt into a session-based model:
+
+- The server returns an `Mcp-Session-Id` header on `initialize`.
+- The client must echo that header on subsequent requests.
+- The same `transport` and `Server` instance handles all requests for that session.
+- `GET /mcp` and `DELETE /mcp` become valid (used for SSE streaming and explicit session teardown).
+
+In stateless mode, `GET` and `DELETE` on `/mcp` return `405 Method not allowed`.
+
+## How it maps onto the existing HTTP API
+
+| Surface | HTTP endpoint | MCP equivalent |
+| --------------- | ------------------------------ | --------------------------------------------------------------------- |
+| `@public` event | `POST /:module/:event` | `tools/call` with `name = "module__event"` |
+| Entity create | `POST /:module/:entity` | `tools/call` with `name = "module__Entity__create"` |
+| Entity query | `GET /:module/:entity` | `tools/call` with `name = "module__Entity__list"` or `resources/read` |
+| Entity by id | `GET /:module/:entity/` | `tools/call` with `name = "module__Entity__get"` |
+| Entity delete | `DELETE /:module/:entity/` | `tools/call` with `name = "module__Entity__delete"` |
+
+Auth, RBAC, hot-reload of dynamically interned modules, and result normalization all behave the same on both surfaces.
+
+## Caveats
+
+- **Tool naming**: agentlang uses `/` between module and entry; MCP forbids `/` in tool names, so we use `__`. Single-segment names (no `__`) are reserved for built-ins like `agentlang_search_tools`.
+- **Entity tools assume an `id` primary key** for `get` and `delete`. Path-qualified or relationship-scoped CRUD should go through public events.
+- **No `tools/list_changed` notification** is emitted on hot-reload — clients re-list on demand and pick up changes naturally.
+- **Concurrency**: stateful sessions are kept in-memory in a `Map`. There is no persistence or cross-process replication; restarts drop sessions and clients have to re-`initialize`.
diff --git a/src/api/http.ts b/src/api/http.ts
index 25379e12..4f983688 100644
--- a/src/api/http.ts
+++ b/src/api/http.ts
@@ -66,6 +66,14 @@ import {
} from '../runtime/integration-client.js';
import * as XLSX from 'xlsx';
import { objectToQueryPattern } from '../language/parser.js';
+import {
+ createMcpServer,
+ newStatefulTransport,
+ newStatelessTransport,
+ runWithSession,
+} from '../runtime/mcpserver.js';
+import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
+import type { Server as McpSdkServer } from '@modelcontextprotocol/sdk/server/index.js';
export async function createApp(appSpec: ApplicationSpec, config?: Config): Promise {
const app = express();
@@ -281,6 +289,102 @@ export async function createApp(appSpec: ApplicationSpec, config?: Config): Prom
});
}
+ if (config?.mcpServer?.enabled) {
+ const mcp = createMcpServer(appSpec, config);
+ const mcpPath = config.mcpServer.path || '/mcp';
+ const stateful = config.mcpServer.stateless === false;
+ const sessions = new Map<
+ string,
+ { server: McpSdkServer; transport: StreamableHTTPServerTransport }
+ >();
+
+ const methodNotAllowed = (res: Response) => {
+ res.status(405).json({
+ jsonrpc: '2.0',
+ error: { code: -32000, message: 'Method not allowed' },
+ id: null,
+ });
+ };
+
+ const handlePost = async (req: Request, res: Response): Promise => {
+ try {
+ const sessionInfo = await verifyAuth('', '', req.headers.authorization);
+ if (isNoSession(sessionInfo)) {
+ res.status(401).send('Authorization required');
+ return;
+ }
+
+ if (stateful) {
+ const sid = (req.headers['mcp-session-id'] as string | undefined) ?? undefined;
+ let entry = sid ? sessions.get(sid) : undefined;
+ if (!entry) {
+ const transport = newStatefulTransport();
+ const server = mcp.build();
+ await server.connect(transport);
+ entry = { server, transport };
+ transport.onclose = () => {
+ const id = transport.sessionId;
+ if (id) sessions.delete(id);
+ };
+ }
+ await runWithSession(sessionInfo, async () => {
+ await entry!.transport.handleRequest(req, res, req.body);
+ });
+ if (entry.transport.sessionId && !sessions.has(entry.transport.sessionId)) {
+ sessions.set(entry.transport.sessionId, entry);
+ }
+ return;
+ }
+
+ // Stateless: fresh server + transport per request.
+ const server = mcp.build();
+ const transport = newStatelessTransport();
+ res.on('close', () => {
+ transport.close().catch(() => undefined);
+ server.close().catch(() => undefined);
+ });
+ await server.connect(transport);
+ await runWithSession(sessionInfo, async () => {
+ await transport.handleRequest(req, res, req.body);
+ });
+ } catch (err: any) {
+ logger.error(`MCP request error: ${err?.stack || err}`);
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: '2.0',
+ error: { code: -32603, message: err?.message || 'Internal MCP error' },
+ id: null,
+ });
+ }
+ }
+ };
+
+ const handleStatefulNonPost = async (req: Request, res: Response): Promise => {
+ const sessionInfo = await verifyAuth('', '', req.headers.authorization);
+ if (isNoSession(sessionInfo)) {
+ res.status(401).send('Authorization required');
+ return;
+ }
+ if (!stateful) {
+ methodNotAllowed(res);
+ return;
+ }
+ const sid = req.headers['mcp-session-id'] as string | undefined;
+ const entry = sid ? sessions.get(sid) : undefined;
+ if (!entry) {
+ res.status(400).send('Missing or unknown Mcp-Session-Id');
+ return;
+ }
+ await runWithSession(sessionInfo, async () => {
+ await entry.transport.handleRequest(req, res);
+ });
+ };
+
+ app.post(mcpPath, handlePost);
+ app.get(mcpPath, handleStatefulNonPost);
+ app.delete(mcpPath, handleStatefulNonPost);
+ }
+
if (isNodeEnv && upload && uploadDir) {
app.post('/uploadFile', upload.single('file'), (req: Request, res: Response) => {
handleFileUpload(req, res, config);
diff --git a/src/runtime/mcpserver.ts b/src/runtime/mcpserver.ts
new file mode 100644
index 00000000..f4a4900b
--- /dev/null
+++ b/src/runtime/mcpserver.ts
@@ -0,0 +1,589 @@
+// MCP server: exposes an agentlang application as Model Context Protocol tools and resources.
+// Ref: https://modelcontextprotocol.io / @modelcontextprotocol/sdk
+import { Server } from '@modelcontextprotocol/sdk/server/index.js';
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
+import {
+ ListToolsRequestSchema,
+ CallToolRequestSchema,
+ ListResourcesRequestSchema,
+ ReadResourceRequestSchema,
+ ErrorCode,
+ McpError,
+} from '@modelcontextprotocol/sdk/types.js';
+import { AsyncLocalStorage } from 'node:async_hooks';
+
+import {
+ AttributeSpec,
+ fetchModule,
+ getAllEntityNames,
+ getAllEventNames,
+ Instance,
+ makeInstance,
+ Module,
+ objectAsInstanceAttributes,
+ Record as AlRecord,
+} from './module.js';
+import { makeFqName } from './util.js';
+import { evaluate, parseAndEvaluateStatement, Result } from './interpreter.js';
+import { ApplicationSpec } from './loader.js';
+import { Config } from './state.js';
+import { logger } from './logger.js';
+import { ActiveSessionInfo, BypassSession } from './auth/defs.js';
+
+// Tool / resource names cannot contain '/'. We use '__' to join module + entry.
+const SEP = '__';
+
+// Session info for the in-flight tool/resource request, populated by the
+// HTTP layer before delegating to transport.handleRequest().
+const sessionStore = new AsyncLocalStorage();
+
+export function runWithSession(session: ActiveSessionInfo, fn: () => Promise): Promise {
+ return sessionStore.run(session, fn);
+}
+
+function currentSession(): ActiveSessionInfo {
+ return sessionStore.getStore() ?? BypassSession;
+}
+
+function fqToolName(moduleName: string, entryName: string, suffix?: string): string {
+ const base = `${moduleName}${SEP}${entryName}`;
+ return suffix ? `${base}${SEP}${suffix}` : base;
+}
+
+function parseToolName(name: string): { moduleName: string; entryName: string; suffix?: string } {
+ const parts = name.split(SEP);
+ if (parts.length < 2) {
+ throw new McpError(ErrorCode.InvalidParams, `Invalid tool name: ${name}`);
+ }
+ if (parts.length === 2) {
+ return { moduleName: parts[0], entryName: parts[1] };
+ }
+ return { moduleName: parts[0], entryName: parts[1], suffix: parts.slice(2).join(SEP) };
+}
+
+function metaDescription(meta: Map | undefined, fallback: string): string {
+ if (!meta) return fallback;
+ const docKeys = ['doc', 'description', 'comment'];
+ for (const k of docKeys) {
+ const v = meta.get(k);
+ if (typeof v === 'string' && v.trim().length > 0) {
+ return v;
+ }
+ }
+ return fallback;
+}
+
+// Map an agentlang AttributeSpec to a JSON Schema fragment.
+function attributeToJsonSchema(spec: AttributeSpec): any {
+ const props = spec.properties;
+ const isArray = props?.get('array') === true;
+ const isObject = props?.get('object') === true;
+ const enumValues: Set | undefined = props?.get('enum');
+
+ const inner = jsonSchemaForType(spec.type, isObject);
+ if (enumValues && enumValues.size > 0) {
+ inner.enum = Array.from(enumValues);
+ }
+ if (isArray) {
+ return { type: 'array', items: inner };
+ }
+ return inner;
+}
+
+function jsonSchemaForType(type: string, isObject: boolean): any {
+ switch (type) {
+ case 'String':
+ case 'Email':
+ case 'UUID':
+ case 'URL':
+ case 'Path':
+ case 'Password':
+ return { type: 'string' };
+ case 'Date':
+ return { type: 'string', format: 'date' };
+ case 'Time':
+ return { type: 'string', format: 'time' };
+ case 'DateTime':
+ return { type: 'string', format: 'date-time' };
+ case 'Int':
+ return { type: 'integer' };
+ case 'Number':
+ case 'Float':
+ case 'Decimal':
+ return { type: 'number' };
+ case 'Boolean':
+ return { type: 'boolean' };
+ case 'Map':
+ return { type: 'object', additionalProperties: true };
+ case 'Any':
+ return {};
+ default:
+ // Custom or referenced type — treat as object if marked, else string.
+ return isObject ? { type: 'object', additionalProperties: true } : { type: 'string' };
+ }
+}
+
+function recordToInputSchema(
+ rec: AlRecord,
+ opts?: { includeAttr?: (name: string) => boolean; allOptional?: boolean }
+): any {
+ const properties: any = {};
+ const required: string[] = [];
+ rec.schema.forEach((spec: AttributeSpec, attrName: string) => {
+ if (opts?.includeAttr && !opts.includeAttr(attrName)) return;
+ properties[attrName] = attributeToJsonSchema(spec);
+ if (opts?.allOptional) return;
+ const optional = spec.properties?.get('optional') === true;
+ const hasDefault =
+ spec.properties?.has('default') ||
+ spec.properties?.has('@default') ||
+ spec.properties?.has('expr');
+ if (!optional && !hasDefault) {
+ required.push(attrName);
+ }
+ });
+ const schema: any = { type: 'object', properties };
+ if (required.length > 0) {
+ schema.required = required;
+ }
+ return schema;
+}
+
+type ListedTool = {
+ name: string;
+ description: string;
+ inputSchema: any;
+};
+
+function listEventTools(): ListedTool[] {
+ const tools: ListedTool[] = [];
+ getAllEventNames().forEach((eventNames: string[], moduleName: string) => {
+ const m: Module = fetchModule(moduleName);
+ eventNames.forEach((eventName: string) => {
+ if (!m.eventIsPublic(eventName)) return;
+ let entry: AlRecord | undefined;
+ try {
+ entry = m.getEntry(eventName) as AlRecord;
+ } catch {
+ return;
+ }
+ const description = metaDescription(
+ entry.meta,
+ `Invoke agentlang event ${makeFqName(moduleName, eventName)}.`
+ );
+ tools.push({
+ name: fqToolName(moduleName, eventName),
+ description,
+ inputSchema: recordToInputSchema(entry),
+ });
+ });
+ });
+ return tools;
+}
+
+function listEntityTools(): ListedTool[] {
+ const tools: ListedTool[] = [];
+ getAllEntityNames().forEach((entityNames: string[], moduleName: string) => {
+ const m: Module = fetchModule(moduleName);
+ entityNames.forEach((entityName: string) => {
+ let entry: AlRecord | undefined;
+ try {
+ entry = m.getEntry(entityName) as AlRecord;
+ } catch {
+ return;
+ }
+ const fq = makeFqName(moduleName, entityName);
+ tools.push({
+ name: fqToolName(moduleName, entityName, 'create'),
+ description: `Create a ${fq} record. Pass attributes as JSON.`,
+ inputSchema: recordToInputSchema(entry),
+ });
+ tools.push({
+ name: fqToolName(moduleName, entityName, 'list'),
+ description: `List ${fq} records. Optional filter attributes are matched as exact equality.`,
+ inputSchema: recordToInputSchema(entry, { allOptional: true }),
+ });
+ tools.push({
+ name: fqToolName(moduleName, entityName, 'get'),
+ description: `Fetch a single ${fq} record by primary key.`,
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
+ });
+ tools.push({
+ name: fqToolName(moduleName, entityName, 'delete'),
+ description: `Delete a ${fq} record by primary key.`,
+ inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
+ });
+ });
+ });
+ return tools;
+}
+
+// Tool name reserved for the built-in tool-search tool. Uses a single-segment
+// name so it never collides with `module__entry[__suffix]` shapes.
+const SEARCH_TOOL_NAME = 'agentlang_search_tools';
+
+function searchToolDef(): ListedTool {
+ return {
+ name: SEARCH_TOOL_NAME,
+ description:
+ 'Search the available agentlang MCP tools by free-text query. Returns the best matches ' +
+ 'ranked by relevance over tool name, module, and description. Use this when there are ' +
+ 'too many tools to enumerate or when you only know what you want to do, not the tool name.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ query: {
+ type: 'string',
+ description:
+ 'Free-text query. Whitespace-split into terms; each term is matched (case-insensitive) ' +
+ 'against tool name, module, and description.',
+ },
+ limit: {
+ type: 'integer',
+ description: 'Maximum number of matches to return. Default 20, max 100.',
+ },
+ kind: {
+ type: 'string',
+ enum: ['any', 'event', 'entity'],
+ description:
+ 'Filter by tool kind. "event" → only @public events; "entity" → only entity CRUD tools; "any" (default) → both.',
+ },
+ },
+ required: ['query'],
+ },
+ };
+}
+
+type ScoredTool = { tool: ListedTool; score: number; kind: 'event' | 'entity' };
+
+function scoreTool(tool: ListedTool, kind: 'event' | 'entity', terms: string[]): number {
+ if (terms.length === 0) return 0;
+ const name = tool.name.toLowerCase();
+ const desc = (tool.description || '').toLowerCase();
+ const parsed = (() => {
+ try {
+ return parseToolName(tool.name);
+ } catch {
+ return undefined;
+ }
+ })();
+ const moduleName = parsed?.moduleName?.toLowerCase() ?? '';
+ const entryName = parsed?.entryName?.toLowerCase() ?? '';
+ const suffix = parsed?.suffix?.toLowerCase() ?? '';
+
+ let score = 0;
+ for (const term of terms) {
+ const t = term.toLowerCase();
+ if (!t) continue;
+ if (name === t) score += 100;
+ if (entryName === t) score += 60;
+ if (entryName.startsWith(t)) score += 25;
+ if (entryName.includes(t)) score += 10;
+ if (moduleName === t) score += 30;
+ if (moduleName.includes(t)) score += 8;
+ if (suffix === t) score += 20;
+ if (desc.includes(t)) score += 5;
+ if (name.includes(t)) score += 3;
+ }
+ return score;
+}
+
+function searchAvailableTools(
+ query: string,
+ limit: number,
+ kind: 'any' | 'event' | 'entity',
+ config?: Config
+): { matches: { name: string; description: string; kind: 'event' | 'entity'; score: number }[] } {
+ const terms = String(query ?? '')
+ .split(/\s+/)
+ .map(s => s.trim())
+ .filter(Boolean);
+
+ const pool: ScoredTool[] = [];
+ if (kind !== 'entity' && isExposeOn(config, 'events')) {
+ listEventTools().forEach(t => pool.push({ tool: t, score: 0, kind: 'event' }));
+ }
+ if (kind !== 'event' && isExposeOn(config, 'entities')) {
+ listEntityTools().forEach(t => pool.push({ tool: t, score: 0, kind: 'entity' }));
+ }
+
+ for (const p of pool) p.score = scoreTool(p.tool, p.kind, terms);
+ const matches = pool
+ .filter(p => p.score > 0)
+ .sort((a, b) => b.score - a.score || a.tool.name.localeCompare(b.tool.name))
+ .slice(0, Math.min(Math.max(1, limit), 100))
+ .map(p => ({
+ name: p.tool.name,
+ description: p.tool.description,
+ kind: p.kind,
+ score: p.score,
+ }));
+ return { matches };
+}
+
+function listEntityResources(): { uri: string; name: string; description: string }[] {
+ const out: { uri: string; name: string; description: string }[] = [];
+ getAllEntityNames().forEach((entityNames: string[], moduleName: string) => {
+ entityNames.forEach((entityName: string) => {
+ const fq = makeFqName(moduleName, entityName);
+ out.push({
+ uri: `agentlang://${moduleName}/${entityName}`,
+ name: fq,
+ description: `All records of ${fq}.`,
+ });
+ });
+ });
+ return out;
+}
+
+// Format a value for use in an agentlang query/mutation pattern.
+function formatLiteral(v: unknown): string {
+ if (v === null || v === undefined) return 'null';
+ if (typeof v === 'string') return `"${v.replace(/"/g, '\\"')}"`;
+ if (typeof v === 'boolean' || typeof v === 'number') return String(v);
+ if (Array.isArray(v) || typeof v === 'object') return JSON.stringify(v);
+ return String(v);
+}
+
+function buildAttrPattern(args: Record, queryStyle: boolean): string {
+ const parts = Object.entries(args).map(([k, v]) => {
+ const op = queryStyle ? '?' : '';
+ return `${k}${op} ${formatLiteral(v)}`;
+ });
+ return `{${parts.join(', ')}}`;
+}
+
+async function callEventTool(
+ moduleName: string,
+ eventName: string,
+ args: Record
+): Promise {
+ const session = currentSession();
+ const inst: Instance = makeInstance(
+ moduleName,
+ eventName,
+ objectAsInstanceAttributes(args)
+ ).setAuthContext(session);
+ return evaluate(inst);
+}
+
+async function callEntityTool(
+ moduleName: string,
+ entityName: string,
+ suffix: string,
+ args: Record
+): Promise {
+ const fq = makeFqName(moduleName, entityName);
+ const session = currentSession();
+ const userId = session.userId;
+ let pattern: string;
+ switch (suffix) {
+ case 'create': {
+ pattern = `{${fq} ${buildAttrPattern(args, false)}}`;
+ break;
+ }
+ case 'list': {
+ const hasFilter = Object.keys(args).length > 0;
+ pattern = hasFilter ? `{${fq} ${buildAttrPattern(args, true)}}` : `{${fq}? {}}`;
+ break;
+ }
+ case 'get': {
+ const id = args.id;
+ if (id === undefined) {
+ throw new McpError(ErrorCode.InvalidParams, `Missing 'id' for ${fq}.get`);
+ }
+ pattern = `{${fq} {id? ${formatLiteral(id)}}}`;
+ break;
+ }
+ case 'delete': {
+ const id = args.id;
+ if (id === undefined) {
+ throw new McpError(ErrorCode.InvalidParams, `Missing 'id' for ${fq}.delete`);
+ }
+ pattern = `delete {${fq} {id? ${formatLiteral(id)}}}`;
+ break;
+ }
+ default:
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown entity action: ${suffix}`);
+ }
+ return parseAndEvaluateStatement(pattern, userId);
+}
+
+function normalizeForJson(r: Result): any {
+ if (r === null || r === undefined) return r;
+ if (Array.isArray(r)) return r.map(normalizeForJson);
+ if (Instance.IsInstance(r)) {
+ r.mergeRelatedInstances();
+ Array.from(r.attributes.keys()).forEach(k => {
+ const v: Result = r.attributes.get(k);
+ if (Array.isArray(v) || Instance.IsInstance(v)) {
+ r.attributes.set(k, normalizeForJson(v));
+ }
+ });
+ return r.asObject();
+ }
+ if (r instanceof Map) return Object.fromEntries(r.entries());
+ return r;
+}
+
+function asTextContent(value: unknown): { type: 'text'; text: string } {
+ return { type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) };
+}
+
+function isExposeOn(config: Config | undefined, key: 'events' | 'entities' | 'resources'): boolean {
+ const exp = config?.mcpServer?.expose;
+ if (!exp) return true;
+ return exp[key] !== false;
+}
+
+export type AgentlangMcp = {
+ /**
+ * Build a fresh MCP server bound to the agentlang runtime. Caller is
+ * responsible for connecting it to a transport and closing it when done.
+ * Used in stateless mode where each HTTP request gets its own server.
+ */
+ build: () => Server;
+ /**
+ * Long-lived shared MCP server. Used in stateful mode together with one
+ * transport per session.
+ */
+ shared: Server;
+ name: string;
+ version: string;
+};
+
+function buildServer(name: string, version: string, config?: Config): Server {
+ const server = new Server(
+ { name, version },
+ {
+ capabilities: {
+ tools: {},
+ resources: {},
+ },
+ }
+ );
+
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
+ const tools: ListedTool[] = [];
+ tools.push(searchToolDef());
+ if (isExposeOn(config, 'events')) tools.push(...listEventTools());
+ if (isExposeOn(config, 'entities')) tools.push(...listEntityTools());
+ return { tools };
+ });
+
+ server.setRequestHandler(CallToolRequestSchema, async request => {
+ const { name: toolName, arguments: rawArgs } = request.params;
+ const args = (rawArgs ?? {}) as Record;
+ try {
+ if (toolName === SEARCH_TOOL_NAME) {
+ const query = typeof args.query === 'string' ? args.query : '';
+ const limit = typeof args.limit === 'number' ? args.limit : 20;
+ const kind =
+ args.kind === 'event' || args.kind === 'entity' || args.kind === 'any'
+ ? args.kind
+ : 'any';
+ const result = searchAvailableTools(query, limit, kind, config);
+ return { content: [asTextContent(result)] };
+ }
+ const { moduleName, entryName, suffix } = parseToolName(toolName);
+ let result: Result;
+ if (suffix) {
+ if (!isExposeOn(config, 'entities')) {
+ throw new McpError(ErrorCode.MethodNotFound, `Entity tools are disabled: ${toolName}`);
+ }
+ result = await callEntityTool(moduleName, entryName, suffix, args);
+ } else {
+ if (!isExposeOn(config, 'events')) {
+ throw new McpError(ErrorCode.MethodNotFound, `Event tools are disabled: ${toolName}`);
+ }
+ result = await callEventTool(moduleName, entryName, args);
+ }
+ return { content: [asTextContent(normalizeForJson(result))] };
+ } catch (err: any) {
+ if (err instanceof McpError) throw err;
+ logger.error(`MCP tool ${toolName} failed: ${err?.stack || err}`);
+ return {
+ isError: true,
+ content: [asTextContent(err?.message || String(err))],
+ };
+ }
+ });
+
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
+ if (!isExposeOn(config, 'resources')) return { resources: [] };
+ return { resources: listEntityResources() };
+ });
+
+ server.setRequestHandler(ReadResourceRequestSchema, async request => {
+ if (!isExposeOn(config, 'resources')) {
+ throw new McpError(ErrorCode.InvalidRequest, 'Resources are disabled');
+ }
+ const uri = request.params.uri;
+ const m = /^agentlang:\/\/([^/]+)\/(.+)$/.exec(uri);
+ if (!m) {
+ throw new McpError(ErrorCode.InvalidParams, `Unknown resource URI: ${uri}`);
+ }
+ const moduleName = m[1];
+ const entityName = m[2];
+ const fq = `${moduleName}/${entityName}`;
+ const session = currentSession();
+ const result = await parseAndEvaluateStatement(`{${fq}? {}}`, session.userId);
+ return {
+ contents: [
+ {
+ uri,
+ mimeType: 'application/json',
+ text: JSON.stringify(normalizeForJson(result), null, 2),
+ },
+ ],
+ };
+ });
+
+ return server;
+}
+
+export function createMcpServer(appSpec: ApplicationSpec, config?: Config): AgentlangMcp {
+ const cfg = config?.mcpServer;
+ const name = cfg?.name ?? appSpec.name;
+ const version = cfg?.version ?? appSpec.version ?? '0.0.0';
+ const shared = buildServer(name, version, config);
+ logger.info(
+ `MCP server '${name}' v${version} ready (path=${cfg?.path ?? '/mcp'}, stateless=${cfg?.stateless !== false})`
+ );
+ return {
+ build: () => buildServer(name, version, config),
+ shared,
+ name,
+ version,
+ };
+}
+
+export function newStatelessTransport(): StreamableHTTPServerTransport {
+ return new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
+}
+
+export function newStatefulTransport(): StreamableHTTPServerTransport {
+ return new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => globalThis.crypto.randomUUID(),
+ });
+}
+
+// Internals exposed for unit testing only. Not part of the public API.
+export const __test__ = {
+ fqToolName,
+ parseToolName,
+ metaDescription,
+ attributeToJsonSchema,
+ jsonSchemaForType,
+ recordToInputSchema,
+ formatLiteral,
+ buildAttrPattern,
+ listEventTools,
+ listEntityTools,
+ listEntityResources,
+ isExposeOn,
+ searchToolDef,
+ searchAvailableTools,
+ scoreTool,
+ SEARCH_TOOL_NAME,
+};
diff --git a/src/runtime/state.ts b/src/runtime/state.ts
index e990b627..a97ba76c 100644
--- a/src/runtime/state.ts
+++ b/src/runtime/state.ts
@@ -140,6 +140,22 @@ export const ConfigSchema = z.object({
})
)
.optional(),
+ mcpServer: z
+ .object({
+ enabled: z.boolean().default(false),
+ path: z.string().default('/mcp'),
+ name: z.string().optional(),
+ version: z.string().optional(),
+ stateless: z.boolean().default(true),
+ expose: z
+ .object({
+ events: z.boolean().default(true),
+ entities: z.boolean().default(true),
+ resources: z.boolean().default(true),
+ })
+ .default({ events: true, entities: true, resources: true }),
+ })
+ .optional(),
});
export type Config = z.infer;
diff --git a/test/api/mcpserver.test.ts b/test/api/mcpserver.test.ts
new file mode 100644
index 00000000..ee98af8b
--- /dev/null
+++ b/test/api/mcpserver.test.ts
@@ -0,0 +1,433 @@
+import { assert, describe, test, beforeAll, afterAll } from 'vitest';
+import type { Server as HttpServer } from 'http';
+import type { AddressInfo } from 'net';
+
+import { createApp } from '../../src/api/http.js';
+import { doInternModule } from '../util.js';
+import { parseAndIntern } from '../../src/runtime/loader.js';
+import { runPostInitTasks } from '../../src/cli/main.js';
+import { setAppConfig, AppConfig } from '../../src/runtime/state.js';
+
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
+
+type Booted = {
+ http: HttpServer;
+ url: string;
+ client: Client;
+};
+
+async function bootApp(opts: {
+ appName?: string;
+ appVersion?: string;
+ config?: any;
+ authHeader?: string;
+}): Promise {
+ const app = await createApp(
+ { name: opts.appName ?? 'mcp-test', version: opts.appVersion ?? '0.0.1' },
+ opts.config
+ );
+ const http = app.listen(0);
+ const addr = http.address() as AddressInfo;
+ const baseUrl = `http://127.0.0.1:${addr.port}`;
+ const url = `${baseUrl}/mcp`;
+
+ const transport = new StreamableHTTPClientTransport(new URL(url), {
+ requestInit: opts.authHeader ? { headers: { Authorization: opts.authHeader } } : undefined,
+ });
+ const client = new Client({ name: 'test-client', version: '0.0.1' });
+ await client.connect(transport);
+ return { http, url: baseUrl, client };
+}
+
+async function shutdown(b: Booted) {
+ try {
+ await b.client.close();
+ } catch {
+ /* ignore */
+ }
+ await new Promise(resolve => b.http.close(() => resolve()));
+}
+
+function parseToolText(result: any): any {
+ const first = result?.content?.[0];
+ assert.ok(first, 'tool call should return content');
+ assert.equal(first.type, 'text');
+ if (typeof first.text !== 'string') return first.text;
+ try {
+ return JSON.parse(first.text);
+ } catch {
+ return first.text;
+ }
+}
+
+// ──────────────────────────────────────────────────────────────────
+// Main suite — no auth, full surface (events + entities + resources)
+// ──────────────────────────────────────────────────────────────────
+
+describe('MCP server — full surface', () => {
+ let booted: Booted;
+
+ beforeAll(async () => {
+ await doInternModule(
+ 'McpApi',
+ `entity Item {
+ id Int @id,
+ name String,
+ qty Int
+ }
+
+ @public event MakeItem {
+ id Int,
+ name String,
+ qty Int
+ }
+
+ workflow MakeItem {
+ {Item {id MakeItem.id, name MakeItem.name, qty MakeItem.qty}}
+ }
+
+ event PrivateOp {
+ id Int
+ }`
+ );
+ booted = await bootApp({
+ config: { service: { port: 0, httpFileHandling: false }, mcpServer: { enabled: true } },
+ });
+ });
+
+ afterAll(async () => {
+ await shutdown(booted);
+ });
+
+ test('initialize hands shake completes', () => {
+ const info = booted.client.getServerVersion();
+ assert.ok(info, 'server info should be returned');
+ assert.equal(info?.name, 'mcp-test');
+ assert.equal(info?.version, '0.0.1');
+ });
+
+ test('tools/list returns event tool for @public event but not private events', async () => {
+ const { tools } = await booted.client.listTools();
+ const names = tools.map(t => t.name);
+ assert.include(names, 'McpApi__MakeItem');
+ assert.notInclude(names, 'McpApi__PrivateOp');
+ });
+
+ test('tools/list also returns entity CRUD tools', async () => {
+ const { tools } = await booted.client.listTools();
+ const names = new Set(tools.map(t => t.name));
+ assert.isTrue(names.has('McpApi__Item__create'));
+ assert.isTrue(names.has('McpApi__Item__list'));
+ assert.isTrue(names.has('McpApi__Item__get'));
+ assert.isTrue(names.has('McpApi__Item__delete'));
+ });
+
+ test('event tool inputSchema reflects attribute types', async () => {
+ const { tools } = await booted.client.listTools();
+ const ev = tools.find(t => t.name === 'McpApi__MakeItem');
+ assert.ok(ev);
+ const sch: any = ev!.inputSchema;
+ assert.equal(sch.type, 'object');
+ assert.deepEqual(sch.properties.id, { type: 'integer' });
+ assert.deepEqual(sch.properties.name, { type: 'string' });
+ assert.deepEqual(sch.properties.qty, { type: 'integer' });
+ assert.includeMembers(sch.required, ['id', 'name', 'qty']);
+ });
+
+ test('calling event tool runs workflow and creates entity', async () => {
+ const result = await booted.client.callTool({
+ name: 'McpApi__MakeItem',
+ arguments: { id: 1, name: 'Pen', qty: 5 },
+ });
+ parseToolText(result); // should be JSON-parseable, not isError
+ assert.notEqual(result.isError, true);
+
+ // Read back via list tool.
+ const listed = await booted.client.callTool({
+ name: 'McpApi__Item__list',
+ arguments: {},
+ });
+ const rows = parseToolText(listed);
+ const flat = Array.isArray(rows[0]) ? rows[0] : rows;
+ const found = flat.find((r: any) => r?.Item?.id === 1 || r?.id === 1);
+ assert.ok(found, `Item id=1 not found in ${JSON.stringify(rows)}`);
+ });
+
+ test('entity create tool inserts a row that the get tool can fetch', async () => {
+ await booted.client.callTool({
+ name: 'McpApi__Item__create',
+ arguments: { id: 2, name: 'Mug', qty: 12 },
+ });
+ const got = await booted.client.callTool({
+ name: 'McpApi__Item__get',
+ arguments: { id: 2 },
+ });
+ const rows = parseToolText(got);
+ const flat = Array.isArray(rows[0]) ? rows[0] : rows;
+ const found = flat.find((r: any) => r?.Item?.id === 2 || r?.id === 2);
+ assert.ok(found, `expected to find id=2 in ${JSON.stringify(rows)}`);
+ });
+
+ test('entity delete tool removes a row', async () => {
+ await booted.client.callTool({
+ name: 'McpApi__Item__create',
+ arguments: { id: 99, name: 'Tmp', qty: 1 },
+ });
+ await booted.client.callTool({
+ name: 'McpApi__Item__delete',
+ arguments: { id: 99 },
+ });
+ const got = await booted.client.callTool({
+ name: 'McpApi__Item__get',
+ arguments: { id: 99 },
+ });
+ const rows = parseToolText(got);
+ const flat = Array.isArray(rows) ? (Array.isArray(rows[0]) ? rows[0] : rows) : [];
+ const stillThere = flat.find((r: any) => r?.Item?.id === 99 || r?.id === 99);
+ assert.notOk(stillThere, `id=99 should have been deleted; got ${JSON.stringify(rows)}`);
+ });
+
+ test('get tool with missing id surfaces an error', async () => {
+ // An McpError thrown from the call handler may either
+ // (a) come back as a protocol error the SDK throws on the client, or
+ // (b) come back as a CallToolResult with isError=true.
+ // We accept either — both are valid MCP surfaces for tool-level failure.
+ let threw = false;
+ try {
+ const result = await booted.client.callTool({
+ name: 'McpApi__Item__get',
+ arguments: {},
+ });
+ if (result.isError !== true) {
+ assert.fail(`expected an error response, got ${JSON.stringify(result)}`);
+ }
+ } catch (err: any) {
+ threw = true;
+ const msg = String(err?.message ?? err);
+ assert.match(msg, /id/i, `expected error to mention 'id', got: ${msg}`);
+ }
+ // If we got here without isError or throwing, that's a fail; assert.fail above already handles it.
+ if (threw) {
+ // good — server-side validation surfaced as a protocol error.
+ }
+ });
+
+ test('unknown tool name returns isError', async () => {
+ const result = await booted.client.callTool({
+ name: 'McpApi__DoesNotExist',
+ arguments: {},
+ });
+ assert.equal(result.isError, true);
+ });
+
+ test('resources/list exposes one resource per entity', async () => {
+ const { resources } = await booted.client.listResources();
+ const names = resources.map(r => r.name);
+ assert.include(names, 'McpApi/Item');
+ const item = resources.find(r => r.name === 'McpApi/Item');
+ assert.equal(item!.uri, 'agentlang://McpApi/Item');
+ });
+
+ test('resources/read returns JSON of all rows for an entity', async () => {
+ const out = await booted.client.readResource({ uri: 'agentlang://McpApi/Item' });
+ assert.ok(out.contents && out.contents.length > 0);
+ const first = out.contents[0];
+ assert.equal(first.uri, 'agentlang://McpApi/Item');
+ assert.equal(first.mimeType, 'application/json');
+ const parsed = JSON.parse(first.text as string);
+ assert.ok(Array.isArray(parsed) || Array.isArray(parsed?.[0]) || parsed !== undefined);
+ });
+
+ test('resources/read with bad URI fails', async () => {
+ let threw = false;
+ try {
+ await booted.client.readResource({ uri: 'bogus://x/y' });
+ } catch {
+ threw = true;
+ }
+ assert.isTrue(threw, 'expected readResource to throw on bad URI');
+ });
+
+ test('agentlang_search_tools is listed and finds tools by query', async () => {
+ const { tools } = await booted.client.listTools();
+ const search = tools.find(t => t.name === 'agentlang_search_tools');
+ assert.ok(search, 'expected built-in search tool to be listed');
+
+ const result = await booted.client.callTool({
+ name: 'agentlang_search_tools',
+ arguments: { query: 'Item' },
+ });
+ const payload = parseToolText(result);
+ assert.ok(Array.isArray(payload.matches));
+ assert.isAbove(payload.matches.length, 0);
+ const names = payload.matches.map((m: any) => m.name);
+ // Both the event tool and entity CRUD tools for Item should rank.
+ assert.include(names, 'McpApi__MakeItem');
+ assert.include(names, 'McpApi__Item__create');
+ // Scores are non-increasing.
+ for (let i = 1; i < payload.matches.length; i++) {
+ assert.isAtLeast(payload.matches[i - 1].score, payload.matches[i].score);
+ }
+ });
+
+ test('agentlang_search_tools respects kind=event', async () => {
+ const result = await booted.client.callTool({
+ name: 'agentlang_search_tools',
+ arguments: { query: 'Item', kind: 'event' },
+ });
+ const payload = parseToolText(result);
+ for (const m of payload.matches) {
+ assert.equal(m.kind, 'event', `expected event-only matches, got ${m.name}`);
+ }
+ });
+
+ test('agentlang_search_tools with no matches returns empty list', async () => {
+ const result = await booted.client.callTool({
+ name: 'agentlang_search_tools',
+ arguments: { query: 'completely-unrelated-zzz' },
+ });
+ const payload = parseToolText(result);
+ assert.deepEqual(payload.matches, []);
+ });
+
+ test('hot-reload: dynamically interned module shows up in tools/list', async () => {
+ // Add a brand-new module after the server is already running.
+ await parseAndIntern(
+ `module HotMcp
+ @public event Echo { msg String }
+ workflow Echo { Echo.msg }`
+ );
+ await runPostInitTasks();
+
+ const { tools } = await booted.client.listTools();
+ const names = tools.map(t => t.name);
+ assert.include(names, 'HotMcp__Echo');
+
+ const out = await booted.client.callTool({
+ name: 'HotMcp__Echo',
+ arguments: { msg: 'hi' },
+ });
+ const txt = parseToolText(out);
+ // Workflow returns the msg string; expect "hi" to appear in result.
+ const flat = JSON.stringify(txt);
+ assert.include(flat, 'hi');
+ });
+});
+
+// ──────────────────────────────────────────────────────────────────
+// Expose toggles
+// ──────────────────────────────────────────────────────────────────
+
+describe('MCP server — expose toggles', () => {
+ let booted: Booted;
+
+ beforeAll(async () => {
+ await doInternModule(
+ 'McpToggles',
+ `entity Box { id Int @id, label String }
+ @public event Ping { name String }
+ workflow Ping { Ping.name }`
+ );
+ booted = await bootApp({
+ appName: 'toggles-app',
+ config: {
+ service: { port: 0, httpFileHandling: false },
+ mcpServer: {
+ enabled: true,
+ expose: { events: false, entities: true, resources: false },
+ },
+ },
+ });
+ });
+
+ afterAll(async () => {
+ await shutdown(booted);
+ });
+
+ test('events disabled → no event tools', async () => {
+ const { tools } = await booted.client.listTools();
+ const names = tools.map(t => t.name);
+ assert.notInclude(names, 'McpToggles__Ping');
+ // Entity tools still present.
+ assert.include(names, 'McpToggles__Box__create');
+ });
+
+ test('resources disabled → empty resources/list', async () => {
+ const { resources } = await booted.client.listResources();
+ assert.equal(resources.length, 0);
+ });
+
+ test('resources/read fails when resources disabled', async () => {
+ let threw = false;
+ try {
+ await booted.client.readResource({ uri: 'agentlang://McpToggles/Box' });
+ } catch {
+ threw = true;
+ }
+ assert.isTrue(threw);
+ });
+});
+
+// ──────────────────────────────────────────────────────────────────
+// Auth gating: with config.auth.enabled, /mcp requires bearer token
+// ──────────────────────────────────────────────────────────────────
+
+describe('MCP server — auth gating', () => {
+ let http: HttpServer;
+ let baseUrl: string;
+ let savedAuthCfg: any;
+
+ beforeAll(async () => {
+ await doInternModule(
+ 'McpAuth',
+ `@public event Hello { name String }
+ workflow Hello { Hello.name }`
+ );
+
+ // Flip auth on at the global config level — this is what isAuthEnabled() reads.
+ savedAuthCfg = AppConfig?.auth;
+ setAppConfig({ ...(AppConfig as any), auth: { enabled: true } } as any);
+
+ const app = await createApp({ name: 'auth-app', version: '0.0.1' }, {
+ service: { port: 0, httpFileHandling: false },
+ auth: { enabled: true },
+ mcpServer: { enabled: true },
+ } as any);
+ http = app.listen(0);
+ const addr = http.address() as AddressInfo;
+ baseUrl = `http://127.0.0.1:${addr.port}`;
+ });
+
+ afterAll(async () => {
+ await new Promise(resolve => http.close(() => resolve()));
+ // Restore previous auth config so other test files aren't affected.
+ setAppConfig({ ...(AppConfig as any), auth: savedAuthCfg } as any);
+ });
+
+ test('POST /mcp without Authorization returns 401', async () => {
+ const res = await fetch(`${baseUrl}/mcp`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json, text/event-stream',
+ },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'initialize',
+ params: {
+ protocolVersion: '2025-03-26',
+ capabilities: {},
+ clientInfo: { name: 'curl', version: '0.0.0' },
+ },
+ }),
+ });
+ assert.equal(res.status, 401);
+ });
+
+ test('GET /mcp without Authorization returns 401', async () => {
+ const res = await fetch(`${baseUrl}/mcp`, { method: 'GET' });
+ assert.equal(res.status, 401);
+ });
+});
diff --git a/test/runtime/mcpserver-unit.test.ts b/test/runtime/mcpserver-unit.test.ts
new file mode 100644
index 00000000..34e6b9d5
--- /dev/null
+++ b/test/runtime/mcpserver-unit.test.ts
@@ -0,0 +1,450 @@
+import { assert, describe, test, beforeAll } from 'vitest';
+import { __test__ } from '../../src/runtime/mcpserver.js';
+import { doInternModule } from '../util.js';
+
+const {
+ fqToolName,
+ parseToolName,
+ metaDescription,
+ attributeToJsonSchema,
+ jsonSchemaForType,
+ formatLiteral,
+ buildAttrPattern,
+ listEventTools,
+ listEntityTools,
+ listEntityResources,
+ isExposeOn,
+ searchToolDef,
+ searchAvailableTools,
+ scoreTool,
+ SEARCH_TOOL_NAME,
+} = __test__;
+
+// ──────────────────────────────────────────────────────────────────
+// fqToolName / parseToolName round-trip
+// ──────────────────────────────────────────────────────────────────
+
+describe('fqToolName / parseToolName', () => {
+ test('builds module__entry and parses back', () => {
+ const n = fqToolName('blog', 'CreatePost');
+ assert.equal(n, 'blog__CreatePost');
+ const parsed = parseToolName(n);
+ assert.equal(parsed.moduleName, 'blog');
+ assert.equal(parsed.entryName, 'CreatePost');
+ assert.equal(parsed.suffix, undefined);
+ });
+
+ test('builds module__entry__suffix and parses back', () => {
+ const n = fqToolName('blog', 'Post', 'create');
+ assert.equal(n, 'blog__Post__create');
+ const parsed = parseToolName(n);
+ assert.equal(parsed.moduleName, 'blog');
+ assert.equal(parsed.entryName, 'Post');
+ assert.equal(parsed.suffix, 'create');
+ });
+
+ test('rejects single-segment names', () => {
+ assert.throws(() => parseToolName('orphan'));
+ });
+
+ test('multi-segment suffix is preserved', () => {
+ const parsed = parseToolName('m__E__a__b');
+ assert.equal(parsed.moduleName, 'm');
+ assert.equal(parsed.entryName, 'E');
+ assert.equal(parsed.suffix, 'a__b');
+ });
+});
+
+// ──────────────────────────────────────────────────────────────────
+// metaDescription
+// ──────────────────────────────────────────────────────────────────
+
+describe('metaDescription', () => {
+ test('returns fallback when meta missing', () => {
+ assert.equal(metaDescription(undefined, 'fb'), 'fb');
+ });
+
+ test('returns fallback when meta has no doc-like keys', () => {
+ const m = new Map([['unrelated', 'value']]);
+ assert.equal(metaDescription(m, 'fb'), 'fb');
+ });
+
+ test('prefers doc, then description, then comment', () => {
+ const a = new Map([['doc', 'D']]);
+ assert.equal(metaDescription(a, 'fb'), 'D');
+ const b = new Map([['description', 'X']]);
+ assert.equal(metaDescription(b, 'fb'), 'X');
+ const c = new Map([['comment', 'C']]);
+ assert.equal(metaDescription(c, 'fb'), 'C');
+ const both = new Map([
+ ['doc', 'D'],
+ ['description', 'X'],
+ ]);
+ assert.equal(metaDescription(both, 'fb'), 'D');
+ });
+
+ test('ignores empty/whitespace doc value', () => {
+ const m = new Map([['doc', ' ']]);
+ assert.equal(metaDescription(m, 'fb'), 'fb');
+ });
+});
+
+// ──────────────────────────────────────────────────────────────────
+// jsonSchemaForType
+// ──────────────────────────────────────────────────────────────────
+
+describe('jsonSchemaForType', () => {
+ test('textual types map to string', () => {
+ for (const t of ['String', 'Email', 'UUID', 'URL', 'Path', 'Password']) {
+ assert.deepEqual(jsonSchemaForType(t, false), { type: 'string' });
+ }
+ });
+
+ test('Date / Time / DateTime carry format', () => {
+ assert.deepEqual(jsonSchemaForType('Date', false), { type: 'string', format: 'date' });
+ assert.deepEqual(jsonSchemaForType('Time', false), { type: 'string', format: 'time' });
+ assert.deepEqual(jsonSchemaForType('DateTime', false), {
+ type: 'string',
+ format: 'date-time',
+ });
+ });
+
+ test('numeric types', () => {
+ assert.deepEqual(jsonSchemaForType('Int', false), { type: 'integer' });
+ for (const t of ['Number', 'Float', 'Decimal']) {
+ assert.deepEqual(jsonSchemaForType(t, false), { type: 'number' });
+ }
+ });
+
+ test('Boolean / Map / Any', () => {
+ assert.deepEqual(jsonSchemaForType('Boolean', false), { type: 'boolean' });
+ assert.deepEqual(jsonSchemaForType('Map', false), {
+ type: 'object',
+ additionalProperties: true,
+ });
+ assert.deepEqual(jsonSchemaForType('Any', false), {});
+ });
+
+ test('unknown type defaults to string, or object when isObject=true', () => {
+ assert.deepEqual(jsonSchemaForType('Custom', false), { type: 'string' });
+ assert.deepEqual(jsonSchemaForType('Custom', true), {
+ type: 'object',
+ additionalProperties: true,
+ });
+ });
+});
+
+// ──────────────────────────────────────────────────────────────────
+// attributeToJsonSchema
+// ──────────────────────────────────────────────────────────────────
+
+describe('attributeToJsonSchema', () => {
+ test('non-array primitive', () => {
+ assert.deepEqual(attributeToJsonSchema({ type: 'String' } as any), { type: 'string' });
+ });
+
+ test('array wraps inner schema', () => {
+ const props = new Map([['array', true]]);
+ assert.deepEqual(attributeToJsonSchema({ type: 'Int', properties: props } as any), {
+ type: 'array',
+ items: { type: 'integer' },
+ });
+ });
+
+ test('enum populates values', () => {
+ const props = new Map([['enum', new Set(['a', 'b', 'c'])]]);
+ const schema = attributeToJsonSchema({ type: 'String', properties: props } as any);
+ assert.equal(schema.type, 'string');
+ assert.deepEqual([...schema.enum].sort(), ['a', 'b', 'c']);
+ });
+
+ test('empty enum set is not emitted', () => {
+ const props = new Map([['enum', new Set()]]);
+ const schema = attributeToJsonSchema({ type: 'String', properties: props } as any);
+ assert.isUndefined(schema.enum);
+ });
+
+ test('object-marked custom type', () => {
+ const props = new Map([['object', true]]);
+ assert.deepEqual(attributeToJsonSchema({ type: 'Custom', properties: props } as any), {
+ type: 'object',
+ additionalProperties: true,
+ });
+ });
+});
+
+// ──────────────────────────────────────────────────────────────────
+// formatLiteral / buildAttrPattern
+// ──────────────────────────────────────────────────────────────────
+
+describe('formatLiteral', () => {
+ test('strings are quoted and embedded quotes escaped', () => {
+ assert.equal(formatLiteral('hi'), '"hi"');
+ assert.equal(formatLiteral('a"b'), '"a\\"b"');
+ });
+
+ test('numbers and booleans are bare', () => {
+ assert.equal(formatLiteral(42), '42');
+ assert.equal(formatLiteral(true), 'true');
+ assert.equal(formatLiteral(false), 'false');
+ });
+
+ test('null / undefined become null', () => {
+ assert.equal(formatLiteral(null), 'null');
+ assert.equal(formatLiteral(undefined), 'null');
+ });
+
+ test('arrays and objects are JSON', () => {
+ assert.equal(formatLiteral([1, 2]), '[1,2]');
+ assert.equal(formatLiteral({ a: 1 }), '{"a":1}');
+ });
+});
+
+describe('buildAttrPattern', () => {
+ test('mutation style uses no ?', () => {
+ assert.equal(buildAttrPattern({ id: 1, name: 'Alice' }, false), '{id 1, name "Alice"}');
+ });
+
+ test('query style uses ?', () => {
+ assert.equal(buildAttrPattern({ id: 1 }, true), '{id? 1}');
+ });
+
+ test('empty args yield empty braces', () => {
+ assert.equal(buildAttrPattern({}, false), '{}');
+ });
+});
+
+// ──────────────────────────────────────────────────────────────────
+// isExposeOn
+// ──────────────────────────────────────────────────────────────────
+
+describe('isExposeOn', () => {
+ test('default-on when no expose block', () => {
+ assert.isTrue(isExposeOn(undefined, 'events'));
+ assert.isTrue(isExposeOn({} as any, 'events'));
+ assert.isTrue(isExposeOn({ mcpServer: {} } as any, 'entities'));
+ });
+
+ test('respects explicit false', () => {
+ const cfg = {
+ mcpServer: { expose: { events: false, entities: true, resources: true } },
+ } as any;
+ assert.isFalse(isExposeOn(cfg, 'events'));
+ assert.isTrue(isExposeOn(cfg, 'entities'));
+ assert.isTrue(isExposeOn(cfg, 'resources'));
+ });
+});
+
+// ──────────────────────────────────────────────────────────────────
+// recordToInputSchema / listEventTools / listEntityTools / resources
+// (these need a live module, so set one up first)
+// ──────────────────────────────────────────────────────────────────
+
+describe('schema/listing helpers (live module)', () => {
+ beforeAll(async () => {
+ await doInternModule(
+ 'McpUnit',
+ `entity Widget {
+ id Int @id,
+ name String,
+ price Float,
+ active Boolean,
+ tags String @array,
+ kind String @enum("small", "large")
+ }
+
+ @public event PingWidget {
+ name String,
+ count Int
+ }
+
+ event PrivatePing {
+ name String
+ }`
+ );
+ });
+
+ test('recordToInputSchema marks all non-optional, non-default attrs as required', () => {
+ const tools = listEntityTools();
+ const create = tools.find(t => t.name === 'McpUnit__Widget__create');
+ assert.ok(create, 'expected Widget__create tool');
+ const sch = create!.inputSchema;
+ assert.equal(sch.type, 'object');
+ assert.ok(Array.isArray(sch.required));
+ for (const a of ['id', 'name', 'price', 'active', 'tags', 'kind']) {
+ assert.include(sch.required, a, `expected ${a} to be required`);
+ }
+ assert.deepEqual(sch.properties.id, { type: 'integer' });
+ assert.deepEqual(sch.properties.name, { type: 'string' });
+ assert.deepEqual(sch.properties.price, { type: 'number' });
+ assert.deepEqual(sch.properties.active, { type: 'boolean' });
+ assert.deepEqual(sch.properties.tags, { type: 'array', items: { type: 'string' } });
+ assert.equal(sch.properties.kind.type, 'string');
+ assert.deepEqual([...sch.properties.kind.enum].sort(), ['large', 'small']);
+ });
+
+ test('list tool input schema is permissive (all attrs included, no required block)', () => {
+ const tools = listEntityTools();
+ const list = tools.find(t => t.name === 'McpUnit__Widget__list');
+ assert.ok(list);
+ assert.isUndefined(list!.inputSchema.required);
+ assert.ok(list!.inputSchema.properties.name);
+ });
+
+ test('get/delete schemas require id', () => {
+ const tools = listEntityTools();
+ const g = tools.find(t => t.name === 'McpUnit__Widget__get');
+ const d = tools.find(t => t.name === 'McpUnit__Widget__delete');
+ assert.ok(g && d);
+ assert.deepEqual(g!.inputSchema.required, ['id']);
+ assert.deepEqual(d!.inputSchema.required, ['id']);
+ });
+
+ test('listEventTools includes only @public events', () => {
+ const tools = listEventTools();
+ const names = tools.map(t => t.name);
+ assert.include(names, 'McpUnit__PingWidget');
+ assert.notInclude(names, 'McpUnit__PrivatePing');
+ });
+
+ test('listEventTools input schema reflects event attributes', () => {
+ const ping = listEventTools().find(t => t.name === 'McpUnit__PingWidget')!;
+ assert.deepEqual(ping.inputSchema.properties.name, { type: 'string' });
+ assert.deepEqual(ping.inputSchema.properties.count, { type: 'integer' });
+ assert.includeMembers(ping.inputSchema.required, ['name', 'count']);
+ });
+
+ test('listEntityResources emits agentlang:// URIs', () => {
+ const res = listEntityResources();
+ const widget = res.find(r => r.name === 'McpUnit/Widget');
+ assert.ok(widget, 'expected Widget resource');
+ assert.equal(widget!.uri, 'agentlang://McpUnit/Widget');
+ });
+});
+
+// ──────────────────────────────────────────────────────────────────
+// search-tools tool
+// ──────────────────────────────────────────────────────────────────
+
+describe('search-tools tool', () => {
+ beforeAll(async () => {
+ await doInternModule(
+ 'McpSearch',
+ `entity Customer {
+ id Int @id,
+ name String,
+ email Email
+ }
+
+ @public event SendInvoice {
+ customerId Int,
+ amount Float
+ }
+
+ workflow SendInvoice {
+ SendInvoice.amount
+ }`
+ );
+ });
+
+ test('searchToolDef has the reserved name and a usable schema', () => {
+ const t = searchToolDef();
+ assert.equal(t.name, SEARCH_TOOL_NAME);
+ assert.equal(t.name, 'agentlang_search_tools');
+ assert.equal(t.inputSchema.type, 'object');
+ assert.deepEqual(t.inputSchema.required, ['query']);
+ assert.ok(t.inputSchema.properties.query);
+ assert.ok(t.inputSchema.properties.limit);
+ assert.equal(t.inputSchema.properties.kind.type, 'string');
+ assert.includeMembers(t.inputSchema.properties.kind.enum, ['any', 'event', 'entity']);
+ });
+
+ test('scoreTool weights exact entry name above substring matches', () => {
+ const exact = {
+ name: 'McpSearch__Customer__create',
+ description: 'Create a McpSearch/Customer record.',
+ inputSchema: {},
+ } as any;
+ const partial = {
+ name: 'McpSearch__SendInvoice',
+ description: 'Send an invoice to a customer.',
+ inputSchema: {},
+ } as any;
+ const exactScore = scoreTool(exact, 'entity', ['Customer']);
+ const partialScore = scoreTool(partial, 'event', ['Customer']);
+ assert.isAbove(
+ exactScore,
+ partialScore,
+ `exact entry-name match (${exactScore}) should beat substring (${partialScore})`
+ );
+ });
+
+ test('scoreTool returns 0 when no terms match', () => {
+ const t = {
+ name: 'McpSearch__Customer__create',
+ description: 'Create a record',
+ inputSchema: {},
+ } as any;
+ assert.equal(scoreTool(t, 'entity', ['nothing-matches-this-zzz']), 0);
+ });
+
+ test('searchAvailableTools finds entity CRUD by entity name', () => {
+ const { matches } = searchAvailableTools('Customer', 20, 'any', undefined);
+ assert.isAbove(matches.length, 0);
+ const top = matches[0];
+ assert.match(top.name, /McpSearch__Customer/);
+ });
+
+ test('searchAvailableTools finds events by description keyword', () => {
+ const { matches } = searchAvailableTools('invoice', 20, 'any', undefined);
+ const names = matches.map(m => m.name);
+ assert.include(names, 'McpSearch__SendInvoice');
+ });
+
+ test('searchAvailableTools respects kind=event', () => {
+ const { matches } = searchAvailableTools('Customer', 20, 'event', undefined);
+ for (const m of matches) {
+ assert.equal(m.kind, 'event', `expected only events, got ${m.name}`);
+ }
+ });
+
+ test('searchAvailableTools respects kind=entity', () => {
+ const { matches } = searchAvailableTools('McpSearch', 20, 'entity', undefined);
+ for (const m of matches) {
+ assert.equal(m.kind, 'entity', `expected only entities, got ${m.name}`);
+ }
+ });
+
+ test('searchAvailableTools respects expose toggles', () => {
+ const cfgEventsOff = {
+ mcpServer: { expose: { events: false, entities: true, resources: true } },
+ } as any;
+ const { matches } = searchAvailableTools('Customer', 20, 'any', cfgEventsOff);
+ for (const m of matches) {
+ assert.notEqual(m.kind, 'event', `events should be filtered out, got ${m.name}`);
+ }
+ });
+
+ test('searchAvailableTools clamps limit', () => {
+ const big = searchAvailableTools('Customer', 5000, 'any', undefined);
+ assert.isAtMost(big.matches.length, 100);
+ const tiny = searchAvailableTools('Customer', 0, 'any', undefined);
+ assert.isAtMost(tiny.matches.length, 1);
+ });
+
+ test('searchAvailableTools returns empty matches for empty query', () => {
+ const { matches } = searchAvailableTools('', 20, 'any', undefined);
+ assert.equal(matches.length, 0);
+ });
+
+ test('matches are sorted by score descending', () => {
+ const { matches } = searchAvailableTools('Customer create', 20, 'any', undefined);
+ for (let i = 1; i < matches.length; i++) {
+ assert.isAtLeast(
+ matches[i - 1].score,
+ matches[i].score,
+ `match ${i - 1} should score >= match ${i}`
+ );
+ }
+ });
+});