Skip to content

MCP Integration Guide — Expose Agents as MCP Tools

Table of Contents

  1. Introduction
  2. Architecture Overview
  3. MCP Server - Exposing Workflows as Tools
  4. MCP Client - Calling External MCP Tools
  5. Configuration Reference
  6. Transport Options
  7. Working with Claude Desktop
  8. Tool Naming and Schemas
  9. MCP Resources
  10. Error Handling
  11. Testing
  12. Complete Examples
  13. Cookbook
  14. MCP Security
  15. Multi-MCP Architecture
  16. SynaptX Gateway Integration
  17. Troubleshooting

1. Introduction

What is MCP?

The Model Context Protocol (MCP) is an open standard that allows LLM applications (such as Claude Desktop, Cursor, and other AI assistants) to discover and invoke tools exposed by external servers. Think of it as a universal plugin interface for AI - any MCP-compliant client can call any MCP-compliant server's tools without custom integration code.

Why MCP in Helix?

Helix already excels at integrating systems through orchestration patterns. MCP extends this to the AI world:

  • MCP Server (mcp-server://): Expose any Helix route as an MCP tool. Claude Desktop can then discover your order processor, data transformer, or API gateway and call it directly.
  • MCP Client (mcp://): Call tools on external MCP servers from within your Helix workflows. Your integration pipeline can invoke an AI coding assistant, a database query tool, or any MCP-enabled service.

Together, these two components turn Helix into a bridge between traditional enterprise systems and AI agents.

SDK

Helix uses the official Go MCP SDK:

github.com/modelcontextprotocol/go-sdk v1.4.0

This provides full MCP 1.0 compliance with support for Streamable HTTP, SSE, and stdio transports.


Quick Start: The mcps: Top-Level Tag

The simplest way to expose agents, pipelines, or workflows as MCP tools is the mcps: top-level YAML primitive. This replaces the older helix.mcp.servers configuration block for most use cases.

Single MCP Server Exposing Agents

agents:
  - id: assistant
    type: react
    trigger: http-server://0.0.0.0:8080/chat
    model:
      provider: openai
      model: gpt-4o
    system_prompt: "You are a helpful assistant."

mcps:
  - id: my-tools
    addr: "0.0.0.0:3100"
    expose: [assistant]

MCP Server Exposing Routes and Pipelines

workflows:
  - id: lookup-order
    description: "Look up an order by ID"
    trigger:
      uri: direct://lookup-order
    steps:
      - target: postgres://db:5432/orders?operation=query

pipelines:
  - id: doc-search
    source: file://./docs
    steps:
      - rag-chunk:
          strategy: semantic
      - rag-embed:
          provider: openai
      - rag-store:
          store: pgvector
      - rag-retrieve:
          strategy: hybrid
          top_k: 5
    output: text

mcps:
  - id: backend-tools
    addr: "0.0.0.0:3100"
    expose: [lookup-order, doc-search]

Multiple MCP Servers

Expose different tools to different audiences:

mcps:
  # Internal tools for developers
  - id: dev-tools
    addr: "127.0.0.1:3001"
    expose: [debug-query, tail-logs, reset-cache]

  # External tools for AI clients
  - id: customer-tools
    addr: "0.0.0.0:3002"
    expose: [check-order, update-address]

Empty Expose (All Routes)

When expose is omitted, all agents, pipelines, and workflows are exposed:

mcps:
  - id: all-tools
    addr: "0.0.0.0:3100"
    # expose is omitted — everything is exposed

The mcps: tag is syntactic sugar that maps to the MCP server component internally. For advanced configuration (custom tool schemas, transport selection, per-tool metadata), use the full helix.mcp.servers config block documented below.


Preferred: Define MCP Tools with tools:

The tools: top-level primitive is the preferred way to define MCP-discoverable endpoints. Unlike generic workflows, tools guarantee schemas -- description, input, and output are all mandatory. The parser rejects tools that omit any of them.

Why tools: Instead of Routes?

Feature workflows: tools:
Input schema Optional (default: permissive additionalProperties: true) Mandatory -- parser rejects tools without it
Output schema None Mandatory -- stored in x-helix-output-schema extension
Description Optional (falls back to "Helix route: {id}") Mandatory
Input validation None (raw body passed through) Validated before execution
from: URI Required Auto-generated as tool://{id}
MCP discovery Exposed with best-effort metadata Exposed with full input+output schemas

Example: Tools with Guaranteed Schemas

tools:
  - id: search
    description: "Search knowledge base"
    input:
      query: { type: string, description: "Search query", required: true }
      limit: { type: integer, description: "Max results", default: 10 }
    output:
      results: { type: array, description: "Matching documents" }
      total: { type: integer, description: "Total matches" }
    steps:
      - transform:
          starlang: |
            q = payload["query"]
            limit = payload.get("limit", 10)
            write({"results": [], "total": 0}, "application/json")

  - id: translate
    description: "Translate text between languages"
    input:
      text: { type: string, description: "Text to translate", required: true }
      source_lang: { type: string, description: "Source language code", default: "en" }
      target_lang: { type: string, description: "Target language code", required: true }
    output:
      translated: { type: string, description: "Translated text" }
      confidence: { type: number, description: "Translation confidence score" }
    steps:
      - agent:
          type: react
          model:
            provider: openai
            model: gpt-4o
          system_prompt: "You are a translator. Return JSON with 'translated' and 'confidence' fields."

mcps:
  - id: my-tools
    addr: "0.0.0.0:3100"
    expose: [search, translate]

When an MCP client connects, it sees tools with full schemas:

Available tools:
- search: Search knowledge base (input: query*, limit; output: results, total)
- translate: Translate text between languages (input: text*, source_lang, target_lang*; output: translated, confidence)

When to Use Routes vs Tools

Use tools: when: - You want guaranteed, self-describing schemas for MCP discovery - The endpoint is primarily consumed by AI agents or MCP clients - Input validation is important

Use workflows: when: - You are building traditional integration flows (Kafka consumers, content-based routing) - The endpoint does not need schema metadata - You need a custom from: URI (e.g., kafka://, timer://)


2. Architecture Overview

                         MCP Server Component
                    ┌─────────────────────────────┐
                    │  mcp-server://0.0.0.0:3000   │
                    │                               │
  Claude Desktop ──►│  Tool: "process_order"  ──────┼──► Workflow: process-order pipeline
  Cursor         ──►│  Tool: "transform_data" ──────┼──► Workflow: transform-data pipeline
  Any MCP Client ──►│  Tool: "lookup_customer" ─────┼──► Workflow: lookup-customer pipeline
                    │                               │
                    │  Resource: helix://workflows   │
                    └─────────────────────────────┘

                         MCP Client Component
                    ┌─────────────────────────────┐
  Helix Workflow ──►│  mcp://localhost:8080/tool   │──► External MCP Server
                    │  (Producer)                   │◄── Tool result → Exchange body
                    └─────────────────────────────┘

Component Summary

Component Scheme Role Direction
MCP Server mcp-server Exposes workflows as MCP tools Consumer (inbound)
MCP Client mcp Calls external MCP tools Producer (outbound)

This follows the same pattern as http-server / http in the HTTP component.


3. MCP Server - Exposing Workflows as Tools

The MCP Server component starts an MCP-compliant server that enumerates your Helix routes and registers each one as a callable tool. When an LLM client invokes a tool, the server creates an Exchange, runs it through the workflow's full processor pipeline (including interceptors and error handlers), and returns the result.

3.1 Quick Start - YAML

The simplest way to expose workflows as MCP tools:

helix.yaml

helix:
  mcp:
    servers:
      - enabled: true
        transport: http
        addr: "0.0.0.0:3000"
        name: "my-helix-tools"
        version: "1.0.0"

workflows:
  - id: greet
    description: "Greet a person by name"
    trigger:
      uri: direct://greet
    steps:
      - transform:
          starlang: |
            name = payload.get("name", "World")
            "Hello, " ++ name ++ "!"

  - id: calculate-tax
    description: "Calculate tax for a given amount and rate"
    trigger:
      uri: direct://calculate-tax
    steps:
      - transform:
          starlang: |
            amount = float(payload.get("amount", 0))
            rate = float(payload.get("rate", 0.1))
            str(amount * rate)

Run it:

helix run helix.yaml

Now Claude Desktop (or any MCP client) can discover greet and calculate_tax as tools.

3.2 Quick Start - Workflow-Based

You can also define the MCP server as a workflow consumer:

workflows:
  - id: mcp-bridge
    trigger:
      uri: mcp-server://0.0.0.0:3000?transport=http&name=my-tools&version=1.0.0
    steps: []

This starts the MCP server as part of the workflow lifecycle. The server still discovers and exposes all other routes in the context.

3.3 Quick Start - Go DSL

package main

import (
    "context"
    "github.com/arvenza/helix/pkg/engine"
    "github.com/arvenza/helix/pkg/exchange"
    helixmcp "github.com/arvenza/helix/pkg/component/mcp"
)

func main() {
    h := engine.NewContext("mcp-demo")

    // Register the MCP server component
    h.AddComponent("mcp-server", helixmcp.NewServer(h))

    // Define a workflow that will become an MCP tool
    h.AddWorkflow(&engine.Workflow{
        ID:          "greet",
        Description: "Greet a person by name",
        FromURI:     "direct://greet",
        Processors: []engine.Processor{
            engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
                body, _ := ex.In().Body().(map[string]any)
                name, _ := body["name"].(string)
                if name == "" {
                    name = "World"
                }
                ex.In().SetBody("Hello, " + name + "!")
                return nil
            }),
        },
    })

    // Start the MCP server route
    h.AddWorkflow(&engine.Workflow{
        ID:      "mcp-server",
        FromURI: "mcp-server://0.0.0.0:3000?transport=http&name=my-tools&version=1.0.0",
    })

    ctx := context.Background()
    h.Start(ctx)
    defer h.Stop(ctx)

    select {} // Block forever
}

3.4 How Workflow-to-Tool Mapping Works

When the MCP server starts, it iterates over all workflows in the HelixContext and registers each as an MCP tool:

Workflow Field MCP Tool Field Notes
ID Tool Name Sanitized: dots, slashes, hyphens → underscores
Description Tool Description Used as-is. Falls back to "Helix route: {id}"
Processors Tool Handler Full pipeline with interceptors and error handler

Tool invocation flow:

  1. MCP client calls tool with JSON arguments
  2. Server creates a new Exchange with InOut pattern
  3. Arguments are set as the exchange body (map[string]any)
  4. Headers are set: HelixMcpToolName, HelixMcpRouteID
  5. Exchange runs through BuildPipeline(route.Processors, interceptors, errorHandler)
  6. Result body is returned as TextContent in the MCP response
  7. If the pipeline fails, IsError: true is set with the error message

3.5 Filtering Routes

By default, all workflows are exposed as tools. Use the routes filter to limit which ones:

helix:
  mcp:
    servers:
      - enabled: true
        transport: http
        addr: "0.0.0.0:3000"
        name: "order-tools"
        workflows:
          - process-order
          - validate-order
          - lookup-customer

Only the three listed workflows will be registered as MCP tools.

3.6 Customizing Tool Metadata

Override tool descriptions and input schemas per workflow:

helix:
  mcp:
    servers:
      - enabled: true
        transport: http
        addr: "0.0.0.0:3000"
        name: "order-api"
        tools:
          process-order:
            description: "Process a customer order with items and shipping address"
            input_schema: |
              {
                "type": "object",
                "properties": {
                  "orderId": {
                    "type": "string",
                    "description": "The unique order identifier"
                  },
                  "items": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "sku": {"type": "string"},
                        "quantity": {"type": "integer"}
                      }
                    }
                  },
                  "shippingAddress": {
                    "type": "string",
                    "description": "Full shipping address"
                  }
                },
                "required": ["orderId", "items"]
              }

When no input_schema is provided, the default permissive schema is used:

{"type": "object", "additionalProperties": true}


4. MCP Client - Calling External MCP Tools

The MCP Client component lets your Helix workflows call tools on external MCP servers. This is useful for integrating AI assistants, code generation services, or any MCP-enabled tool into your integration pipelines.

4.1 URI Format

mcp://server-address:port/tool-name?transport=http
Part Description
server-address:port The MCP server's address
/tool-name The tool to invoke
transport http (default), sse, or stdio

4.2 YAML Example

workflows:
  - id: ai-assisted-transform
    trigger:
      uri: direct://transform
    steps:
      # Prepare the arguments for the MCP tool
      - transform:
          starlang: |
            {"code": payload, "language": "python"}

      # Call the external MCP tool
      - target: mcp://localhost:8080/format-code?transport=http

      # The tool result is now the exchange body
      - log:
          message: "Formatted code: ${body}"

4.3 Go DSL Example

h.AddWorkflow(&engine.Workflow{
    ID:      "call-mcp-tool",
    FromURI: "direct://invoke",
    ToURIs:  []string{"mcp://localhost:8080/analyze-data?transport=http"},
})

4.4 Body-to-Arguments Conversion

The exchange body is automatically converted to MCP tool arguments:

Body Type Conversion
map[string]any Used directly as tool arguments
string (valid JSON object) Parsed into map[string]any
string (non-JSON) Wrapped as {"input": "the string value"}
[]byte (valid JSON object) Parsed into map[string]any
[]byte (non-JSON) Wrapped as {"input": "the byte value"}
Any other type JSON-marshaled, then parsed as above

4.5 Tool Name Override

You can dynamically choose which tool to call by setting a header:

workflows:
  - id: dynamic-tool-call
    trigger:
      uri: direct://dynamic
    steps:
      - set-header:
          name: HelixMcpToolName
          starlang: |
            "analyze-" ++ headers.get("dataType", "generic")
      - target: mcp://localhost:8080/placeholder?transport=http

The HelixMcpToolName header overrides the tool name from the URI.

4.6 Lazy Connection

The MCP client uses sync.Once for lazy initialization. The connection to the external MCP server is established on the first tool call, not at route startup. This means:

  • Routes start instantly even if the MCP server isn't available yet
  • Connection errors surface at first use, not at boot
  • This matches the lazy-init pattern used by other Helix components (gRPC, Kafka)

4.7 Authenticated MCP Calls

When calling external MCP servers that require authentication, add an auth: block to the MCP tool definition. This applies to both workflow-level to: mcp://... calls and agent-level type: mcp tools.

Agent tool with auth:

agents:
  - id: my-agent
    tools:
      - name: secure-tools
        type: mcp
        mcp_server:
          url: "http://mcp.internal:3100"
          transport: http
        auth:
          type: bearer
          token: "${env:MCP_TOKEN}"

API key auth:

agents:
  - id: my-agent
    tools:
      - name: vendor-tools
        type: mcp
        mcp_server:
          url: "https://api.vendor.com/mcp"
        auth:
          type: api-key
          header: "X-API-Key"
          key: "${env:VENDOR_API_KEY}"

OAuth2 client credentials:

agents:
  - id: my-agent
    tools:
      - name: corp-tools
        type: mcp
        mcp_server:
          url: "https://tools.corp.internal/mcp"
        auth:
          type: oauth2
          token_url: "https://auth.corp.internal/token"
          client_id: "${env:OAUTH_CLIENT_ID}"
          client_secret: "${env:OAUTH_CLIENT_SECRET}"
          scopes: ["tools:read"]

mTLS:

agents:
  - id: my-agent
    tools:
      - name: secure-tools
        type: mcp
        mcp_server:
          url: "https://mcp.internal:3100"
        auth:
          type: mtls
          cert_file: "/certs/client.crt"
          key_file: "/certs/client.key"
          ca_file: "/certs/ca.crt"

See the Security Guide for all auth types and env var resolution.

4.8 Stdio Transport -- MCP Tool Proxy

The MCP client now supports transport: stdio, enabling agents to spawn an MCP-compliant command as a subprocess and communicate over stdin/stdout. This is the standard transport used by tools like Playwright MCP, filesystem MCP servers, and other CLI-based MCP tool providers.

Previously, the MCP client only supported HTTP and SSE transports, requiring the MCP server to already be running. With stdio support, Helix manages the subprocess lifecycle automatically.

How it works:

  1. Helix spawns the command specified in mcp_server.command with the given args
  2. Sends MCP initialize request over the subprocess stdin
  3. Sends tools/list to discover all tools the server provides
  4. All discovered tools become available to the agent
  5. Tool calls are routed to the subprocess via stdin, results read from stdout
  6. The subprocess is terminated when the agent or route stops

Agent tool with stdio MCP server:

agents:
  - id: browser-agent
    type: react
    trigger: http-server://0.0.0.0:8080/browse
    model:
      provider: anthropic
      model: claude-sonnet-4-20250514
    tools:
      - name: browser
        type: mcp
        description: "Browser automation via Playwright"
        mcp_server:
          command: npx
          args: ["@playwright/mcp"]
          transport: stdio

Custom MCP tool server:

agents:
  - id: db-agent
    type: react
    trigger: http-server://0.0.0.0:8080/query
    model:
      provider: openai
      model: gpt-4o
    tools:
      - name: database-tools
        type: mcp
        description: "Database query tools"
        mcp_server:
          command: /usr/local/bin/db-mcp-server
          args: ["--dsn", "${env:DATABASE_URL}"]
          transport: stdio

Key differences from HTTP/SSE transport:

Feature HTTP/SSE Stdio
Server lifecycle External (must be running) Managed by Helix (spawned/terminated)
Connection Network (host:port) Subprocess (stdin/stdout)
Configuration url: field command: + args: fields
Tool discovery On first call (lazy) On subprocess start
Use case Remote MCP servers Local CLI tools, Playwright, etc.

4A. MCP CLI Commands

The helix mcp subcommands help you inspect and manage MCP servers without writing code.

helix mcp list

List MCP servers and their exposed tools from a YAML file:

helix mcp list flow.yaml

Output:

MCP Servers in flow.yaml:

NAME                 TRANSPORT  ADDRESS                   TOOLS
----                 ---------  -------                   -----
helix-ai-tools       http       0.0.0.0:3100              summarize, translate, search

Available workflows (3):
  summarize                 [agent ] Summarize text (2 params)
  translate                 [agent ] Translate text (3 params)
  search                    [tool  ] Search knowledge base (2 params, output: 2 fields)

Tools defined with the tools: primitive are marked [tool ] and include output schema information. Agents are marked [agent ].

helix mcp serve

Start MCP servers defined in a YAML file. Equivalent to helix run -- MCP servers start automatically alongside agents and pipelines:

helix mcp serve flow.yaml

helix mcp inspect

Connect to a running MCP server and list its tools with schemas. Uses the official MCP SDK over HTTP/SSE transport:

helix mcp inspect localhost:3100

Output:

MCP Server: http://localhost:3100
Tools: 3

TOOL                      DESCRIPTION
----                      -----------
summarize                 Summarize text into key points
translate                 Translate text between languages
search                    Search knowledge base

This is useful for verifying that a remote MCP server exposes the tools you expect before wiring them into an agent.


5. Configuration Reference

5.1 helix.yaml - MCP Server Config

helix:
  mcp:
    servers:
      - enabled: true                    # Enable this MCP server instance
        transport: http                  # http | sse | stdio
        addr: "0.0.0.0:3000"           # Listen address (http/sse only)
        name: "my-helix-server"         # Server name reported to clients
        version: "1.0.0"               # Server version reported to clients
        workflows:                         # Filter: only expose these routes (empty = all)
          - route-a
          - route-b
        tools:                          # Per-tool metadata overrides
          route-a:
            description: "Custom description for route-a"
            input_schema: '{"type":"object","properties":{"key":{"type":"string"}}}'

5.2 URI Parameters - MCP Server

mcp-server://addr?transport=http&name=my-server&version=1.0.0
Parameter Default Description
transport http Transport type: http, sse, stdio
name helix-mcp Server name
version 1.0.0 Server version

5.3 URI Parameters - MCP Client

mcp://host:port/tool-name?transport=http
Parameter Default Description
transport http Transport type: http, sse, stdio

5.4 Exchange Headers

Headers set during MCP interactions:

Header Direction Description
HelixMcpToolName In/Out Tool name (can be set to override)
HelixMcpRouteID In Original workflow ID
HelixMcpIsError Out "true" if tool returned an error
HelixMcpServerName In Server name (on server-side exchanges)

6. Transport Options

The default and recommended transport. Uses standard HTTP with JSON-RPC 2.0 over POST requests. Supports connection resumption and server-sent events for streaming.

helix:
  mcp:
    servers:
      - enabled: true
        transport: http
        addr: "0.0.0.0:3000"

Client connects to: http://localhost:3000/mcp

6.2 Server-Sent Events (SSE)

Legacy transport using SSE for server-to-client messages and HTTP POST for client-to-server. Useful for environments that don't support bidirectional HTTP well.

helix:
  mcp:
    servers:
      - enabled: true
        transport: sse
        addr: "0.0.0.0:3000"

Client connects to: http://localhost:3000/sse (event stream) and http://localhost:3000/message (commands).

6.3 Stdio

For local integrations where the MCP server runs as a subprocess. The client launches the Helix binary and communicates over stdin/stdout.

helix:
  mcp:
    servers:
      - enabled: true
        transport: stdio

No addr needed - communication happens over the process's standard I/O streams.

This is the transport used by Claude Desktop when running local MCP servers.


7. Working with Claude Desktop

7.1 HTTP Transport

Add to your Claude Desktop config (~/.config/claude/claude_desktop_config.json on Linux, ~/Library/Application Support/Claude/claude_desktop_config.json on macOS):

{
  "mcpServers": {
    "helix": {
      "url": "http://localhost:3000/mcp"
    }
  }
}

Then run your Helix server:

helix run helix.yaml

7.2 Stdio Transport (Subprocess)

Claude Desktop can launch Helix as a subprocess:

{
  "mcpServers": {
    "helix": {
      "command": "/usr/local/bin/helix",
      "args": ["run", "/path/to/helix.yaml"]
    }
  }
}

With helix.yaml:

helix:
  mcp:
    servers:
      - enabled: true
        transport: stdio
        name: "my-tools"

workflows:
  - id: lookup-order
    description: "Look up an order by ID from the database"
    trigger:
      uri: direct://lookup-order
    steps:
      - target: postgres://db:5432/orders?operation=query
          parameters:
            query: "SELECT * FROM orders WHERE id = :orderId"
      - transform:
          starlang: |
            write(payload, "application/json")

Now Claude can say: "Look up order ORD-12345" and it will call your lookup_order tool, which queries PostgreSQL and returns the result.

7.3 What Claude Sees

When connected, Claude sees your tools in its tool list:

Available tools:
- lookup_order: Look up an order by ID from the database
- process_payment: Process a payment transaction
- send_notification: Send a notification via email or Slack

Claude can then use these tools naturally in conversation:

User: "Check the status of order ORD-789" Claude: Let me look that up for you. [Calls lookup_order with {"orderId": "ORD-789"}] The order ORD-789 is currently "shipped" - it was dispatched on March 3rd and is expected to arrive by March 7th.


8. Tool Naming and Schemas

8.1 Name Sanitization

Workflow IDs are sanitized for MCP tool name compliance:

Workflow ID Tool Name
process-order process_order
api.v2.users api_v2_users
my/route/path my_route_path
simple simple

The sanitization replaces dots (.), hyphens (-), and slashes (/) with underscores (_).

8.2 Default Schema

When no input schema is specified, tools accept any JSON object:

{
  "type": "object",
  "additionalProperties": true
}

8.3 Custom Schemas

Provide structured schemas so LLMs know exactly what arguments to pass:

helix:
  mcp:
    servers:
      - enabled: true
        transport: http
        addr: "0.0.0.0:3000"
        tools:
          create-ticket:
            description: "Create a support ticket in the ticketing system"
            input_schema: |
              {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string",
                    "description": "Brief title of the issue"
                  },
                  "description": {
                    "type": "string",
                    "description": "Detailed description of the problem"
                  },
                  "priority": {
                    "type": "string",
                    "enum": ["low", "medium", "high", "critical"],
                    "description": "Ticket priority level"
                  },
                  "assignee": {
                    "type": "string",
                    "description": "Email of the person to assign the ticket to"
                  }
                },
                "required": ["title", "description"]
              }

This helps Claude generate correct tool calls:

[Calls create_ticket with {"title": "Login page broken", "description": "Users get 500 error on /login since the last deploy", "priority": "high"}]


9. MCP Resources

The MCP server automatically exposes a resource that lists all available workflows:

9.1 helix://workflows

Returns a JSON array of all workflows registered in the HelixContext:

[
  {
    "id": "process-order",
    "from_uri": "direct://process-order",
    "description": "Process a customer order",
    "state": "Started"
  },
  {
    "id": "send-email",
    "from_uri": "direct://send-email",
    "description": "Send an email notification",
    "state": "Started"
  }
]

MCP clients can read this resource to understand the full route topology before invoking tools.


10. Error Handling

10.1 Pipeline Errors

If a workflow's processor pipeline fails, the MCP tool returns an error result:

workflows:
  - id: risky-operation
    description: "An operation that might fail"
    trigger:
      uri: direct://risky
    error-handler:
      dead-letter-channel: log://errors
    steps:
      - process:
          starlang: |
            if payload.get("value", 0) < 0:
                fail("Value must be non-negative")
            payload

When called with {"value": -1}: - The tool returns IsError: true - The error message is sent as TextContent - The LLM receives the error and can decide how to handle it

10.2 Client-Side Errors

When using the MCP client component, errors are handled through the exchange:

workflows:
  - id: call-external
    trigger:
      uri: direct://call
    steps:
      - target: mcp://localhost:8080/some-tool?transport=http
      - choice:
          when:
            - starlang: |
                headers.get("HelixMcpIsError", "false") == "true"
              steps:
                - log:
                    message: "MCP tool failed: ${body}"
          otherwise:
            steps:
              - log:
                  message: "MCP tool succeeded: ${body}"

11. Testing

11.1 In-Memory Transport

The MCP SDK provides mcpsdk.NewInMemoryTransports() for pure in-process testing without any network:

package mcp_test

import (
    "context"
    "encoding/json"
    "testing"

    mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
    "github.com/arvenza/helix/pkg/engine"
    "github.com/arvenza/helix/pkg/exchange"
    helixmcp "github.com/arvenza/helix/pkg/component/mcp"
)

func TestMyTool(t *testing.T) {
    ctx := context.Background()

    // Create Helix context with a test route
    hc := engine.NewContext("test")
    hc.AddWorkflow(&engine.Workflow{
        ID:          "echo",
        Description: "Echo back the input",
        FromURI:     "direct://echo",
        Processors: []engine.Processor{
            engine.ProcessorFunc(func(ctx context.Context, ex *exchange.Exchange) error {
                // Echo: just leave the body as-is
                return nil
            }),
        },
    })

    // Create MCP server with in-memory transport
    srv := mcpsdk.NewServer(&mcpsdk.Implementation{
        Name:    "test-server",
        Version: "1.0.0",
    })

    // Register workflows as tools (same logic as ServerConsumer)
    // ... register tools ...

    // Create in-memory client-server pair
    clientTransport, serverTransport := mcpsdk.NewInMemoryTransports()

    // Start server
    go srv.Run(ctx, serverTransport)

    // Connect client
    client := mcpsdk.NewClient(&mcpsdk.Implementation{
        Name:    "test-client",
        Version: "1.0.0",
    })
    session, _ := client.Connect(ctx, clientTransport)

    // Call the tool
    result, err := session.CallTool(ctx, &mcpsdk.CallToolRequest{
        Params: mcpsdk.CallToolParams{
            Name:      "echo",
            Arguments: json.RawMessage(`{"message": "hello"}`),
        },
    })

    if err != nil {
        t.Fatal(err)
    }
    if result.IsError {
        t.Error("expected success")
    }
    // Verify result content...
}

11.2 Integration Testing

For full integration tests, start an actual HTTP server:

func TestMCPIntegration(t *testing.T) {
    // Start Helix with MCP server on a random port
    cfg := &config.Config{}
    cfg.Helix.MCP.Servers = []config.MCPServerConfig{{
        Enabled:   true,
        Transport: "http",
        Addr:      "127.0.0.1:0", // random port
        Name:      "test",
    }}

    // ... start Helix, get actual port ...

    // Connect with MCP client
    // ... call tools, verify results ...
}

11.3 Running the MCP Test Suite

# Run only MCP tests
go test ./pkg/component/mcp/ - v

# Run with race detector
go test ./pkg/component/mcp/ - race - v

# Run specific test
go test ./pkg/component/mcp/ - run TestRouteToTool_InMemory - v

The MCP test suite includes 14 tests covering: - Component scheme registration - Tool name sanitization - URI parsing for client paths - Content extraction - Full round-trip route-to-tool invocation (in-memory) - Error propagation - Multiple route registration - Resource listing - Producer/Consumer direction enforcement


12. Complete Examples

12.1 Database Query Tool for Claude

Expose a PostgreSQL query interface as an MCP tool:

# helix.yaml
helix:
  mcp:
    servers:
      - enabled: true
        transport: http
        addr: "0.0.0.0:3000"
        name: "db-tools"
        tools:
          query-customers:
            description: "Search customers by name, email, or ID"
            input_schema: |
              {
                "type": "object",
                "properties": {
                  "search": {"type": "string", "description": "Search term"},
                  "field": {"type": "string", "enum": ["name", "email", "id"], "description": "Field to search"},
                  "limit": {"type": "integer", "description": "Max results (default 10)"}
                },
                "required": ["search"]
              }

workflows:
  - id: query-customers
    description: "Search customers in the database"
    trigger:
      uri: direct://query-customers
    steps:
      - transform:
          starlang: |
            field = payload.get("field", "name")
            limit = payload.get("limit", 10)
            search = payload.get("search", "")
            {
              "query": "SELECT id, name, email, created_at FROM customers WHERE " ++ field ++ " ILIKE $1 LIMIT $2",
              "params": ["%" ++ search ++ "%", limit]
            }
      - target: postgres://localhost:5432/mydb?operation=query
      - transform:
          starlang: |
            write(payload, "application/json")

12.2 Multi-System Orchestration

Combine multiple backend systems into coherent tools:

helix:
  mcp:
    servers:
      - enabled: true
        transport: http
        addr: "0.0.0.0:3000"
        name: "ops-tools"

workflows:
  - id: deploy-service
    description: "Deploy a service to Kubernetes with approval workflow"
    trigger:
      uri: direct://deploy-service
    steps:
      # Validate the deployment request
      - process:
          starlang: |
            service = payload.get("service", "")
            version = payload.get("version", "")
            env = payload.get("environment", "staging")
            if not service:
                fail("service name is required")
            if not version:
                fail("version is required")
            if env == "production" and not payload.get("approved", False):
                fail("production deployments require approval=true")
            payload

      # Record in audit log
      - wiretap: kafka://deployments?topic=audit-log

      # Trigger the Kubernetes deployment
      - transform:
          starlang: |
            {
              "apiVersion": "apps/v1",
              "kind": "Deployment",
              "metadata": {"name": payload["service"]},
              "spec": {"image": payload["service"] ++ ":" ++ payload["version"]}
            }
      - target: http://kubernetes.default.svc/apis/apps/v1/namespaces/default/deployments

      # Send Slack notification
      - wiretap: slack://ops-channel

      - transform:
          starlang: |
            write({"status": "deployed", "service": payload["service"], "version": payload["version"]}, "application/json")

  - id: check-health
    description: "Check the health status of a service"
    trigger:
      uri: direct://check-health
    steps:
      - transform:
          starlang: |
            service = payload.get("service", "")
            {"url": "http://" ++ service ++ ".default.svc:8080/health"}
      - target: http://dynamic?bridgeEndpoint=true
      - transform:
          starlang: |
            write({"service": payload["service"], "healthy": response_code == 200}, "application/json")

12.3 MCP Client - Calling an External AI Tool

Use an external MCP server's tools in your pipeline:

workflows:
  - id: smart-categorize
    description: "Categorize incoming support tickets using AI"
    trigger:
      uri: kafka://support-tickets?groupId=categorizer
    steps:
      # Prepare for the AI categorization tool
      - transform:
          starlang: |
            {
              "text": payload["subject"] ++ "\n" ++ payload["payload"],
              "categories": ["billing", "technical", "account", "feature-request", "other"]
            }

      # Call the AI categorization tool on an external MCP server
      - target: mcp://ai-server.internal:8080/categorize-text?transport=http

      # Route based on category
      - choice:
          when:
            - starlang: |
                "billing" in payload
              steps:
                - target: kafka://billing-tickets
            - starlang: |
                "technical" in payload
              steps:
                - target: kafka://tech-tickets
          otherwise:
            steps:
              - target: kafka://general-tickets

12.4 Bidirectional MCP - Server and Client Together

Expose tools that themselves call other MCP servers:

helix:
  mcp:
    servers:
      - enabled: true
        transport: http
        addr: "0.0.0.0:3000"
        name: "orchestrator"

workflows:
  - id: research-topic
    description: "Research a topic using multiple AI sources and compile results"
    trigger:
      uri: direct://research-topic
    steps:
      # Fan out to multiple MCP AI servers in parallel
      - multicast:
          strategy: parallel
          steps:
            - target: mcp://scholar-ai:8080/search-papers?transport=http
            - target: mcp://web-search:8080/search?transport=http
            - target: mcp://knowledge-base:8080/query?transport=http

      # Aggregate results
      - aggregate:
          strategy: collect
          completion:
            size: 3
            timeout: 30s

      # Compile final report
      - transform:
          starlang: |
            results = payload  # list of 3 results
            report = "# Research Report\n\n"
            for i, r in enumerate(results):
                report = report ++ "## Source " ++ str(i+1) ++ "\n" ++ str(r) ++ "\n\n"
            report

13. Cookbook

Recipe 1: Expose a REST API as MCP Tools

If you have existing REST endpoints, you can proxy them as MCP tools:

helix:
  mcp:
    servers:
      - enabled: true
        transport: stdio
        name: "my-api"
        tools:
          get-user:
            description: "Get user details by ID"
            input_schema: '{"type":"object","properties":{"userId":{"type":"string"}},"required":["userId"]}'
          list-orders:
            description: "List recent orders for a user"
            input_schema: '{"type":"object","properties":{"userId":{"type":"string"},"limit":{"type":"integer"}},"required":["userId"]}'

workflows:
  - id: get-user
    trigger:
      uri: direct://get-user
    steps:
      - set-header:
          name: Authorization
          constant: "Bearer ${env:API_TOKEN}"
      - target: http://api.internal/users/${body.userId}
      - transform:
          starlang: write(payload, "application/json")

  - id: list-orders
    trigger:
      uri: direct://list-orders
    steps:
      - set-header:
          name: Authorization
          constant: "Bearer ${env:API_TOKEN}"
      - target: http://api.internal/users/${payload.userId}/orders?limit=${payload.limit}
      - transform:
          starlang: write(payload, "application/json")

Recipe 2: MCP Tool with Validation and Error Handling

workflows:
  - id: create-invoice
    description: "Create an invoice for a customer"
    trigger:
      uri: direct://create-invoice
    error-handler:
      default:
        maximum-redeliveries: 0
    steps:
      # Validate input
      - process:
          starlang: |
            required = ["customerId", "items", "currency"]
            for field in required:
                if field not in payload:
                    fail("Missing required field: " ++ field)
            if payload["currency"] not in ["USD", "EUR", "GBP"]:
                fail("Unsupported currency: " ++ payload["currency"])
            total = sum([item.get("price", 0) * item.get("qty", 1) for item in payload["items"]])
            payload["total"] = total
            payload

      # Create in database
      - target: postgres://localhost:5432/billing?operation=insert&table=invoices

      # Return confirmation
      - transform:
          starlang: |
            write({
              "invoiceId": payload["id"],
              "total": payload["total"],
              "currency": payload["currency"],
              "status": "created"
            }, "application/json")

Recipe 3: Streaming File Processing Tool

workflows:
  - id: analyze-csv
    description: "Analyze a CSV file and return statistics"
    trigger:
      uri: direct://analyze-csv
    steps:
      - transform:
          starlang: |
            path = payload.get("filePath", "")
            if not path:
                fail("filePath is required")
            {"path": path}
      - target: file:///?fileName=${payload.path}&operation=read
      - transform:
          starlang: |
            lines = payload.strip().split("\n")
            headers = lines[0].split(",")
            rows = sizeOf(lines) - 1
            write({
              "fileName": headers_list,
              "columns": sizeOf(headers_list),
              "rows": rows,
              "headers": headers,
              "sample": lines[1] if rows > 0 else ""
            }, "application/json")

Recipe 4: Multiple MCP Servers on Different Ports

Run separate MCP servers for different audiences:

helix:
  mcp:
    servers:
      # Internal tools for developers
      - enabled: true
        transport: http
        addr: "127.0.0.1:3001"
        name: "dev-tools"
        workflows:
          - debug-query
          - tail-logs
          - reset-cache

      # External tools for customers
      - enabled: true
        transport: http
        addr: "0.0.0.0:3002"
        name: "customer-tools"
        workflows:
          - check-order-status
          - update-address
          - request-refund

Recipe 5: MCP Tool Chaining

One tool calls another through the Helix pipeline:

workflows:
  - id: full-onboarding
    description: "Complete customer onboarding: create account, send welcome email, provision resources"
    trigger:
      uri: direct://full-onboarding
    steps:
      # Step 1: Create account
      - target: direct://create-account
      - enrich:
          starlang: |
            accountId = payload["accountId"]

      # Step 2: Send welcome email
      - transform:
          starlang: |
            {"to": payload["email"], "template": "welcome", "accountId": payload["accountId"]}
      - target: direct://send-email

      # Step 3: Provision resources
      - transform:
          starlang: |
            {"accountId": accountId, "plan": payload.get("plan", "free")}
      - target: direct://provision-resources

      # Return summary
      - transform:
          starlang: |
            write({
              "status": "onboarding_complete",
              "accountId": accountId,
              "steps": ["account_created", "welcome_sent", "resources_provisioned"]
            }, "application/json")

14. MCP Security

Helix MCP servers include a full HTTP security middleware stack applied at the transport layer, before any MCP protocol handling.

Authentication

mcps:
  - id: my-tools
    addr: 0.0.0.0:3100
    api_keys:
      - my-secret-key-123
    trusted_proxies:
      - "127.0.0.1"
      - "::1"

Clients authenticate via Authorization: Bearer <key> or X-API-Key: <key> header. Keys are validated using constant-time comparison. Requests from trusted_proxies IPs skip auth (for gateway proxies like SynaptX).

Tool Filtering

mcps:
  - id: public-tools
    expose:
      - code-assistant
      - math-agent
    allowed_tools:            # Whitelist (empty = all exposed tools)
      - code-assistant
    blocked_tools:            # Blacklist (always blocked at call time)
      - admin-tool
  • expose — which route/agent IDs to register as MCP tools
  • allowed_tools — if non-empty, only these tools can be called (whitelist)
  • blocked_tools — these tools return isError: true with "tool X is blocked" (blacklist overrides allowed)
  • A tool in both lists → blocked wins

CORS

mcps:
  - id: my-tools
    cors_origins:
      - "http://localhost:3000"
      - "http://localhost:5173"

Preflight OPTIONS requests from unlisted origins get 403 Forbidden. The Mcp-Session-Id header is exposed via Access-Control-Expose-Headers.

Rate Limiting and Body Size

mcps:
  - id: my-tools
    rate_limit: 60            # 60 requests/minute per client
    max_body_bytes: 2097152   # 2MB max request body

Rate limiting uses a token-bucket algorithm (refills per minute). Oversized requests receive 413 Request Entity Too Large.

Two-Layer Security with SynaptX

When deployed behind SynaptX, security operates at two layers:

Layer Feature Enforcement
SynaptX allowed_tools / blocked_tools HTTP 403 before reaching Helix
SynaptX Prompt injection guard 403 on injection patterns
SynaptX PII detection 403 on SSN, credit card, etc.
SynaptX Virtual key budget/rate 402/429 on limit exceed
Helix MCP api_keys auth 401 for unauthenticated
Helix MCP blocked_tools isError: true in MCP result
Helix MCP rate_limit 429 per minute
Helix MCP cors_origins 403 on CORS preflight

15. Multi-MCP Architecture

Run multiple MCP servers on different ports with independent security:

mcps:
  # Public: exposed to all clients
  - id: public-tools
    addr: 0.0.0.0:3100
    expose: [code-assistant, math-agent, summarizer]
    api_keys: [mcp-public-key]
    rate_limit: 120

  # Admin: restricted access, separate key
  - id: admin-tools
    addr: 0.0.0.0:3101
    expose: [admin-tool, code-assistant]
    api_keys: [mcp-admin-key]
    rate_limit: 30
    blocked_tools: [summarizer]

Each MCP server: - Runs on its own port with its own HTTP listener - Has independent API keys (cross-key access is rejected) - Has its own tool filtering, rate limits, and CORS config - Registers only the tools from its expose list - Can block specific tools even if exposed

SynaptX Proxying

SynaptX can proxy multiple MCP servers:

# synaptx.toml
[[routes]]
name = "MCP Public"
plugin = "mcp"
path = "/mcp/"
[routes.handle]
upstream_url = "http://localhost:3100/mcp"
allowed_tools = ["code-assistant", "math-agent"]
blocked_tools = ["admin-tool"]

[[routes]]
name = "MCP Admin"
plugin = "mcp"
path = "/mcp-admin/"
[routes.handle]
upstream_url = "http://localhost:3101/mcp"
blocked_tools = ["summarizer"]

16. SynaptX Gateway Integration

Virtual Keys

Each agent gets its own SynaptX virtual key with budget, rate limit, and model ACL:

agents:
  - id: code-assistant
    model:
      provider: openai
      model: gpt-4o-mini
      api_key: sk-vk-xxxxx              # SynaptX virtual key
      base_url: http://localhost:7080/ai/openai

Create virtual keys via SynaptX admin API:

curl -X POST http://localhost:7081/virtual-keys \
  -H "Content-Type: application/json" \
  -d '{
    "name": "code-assistant-key",
    "team_id": "team-helix",
    "max_budget": 50.0,
    "max_rpm": 60,
    "allowed_providers": ["openai"],
    "allowed_models": ["gpt-4o-mini"]
  }'

SynaptX Policies

SynaptX provides governance policies that protect MCP, A2A, and AG-UI endpoints:

  • Prompt Guard — blocks 16 injection patterns (per-workflow: prompt_guard = true)
  • PII Detection — blocks SSN, credit card, AWS keys (per-workflow: pii_mode = "block")
  • WAF — SQL injection, XSS detection
  • Governance Policies — ABAC/RBAC with conditions (time, IP, trust level)
  • Agent Budgets — daily/monthly token and cost limits
  • Inter-Agent Rules — control which agents can call each other
  • Audit Trail — all policy evaluations logged

Auto-Discovery

SynaptX discovers Helix automatically:

# synaptx.toml
[discovery]
enabled = true
helix_urls = ["http://localhost:9999"]
interval_secs = 30

Helix exposes a manifest at GET /.well-known/helix-manifest.json containing: - Registered agents (ID, type, model, tools, capabilities) - MCP servers (endpoints, tools) - HTTP routes

17. Troubleshooting

MCP server starts but no tools appear

  • Ensure workflows are defined before the MCP server starts (order matters in YAML)
  • Check that the routes filter (if set) includes the routes you expect
  • Verify workflows have a trigger.uri - workflows without it won't be registered

Tool call returns empty result

  • Check that the workflow's processor pipeline sets the exchange body
  • For InOut exchanges (which MCP uses), the result comes from ex.In().Body()
  • Add a log step to debug what's happening in the pipeline

Claude Desktop doesn't connect

  • For HTTP transport: verify the server is listening (curl http://localhost:3000/mcp)
  • For stdio transport: verify helix binary is in PATH and the YAML file path is correct
  • Check Claude Desktop logs for connection errors

MCP client can't reach server

  • Verify the server address and port in the URI
  • Check the transport matches the server's transport (http vs sse)
  • For first-call failures (lazy init), check the Helix logs for connection errors

Tool name doesn't match

  • Remember: hyphens become underscores (my-tool → my_tool)
  • Use the helix://routes resource to see registered workflow ID
  • Use HelixMcpToolName header for explicit tool name control

Common URI patterns

# Server
mcp-server://0.0.0.0:3000                          # HTTP on port 3000
mcp-server://0.0.0.0:3000?transport=sse             # SSE on port 3000
mcp-server://localhost:0?transport=stdio             # Stdio (addr ignored)

# Client
mcp://localhost:8080/my-tool                        # Call "my-tool" via HTTP
mcp://ai-server:9090/analyze?transport=sse          # Call "analyze" via SSE