Skip to content
Ori Pekelman edited this page May 24, 2026 · 1 revision

Tep::MCP

The agent-as-driver surface. Tep apps expose themselves as Model Context Protocol servers so Claude Code / OpenCode / Gravity CLI / any MCP client can discover and drive the catalog natively.

Battery 5 in docs/MCP-BATTERY.md.

Hello, greet

require 'sinatra'

mcp_tool 'greet', "Say hi to someone" do
  param :name, String, "person to greet"

  on_call do |name:|
    Tep::MCP.text("hello " + name)
  end
end
bin/tep build hello.rb -o ./hello
./hello -p 4567

The translator emits:

  • POST /tools/greet — HTTP-direct endpoint for curl / non-MCP agents.
  • POST /mcp — JSON-RPC 2.0 dispatcher (initialize / tools/list / tools/call).
  • GET /llms.txt — markdown catalog.
  • GET /openapi.json — OpenAPI 3.0.3 spec.

Test it:

curl http://127.0.0.1:4567/llms.txt
curl -X POST http://127.0.0.1:4567/tools/greet \
  -H "Content-Type: application/json" \
  -d '{"name":"world"}'
# -> hello world

Tools

mcp_tool 'name', "description" [, caps: [:sym]] do; param ...; on_call do; ... end; end.

Typed params

mcp_tool 'add', "Add two integers" do
  param :a, Integer, "left operand"
  param :b, Integer, "right operand"

  on_call do |a:, b:|
    Tep::MCP.text((a + b).to_s)
  end
end

String, Integer, and Float are supported in v1. They map to JSON Schema string / integer / number in the auto-published catalog. Param values get decoded out of the JSON request body (or the JSON-RPC params.arguments) before the on_call body runs, so user code only ever sees properly-typed locals.

Capability gating

mcp_tool 'wipe_db', "Drop everything", caps: [:admin] do
  on_call do
    Tep::MCP.text("wiped")
  end
end

The translator emits a req.identity.may?(:admin) check at the top of the generated dispatch cmeth; failure returns Tep::MCP.error("missing capability: admin") without running the body. Identity comes from the regular Tep::Auth chain (Tep::AuthBearerToken / Tep::AuthSessionCookie / Tep::AuthOAuth2), so an MCP client authenticating with a JWT that delegates :admin from a human gets the same allow/deny treatment as any other request path.

Result types

Inside on_call, return one of:

Tep::MCP.text("plain text content block")
Tep::MCP.error("error message; sets isError:true in the envelope")

JSON content + streaming progress notifications are 5.5+ follow-ups.

Resources

Read-only fetches. Same DSL shape as tools, with on_read instead of on_call:

mcp_resource 'server/status', "Current server status" do
  on_read do
    Tep::MCP.resource_text("server/status", "uptime: " + uptime.to_s)
  end
end

generates:

  • GET /resources/server/status
  • resources/list arm in /mcp (returns uri + name + description + mimeType per resource).
  • resources/read arm in /mcp (looks up by URI, returns contents:[{uri,mimeType,text}]).

URI templating ('experiments/{id}/log' with captured id) is 5.5+. For per-id reads today, use a tool that takes id as a param.

Discovery surfaces

Three co-exist, all auto-generated from the same DSL:

URL Format Audience
POST /mcp JSON-RPC 2.0 MCP-native clients
GET /llms.txt Markdown LLM-readable catalog, humans skimming
GET /openapi.json OpenAPI 3.0.3 Non-MCP agents, Swagger UI

The initialize response on /mcp advertises capabilities: {tools:{}, resources:{}} whenever the app declares any of either kind.

AGENTS.md convention

Tep apps shipping MCP should ship an AGENTS.md at the repo root describing the agent-facing surface:

  • What the app does (one paragraph).
  • Discovery URLs.
  • Tool + resource catalog with capability requirements.
  • Invariants the app maintains (state machines, id rules, append- only logs, ...).
  • "Things you should NOT do" — guardrails an agent can't infer from the schema alone (concurrency caps, anti-patterns).
  • Authorization model.

This file is not auto-generated. Claude Code reads it once per session before driving the app, so the prose-level invariants

Demo

examples/experiments — mock training-run manager driving the full agent-as-driver loop in ~200 lines: 4 tools (2 capped on :run_experiments), 2 resources, AGENTS.md, README walking through both the MCP-native and curl driver paths.

What's not in v1

  • URI templating for resources ('experiments/{id}/log'). Use a tool with an id param meanwhile.
  • Streaming via stream do |out| ... end over SSE. Tools return a single result; long-running operations either return immediately and expose progress as a resource, or wait synchronously.
  • MCP progress notifications during long-running tool calls.
  • Resource subscriptions (resources/subscribe + notifications/resources/updated).

All four are tracked for 5.5+. Apps with real streaming needs today can run the tool body, return a quick "started" text, then expose a resource for polling.

Clone this wiki locally