Octov0.11.7
AI

Expose an MCP Server

Turn a flow into an MCP server with tools, resources, and prompts.

The mcp-router block turns a flow into a stateless Model Context Protocol server. Put it behind an http source and every POST to the route is one MCP JSON-RPC request; the block's output body is the JSON-RPC response. Each tool you declare is an ordinary flow branch, so an MCP tool call runs a flow and returns its result.

An MCP client listing the tools exposed by an Octo mcp-router server

The router is a protocol adapter and calls no LLM. The intelligence lives in the MCP client (Claude Code, Claude Desktop, or any other MCP client) that connects to it.

A complete server

This is samples/mcp-router from the repo: one tool, a fixed resource, a resource template, and one prompt.

config.yaml
service:
  name: mcp-router

env:
  - name: HTTP_PORT
    default: "8080"

# Template resources advertised as MCP resources and prompts, referenced by alias.
resources:
  templates:
    - resource: guide.md
      as: guide
    - resource: greet.md
      as: greet
    - resource: city.md
      as: city

connectors:
  - name: api
    type: http
    settings:
      port: ${HTTP_PORT}

flows:
  - name: mcp
    source:
      connector: api
      type: http
      settings:
        path: /mcp
    process:
      - type: mcp-router
        name: weather-mcp
        serverName: weather-tools
        tools:
          - name: forecast
            description: Return a short weather forecast for a city.
            # Display name for people; `name` stays the identifier clients call by.
            title: Weather forecast
            # Hints, so a client knows how much ceremony to put in front of a call.
            # They are advisory: the runtime does not enforce them.
            annotations:
              readOnlyHint: true
              openWorldHint: true
            inputSchema: |
              {
                "type": "object",
                "required": ["city"],
                "properties": { "city": { "type": "string" } }
              }
            # Declaring the shape of the answer makes the router return it as
            # structuredContent as well as text, so a client can use it as data.
            outputSchema: |
              {
                "type": "object",
                "required": ["city", "summary"],
                "properties": {
                  "city": { "type": "string" },
                  "summary": { "type": "string" }
                }
              }
            process:
              # A real tool would call a weather API; here we echo a fixed
              # forecast so the server runs without external dependencies.
              - type: set-payload
                settings:
                  value: '{"city": body.city, "summary": "Sunny, 24C"}'
        resources:
          - uri: octo://guide
            name: guide
            description: Operator guide for the weather tools.
            mimeType: text/markdown
            resource: guide
          # A family of documents rather than one. Clients discover it on
          # resources/templates/list, fill in {city}, then read the concrete uri;
          # what the placeholder took reaches the template as body.city.
          - uriTemplate: octo://city/{city}
            name: city-briefing
            description: A briefing for any city.
            mimeType: text/markdown
            resource: city
        prompts:
          - name: assist
            description: Prime an assistant to help a user in a given city.
            arguments:
              - name: city
                description: The user's city.
                required: true
            resource: greet

The template files are plain text with {{ }} expressions:

greet.md
You are helping a user in {{ body.city }}. Offer to fetch the forecast with the
`forecast` tool, and keep answers short and friendly.
guide.md
# Weather tools: operator guide

This MCP server exposes a small set of weather tools.

- `forecast` returns a short forecast for a city.
- Values are illustrative; wire the tool flow to a real API to make it live.
- Be kind to rate limits and cache when you can.

Run it:

bin/octo run --config samples/mcp-router

The block, field by field

The mcp-router is a composite block: tools, resources, prompts, and serverName sit at the block top level, not under settings. At least one tool, resource, or prompt is required.

serverName

The name reported in the initialize handshake's serverInfo. Falls back to the block's name, then to octo-mcp.

tools[]

Each entry is one MCP tool, declared exactly like an ai-agent tool. name is the tool name clients call, unique within the block. description is handed to the model, so say what the tool returns and when to use it. inputSchema is a JSON Schema string for the arguments. process is the flow branch that runs on tools/call: the call's arguments object becomes the branch's message body (a schema property city is body.city), and the branch's final body is returned as the tool's text result. A branch failure, or an unknown tool name, comes back as an isError tool result the model can see and recover from, not a protocol error.

Tool metadata

A tool may also declare three pieces of MCP metadata. All three are mcp-router only; an ai-agent rejects them when the flow is built, because an LLM tool call has no protocol carrying them.

tools:
  - name: forecast
    title: Weather forecast
    description: Return a short weather forecast for a city.
    annotations:
      readOnlyHint: true
      openWorldHint: true
    outputSchema: |
      { "type": "object", "properties": { "summary": { "type": "string" } } }
FieldMeaning
titleA display name for people, where name stays the identifier clients call by. Shown in consent prompts and tool pickers.
annotationsreadOnlyHint, destructiveHint, idempotentHint and openWorldHint, so a client can decide how much ceremony to put in front of a call. A hint you leave out is not advertised at all, since the protocol's defaults differ per hint. They are advisory: the runtime does not check a tool branch against them, and the protocol tells clients to treat them as untrusted.
outputSchemaThe shape of what the branch returns. Declaring it makes tools/call send the result as structuredContent as well as text. The text block stays either way, because the spec requires the serialized JSON there for clients that do not read structuredContent. A branch whose result is not a JSON object gets no structuredContent; the call still succeeds.

resources[]

Documents the server advertises and serves on resources/read. Each entry declares either a uri or a uriTemplate, never both.

FieldMeaning
uriThe stable identifier clients read by, e.g. octo://guide. One fixed document, listed on resources/list. Unique within the block.
uriTemplateA family of documents rather than one, listed on resources/templates/list instead. See below.
nameA human-readable name. Required.
descriptionOptional.
mimeTypeDefaults to text/plain.
resourceThe alias of a template resource declared under resources.templates. The template is rendered on each read.

Resource templates

A uriTemplate is an RFC 6570 level-1 template: literal text with {name} placeholders. One declaration stands in for every city, contact or ticket a client might ask about.

resources:
  - uriTemplate: octo://city/{city}
    name: city-briefing
    description: A briefing for any city.
    mimeType: text/markdown
    resource: city

A client discovers it on resources/templates/list, fills in the placeholders, and reads the concrete uri. The router matches that uri against the template and hands what each placeholder took to the rendered template as the body, so {city} is {{ body.city }}. The template also sees the request's variables, so a router behind a jwt-validate can render per-caller content.

curl -s localhost:8080/mcp -d '{"jsonrpc":"2.0","id":1,"method":"resources/templates/list"}'
curl -s localhost:8080/mcp -d '{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"octo://city/Paris"}}'

Matching rules:

  • Only simple expansion is supported: no operators (+ # . / ; ? &), no explode, no prefix lengths.
  • A placeholder matches up to the next literal rather than greedily, so octo://city/{city}/weather takes one segment instead of swallowing the rest.
  • A placeholder never matches empty: octo://city/ is not a city.
  • Values are percent-decoded, so octo://city/S%C3%A3o%20Paulo renders as São Paulo.
  • A fixed uri always wins over a template that would also match it, which is how you carve one special case out of a family. Templates are otherwise tried in declaration order.
  • A placeholder name must be a valid identifier, because its value is read as body.<name>. {contact-id} is rejected when the flow is built.

prompts[]

Parameterized prompt templates served on prompts/get. name and description are advertised on prompts/list; arguments[] each carry name, description, and required; resource is the template resource alias to render. The client's arguments are exposed to the template as the body, so an argument city renders as {{ body.city }}. The request's variables are available too, so a prompt can read what a jwt-validate in front of the router left behind. The rendered text is returned as a single user message.

The protocol surface

The router is stateless: no sessions, no SSE, one JSON-RPC request per HTTP POST, one response. It handles:

MethodBehavior
initializeReports serverInfo (the runtime's own release version) and negotiates the protocol version. Advertises only the capabilities the block declares: a router with tools but no prompts does not claim prompts.
pingReturns an empty result.
tools/listThe declared tools with their JSON Schemas.
tools/callRoutes to the matching tool branch.
resources/listThe declared fixed-uri resources.
resources/templates/listThe declared uriTemplate resources.
resources/readRenders the resource a uri names, matching it against the templates when no fixed uri matches.
prompts/list, prompts/getThe declared prompts; get renders the template with the arguments as body.

A request without an id is a JSON-RPC notification (clients send notifications/initialized after the handshake): the router acknowledges it with an empty 202 response instead of a JSON-RPC reply. Any other method returns a standard -32601 method-not-found error.

Protocol version negotiation

The router speaks 2024-11-05, 2025-03-26 and 2025-06-18, since it covers the subset of the protocol that has not changed across them. Ask for one of those and you get it back. Ask for anything else and you get 2025-06-18, the latest the router supports, rather than your own version echoed. Send no version at all and you get 2024-11-05.

Capabilities are advertised honestly

initialize reports only what the block declares: a router with tools and no prompts does not advertise prompts. Neither subscribe nor listChanged is ever claimed, since a stateless router has no channel to notify a client over, and the default for both is false.

Test it with curl

Every interaction is a plain POST, so you can drive the whole server from a terminal:

curl -s localhost:8080/mcp -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
curl -s localhost:8080/mcp -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"forecast","description":"Return a short weather forecast for a city.","inputSchema":{"type":"object","required":["city"],"properties":{"city":{"type":"string"}}}}]}}

Call the tool, read the resource, render the prompt:

curl -s localhost:8080/mcp -d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
  "params":{"name":"forecast","arguments":{"city":"Paris"}}}'
curl -s localhost:8080/mcp -d '{"jsonrpc":"2.0","id":4,"method":"resources/read",
  "params":{"uri":"octo://guide"}}'
curl -s localhost:8080/mcp -d '{"jsonrpc":"2.0","id":5,"method":"prompts/get",
  "params":{"name":"assist","arguments":{"city":"Paris"}}}'

Connect a client

The server speaks HTTP, so point any MCP client at the route URL.

claude mcp add --transport http weather-tools http://localhost:8080/mcp

Then ask Claude Code to fetch a forecast; it discovers the forecast tool via tools/list and calls it.

Octo is an MCP server, not an MCP client. Flows and ai-agent blocks do not consume external MCP servers; an agent's tools are flow branches inside the integration (see Agent Skills and Tools). The mcp-router is how you hand those same flow-backed capabilities to clients outside Octo.

Next steps

The server above is wide open: anyone who can reach the port can call the tools. For anything beyond localhost, put a jwt-validate block in front of the router and serve OAuth protected-resource metadata so MCP clients can authenticate; see Secure an MCP Server with OAuth.

On this page