MCP Integration Guide — Expose Agents as MCP Tools¶
Table of Contents¶
- Introduction
- Architecture Overview
- MCP Server - Exposing Workflows as Tools
- MCP Client - Calling External MCP Tools
- Configuration Reference
- Transport Options
- Working with Claude Desktop
- Tool Naming and Schemas
- MCP Resources
- Error Handling
- Testing
- Complete Examples
- Cookbook
- MCP Security
- Multi-MCP Architecture
- SynaptX Gateway Integration
- 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:
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:
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:
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:
- MCP client calls tool with JSON arguments
- Server creates a new
ExchangewithInOutpattern - Arguments are set as the exchange body (
map[string]any) - Headers are set:
HelixMcpToolName,HelixMcpRouteID - Exchange runs through
BuildPipeline(route.Processors, interceptors, errorHandler) - Result body is returned as
TextContentin the MCP response - If the pipeline fails,
IsError: trueis 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:
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¶
| 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:
- Helix spawns the command specified in
mcp_server.commandwith the givenargs - Sends MCP
initializerequest over the subprocess stdin - Sends
tools/listto discover all tools the server provides - All discovered tools become available to the agent
- Tool calls are routed to the subprocess via stdin, results read from stdout
- 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:
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 inspect¶
Connect to a running MCP server and list its tools with schemas. Uses the official MCP SDK over HTTP/SSE transport:
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¶
| 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¶
| 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¶
6.1 Streamable HTTP (Recommended)¶
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.
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.
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.
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):
Then run your Helix server:
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:
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 toolsallowed_tools— if non-empty, only these tools can be called (whitelist)blocked_tools— these tools returnisError: truewith"tool X is blocked"(blacklist overrides allowed)- A tool in both lists → blocked wins
CORS¶
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:
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
routesfilter (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
InOutexchanges (which MCP uses), the result comes fromex.In().Body() - Add a
logstep 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
helixbinary 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 (
httpvssse) - 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://routesresource to see registered workflow ID - Use
HelixMcpToolNameheader 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