diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..26658a7 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,394 @@ +# Rocket.Chat MCP Server Generator — DSL Reference + +You generate MCP servers for Rocket.Chat APIs. Pipeline: `get_capability_guide` → `get_endpoint_schemas` → `generate`. + +**Always generate.** Never stop to ask about approach. If unclear, approximate and generate anyway. + +**Do not output a detailed plan** — proceed directly to tool calls. Use internal reasoning for architecture decisions. + +**NEVER call write_todos before generate**. After `get_endpoint_schemas`, your NEXT tool call MUST be `generate` with the complete DSL. All planning happens in your internal reasoning, not in tool calls. + +--- + +## DSL Structure + +``` +PROJECT project-name +DESCRIPTION One-line project description + +WORKFLOW workflow_name + DESCRIPTION What this workflow does + PARAM name : type : description + STEP step_id : step_type + ...fields... +``` + +Each workflow becomes one MCP tool. Each step runs in dependency order. + +--- + +## Keywords + +### Top-level + +| Keyword | Usage | +| ------------------ | ------------------------------- | +| `PROJECT name` | Project identifier (kebab-case) | +| `DESCRIPTION text` | Project or workflow description | +| `WORKFLOW name` | Start a workflow (snake_case) | + +### Workflow-level (before any STEP) + +| Keyword | Usage | +| --------------------------------- | ------------------------------ | +| `DESCRIPTION text` | What this workflow does | +| `PARAM name : type : description` | Declare a tool input parameter | +| `STEP id : type` | Start a step | + +**PARAM types**: `string`, `number`, `boolean`, `object`, `array`. Description is optional. Access via `{{params.name}}` in templates. + +### Step-level + +| Keyword | Applies to | Usage | +| ----------------------- | ----------- | ------------------------------------------------- | +| `LABEL text` | all | Human-readable step name | +| `DEPENDS ON id1 id2` | all | Execution dependencies | +| `OPERATION operationId` | api_call | Which API endpoint to call | +| `MAP path = value` | api_call | Input field (dot-paths build nested objects) | +| `FOR_EACH {{ref}}` | api_call | Iterate over an array | +| `AS varname` | api_call | Loop variable name | +| `OUTPUT_PATH field` | api_call | Extract a sub-field from the response | +| `PROMPT text` | sampling | LLM prompt | +| `SYSTEM_PROMPT text` | sampling | System message | +| `MAX_TOKENS n` | sampling | Token limit (default 1000) | +| `RESPONSE_FORMAT json` | sampling | Parse response as JSON | +| `CONTENT_TEXT text` | sampling | Multi-modal: text content | +| `CONTENT_IMAGE url` | sampling | Multi-modal: image URL | +| `EXPRESSION js` | transform | JavaScript expression (`params`/`steps` in scope) | +| `CONDITION js` | conditional | Boolean JS expression | +| `THEN step_id` | conditional | Step to run if true | +| `ELSE step_id` | conditional | Step to run if false | +| `MESSAGE text` | elicitation | Prompt shown to user | +| `SCHEMA json` | elicitation | JSON Schema for user response | +| `ON_DECLINE action` | elicitation | `abort` or `skip_remaining` | + +### MAP syntax + +Dot-paths build nested objects: + +``` +MAP message.rid = {{params.room_id}} +MAP message.msg = Hello +``` + +→ `{ message: { rid: "{{params.room_id}}", msg: "Hello" } }` + +Values auto-typed: numbers → number, `true`/`false` → boolean, `{...}`/`[...]` → parsed JSON. + +### Heredoc + +Multi-line values use `<<<` ... `>>>`: + +``` +EXPRESSION <<< + const items = steps.fetch.messages || []; + return items.map(m => ({ id: m._id, text: m.msg })) +>>> +``` + +Works with: `EXPRESSION`, `CONDITION`, `PROMPT`, `SYSTEM_PROMPT`, `CONTENT_TEXT`, `MESSAGE`, `SCHEMA`. + +**MAP does NOT support heredoc.** For complex or multi-line MAP values, use a `transform` step to build the text, then reference the result: `MAP text = {{steps.my_transform}}`. See the "Complex Message Text" recipe below. + +--- + +## Step Types + +| Type | Purpose | Key fields | +| ------------- | ------------------------------- | -------------------------------------------- | +| `api_call` | Call a Rocket.Chat API endpoint | `OPERATION`, `MAP`, `FOR_EACH`/`AS` | +| `sampling` | LLM reasoning/analysis | `PROMPT`, `SYSTEM_PROMPT`, `RESPONSE_FORMAT` | +| `elicitation` | Ask the human user a question | `MESSAGE`, `SCHEMA`, `ON_DECLINE` | +| `transform` | JavaScript data transformation | `EXPRESSION` | +| `conditional` | Branch logic | `CONDITION`, `THEN`, `ELSE` | + +--- + +## Templates + +- `{{params.name}}` — access tool input parameters +- `{{steps.step_id.field}}` — access a previous step's output +- `{{steps.step_id}}` — entire step result (auto-serialized) +- JS expressions work in templates: `{{params.count > 5 ? 'many' : 'few'}}` +- Array methods work: `{{steps.fetch.items.map(i => i.name).join(', ')}}` +- Null-coalescing: `{{steps.ask.format ?? "brief"}}` + +In `transform`/`conditional`, use bare JS — `params` and `steps` are in scope directly. +Object returns in transforms: wrap in parens — `({ key: value })`. + +--- + +## Auto-Handled (omit from DSL) + +The system automatically handles these — do NOT specify them: + +- **`dependsOn` from template refs** — if step B uses `{{steps.A.foo}}`, the dependency is auto-wired +- **`continueOnError`** — auto-set on channel creation, mute/unmute, hardcoded channels, and leaf steps +- **Ensure-channel injection** — `#channel-name` in `postMessage` auto-creates the channel first +- **`operationId` normalization** — typos, case mismatches, and separator differences are auto-corrected +- **`outputPath` inference** — if all downstream refs access the same sub-field, it's extracted automatically +- **`as` auto-set** — if `FOR_EACH` is present without `AS`, a default loop variable is generated +- **`thenStep` inference** — conditionals with a single dependent step auto-infer the branch target +- **Template normalization** — bare `steps.X.foo` auto-wrapped to `{{steps.X.foo}}`, `.result.` stripped, Handlebars converted to JS +- **`responseFormat` inference** — if the prompt asks for JSON, `responseFormat: "json"` is set automatically +- **Label generation** — missing labels are derived from the step ID + +### Common Mistakes (avoid these) + +- Use `ON_DECLINE skip_remaining` — NOT `skip` or `skip_rest`. +- "Notify admin" = `MAP channel = @admin` (DM via postMessage) — do NOT invent channel names. +- Do NOT nest steps inside other steps — every step is top-level. +- Do NOT use Handlebars (`{{#each}}`, `{{#if}}`) — use JS: `.map()`, ternary. +- Do NOT use `{{{triple braces}}}` — our template engine uses `{{double braces}}` only. The Handlebars unescaped syntax `{{{...}}}` is NOT supported. +- For complex/multi-line message text with dynamic content, use a **transform step** to build the text, then `MAP text = {{steps.my_transform}}`. +- Do NOT edit or read generated files after `generate` succeeds — output is final. +- To DM a user, use `chat_postMessage` with `MAP channel = @username` — do NOT use `chat_sendMessage` with the user's ID as rid. `sendMessage.rid` requires a room ID, not a user ID. + +--- + +## Example + +Two workflows covering every DSL pattern: a channel cleanup tool (FOR_EACH, fan-out/fan-in, transforms, sampling, elicitation with abort, conditionals with THEN/ELSE, dot-path MAPs, heredocs, ensure-channel, null-coalescing, ternary, JSON MAP values, OUTPUT_PATH) and a content review tool (multimodal vision sampling, ON_DECLINE skip_remaining, @username DMs, chat_react). + +``` +PROJECT workspace-admin +DESCRIPTION Enterprise workspace administration — channel lifecycle management and content moderation + +WORKFLOW cleanup_channels + DESCRIPTION Audit channels for inactivity, AI-rank by archival safety, confirm with user, archive dead channels, notify owners, post report + PARAM days_inactive : number : Days of inactivity to consider a channel dead + PARAM notify_owners : boolean : Whether to DM channel owners before archiving + + STEP get_channels : api_call + LABEL Fetch Active Channels + OPERATION get-api-v1-channels_list + MAP count = 50 + MAP sort = {"msgs": -1} + OUTPUT_PATH channels + + STEP get_history : api_call + LABEL Get Last Activity Per Channel + DEPENDS ON get_channels + OPERATION get-api-v1-channels_history + FOR_EACH {{steps.get_channels}} + AS ch + MAP roomId = {{ch._id}} + MAP count = 1 + + STEP get_members : api_call + LABEL Get Members Per Channel + DEPENDS ON get_channels + OPERATION get-api-v1-channels_members + FOR_EACH {{steps.get_channels}} + AS ch + MAP roomId = {{ch._id}} + MAP count = 50 + + STEP categorize : transform + LABEL Categorize Channel Health + DEPENDS ON get_channels get_history get_members + EXPRESSION <<< + const channels = steps.get_channels || []; + const cutoff = Date.now() - (params.days_inactive || 30) * 86400000; + return channels.map((ch, i) => { + const lastMsg = (steps.get_history?.[i]?.messages || [])[0]; + const members = steps.get_members?.[i]?.members || []; + const lastActive = lastMsg ? new Date(lastMsg.ts).getTime() : 0; + const owner = members.find(m => m.roles?.includes('owner')); + return ({ + name: ch.name, _id: ch._id, + isDead: lastActive < cutoff, + memberCount: members.length, + lastActive: lastMsg?.ts || 'never', + ownerUsername: owner?.username || null + }) + }).filter(c => c.isDead) + >>> + + STEP rank : sampling + LABEL AI-Rank Archive Safety + DEPENDS ON categorize + SYSTEM_PROMPT You are a workspace administrator assessing which inactive channels are safe to archive. + PROMPT <<< + These channels have had no activity for {{params.days_inactive}}+ days: + {{steps.categorize}} + + For each, assess archive safety. Channels named test-*, temp-*, poc-* are safer. + Channels with many members or descriptive project names need caution. + + Return JSON: { + "safe": [{ "name": "string", "_id": "string", "reason": "why safe" }], + "risky": [{ "name": "string", "_id": "string", "concern": "why risky" }] + } + >>> + MAX_TOKENS 2000 + + STEP has_dead : conditional + LABEL Any Dead Channels? + DEPENDS ON rank + CONDITION steps.rank.safe.length > 0 || steps.rank.risky.length > 0 + THEN confirm_archive + ELSE post_all_clear + + STEP confirm_archive : elicitation + LABEL Confirm Archival Plan + DEPENDS ON has_dead + MESSAGE <<< + Found {{steps.rank.safe.length}} safe and {{steps.rank.risky.length}} risky inactive channels: + + Safe to archive: + {{steps.rank.safe.map(c => ' ✅ #' + c.name + ' — ' + c.reason).join('\n')}} + + Risky (proceed with caution): + {{steps.rank.risky.map(c => ' ⚠️ #' + c.name + ' — ' + c.concern).join('\n')}} + >>> + SCHEMA {"type":"object","properties":{"scope":{"type":"string","enum":["safe-only","all","none"],"description":"Which channels to archive"},"notify":{"type":"boolean","description":"DM channel owners first"}},"required":["scope"]} + ON_DECLINE abort + + STEP select_targets : transform + LABEL Build Archive Target List + DEPENDS ON confirm_archive rank categorize + EXPRESSION <<< + const scope = steps.confirm_archive.scope ?? 'safe-only'; + if (scope === 'none') return []; + const selected = scope === 'all' + ? [...steps.rank.safe, ...steps.rank.risky] + : steps.rank.safe; + const catMap = new Map(steps.categorize.map(c => [c._id, c])); + return selected.map(s => ({ ...s, ownerUsername: catMap.get(s._id)?.ownerUsername })) + >>> + + STEP post_notice : api_call + LABEL Post Archive Notice + DEPENDS ON select_targets + OPERATION post-api-v1-chat_sendMessage + FOR_EACH {{steps.select_targets}} + AS target + MAP message.rid = {{target._id}} + MAP message.msg = 📦 This channel is being archived due to {{params.days_inactive}}+ days of inactivity. Contact a workspace admin to restore it. + + STEP archive_channels : api_call + LABEL Archive Channels + DEPENDS ON post_notice + OPERATION post-api-v1-channels_archive + FOR_EACH {{steps.select_targets}} + AS target + MAP roomId = {{target._id}} + + STEP post_report : api_call + LABEL Post Audit Summary + DEPENDS ON archive_channels + OPERATION post-api-v1-chat_postMessage + MAP channel = #workspace-admin + MAP text = 📊 Cleanup complete: archived {{steps.select_targets.length}} channels (scope: {{steps.confirm_archive.scope ?? "safe-only"}}). Owners {{params.notify_owners ? "were notified" : "were not notified"}}. + + STEP post_all_clear : api_call + LABEL Report All Clear + DEPENDS ON has_dead + OPERATION post-api-v1-chat_postMessage + MAP channel = #workspace-admin + MAP text = ✅ No channels inactive for {{params.days_inactive}}+ days. + +WORKFLOW review_flagged_content + DESCRIPTION Analyze a flagged image for policy violations using AI vision, take action after human review + PARAM message_id : string : ID of the flagged message + PARAM image_url : string : URL of the image to review + PARAM room_id : string : Room where the image was posted + PARAM poster : string : Username who posted the image + + STEP analyze : sampling + LABEL AI Vision Analysis + CONTENT_TEXT Analyze this image for content policy violations (nudity, violence, hate symbols, spam). Return JSON: { "flagged": true/false, "category": "safe"|"nudity"|"violence"|"hate"|"spam", "confidence": 0.0-1.0, "reason": "explanation" } + CONTENT_IMAGE {{params.image_url}} + MAX_TOKENS 500 + + STEP is_flagged : conditional + LABEL Policy Violation Detected? + DEPENDS ON analyze + CONDITION steps.analyze.flagged === true && steps.analyze.confidence > 0.8 + THEN confirm_action + ELSE mark_safe + + STEP confirm_action : elicitation + LABEL Confirm Moderation Action + DEPENDS ON is_flagged + MESSAGE Image from @{{params.poster}} flagged as {{steps.analyze.category}} ({{steps.analyze.confidence > 0.9 ? 'high' : 'moderate'}} confidence): {{steps.analyze.reason}}. Delete the message? + SCHEMA {"type":"object","properties":{"delete":{"type":"boolean"}},"required":["delete"]} + ON_DECLINE skip_remaining + + STEP delete_msg : api_call + LABEL Delete Flagged Message + DEPENDS ON confirm_action + OPERATION post-api-v1-chat_delete + MAP roomId = {{params.room_id}} + MAP msgId = {{params.message_id}} + + STEP dm_poster : api_call + LABEL Notify Poster + DEPENDS ON delete_msg + OPERATION post-api-v1-chat_postMessage + MAP channel = @{{params.poster}} + MAP text = Your message was removed for a policy violation ({{steps.analyze.category}}). Please review the content guidelines. + + STEP log_action : api_call + LABEL Log to Moderation Channel + DEPENDS ON delete_msg + OPERATION post-api-v1-chat_postMessage + MAP channel = #moderation-log + MAP text = 🚫 Removed image from @{{params.poster}} — {{steps.analyze.category}} ({{steps.analyze.confidence}}). Reason: {{steps.analyze.reason}} + + STEP mark_safe : api_call + LABEL Mark as Reviewed + DEPENDS ON is_flagged + OPERATION post-api-v1-chat_react + MAP messageId = {{params.message_id}} + MAP emoji = white_check_mark +``` + +--- + +## Recipes + +### Complex Message Text + +When a `MAP` value needs formatting, iteration, or conditional logic over step results, **always** use a `transform` step to build the text: + +``` +STEP build_report : transform + DEPENDS ON fetch_data categorize + EXPRESSION <<< + const items = steps.categorize || []; + const lines = items.map(c => `- #${c.name}: ${c.status}`).join('\n'); + return `*Report:*\n${lines || '_No items._'}` + >>> + +STEP post_report : api_call + DEPENDS ON build_report + OPERATION post-api-v1-chat_postMessage + MAP channel = #reports + MAP text = {{steps.build_report}} +``` + +**Do NOT** put complex logic directly in MAP: +``` +# ❌ WRONG — MAP does not support heredoc or complex expressions +MAP text = <<< + *Report:* + {{#each steps.items}} + - {{this.name}} + {{/each}} +>>> + +# ✅ CORRECT — transform builds text, MAP references it +MAP text = {{steps.build_report}} +``` diff --git a/commands/generator/generate.toml b/commands/generator/generate.toml new file mode 100644 index 0000000..1f79bed --- /dev/null +++ b/commands/generator/generate.toml @@ -0,0 +1,16 @@ +description = "Generate a minimal MCP server for selected Rocket.Chat APIs" +prompt = """ +User's request: {{args}} + +If the request is completely empty or has zero actionable detail (e.g. "make something"), ask what the MCP server should do. + +Otherwise — even if the request is complex — ALWAYS run the full pipeline without stopping to ask: + +1. `get_capability_guide` — match needs to operationIds. +2. `get_endpoint_schemas` — get exact field names for inputMapping. +3. `generate` — one call with the complete DSL. If it fails, retry the same payload. + +Never stop to ask about implementation approach, limitations, or simplifications. If something can't be done exactly, approximate and generate anyway. + +After `generate` succeeds, STOP. DO NOT read, edit, grep, or "fix" any files inside the generated project — even if the output mentions warnings, notes, or suggestions. Composer Notes are informational; they are already auto-resolved in the generated code. Confirm success and stop. +""" diff --git a/gemini-extension.json b/gemini-extension.json new file mode 100644 index 0000000..c373a39 --- /dev/null +++ b/gemini-extension.json @@ -0,0 +1,14 @@ +{ + "name": "mcp-server-generator", + "version": "0.1.0", + "owner": "sezallagwal", + "description": "Generate minimal MCP servers for Rocket.Chat APIs from a workflow DSL", + "contextFileName": "GEMINI.md", + "mcpServers": { + "mcp-server-generator": { + "command": "node", + "args": ["--import", "tsx", "${extensionPath}${/}src${/}index.ts"], + "cwd": "${extensionPath}" + } + } +} diff --git a/package.json b/package.json index 2186238..fc7ad8b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "main": "dist/index.js", "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "tsc -p tsconfig.build.json && node scripts/copy-engine-sources.mjs", "check": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build", "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "dev": "tsx src/index.ts", diff --git a/scripts/copy-engine-sources.mjs b/scripts/copy-engine-sources.mjs new file mode 100644 index 0000000..260e57b --- /dev/null +++ b/scripts/copy-engine-sources.mjs @@ -0,0 +1,33 @@ +/** + * Post-build step: copy the workflow engine `.ts` sources next to the compiled + * output in `dist/workflow/`. + * + * The generator vendors the engine into every generated project by reading its + * source files at runtime (see `src/generator/engine-bundle.ts`). `tsc` only + * emits `.js`, so without this copy a built/published `dist` package would have + * no engine sources to read. Copying the sources keeps the built package + * self-contained and identical in behavior to running from source. + */ +import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const srcWorkflow = join(repoRoot, "src", "workflow"); +const distWorkflow = join(repoRoot, "dist", "workflow"); + +if (!existsSync(srcWorkflow)) { + console.error(`copy-engine-sources: missing source directory ${srcWorkflow}`); + process.exit(1); +} + +mkdirSync(distWorkflow, { recursive: true }); + +const sources = readdirSync(srcWorkflow).filter((name) => name.endsWith(".ts")); +for (const name of sources) { + cpSync(join(srcWorkflow, name), join(distWorkflow, name)); +} + +console.error( + `copy-engine-sources: copied ${sources.length} engine source file(s) to dist/workflow`, +); diff --git a/src/generator/codegen.ts b/src/generator/codegen.ts new file mode 100644 index 0000000..b1f3898 --- /dev/null +++ b/src/generator/codegen.ts @@ -0,0 +1,197 @@ +import type { JSONSchema7 } from "json-schema"; +import type { WorkflowDefinition } from "../workflow/types.js"; +import type { GeneratorEndpoint } from "./types.js"; +import { escapeBlockComment } from "./escape.js"; + +/** Indent a multi-line JSON blob so it nests cleanly inside generated source. */ +function indentJson(value: unknown, indent = "const "): string { + void indent; + return JSON.stringify(value, null, 2) + .split("\n") + .map((line, i) => (i === 0 ? line : " " + line)) + .join("\n"); +} + +/** Map a JSON-schema param entry to a Zod expression usable under Zod 3 and 4. */ +function zodForParam(schema: JSONSchema7 | undefined): string { + const type = + typeof schema?.type === "string" ? schema.type.toLowerCase() : ""; + switch (type) { + case "string": + return "z.string()"; + case "number": + case "integer": + return "z.number()"; + case "boolean": + return "z.boolean()"; + case "array": + return "z.array(z.any())"; + case "object": + return "z.record(z.string(), z.any())"; + default: + return "z.any()"; + } +} + +function zodShape(params: JSONSchema7): string { + const props = + (params.properties as Record | undefined) ?? {}; + const required = new Set( + Array.isArray(params.required) ? params.required : [], + ); + const entries = Object.entries(props).map(([key, schema]) => { + const base = zodForParam(schema); + const desc = schema?.description + ? `.describe(${JSON.stringify(schema.description)})` + : ""; + const optional = required.has(key) ? "" : ".optional()"; + return ` ${JSON.stringify(key)}: ${base}${desc}${optional}`; + }); + return entries.length > 0 ? `{\n${entries.join(",\n")}\n }` : "{}"; +} + +/** Generate `src/tools/.ts` — the workflow embedded as data + handler. */ +export function generateToolFile(workflow: WorkflowDefinition): string { + return `/** + * Workflow tool: ${escapeBlockComment(workflow.name)} + * ${escapeBlockComment(workflow.description)} + * + * Steps: ${escapeBlockComment(workflow.steps.map((s) => s.id).join(" -> "))} + * Generated by mcp-server-generator. + */ +import { runWorkflow } from "../engine/index.js"; +import type { WorkflowDefinition } from "../engine/types.js"; +import type { + EndpointInfo, + WorkflowClient, + WorkflowServer, +} from "../engine/executor.js"; + +const workflow: WorkflowDefinition = ${indentJson(workflow)}; + +export const tool = { + name: ${JSON.stringify(workflow.name)}, + description: ${JSON.stringify(workflow.description)}, +}; + +export interface ToolContext { + client: WorkflowClient; + server: WorkflowServer; + endpoints: Record; +} + +export function createHandler(ctx: ToolContext) { + return async (args: Record) => { + const result = await runWorkflow(workflow, args, ctx); + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + ...(result.status === "error" ? { isError: true } : {}), + }; + }; +} +`; +} + +/** Generate `src/endpoints.ts` — the operationId -> { method, path } registry. */ +export function generateEndpointMap(endpoints: GeneratorEndpoint[]): string { + const entries = endpoints + .map( + (ep) => + ` ${JSON.stringify(ep.operationId)}: { method: ${JSON.stringify( + ep.method.toUpperCase(), + )}, path: ${JSON.stringify(ep.path)} },`, + ) + .join("\n"); + return `/** + * Endpoint registry — operationId -> HTTP method + path. + * Generated by mcp-server-generator. + */ +export const endpointMap: Record = { +${entries} +}; +`; +} + +/** Generate `src/server.ts` — the entry point wiring tools to stdio transport. */ +export function generateServerEntry( + serverName: string, + workflows: WorkflowDefinition[], + moduleNames: string[], +): string { + const toolImports = workflows + .map( + (_w, i) => + `import { tool as wfTool${i}, createHandler as createHandler${i} } from "./tools/${moduleNames[i]}.js";`, + ) + .join("\n"); + + const registrations = workflows + .map( + (w, i) => + ` server.registerTool(\n` + + ` wfTool${i}.name,\n` + + ` { description: wfTool${i}.description, inputSchema: ${zodShape(w.params)} },\n` + + ` createHandler${i}({ client, server: workflowServer, endpoints: endpointMap }) as never,\n` + + ` );`, + ) + .join("\n"); + + return `#!/usr/bin/env node +/** + * ${escapeBlockComment(serverName)} — MCP server + * Generated by mcp-server-generator. ${workflows.length} workflow tool(s). + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { endpointMap } from "./endpoints.js"; +import { client, initAuth } from "./rc-client.js"; +import type { WorkflowServer } from "./engine/executor.js"; +${toolImports} + +/** + * Adapter exposing the connected MCP client's sampling and elicitation to the + * workflow engine. createMessage asks the client's LLM; elicitInput asks the user. + */ +function makeWorkflowServer(mcp: McpServer): WorkflowServer { + return { + async createMessage(message) { + const res = await mcp.server.createMessage({ + messages: [ + { role: "user", content: { type: "text", text: message.prompt } }, + ], + ...(message.systemPrompt ? { systemPrompt: message.systemPrompt } : {}), + maxTokens: message.maxTokens ?? 1024, + }); + const text = + res.content && res.content.type === "text" ? res.content.text : ""; + return { content: { type: "text", text } }; + }, + async elicitInput(params) { + const res = await mcp.server.elicitInput({ + message: params.message, + requestedSchema: params.requestedSchema as never, + }); + return { action: res.action, content: res.content }; + }, + }; +} + +const server = new McpServer({ name: ${JSON.stringify(serverName)}, version: "1.0.0" }); +const workflowServer = makeWorkflowServer(server); + +${registrations} + +async function main() { + await initAuth(); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error(${JSON.stringify(serverName)} + " running — ${workflows.length} workflow tool(s)"); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); +`; +} diff --git a/src/generator/dsl-mapping.ts b/src/generator/dsl-mapping.ts new file mode 100644 index 0000000..58b453d --- /dev/null +++ b/src/generator/dsl-mapping.ts @@ -0,0 +1,73 @@ +import type { JSONSchema7 } from "json-schema"; +import type { DslStep, DslWorkflow } from "../dsl/types.js"; +import type { ComposeStepInput, ComposeWorkflowInput } from "../composer/types.js"; +import type { StepConfig } from "../workflow/types.js"; + +/** + * Bridge the flat DSL step shape produced by the parser into the discriminated + * `StepConfig` union the composer consumes. This is the seam between the DSL + * front end and the workflow model. + */ +function stepConfig(step: DslStep): StepConfig { + switch (step.type) { + case "api_call": + return { + type: "api_call", + operationId: step.operationId ?? "", + inputMapping: step.inputMapping ?? {}, + ...(step.outputPath !== undefined ? { outputPath: step.outputPath } : {}), + ...(step.forEach !== undefined ? { forEach: step.forEach } : {}), + ...(step.as !== undefined ? { as: step.as } : {}), + }; + case "sampling": + return { + type: "sampling", + prompt: step.prompt ?? "", + ...(step.content !== undefined ? { content: step.content } : {}), + ...(step.systemPrompt !== undefined ? { systemPrompt: step.systemPrompt } : {}), + ...(step.maxTokens !== undefined ? { maxTokens: step.maxTokens } : {}), + ...(step.responseFormat !== undefined + ? { responseFormat: step.responseFormat as "text" | "json" } + : {}), + }; + case "elicitation": + return { + type: "elicitation", + message: step.message ?? "", + requestedSchema: (step.requestedSchema ?? { type: "object" }) as JSONSchema7, + ...(step.onDecline !== undefined ? { onDecline: step.onDecline } : {}), + }; + case "transform": + return { type: "transform", expression: step.expression ?? "" }; + case "conditional": + return { + type: "conditional", + condition: step.condition ?? "", + thenStep: step.thenStep ?? "", + ...(step.elseStep !== undefined ? { elseStep: step.elseStep } : {}), + }; + default: + throw new Error(`Unknown DSL step type "${step.type}" in step "${step.id}".`); + } +} + +function toStepInput(step: DslStep): ComposeStepInput { + return { + id: step.id, + label: step.label ?? step.id, + config: stepConfig(step), + ...(step.dependsOn && step.dependsOn.length > 0 ? { dependsOn: step.dependsOn } : {}), + }; +} + +/** Convert a parsed DSL workflow into the composer's input shape. */ +export function dslWorkflowToComposeInput( + workflow: DslWorkflow, +): ComposeWorkflowInput { + return { + name: workflow.name, + description: workflow.description, + params: (workflow.params ?? { type: "object", properties: {} }) as JSONSchema7, + steps: workflow.steps.map(toStepInput), + }; +} diff --git a/src/generator/engine-bundle.ts b/src/generator/engine-bundle.ts new file mode 100644 index 0000000..b0d498f --- /dev/null +++ b/src/generator/engine-bundle.ts @@ -0,0 +1,81 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { GeneratedFile } from "./types.js"; + +/** + * Runtime engine modules vendored verbatim into every generated project. These + * are the actual source files from `src/workflow/` — copied, never re-authored — + * so a generated server runs the exact engine this package ships and tests. + * + * `index.ts` and `types.ts` from the workflow folder are intentionally excluded: + * the workflow barrel re-exports the composer (compile-time only), so the + * generated project gets a slim engine barrel instead (see ENGINE_INDEX). + */ +export const ENGINE_MODULES = [ + "types.ts", + "expression-security.ts", + "templates.ts", + "api-call.ts", + "sampling.ts", + "executor.ts", +] as const; + +const ENGINE_INDEX = `export * from "./types.js"; +export * from "./expression-security.js"; +export * from "./templates.js"; +export * from "./api-call.js"; +export * from "./sampling.js"; +export * from "./executor.js"; +`; + +/** Walk up from `startDir` until a directory containing `package.json` is found. */ +function findPackageRoot(startDir: string): string { + let dir = startDir; + for (;;) { + if (existsSync(join(dir, "package.json"))) return dir; + const parent = dirname(dir); + if (parent === dir) return startDir; + dir = parent; + } +} + +/** + * Locate the directory holding the engine `.ts` sources, tolerating every run + * layout: + * - from source via tsx: `src/generator` -> `src/workflow`; + * - from a built package: `dist/generator` -> `dist/workflow` (the build step + * copies the `.ts` sources next to the compiled output); + * - as a last resort, the always-present `src/workflow` under the package + * root, so a bare `tsc` build (without the copy step) still works. + * + * The first candidate that actually contains the engine sources wins. + */ +export function workflowDir(): string { + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(here, "..", "workflow"), + join(findPackageRoot(here), "src", "workflow"), + ]; + const probe = ENGINE_MODULES[0]; + for (const dir of candidates) { + if (existsSync(join(dir, probe))) return dir; + } + // Nothing found — return the primary candidate so the caller surfaces a + // clear ENOENT naming the exact missing engine source. + return candidates[0]; +} + +/** + * Read the engine source files and return them as generated files under + * `src/engine/`, plus a slim barrel. + */ +export function bundleEngine(): GeneratedFile[] { + const dir = workflowDir(); + const files: GeneratedFile[] = ENGINE_MODULES.map((name) => ({ + path: `src/engine/${name}`, + content: readFileSync(join(dir, name), "utf8"), + })); + files.push({ path: "src/engine/index.ts", content: ENGINE_INDEX }); + return files; +} diff --git a/src/generator/escape.ts b/src/generator/escape.ts new file mode 100644 index 0000000..536209c --- /dev/null +++ b/src/generator/escape.ts @@ -0,0 +1,33 @@ +/** + * Neutralize DSL-controlled text before it is embedded into generated source. + * + * Generated files interpolate workflow names, descriptions, and step ids that + * originate from user-authored DSL. Left raw, a value such as a DESCRIPTION + * containing a comment terminator could close a block comment and turn the + * remainder of the file into executable generated code. These helpers make + * such text inert for the context it lands in. + */ + +/** + * Make `text` safe to embed inside a block comment: break any comment-closing + * sequence and collapse newlines so the text stays on its comment line. The + * transformation only affects otherwise-inert comment text. + */ +export function escapeBlockComment(text: string): string { + return String(text ?? "") + .replace(/\*\//g, "*\\/") + .replace(/[\r\n]+/g, " ") + .trim(); +} + +/** + * Make `text` safe inside a Markdown table cell: escape the pipe delimiter and + * drop newlines so a user-supplied value cannot break the table layout. + */ +export function escapeMarkdownCell(text: string): string { + return String(text ?? "") + .replace(/\\/g, "\\\\") + .replace(/\|/g, "\\|") + .replace(/[\r\n]+/g, " ") + .trim(); +} diff --git a/src/generator/index.ts b/src/generator/index.ts new file mode 100644 index 0000000..70abc30 --- /dev/null +++ b/src/generator/index.ts @@ -0,0 +1,7 @@ +export * from "./types.js"; +export * from "./dsl-mapping.js"; +export * from "./engine-bundle.js"; +export * from "./codegen.js"; +export * from "./scaffold.js"; +export * from "./project.js"; +export * from "./pipeline.js"; diff --git a/src/generator/pipeline.ts b/src/generator/pipeline.ts new file mode 100644 index 0000000..6eae83d --- /dev/null +++ b/src/generator/pipeline.ts @@ -0,0 +1,59 @@ +import { composeWorkflowDefinition } from "../composer/index.js"; +import type { ComposerWarning } from "../composer/types.js"; +import { parseDsl } from "../dsl/index.js"; +import type { WorkflowDefinition } from "../workflow/types.js"; +import { dslWorkflowToComposeInput } from "./dsl-mapping.js"; +import { generateProject } from "./project.js"; +import type { GeneratorEndpoint, GenerateProjectResult } from "./types.js"; + +export interface ComposeDslResult { + projectName: string; + description: string; + workflows: WorkflowDefinition[]; + warnings: ComposerWarning[]; +} + +/** Parse a DSL document and compose every workflow it declares. */ +export function composeDsl(dsl: string): ComposeDslResult { + const parsed = parseDsl(dsl); + const workflows: WorkflowDefinition[] = []; + const warnings: ComposerWarning[] = []; + + for (const wf of parsed.workflows) { + const result = composeWorkflowDefinition(dslWorkflowToComposeInput(wf)); + workflows.push(result.workflow); + warnings.push(...result.warnings); + } + + return { + projectName: parsed.projectName, + description: parsed.description, + workflows, + warnings, + }; +} + +export interface GenerateFromDslOptions { + /** Endpoint registry for every operationId the workflows call. */ + endpoints: GeneratorEndpoint[]; + /** Override the server name (defaults to the DSL PROJECT name). */ + serverName?: string; +} + +export interface GenerateFromDslResult extends GenerateProjectResult { + warnings: ComposerWarning[]; +} + +/** Full pipeline: DSL text -> parsed -> composed -> generated project files. */ +export function generateFromDsl( + dsl: string, + options: GenerateFromDslOptions, +): GenerateFromDslResult { + const composed = composeDsl(dsl); + const result = generateProject({ + serverName: options.serverName ?? composed.projectName, + workflows: composed.workflows, + endpoints: options.endpoints, + }); + return { ...result, warnings: composed.warnings }; +} diff --git a/src/generator/project.ts b/src/generator/project.ts new file mode 100644 index 0000000..68e5be0 --- /dev/null +++ b/src/generator/project.ts @@ -0,0 +1,130 @@ +import { bundleEngine } from "./engine-bundle.js"; +import { + generateEndpointMap, + generateServerEntry, + generateToolFile, +} from "./codegen.js"; +import { + generateEnvExample, + generateGitignore, + generatePackageJson, + generateReadme, + generateRcClient, + generateTsConfig, +} from "./scaffold.js"; +import type { + GeneratedFile, + GenerateProjectInput, + GenerateProjectResult, +} from "./types.js"; +import type { WorkflowDefinition } from "../workflow/types.js"; + +/** Normalize an arbitrary name into a valid lowercase package/server name. */ +export function sanitizeServerName(name: string): string { + const cleaned = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + const safe = /^[a-z]/.test(cleaned) ? cleaned : `mcp_${cleaned}`; + return safe || "mcp_server"; +} + +/** + * Reduce a workflow name to a safe module basename for `src/tools/.ts`. + * The result only contains `[A-Za-z0-9_]` and always starts with a letter or + * underscore, so it is safe both as a filename and when embedded in an import + * specifier — a workflow name is otherwise unconstrained DSL text. + */ +export function sanitizeModuleName(name: string): string { + const cleaned = name + .trim() + .replace(/[^A-Za-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, ""); + if (cleaned === "") return "tool"; + return /^[A-Za-z_]/.test(cleaned) ? cleaned : `tool_${cleaned}`; +} + +/** + * Assign a unique module basename to each workflow (aligned by index), + * disambiguating collisions produced by sanitization with a numeric suffix. + */ +export function assignModuleNames(workflows: WorkflowDefinition[]): string[] { + const used = new Set(); + return workflows.map((workflow) => { + const base = sanitizeModuleName(workflow.name); + let candidate = base; + let counter = 2; + while (used.has(candidate)) { + candidate = `${base}_${counter++}`; + } + used.add(candidate); + return candidate; + }); +} + +/** Assemble the full set of files for a generated MCP server project. */ +export function generateProject( + input: GenerateProjectInput, +): GenerateProjectResult { + const serverName = sanitizeServerName(input.serverName); + const { workflows, endpoints } = input; + + if (workflows.length === 0) { + throw new Error("Cannot generate a project with no workflows."); + } + + const usesSampling = workflows.some((w) => w.usesSampling); + const usesElicitation = workflows.some((w) => w.usesElicitation); + + const files: GeneratedFile[] = []; + + // Vendored engine. + files.push(...bundleEngine()); + + // One tool file per workflow, keyed by a safe, unique module basename so an + // arbitrary workflow name can never break the filename or its import. + const moduleNames = assignModuleNames(workflows); + workflows.forEach((workflow, i) => { + files.push({ + path: `src/tools/${moduleNames[i]}.ts`, + content: generateToolFile(workflow), + }); + }); + + // Wiring + scaffolding. + files.push({ + path: "src/endpoints.ts", + content: generateEndpointMap(endpoints), + }); + files.push({ path: "src/rc-client.ts", content: generateRcClient() }); + files.push({ + path: "src/server.ts", + content: generateServerEntry(serverName, workflows, moduleNames), + }); + files.push({ + path: "package.json", + content: generatePackageJson(serverName), + }); + files.push({ path: "tsconfig.json", content: generateTsConfig() }); + files.push({ path: ".gitignore", content: generateGitignore() }); + files.push({ + path: ".env.example", + content: generateEnvExample(usesSampling), + }); + files.push({ + path: "README.md", + content: generateReadme(serverName, workflows, endpoints), + }); + + return { + files, + summary: { + serverName, + workflowCount: workflows.length, + endpointCount: endpoints.length, + usesSampling, + usesElicitation, + }, + }; +} diff --git a/src/generator/scaffold.ts b/src/generator/scaffold.ts new file mode 100644 index 0000000..0b87f68 --- /dev/null +++ b/src/generator/scaffold.ts @@ -0,0 +1,280 @@ +import type { WorkflowDefinition } from "../workflow/types.js"; +import type { GeneratorEndpoint } from "./types.js"; +import { escapeMarkdownCell } from "./escape.js"; + +/** Generate `src/rc-client.ts` — a Rocket.Chat HTTP client implementing WorkflowClient. */ +export function generateRcClient(): string { + return `/** + * Rocket.Chat HTTP client. Implements the engine's WorkflowClient contract: + * request(method, path, { auth, body }) -> { ok, status, data } + * + * Auth priority: + * 1. ROCKETCHAT_AUTH_TOKEN + ROCKETCHAT_USER_ID — used directly + * 2. ROCKETCHAT_USER + ROCKETCHAT_PASSWORD — auto-login at startup + * 3. neither — unconfigured (auth'd calls will fail) + * Generated by mcp-server-generator. + */ +const config = { + baseUrl: process.env.ROCKETCHAT_URL || "http://localhost:3000", + authToken: process.env.ROCKETCHAT_AUTH_TOKEN || "", + userId: process.env.ROCKETCHAT_USER_ID || "", +}; + +const REQUEST_TIMEOUT_MS = Number(process.env.ROCKETCHAT_REQUEST_TIMEOUT_MS) || 30000; + +export interface ApiResponse { + ok: boolean; + status: number; + data: unknown; +} + +async function parseBody(res: Response): Promise { + const raw = await res.text(); + if (raw.length === 0) return null; + const contentType = res.headers.get("content-type") || ""; + if (contentType.includes("json")) { + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + return raw; +} + +let initialized = false; + +export async function initAuth(): Promise { + if (initialized) return; + initialized = true; + + if (config.authToken && config.userId) { + console.error("Using pre-existing Rocket.Chat auth tokens."); + return; + } + + const user = process.env.ROCKETCHAT_USER || ""; + const password = process.env.ROCKETCHAT_PASSWORD || ""; + if (!user || !password) { + console.error("No Rocket.Chat credentials set — starting unconfigured."); + return; + } + + try { + const res = await fetch(config.baseUrl + "/api/v1/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user, password }), + }); + const data = (await parseBody(res)) as { + data?: { authToken?: string; userId?: string }; + }; + if (res.ok && data?.data?.authToken && data.data.userId) { + config.authToken = data.data.authToken; + config.userId = data.data.userId; + console.error("Authenticated as " + user); + } else { + console.error("Rocket.Chat login failed (HTTP " + res.status + ")."); + } + } catch (err) { + console.error("Could not reach Rocket.Chat for login: " + String(err)); + } +} + +function buildUrl(path: string): string { + return path.startsWith("http") ? path : config.baseUrl + path; +} + +class RocketChatClient { + async request( + method: string, + path: string, + options: { auth?: boolean; body?: Record } = {}, + ): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (options.auth) { + headers["X-Auth-Token"] = config.authToken; + headers["X-User-Id"] = config.userId; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const res = await fetch(buildUrl(path), { + method, + headers, + ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), + signal: controller.signal, + }); + const data = await parseBody(res); + return { ok: res.ok, status: res.status, data }; + } catch (err) { + return { + ok: false, + status: 0, + data: err instanceof Error ? err.message : String(err), + }; + } finally { + clearTimeout(timer); + } + } +} + +export const client = new RocketChatClient(); +`; +} + +export function generatePackageJson(serverName: string): string { + const pkg = { + name: serverName, + version: "1.0.0", + type: "module", + engines: { node: ">=20" }, + scripts: { + start: "node --env-file-if-exists=.env --import tsx src/server.ts", + build: "tsc", + "start:built": "node dist/server.js", + }, + dependencies: { + "@modelcontextprotocol/sdk": "^1.27.1", + acorn: "^8.17.0", + zod: "^4.3.6", + }, + devDependencies: { + "@types/json-schema": "^7.0.15", + "@types/node": "^22.0.0", + tsx: "^4.21.0", + typescript: "^5.9.3", + }, + }; + return JSON.stringify(pkg, null, 2) + "\n"; +} + +export function generateTsConfig(): string { + const config = { + compilerOptions: { + target: "ES2022", + module: "Node16", + moduleResolution: "Node16", + outDir: "./dist", + rootDir: "./src", + strict: true, + esModuleInterop: true, + skipLibCheck: true, + resolveJsonModule: true, + types: ["node"], + }, + include: ["src"], + }; + return JSON.stringify(config, null, 2) + "\n"; +} + +export function generateGitignore(): string { + return [ + "node_modules/", + "dist/", + ".env", + ".env.*", + "!.env.example", + "*.log", + ".DS_Store", + "", + ].join("\n"); +} + +export function generateEnvExample(usesSampling: boolean): string { + let env = `# Rocket.Chat connection +ROCKETCHAT_URL=http://localhost:3000 + +# Credentials — the server acts as this user +ROCKETCHAT_USER=your-username +ROCKETCHAT_PASSWORD=your-password + +# Or token auth (takes priority over user/password) +# ROCKETCHAT_AUTH_TOKEN= +# ROCKETCHAT_USER_ID= +`; + if (usesSampling) { + env += ` +# Sampling steps request the MCP client's LLM, so no extra key is required when +# your MCP client supports sampling. +`; + } + return env; +} + +export function generateReadme( + serverName: string, + workflows: WorkflowDefinition[], + endpoints: GeneratorEndpoint[], +): string { + const date = new Date().toISOString().split("T")[0]; + const workflowRows = workflows + .map((w) => { + const features: string[] = []; + if (w.usesSampling) features.push("AI"); + if (w.usesElicitation) features.push("Human-in-loop"); + const badge = features.length > 0 ? features.join(", ") : "Automation"; + return `| \`${escapeMarkdownCell(w.name)}\` | ${escapeMarkdownCell( + w.description, + )} | ${w.steps.length} | ${badge} |`; + }) + .join("\n"); + const endpointRows = endpoints + .map( + (ep) => + `| \`${escapeMarkdownCell(ep.operationId)}\` | \`${escapeMarkdownCell( + ep.method.toUpperCase(), + )}\` | \`${escapeMarkdownCell(ep.path)}\` |`, + ) + .join("\n"); + + return `# ${serverName} + +A workflow-based [MCP](https://modelcontextprotocol.io/) server for Rocket.Chat, +generated by **mcp-server-generator**. Each tool runs a multi-step workflow that +chains API calls, AI sampling, and user confirmation behind one call. + +> Generated on ${date} + +## Quick start + +\`\`\`bash +npm install +cp .env.example .env # fill in your Rocket.Chat credentials +npm start +\`\`\` + +## Workflow tools + +| Tool | Description | Steps | Features | +|------|-------------|-------|----------| +${workflowRows} + +## API endpoints used + +| operationId | Method | Path | +|-------------|--------|------| +${endpointRows} + +## Project layout + +\`\`\` +${serverName}/ +├── src/ +│ ├── server.ts # entry point — wires tools to stdio transport +│ ├── rc-client.ts # Rocket.Chat HTTP client +│ ├── endpoints.ts # operationId -> method + path +│ ├── engine/ # vendored workflow engine +│ └── tools/ # one file per workflow +├── .env.example +├── package.json +├── tsconfig.json +└── README.md +\`\`\` + +--- + +*Generated by mcp-server-generator.* +`; +} diff --git a/src/generator/types.ts b/src/generator/types.ts new file mode 100644 index 0000000..7dfbc1d --- /dev/null +++ b/src/generator/types.ts @@ -0,0 +1,34 @@ +import type { WorkflowDefinition } from "../workflow/types.js"; + +/** A single file in a generated project, with a project-relative POSIX path. */ +export interface GeneratedFile { + path: string; + content: string; +} + +/** Minimal endpoint record the generator needs to build the endpoint map. */ +export interface GeneratorEndpoint { + operationId: string; + method: string; + path: string; + summary?: string; +} + +export interface GenerateProjectInput { + /** Lowercase project/server name (e.g. "rocketchat_ops"). */ + serverName: string; + workflows: WorkflowDefinition[]; + endpoints: GeneratorEndpoint[]; +} + +/** Result of generating a project: the file set plus a short summary. */ +export interface GenerateProjectResult { + files: GeneratedFile[]; + summary: { + serverName: string; + workflowCount: number; + endpointCount: number; + usesSampling: boolean; + usesElicitation: boolean; + }; +} diff --git a/src/server.ts b/src/server.ts index ab0182e..158517d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,10 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { SpecParser } from "./parser/index.js"; import type { SpecParserInterface } from "./parser/index.js"; import { handleGetCapabilityGuide } from "./tools/get-capability-guide.js"; import { handleGetEndpointSchemas } from "./tools/get-endpoint-schemas.js"; +import { handleGenerate } from "./tools/generate.js"; export function createMcpServer(parser?: SpecParserInterface): { server: McpServer; @@ -50,5 +51,22 @@ export function createMcpServer(parser?: SpecParserInterface): { handleGetEndpointSchemas(resolvedParser, operationIds), ); + server.registerTool( + "generate", + { + description: + "Generate a complete, runnable MCP server project from a workflow DSL document. " + + "Call this LAST, after get_capability_guide and get_endpoint_schemas. " + + "Pass the full DSL in one call; the endpoints referenced by the workflows are resolved automatically. " + + "Writes the project (server entry, Rocket.Chat client, vendored workflow engine, one tool per workflow, README) to disk.", + inputSchema: { + dsl: z.string(), + outputDir: z.string().optional(), + }, + }, + async ({ dsl, outputDir }) => + handleGenerate(resolvedParser, { dsl, outputDir }), + ); + return { server, parser: resolvedParser }; } diff --git a/src/tests/generator/engine-bundle-built.integration.test.ts b/src/tests/generator/engine-bundle-built.integration.test.ts new file mode 100644 index 0000000..ab98654 --- /dev/null +++ b/src/tests/generator/engine-bundle-built.integration.test.ts @@ -0,0 +1,81 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +/** + * Smoke test proving `generate` works from the BUILT package, not just from + * source. `engine-bundle` reads the engine `.ts` sources at runtime; `tsc` + * emits only `.js`, so this exercises the build's engine-source copy step and + * the built module's path resolution end to end. + */ + +const repoRoot = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "..", +); +const DSL = ` +PROJECT built_smoke +DESCRIPTION built package smoke test + +WORKFLOW w + DESCRIPTION single sampling step + STEP think : sampling + PROMPT hello +`; + +const endpoints = [ + { + operationId: "chat.postMessage", + method: "POST", + path: "/api/v1/chat.postMessage", + }, +]; + +describe("generate works from the built dist package", () => { + before(() => { + const build = spawnSync("npm run build", { + cwd: repoRoot, + shell: true, + encoding: "utf8", + }); + assert.equal( + build.status, + 0, + `build failed:\n${build.stdout}\n${build.stderr}`, + ); + }); + + it("copies engine sources next to the compiled output", () => { + assert.ok( + existsSync(join(repoRoot, "dist", "workflow", "executor.ts")), + "dist/workflow/executor.ts should exist after build", + ); + }); + + it("bundles a real, non-empty engine from the built generator", async () => { + const builtPipeline = pathToFileURL( + join(repoRoot, "dist", "generator", "index.js"), + ).href; + const { generateFromDsl } = await import(builtPipeline); + + const result = generateFromDsl(DSL, { endpoints }); + const files = new Map( + result.files.map((f: { path: string; content: string }) => [ + f.path, + f.content, + ]), + ); + + const executor = files.get("src/engine/executor.ts"); + assert.ok( + executor && executor.length > 0, + "engine executor must be bundled", + ); + assert.match(executor!, /export async function runWorkflow/); + }); +}); diff --git a/src/tests/generator/escape.unit.test.ts b/src/tests/generator/escape.unit.test.ts new file mode 100644 index 0000000..94951df --- /dev/null +++ b/src/tests/generator/escape.unit.test.ts @@ -0,0 +1,67 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + escapeBlockComment, + escapeMarkdownCell, +} from "../../generator/escape.js"; +import { + assignModuleNames, + sanitizeModuleName, +} from "../../generator/project.js"; +import type { WorkflowDefinition } from "../../workflow/types.js"; + +describe("escapeBlockComment", () => { + it("breaks a comment terminator so it cannot close the block", () => { + const out = escapeBlockComment("*/ evil();"); + assert.ok(!out.includes("*/"), `still contains a terminator: ${out}`); + }); + + it("collapses newlines onto a single comment line", () => { + assert.equal(escapeBlockComment("a\nb\r\nc"), "a b c"); + }); + + it("neutralizes every terminator, not just the first", () => { + const out = escapeBlockComment("a */ b */ c"); + assert.ok(!out.includes("*/")); + }); + + it("is safe on empty / nullish input", () => { + assert.equal(escapeBlockComment(""), ""); + assert.equal(escapeBlockComment(undefined as unknown as string), ""); + }); +}); + +describe("escapeMarkdownCell", () => { + it("escapes pipes and drops newlines", () => { + assert.equal(escapeMarkdownCell("a | b\nc"), "a \\| b c"); + }); +}); + +describe("sanitizeModuleName", () => { + it("reduces arbitrary names to a safe module basename", () => { + assert.equal(sanitizeModuleName('evil"; run()//'), "evil_run"); + assert.equal(sanitizeModuleName("../../etc/passwd"), "etc_passwd"); + assert.equal(sanitizeModuleName("summarize_channel"), "summarize_channel"); + }); + + it("guarantees a leading letter/underscore and never empties out", () => { + assert.match(sanitizeModuleName("123"), /^[A-Za-z_]/); + assert.equal(sanitizeModuleName("***"), "tool"); + }); +}); + +describe("assignModuleNames", () => { + it("disambiguates names that sanitize to the same basename", () => { + const wf = (name: string): WorkflowDefinition => ({ + name, + description: "", + params: { type: "object", properties: {} }, + steps: [], + requiredEndpoints: [], + usesSampling: false, + usesElicitation: false, + }); + const names = assignModuleNames([wf("a b"), wf("a-b"), wf("a/b")]); + assert.deepEqual(names, ["a_b", "a_b_2", "a_b_3"]); + }); +}); diff --git a/src/tests/generator/generate.integration.test.ts b/src/tests/generator/generate.integration.test.ts new file mode 100644 index 0000000..d65c958 --- /dev/null +++ b/src/tests/generator/generate.integration.test.ts @@ -0,0 +1,193 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { generateFromDsl } from "../../generator/pipeline.js"; +import { runWorkflow } from "../../workflow/executor.js"; +import type { + EndpointInfo, + WorkflowClient, + WorkflowServer, +} from "../../workflow/executor.js"; +import type { WorkflowDefinition } from "../../workflow/types.js"; +import type { + GetFullEndpointsResult, + FullEndpoint, +} from "../../parser/types.js"; +import { handleGenerate } from "../../tools/generate.js"; + +const DSL = ` +PROJECT rocketchat_ops +DESCRIPTION Demo workflows for integration testing + +WORKFLOW summarize_channel + DESCRIPTION Fetch recent messages, summarize with AI, post the summary + PARAM roomId : string : Target room id + + STEP fetch : api_call + LABEL Fetch messages + OPERATION channels.history + MAP roomId = {{params.roomId}} + MAP count = 20 + + STEP summarize : sampling + LABEL Summarize messages + DEPENDS ON fetch + PROMPT <<< + Summarize these messages and respond with JSON: {{steps.fetch.messages}} + >>> + RESPONSE_FORMAT json + + STEP post : api_call + LABEL Post the summary + DEPENDS ON summarize + OPERATION chat.postMessage + MAP roomId = {{params.roomId}} + MAP text = {{steps.summarize.summary}} +`; + +const endpoints = [ + { operationId: "channels.history", method: "GET", path: "/api/v1/channels.history", summary: "Channel history" }, + { operationId: "chat.postMessage", method: "POST", path: "/api/v1/chat.postMessage", summary: "Post message" }, +]; + +function fileMap(files: { path: string; content: string }[]): Map { + return new Map(files.map((f) => [f.path, f.content])); +} + +/** Pull the embedded `const workflow: WorkflowDefinition = {...};` JSON back out. */ +function extractWorkflow(toolSource: string): WorkflowDefinition { + const marker = "const workflow: WorkflowDefinition = "; + const start = toolSource.indexOf(marker) + marker.length; + const end = toolSource.indexOf(";\n\nexport const tool", start); + return JSON.parse(toolSource.slice(start, end)) as WorkflowDefinition; +} + +describe("DSL -> generated MCP server", () => { + const result = generateFromDsl(DSL, { endpoints }); + const files = fileMap(result.files); + + it("emits a complete, well-formed project file set", () => { + for (const expected of [ + "package.json", + "tsconfig.json", + "README.md", + ".env.example", + ".gitignore", + "src/server.ts", + "src/rc-client.ts", + "src/endpoints.ts", + "src/engine/executor.ts", + "src/engine/templates.ts", + "src/engine/index.ts", + "src/tools/summarize_channel.ts", + ]) { + assert.ok(files.has(expected), `missing generated file: ${expected}`); + } + }); + + it("vendors the real engine source", () => { + assert.match(files.get("src/engine/executor.ts")!, /export async function runWorkflow/); + assert.match(files.get("src/engine/templates.ts")!, /validateSafeExpression/); + }); + + it("wires every workflow tool into the server entry", () => { + const server = files.get("src/server.ts")!; + assert.match(server, /from ".\/tools\/summarize_channel.js"/); + assert.match(server, /server\.registerTool/); + assert.match(server, /StdioServerTransport/); + }); + + it("produces a valid package.json with required deps", () => { + const pkg = JSON.parse(files.get("package.json")!); + assert.equal(pkg.name, "rocketchat_ops"); + assert.ok(pkg.dependencies["@modelcontextprotocol/sdk"]); + assert.ok(pkg.dependencies.acorn, "engine needs acorn for expression validation"); + assert.ok(pkg.dependencies.zod); + }); + + it("records every referenced endpoint in the endpoint map", () => { + const map = files.get("src/endpoints.ts")!; + assert.match(map, /"channels.history"/); + assert.match(map, /"chat.postMessage"/); + assert.match(map, /\/api\/v1\/chat.postMessage/); + }); + + it("embeds a workflow that the engine can actually execute", async () => { + const toolSource = files.get("src/tools/summarize_channel.ts")!; + const workflow = extractWorkflow(toolSource); + assert.equal(workflow.name, "summarize_channel"); + + const calls: string[] = []; + const client: WorkflowClient = { + async request(method, path) { + calls.push(`${method} ${path}`); + return { ok: true, status: 200, data: { messages: [{ msg: "hello" }] } }; + }, + }; + const server: WorkflowServer = { + async createMessage() { + return { content: { type: "text", text: '{"summary":"all good"}' } }; + }, + }; + const endpointInfo: Record = { + "channels.history": { method: "GET", path: "/api/v1/channels.history" }, + "chat.postMessage": { method: "POST", path: "/api/v1/chat.postMessage" }, + }; + + const run = await runWorkflow(workflow, { roomId: "room1" }, { + client, + server, + endpoints: endpointInfo, + }); + + assert.equal(run.status, "success", JSON.stringify(run)); + assert.deepEqual(run.stepResults.summarize, { summary: "all good" }); + assert.ok(calls.some((c) => c.includes("chat.postMessage"))); + }); +}); + +describe("generate tool writes a project to disk", () => { + const outputDir = join(process.cwd(), ".tmp-generated-test"); + + after(() => { + rmSync(outputDir, { recursive: true, force: true }); + }); + + const stubParser = { + async getFullEndpoints(): Promise { + return { + endpoints: endpoints.map( + (ep) => + ({ + operationId: ep.operationId, + method: ep.method, + path: ep.path, + summary: ep.summary ?? "", + description: "", + domain: "messaging", + parameters: [], + security: [], + inputSchema: { type: "object" }, + parameterSchemas: {}, + }) as unknown as FullEndpoint, + ), + correctedIds: new Map(), + }; + }, + }; + + it("resolves endpoints, generates, and writes files", async () => { + const response = await handleGenerate(stubParser, { dsl: DSL, outputDir }); + assert.ok(!("isError" in response && response.isError), response.content[0].text); + assert.match(response.content[0].text, /Generated MCP server "rocketchat_ops"/); + + const root = join(outputDir, "rocketchat_ops"); + assert.ok(existsSync(join(root, "src", "server.ts"))); + assert.ok(existsSync(join(root, "src", "tools", "summarize_channel.ts"))); + assert.ok(existsSync(join(root, "src", "engine", "executor.ts"))); + + const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + assert.equal(pkg.name, "rocketchat_ops"); + }); +}); diff --git a/src/tests/generator/injection-safety.unit.test.ts b/src/tests/generator/injection-safety.unit.test.ts new file mode 100644 index 0000000..d1d3281 --- /dev/null +++ b/src/tests/generator/injection-safety.unit.test.ts @@ -0,0 +1,96 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { generateFromDsl } from "../../generator/pipeline.js"; +import { generateProject } from "../../generator/project.js"; +import type { WorkflowDefinition } from "../../workflow/types.js"; + +/** + * A DESCRIPTION crafted to break out of the generated block comment. The + * composer validates workflow *names* but not free-text descriptions, so the + * generator must neutralize the comment terminator itself. + */ +const HOSTILE_DSL = ` +PROJECT hostile_project +DESCRIPTION top-level description + +WORKFLOW evil_flow + DESCRIPTION close comment */ globalThis.PWNED = 2; /* reopen + STEP only : api_call + OPERATION channels.history + MAP roomId = room1 +`; + +function fileMap( + files: { path: string; content: string }[], +): Map { + return new Map(files.map((f) => [f.path, f.content])); +} + +describe("generated code is safe from a hostile DSL description", () => { + const result = generateFromDsl(HOSTILE_DSL, { endpoints: [] }); + const files = fileMap(result.files); + const tool = files.get("src/tools/evil_flow.ts")!; + + it("does not let the description escape its block comment", () => { + // The first `*/` in the file must be the header comment's own terminator. + // If the description's `*/` were left raw it would become the first + // terminator and the payload would spill out as code before it. + const terminator = tool.indexOf("*/"); + assert.ok(terminator >= 0, "expected a header comment terminator"); + const header = tool.slice(0, terminator); + assert.ok( + header.includes("globalThis.PWNED = 2;"), + "the description broke out of the header comment", + ); + assert.ok(header.includes("*\\/"), "the terminator was not neutralized"); + }); + + it("keeps the description as an inert escaped string literal", () => { + // `*/` inside a double-quoted string literal is harmless; JSON.stringify + // produces a valid literal for the description field. + assert.ok( + tool.includes( + 'description: "close comment */ globalThis.PWNED = 2; /* reopen"', + ), + ); + }); +}); + +describe("generateProject sanitizes unconstrained workflow names (defense in depth)", () => { + // generateProject can be called directly with a WorkflowDefinition that never + // passed composer name validation, so it must not trust the name. + const hostile: WorkflowDefinition = { + name: 'x"; globalThis.PWNED = 1; //', + description: "d", + params: { type: "object", properties: {} }, + steps: [ + { id: "s", label: "s", config: { type: "transform", expression: "1" } }, + ], + requiredEndpoints: [], + usesSampling: false, + usesElicitation: false, + }; + + const result = generateProject({ + serverName: "safe_server", + workflows: [hostile], + endpoints: [], + }); + const files = fileMap(result.files); + + it("writes the tool under a safe module basename", () => { + const toolPaths = [...files.keys()].filter((p) => + p.startsWith("src/tools/"), + ); + assert.equal(toolPaths.length, 1); + assert.match(toolPaths[0], /^src\/tools\/[A-Za-z0-9_]+\.ts$/); + }); + + it("emits a safe, quote-free import specifier in the server entry", () => { + const server = files.get("src/server.ts")!; + const importMatch = server.match(/from "(\.\/tools\/[^"]+)"/); + assert.ok(importMatch, "expected a tool import in server.ts"); + assert.match(importMatch![1], /^\.\/tools\/[A-Za-z0-9_]+\.js$/); + assert.ok(!server.includes("globalThis.PWNED")); + }); +}); diff --git a/src/tests/tools/generate-fail-closed.unit.test.ts b/src/tests/tools/generate-fail-closed.unit.test.ts new file mode 100644 index 0000000..79cf7fd --- /dev/null +++ b/src/tests/tools/generate-fail-closed.unit.test.ts @@ -0,0 +1,106 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { handleGenerate } from "../../tools/generate.js"; +import type { + FullEndpoint, + GetFullEndpointsResult, +} from "../../parser/types.js"; + +function makeEndpoint( + operationId: string, + method: string, + path: string, +): FullEndpoint { + return { + operationId, + method, + path, + summary: "", + description: "", + domain: "messaging", + parameters: [], + security: [], + inputSchema: { type: "object" }, + parameterSchemas: {}, + } as unknown as FullEndpoint; +} + +/** Parser stub that resolves a fixed set of endpoints plus an optional correction map. */ +function stubParser( + endpoints: FullEndpoint[], + correctedIds: Map = new Map(), +) { + return { + async getFullEndpoints(): Promise { + return { endpoints, correctedIds }; + }, + }; +} + +const DSL = ` +PROJECT resolve_test +DESCRIPTION operationId resolution behavior + +WORKFLOW w + DESCRIPTION single api call + PARAM roomId : string : Target room + + STEP fetch : api_call + OPERATION channels.history + MAP roomId = {{params.roomId}} +`; + +describe("generate fails closed on unresolved operationIds", () => { + const outputDir = join(process.cwd(), ".tmp-fail-closed-test"); + after(() => rmSync(outputDir, { recursive: true, force: true })); + + it("returns an error and writes nothing when an operationId is unknown", async () => { + const response = await handleGenerate(stubParser([]), { + dsl: DSL, + outputDir, + }); + assert.ok( + "isError" in response && response.isError, + "expected an error result", + ); + assert.match(response.content[0].text, /could not be resolved/); + assert.match(response.content[0].text, /channels\.history/); + assert.ok( + !existsSync(join(outputDir, "resolve_test")), + "no project should be written on failure", + ); + }); +}); + +describe("generate rewrites auto-corrected operationIds before generating", () => { + const outputDir = join(process.cwd(), ".tmp-corrected-test"); + after(() => rmSync(outputDir, { recursive: true, force: true })); + + it("embeds the corrected id in both the tool and the endpoint map", async () => { + const parser = stubParser( + [makeEndpoint("channels-history", "GET", "/api/v1/channels.history")], + new Map([["channels.history", "channels-history"]]), + ); + + const response = await handleGenerate(parser, { dsl: DSL, outputDir }); + assert.ok( + !("isError" in response && response.isError), + response.content[0].text, + ); + assert.match(response.content[0].text, /Auto-corrected operationIds/); + + const root = join(outputDir, "resolve_test"); + const tool = readFileSync(join(root, "src", "tools", "w.ts"), "utf8"); + const endpointsFile = readFileSync( + join(root, "src", "endpoints.ts"), + "utf8", + ); + + // The embedded workflow must reference the corrected id, never the stale one. + assert.match(tool, /"operationId": "channels-history"/); + assert.ok(!tool.includes('"operationId": "channels.history"')); + assert.match(endpointsFile, /"channels-history"/); + }); +}); diff --git a/src/tests/tools/mcp-protocol.smoke.test.ts b/src/tests/tools/mcp-protocol.smoke.test.ts index f3d2300..1fa6c6b 100644 --- a/src/tests/tools/mcp-protocol.smoke.test.ts +++ b/src/tests/tools/mcp-protocol.smoke.test.ts @@ -86,7 +86,8 @@ describe("MCP protocol smoke test", () => { assert.ok(names.includes("get_capability_guide")); assert.ok(names.includes("get_endpoint_schemas")); - assert.equal(tools.length, 2); + assert.ok(names.includes("generate")); + assert.equal(tools.length, 3); const schemaTool = tools.find( (tool) => tool.name === "get_endpoint_schemas", diff --git a/src/tests/workflow/missing-endpoint.unit.test.ts b/src/tests/workflow/missing-endpoint.unit.test.ts new file mode 100644 index 0000000..eb9f337 --- /dev/null +++ b/src/tests/workflow/missing-endpoint.unit.test.ts @@ -0,0 +1,53 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { runWorkflow } from "../../workflow/executor.js"; +import type { + EndpointInfo, + WorkflowClient, + WorkflowServer, +} from "../../workflow/executor.js"; +import type { WorkflowDefinition } from "../../workflow/types.js"; + +/** + * An api_call step whose operationId is absent from the endpoint map must fail + * loudly instead of silently issuing a request to an empty `GET` path. + */ +const workflow: WorkflowDefinition = { + name: "wf", + description: "d", + params: { type: "object", properties: {} }, + steps: [ + { + id: "call", + label: "call", + config: { type: "api_call", operationId: "missing.op", inputMapping: {} }, + }, + ], + requiredEndpoints: ["missing.op"], + usesSampling: false, + usesElicitation: false, +}; + +describe("api_call with an unregistered operationId fails closed at runtime", () => { + it("errors out and never issues a request", async () => { + let requestCount = 0; + const client: WorkflowClient = { + async request() { + requestCount++; + return { ok: true, status: 200, data: {} }; + }, + }; + const server: WorkflowServer = { + async createMessage() { + return { content: { type: "text", text: "" } }; + }, + }; + const endpoints: Record = {}; + + const run = await runWorkflow(workflow, {}, { client, server, endpoints }); + + assert.equal(run.status, "error"); + assert.match(run.error ?? "", /No endpoint registered for operationId/); + assert.equal(requestCount, 0, "no HTTP request should be made"); + }); +}); diff --git a/src/tools/generate.ts b/src/tools/generate.ts new file mode 100644 index 0000000..d4f2faf --- /dev/null +++ b/src/tools/generate.ts @@ -0,0 +1,152 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { EndpointDetailSource } from "../parser/types.js"; +import { composeDsl } from "../generator/pipeline.js"; +import { generateProject, sanitizeServerName } from "../generator/project.js"; +import type { GeneratorEndpoint } from "../generator/types.js"; + +export interface GenerateArgs { + /** The workflow DSL document. */ + dsl: string; + /** Directory to write the generated project into. Default: "./generated". */ + outputDir?: string; +} + +function ok(text: string) { + return { content: [{ type: "text" as const, text }] }; +} + +function fail(text: string) { + return { content: [{ type: "text" as const, text }], isError: true }; +} + +/** + * Generate a complete MCP server project from a DSL document and write it to + * disk. Resolves the API endpoints the workflows reference through the parser, + * so the generated endpoint map carries real methods and paths. + */ +export async function handleGenerate( + parser: EndpointDetailSource, + args: GenerateArgs, +) { + let composed; + try { + composed = composeDsl(args.dsl); + } catch (err) { + return fail( + `DSL error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (composed.workflows.length === 0) { + return fail("The DSL declared no workflows."); + } + + const operationIds = [ + ...new Set(composed.workflows.flatMap((w) => w.requiredEndpoints)), + ].filter(Boolean); + + let endpoints: GeneratorEndpoint[]; + let correctedIds: ReadonlyMap; + try { + const resolved = await parser.getFullEndpoints(operationIds); + endpoints = resolved.endpoints.map((ep) => ({ + operationId: ep.operationId, + method: ep.method, + path: ep.path, + summary: ep.summary, + })); + correctedIds = resolved.correctedIds; + } catch (err) { + return fail( + `Failed to resolve endpoints: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Map every requested operationId to the endpoint it actually resolved to + // (the parser auto-corrects near-misses and reports them via correctedIds). + const resolvedIds = new Set(endpoints.map((ep) => ep.operationId)); + const actualFor = (id: string): string => correctedIds.get(id) ?? id; + + // Fail closed: refuse to generate if any operationId cannot be resolved to a + // real endpoint. Generating anyway produces tools whose api_call steps fall + // back to an empty GET path at runtime. + const unresolved = operationIds.filter( + (id) => !resolvedIds.has(actualFor(id)), + ); + if (unresolved.length > 0) { + return fail( + `Cannot generate: ${unresolved.length} operationId(s) could not be resolved to an endpoint: ` + + `${unresolved.join(", ")}. ` + + `Verify them with get_endpoint_schemas and fix the OPERATION lines in the DSL.`, + ); + } + + // Rewrite corrected operationIds into the composed workflows so the embedded + // steps and the generated endpoint map agree — otherwise a corrected id would + // be missing from the map and hit the empty-GET fallback at runtime. + const corrected: string[] = []; + for (const workflow of composed.workflows) { + for (const step of workflow.steps) { + if (step.config.type === "api_call") { + const actual = actualFor(step.config.operationId); + if (actual !== step.config.operationId) { + corrected.push(`${step.config.operationId} -> ${actual}`); + step.config.operationId = actual; + } + } + } + workflow.requiredEndpoints = workflow.requiredEndpoints.map(actualFor); + } + + let result; + try { + result = generateProject({ + serverName: composed.projectName, + workflows: composed.workflows, + endpoints, + }); + } catch (err) { + return fail( + `Generation failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const root = join(args.outputDir ?? "generated", result.summary.serverName); + try { + for (const file of result.files) { + const target = join(root, file.path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, file.content, "utf8"); + } + } catch (err) { + return fail( + `Failed to write project: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const lines = [ + `Generated MCP server "${result.summary.serverName}" at ${root}`, + ` Workflows: ${result.summary.workflowCount}`, + ` Endpoints: ${result.summary.endpointCount}`, + ` Files: ${result.files.length}`, + ` Sampling: ${result.summary.usesSampling ? "yes" : "no"}, Elicitation: ${ + result.summary.usesElicitation ? "yes" : "no" + }`, + ]; + if (corrected.length > 0) { + lines.push(` Auto-corrected operationIds: ${corrected.join(", ")}`); + } + if (composed.warnings.length > 0) { + lines.push( + ` Composer notes (${composed.warnings.length}) — informational:`, + ); + for (const w of composed.warnings.slice(0, 10)) { + lines.push(` - [${w.code}] ${w.message}`); + } + } + + return ok(lines.join("\n")); +} + +export { sanitizeServerName }; diff --git a/src/workflow/api-call.ts b/src/workflow/api-call.ts index e4c9b79..044a0af 100644 --- a/src/workflow/api-call.ts +++ b/src/workflow/api-call.ts @@ -74,7 +74,8 @@ function pruneEmptyParams( for (const [key, raw] of Object.entries(step.inputMapping)) { if (typeof raw !== "string" || !raw.includes("{{")) continue; const resolved = payload[key]; - if (resolved !== "" && resolved !== undefined && resolved !== null) continue; + if (resolved !== "" && resolved !== undefined && resolved !== null) + continue; const paramMatch = raw.match(/\{\{\s*params\.(\w+)/); const stepMatch = raw.match(/\{\{\s*steps\.(\w+)/); @@ -98,8 +99,14 @@ async function callOnce( endpoints: Record, ): Promise { const endpoint: EndpointInfo | undefined = endpoints[step.operationId]; - const method = (endpoint?.method || "GET").toUpperCase(); - const rawPath = endpoint?.path || ""; + if (!endpoint) { + throw new Error( + `No endpoint registered for operationId "${step.operationId}". ` + + `The workflow references an operationId that is not in the endpoint map.`, + ); + } + const method = endpoint.method.toUpperCase(); + const rawPath = endpoint.path; pruneEmptyParams(step, payload, state); @@ -136,13 +143,18 @@ async function callOnce( ); } - if (typeof response.data === "string" && response.data.length > MAX_RESPONSE_BYTES) { + if ( + typeof response.data === "string" && + response.data.length > MAX_RESPONSE_BYTES + ) { throw new Error( `API response for "${step.operationId}" exceeds the 10 MB limit.`, ); } - return step.outputPath ? extractPath(response.data, step.outputPath) : response.data; + return step.outputPath + ? extractPath(response.data, step.outputPath) + : response.data; } /** Execute an `api_call` step, including its optional `forEach` fan-out. */ @@ -235,7 +247,11 @@ export async function executeApiCall( return; } - const payload = resolveMapping(config.inputMapping, state.params, state.steps); + const payload = resolveMapping( + config.inputMapping, + state.params, + state.steps, + ); const result = await callOnce(config, payload, state, client, endpoints); state.steps[step.id] = result; state.status[step.id] = "success";