From 12e3b979f2064580580007914735382e108c34a5 Mon Sep 17 00:00:00 2001 From: sezallagwal Date: Sun, 28 Jun 2026 14:22:28 +0530 Subject: [PATCH 1/2] Add end-to-end evaluation tests and project documentation - Add full pipeline integration tests (DSL -> compose -> generate -> validate output) - Add architecture documentation with module map and data flow diagrams - Add complete DSL reference with grammar, keywords, and examples - Add generated project anatomy documentation - Add security model documentation (sandbox, allowlist, threat model) - Add testing guide with test suite map and conventions - Update README with full project description, capabilities, and doc links - Update CONTRIBUTING with complete setup, workflow, and conventions guide --- CONTRIBUTING.md | 97 +++-- README.md | 83 +++- docs/ARCHITECTURE.md | 371 +++++++++++++++++ docs/DSL_REFERENCE.md | 336 +++++++++++++++ docs/GENERATED_PROJECT.md | 148 +++++++ docs/SECURITY.md | 89 ++++ docs/TESTING.md | 112 +++++ .../integration/pipeline.integration.test.ts | 391 ++++++++++++++++++ 8 files changed, 1587 insertions(+), 40 deletions(-) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DSL_REFERENCE.md create mode 100644 docs/GENERATED_PROJECT.md create mode 100644 docs/SECURITY.md create mode 100644 docs/TESTING.md create mode 100644 src/tests/integration/pipeline.integration.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b884755..717526d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,16 +1,19 @@ -# Contributing to Rocket.Chat MCP Server Generator +# Contributing -Thanks for helping improve the Rocket.Chat MCP Server Generator. This project is a TypeScript MCP server that discovers Rocket.Chat REST APIs, exposes schema inspection tools, and generates focused workflow-driven MCP servers. +Thanks for helping improve the Rocket.Chat MCP Server Generator. This guide covers everything you need to set up, develop, test, and submit changes. + +--- ## Prerequisites -- Node.js 22 or newer -- npm -- Git +- **Node.js 22 or newer** +- **npm** +- **Git** +- **gemini-cli** — optional, only if you want to test the repo as a Gemini extension -## Getting Started +## Setup -Clone the repository and install dependencies: +Clone and install: ```bash git clone https://github.com/RocketChat/MCPServerGenerator_GSoC2026.git @@ -18,44 +21,76 @@ cd MCPServerGenerator_GSoC2026 npm install ``` -Run the local validation checks: +Verify your environment: ```bash npm run typecheck -npm run test:unit +npm test npm run build ``` +## Everyday Commands + +| Command | What it does | +| --- | --- | +| `npm run dev` | Run the MCP server from source (via `tsx`) for local iteration | +| `npm start` | Run the compiled server from `dist/` | +| `npm run build` | Compile TypeScript to `dist/` | +| `npm run typecheck` | Type-check without emitting files | +| `npm test` | Run the full unit/integration test suite | +| `npm run lint` / `npm run lint:fix` | Lint (and auto-fix) with ESLint | +| `npm run format` / `npm run format:check` | Format (and check) with Prettier | +| `npm run check` | Everything above, gate-style — run this before pushing | + ## Development Workflow -Use the development entry point while iterating locally: +1. Create a branch with a descriptive name, e.g. `fix/schema-output` or `docs/dsl-reference`. +2. Make focused changes. Keep refactors out of feature/bug-fix PRs unless required for the fix. +3. Add or update tests for new behavior. +4. Run `npm run check` and make sure it passes. +5. Open a pull request (see the checklist below). -```bash -npm run dev -``` +## Coding Conventions -Use the compiled entry point after a build: +- **TypeScript strict mode**, ESM syntax. +- Local imports must include the runtime `.js` extension: `import { createMcpServer } from "./server.js";`. +- **Lint & format are enforced.** ESLint + Prettier run in `npm run check`. +- **Logging goes to stderr.** Never write to stdout in the MCP server — stdout is the JSON-RPC channel. Use `console.error`. +- **Prefer narrow interfaces.** Tool handlers should depend on small contracts instead of concrete classes — this keeps handlers testable. +- Keep tool handlers focused on MCP response shaping; put parsing and workflow logic in the parser/composer/workflow modules. +- Use lowercase, hyphenated filenames for multi-word modules (e.g. `get-endpoint-schemas.ts`). -```bash -npm start -``` +## Where Things Live -Before opening a pull request, run the full project check when your environment can support all tests: +The source is organized by responsibility under `src/`: -```bash -npm run check -``` +| Directory | Responsibility | +| --- | --- | +| `src/tools/` | The three MCP tool handlers | +| `src/dsl/` | DSL parser (scanner + recursive descent parser) | +| `src/composer/` | Workflow validation, normalization, inference, ordering | +| `src/generator/` | Code generation pipeline | +| `src/parser/` | OpenAPI fetch, cache, endpoint extraction | +| `src/workflow/` | Runtime engine (executor, templates, sandbox, security) | +| `src/utils/` | operationId resolver | -## Code Quality +For a full explanation of the architecture, read [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). -This repository uses TypeScript, ESLint, Prettier, and Node's built-in test runner. Keep changes focused and run the relevant checks before submitting work: +## Testing Conventions -```bash -npm run format:check -npm run lint -npm run typecheck -npm run test:unit -npm run build -``` +Tests use Node's built-in `node:test` with `node:assert/strict`. Tests live under `src/tests/`, grouped by area. Place new tests in the folder matching the module you're changing. + +When changing **parser** behavior, cover: exact operationId matches, fuzzy-corrected ids, unmatched ids, request-body extraction, and response schema output. + +When changing the **composer** or **workflow engine**, cover the normalization/validation rule you touched and at least one end-to-end case. + +See [`docs/TESTING.md`](docs/TESTING.md) for the full test suite map. + +## Pull Request Checklist + +Before requesting review: -Use `npm run format` only when you intentionally want Prettier to rewrite files. +- [ ] `npm run check` passes (format, lint, types, tests, build) +- [ ] New behavior has focused tests +- [ ] Public MCP tool behavior is documented or covered by a smoke test +- [ ] Relevant docs updated if behavior or structure changed diff --git a/README.md b/README.md index e1d62e7..da00579 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,68 @@ -# Rocket.Chat MCP Server Generator +

Rocket.Chat MCP Server Generator

-A tool that generates minimal MCP servers for Rocket.Chat — covering only the subset of APIs a project actually needs, instead of bundling every endpoint. This cuts down the context bloat that makes MCP expensive in agentic workflows, and can bring Rocket.Chat code generation projects within the free tier of most LLM platform providers. +

+ A gemini-cli extension that generates minimal, workflow-driven MCP servers — solving context bloat by exposing only the workflows a project actually needs. +

-## Current Status +

+ License: MIT + Node.js >= 22 + TypeScript + MCP SDK + 558 Endpoints +

-Implemented: +--- -- Parses Rocket.Chat OpenAPI specs by domain -- Lists compact endpoint capability guides -- Returns request and response schemas for selected operationIds -- Uses memory and disk cache for specs -- Includes unit and integration tests +## The Problem + +A major pain point when adopting MCP is **context bloat**. Most MCP servers ship support for a large surface of service APIs, so anyone adopting them spends most of their token budget on static tool definitions for calls they will never make. In agentic code-generation workflows this is worse — every agent burns tokens in loops carrying tools the project will never use. + +## The Solution + +This generator lets you create a **minimal** MCP server that covers only the subset of APIs your project actually needs. Describe what you want in plain English; the generator finds the relevant REST APIs, composes multi-step workflow tools that chain those API calls with AI reasoning, and outputs a complete, runnable MCP server project. + +The output is **not** a thin REST wrapper. Each generated tool is a **workflow** that chains multiple API calls, LLM reasoning, user confirmation, data transforms, and conditional logic into one higher-level operation — invoked from any MCP-compatible client such as Gemini CLI, Claude Desktop, Cursor, or VS Code Copilot. + +## How It Works + +It is itself an MCP server that exposes three tools. An AI client calls them in sequence: + +| Tool | Purpose | +| --- | --- | +| **`get_capability_guide`** | Discovers and lists the available REST API endpoints | +| **`get_endpoint_schemas`** | Returns exact request/response schemas for the chosen endpoints | +| **`generate`** | Validates the described workflows and writes a complete MCP server project | + +``` +Describe intent -> get_capability_guide -> get_endpoint_schemas -> generate -> ready to deploy +``` + +You describe the goal in natural language; the AI handles endpoint selection, workflow composition, and code generation autonomously. + +## Key Capabilities + +- **Plain-English to working server** — go from an idea to a complete, tested MCP server project without writing the integration by hand. +- **Workflow tools, not raw endpoints** — each tool composes API calls, AI reasoning (sampling), human-in-the-loop confirmation (elicitation), data transforms, and conditional branching. +- **Minimal by design** — only the workflows you ask for are generated, keeping the token footprint small. +- **Automatic API discovery** — fetches official OpenAPI specs at runtime (558 Rocket.Chat endpoints across 12 domains), so new endpoints are available without manual definitions. +- **Complete project output** — every generated server includes source, a runtime workflow engine, tests, configuration, and a README. +- **Two ways to run** — as a Gemini CLI extension (AI-driven), or as a standalone MCP server for deterministic generation. + +## Prerequisites + +- [Node.js](https://nodejs.org/) v22 or newer +- [gemini-cli](https://github.com/google-gemini/gemini-cli) installed and configured (for the AI-driven flow) + +## Quick Start + +Install as a Gemini CLI extension, then describe what you need: + +``` +gemini> I need an MCP server that can send messages and manage channels +``` + +Gemini discovers the right endpoints, composes the workflows, and generates a complete project — ready to install and run. ## Building & Testing @@ -19,3 +71,16 @@ npm install npm test npm run build ``` + +## Documentation + +- **[Architecture & High-Level Design](docs/ARCHITECTURE.md)** — how the system fits together +- **[DSL Reference](docs/DSL_REFERENCE.md)** — the complete DSL language spec +- **[Generated Project Anatomy](docs/GENERATED_PROJECT.md)** — what the output looks like +- **[Security Model](docs/SECURITY.md)** — sandbox, allowlist, and threat model +- **[Testing Guide](docs/TESTING.md)** — test suite map and conventions +- **[Contributing](CONTRIBUTING.md)** — setup, workflow, and contribution guide + +## License + +[MIT](LICENSE) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e7db447 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,371 @@ +# MCP Server Generator — Architecture & High-Level Design + +> This is the authoritative high-level design of the project. It describes the architecture and every module — what each one is responsible for and how they fit together. It deliberately does not explain code, algorithms, or implementation details. + +--- + +## 1. What the System Is + +This project is a **meta-MCP-server**: an MCP server whose purpose is to **generate other MCP servers**. Instead of exposing business tools (like "send a message"), it exposes tools that let an AI assistant **describe a workflow in a small DSL and receive a complete, runnable MCP server project** in return. + +It is driven as an MCP server — an AI client (Gemini CLI, Claude Desktop, VS Code Copilot) connects over stdio and calls three tools to discover an API, inspect schemas, and generate a project. + +```mermaid +mindmap + root((MCP Server
Generator)) + Purpose + Generate MCP servers from a DSL + Chain API calls + AI + human steps + Output a runnable TypeScript project + Entry Points + MCP server over stdio + Pipeline + Parse DSL + Compose and validate workflow + Generate code + Runtime + Workflow engine copied into output + api_call sampling elicitation transform conditional +``` + +--- + +## 2. System Context + +The generator sits between an **author** (an AI client) and the **generated artifact** (a project on disk). It reads API knowledge from an **OpenAPI source**. + +```mermaid +flowchart LR + subgraph Authors + AI[AI Client
Gemini / Claude / Copilot] + end + + subgraph Generator[MCP Server Generator] + TOOLS[Three MCP Tools] + CORE[Core Pipeline] + end + + SPEC[(OpenAPI Specs
Rocket.Chat)] + OUT[(Generated Project
on disk)] + + AI -->|stdio JSON-RPC| TOOLS + TOOLS --> CORE + CORE -->|fetch & parse| SPEC + CORE -->|write files| OUT + OUT -->|runs as its own| AInew[New MCP Server] +``` + +--- + +## 3. The Three-Tool Contract + +When used as an MCP server, the whole experience is three tools called in sequence: + +```mermaid +sequenceDiagram + participant AI as AI Client + participant G as Generator (MCP) + participant S as OpenAPI Source + + AI->>G: 1. get_capability_guide + G->>S: fetch & parse all domains + S-->>G: endpoints + G-->>AI: compact guide (summaries -> operationIds) + + AI->>G: 2. get_endpoint_schemas([operationIds]) + G->>S: fetch chosen domains + S-->>G: full schemas + G-->>AI: request/response schemas (+ auto-corrected ids) + + Note over AI: AI writes DSL using the schemas + + AI->>G: 3. generate(dsl) + G->>G: parse -> compose -> generate -> write + G-->>AI: success + project location +``` + +| Tool | Responsibility | Backed by module | +|------|----------------|------------------| +| `get_capability_guide` | Discovery: list every endpoint as `summary -> operationId` | `tools/get-capability-guide.ts` | +| `get_endpoint_schemas` | Detail: return exact request/response schemas for chosen ids | `tools/get-endpoint-schemas.ts` | +| `generate` | Build a full MCP server project from a DSL string | `tools/generate.ts` | + +--- + +## 4. Module Map + +The source is organized into focused modules under `src/`: + +```mermaid +flowchart TB + subgraph Entry[Entry Layer] + IDX[index.ts
stdio bootstrap] + SRV[server.ts
MCP server + tool registration] + end + + subgraph ToolLayer[Tool Layer - src/tools] + T1[get-capability-guide] + T2[get-endpoint-schemas] + T3[generate] + end + + subgraph Core[Core Pipeline] + DSL[src/dsl
DSL parser] + COMP[src/composer
workflow composer] + GEN[src/generator
code generator] + end + + subgraph Support[Supporting Modules] + PARSE[src/parser
OpenAPI parser] + WF[src/workflow
runtime engine] + UTIL[src/utils
operationId resolver] + end + + IDX --> SRV + SRV --> T1 & T2 & T3 + T1 --> PARSE + T2 --> PARSE + T3 --> DSL --> COMP --> GEN + T3 --> PARSE + GEN --> WF + COMP --> WF +``` + +--- + +## 5. Module Overviews + +### 5.1 Entry Layer + +- **`index.ts`** — Process bootstrap. Creates the MCP server and connects it over a stdio transport. +- **`server.ts`** — Builds the `McpServer`, registers the three tools with their descriptions and schemas. This is the composition root. + +### 5.2 Tool Layer — `src/tools` + +The request handlers for each MCP tool. They orchestrate the core modules and shape the responses returned to the AI. + +- **`get-capability-guide.ts`** — Handler for discovery. Asks the parser for all endpoints and formats them into a guide. +- **`get-endpoint-schemas.ts`** — Handler that returns exact request/response schemas for the operationIds the AI selected. +- **`generate.ts`** — The orchestrator for project generation. Drives parse -> compose -> generate. + +### 5.3 DSL Module — `src/dsl` + +Turns the author's DSL text into a structured, in-memory representation. This is the "front-end" of the compiler analogy. + +- **`scanner.ts`** — Lexical pass over raw DSL text (line handling, comments, heredoc blocks). +- **`parser.ts`** — A recursive descent parser that builds projects, workflows, params, and steps. +- **`types.ts`** — DSL-level types (`DslWorkflow`, `DslStep`, `ParseDslResult`). +- **`index.ts`** — Public surface (`parseDsl`, types). + +```mermaid +stateDiagram-v2 + [*] --> ROOT + ROOT --> ROOT: PROJECT / DESCRIPTION + ROOT --> WORKFLOW: WORKFLOW + WORKFLOW --> WORKFLOW: DESCRIPTION / PARAM + WORKFLOW --> STEP: STEP + STEP --> STEP: MAP / OPERATION / DEPENDS ON ... + STEP --> WORKFLOW: next WORKFLOW + WORKFLOW --> [*] + STEP --> [*] +``` + +### 5.4 Composer Module — `src/composer` + +The "middle-end" — semantic analysis, normalization, validation, and ordering. It takes raw parsed steps and produces a fully validated, dependency-ordered `WorkflowDefinition`. + +| File | Responsibility | +|------|----------------| +| `composer.ts` | Orchestrates the full validation/normalization pipeline | +| `dsl-mapping.ts` | Adapts raw DSL workflow objects into the composer's input shape | +| `validation.ts` | Structural checks: unique ids, required fields, reference integrity, cycles | +| `normalization.ts` | Auto-fixes common authoring mistakes (template syntax, escaping) | +| `inference.ts` | Fills in omitted information (conditional targets, implicit dependencies) | +| `warnings.ts` | Emits non-fatal quality warnings (unused steps, orphans, deep chains) | +| `types.ts` | Composer-level input/output and warning types | + +```mermaid +flowchart LR + IN[raw parsed steps] --> N[normalize] + N --> I[infer missing pieces] + I --> V[validate] + V --> W[generate warnings] + W --> S[topological sort] + S --> OUT[WorkflowDefinition
ordered + validated] +``` + +### 5.5 Generator Module — `src/generator` + +The "back-end" — turns a validated `WorkflowDefinition` plus endpoint info into a complete set of project files. + +| File | Responsibility | +|------|----------------| +| `pipeline.ts` | Full pipeline: DSL -> parsed -> composed -> generated project | +| `project.ts` | Assembles the complete file set for a project | +| `codegen.ts` | Generates per-workflow tool files, endpoint maps, server entry | +| `scaffold.ts` | Static/templated files: REST client, package.json, tsconfig, README | +| `engine-bundle.ts` | Copies the workflow engine into the output | +| `dsl-mapping.ts` | Maps DSL structures to generator input | +| `types.ts` | Generator-level types | + +```mermaid +flowchart TB + WD[WorkflowDefinition] --> SC[codegen] + WD --> TM[scaffold] + SC & TM --> FM[file map] + FM --> PL[project.generateProject] + PL --> ENG[bundle engine] + PL --> DISK[(project on disk)] +``` + +### 5.6 OpenAPI Parser Module — `src/parser` + +Reads API knowledge from OpenAPI specs and exposes it to the tools. + +| File | Responsibility | +|------|----------------| +| `spec-source.ts` | Fetches and caches OpenAPI documents (in-memory / disk / remote) | +| `spec-parser.ts` | Implements `SpecParser`: lists endpoints and returns full endpoint details | +| `endpoint-extraction.ts` | Extracts compact and full endpoint records from a parsed spec | +| `schema-mapper.ts` | Converts OpenAPI schemas into JSON-Schema-style shapes | +| `types.ts` | Domain, endpoint, and parser interface types | +| `index.ts` | Public surface | + +```mermaid +flowchart LR + REQ[tool request] --> SP[SpecParser] + SP --> SS[spec-source
3-tier cache] + SS -->|miss| NET[(remote spec)] + SS -->|hit| MEM[(memory / disk)] + SP --> EX[endpoint-extraction] + EX --> SM[schema-mapper] + SM --> RES[endpoints + schemas] +``` + +### 5.7 Workflow Module — `src/workflow` + +The runtime engine. These files are **copied verbatim into every generated project** — they are both part of this repo and the runtime of the output. + +| File | Responsibility | +|------|----------------| +| `executor.ts` | The execution loop: step scheduling, branching, error handling | +| `api-call.ts` | Executes `api_call` steps (payload, request, response parsing) | +| `sampling.ts` | Executes `sampling` (LLM) steps | +| `templates.ts` | Evaluates template expressions and resolves `{{...}}` placeholders | +| `expression-security.ts` | Guards expressions against dangerous patterns (AST allowlist) | +| `types.ts` | `WorkflowDefinition` / step types shared with codegen | + +```mermaid +flowchart TB + RW[runWorkflow] --> SCH{find ready steps} + SCH --> TYPE{step type} + TYPE -->|api_call| API[api-call] + TYPE -->|sampling| SMP[sampling] + TYPE -->|transform| EXP[templates] + TYPE -->|conditional| CON[branch select] + TYPE -->|elicitation| ELI[ask user] + API & SMP & EXP & CON & ELI --> ST[update state] + ST --> SCH + ST --> DONE[result: status + stepResults] +``` + +--- + +## 6. End-to-End Data Flow + +The full journey from a DSL string to a project on disk: + +```mermaid +flowchart TB + START([DSL input]) --> PARSE[dsl.parseDsl] + PARSE --> MAP[dslWorkflowToComposeInput] + MAP --> COMPOSE[composeWorkflowDefinition] + COMPOSE --> EP[collect required endpoints] + EP --> RES[derive method + path from operationId] + RES --> BUILD[generator.generateProject] + BUILD --> COPY[bundle workflow engine] + COPY --> END([runnable MCP server project]) +``` + +--- + +## 7. Pipeline-as-Compiler Analogy + +The architecture mirrors a classic compiler: + +```mermaid +flowchart LR + subgraph FrontEnd[Front-end] + L[DSL Scanner
lexing] + P[DSL Parser
parsing -> AST] + end + subgraph MiddleEnd[Middle-end] + C[Composer
validate / normalize / infer / order] + end + subgraph BackEnd[Back-end] + G[Generator
emit project files] + end + subgraph Runtime[Runtime] + E[Workflow Engine
executes in output project] + end + L --> P --> C --> G --> E +``` + +--- + +## 8. Generated Project — Output View + +What the system produces: + +```mermaid +flowchart TB + subgraph Project[Generated MCP Server] + SRV2[src/server.ts
MCP entry - stdio] + CLIENT2[src/rc-client.ts
REST API client] + TOOLS2[src/tools/*.ts
one file per workflow] + ENGINE2[src/engine/*
copied workflow engine] + META[package.json / tsconfig.json
.env.example / README.md] + end + SRV2 --> TOOLS2 --> ENGINE2 --> CLIENT2 +``` + +The generated server runs independently: an AI client calls a workflow tool, the engine executes its steps in topological order, and results flow back. + +--- + +## 9. Step Types + +The five workflow step types the engine understands: + +```mermaid +flowchart LR + A[api_call
calls a REST endpoint] + S[sampling
LLM reasoning] + E[elicitation
ask the human] + T[transform
reshape data via expression] + C[conditional
branch on a boolean] +``` + +| Type | Purpose | Required fields | Output | +|------|---------|-----------------|--------| +| `api_call` | Call a REST endpoint | `operationId` | Parsed response | +| `sampling` | LLM reasoning/analysis | `prompt` | Text or JSON | +| `elicitation` | Ask the user, wait for input | `message`, `requestedSchema` | User response | +| `transform` | Reshape data | `expression` | Expression result | +| `conditional` | Branch execution | `condition`, `thenStep` | Boolean | + +--- + +## 10. Module Responsibility Summary + +| Module | Layer | One-line responsibility | +|--------|-------|-------------------------| +| `src/index.ts` | Entry | Boot the MCP server over stdio | +| `src/server.ts` | Entry | Register the three MCP tools (composition root) | +| `src/tools` | Tool | Request handlers for discovery, schemas, generation | +| `src/dsl` | Core | Parse DSL text into structured workflows | +| `src/composer` | Core | Validate, normalize, infer, and order workflows | +| `src/generator` | Core | Generate and write all project files | +| `src/parser` | Support | Fetch/parse OpenAPI specs; expose endpoints + schemas | +| `src/workflow` | Support | Runtime engine (copied into output) | +| `src/utils` | Support | operationId reconciliation | diff --git a/docs/DSL_REFERENCE.md b/docs/DSL_REFERENCE.md new file mode 100644 index 0000000..445ec02 --- /dev/null +++ b/docs/DSL_REFERENCE.md @@ -0,0 +1,336 @@ +# DSL Reference + +> The complete specification of the workflow DSL consumed by the `generate` tool. This is the user-facing contract: the AI (or a developer) writes DSL text, and the generator turns it into a runnable MCP server. + +--- + +## 1. Overview + +The DSL is **flat and line-oriented**: every meaningful line is `KEYWORD value`. There is no significant indentation (indentation is cosmetic and trimmed). A document declares one **project**, which contains one or more **workflows**, each containing one or more **steps**. Each workflow becomes one MCP tool; each step is one unit of work (an API call, an LLM call, a user prompt, a data transform, or a branch). + +``` +PROJECT +DESCRIPTION + +WORKFLOW + DESCRIPTION + PARAM : [: ] + STEP : + +``` + +Design goal: be robust against the ways an LLM mangles structured formats. Keywords are uppercase, values are free-form, and the parser auto-corrects or rejects common mistakes with line-numbered errors. + +--- + +## 2. Grammar (EBNF-style) + +```ebnf +document = project_decl , project_desc , { workflow } ; + +project_decl = "PROJECT" , WS , name , NL ; +project_desc = "DESCRIPTION" , WS , text , NL ; + +workflow = "WORKFLOW" , WS , name , NL , + { workflow_desc | param | step } ; +workflow_desc = "DESCRIPTION" , WS , text , NL ; +param = "PARAM" , WS , name , ":" , param_type , [ ":" , text ] , NL ; + +step = "STEP" , WS , id , ":" , step_type , NL , + { step_field } ; + +step_field = label | depends | operation | output_path | for_each | as + | map | expression | condition | then | else + | prompt | system_prompt | max_tokens | response_format + | message | schema | on_decline | continue_on_error ; + +heredoc = WS , "<<<" , NL , { any_line , NL } , ">>>" , NL ; + +param_type = "string" | "number" | "boolean" | "object" | "array" ; +step_type = "api_call" | "sampling" | "elicitation" | "transform" | "conditional" ; +comment = "#" , text , NL ; +``` + +--- + +## 3. Lexical Rules + +### 3.1 Lines & whitespace +- Line endings are normalized to `\n` before parsing. +- Leading/trailing whitespace on a line is trimmed. Indentation carries no meaning. + +### 3.2 Comments +Only lines whose first non-whitespace character is `#` are comments. + +``` +# This whole line is a comment +MAP channel = #workspace-admin <- NOT a comment; "#workspace-admin" is the value +``` + +### 3.3 Required document structure +A valid document must have: +1. exactly one `PROJECT ` (must be first meaningful line), +2. a project-level `DESCRIPTION`, +3. at least one `WORKFLOW`. + +--- + +## 4. Project Level + +| Keyword | Form | Rules | +| --- | --- | --- | +| `PROJECT` | `PROJECT ` | First line. Name becomes the output directory. | +| `DESCRIPTION` | `DESCRIPTION ` | Only recognized as project-level before the first `WORKFLOW`. | + +--- + +## 5. Workflow Level + +``` +WORKFLOW + DESCRIPTION + PARAM : [: ] + ... + STEP ... +``` + +- **`WORKFLOW `** — starts a workflow. The name should be snake_case (becomes the MCP tool name). +- **`DESCRIPTION `** — the tool description. Required. +- **`PARAM : [: ]`** — declares an input parameter. + +### 5.1 PARAM types + +| Type | JSON Schema type | +| --- | --- | +| `string` | `string` | +| `number` | `number` | +| `boolean` | `boolean` | +| `object` | `object` | +| `array` | `array` | + +Parameters are referenced in steps via `{{params.}}`. + +--- + +## 6. Step Level + +``` +STEP : + +``` + +- **``** must be unique within the workflow. +- **``** is one of the five step types. + +### 6.1 Step types and required fields + +| Type | Purpose | Required | Output at runtime | +| --- | --- | --- | --- | +| `api_call` | Call a REST endpoint | `OPERATION` | parsed API response | +| `sampling` | LLM reasoning/analysis | `PROMPT` | text string or parsed JSON | +| `elicitation` | Ask the user, wait for response | `MESSAGE` | user's response | +| `transform` | Reshape data via JS | `EXPRESSION` | whatever the expression returns | +| `conditional` | Branch execution | `CONDITION` + (`THEN` or `ELSE`) | boolean | + +### 6.2 Step keyword reference + +| Keyword | Applies to | Value | Notes | +| --- | --- | --- | --- | +| `LABEL` | all | text | Human label | +| `DEPENDS ON` | all | space-separated ids | Explicit ordering (usually inferred) | +| `OPERATION` | api_call | operationId | The REST endpoint to call | +| `OUTPUT_PATH` | api_call | dot path | Extract a sub-field of the response | +| `FOR_EACH` | api_call | collection expression | Iterate over items | +| `AS` | api_call | identifier | Names the loop variable | +| `MAP` | api_call | `path = value` | Builds the request payload | +| `EXPRESSION` | transform | JS (inline/heredoc) | The transform body | +| `CONDITION` | conditional | JS boolean (inline/heredoc) | The branch test | +| `THEN` / `ELSE` | conditional | step id | Branch targets | +| `PROMPT` | sampling | text (inline/heredoc) | The user prompt | +| `SYSTEM_PROMPT` | sampling | text (inline/heredoc) | System instructions | +| `MAX_TOKENS` | sampling | integer | Output cap | +| `RESPONSE_FORMAT` | sampling | token | e.g. `json` to force JSON parsing | +| `MESSAGE` | elicitation | text (inline/heredoc) | The question shown to the user | +| `SCHEMA` | elicitation | JSON (inline/heredoc) | Expected shape of the response | +| `ON_DECLINE` | elicitation | `abort` or `skip_remaining` | What to do if user declines | +| `CONTINUE_ON_ERROR` | api_call | (flag) | Don't fail if this step errors | + +--- + +## 7. MAP: Dot-paths, Auto-typing, Merging + +### 7.1 Dot-paths +`MAP a.b.c = value` produces nested objects: `{ a: { b: { c: value } } }`. + +### 7.2 Merging +Multiple MAPs to the same step deep-merge: + +``` +MAP message.rid = {{params.room_id}} +MAP message.msg = Hello +``` +produces `{ "message": { "rid": "{{params.room_id}}", "msg": "Hello" } }` + +### 7.3 Value auto-typing + +| Input | Result | +| --- | --- | +| `true` / `false` | boolean | +| `42`, `-3.14` | number | +| `{ ... }` / `[ ... ]` (valid JSON) | parsed object/array | +| anything else | string | + +Templates (`{{...}}`) are never coerced — they stay as strings. + +### 7.4 MAP forbids heredoc +`MAP x = <<<` throws an error. Use a transform step for complex values. + +--- + +## 8. Heredocs (Multi-line Values) + +Keywords that hold multi-line content support heredoc syntax: put `<<<` after the keyword, then content lines, then a line that is exactly `>>>`. + +Heredoc-capable keywords: `EXPRESSION`, `CONDITION`, `PROMPT`, `SYSTEM_PROMPT`, `MESSAGE`, `SCHEMA`. + +``` +STEP categorize : transform + EXPRESSION <<< + const channels = steps.get_channels || []; + const cutoff = Date.now() - params.days_inactive * 86400000; + return channels.filter(ch => new Date(ch.lm).getTime() < cutoff); + >>> +``` + +Rules: +- Content between `<<<` and `>>>` is captured verbatim (newlines preserved). +- Triple-brace normalization: `{{{expr}}}` is collapsed to `{{expr}}`. +- An empty value throws. +- Reaching end-of-file before `>>>` throws `Unterminated heredoc (missing >>>)`. +- `SCHEMA` heredocs must contain valid JSON. + +--- + +## 9. Template Expressions & Data Flow + +Steps pass data via `{{...}}` templates. Two reference roots: + +- `{{params.}}` — a workflow parameter. +- `{{steps.}}` — the result of a prior step (sub-fields: `{{steps.fetch.channels}}`). + +For `forEach`, the loop variable is referenced as `{{.field}}`. + +The composer validates every reference and **auto-adds dependencies** when a step references another via `{{steps.X}}`. + +`transform` and `conditional` bodies are raw JavaScript — inside them you use `steps.x`, `params.y` directly (no braces). + +--- + +## 10. Complete Examples + +### 10.1 Minimal — single API call + +``` +PROJECT hello-bot +DESCRIPTION Posts a greeting to a channel + +WORKFLOW post_greeting + DESCRIPTION Send a greeting message + PARAM channel : string : Target channel name + PARAM text : string : Message text + + STEP send : api_call + LABEL Send message + OPERATION post-api-v1-chat_postMessage + MAP channel = {{params.channel}} + MAP text = {{params.text}} +``` + +### 10.2 Multi-step — fetch, AI analysis, branch, act + +``` +PROJECT channel-janitor +DESCRIPTION Archives channels inactive for N days, with AI triage + +WORKFLOW cleanup_channels + DESCRIPTION Find inactive channels, ask AI which are safe to archive, archive them + PARAM days_inactive : number : Days of inactivity threshold + + STEP get_channels : api_call + LABEL Fetch channels + OPERATION get-api-v1-channels_list + MAP count = 100 + OUTPUT_PATH channels + + STEP triage : sampling + LABEL AI triage + RESPONSE_FORMAT json + SYSTEM_PROMPT Respond ONLY with JSON. + PROMPT <<< + Given these channels: {{steps.get_channels}} + Return JSON: { "safe": [names], "risky": [names] } + >>> + + STEP has_safe : conditional + LABEL Any safe to archive? + CONDITION steps.triage.safe.length > 0 + THEN archive + + STEP archive : api_call + LABEL Archive safe channels + OPERATION post-api-v1-channels_archive + FOR_EACH {{steps.triage.safe}} + AS name + MAP roomId = {{name}} + CONTINUE_ON_ERROR +``` + +### 10.3 Human-in-the-loop — elicitation + +``` +PROJECT broadcast +DESCRIPTION Drafts an announcement, confirms with the user, then posts it + +WORKFLOW announce + DESCRIPTION Compose, confirm, broadcast + PARAM topic : string : What to announce + + STEP draft : sampling + LABEL Draft announcement + PROMPT Write a short announcement about: {{params.topic}} + + STEP confirm : elicitation + LABEL Confirm before sending + MESSAGE Send this announcement? {{steps.draft}} + SCHEMA {"type":"object","properties":{"approved":{"type":"boolean"}}} + ON_DECLINE abort + + STEP post : api_call + LABEL Post to #announcements + OPERATION post-api-v1-chat_postMessage + MAP channel = #announcements + MAP text = {{steps.draft}} +``` + +--- + +## 11. Parse-time Error Catalog + +| Error message | Cause | +| --- | --- | +| `Missing PROJECT declaration` | no `PROJECT` line | +| `Missing project DESCRIPTION` | no project-level `DESCRIPTION` | +| `No WORKFLOW declarations found` | zero workflows | +| `STEP requires format "STEP id : type"` | malformed step header | +| `Unknown step type "X"` | type not in the five valid types | +| `PARAM type "X" invalid` | bad param type | +| `Duplicate step ID "X"` / `Duplicate PARAM "X"` | name collisions | +| `MAP requires format "MAP path = value"` | missing `=` | +| `MAP does not support heredoc (<<<)` | heredoc used with MAP | +| `Unterminated heredoc (missing >>>)` | EOF inside a heredoc | +| `Invalid JSON in SCHEMA` | bad SCHEMA JSON | +| `Unknown keyword "X" in step "Y"` | unrecognized keyword | +| `Step "X" (type) requires ` | missing required field | +| `Step "X" has FOR_EACH without AS` | unpaired iteration keywords | + +Every error is prefixed `Line N:` for easy navigation. diff --git a/docs/GENERATED_PROJECT.md b/docs/GENERATED_PROJECT.md new file mode 100644 index 0000000..90376b0 --- /dev/null +++ b/docs/GENERATED_PROJECT.md @@ -0,0 +1,148 @@ +# Generated Project Anatomy + +> What the generator produces, from the perspective of someone who received a generated project and wants to run, configure, understand, or extend it. + +A generated project is a complete, self-contained, runnable MCP server. It does not depend on this generator at runtime — the workflow engine is copied in. + +--- + +## 1. Directory Layout + +``` +/ +├── src/ +│ ├── server.ts # Entry: MCP server + tool registration +│ ├── endpoints.ts # operationId -> {method, path} registry +│ ├── rc-client.ts # REST client (auth, headers) +│ ├── engine/ # Runtime workflow engine (copied verbatim) +│ │ ├── workflow-engine.ts +│ │ ├── executor.ts +│ │ ├── api-call.ts +│ │ ├── sampling.ts +│ │ ├── templates.ts +│ │ └── expression-security.ts +│ ├── tools/ +│ │ └── .ts # One file per workflow (step data + handler) +│ └── tests/ +│ └── .test.ts # One test file per workflow +├── package.json +├── tsconfig.json +├── .gitignore +├── .env.example # copy to .env and fill in +└── README.md +``` + +```mermaid +flowchart TB + SRV[server.ts] -->|registers| TOOLS[tools/*.ts] + TOOLS -->|runWorkflow| ENG[engine/*] + ENG -->|HTTP| CLIENT[rc-client.ts] +``` + +--- + +## 2. What Each Piece Is + +| File / dir | Purpose | +| --- | --- | +| `src/server.ts` | Builds an `McpServer`, imports each workflow tool, registers them with input schemas, and connects the transport | +| `src/endpoints.ts` | The `operationId -> { method, path }` registry | +| `src/rc-client.ts` | The REST client. Handles auth (tokens or auto-login), header injection | +| `src/engine/*` | The runtime that interprets step definitions: scheduling, template resolution, sandboxed evaluator, API execution, and sampling | +| `src/tools/.ts` | Per workflow: the step definitions as data, metadata, and a handler that calls `runWorkflow` | +| `src/tests/*` | Generated tests per workflow | +| `.env.example` | Template for credentials. Copy to `.env` and fill in | +| `.gitignore` | Ignores `node_modules`, `dist`, and `.env` | +| `package.json` | Scripts (`start`, `build`, `test`) and dependencies | + +--- + +## 3. Running It + +```bash +cd +npm install +cp .env.example .env # fill in your credentials +npm start +``` + +`npm start` runs the server from source via `tsx`. The server speaks **stdio** by default — it's meant to be launched by an MCP client. + +| Script | Does | +| --- | --- | +| `npm start` | Run with tsx (loads .env) | +| `npm run build` | Compile TypeScript to dist/ | +| `npm test` | Run the generated tests | + +--- + +## 4. Configuration (`.env`) + +```env +ROCKETCHAT_URL=http://localhost:3000 + +# Mode 1 - username/password (auto-login at startup) +ROCKETCHAT_USER=your-username +ROCKETCHAT_PASSWORD=your-password + +# Mode 2 - pre-existing tokens +# ROCKETCHAT_AUTH_TOKEN=... +# ROCKETCHAT_USER_ID=... +``` + +If a workflow uses `sampling`, AI provider settings are also included: + +```env +# GEMINI_API_KEY=... +``` + +--- + +## 5. Connecting to an MCP Client + +The generated README includes ready-to-paste configs: + +```jsonc +{ + "mcpServers": { + "": { + "command": "node", + "args": ["--env-file-if-exists=.env", "--import", "tsx", "src/server.ts"], + "cwd": "/absolute/path/to/" + } + } +} +``` + +--- + +## 6. How a Tool Runs + +When the client invokes a workflow tool with arguments: + +```mermaid +sequenceDiagram + participant C as MCP Client + participant T as tool handler + participant E as engine + participant API as REST API / LLM / User + C->>T: callTool(name, args) + T->>E: runWorkflow(steps, args) + loop steps in topological order + E->>API: api_call / sampling / elicitation + API-->>E: result + end + E-->>C: { status, completedSteps, stepResults } +``` + +--- + +## 7. Extending a Generated Project + +Because steps are **data**, light changes don't require touching the engine: + +- **Tweak a workflow** — edit the step definitions in `src/tools/.ts` +- **Add a workflow** — add a new tool file following the existing pattern, register it in `src/server.ts` +- **Change auth / base URL** — `.env` only; no code change + +The engine files under `src/engine/` are copied verbatim from the generator. Avoid editing them by hand — changes should be made in the generator and re-generated. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..d17f535 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,89 @@ +# Security Model + +> The threat model for the generator and the servers it produces. The central risk is that LLM-authored expressions run as JavaScript, so the bulk of this document is about how transform/conditional/template expressions are constrained. + +--- + +## 1. What Runs Untrusted Code + +```mermaid +flowchart TB + AI[LLM-authored DSL] --> EXP["transform/conditional expressions
(raw JS)"] + AI --> TPL["template strings {{...}}
(JS expressions)"] + EXP --> SEC[validateSafeExpression
AST allowlist] + TPL --> SEC + SEC --> VM[node:vm sandbox + timeout] +``` + +Three kinds of LLM-authored strings are evaluated as JavaScript: +- **`transform` expressions** — full JS bodies that return a value. +- **`conditional` conditions** — JS booleans. +- **`{{...}}` template expressions** — JS evaluated per placeholder. + +Everything else (operationIds, MAP literals, prompts without templates) is inert data. + +--- + +## 2. Defense in Depth + +| Layer | Where | What it stops | +| --- | --- | --- | +| 1. Parse-time | `dsl/parser.ts` | Structurally invalid steps; MAP heredocs; malformed input | +| 2. Compose-time AST | `composer/validation.ts` | Unsafe transform/conditional before code is generated | +| 3. Runtime AST + sandbox | `workflow/expression-security.ts` + `workflow/templates.ts` | Re-checked on every evaluation, run in isolated VM with timeout | + +The same `validateSafeExpression` runs at both compose time and runtime. + +--- + +## 3. The AST Allowlist + +`validateSafeExpression` parses the expression with **acorn** and walks the AST. The model is **deny-by-default**: a node type, identifier, property, or call is rejected unless explicitly allowed. + +### 3.1 Allowed +- **Node types**: literals, arrays/objects, member access, conditionals/logical/binary/unary, arrow functions, blocks, if/return, template literals, let/const declarations, spreads. +- **Globals**: `undefined/null/true/false/NaN/Infinity`, safe call sets. +- **Identifier calls**: `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, `isNaN`, `isFinite`, `encodeURIComponent`, `decodeURIComponent`. +- **Static calls**: `Array.isArray/from`, `Date.now/parse`, `JSON.parse/stringify`, `Math.*`, `Number.*`, `Object.entries/fromEntries/keys/values`. +- **Instance methods**: common array/string methods (`map`, `filter`, `reduce`, `slice`, `join`, `includes`, `replace`, `split`, `sort`, `toLowerCase`, etc.). + +### 3.2 Blocked +- **Properties**: `__proto__`, `constructor`, `prototype` — prototype-pollution vectors. +- **`var`** declarations (only `let`/`const`). +- **Mutation of `params`/`steps`** — only locally declared variables can be assigned. +- **Any unlisted call** — blocks `require`, `import`, `eval`, `Function`, `fetch`, `process.*`, timers, etc. + +--- + +## 4. The Sandbox + +Validated expressions run in `node:vm` with a constrained context and a hard timeout. + +Properties: +- **No Node built-ins** beyond the explicit safe globals — no `require`, `process`, `Buffer`, `fs`, network, timers. +- **Fresh context per evaluation** (`runInNewContext`) — no shared mutable state. +- **100ms timeout** — caps runaway loops. +- **`"use strict"`** wrapping. +- **Template failures are swallowed** — a `{{...}}` that throws resolves to `""` rather than crashing the workflow. + +--- + +## 5. Threat Checklist + +| Threat | Mitigation | Residual risk | +| --- | --- | --- | +| Arbitrary code via transform/conditional | AST allowlist (compose + runtime) + vm + timeout | POC-grade; no formal audit | +| Prototype pollution / constructor escape | Blocked properties | — | +| Module/host access (require, process, fetch) | deny-by-default calls + no built-ins in sandbox | — | +| Infinite loop / DoS in expression | 100ms vm timeout | many steps can still be slow | +| Reading secrets via process.env | not in sandbox | secrets still live in server env | +| Oversized / abusive API payloads | field validation | API-side limits still apply | + +--- + +## 6. Operational Recommendations + +- Run generated servers with a **least-privilege** Rocket.Chat account. +- Keep credentials in the server's `.env`, never in the DSL or generated source. +- Review generated transform/conditional code before deploying to production. +- Treat content fetched by sampling and external specs as untrusted input. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..ff59277 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,112 @@ +# Testing Guide + +> A map of the test suite, what each area covers, how to run subsets, and how to add new tests. Tests use Node's built-in runner (`node:test`) with `node:assert/strict` and `tsx` for TypeScript. + +--- + +## 1. How to Run + +| Command | Runs | +| --- | --- | +| `npm test` | The full test suite | +| `npm run build` | Compile TypeScript (build verification) | + +Run a single file directly: + +```bash +npx tsx --test src/tests/dsl/parser.unit.test.ts +``` + +--- + +## 2. The Test Tree + +``` +src/tests/ +├── dsl/ # DSL parser + scanner +├── parser/ # OpenAPI fetch, extract, schema, fuzzy +├── tools/ # MCP tool handlers + protocol smoke +├── utils/ # operationId resolver +├── generator/ # code generation integration +├── workflow/ # engine, executor, sandbox, security +└── integration/ # full pipeline end-to-end +``` + +Naming conventions: +- `*.unit.test.ts` — isolated module behavior +- `*.integration.test.ts` — behavior spanning modules +- `*.smoke.test.ts` — MCP protocol / end-to-end sanity + +--- + +## 3. What Each Area Covers + +### 3.1 `dsl/` — DSL parser & scanner + +| File | Focus | +| --- | --- | +| `scanner.unit.test.ts` | Line iteration, blank/comment skipping, heredoc collection | +| `parser.unit.test.ts` | Overall parse of valid documents | +| `parser-errors.unit.test.ts` | Every parse-time error | +| `parser-integration.unit.test.ts` | Complex multi-workflow documents | + +### 3.2 `parser/` — OpenAPI parsing + +| File | Focus | +| --- | --- | +| `spec-source.unit.test.ts` | Cache tiers, TTL, URL building | +| `endpoint-extraction.unit.test.ts` | Compact/full extraction, id sanitize/dedupe | +| `schema-mapper.unit.test.ts` | OpenAPI -> JSON Schema, nullable, cycle/depth guards | +| `fuzzy-match.unit.test.ts` | Exact/normalized/fuzzy operationId matching | +| `spec-parser.unit.test.ts` | SpecParser orchestration | +| `get-full-endpoints.integration.test.ts` | Fast/slow/fuzzy paths | +| `index.integration.test.ts` | Parser public surface | +| `cache-and-stats.integration.test.ts` | Caching behavior | + +### 3.3 `tools/` — MCP tool handlers + +| File | Focus | +| --- | --- | +| `format-capability-guide.unit.test.ts` | Guide formatting, grouping | +| `get-capability-guide.unit.test.ts` | Discovery handler | +| `get-endpoint-schemas.unit.test.ts` | Schema handler, corrected/unmatched ids | +| `mcp-protocol.smoke.test.ts` | `listTools`/`callTool` protocol surface | +| `annotations.integration.test.ts` | Endpoint annotations | + +### 3.4 `workflow/` — runtime engine + +| File | Focus | +| --- | --- | +| `executor.integration.test.ts` | Execution loop, step scheduling, branching | +| `sandbox-security.unit.test.ts` | Sandbox escapes are blocked | + +### 3.5 `generator/` — code generation + +| File | Focus | +| --- | --- | +| `generate.integration.test.ts` | MCP tool invocation and project output | +| `pipeline.integration.test.ts` | DSL string -> generated project (full pipeline) | + +### 3.6 `integration/` — full pipeline + +| File | Focus | +| --- | --- | +| `pipeline.integration.test.ts` | End-to-end: DSL -> compose -> generate -> validate output | + +--- + +## 4. Testing Conventions + +- **Use narrow interfaces for mocks.** Tool handler tests can implement source interfaces with only the needed methods. +- **Assert behavior, not implementation.** Prefer asserting on produced `WorkflowDefinition`, generated file content, or engine results. +- **Keep tests focused.** One test file per behavior area. + +--- + +## 5. Adding Tests + +When changing the **parser**, cover: exact operationId matches, fuzzy-corrected ids, unmatched ids, request-body extraction, path/query/header parameters, and response schema output. + +When changing the **composer** or **engine**, cover the normalization/validation rule you touched and at least one end-to-end case. + +When changing **MCP tool surface**, add or update a smoke test around `listTools()`/`callTool()`. diff --git a/src/tests/integration/pipeline.integration.test.ts b/src/tests/integration/pipeline.integration.test.ts new file mode 100644 index 0000000..b019561 --- /dev/null +++ b/src/tests/integration/pipeline.integration.test.ts @@ -0,0 +1,391 @@ +/** + * End-to-end integration tests for the full pipeline: DSL string -> generated project. + * + * Verifies that: + * 1. The DSL parser produces valid structures + * 2. The composer validates and transforms workflows + * 3. The code generator produces valid TypeScript files + * 4. The generated project structure is complete + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { composeDsl, generateFromDsl } from "../../generator/pipeline.js"; +import { generateProject } from "../../generator/project.js"; +import type { GeneratorEndpoint } from "../../generator/types.js"; + +const MINIMAL_DSL = ` +PROJECT test-bot +DESCRIPTION A minimal test bot + +WORKFLOW send_greeting + DESCRIPTION Sends a greeting message to a channel + PARAM channel_name : string : The channel to greet + PARAM message : string : The greeting message + + STEP post_message : api_call + LABEL Post greeting message + OPERATION post-api-v1-chat_postMessage + MAP channel = {{params.channel_name}} + MAP text = {{params.message}} +`; + +const MULTI_WORKFLOW_DSL = ` +PROJECT onboarding-bot +DESCRIPTION Automates user onboarding workflows + +WORKFLOW create_onboarding_channel + DESCRIPTION Creates a private onboarding channel for a new user + PARAM username : string : The new user's username + PARAM channel_name : string : Name for the onboarding channel + + STEP create_channel : api_call + LABEL Create onboarding channel + OPERATION post-api-v1-channels_create + MAP name = {{params.channel_name}} + + STEP invite_user : api_call + LABEL Invite user to channel + DEPENDS ON create_channel + OPERATION post-api-v1-channels_invite + MAP roomId = {{steps.create_channel.channel._id}} + MAP userId = {{params.username}} + + STEP send_welcome : api_call + LABEL Send welcome message + DEPENDS ON invite_user + OPERATION post-api-v1-chat_postMessage + MAP channel = {{params.channel_name}} + MAP text = Welcome aboard! + +WORKFLOW analyze_sentiment + DESCRIPTION Analyzes message sentiment using AI + PARAM room_id : string : Room to analyze + PARAM count : number : Number of messages to analyze + + STEP fetch_messages : api_call + LABEL Fetch recent messages + OPERATION get-api-v1-channels_history + MAP roomId = {{params.room_id}} + MAP count = {{params.count}} + + STEP analyze : sampling + LABEL Analyze sentiment + DEPENDS ON fetch_messages + PROMPT <<< +Analyze the sentiment of these messages and provide a summary. +Messages: {{steps.fetch_messages}} +Respond with a JSON object containing: overall_sentiment, positive_count, negative_count. +>>> + SYSTEM_PROMPT You are a sentiment analysis expert. Always respond in JSON. + MAX_TOKENS 500 +`; + +const TRANSFORM_DSL = ` +PROJECT data-pipeline +DESCRIPTION Data transformation workflows + +WORKFLOW extract_users + DESCRIPTION Extracts and transforms user data + PARAM department : string : Department to filter + + STEP get_users : api_call + LABEL Get all users + OPERATION get-api-v1-users_list + + STEP filter_dept : transform + LABEL Filter by department + DEPENDS ON get_users + EXPRESSION <<< +const users = steps.get_users || []; +return users.filter(u => u.department === params.department); +>>> + + STEP format_report : transform + LABEL Format the report + DEPENDS ON filter_dept + EXPRESSION <<< +const filtered = steps.filter_dept; +return { department: params.department, count: filtered.length, users: filtered.map(u => u.username) }; +>>> +`; + +const CONDITIONAL_DSL = ` +PROJECT moderation-bot +DESCRIPTION Automated content moderation + +WORKFLOW moderate_message + DESCRIPTION Checks and moderates a message + PARAM room_id : string : The room ID + PARAM message_id : string : The message ID + + STEP fetch_msg : api_call + LABEL Fetch the message + OPERATION get-api-v1-chat_getMessage + MAP msgId = {{params.message_id}} + + STEP check_content : sampling + LABEL Check for violations + DEPENDS ON fetch_msg + PROMPT <<< +Check if this message violates community guidelines: +{{steps.fetch_msg}} +Respond with JSON: { "violation": true/false, "reason": "..." } +>>> + SYSTEM_PROMPT You are a content moderator. Respond only with JSON. + RESPONSE_FORMAT json + + STEP is_violation : conditional + LABEL Check if violation found + DEPENDS ON check_content + CONDITION steps.check_content.violation === true + THEN delete_msg + ELSE log_clean + + STEP delete_msg : api_call + LABEL Delete the message + DEPENDS ON is_violation + OPERATION post-api-v1-chat_delete + MAP roomId = {{params.room_id}} + MAP msgId = {{params.message_id}} + + STEP log_clean : transform + LABEL Log clean result + DEPENDS ON is_violation + EXPRESSION ({ status: "clean", messageId: params.message_id }) +`; + +describe("composeDsl — parse and compose pipeline", () => { + it("parses and composes a minimal workflow", () => { + const result = composeDsl(MINIMAL_DSL); + assert.equal(result.projectName, "test-bot"); + assert.equal(result.workflows.length, 1); + assert.equal(result.workflows[0].name, "send_greeting"); + }); + + it("parses and composes multiple workflows", () => { + const result = composeDsl(MULTI_WORKFLOW_DSL); + assert.equal(result.projectName, "onboarding-bot"); + assert.equal(result.workflows.length, 2); + const names = result.workflows.map((w) => w.name); + assert.ok(names.includes("create_onboarding_channel")); + assert.ok(names.includes("analyze_sentiment")); + }); + + it("composes transform workflows", () => { + const result = composeDsl(TRANSFORM_DSL); + assert.equal(result.workflows.length, 1); + const wf = result.workflows[0]; + assert.equal(wf.name, "extract_users"); + assert.equal(wf.steps.length, 3); + }); + + it("composes conditional workflows", () => { + const result = composeDsl(CONDITIONAL_DSL); + const wf = result.workflows[0]; + const condStep = wf.steps.find( + (s) => s.config.type === "conditional", + ); + assert.ok(condStep, "Should have a conditional step"); + assert.equal(condStep!.id, "is_violation"); + }); + + it("detects sampling capability", () => { + const result = composeDsl(MULTI_WORKFLOW_DSL); + const samplingWf = result.workflows.find( + (w) => w.name === "analyze_sentiment", + )!; + assert.ok(samplingWf.usesSampling); + }); + + it("throws on invalid DSL", () => { + assert.throws(() => composeDsl("INVALID_KEYWORD something")); + }); + + it("throws on missing workflow", () => { + assert.throws( + () => composeDsl("PROJECT x\nDESCRIPTION y\n"), + /No WORKFLOW/, + ); + }); +}); + +describe("generateFromDsl — full generation pipeline", () => { + const endpoints: GeneratorEndpoint[] = [ + { + operationId: "post-api-v1-chat_postMessage", + method: "POST", + path: "/api/v1/chat.postMessage", + }, + ]; + + it("generates a project from minimal DSL", () => { + const result = generateFromDsl(MINIMAL_DSL, { endpoints }); + assert.equal(result.files.length > 0, true); + assert.equal(result.summary.serverName, "test_bot"); + assert.equal(result.summary.workflowCount, 1); + }); + + it("generates workflow tool file", () => { + const result = generateFromDsl(MINIMAL_DSL, { endpoints }); + const toolFile = result.files.find( + (f) => f.path === "src/tools/send_greeting.ts", + ); + assert.ok(toolFile, "Missing workflow tool file"); + assert.ok(toolFile!.content.includes("send_greeting")); + }); +}); + +describe("generateProject — file structure completeness", () => { + const composed = composeDsl(MULTI_WORKFLOW_DSL); + const endpoints: GeneratorEndpoint[] = [ + { operationId: "post-api-v1-channels_create", method: "POST", path: "/api/v1/channels.create" }, + { operationId: "post-api-v1-channels_invite", method: "POST", path: "/api/v1/channels.invite" }, + { operationId: "post-api-v1-chat_postMessage", method: "POST", path: "/api/v1/chat.postMessage" }, + { operationId: "get-api-v1-channels_history", method: "GET", path: "/api/v1/channels.history" }, + ]; + + const result = generateProject({ + serverName: composed.projectName, + workflows: composed.workflows, + endpoints, + }); + + it("generates all required files", () => { + const paths = result.files.map((f) => f.path); + assert.ok(paths.includes("src/server.ts"), "Missing server.ts"); + assert.ok(paths.includes("src/rc-client.ts"), "Missing rc-client.ts"); + assert.ok(paths.includes("package.json"), "Missing package.json"); + assert.ok(paths.includes("tsconfig.json"), "Missing tsconfig.json"); + assert.ok(paths.includes(".env.example"), "Missing .env.example"); + assert.ok(paths.includes(".gitignore"), "Missing .gitignore"); + assert.ok(paths.includes("README.md"), "Missing README.md"); + }); + + it("generates tool files for both workflows", () => { + const paths = result.files.map((f) => f.path); + assert.ok(paths.includes("src/tools/create_onboarding_channel.ts")); + assert.ok(paths.includes("src/tools/analyze_sentiment.ts")); + }); + + it("server entry imports both workflows", () => { + const serverFile = result.files.find((f) => f.path === "src/server.ts"); + assert.ok(serverFile); + assert.ok(serverFile!.content.includes("create_onboarding_channel")); + assert.ok(serverFile!.content.includes("analyze_sentiment")); + }); + + it("package.json is valid JSON with correct fields", () => { + const pkgFile = result.files.find((f) => f.path === "package.json"); + assert.ok(pkgFile); + const pkg = JSON.parse(pkgFile!.content); + assert.equal(pkg.type, "module"); + assert.ok(pkg.dependencies["@modelcontextprotocol/sdk"]); + assert.ok(pkg.dependencies["zod"]); + assert.ok(pkg.scripts.start); + assert.ok(pkg.scripts.build); + }); + + it("tsconfig.json is valid JSON with ESM settings", () => { + const tsFile = result.files.find((f) => f.path === "tsconfig.json"); + assert.ok(tsFile); + const config = JSON.parse(tsFile!.content); + assert.ok(config.compilerOptions.strict); + assert.equal(config.compilerOptions.module, "Node16"); + }); + + it("endpoint map includes all required endpoints", () => { + const epFile = result.files.find((f) => f.path === "src/endpoints.ts"); + assert.ok(epFile); + assert.ok(epFile!.content.includes("post-api-v1-channels_create")); + assert.ok(epFile!.content.includes("post-api-v1-chat_postMessage")); + assert.ok(epFile!.content.includes("get-api-v1-channels_history")); + }); + + it("server.ts has valid import structure", () => { + const serverFile = result.files.find((f) => f.path === "src/server.ts"); + assert.ok(serverFile); + const code = serverFile!.content; + assert.ok(code.includes("import { McpServer }") || code.includes("McpServer")); + assert.ok(code.includes("StdioServerTransport")); + }); + + it("workflow tool code references the workflow engine", () => { + const toolFile = result.files.find( + (f) => f.path === "src/tools/create_onboarding_channel.ts", + ); + assert.ok(toolFile); + assert.ok(toolFile!.content.includes("runWorkflow")); + }); + + it("README includes project documentation", () => { + const readme = result.files.find((f) => f.path === "README.md"); + assert.ok(readme); + assert.ok(readme!.content.includes("onboarding")); + assert.ok(readme!.content.includes("npm install")); + }); + + it("reports correct summary", () => { + assert.equal(result.summary.workflowCount, 2); + assert.equal(result.summary.endpointCount, 4); + assert.ok(result.summary.usesSampling); + }); +}); + +describe("generateProject — error handling", () => { + it("throws on empty workflows array", () => { + assert.throws( + () => + generateProject({ + serverName: "test", + workflows: [], + endpoints: [], + }), + /no workflows/i, + ); + }); +}); + +describe("generateProject — generated TypeScript validity", () => { + const composed = composeDsl(MINIMAL_DSL); + const endpoints: GeneratorEndpoint[] = [ + { operationId: "post-api-v1-chat_postMessage", method: "POST", path: "/api/v1/chat.postMessage" }, + ]; + + const result = generateProject({ + serverName: composed.projectName, + workflows: composed.workflows, + endpoints, + }); + + it("server.ts has valid async main structure", () => { + const serverFile = result.files.find((f) => f.path === "src/server.ts"); + assert.ok(serverFile); + const code = serverFile!.content; + assert.ok(code.includes("async function main()") || code.includes("async")); + assert.ok(code.includes("connect")); + }); + + it("tool file has export structure", () => { + const toolFile = result.files.find( + (f) => f.path === "src/tools/send_greeting.ts", + ); + assert.ok(toolFile); + const code = toolFile!.content; + assert.ok(code.includes("export")); + assert.ok(code.includes("tool")); + }); + + it(".gitignore excludes sensitive files", () => { + const gitignore = result.files.find((f) => f.path === ".gitignore"); + assert.ok(gitignore); + assert.ok(gitignore!.content.includes("node_modules")); + assert.ok(gitignore!.content.includes(".env")); + }); + + it(".env.example contains credential placeholders", () => { + const envFile = result.files.find((f) => f.path === ".env.example"); + assert.ok(envFile); + assert.ok(envFile!.content.includes("ROCKETCHAT_URL")); + }); +}); From 22023132ae81c67f8508923a7f156a1a8ac7f057 Mon Sep 17 00:00:00 2001 From: sezallagwal Date: Wed, 8 Jul 2026 02:22:10 +0530 Subject: [PATCH 2/2] Address review: generate tests, document DSL constructs, compile smoke test, CI on stacked PRs - Generator now emits src/tests/setup.ts + per-workflow smoke tests and a 'test' script, so the generated-project docs/README claims are accurate - Document CONTENT_TEXT, CONTENT_IMAGE (multimodal sampling) and the top-level WEBHOOK construct in the DSL reference (WEBHOOK noted as parsed-but-not-yet-emitted) - Add generated-project.integration.test.ts: writes a project to a temp dir, runs 'tsc --noEmit' over it, and runs its generated npm test - CI now runs on all PRs (not only those targeting main) so stacked PRs get format/lint/typecheck/test/build - Fix pre-existing lint errors and repo-wide Prettier formatting surfaced by enabling CI; correct stale docs (engine filenames, GEMINI_API_KEY claim) and add missing lint:fix script --- .github/workflows/ci.yml | 4 +- .gitignore | 3 + CONTRIBUTING.md | 38 ++-- GEMINI.md | 1 + README.md | 10 +- docs/ARCHITECTURE.md | 112 +++++----- docs/DSL_REFERENCE.md | 203 ++++++++++++------ docs/GENERATED_PROJECT.md | 58 ++--- docs/SECURITY.md | 28 +-- docs/TESTING.md | 73 +++---- package.json | 1 + src/composer/inference.ts | 23 +- src/composer/normalization.ts | 40 ++-- src/composer/utils.ts | 6 +- src/composer/validation.ts | 49 +++-- src/composer/warnings.ts | 14 +- src/generator/codegen.ts | 193 ++++++++++++++++- src/generator/dsl-mapping.ts | 30 ++- src/generator/project.ts | 31 ++- src/generator/scaffold.ts | 24 ++- .../generator/generate.integration.test.ts | 63 ++++-- .../generated-project.integration.test.ts | 148 +++++++++++++ .../integration/pipeline.integration.test.ts | 38 +++- .../workflow/executor.integration.test.ts | 83 +++++-- .../workflow/sandbox-security.unit.test.ts | 11 +- src/tools/generate.ts | 12 +- src/workflow/api-call.ts | 18 +- src/workflow/executor.ts | 44 +++- src/workflow/expression-security.ts | 31 ++- src/workflow/sampling.ts | 7 +- src/workflow/templates.ts | 5 +- 31 files changed, 1044 insertions(+), 357 deletions(-) create mode 100644 src/tests/generator/generated-project.integration.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0751286..21476c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,10 @@ name: CI on: push: branches: [main] + # Run on every PR, including stacked PRs that target feature branches (not + # only PRs into main), so format/lint/typecheck/test/build issues are caught + # early while the repo grows. pull_request: - branches: [main] permissions: contents: read diff --git a/.gitignore b/.gitignore index e667617..1b156db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ .env .cache/ npm-debug.log* + +# Temp output from integration tests that write generated projects to disk +.tmp-*/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 717526d..f38d061 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,16 +31,16 @@ npm run build ## Everyday Commands -| Command | What it does | -| --- | --- | -| `npm run dev` | Run the MCP server from source (via `tsx`) for local iteration | -| `npm start` | Run the compiled server from `dist/` | -| `npm run build` | Compile TypeScript to `dist/` | -| `npm run typecheck` | Type-check without emitting files | -| `npm test` | Run the full unit/integration test suite | -| `npm run lint` / `npm run lint:fix` | Lint (and auto-fix) with ESLint | -| `npm run format` / `npm run format:check` | Format (and check) with Prettier | -| `npm run check` | Everything above, gate-style — run this before pushing | +| Command | What it does | +| ----------------------------------------- | -------------------------------------------------------------- | +| `npm run dev` | Run the MCP server from source (via `tsx`) for local iteration | +| `npm start` | Run the compiled server from `dist/` | +| `npm run build` | Compile TypeScript to `dist/` | +| `npm run typecheck` | Type-check without emitting files | +| `npm test` | Run the full unit/integration test suite | +| `npm run lint` / `npm run lint:fix` | Lint (and auto-fix) with ESLint | +| `npm run format` / `npm run format:check` | Format (and check) with Prettier | +| `npm run check` | Everything above, gate-style — run this before pushing | ## Development Workflow @@ -64,15 +64,15 @@ npm run build The source is organized by responsibility under `src/`: -| Directory | Responsibility | -| --- | --- | -| `src/tools/` | The three MCP tool handlers | -| `src/dsl/` | DSL parser (scanner + recursive descent parser) | -| `src/composer/` | Workflow validation, normalization, inference, ordering | -| `src/generator/` | Code generation pipeline | -| `src/parser/` | OpenAPI fetch, cache, endpoint extraction | -| `src/workflow/` | Runtime engine (executor, templates, sandbox, security) | -| `src/utils/` | operationId resolver | +| Directory | Responsibility | +| ---------------- | ------------------------------------------------------- | +| `src/tools/` | The three MCP tool handlers | +| `src/dsl/` | DSL parser (scanner + recursive descent parser) | +| `src/composer/` | Workflow validation, normalization, inference, ordering | +| `src/generator/` | Code generation pipeline | +| `src/parser/` | OpenAPI fetch, cache, endpoint extraction | +| `src/workflow/` | Runtime engine (executor, templates, sandbox, security) | +| `src/utils/` | operationId resolver | For a full explanation of the architecture, read [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). diff --git a/GEMINI.md b/GEMINI.md index 26658a7..fcaa8c0 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -380,6 +380,7 @@ STEP post_report : api_call ``` **Do NOT** put complex logic directly in MAP: + ``` # ❌ WRONG — MAP does not support heredoc or complex expressions MAP text = <<< diff --git a/README.md b/README.md index da00579..e48c5a9 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,11 @@ The output is **not** a thin REST wrapper. Each generated tool is a **workflow** It is itself an MCP server that exposes three tools. An AI client calls them in sequence: -| Tool | Purpose | -| --- | --- | -| **`get_capability_guide`** | Discovers and lists the available REST API endpoints | -| **`get_endpoint_schemas`** | Returns exact request/response schemas for the chosen endpoints | -| **`generate`** | Validates the described workflows and writes a complete MCP server project | +| Tool | Purpose | +| -------------------------- | -------------------------------------------------------------------------- | +| **`get_capability_guide`** | Discovers and lists the available REST API endpoints | +| **`get_endpoint_schemas`** | Returns exact request/response schemas for the chosen endpoints | +| **`generate`** | Validates the described workflows and writes a complete MCP server project | ``` Describe intent -> get_capability_guide -> get_endpoint_schemas -> generate -> ready to deploy diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e7db447..1077c49 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -84,11 +84,11 @@ sequenceDiagram G-->>AI: success + project location ``` -| Tool | Responsibility | Backed by module | -|------|----------------|------------------| -| `get_capability_guide` | Discovery: list every endpoint as `summary -> operationId` | `tools/get-capability-guide.ts` | +| Tool | Responsibility | Backed by module | +| ---------------------- | ------------------------------------------------------------ | ------------------------------- | +| `get_capability_guide` | Discovery: list every endpoint as `summary -> operationId` | `tools/get-capability-guide.ts` | | `get_endpoint_schemas` | Detail: return exact request/response schemas for chosen ids | `tools/get-endpoint-schemas.ts` | -| `generate` | Build a full MCP server project from a DSL string | `tools/generate.ts` | +| `generate` | Build a full MCP server project from a DSL string | `tools/generate.ts` | --- @@ -174,15 +174,15 @@ stateDiagram-v2 The "middle-end" — semantic analysis, normalization, validation, and ordering. It takes raw parsed steps and produces a fully validated, dependency-ordered `WorkflowDefinition`. -| File | Responsibility | -|------|----------------| -| `composer.ts` | Orchestrates the full validation/normalization pipeline | -| `dsl-mapping.ts` | Adapts raw DSL workflow objects into the composer's input shape | -| `validation.ts` | Structural checks: unique ids, required fields, reference integrity, cycles | -| `normalization.ts` | Auto-fixes common authoring mistakes (template syntax, escaping) | -| `inference.ts` | Fills in omitted information (conditional targets, implicit dependencies) | -| `warnings.ts` | Emits non-fatal quality warnings (unused steps, orphans, deep chains) | -| `types.ts` | Composer-level input/output and warning types | +| File | Responsibility | +| ------------------ | --------------------------------------------------------------------------- | +| `composer.ts` | Orchestrates the full validation/normalization pipeline | +| `dsl-mapping.ts` | Adapts raw DSL workflow objects into the composer's input shape | +| `validation.ts` | Structural checks: unique ids, required fields, reference integrity, cycles | +| `normalization.ts` | Auto-fixes common authoring mistakes (template syntax, escaping) | +| `inference.ts` | Fills in omitted information (conditional targets, implicit dependencies) | +| `warnings.ts` | Emits non-fatal quality warnings (unused steps, orphans, deep chains) | +| `types.ts` | Composer-level input/output and warning types | ```mermaid flowchart LR @@ -198,15 +198,15 @@ flowchart LR The "back-end" — turns a validated `WorkflowDefinition` plus endpoint info into a complete set of project files. -| File | Responsibility | -|------|----------------| -| `pipeline.ts` | Full pipeline: DSL -> parsed -> composed -> generated project | -| `project.ts` | Assembles the complete file set for a project | -| `codegen.ts` | Generates per-workflow tool files, endpoint maps, server entry | -| `scaffold.ts` | Static/templated files: REST client, package.json, tsconfig, README | -| `engine-bundle.ts` | Copies the workflow engine into the output | -| `dsl-mapping.ts` | Maps DSL structures to generator input | -| `types.ts` | Generator-level types | +| File | Responsibility | +| ------------------ | ------------------------------------------------------------------- | +| `pipeline.ts` | Full pipeline: DSL -> parsed -> composed -> generated project | +| `project.ts` | Assembles the complete file set for a project | +| `codegen.ts` | Generates per-workflow tool files, endpoint maps, server entry | +| `scaffold.ts` | Static/templated files: REST client, package.json, tsconfig, README | +| `engine-bundle.ts` | Copies the workflow engine into the output | +| `dsl-mapping.ts` | Maps DSL structures to generator input | +| `types.ts` | Generator-level types | ```mermaid flowchart TB @@ -222,14 +222,14 @@ flowchart TB Reads API knowledge from OpenAPI specs and exposes it to the tools. -| File | Responsibility | -|------|----------------| -| `spec-source.ts` | Fetches and caches OpenAPI documents (in-memory / disk / remote) | -| `spec-parser.ts` | Implements `SpecParser`: lists endpoints and returns full endpoint details | -| `endpoint-extraction.ts` | Extracts compact and full endpoint records from a parsed spec | -| `schema-mapper.ts` | Converts OpenAPI schemas into JSON-Schema-style shapes | -| `types.ts` | Domain, endpoint, and parser interface types | -| `index.ts` | Public surface | +| File | Responsibility | +| ------------------------ | -------------------------------------------------------------------------- | +| `spec-source.ts` | Fetches and caches OpenAPI documents (in-memory / disk / remote) | +| `spec-parser.ts` | Implements `SpecParser`: lists endpoints and returns full endpoint details | +| `endpoint-extraction.ts` | Extracts compact and full endpoint records from a parsed spec | +| `schema-mapper.ts` | Converts OpenAPI schemas into JSON-Schema-style shapes | +| `types.ts` | Domain, endpoint, and parser interface types | +| `index.ts` | Public surface | ```mermaid flowchart LR @@ -246,14 +246,14 @@ flowchart LR The runtime engine. These files are **copied verbatim into every generated project** — they are both part of this repo and the runtime of the output. -| File | Responsibility | -|------|----------------| -| `executor.ts` | The execution loop: step scheduling, branching, error handling | -| `api-call.ts` | Executes `api_call` steps (payload, request, response parsing) | -| `sampling.ts` | Executes `sampling` (LLM) steps | -| `templates.ts` | Evaluates template expressions and resolves `{{...}}` placeholders | -| `expression-security.ts` | Guards expressions against dangerous patterns (AST allowlist) | -| `types.ts` | `WorkflowDefinition` / step types shared with codegen | +| File | Responsibility | +| ------------------------ | ------------------------------------------------------------------ | +| `executor.ts` | The execution loop: step scheduling, branching, error handling | +| `api-call.ts` | Executes `api_call` steps (payload, request, response parsing) | +| `sampling.ts` | Executes `sampling` (LLM) steps | +| `templates.ts` | Evaluates template expressions and resolves `{{...}}` placeholders | +| `expression-security.ts` | Guards expressions against dangerous patterns (AST allowlist) | +| `types.ts` | `WorkflowDefinition` / step types shared with codegen | ```mermaid flowchart TB @@ -346,26 +346,26 @@ flowchart LR C[conditional
branch on a boolean] ``` -| Type | Purpose | Required fields | Output | -|------|---------|-----------------|--------| -| `api_call` | Call a REST endpoint | `operationId` | Parsed response | -| `sampling` | LLM reasoning/analysis | `prompt` | Text or JSON | -| `elicitation` | Ask the user, wait for input | `message`, `requestedSchema` | User response | -| `transform` | Reshape data | `expression` | Expression result | -| `conditional` | Branch execution | `condition`, `thenStep` | Boolean | +| Type | Purpose | Required fields | Output | +| ------------- | ---------------------------- | ---------------------------- | ----------------- | +| `api_call` | Call a REST endpoint | `operationId` | Parsed response | +| `sampling` | LLM reasoning/analysis | `prompt` | Text or JSON | +| `elicitation` | Ask the user, wait for input | `message`, `requestedSchema` | User response | +| `transform` | Reshape data | `expression` | Expression result | +| `conditional` | Branch execution | `condition`, `thenStep` | Boolean | --- ## 10. Module Responsibility Summary -| Module | Layer | One-line responsibility | -|--------|-------|-------------------------| -| `src/index.ts` | Entry | Boot the MCP server over stdio | -| `src/server.ts` | Entry | Register the three MCP tools (composition root) | -| `src/tools` | Tool | Request handlers for discovery, schemas, generation | -| `src/dsl` | Core | Parse DSL text into structured workflows | -| `src/composer` | Core | Validate, normalize, infer, and order workflows | -| `src/generator` | Core | Generate and write all project files | -| `src/parser` | Support | Fetch/parse OpenAPI specs; expose endpoints + schemas | -| `src/workflow` | Support | Runtime engine (copied into output) | -| `src/utils` | Support | operationId reconciliation | +| Module | Layer | One-line responsibility | +| --------------- | ------- | ----------------------------------------------------- | +| `src/index.ts` | Entry | Boot the MCP server over stdio | +| `src/server.ts` | Entry | Register the three MCP tools (composition root) | +| `src/tools` | Tool | Request handlers for discovery, schemas, generation | +| `src/dsl` | Core | Parse DSL text into structured workflows | +| `src/composer` | Core | Validate, normalize, infer, and order workflows | +| `src/generator` | Core | Generate and write all project files | +| `src/parser` | Support | Fetch/parse OpenAPI specs; expose endpoints + schemas | +| `src/workflow` | Support | Runtime engine (copied into output) | +| `src/utils` | Support | operationId reconciliation | diff --git a/docs/DSL_REFERENCE.md b/docs/DSL_REFERENCE.md index 445ec02..9e963e6 100644 --- a/docs/DSL_REFERENCE.md +++ b/docs/DSL_REFERENCE.md @@ -6,7 +6,7 @@ ## 1. Overview -The DSL is **flat and line-oriented**: every meaningful line is `KEYWORD value`. There is no significant indentation (indentation is cosmetic and trimmed). A document declares one **project**, which contains one or more **workflows**, each containing one or more **steps**. Each workflow becomes one MCP tool; each step is one unit of work (an API call, an LLM call, a user prompt, a data transform, or a branch). +The DSL is **flat and line-oriented**: every meaningful line is `KEYWORD value`. There is no significant indentation (indentation is cosmetic and trimmed). A document declares one **project**, which contains one or more **workflows** (and, optionally, one or more top-level **webhook** endpoints — see §12), each workflow containing one or more **steps**. Each workflow becomes one MCP tool; each step is one unit of work (an API call, an LLM call, a user prompt, a data transform, or a branch). ``` PROJECT @@ -26,7 +26,7 @@ Design goal: be robust against the ways an LLM mangles structured formats. Keywo ## 2. Grammar (EBNF-style) ```ebnf -document = project_decl , project_desc , { workflow } ; +document = project_decl , project_desc , { workflow | webhook } ; project_decl = "PROJECT" , WS , name , NL ; project_desc = "DESCRIPTION" , WS , text , NL ; @@ -41,9 +41,19 @@ step = "STEP" , WS , id , ":" , step_type , NL , step_field = label | depends | operation | output_path | for_each | as | map | expression | condition | then | else - | prompt | system_prompt | max_tokens | response_format + | prompt | system_prompt | content_text | content_image + | max_tokens | response_format | message | schema | on_decline | continue_on_error ; +content_text = "CONTENT_TEXT" , ( WS , text | heredoc ) , NL ; +content_image = "CONTENT_IMAGE" , WS , url , NL ; + +webhook = "WEBHOOK" , WS , path , NL , + { webhook_desc | methods } ; +webhook_desc = "DESCRIPTION" , WS , text , NL ; +methods = "METHODS" , WS , method , { WS , method } , NL ; +method = "get" | "post" ; + heredoc = WS , "<<<" , NL , { any_line , NL } , ">>>" , NL ; param_type = "string" | "number" | "boolean" | "object" | "array" ; @@ -56,10 +66,12 @@ comment = "#" , text , NL ; ## 3. Lexical Rules ### 3.1 Lines & whitespace + - Line endings are normalized to `\n` before parsing. - Leading/trailing whitespace on a line is trimmed. Indentation carries no meaning. ### 3.2 Comments + Only lines whose first non-whitespace character is `#` are comments. ``` @@ -68,7 +80,9 @@ MAP channel = #workspace-admin <- NOT a comment; "#workspace-admin" is the va ``` ### 3.3 Required document structure + A valid document must have: + 1. exactly one `PROJECT ` (must be first meaningful line), 2. a project-level `DESCRIPTION`, 3. at least one `WORKFLOW`. @@ -77,9 +91,9 @@ A valid document must have: ## 4. Project Level -| Keyword | Form | Rules | -| --- | --- | --- | -| `PROJECT` | `PROJECT ` | First line. Name becomes the output directory. | +| Keyword | Form | Rules | +| ------------- | -------------------- | ------------------------------------------------------------- | +| `PROJECT` | `PROJECT ` | First line. Name becomes the output directory. | | `DESCRIPTION` | `DESCRIPTION ` | Only recognized as project-level before the first `WORKFLOW`. | --- @@ -100,13 +114,13 @@ WORKFLOW ### 5.1 PARAM types -| Type | JSON Schema type | -| --- | --- | -| `string` | `string` | -| `number` | `number` | -| `boolean` | `boolean` | -| `object` | `object` | -| `array` | `array` | +| Type | JSON Schema type | +| --------- | ---------------- | +| `string` | `string` | +| `number` | `number` | +| `boolean` | `boolean` | +| `object` | `object` | +| `array` | `array` | Parameters are referenced in steps via `{{params.}}`. @@ -124,65 +138,71 @@ STEP : ### 6.1 Step types and required fields -| Type | Purpose | Required | Output at runtime | -| --- | --- | --- | --- | -| `api_call` | Call a REST endpoint | `OPERATION` | parsed API response | -| `sampling` | LLM reasoning/analysis | `PROMPT` | text string or parsed JSON | -| `elicitation` | Ask the user, wait for response | `MESSAGE` | user's response | -| `transform` | Reshape data via JS | `EXPRESSION` | whatever the expression returns | -| `conditional` | Branch execution | `CONDITION` + (`THEN` or `ELSE`) | boolean | +| Type | Purpose | Required | Output at runtime | +| ------------- | ------------------------------- | --------------------------------------------------------- | ------------------------------- | +| `api_call` | Call a REST endpoint | `OPERATION` | parsed API response | +| `sampling` | LLM reasoning/analysis | `PROMPT` (plus optional `CONTENT_TEXT` / `CONTENT_IMAGE`) | text string or parsed JSON | +| `elicitation` | Ask the user, wait for response | `MESSAGE` | user's response | +| `transform` | Reshape data via JS | `EXPRESSION` | whatever the expression returns | +| `conditional` | Branch execution | `CONDITION` + (`THEN` or `ELSE`) | boolean | ### 6.2 Step keyword reference -| Keyword | Applies to | Value | Notes | -| --- | --- | --- | --- | -| `LABEL` | all | text | Human label | -| `DEPENDS ON` | all | space-separated ids | Explicit ordering (usually inferred) | -| `OPERATION` | api_call | operationId | The REST endpoint to call | -| `OUTPUT_PATH` | api_call | dot path | Extract a sub-field of the response | -| `FOR_EACH` | api_call | collection expression | Iterate over items | -| `AS` | api_call | identifier | Names the loop variable | -| `MAP` | api_call | `path = value` | Builds the request payload | -| `EXPRESSION` | transform | JS (inline/heredoc) | The transform body | -| `CONDITION` | conditional | JS boolean (inline/heredoc) | The branch test | -| `THEN` / `ELSE` | conditional | step id | Branch targets | -| `PROMPT` | sampling | text (inline/heredoc) | The user prompt | -| `SYSTEM_PROMPT` | sampling | text (inline/heredoc) | System instructions | -| `MAX_TOKENS` | sampling | integer | Output cap | -| `RESPONSE_FORMAT` | sampling | token | e.g. `json` to force JSON parsing | -| `MESSAGE` | elicitation | text (inline/heredoc) | The question shown to the user | -| `SCHEMA` | elicitation | JSON (inline/heredoc) | Expected shape of the response | -| `ON_DECLINE` | elicitation | `abort` or `skip_remaining` | What to do if user declines | -| `CONTINUE_ON_ERROR` | api_call | (flag) | Don't fail if this step errors | +| Keyword | Applies to | Value | Notes | +| ------------------- | ----------- | --------------------------- | ------------------------------------------------------------------------ | +| `LABEL` | all | text | Human label | +| `DEPENDS ON` | all | space-separated ids | Explicit ordering (usually inferred) | +| `OPERATION` | api_call | operationId | The REST endpoint to call | +| `OUTPUT_PATH` | api_call | dot path | Extract a sub-field of the response | +| `FOR_EACH` | api_call | collection expression | Iterate over items | +| `AS` | api_call | identifier | Names the loop variable | +| `MAP` | api_call | `path = value` | Builds the request payload | +| `EXPRESSION` | transform | JS (inline/heredoc) | The transform body | +| `CONDITION` | conditional | JS boolean (inline/heredoc) | The branch test | +| `THEN` / `ELSE` | conditional | step id | Branch targets | +| `PROMPT` | sampling | text (inline/heredoc) | The user prompt. Required for sampling steps | +| `SYSTEM_PROMPT` | sampling | text (inline/heredoc) | System instructions | +| `CONTENT_TEXT` | sampling | text (inline/heredoc) | Extra multimodal **text** part sent alongside the prompt. Repeatable | +| `CONTENT_IMAGE` | sampling | url | Multimodal **image** part (by URL) sent alongside the prompt. Repeatable | +| `MAX_TOKENS` | sampling | integer | Output cap | +| `RESPONSE_FORMAT` | sampling | token | e.g. `json` to force JSON parsing | +| `MESSAGE` | elicitation | text (inline/heredoc) | The question shown to the user | +| `SCHEMA` | elicitation | JSON (inline/heredoc) | Expected shape of the response | +| `ON_DECLINE` | elicitation | `abort` or `skip_remaining` | What to do if user declines | +| `CONTINUE_ON_ERROR` | api_call | (flag) | Don't fail if this step errors | --- ## 7. MAP: Dot-paths, Auto-typing, Merging ### 7.1 Dot-paths + `MAP a.b.c = value` produces nested objects: `{ a: { b: { c: value } } }`. ### 7.2 Merging + Multiple MAPs to the same step deep-merge: ``` MAP message.rid = {{params.room_id}} MAP message.msg = Hello ``` + produces `{ "message": { "rid": "{{params.room_id}}", "msg": "Hello" } }` ### 7.3 Value auto-typing -| Input | Result | -| --- | --- | -| `true` / `false` | boolean | -| `42`, `-3.14` | number | +| Input | Result | +| ---------------------------------- | ------------------- | +| `true` / `false` | boolean | +| `42`, `-3.14` | number | | `{ ... }` / `[ ... ]` (valid JSON) | parsed object/array | -| anything else | string | +| anything else | string | Templates (`{{...}}`) are never coerced — they stay as strings. ### 7.4 MAP forbids heredoc + `MAP x = <<<` throws an error. Use a transform step for complex values. --- @@ -191,7 +211,7 @@ Templates (`{{...}}`) are never coerced — they stay as strings. Keywords that hold multi-line content support heredoc syntax: put `<<<` after the keyword, then content lines, then a line that is exactly `>>>`. -Heredoc-capable keywords: `EXPRESSION`, `CONDITION`, `PROMPT`, `SYSTEM_PROMPT`, `MESSAGE`, `SCHEMA`. +Heredoc-capable keywords: `EXPRESSION`, `CONDITION`, `PROMPT`, `SYSTEM_PROMPT`, `CONTENT_TEXT`, `MESSAGE`, `SCHEMA`. ``` STEP categorize : transform @@ -203,6 +223,7 @@ STEP categorize : transform ``` Rules: + - Content between `<<<` and `>>>` is captured verbatim (newlines preserved). - Triple-brace normalization: `{{{expr}}}` is collapsed to `{{expr}}`. - An empty value throws. @@ -312,25 +333,83 @@ WORKFLOW announce MAP text = {{steps.draft}} ``` +### 10.4 Multimodal sampling — text + image parts + +A `sampling` step always needs a `PROMPT`. `CONTENT_TEXT` and `CONTENT_IMAGE` add +extra multimodal parts (both repeatable) that are sent to the model alongside it. + +``` +PROJECT vision-bot +DESCRIPTION Describes an uploaded image + +WORKFLOW describe_image + DESCRIPTION Ask the model to describe an image with extra context + PARAM image_url : string : URL of the image to describe + + STEP describe : sampling + LABEL Describe the image + PROMPT Describe the attached image for a screen-reader user. + CONTENT_TEXT <<< + Focus on people, text, and any UI elements visible. + >>> + CONTENT_IMAGE {{params.image_url}} +``` + --- ## 11. Parse-time Error Catalog -| Error message | Cause | -| --- | --- | -| `Missing PROJECT declaration` | no `PROJECT` line | -| `Missing project DESCRIPTION` | no project-level `DESCRIPTION` | -| `No WORKFLOW declarations found` | zero workflows | -| `STEP requires format "STEP id : type"` | malformed step header | -| `Unknown step type "X"` | type not in the five valid types | -| `PARAM type "X" invalid` | bad param type | -| `Duplicate step ID "X"` / `Duplicate PARAM "X"` | name collisions | -| `MAP requires format "MAP path = value"` | missing `=` | -| `MAP does not support heredoc (<<<)` | heredoc used with MAP | -| `Unterminated heredoc (missing >>>)` | EOF inside a heredoc | -| `Invalid JSON in SCHEMA` | bad SCHEMA JSON | -| `Unknown keyword "X" in step "Y"` | unrecognized keyword | -| `Step "X" (type) requires ` | missing required field | -| `Step "X" has FOR_EACH without AS` | unpaired iteration keywords | +| Error message | Cause | +| ------------------------------------------------------------- | ------------------------------------- | +| `Missing PROJECT declaration` | no `PROJECT` line | +| `Missing project DESCRIPTION` | no project-level `DESCRIPTION` | +| `No WORKFLOW declarations found` | zero workflows | +| `STEP requires format "STEP id : type"` | malformed step header | +| `Unknown step type "X"` | type not in the five valid types | +| `PARAM type "X" invalid` | bad param type | +| `Duplicate step ID "X"` / `Duplicate PARAM "X"` | name collisions | +| `MAP requires format "MAP path = value"` | missing `=` | +| `MAP does not support heredoc (<<<)` | heredoc used with MAP | +| `Unterminated heredoc (missing >>>)` | EOF inside a heredoc | +| `Invalid JSON in SCHEMA` | bad SCHEMA JSON | +| `Unknown keyword "X" in step "Y"` | unrecognized keyword | +| `Step "X" (type) requires ` | missing required field | +| `Step "X" (sampling) requires PROMPT or CONTENT_TEXT` | sampling step with no text content | +| `CONTENT_TEXT requires an inline value or heredoc (<<<)` | empty `CONTENT_TEXT` | +| `Step "X" has FOR_EACH without AS` | unpaired iteration keywords | +| `WEBHOOK requires a path` | bare `WEBHOOK` with no path | +| `WEBHOOK "X" requires at least one method (METHODS get post)` | webhook with no `METHODS` | +| `Invalid HTTP method "X" in WEBHOOK` | method other than `get` / `post` | +| `Unknown keyword in WEBHOOK context: "X"` | unrecognized keyword inside a webhook | Every error is prefixed `Line N:` for easy navigation. + +--- + +## 12. Webhook Endpoints (top-level) + +Alongside `WORKFLOW`, a document may declare one or more top-level `WEBHOOK` +blocks. These describe inbound HTTP endpoints the server could expose for +external systems to call. + +``` +WEBHOOK /incoming-alert + DESCRIPTION Receives external alert payloads + METHODS post + +WEBHOOK /status + DESCRIPTION Health check + METHODS get post +``` + +| Keyword | Form | Rules | +| ------------- | ----------------------- | -------------------------------------------------------- | +| `WEBHOOK` | `WEBHOOK ` | Starts a webhook block. A path is required. | +| `DESCRIPTION` | `DESCRIPTION ` | Optional human description. | +| `METHODS` | `METHODS [ ...]` | One or more of `get` / `post`. At least one is required. | + +> **Status:** `WEBHOOK` blocks are fully **parsed and validated** today (see the +> error catalog above), and are surfaced on the parse result as +> `webhookEndpoints`. They are **not yet emitted** into the generated server — +> the construct is reserved for upcoming webhook-triggered workflows. Declaring +> a webhook has no effect on the generated output for now. diff --git a/docs/GENERATED_PROJECT.md b/docs/GENERATED_PROJECT.md index 90376b0..59def6f 100644 --- a/docs/GENERATED_PROJECT.md +++ b/docs/GENERATED_PROJECT.md @@ -15,16 +15,18 @@ A generated project is a complete, self-contained, runnable MCP server. It does │ ├── endpoints.ts # operationId -> {method, path} registry │ ├── rc-client.ts # REST client (auth, headers) │ ├── engine/ # Runtime workflow engine (copied verbatim) -│ │ ├── workflow-engine.ts -│ │ ├── executor.ts +│ │ ├── types.ts +│ │ ├── expression-security.ts +│ │ ├── templates.ts │ │ ├── api-call.ts │ │ ├── sampling.ts -│ │ ├── templates.ts -│ │ └── expression-security.ts +│ │ ├── executor.ts +│ │ └── index.ts # slim engine barrel │ ├── tools/ │ │ └── .ts # One file per workflow (step data + handler) │ └── tests/ -│ └── .test.ts # One test file per workflow +│ ├── setup.ts # Shared mock client/server/endpoints +│ └── .test.ts # One smoke test per workflow ├── package.json ├── tsconfig.json ├── .gitignore @@ -43,17 +45,18 @@ flowchart TB ## 2. What Each Piece Is -| File / dir | Purpose | -| --- | --- | -| `src/server.ts` | Builds an `McpServer`, imports each workflow tool, registers them with input schemas, and connects the transport | -| `src/endpoints.ts` | The `operationId -> { method, path }` registry | -| `src/rc-client.ts` | The REST client. Handles auth (tokens or auto-login), header injection | -| `src/engine/*` | The runtime that interprets step definitions: scheduling, template resolution, sandboxed evaluator, API execution, and sampling | -| `src/tools/.ts` | Per workflow: the step definitions as data, metadata, and a handler that calls `runWorkflow` | -| `src/tests/*` | Generated tests per workflow | -| `.env.example` | Template for credentials. Copy to `.env` and fill in | -| `.gitignore` | Ignores `node_modules`, `dist`, and `.env` | -| `package.json` | Scripts (`start`, `build`, `test`) and dependencies | +| File / dir | Purpose | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `src/server.ts` | Builds an `McpServer`, imports each workflow tool, registers them with input schemas, and connects the transport | +| `src/endpoints.ts` | The `operationId -> { method, path }` registry | +| `src/rc-client.ts` | The REST client. Handles auth (tokens or auto-login), header injection | +| `src/engine/*` | The runtime that interprets step definitions: scheduling, template resolution, sandboxed evaluator, API execution, and sampling | +| `src/tools/.ts` | Per workflow: the step definitions as data, metadata, and a handler that calls `runWorkflow` | +| `src/tests/setup.ts` | Shared, network-free mocks (client, MCP server, endpoint map) used by the generated tests | +| `src/tests/.test.ts` | Per workflow: a smoke test asserting tool metadata, endpoint wiring, and a full engine run on mock data returns a well-formed result | +| `.env.example` | Template for credentials. Copy to `.env` and fill in | +| `.gitignore` | Ignores `node_modules`, `dist`, and `.env` | +| `package.json` | Scripts (`start`, `build`, `test`) and dependencies | --- @@ -68,11 +71,11 @@ npm start `npm start` runs the server from source via `tsx`. The server speaks **stdio** by default — it's meant to be launched by an MCP client. -| Script | Does | -| --- | --- | -| `npm start` | Run with tsx (loads .env) | +| Script | Does | +| --------------- | --------------------------- | +| `npm start` | Run with tsx (loads .env) | | `npm run build` | Compile TypeScript to dist/ | -| `npm test` | Run the generated tests | +| `npm test` | Run the generated tests | --- @@ -90,11 +93,10 @@ ROCKETCHAT_PASSWORD=your-password # ROCKETCHAT_USER_ID=... ``` -If a workflow uses `sampling`, AI provider settings are also included: - -```env -# GEMINI_API_KEY=... -``` +If a workflow uses `sampling`, the generated `.env.example` notes that no extra +API key is required: sampling steps ask the **connected MCP client's** LLM +(via MCP sampling), so the client supplies the model. There is no separate AI +provider key to configure. --- @@ -108,9 +110,9 @@ The generated README includes ready-to-paste configs: "": { "command": "node", "args": ["--env-file-if-exists=.env", "--import", "tsx", "src/server.ts"], - "cwd": "/absolute/path/to/" - } - } + "cwd": "/absolute/path/to/", + }, + }, } ``` diff --git a/docs/SECURITY.md b/docs/SECURITY.md index d17f535..42b0fd2 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -16,6 +16,7 @@ flowchart TB ``` Three kinds of LLM-authored strings are evaluated as JavaScript: + - **`transform` expressions** — full JS bodies that return a value. - **`conditional` conditions** — JS booleans. - **`{{...}}` template expressions** — JS evaluated per placeholder. @@ -26,10 +27,10 @@ Everything else (operationIds, MAP literals, prompts without templates) is inert ## 2. Defense in Depth -| Layer | Where | What it stops | -| --- | --- | --- | -| 1. Parse-time | `dsl/parser.ts` | Structurally invalid steps; MAP heredocs; malformed input | -| 2. Compose-time AST | `composer/validation.ts` | Unsafe transform/conditional before code is generated | +| Layer | Where | What it stops | +| ------------------------ | ----------------------------------------------------------- | --------------------------------------------------------------- | +| 1. Parse-time | `dsl/parser.ts` | Structurally invalid steps; MAP heredocs; malformed input | +| 2. Compose-time AST | `composer/validation.ts` | Unsafe transform/conditional before code is generated | | 3. Runtime AST + sandbox | `workflow/expression-security.ts` + `workflow/templates.ts` | Re-checked on every evaluation, run in isolated VM with timeout | The same `validateSafeExpression` runs at both compose time and runtime. @@ -41,6 +42,7 @@ The same `validateSafeExpression` runs at both compose time and runtime. `validateSafeExpression` parses the expression with **acorn** and walks the AST. The model is **deny-by-default**: a node type, identifier, property, or call is rejected unless explicitly allowed. ### 3.1 Allowed + - **Node types**: literals, arrays/objects, member access, conditionals/logical/binary/unary, arrow functions, blocks, if/return, template literals, let/const declarations, spreads. - **Globals**: `undefined/null/true/false/NaN/Infinity`, safe call sets. - **Identifier calls**: `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, `isNaN`, `isFinite`, `encodeURIComponent`, `decodeURIComponent`. @@ -48,6 +50,7 @@ The same `validateSafeExpression` runs at both compose time and runtime. - **Instance methods**: common array/string methods (`map`, `filter`, `reduce`, `slice`, `join`, `includes`, `replace`, `split`, `sort`, `toLowerCase`, etc.). ### 3.2 Blocked + - **Properties**: `__proto__`, `constructor`, `prototype` — prototype-pollution vectors. - **`var`** declarations (only `let`/`const`). - **Mutation of `params`/`steps`** — only locally declared variables can be assigned. @@ -60,6 +63,7 @@ The same `validateSafeExpression` runs at both compose time and runtime. Validated expressions run in `node:vm` with a constrained context and a hard timeout. Properties: + - **No Node built-ins** beyond the explicit safe globals — no `require`, `process`, `Buffer`, `fs`, network, timers. - **Fresh context per evaluation** (`runInNewContext`) — no shared mutable state. - **100ms timeout** — caps runaway loops. @@ -70,14 +74,14 @@ Properties: ## 5. Threat Checklist -| Threat | Mitigation | Residual risk | -| --- | --- | --- | -| Arbitrary code via transform/conditional | AST allowlist (compose + runtime) + vm + timeout | POC-grade; no formal audit | -| Prototype pollution / constructor escape | Blocked properties | — | -| Module/host access (require, process, fetch) | deny-by-default calls + no built-ins in sandbox | — | -| Infinite loop / DoS in expression | 100ms vm timeout | many steps can still be slow | -| Reading secrets via process.env | not in sandbox | secrets still live in server env | -| Oversized / abusive API payloads | field validation | API-side limits still apply | +| Threat | Mitigation | Residual risk | +| -------------------------------------------- | ------------------------------------------------ | -------------------------------- | +| Arbitrary code via transform/conditional | AST allowlist (compose + runtime) + vm + timeout | POC-grade; no formal audit | +| Prototype pollution / constructor escape | Blocked properties | — | +| Module/host access (require, process, fetch) | deny-by-default calls + no built-ins in sandbox | — | +| Infinite loop / DoS in expression | 100ms vm timeout | many steps can still be slow | +| Reading secrets via process.env | not in sandbox | secrets still live in server env | +| Oversized / abusive API payloads | field validation | API-side limits still apply | --- diff --git a/docs/TESTING.md b/docs/TESTING.md index ff59277..89363b6 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -6,9 +6,9 @@ ## 1. How to Run -| Command | Runs | -| --- | --- | -| `npm test` | The full test suite | +| Command | Runs | +| --------------- | --------------------------------------- | +| `npm test` | The full test suite | | `npm run build` | Compile TypeScript (build verification) | Run a single file directly: @@ -33,6 +33,7 @@ src/tests/ ``` Naming conventions: + - `*.unit.test.ts` — isolated module behavior - `*.integration.test.ts` — behavior spanning modules - `*.smoke.test.ts` — MCP protocol / end-to-end sanity @@ -43,54 +44,54 @@ Naming conventions: ### 3.1 `dsl/` — DSL parser & scanner -| File | Focus | -| --- | --- | -| `scanner.unit.test.ts` | Line iteration, blank/comment skipping, heredoc collection | -| `parser.unit.test.ts` | Overall parse of valid documents | -| `parser-errors.unit.test.ts` | Every parse-time error | -| `parser-integration.unit.test.ts` | Complex multi-workflow documents | +| File | Focus | +| --------------------------------- | ---------------------------------------------------------- | +| `scanner.unit.test.ts` | Line iteration, blank/comment skipping, heredoc collection | +| `parser.unit.test.ts` | Overall parse of valid documents | +| `parser-errors.unit.test.ts` | Every parse-time error | +| `parser-integration.unit.test.ts` | Complex multi-workflow documents | ### 3.2 `parser/` — OpenAPI parsing -| File | Focus | -| --- | --- | -| `spec-source.unit.test.ts` | Cache tiers, TTL, URL building | -| `endpoint-extraction.unit.test.ts` | Compact/full extraction, id sanitize/dedupe | -| `schema-mapper.unit.test.ts` | OpenAPI -> JSON Schema, nullable, cycle/depth guards | -| `fuzzy-match.unit.test.ts` | Exact/normalized/fuzzy operationId matching | -| `spec-parser.unit.test.ts` | SpecParser orchestration | -| `get-full-endpoints.integration.test.ts` | Fast/slow/fuzzy paths | -| `index.integration.test.ts` | Parser public surface | -| `cache-and-stats.integration.test.ts` | Caching behavior | +| File | Focus | +| ---------------------------------------- | ---------------------------------------------------- | +| `spec-source.unit.test.ts` | Cache tiers, TTL, URL building | +| `endpoint-extraction.unit.test.ts` | Compact/full extraction, id sanitize/dedupe | +| `schema-mapper.unit.test.ts` | OpenAPI -> JSON Schema, nullable, cycle/depth guards | +| `fuzzy-match.unit.test.ts` | Exact/normalized/fuzzy operationId matching | +| `spec-parser.unit.test.ts` | SpecParser orchestration | +| `get-full-endpoints.integration.test.ts` | Fast/slow/fuzzy paths | +| `index.integration.test.ts` | Parser public surface | +| `cache-and-stats.integration.test.ts` | Caching behavior | ### 3.3 `tools/` — MCP tool handlers -| File | Focus | -| --- | --- | -| `format-capability-guide.unit.test.ts` | Guide formatting, grouping | -| `get-capability-guide.unit.test.ts` | Discovery handler | -| `get-endpoint-schemas.unit.test.ts` | Schema handler, corrected/unmatched ids | -| `mcp-protocol.smoke.test.ts` | `listTools`/`callTool` protocol surface | -| `annotations.integration.test.ts` | Endpoint annotations | +| File | Focus | +| -------------------------------------- | --------------------------------------- | +| `format-capability-guide.unit.test.ts` | Guide formatting, grouping | +| `get-capability-guide.unit.test.ts` | Discovery handler | +| `get-endpoint-schemas.unit.test.ts` | Schema handler, corrected/unmatched ids | +| `mcp-protocol.smoke.test.ts` | `listTools`/`callTool` protocol surface | +| `annotations.integration.test.ts` | Endpoint annotations | ### 3.4 `workflow/` — runtime engine -| File | Focus | -| --- | --- | -| `executor.integration.test.ts` | Execution loop, step scheduling, branching | -| `sandbox-security.unit.test.ts` | Sandbox escapes are blocked | +| File | Focus | +| ------------------------------- | ------------------------------------------ | +| `executor.integration.test.ts` | Execution loop, step scheduling, branching | +| `sandbox-security.unit.test.ts` | Sandbox escapes are blocked | ### 3.5 `generator/` — code generation -| File | Focus | -| --- | --- | -| `generate.integration.test.ts` | MCP tool invocation and project output | -| `pipeline.integration.test.ts` | DSL string -> generated project (full pipeline) | +| File | Focus | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `generate.integration.test.ts` | MCP tool invocation and project output | +| `generated-project.integration.test.ts` | Writes a generated project to a temp dir, then runs `tsc --noEmit` over it and executes its generated `npm test` suite — proves the emitted code is valid TypeScript and its tests pass | ### 3.6 `integration/` — full pipeline -| File | Focus | -| --- | --- | +| File | Focus | +| ------------------------------ | --------------------------------------------------------- | | `pipeline.integration.test.ts` | End-to-end: DSL -> compose -> generate -> validate output | --- diff --git a/package.json b/package.json index 2186238..5aed0e8 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint .", + "lint:fix": "eslint . --fix", "start": "node dist/index.js", "test": "tsx --test \"src/tests/**/*.test.ts\"", "test:integration": "tsx --test \"src/tests/**/*.integration.test.ts\"", diff --git a/src/composer/inference.ts b/src/composer/inference.ts index 9738084..d25c0ab 100644 --- a/src/composer/inference.ts +++ b/src/composer/inference.ts @@ -5,8 +5,16 @@ import type { SamplingStep, TransformStep, } from "../workflow/types.js"; -import { ComposerError, type ComposerWarning, type ComposeStepInput } from "./types.js"; -import { JS_BUILTIN_METHODS, STEP_REF_RE, BARE_STEP_REF_RE, extractStepRefs, extractTemplateStrings } from "./utils.js"; +import { + ComposerError, + type ComposerWarning, + type ComposeStepInput, +} from "./types.js"; +import { + JS_BUILTIN_METHODS, + extractStepRefs, + extractTemplateStrings, +} from "./utils.js"; export function inferMissingConditionalTargets( steps: ComposeStepInput[], @@ -34,12 +42,12 @@ export function inferMissingConditionalTargets( } else if (candidates.length === 0) { throw new ComposerError( `Step "${step.id}" (conditional): thenStep is required. ` + - `Has elseStep="${cfg.elseStep}" but no other step depends on this conditional to infer thenStep from.`, + `Has elseStep="${cfg.elseStep}" but no other step depends on this conditional to infer thenStep from.`, ); } else { throw new ComposerError( `Step "${step.id}" (conditional): thenStep is required. ` + - `Multiple steps depend on it [${candidates.map((s) => s.id).join(", ")}] — specify thenStep explicitly.`, + `Multiple steps depend on it [${candidates.map((s) => s.id).join(", ")}] — specify thenStep explicitly.`, ); } } else { @@ -53,12 +61,12 @@ export function inferMissingConditionalTargets( } else if (dependents.length === 0) { throw new ComposerError( `Step "${step.id}" (conditional): thenStep is required. ` + - `No steps depend on this conditional — cannot infer thenStep.`, + `No steps depend on this conditional — cannot infer thenStep.`, ); } else { throw new ComposerError( `Step "${step.id}" (conditional): thenStep is required. ` + - `Multiple steps depend on it [${dependents.map((s) => s.id).join(", ")}] — specify thenStep explicitly.`, + `Multiple steps depend on it [${dependents.map((s) => s.id).join(", ")}] — specify thenStep explicitly.`, ); } } @@ -271,8 +279,6 @@ function rewriteStepRefs( for (const step of steps) { const cfg = step.config; - const isJs = cfg.type === "transform" || cfg.type === "conditional"; - const activeRe = isJs ? jsRe : tmplRe; switch (cfg.type) { case "api_call": { @@ -335,4 +341,3 @@ function rewriteDeep(value: unknown, re: RegExp): unknown { } return value; } - diff --git a/src/composer/normalization.ts b/src/composer/normalization.ts index 70482bf..fc639ce 100644 --- a/src/composer/normalization.ts +++ b/src/composer/normalization.ts @@ -7,9 +7,15 @@ import type { SamplingStep, TransformStep, } from "../workflow/types.js"; -import { ComposerError, type ComposerWarning, type ComposeStepInput } from "./types.js"; +import { + ComposerError, + type ComposerWarning, + type ComposeStepInput, +} from "./types.js"; -export function normalizeStepFields(steps: ComposeStepInput[]): ComposerWarning[] { +export function normalizeStepFields( + steps: ComposeStepInput[], +): ComposerWarning[] { const warnings: ComposerWarning[] = []; for (const step of steps) { const cfg = step.config as unknown as Record; @@ -35,7 +41,6 @@ export function normalizeStepFields(steps: ComposeStepInput[]): ComposerWarning[ return warnings; } - export function normalizeEventParamShorthand( steps: ComposeStepInput[], params: JSONSchema7, @@ -136,7 +141,7 @@ export function normalizeEventParamShorthand( } function rewriteJs(stepId: string, value: string, fieldName: string): string { - let result = value; + const result = value; for (const rule of jsRewriters) { if (rule.re.test(result)) { warnings.push({ @@ -260,8 +265,8 @@ function convertHandlebarsBlocks( if (unsupportedBlock) { throw new ComposerError( `Step "${stepId}" field "${fieldName}" uses unsupported Handlebars helper "{{#${unsupportedBlock[1]}}}". ` + - `The template engine uses {{jsExpression}} syntax. ` + - `Use JavaScript expressions instead (e.g. array.map(), ternary operators).`, + `The template engine uses {{jsExpression}} syntax. ` + + `Use JavaScript expressions instead (e.g. array.map(), ternary operators).`, ); } @@ -274,8 +279,8 @@ function convertHandlebarsBlocks( if (/\{\{#(each|if)\b/.test(body)) { throw new ComposerError( `Step "${stepId}" field "${fieldName}" uses nested Handlebars blocks which cannot be auto-converted. ` + - `Use JavaScript expressions instead. Example: ` + - `{{${col}.map(item => item.name + ": " + item.value).join("\\n")}}`, + `Use JavaScript expressions instead. Example: ` + + `{{${col}.map(item => item.name + ": " + item.value).join("\\n")}}`, ); } @@ -316,8 +321,8 @@ function convertHandlebarsBlocks( } catch { throw new ComposerError( `Step "${stepId}" field "${fieldName}": auto-converted Handlebars {{#each}} failed to compile. ` + - `Original: "${_.trim()}". Converted: "${expr}". ` + - `Use JavaScript expressions directly instead.`, + `Original: "${_.trim()}". Converted: "${expr}". ` + + `Use JavaScript expressions directly instead.`, ); } @@ -379,7 +384,9 @@ function convertHandlebarsBlocks( return result; } -export function normalizeTemplateFields(steps: ComposeStepInput[]): ComposerWarning[] { +export function normalizeTemplateFields( + steps: ComposeStepInput[], +): ComposerWarning[] { const warnings: ComposerWarning[] = []; const asVars = new Set(); @@ -449,7 +456,7 @@ export function normalizeTemplateFields(steps: ComposeStepInput[]): ComposerWarn ): unknown { if (typeof value === "string") { // Detect stringified JSON objects/arrays and parse them back to native types - if (/^\s*[\[{]/.test(value)) { + if (/^\s*[[{]/.test(value)) { try { const parsed = JSON.parse(value); if (typeof parsed === "object" && parsed !== null) { @@ -460,7 +467,9 @@ export function normalizeTemplateFields(steps: ComposeStepInput[]): ComposerWarn }); return normalizeValue(stepId, parsed, fieldName); } - } catch { } + } catch { + // Not valid JSON — leave the value as a plain string. + } } return normalizeString(stepId, value, fieldName); } @@ -583,7 +592,9 @@ export function normalizeTemplateFields(steps: ComposeStepInput[]): ComposerWarn return warnings; } -export function flattenNestedSteps(steps: ComposeStepInput[]): ComposerWarning[] { +export function flattenNestedSteps( + steps: ComposeStepInput[], +): ComposerWarning[] { const warnings: ComposerWarning[] = []; const extracted: ComposeStepInput[] = []; @@ -615,4 +626,3 @@ export function flattenNestedSteps(steps: ComposeStepInput[]): ComposerWarning[] steps.push(...extracted); return warnings; } - diff --git a/src/composer/utils.ts b/src/composer/utils.ts index 7a53e13..8d924f4 100644 --- a/src/composer/utils.ts +++ b/src/composer/utils.ts @@ -65,7 +65,9 @@ export const JS_BUILTIN_METHODS = new Set([ "length", ]); -export function findLiteralRid(mapping: Record): string | null { +export function findLiteralRid( + mapping: Record, +): string | null { function check(val: unknown): string | null { if (typeof val === "string" && !val.includes("{{")) return val; return null; @@ -187,5 +189,3 @@ export function topologicalSort(steps: ComposeStepInput[]): string[] { return order; } - - diff --git a/src/composer/validation.ts b/src/composer/validation.ts index b7ae83a..31c35e6 100644 --- a/src/composer/validation.ts +++ b/src/composer/validation.ts @@ -4,8 +4,19 @@ import type { StepConfig, TransformStep, } from "../workflow/types.js"; -import { ComposerError, type ComposerWarning, type ComposeStepInput } from "./types.js"; -import { BARE_PARAM_REF_RE, JS_BUILTIN_METHODS, PARAM_REF_RE, STEP_FIELD_ACCESS_RE, STEP_REF_RE, extractTemplateStrings } from "./utils.js"; +import { + ComposerError, + type ComposerWarning, + type ComposeStepInput, +} from "./types.js"; +import { + BARE_PARAM_REF_RE, + JS_BUILTIN_METHODS, + PARAM_REF_RE, + STEP_FIELD_ACCESS_RE, + STEP_REF_RE, + extractTemplateStrings, +} from "./utils.js"; import { autoReturnExpression, validateSafeExpression, @@ -90,7 +101,6 @@ export function detectCycles(steps: ComposeStepInput[]): void { } } - export function validateStepConfig(step: ComposeStepInput): void { const cfg = step.config; switch (cfg.type) { @@ -155,7 +165,7 @@ export function validateStepConfig(step: ComposeStepInput): void { break; default: throw new ComposerError( - `Step "${step.id}": unknown step type "${(cfg as any).type}"`, + `Step "${step.id}": unknown step type "${(cfg as StepConfig).type}"`, ); } } @@ -182,8 +192,8 @@ export function validateTemplateReferences( const warnings: ComposerWarning[] = []; const stepIds = new Set(steps.map((s) => s.id)); for (const step of steps) { - if (step.config.type === "api_call" && (step.config as any).as) { - stepIds.add((step.config as any).as); + if (step.config.type === "api_call" && step.config.as) { + stepIds.add(step.config.as); } } const paramProps = new Set( @@ -210,9 +220,9 @@ export function validateTemplateReferences( const hint = findDomainKeyHint(topField, params); throw new ComposerError( `Step "${step.id}" references "params.${topField}" but "${topField}" is not in the workflow params schema. Available: ${[...paramProps].join(", ") || "(none)"}` + - (hint - ? `. Did you mean "params.${hint}.${topField}"? Event params are nested under the domain key.` - : ""), + (hint + ? `. Did you mean "params.${hint}.${topField}"? Event params are nested under the domain key.` + : ""), ); } if (segments.length > 1) { @@ -237,9 +247,9 @@ export function validateTemplateReferences( const hint = findDomainKeyHint(topField, params); throw new ComposerError( `Step "${step.id}" references "params.${topField}" but "${topField}" is not in the workflow params schema. Available: ${[...paramProps].join(", ") || "(none)"}` + - (hint - ? `. Did you mean "params.${hint}.${topField}"? Event params are nested under the domain key.` - : ""), + (hint + ? `. Did you mean "params.${hint}.${topField}"? Event params are nested under the domain key.` + : ""), ); } if (segments.length > 1) { @@ -321,18 +331,18 @@ export function validateDataFlowTypes(steps: ComposeStepInput[]): void { if (outputType === "string") { throw new ComposerError( `Step "${step.id}" accesses ".${field}" on step "${refStepId}" (${refStep.config.type}), ` + - `but ${refStep.config.type} results are plain text strings with no properties. ` + - `Fix: (1) Add a systemPrompt to step "${refStepId}" asking the LLM to respond in JSON only, ` + - `(2) Add a transform step after "${refStepId}" with expression 'JSON.parse(steps.${refStepId})', ` + - `then (3) reference 'steps..${field}' instead. ` + - `Alternatively, evaluate the raw string directly (e.g. 'steps.${refStepId}.includes("...")').`, + `but ${refStep.config.type} results are plain text strings with no properties. ` + + `Fix: (1) Add a systemPrompt to step "${refStepId}" asking the LLM to respond in JSON only, ` + + `(2) Add a transform step after "${refStepId}" with expression 'JSON.parse(steps.${refStepId})', ` + + `then (3) reference 'steps..${field}' instead. ` + + `Alternatively, evaluate the raw string directly (e.g. 'steps.${refStepId}.includes("...")').`, ); } if (outputType === "boolean") { throw new ComposerError( `Step "${step.id}" accesses ".${field}" on step "${refStepId}" (conditional), ` + - `but conditional results are booleans with no properties. ` + - `Use the boolean value directly: 'steps.${refStepId} === true'.`, + `but conditional results are booleans with no properties. ` + + `Use the boolean value directly: 'steps.${refStepId} === true'.`, ); } } @@ -359,4 +369,3 @@ export function validateSafeWorkflowExpressions( } /** Auto-infer responseSchema for JSON sampling steps from downstream field-access patterns. */ - diff --git a/src/composer/warnings.ts b/src/composer/warnings.ts index 8c202d9..ad2d184 100644 --- a/src/composer/warnings.ts +++ b/src/composer/warnings.ts @@ -4,7 +4,11 @@ import type { ConditionalStep, SamplingStep, } from "../workflow/types.js"; -import { ComposerError, type ComposerWarning, type ComposeStepInput } from "./types.js"; +import { + ComposerError, + type ComposerWarning, + type ComposeStepInput, +} from "./types.js"; import { extractStepRefs, findLiteralRid } from "./utils.js"; export function generateSemanticWarnings( @@ -12,11 +16,10 @@ export function generateSemanticWarnings( params?: JSONSchema7, ): ComposerWarning[] { const warnings: ComposerWarning[] = []; - const stepIds = new Set(steps.map((s) => s.id)); const hasParams = params && Object.keys((params.properties as Record) ?? {}).length > - 0; + 0; const referencedSteps = new Set(); for (const step of steps) { @@ -61,8 +64,8 @@ export function generateSemanticWarnings( if (!hasTemplateRef && hasParams) { throw new ComposerError( `Sampling step "${step.id}" prompt does not reference any {{params.*}} or {{steps.*}} data. ` + - `The AI will have no input to analyze. Include the relevant data in the prompt ` + - `(e.g. {{params.message.text}}).`, + `The AI will have no input to analyze. Include the relevant data in the prompt ` + + `(e.g. {{params.message.text}}).`, ); } } @@ -194,4 +197,3 @@ export function generateSemanticWarnings( return warnings; } - diff --git a/src/generator/codegen.ts b/src/generator/codegen.ts index 08812a8..d2d2fa1 100644 --- a/src/generator/codegen.ts +++ b/src/generator/codegen.ts @@ -13,7 +13,8 @@ function indentJson(value: unknown, indent = "const "): string { /** 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() : ""; + const type = + typeof schema?.type === "string" ? schema.type.toLowerCase() : ""; switch (type) { case "string": return "z.string()"; @@ -32,8 +33,11 @@ function zodForParam(schema: JSONSchema7 | undefined): string { } function zodShape(params: JSONSchema7): string { - const props = (params.properties as Record | undefined) ?? {}; - const required = new Set(Array.isArray(params.required) ? params.required : []); + 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 @@ -87,6 +91,189 @@ export function createHandler(ctx: ToolContext) { `; } +/** A representative sample value for a param, used to seed generated tests. */ +function sampleArgValue(schema: JSONSchema7 | undefined): unknown { + const type = + typeof schema?.type === "string" ? schema.type.toLowerCase() : ""; + switch (type) { + case "number": + case "integer": + return 1; + case "boolean": + return true; + case "array": + return []; + case "object": + return {}; + case "string": + default: + return "test"; + } +} + +/** Build a full argument object for a workflow from its param schema. */ +function sampleArgs(params: JSONSchema7): Record { + const props = + (params.properties as Record | undefined) ?? {}; + const out: Record = {}; + for (const [key, schema] of Object.entries(props)) { + out[key] = sampleArgValue(schema); + } + return out; +} + +/** + * Generate `src/tests/setup.ts` — shared, network-free mocks so generated + * tests exercise the real workflow engine on synthetic data. + */ +export function generateTestSetup(): string { + return `/** + * Shared test setup — in-memory mocks for the workflow engine. + * + * These let the generated tests run the real engine end-to-end with no network + * calls and no LLM: \`request\` returns generic, shape-rich data, sampling + * returns JSON, and elicitation always accepts. + * Generated by mcp-server-generator. + */ +import type { + ApiResponse, + EndpointInfo, + WorkflowClient, + WorkflowServer, +} from "../engine/executor.js"; +import { endpointMap } from "../endpoints.js"; + +/** A generic, field-rich record so downstream steps find something to read. */ +function mockRecord() { + return { + _id: "mock-id", + id: "mock-id", + name: "mock", + fname: "mock", + username: "mockuser", + msg: "mock message", + text: "mock text", + title: "mock title", + summary: "mock summary", + url: "https://example.com/mock", + ts: "2020-01-01T00:00:00.000Z", + u: { _id: "mock-user", username: "mockuser", name: "Mock User" }, + }; +} + +/** A permissive mock client — every request "succeeds" with generic data. */ +export function createMockClient(): WorkflowClient { + return { + async request(): Promise { + const record = mockRecord(); + return { + ok: true, + status: 200, + data: { + success: true, + messages: [record], + channels: [record], + items: [record], + ...record, + }, + }; + }, + }; +} + +/** A mock MCP server: sampling returns JSON; elicitation always accepts. */ +export function createMockServer(): WorkflowServer { + return { + async createMessage() { + return { + content: { + type: "text", + text: JSON.stringify({ + summary: "mock summary", + safe: ["mock"], + risky: [], + flagged: ["mock"], + approved: true, + }), + }, + }; + }, + async elicitInput() { + return { action: "accept", content: { approved: true } }; + }, + }; +} + +/** The generated endpoint registry, typed for the engine. */ +export function mockEndpoints(): Record { + return endpointMap; +} +`; +} + +/** Generate `src/tests/.test.ts` — a per-workflow smoke test. */ +export function generateToolTest(workflow: WorkflowDefinition): string { + const args = JSON.stringify(sampleArgs(workflow.params), null, 2) + .split("\n") + .map((line, i) => (i === 0 ? line : " " + line)) + .join("\n"); + + return `/** + * Generated tests for the "${workflow.name}" workflow tool. + * + * Verifies tool metadata, endpoint wiring, and a full engine run on mock data. + * Generated by mcp-server-generator. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { tool, createHandler } from "../tools/${workflow.name}.js"; +import { endpointMap } from "../endpoints.js"; +import { createMockClient, createMockServer, mockEndpoints } from "./setup.js"; + +const sampleArgs = ${args}; +const requiredEndpoints: string[] = ${JSON.stringify(workflow.requiredEndpoints)}; + +describe(${JSON.stringify(workflow.name + " workflow tool")}, () => { + it("exposes the expected tool metadata", () => { + assert.equal(tool.name, ${JSON.stringify(workflow.name)}); + assert.ok(tool.description.length > 0, "tool has a description"); + }); + + it("declares every endpoint it calls in the endpoint map", () => { + for (const operationId of requiredEndpoints) { + assert.ok( + endpointMap[operationId], + \`endpoint "\${operationId}" is missing from the endpoint map\`, + ); + } + }); + + it("runs end-to-end on mock data and returns a well-formed MCP result", async () => { + const handler = createHandler({ + client: createMockClient(), + server: createMockServer(), + endpoints: mockEndpoints(), + }); + + const result = await handler(sampleArgs); + + assert.ok(Array.isArray(result.content), "result has a content array"); + assert.equal(result.content[0].type, "text"); + + const run = JSON.parse(result.content[0].text) as { + status: string; + completedSteps: string[]; + }; + assert.ok( + ["success", "partial", "aborted", "error"].includes(run.status), + \`unexpected workflow status: \${run.status}\`, + ); + assert.ok(Array.isArray(run.completedSteps), "result reports completed steps"); + }); +}); +`; +} + /** Generate `src/endpoints.ts` — the operationId -> { method, path } registry. */ export function generateEndpointMap(endpoints: GeneratorEndpoint[]): string { const entries = endpoints diff --git a/src/generator/dsl-mapping.ts b/src/generator/dsl-mapping.ts index 58b453d..3de52c1 100644 --- a/src/generator/dsl-mapping.ts +++ b/src/generator/dsl-mapping.ts @@ -1,6 +1,9 @@ import type { JSONSchema7 } from "json-schema"; import type { DslStep, DslWorkflow } from "../dsl/types.js"; -import type { ComposeStepInput, ComposeWorkflowInput } from "../composer/types.js"; +import type { + ComposeStepInput, + ComposeWorkflowInput, +} from "../composer/types.js"; import type { StepConfig } from "../workflow/types.js"; /** @@ -15,7 +18,9 @@ function stepConfig(step: DslStep): StepConfig { type: "api_call", operationId: step.operationId ?? "", inputMapping: step.inputMapping ?? {}, - ...(step.outputPath !== undefined ? { outputPath: step.outputPath } : {}), + ...(step.outputPath !== undefined + ? { outputPath: step.outputPath } + : {}), ...(step.forEach !== undefined ? { forEach: step.forEach } : {}), ...(step.as !== undefined ? { as: step.as } : {}), }; @@ -24,7 +29,9 @@ function stepConfig(step: DslStep): StepConfig { type: "sampling", prompt: step.prompt ?? "", ...(step.content !== undefined ? { content: step.content } : {}), - ...(step.systemPrompt !== undefined ? { systemPrompt: step.systemPrompt } : {}), + ...(step.systemPrompt !== undefined + ? { systemPrompt: step.systemPrompt } + : {}), ...(step.maxTokens !== undefined ? { maxTokens: step.maxTokens } : {}), ...(step.responseFormat !== undefined ? { responseFormat: step.responseFormat as "text" | "json" } @@ -34,7 +41,9 @@ function stepConfig(step: DslStep): StepConfig { return { type: "elicitation", message: step.message ?? "", - requestedSchema: (step.requestedSchema ?? { type: "object" }) as JSONSchema7, + requestedSchema: (step.requestedSchema ?? { + type: "object", + }) as JSONSchema7, ...(step.onDecline !== undefined ? { onDecline: step.onDecline } : {}), }; case "transform": @@ -47,7 +56,9 @@ function stepConfig(step: DslStep): StepConfig { ...(step.elseStep !== undefined ? { elseStep: step.elseStep } : {}), }; default: - throw new Error(`Unknown DSL step type "${step.type}" in step "${step.id}".`); + throw new Error( + `Unknown DSL step type "${step.type}" in step "${step.id}".`, + ); } } @@ -56,7 +67,9 @@ function toStepInput(step: DslStep): ComposeStepInput { id: step.id, label: step.label ?? step.id, config: stepConfig(step), - ...(step.dependsOn && step.dependsOn.length > 0 ? { dependsOn: step.dependsOn } : {}), + ...(step.dependsOn && step.dependsOn.length > 0 + ? { dependsOn: step.dependsOn } + : {}), }; } @@ -67,7 +80,10 @@ export function dslWorkflowToComposeInput( return { name: workflow.name, description: workflow.description, - params: (workflow.params ?? { type: "object", properties: {} }) as JSONSchema7, + params: (workflow.params ?? { + type: "object", + properties: {}, + }) as JSONSchema7, steps: workflow.steps.map(toStepInput), }; } diff --git a/src/generator/project.ts b/src/generator/project.ts index 1b83144..2c97d60 100644 --- a/src/generator/project.ts +++ b/src/generator/project.ts @@ -2,7 +2,9 @@ import { bundleEngine } from "./engine-bundle.js"; import { generateEndpointMap, generateServerEntry, + generateTestSetup, generateToolFile, + generateToolTest, } from "./codegen.js"; import { generateEnvExample, @@ -48,22 +50,41 @@ export function generateProject( // Vendored engine. files.push(...bundleEngine()); - // One tool file per workflow. + // One tool file + one test file per workflow. for (const workflow of workflows) { files.push({ path: `src/tools/${workflow.name}.ts`, content: generateToolFile(workflow), }); + files.push({ + path: `src/tests/${workflow.name}.test.ts`, + content: generateToolTest(workflow), + }); } + // Shared test setup (mock client/server/endpoints). + files.push({ path: "src/tests/setup.ts", content: generateTestSetup() }); + // Wiring + scaffolding. - files.push({ path: "src/endpoints.ts", content: generateEndpointMap(endpoints) }); + 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) }); - files.push({ path: "package.json", content: generatePackageJson(serverName) }); + files.push({ + path: "src/server.ts", + content: generateServerEntry(serverName, workflows), + }); + 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: ".env.example", + content: generateEnvExample(usesSampling), + }); files.push({ path: "README.md", content: generateReadme(serverName, workflows, endpoints), diff --git a/src/generator/scaffold.ts b/src/generator/scaffold.ts index 1f80daf..91f20fe 100644 --- a/src/generator/scaffold.ts +++ b/src/generator/scaffold.ts @@ -133,6 +133,7 @@ export function generatePackageJson(serverName: string): string { start: "node --env-file-if-exists=.env --import tsx src/server.ts", build: "tsc", "start:built": "node dist/server.js", + test: 'tsx --test "src/tests/**/*.test.ts"', }, dependencies: { "@modelcontextprotocol/sdk": "^1.27.1", @@ -169,9 +170,16 @@ export function generateTsConfig(): string { } export function generateGitignore(): string { - return ["node_modules/", "dist/", ".env", ".env.*", "!.env.example", "*.log", ".DS_Store", ""].join( - "\n", - ); + return [ + "node_modules/", + "dist/", + ".env", + ".env.*", + "!.env.example", + "*.log", + ".DS_Store", + "", + ].join("\n"); } export function generateEnvExample(usesSampling: boolean): string { @@ -233,6 +241,13 @@ cp .env.example .env # fill in your Rocket.Chat credentials npm start \`\`\` +## Development + +\`\`\`bash +npm test # run the generated workflow smoke tests +npm run build # type-check and compile to dist/ +\`\`\` + ## Workflow tools | Tool | Description | Steps | Features | @@ -254,7 +269,8 @@ ${serverName}/ │ ├── rc-client.ts # Rocket.Chat HTTP client │ ├── endpoints.ts # operationId -> method + path │ ├── engine/ # vendored workflow engine -│ └── tools/ # one file per workflow +│ ├── tools/ # one file per workflow +│ └── tests/ # one smoke test per workflow + shared setup ├── .env.example ├── package.json ├── tsconfig.json diff --git a/src/tests/generator/generate.integration.test.ts b/src/tests/generator/generate.integration.test.ts index d65c958..9e87fec 100644 --- a/src/tests/generator/generate.integration.test.ts +++ b/src/tests/generator/generate.integration.test.ts @@ -47,11 +47,23 @@ WORKFLOW summarize_channel `; 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" }, + { + 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 { +function fileMap( + files: { path: string; content: string }[], +): Map { return new Map(files.map((f) => [f.path, f.content])); } @@ -87,8 +99,14 @@ describe("DSL -> generated MCP server", () => { }); 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/); + 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", () => { @@ -102,7 +120,10 @@ describe("DSL -> generated MCP server", () => { 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.acorn, + "engine needs acorn for expression validation", + ); assert.ok(pkg.dependencies.zod); }); @@ -122,7 +143,11 @@ describe("DSL -> generated MCP server", () => { const client: WorkflowClient = { async request(method, path) { calls.push(`${method} ${path}`); - return { ok: true, status: 200, data: { messages: [{ msg: "hello" }] } }; + return { + ok: true, + status: 200, + data: { messages: [{ msg: "hello" }] }, + }; }, }; const server: WorkflowServer = { @@ -135,11 +160,15 @@ describe("DSL -> generated MCP server", () => { "chat.postMessage": { method: "POST", path: "/api/v1/chat.postMessage" }, }; - const run = await runWorkflow(workflow, { roomId: "room1" }, { - client, - server, - endpoints: endpointInfo, - }); + 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" }); @@ -179,8 +208,14 @@ describe("generate tool writes a project to disk", () => { 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"/); + 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"))); diff --git a/src/tests/generator/generated-project.integration.test.ts b/src/tests/generator/generated-project.integration.test.ts new file mode 100644 index 0000000..b5af280 --- /dev/null +++ b/src/tests/generator/generated-project.integration.test.ts @@ -0,0 +1,148 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { generateFromDsl } from "../../generator/pipeline.js"; +import type { GeneratedFile } from "../../generator/types.js"; + +/** + * End-to-end quality gates for a *generated* project — the strongest signal + * that codegen is healthy: + * + * 1. The whole project is valid TypeScript (`tsc --noEmit`). + * 2. The generated `npm test` suite actually runs and passes. + * + * We write a full project to a temp dir under the repo root; dependency and + * `@types` resolution walks up to this repo's `node_modules`, so no install + * step is needed. The DSL exercises every step type (api_call, sampling with + * multimodal CONTENT_*, elicitation, transform, conditional) plus forEach. + */ +const DSL = ` +PROJECT generated_quality +DESCRIPTION Exercises every step type so the whole generated project is checked + +WORKFLOW triage_and_report + DESCRIPTION Fetch messages, summarize with AI, confirm, then post + PARAM roomId : string : Target room id + PARAM dryRun : boolean : Skip posting when true + + STEP fetch : api_call + LABEL Fetch messages + OPERATION channels.history + MAP roomId = {{params.roomId}} + MAP count = 20 + OUTPUT_PATH messages + + STEP summarize : sampling + LABEL Summarize messages + DEPENDS ON fetch + RESPONSE_FORMAT json + SYSTEM_PROMPT Respond ONLY with JSON. + PROMPT Summarize the fetched messages. + CONTENT_TEXT <<< + Summarize these messages as JSON { "summary": string, "flagged": [ids] }: + {{steps.fetch}} + >>> + CONTENT_IMAGE https://example.com/context.png + + STEP needs_post : conditional + LABEL Post only when not a dry run + CONDITION params.dryRun !== true + THEN post + + STEP post : api_call + LABEL Post the summary + DEPENDS ON summarize + OPERATION chat.postMessage + MAP roomId = {{params.roomId}} + MAP text = {{steps.summarize.summary}} + + STEP confirm : elicitation + LABEL Confirm archive + DEPENDS ON summarize + MESSAGE Archive the flagged items? {{steps.summarize.summary}} + SCHEMA {"type":"object","properties":{"approved":{"type":"boolean"}}} + ON_DECLINE skip_remaining +`; + +const endpoints = [ + { + operationId: "channels.history", + method: "GET", + path: "/api/v1/channels.history", + }, + { + operationId: "chat.postMessage", + method: "POST", + path: "/api/v1/chat.postMessage", + }, +]; + +describe("generated project quality gates", () => { + const outDir = join(process.cwd(), ".tmp-generated-project"); + let files: GeneratedFile[] = []; + + before(() => { + files = generateFromDsl(DSL, { endpoints }).files; + for (const file of files) { + const dest = join(outDir, file.path); + mkdirSync(dirname(dest), { recursive: true }); + writeFileSync(dest, file.content, "utf8"); + } + }); + + after(() => { + rmSync(outDir, { recursive: true, force: true }); + }); + + it("is valid TypeScript (tsc --noEmit)", () => { + const tscEntry = join( + process.cwd(), + "node_modules", + "typescript", + "bin", + "tsc", + ); + try { + execFileSync( + process.execPath, + [tscEntry, "--noEmit", "-p", join(outDir, "tsconfig.json")], + { + cwd: process.cwd(), + stdio: "pipe", + encoding: "utf8", + }, + ); + } catch (err) { + const e = err as { stdout?: string; stderr?: string }; + assert.fail( + `Generated project failed to type-check:\n${e.stdout ?? ""}\n${e.stderr ?? ""}`, + ); + } + }); + + it("passes its own generated `npm test` suite", () => { + const testFiles = files + .filter((f) => f.path.endsWith(".test.ts")) + .map((f) => join(outDir, f.path)); + assert.ok(testFiles.length > 0, "generator emitted at least one test file"); + + try { + execFileSync( + process.execPath, + ["--import", "tsx", "--test", ...testFiles], + { + cwd: outDir, + stdio: "pipe", + encoding: "utf8", + }, + ); + } catch (err) { + const e = err as { stdout?: string; stderr?: string }; + assert.fail( + `Generated tests failed:\n${e.stdout ?? ""}\n${e.stderr ?? ""}`, + ); + } + }); +}); diff --git a/src/tests/integration/pipeline.integration.test.ts b/src/tests/integration/pipeline.integration.test.ts index b019561..b684c50 100644 --- a/src/tests/integration/pipeline.integration.test.ts +++ b/src/tests/integration/pipeline.integration.test.ts @@ -183,9 +183,7 @@ describe("composeDsl — parse and compose pipeline", () => { it("composes conditional workflows", () => { const result = composeDsl(CONDITIONAL_DSL); const wf = result.workflows[0]; - const condStep = wf.steps.find( - (s) => s.config.type === "conditional", - ); + const condStep = wf.steps.find((s) => s.config.type === "conditional"); assert.ok(condStep, "Should have a conditional step"); assert.equal(condStep!.id, "is_violation"); }); @@ -239,10 +237,26 @@ describe("generateFromDsl — full generation pipeline", () => { describe("generateProject — file structure completeness", () => { const composed = composeDsl(MULTI_WORKFLOW_DSL); const endpoints: GeneratorEndpoint[] = [ - { operationId: "post-api-v1-channels_create", method: "POST", path: "/api/v1/channels.create" }, - { operationId: "post-api-v1-channels_invite", method: "POST", path: "/api/v1/channels.invite" }, - { operationId: "post-api-v1-chat_postMessage", method: "POST", path: "/api/v1/chat.postMessage" }, - { operationId: "get-api-v1-channels_history", method: "GET", path: "/api/v1/channels.history" }, + { + operationId: "post-api-v1-channels_create", + method: "POST", + path: "/api/v1/channels.create", + }, + { + operationId: "post-api-v1-channels_invite", + method: "POST", + path: "/api/v1/channels.invite", + }, + { + operationId: "post-api-v1-chat_postMessage", + method: "POST", + path: "/api/v1/chat.postMessage", + }, + { + operationId: "get-api-v1-channels_history", + method: "GET", + path: "/api/v1/channels.history", + }, ]; const result = generateProject({ @@ -306,7 +320,9 @@ describe("generateProject — file structure completeness", () => { const serverFile = result.files.find((f) => f.path === "src/server.ts"); assert.ok(serverFile); const code = serverFile!.content; - assert.ok(code.includes("import { McpServer }") || code.includes("McpServer")); + assert.ok( + code.includes("import { McpServer }") || code.includes("McpServer"), + ); assert.ok(code.includes("StdioServerTransport")); }); @@ -349,7 +365,11 @@ describe("generateProject — error handling", () => { describe("generateProject — generated TypeScript validity", () => { const composed = composeDsl(MINIMAL_DSL); const endpoints: GeneratorEndpoint[] = [ - { operationId: "post-api-v1-chat_postMessage", method: "POST", path: "/api/v1/chat.postMessage" }, + { + operationId: "post-api-v1-chat_postMessage", + method: "POST", + path: "/api/v1/chat.postMessage", + }, ]; const result = generateProject({ diff --git a/src/tests/workflow/executor.integration.test.ts b/src/tests/workflow/executor.integration.test.ts index 0d9ff51..1b387de 100644 --- a/src/tests/workflow/executor.integration.test.ts +++ b/src/tests/workflow/executor.integration.test.ts @@ -20,12 +20,27 @@ function makeClient( path: string, body?: Record, ) => unknown, -): { client: WorkflowClient; calls: Array<{ method: string; path: string; body?: Record }> } { - const calls: Array<{ method: string; path: string; body?: Record }> = []; +): { + client: WorkflowClient; + calls: Array<{ + method: string; + path: string; + body?: Record; + }>; +} { + const calls: Array<{ + method: string; + path: string; + body?: Record; + }> = []; const client: WorkflowClient = { async request(method, path, options) { calls.push({ method, path, body: options?.body }); - return { ok: true, status: 200, data: handler(method, path, options?.body) }; + return { + ok: true, + status: 200, + data: handler(method, path, options?.body), + }; }, }; return { client, calls }; @@ -37,7 +52,10 @@ const silentServer: WorkflowServer = { }, }; -function options(client: WorkflowClient, server: WorkflowServer = silentServer): RunWorkflowOptions { +function options( + client: WorkflowClient, + server: WorkflowServer = silentServer, +): RunWorkflowOptions { return { client, server, endpoints }; } @@ -61,7 +79,11 @@ describe("runWorkflow", () => { { id: "list", label: "List channels", - config: { type: "api_call", operationId: "channels.list", inputMapping: {} }, + config: { + type: "api_call", + operationId: "channels.list", + inputMapping: {}, + }, }, ]), {}, @@ -76,7 +98,9 @@ describe("runWorkflow", () => { it("flows data between steps via templates", async () => { const { client, calls } = makeClient((_m, path, body) => - path.includes("channels.list") ? { channels: [{ _id: "room42" }] } : { ok: true, echoed: body }, + path.includes("channels.list") + ? { channels: [{ _id: "room42" }] } + : { ok: true, echoed: body }, ); const result = await runWorkflow( @@ -84,7 +108,12 @@ describe("runWorkflow", () => { { id: "list", label: "List", - config: { type: "api_call", operationId: "channels.list", inputMapping: {}, outputPath: "channels" }, + config: { + type: "api_call", + operationId: "channels.list", + inputMapping: {}, + outputPath: "channels", + }, }, { id: "post", @@ -113,7 +142,11 @@ describe("runWorkflow", () => { { id: "fetch", label: "Fetch", - config: { type: "api_call", operationId: "channels.list", inputMapping: {} }, + config: { + type: "api_call", + operationId: "channels.list", + inputMapping: {}, + }, }, { id: "double", @@ -134,7 +167,12 @@ describe("runWorkflow", () => { { id: "cond", label: "Check", - config: { type: "conditional", condition: "params.flag === true", thenStep: "yes", elseStep: "no" }, + config: { + type: "conditional", + condition: "params.flag === true", + thenStep: "yes", + elseStep: "no", + }, }, { id: "yes", @@ -160,7 +198,9 @@ describe("runWorkflow", () => { }); it("fans out a forEach api_call", async () => { - const { client, calls } = makeClient((_m, _p, body) => ({ posted: body?.roomId })); + const { client, calls } = makeClient((_m, _p, body) => ({ + posted: body?.roomId, + })); const result = await runWorkflow( workflow([ { @@ -187,7 +227,9 @@ describe("runWorkflow", () => { it("parses JSON sampling output by intent", async () => { const server: WorkflowServer = { async createMessage() { - return { content: { type: "text", text: 'Here you go: {"summary":"done"}' } }; + return { + content: { type: "text", text: 'Here you go: {"summary":"done"}' }, + }; }, }; const { client } = makeClient(() => ({})); @@ -196,7 +238,11 @@ describe("runWorkflow", () => { { id: "sum", label: "Summarize", - config: { type: "sampling", prompt: "Respond with a JSON object", responseFormat: "json" }, + config: { + type: "sampling", + prompt: "Respond with a JSON object", + responseFormat: "json", + }, }, ]), {}, @@ -220,7 +266,12 @@ describe("runWorkflow", () => { { id: "ask", label: "Confirm", - config: { type: "elicitation", message: "ok?", requestedSchema: { type: "object" }, onDecline: "abort" }, + config: { + type: "elicitation", + message: "ok?", + requestedSchema: { type: "object" }, + onDecline: "abort", + }, }, ]), {}, @@ -240,7 +291,11 @@ describe("runWorkflow", () => { { id: "list", label: "List", - config: { type: "api_call", operationId: "channels.list", inputMapping: {} }, + config: { + type: "api_call", + operationId: "channels.list", + inputMapping: {}, + }, }, ]), {}, diff --git a/src/tests/workflow/sandbox-security.unit.test.ts b/src/tests/workflow/sandbox-security.unit.test.ts index 14fc810..d3c6299 100644 --- a/src/tests/workflow/sandbox-security.unit.test.ts +++ b/src/tests/workflow/sandbox-security.unit.test.ts @@ -13,7 +13,10 @@ describe("expression sandbox security", () => { describe("known escape patterns are rejected", () => { const escapes: Array<[string, string]> = [ ["constructor walk", "({}).constructor.constructor('return 1')()"], - ["function constructor", "(function(){}).constructor('return process')()"], + [ + "function constructor", + "(function(){}).constructor('return process')()", + ], ["__proto__ access", "params.__proto__"], ["prototype access", "params.constructor.prototype"], ["process reference", "process.exit(1)"], @@ -90,7 +93,11 @@ describe("expression sandbox security", () => { it("recurses into objects and arrays", () => { assert.deepEqual( - resolveValue({ who: "{{params.name}}", n: "{{params.count}}" }, params, steps), + resolveValue( + { who: "{{params.name}}", n: "{{params.count}}" }, + params, + steps, + ), { who: "Ada", n: 3 }, ); }); diff --git a/src/tools/generate.ts b/src/tools/generate.ts index 9a79468..189c59e 100644 --- a/src/tools/generate.ts +++ b/src/tools/generate.ts @@ -33,7 +33,9 @@ export async function handleGenerate( try { composed = composeDsl(args.dsl); } catch (err) { - return fail(`DSL error: ${err instanceof Error ? err.message : String(err)}`); + return fail( + `DSL error: ${err instanceof Error ? err.message : String(err)}`, + ); } if (composed.workflows.length === 0) { @@ -99,10 +101,14 @@ export async function handleGenerate( }`, ]; if (missing.length > 0) { - lines.push(` Unresolved operationIds (verify these): ${missing.join(", ")}`); + lines.push( + ` Unresolved operationIds (verify these): ${missing.join(", ")}`, + ); } if (composed.warnings.length > 0) { - lines.push(` Composer notes (${composed.warnings.length}) — informational:`); + lines.push( + ` Composer notes (${composed.warnings.length}) — informational:`, + ); for (const w of composed.warnings.slice(0, 10)) { lines.push(` - [${w.code}] ${w.message}`); } diff --git a/src/workflow/api-call.ts b/src/workflow/api-call.ts index 3d09c28..a96270e 100644 --- a/src/workflow/api-call.ts +++ b/src/workflow/api-call.ts @@ -45,7 +45,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+)/); @@ -103,13 +104,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. */ @@ -174,7 +180,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"; diff --git a/src/workflow/executor.ts b/src/workflow/executor.ts index d299039..4bebdaf 100644 --- a/src/workflow/executor.ts +++ b/src/workflow/executor.ts @@ -168,7 +168,10 @@ function shouldRun( return false; } - if (stepDeps.length > 0 && stepDeps.every((d) => state.status[d] === "skipped")) { + if ( + stepDeps.length > 0 && + stepDeps.every((d) => state.status[d] === "skipped") + ) { markSkipped(state, id); return false; } @@ -194,7 +197,11 @@ function executeConditional(step: WorkflowStep, state: ExecutionState): void { const config = step.config as ConditionalStep; let taken: boolean; try { - taken = evaluateCondition(config.condition || "false", state.params, state.steps); + taken = evaluateCondition( + config.condition || "false", + state.params, + state.steps, + ); } catch { taken = false; } @@ -232,7 +239,11 @@ async function executeElicitation( `Step "${step.id}" requires elicitation but the server does not support it.`, ); } - const message = resolveTemplate(config.message || "", state.params, state.steps); + const message = resolveTemplate( + config.message || "", + state.params, + state.steps, + ); const result = await server.elicitInput({ message, requestedSchema: config.requestedSchema, @@ -312,10 +323,18 @@ export async function runWorkflow( conditionalSkips: {}, }; - const runStep = async (step: WorkflowStep): Promise => { + const runStep = async ( + step: WorkflowStep, + ): Promise => { switch (step.config.type) { case "api_call": - await executeApiCall(step, state, options.client, options.endpoints, maxForEach); + await executeApiCall( + step, + state, + options.client, + options.endpoints, + maxForEach, + ); return null; case "sampling": await executeSampling(step, state, options.server); @@ -329,9 +348,7 @@ export async function runWorkflow( executeConditional(step, state); return null; default: - throw new Error( - `Unknown step type for step "${step.id}".`, - ); + throw new Error(`Unknown step type for step "${step.id}".`); } }; @@ -379,7 +396,8 @@ export async function runWorkflow( // Conditional/elicitation steps run alone so branch decisions settle // before dependents are scheduled; everything else runs in parallel. const solo = ready.find( - (s) => s.config.type === "conditional" || s.config.type === "elicitation", + (s) => + s.config.type === "conditional" || s.config.type === "elicitation", ); const batch = solo ? [solo] : ready; @@ -404,7 +422,9 @@ export async function runWorkflow( status: "success", completedSteps: state.completed, stepResults: state.steps, - ...(Object.keys(state.errors).length > 0 ? { stepErrors: state.errors } : {}), + ...(Object.keys(state.errors).length > 0 + ? { stepErrors: state.errors } + : {}), }; } catch (err) { return { @@ -412,7 +432,9 @@ export async function runWorkflow( error: err instanceof Error ? err.message : String(err), completedSteps: state.completed, stepResults: state.steps, - ...(Object.keys(state.errors).length > 0 ? { stepErrors: state.errors } : {}), + ...(Object.keys(state.errors).length > 0 + ? { stepErrors: state.errors } + : {}), }; } } diff --git a/src/workflow/expression-security.ts b/src/workflow/expression-security.ts index c5686e0..9467fa9 100644 --- a/src/workflow/expression-security.ts +++ b/src/workflow/expression-security.ts @@ -60,7 +60,10 @@ const SAFE_STATIC_CALLS = new Map([ ["Date", new Set(["now", "parse"])], ["JSON", new Set(["parse", "stringify"])], ["Math", new Set(Object.getOwnPropertyNames(Math))], - ["Number", new Set(["isFinite", "isInteger", "isNaN", "parseFloat", "parseInt"])], + [ + "Number", + new Set(["isFinite", "isInteger", "isNaN", "parseFloat", "parseInt"]), + ], ["Object", new Set(["entries", "fromEntries", "keys", "values"])], ["String", new Set(["raw"])], ]); @@ -93,6 +96,10 @@ const BLOCKED_IDENTIFIERS = new Set([ const BLOCKED_PROPERTIES = new Set(["__proto__", "constructor", "prototype"]); +// acorn AST nodes are dynamically shaped; a precise discriminated-union type +// here would add significant noise without improving the safety analysis, and +// refactoring this security-critical walker carries more risk than value. +// eslint-disable-next-line @typescript-eslint/no-explicit-any type Node = Record; export function validateSafeExpression(expr: string, context: string): void { @@ -107,6 +114,7 @@ export function validateSafeExpression(expr: string, context: string): void { const detail = err instanceof Error ? err.message : String(err); throw new Error( `Unsafe ${context} expression rejected: "${expr}". ${detail}`, + { cause: err }, ); } } @@ -284,7 +292,10 @@ function validateNode(node: Node | null | undefined, scope: Scope): void { } } -function validateList(nodes: Array, scope: Scope): void { +function validateList( + nodes: Array, + scope: Scope, +): void { for (const child of nodes) validateNode(child, scope); } @@ -353,7 +364,9 @@ function validateIdentifier(name: string, scope: Scope): void { function validateMutationTarget(node: Node, scope: Scope): void { if (node.type === "Identifier") { if (!scope.has(node.name)) { - reject(`assignment to undeclared identifier "${node.name}" is not allowed`); + reject( + `assignment to undeclared identifier "${node.name}" is not allowed`, + ); } validateIdentifier(node.name, scope); return; @@ -399,15 +412,21 @@ function patternNames(node: Node): string[] { function memberRootName(node: Node): string | null { let current = node; - while (current.type === "MemberExpression" || current.type === "ChainExpression") { - current = current.type === "ChainExpression" ? current.expression : current.object; + while ( + current.type === "MemberExpression" || + current.type === "ChainExpression" + ) { + current = + current.type === "ChainExpression" ? current.expression : current.object; } return current.type === "Identifier" ? current.name : null; } function propertyName(node: Node): string | null { if (node.computed) { - return node.property.type === "Literal" ? String(node.property.value) : null; + return node.property.type === "Literal" + ? String(node.property.value) + : null; } return node.property.type === "Identifier" ? node.property.name : null; } diff --git a/src/workflow/sampling.ts b/src/workflow/sampling.ts index 4133c5a..7349d56 100644 --- a/src/workflow/sampling.ts +++ b/src/workflow/sampling.ts @@ -10,7 +10,8 @@ export function detectJsonIntent(step: { prompt?: string; systemPrompt?: string; }): boolean { - const haystack = `${step.systemPrompt || ""} ${step.prompt || ""}`.toLowerCase(); + const haystack = + `${step.systemPrompt || ""} ${step.prompt || ""}`.toLowerCase(); return ( haystack.includes("json") || haystack.includes("respond only with") || @@ -99,7 +100,9 @@ export async function executeSampling( const config = step.config as SamplingStep; const jsonMode = config.responseFormat === "json" || - Boolean(config.responseSchema && Object.keys(config.responseSchema).length > 0) || + Boolean( + config.responseSchema && Object.keys(config.responseSchema).length > 0, + ) || detectJsonIntent(config); const response = await server.createMessage({ diff --git a/src/workflow/templates.ts b/src/workflow/templates.ts index 01481e0..d0be691 100644 --- a/src/workflow/templates.ts +++ b/src/workflow/templates.ts @@ -147,7 +147,10 @@ export function evaluateExpressionBlock( try { return runInSandbox(`"use strict"; (${expr});`, sandbox); } catch { - return runInSandbox(`"use strict"; (function() { ${withReturn} })();`, sandbox); + return runInSandbox( + `"use strict"; (function() { ${withReturn} })();`, + sandbox, + ); } }