Skip to main content
Glama
README.md
# FlowMCP

Most MCP servers wrap an entire platform: every endpoint becomes a tool, the model gets a 40-tool surface, and orchestration is outsourced to sampling — then everyone blames the model. FlowMCP inverts that: **workflows are the tools.** Each MCP tool is one known, named workflow; a deterministic engine executes the steps; the model's only job is picking the flow and filling 2–3 parameters. Small models (7–30B) can drive this reliably, because there is almost nothing to get wrong.

## Quickstart (60 seconds)

```sh
npm install -g @petergreenappliedai/flowmcp
flowmcp serve                    # serves the demo flows over stdio
flowmcp serve --flows ~/my-flows # serves yours
```

Or from a clone (for development):

```sh
git clone https://github.com/PeterGreenAppliedAI/FlowMCP.git && cd FlowMCP
npm install
npm test          # hermetic — no network needed
npm start         # serves MCP over stdio
```

Point any MCP client at it. Claude Desktop / Claude Code / anything MCP:

```json
{
  "mcpServers": {
    "flowmcp": {
      "command": "npx",
      "args": ["-y", "@petergreenappliedai/flowmcp", "serve", "--flows", "/absolute/path/to/your/flows"]
    }
  }
}
```

Your client will list **two** tools — `morning_brief` and `hn_top` — not forty. Both run against keyless public APIs (Open-Meteo, Hacker News), so they work on a fresh clone with zero configuration.

```
> morning_brief city="Lisbon"

# Morning brief — Lisbon, Portugal

## Weather today
High 29.4°C / low 19.3°C, 0% chance of rain.

## Top of Hacker News
- **…** — 330 points https://…
```

## Flow file format

Flows are data, not code. The server loads every `flows/*.flow.json5` at startup and exposes each as one MCP tool. An invalid flow is a loud startup error naming the file and field.

```json5
{
  name: 'morning_brief',          // becomes the MCP tool name (snake_case)
  description: 'WHEN TO USE: …',  // ≤300 chars — this is the model's entire manual
  input: {                        // 0–3 parameters, no more
    city: { type: 'string', description: 'City for the weather', required: false, default: 'New York' },
  },
  env: ['WEATHER_API_KEY'],       // ONLY these env vars are visible to {{env.X}} — least privilege
  steps: [ /* run in order; each result is available as steps.<id> */ ],
  output: '{{steps.render}}',     // the tool's text result
}
```

Check a directory without serving: `npm start -- --flows ./my-flows --validate` exits 0 if every flow is valid, 1 with the file and field otherwise.

### Step kinds

| kind | fields | what it does |
|------|--------|--------------|
| `http_request` | `method` (GET/POST), `url`, `headers?`, `body?`, `timeoutMs?` (default 15000) | Fetch a URL; JSON responses are parsed. One automatic retry on network error — **GET only**: a timed-out POST may have landed, so it is never retried. |
| `transform` | `expr` | Reshape prior results with a sandboxed expression — paths, object/array literals, comparisons. No code execution. |
| `template` | `template` | Mustache-style string build: `{{steps.x.y[0]}}`. Arrays join line-per-item; missing terminal values render as `''`. |
| `map` | `over`, `as?` (default `item`), `step` | Run one leaf step per array element, sequentially, **max 10 items** — slice with `steps.ids[0:5]`. |
| `branch` | `if`, `then`, `else?` | Evaluate a condition, run one of two step lists. No nested branches. |
| `mcp_call` | `server`, `tool`, `args?`, `timeoutMs?` (default 30000), `maxResultChars?` (default 8000) | Call one tool on a downstream MCP server from `servers.json5`. See Composition below. |

Everything downstream of a step sees `input.*`, `env.*` (for `{{env.API_KEY}}` — never put secrets in flow files), and `steps.<id>`. A failed step aborts the flow and returns a structured `isError` result naming the step. Whole-flow timeout: 60s.

### Writing your own flow

Drop a file in a flows directory, restart the server — that's the whole workflow. The server reads `flows/` in the repo by default; point it anywhere with `--flows` (or the `FLOWMCP_FLOWS_DIR` env var), which is how you keep private flows out of a public checkout:

```sh
npm start -- --flows ~/my-flows
```

```json5
// flows/cat_fact.flow.json5
{
  name: 'cat_fact',
  description: 'WHEN TO USE: the user wants a random cat fact.',
  input: {},
  steps: [
    { id: 'fact', kind: 'http_request', url: 'https://catfact.ninja/fact' },
    { id: 'render', kind: 'template', template: 'Cat fact: {{steps.fact.fact}}' },
  ],
  output: '{{steps.render}}',
}
```

## Composition: wrapping other MCP servers

Flows can call tools on *other* MCP servers — and this is where the thesis becomes an operation instead of an opinion. Register downstream servers in a `servers.json5` next to your flow files:

Downstream servers speak either transport: stdio (`command`) or remote
Streamable HTTP (`url` + `headers`, e.g. a hosted Shopify/Business Central
MCP — tokens interpolated from env):

```json5
{
  erp: {
    url: 'https://your-tenant.example.com/mcp',
    headers: { Authorization: 'Bearer {{env.ERP_TOKEN}}' },
    attestReadOnly: ['list_customers', 'get_customer'],  // operator-attested reads (server annotates nothing)
    allow: ['post_invoice'],                       // write-capable, two-phase gated
  },
  github: {
    command: 'npx',
    args: ['-y', '@modelcontextprotocol/server-github'],
    env: { GITHUB_TOKEN: '{{env.GITHUB_TOKEN}}' },  // interpolated — never inline secrets
    allow: [],                                       // non-read-only tools need explicit listing
    shell: true,                                     // Windows: npx is a .cmd shim — raw spawn can't exec it
  },
}
```

(`shell` defaults to false. On Windows, `.cmd` shims like `npx` need `shell: true` — or point `command` directly at a Node entry point. `servers.json5` is operator-trusted config, so the shell opt-in is a portability knob, not an injection surface. Relative paths in `command`/`args` resolve against the directory containing `servers.json5` — the config works no matter where the flowmcp process was started from.)

Then use an `mcp_call` step like any other:

```json5
{ id: 'issue', kind: 'mcp_call', server: 'github', tool: 'get_issue',
  args: { owner: 'x', repo: 'y', issue_number: '{{input.n}}' } }
```

The key property: the wrapped server's 40 tools **never appear in FlowMCP's `tools/list`**. 40 tools in, 3 workflows out — the model's surface never grows, no matter how many servers sit behind it.

Rules of engagement:

- **Read-only by default, fail-closed.** A downstream tool is callable only if it declares `annotations.readOnlyHint: true`, is operator-attested as a read in that server's `attestReadOnly` list (a security assertion — production servers often annotate nothing), or is explicitly named in its `allow` list. Naming a write tool is a consent moment, on purpose — and it changes what FlowMCP advertises: annotations are **computed per flow** from its steps, so a flow containing a POST or an allowlisted write tool is published with `readOnlyHint: false, destructiveHint: true`. FlowMCP never tells a client a write-capable flow is read-only.
- **Children get a minimal environment.** Downstream servers receive a baseline (`PATH`, `HOME`, …) plus the vars you configure in their `env` block — never the whole parent environment, unless you set `inheritEnv: true` for that server.
- **One session per child, not per flow.** Downstream servers spawn lazily on first use, stay alive across calls, respawn on crash (3 attempts, then a 5s backoff), and shut down after 5 minutes idle. The client handshakes at the newest supported protocol revision and validates what comes back.
- **The step timeout covers spawn + handshake + call** as one unit, bounded by the flow's 60s deadline — a slow cold-start can't invisibly eat the budget.
- **Results are capped** at `maxResultChars` (default 8K) — downstream verbosity is not your flow's problem to inherit. `structuredContent` is preferred when the downstream tool provides it; otherwise JSON text results are parsed so later steps can path into them.

## The benchmark

The thesis is an empirical claim, so we tested it: six conditions, ten local models
(4B–35B plus DeepSeek v4-flash), identical fixture data, outcome-based scoring. Headline:
**79% task success through the two-flow façade vs 10% on the same 35 tools raw** — paired
McNemar 33 discordant pairs, every one favoring the façade (exact p ≈ 2.3×10⁻¹⁰) — at a
tenth of the tokens per attempt. A 7B through the façade outscored a 35B driving the raw
surface. Full report with charts, per-model tables, and everything the data does *not*
prove: **[petergreenappliedai.github.io/FlowMCP](https://petergreenappliedai.github.io/FlowMCP/)** ·
method, harness, raw results, and transcripts in [bench/](bench/).

## The CLI

One entry point for the whole loop (from a clone: `npx tsx src/cli.ts <cmd>`, or
`npm run build` once and use `node dist/cli.js`; installed as a package it's the
`flowmcp` bin):

| command | what it does |
|---|---|
| `flowmcp serve [--flows <dir>]` | serve flows as MCP tools over stdio (default) |
| `flowmcp validate [--flows <dir>]` | check flows + `servers.json5` + `registry.json5`, exit 0/1 |
| `flowmcp status [--flows <dir>]` | registry health and advisory nominations |
| `flowmcp explain [--flows <dir>]` | print a routing preamble for LLM hosts |
| `flowmcp author --servers-dir <dir> --name <flow> --model <id> [--gateway <url>] "<intent>"` | author a flow with a model, under observation |
| `flowmcp compile <run.v0.json> [outDir] [flowName] [serverName]` | compile a recorded trace into a candidate flow |
| `flowmcp detect <executions.jsonl>` | nominate recurring procedures from execution logs |
| `flowmcp shadow <flow> --agent '<cmd>' [--judge '<cmd>']` | shadow-verify a flow against a host-supplied agent |
| `flowmcp compile-graphql <query-log.jsonl> --server <name> --tool <tool>` | compile recurring GraphQL operations into candidate flows |

`author` needs an OpenAI-compatible endpoint via `--gateway` or the `GATEWAY` env var —
there is no default endpoint or model; any local or hosted model works.

## The authoring loop (experimental)

"Workflows as tools" has an obvious objection: someone has to author the workflows. The
answer, shipped as `flowmcp author` / `compile` / `detect`: **a model helps author the
flow once, under observation and validation — it does not improvise the workflow at
runtime.** A model writes a program against the tool surface; an instrumented runner
records its execution (cassette record/replay for live, nondeterministic APIs); the
compiler derives a candidate flow from the *observed trace* — dataflow classified by
variant differencing, constants separated from inputs, redundant calls removed — and
emits it with provenance, warnings, and fail-closed refusals for anything it cannot
prove. Replay against mutated data catches hardcoding before a human ever reviews it.

The full loop exists and has run end-to-end: `detect.ts` nominates flow candidates from
execution logs (frequency × cost × success, inputs discovered from cross-run argument
variance); `flowmcp author` takes an intent, introspects the configured servers'
read-only tools, has a model write and repair a script in a disposable sandboxed process,
records it against the real servers, and compiles the trace. Dogfooded on a real
recurring news-gathering workflow: the compiled flow replaced a multi-minute agentic
search sweep with one 4.5-second deterministic call at zero model tokens. Nothing in the
loop is provider-specific — the dogfood happens to use a self-hosted SearXNG wrapper, but
any MCP server exposing a search (or any other read-only) tool slots into `servers.json5`
the same way.

This is not "automatic workflow generation": a generated flow carries provenance for
every inference, warns where the DSL cannot express the source, and requires review
before serving — and always before writes. The precise claim: existing MCP workflow
engines execute workflows; FlowMCP is designed to **compile observed tool use into
small, reviewable workflow tools, and then execute them deterministically**. Intelligence
at build time, determinism at runtime.

## The registry: promotion and rot detection (v0.6)

A compiled flow is deterministic code, and deterministic code can silently rot. The
registry is the maintenance layer: drop a `registry.json5` beside your flows and the
directory becomes governed — every flow must be listed with a state
(`candidate → reviewed → active → retired`), **only `active` flows are served**, and an
unlisted flow file is a loud startup error. Entries carry provenance (source trace,
authoring model) and review records. No registry file → nothing changes.

With a registry present, every flow execution is logged to `registry-log.jsonl` — an
open append-only contract that external systems write to as well: a consuming agent's
editorial layer can append `signal` records ("this lens of the output was thin, I
patched it"), and a shadow-replay harness can append `shadow` records (flow output vs
the specialist path). `npm start -- --flows <dir> --status` computes per-flow health and
prints advisory nominations, in cost order:

- **Loud failure counting (free):** 3+ consecutive failed runs → needs review.
- **Consumer signals (free):** the same lens patched in each of the last 3 gap-check
  signals → recompile candidate — this catches *stale-but-well-formed* output, the rot
  no structural check can see, using judgment the consumer was already paying for.
- **Shadow replay (paid, scheduled):** `flowmcp shadow <flow> --agent '<cmd>' --judge '<cmd>'`
  re-derives the task through a host-supplied agent, has a host-supplied judge compare, and
  records the verdict. FlowMCP never calls a model — the agent and judge are injected
  commands; without a judge nothing is recorded. Write flows are refused (shadowing one
  would write twice).

Nominations are advisory: `--status` never mutates the registry. Promotion and
retirement stay human decisions — the registry's job is to make them informed and cheap.
Full spec in [FORMAT.md](FORMAT.md).

## Trust model

Flow files are **trusted programs** — treat them like code, review them like code. The expression language can't execute code, but a flow can still send data to any URL it names; what bounds the blast radius is what the flow can *see*: only the env vars it declares in `env: [...]` (never all of `process.env`), only the 0–3 inputs it declares, and only downstream MCP tools that are read-only or explicitly allowlisted. `servers.json5` is operator configuration, same trust level as the server's own command line. Don't load flow files you haven't read.

## Design constraints (on purpose)

- **Hand-rolled server protocol**, ~150 lines: `initialize`, `tools/list`, `tools/call`, `ping` over newline-delimited JSON-RPC on stdio — small enough to audit in one sitting. The line we hold: we implement the MCP surface we govern; we use the reference client for downstream interoperability.
- **Dependencies:** `zod`, `json5`, and the official MCP SDK — used ONLY as the reference client transport for consuming remote (Streamable HTTP) downstream servers, pinned to its v1 line. FlowMCP's governed server runtime and workflow engine are hand-built and intentionally small; commodity protocol churn is delegated to the reference client.
- **Small surfaces everywhere:** few tools, ≤300-char descriptions, ≤3 params. Every token in `tools/list` is budget spent by every client on every turn.
- **Writes are gated by construction.** A flow containing a write step (a POST, or an `mcp_call` to an allowlisted tool) automatically gets a **two-phase confirmation protocol** — there is no opt-out flag. The first call runs the read steps, pauses before the first write, and returns a proposal plus a single-use confirmation token (5-minute expiry) bound to the frozen pre-write state; confirming executes exactly what was proposed, never a recomputation. A `proposal` template on the flow customizes the prompt. Write flows advertise `readOnlyHint: false` and a `confirm` parameter — all computed from the steps, never declared. With an elicitation-capable client (v0.5), approval is host-mediated instead: the server elicits `{approve}` through the host and the model never holds a token; missing required parameters are elicited the same way. With plain clients, the token protocol applies — a checkpoint, not a guaranteed human gate.
- stdout is the protocol channel; all logging goes to stderr.

## Roadmap

Done and shipped: the six-condition benchmark ([report](https://petergreenappliedai.github.io/FlowMCP/), frozen at tag `bench-2026-07-31`), the trace→flow compiler and authoring loop (now first-class CLI: `flowmcp author` / `compile` / `detect`; the benchmark corpus stays in [bench/](bench/)), remote Streamable HTTP downstreams with operator attestation + schema drift pinning (v0.4), host-mediated write approval via elicitation (v0.5), the flow registry with promotion states, run logging, and staleness nominations (v0.6), the unified `flowmcp` CLI (v0.7, [on npm](https://www.npmjs.com/package/@petergreenappliedai/flowmcp)), and the shadow-verification harness with host-injected agent and judge (v0.8).

Ahead:

- A broader benchmark task suite: more decline and partial-match shapes, tasks with no flow coverage, more trials per cell
- MCP conformance matrix (Inspector-based CI against current protocol revisions)
- Destination allowlists and HTTPS policy for `http_request`
- HTTP transport for the server itself
- Flow hot-reload

## Development

```sh
npm test            # vitest: spawns the real server, speaks JSON-RPC, mocks only outbound HTTP
npm run typecheck   # strict TS, no emit
npm run build       # emits dist/ — the `flowmcp` bin entry points there
```

CI runs typecheck + tests on Node 20 and 22 for every push. Engineering log — what worked, what didn't, what the fix was — lives in [DECISIONS.md](DECISIONS.md). The flow file format is specified as a portable contract in [FORMAT.md](FORMAT.md); benchmark method and results live in [bench/](bench/).

MIT license.

TDQS

A4.1/5.0

Scored across 2 tools

Disambiguation3/5

The tools overlap: morning_brief includes the top 5 HN stories, so both tools can satisfy a request for HN top stories. However, the descriptions clearly distinguish when to use each: hn_top for immediate HN queries and morning_brief for a broader daily summary. This overlap creates some ambiguity, but the usage cues help.

Naming Consistency4/5

Both tool names follow a consistent pattern of lowercase noun phrases with underscores (hn_top, morning_brief). While they don't use verb_noun naming, the style is predictable and coherent across the set, with no mixed conventions.

Tool Count3/5

With only 2 tools, the set is on the thin side, but it matches the server's narrow purpose of providing a morning briefing and HN top stories. The count is borderline but not unreasonable for such a small scope.

Completeness4/5

For the apparent domain of news and weather summaries, the core workflows are covered. A minor gap is the lack of a standalone weather-only tool, but the combined briefing handles the main use case. The surface is largely complete for its stated purpose.

Maintenance

ActivitySlowing
ResponsivenessNo issues