Skip to content
Trilha
Chapters

Reference

mcp

Model Context Protocol client and server (stdio and Streamable HTTP).

import "github.com/emersonjoe/trilha/ai/mcp" — JSON-RPC 2.0, revision 2025-03-26, no external dependencies. Covers the tools capability (list and call).

trilha mcp — the project as tools#

An agent with a shell does not need this: trilha ctx --json and trilha check --json are already the answer. This is for the agent that has no shell — a chat client, an editor that speaks MCP and nothing else — which otherwise has to guess what your project contains.

cd my-project
trilha mcp

It speaks MCP over stdin/stdout and answers about the project it was started in. Point your client at it:

{
  "mcpServers": {
    "trilha": { "command": "trilha", "args": ["mcp"], "cwd": "/path/to/my-project" }
  }
}

That shape works in Claude Desktop, Claude Code and any client that launches a stdio server. cwd is what decides the project, and nothing the model sends can change it.

The tools#

ToolWhat it answersBehind it
describe_projectroutes, API, types and setup, as JSONtrilha ctx --json
routesthe route table in precedence ordertrilha routes
ui_describethe ui catalogue: signature, purpose, an example that compilesthe catalogue in the binary — no project needed
checkgen, gofmt, vet, tests, audit and OpenAPI, as JSONtrilha check --json
get_contextthe project map sliced for one job (pack), priced in estimated tokens, within a budgettrilha ctx --pack
search_codecode as path:line windows, never a whole filethe project's files, read-only
get_patternone screen pattern whole: components, snippet, data, accessibilitytrilha ui patterns <name>
generatewrites a page, route, test or componenttrilha generate — only with --write

Each one is a wrapper: the tool answers what the command answers, byte for byte, and a test holds it to that. There is no second implementation to drift.

What it will not do#

It is read-only unless you say otherwise. Without --write, the tool that writes files is not registered — it is not in tools/list, so a model cannot ask for it and there is no refusal to argue with. Start it with trilha mcp --write when you want the agent to scaffold, and the tool says Writes files in the first line of its own description, which is what the person approving the call reads.

It never uses a shell. Every command runs as a program plus an argument slice. A value from the model is never concatenated into a command line.

It checks arguments against an allowlist, not against a list of bad things. A route address has to look like a route address; a component name has to be an identifier; a method has to be one of the six. /../../etc/passwd, /x; rm -rf / and --force are refused with the reason, before anything runs.

It cannot leave the project. The working directory is decided when the server starts.

One command at a time, each with a deadline (10 minutes for check, which runs your test suite; one minute for the rest) and a cap of 1 MiB of output, so a tool call cannot become a fork bomb or eat memory.

It opens no network connection, and it writes nothing to stdout except the protocol — every log line goes to stderr, where you can watch what the agent asked for:

trilha mcp 0.43.0 · /path/to/my-project · read-only unless --write; no shell; arguments allow-listed
tools: describe_project, check, routes, ui_describe, get_context, search_code, get_pattern
→ trilha ctx --json

trilha mcp --from-routes#

The other thing the CLI answers is what would my API look like as tools: the list FromRoutes would publish, derived from app/ and the OpenAPI document, printed before any server exists.

$ trilha mcp --from-routes
tools mcp.FromRoutes would expose (include: /api/*):
  deleteApiPostsId   DELETE /api/posts/{id}   DELETE removes a post.
  getApiPosts        GET /api/posts           GET lists posts.
  getApiPostsId      GET /api/posts/{id}      GET returns one post by slug.
  postApiPosts       POST /api/posts          POST creates a post from JSON.

--include takes the same patterns as FromRoutesOpts.Include. A route left out — a multipart upload, for one — is printed with the reason, the same line the server logs.

Documentation server#

The site application serves a read-only Streamable HTTP MCP endpoint at POST /mcp. It keeps the documentation out of the CLI binary and reads the exact Markdown embedded by the site:

ToolAnswer
search_docsmatching pages by title, description and body; locale is en or pt
get_pageone page by site path, such as /reference/ui
get_recipeone Cookbook page by slug, such as pagination
get_contextthe slice of documentation one recipe touches — the recipe's page and the pages it links to — each priced in estimated tokens (est., 4 chars/token)
search_codethe Cookbook's Go sources as path:line windows, the matching line plus one after it, a handful of matches at most — never a whole file
get_errorone catalog entry by code — cause, fix, example — the same table trilha check prints beside every failure; a family code such as E_VULN_GO-2026-0001 answers the family's entry
get_patternone of the kit's screen patterns by name — components, data contract, accessibility notes and a complete page.go of at most 60 lines that compiles; the same data as trilha ui patterns

get_recipe returns the canonical recipe Markdown. The site test already checks that its Go blocks occur character for character in examples/cookbook, so the MCP and the published page cannot quietly drift from the code that compiles. Run the site application on a host that can accept POST requests; the GitHub Pages export remains the static documentation mirror and does not attempt to emulate MCP over files.

Client#

func Dial(ctx, dial Dialer) (*Client, error)     // opens the transport and runs initialize
func Stdio(name string, args ...string) Dialer    // child process, JSON per line
func HTTP(url string, headers map[string]string) Dialer  // Streamable HTTP (POST per message)
func HTTPWith(url string, client *http.Client, headers map[string]string) Dialer  // the same, over a client you built
MethodRole
ListTools(ctx) ([]ToolInfo, error)follows pagination (nextCursor)
CallTool(ctx, name, args) (CallResult, error)CallResult.Text() joins the text items
Tools(ctx) ([]*ai.Tool, error)tools ready for an ai.Agent; isError becomes an error
Server.Name/Version/ProtocolVersionfilled by initialize
Close()closes the transport and ends the child process

The HTTP client keeps the Mcp-Session-Id received on initialize and sends it on the following messages; it accepts JSON or text/event-stream responses.

Server#

func NewServer(name, version string, tools ...*ai.Tool) *Server
func (s *Server) ServeHTTP(c *trilha.Ctx) error         // in app/.../route.go: POST
func (s *Server) Handler() http.Handler                  // outside Trilha
func (s *Server) ServeStdio(ctx, r io.Reader, w io.Writer) error

Methods served: initialize, ping, tools/list, tools/call; notifications are accepted without a response (202). Over HTTP, initialize emits Mcp-Session-Id; messages without a valid session get 404; sessions expire after SessionTTL (1 h) without use. Only POST is accepted (405 with Allow: POST for the rest). Body limited to 4 MiB.

Tool errors and panics become a result with isError: true, as the protocol requires; an unknown tool is JSON-RPC error -32602.

Your API as tools#

func FromRoutes(app *trilha.App, o FromRoutesOpts) *Server
func Preview(openAPI []byte, routes map[string][]string, o FromRoutesOpts) ([]ToolInfo, []string, error)

type FromRoutesOpts struct {
	Name, Version string   // what initialize answers
	Include       []string // patterns to expose; default /api/*
	Exclude       []string // patterns to hide, checked after Include
	OpenAPI       []byte   // the document trilha openapi writes; required
}

The context tools#

func ContextTools(opts ContextOpts) []*ai.Tool

type ContextOpts struct {
	Root    string // the project's directory
	Module  string // its module path; read from go.mod when empty
	Version string // what the map stamps
}

ContextTools returns get_context, search_code and get_pattern, the tools an agent uses before it writes a line: the project map sliced for one pack and priced in estimated tokens (the same slices trilha ctx --pack answers), and code as path:line windows instead of whole files, and a screen pattern of the kit whole — components, data, accessibility and a page.go that compiles (Patterns). Append them to the server's tools next to the domain ones.

An API written under app/api/ is already what an agent needs: a name, a description, an argument schema, a handler. FromRoutes publishes it as MCP tools without a second declaration — one tool per (method, route), named by the operation id of the document (getApiPosts, postApiPosts), described by the handler's own doc comment, with the path parameters, the query and the body flattened into one input schema.

//go:embed mcp/openapi.json
var openAPI []byte

func Setup(a *trilha.App) error {
	trilha.Provide(a, mcp.FromRoutes(a, mcp.FromRoutesOpts{Name: "blog", Version: "1.0", OpenAPI: openAPI}))
	return nil
}
// app/mcp/route.go
func POST(c *trilha.Ctx) error { return trilha.Use[*mcp.Server](c).ServeHTTP(c) }

The document is what gives the tools their descriptions and schemas — the runtime does not keep them — so it lives next to the route, at app/mcp/openapi.json, written by trilha openapi -o app/mcp/openapi.json and embedded. trilha check compares every openapi.json under app/ with the routes, so a copy that fell behind fails the build, not the agent. Without a document FromRoutes panics: a tool with no description is a tool a model will misuse.

A call is the route. The tool builds the request — {id} into the path, q into the query, the rest as the JSON body — and runs it through app.Handler(), chain included: the rate limit, the API key, the audit and the log see the same request a curl would send. The caller's Authorization, X-Forwarded-For, X-Request-ID and Accept-Language travel with it; the actor of the audit record says Via: "mcp". A 4xx/5xx becomes a tool result with isError: true and the body as the text, so the model reads the 422 the handler wrote.

tools/list is per caller. Before listing a tool the server probes its route with the caller's headers — the chain runs, the handler does not — and a route that would answer 401 or 403 is not in the list. A key with docs:read sees the reads; the writes are not hidden behind a refusal, they are absent. The probe takes no rate-limit token and counts no usage (see App.Probe).

What is left out, with the reason in the log and in trilha mcp --from-routes: pages (page.go), routes outside Include, OPTIONS/HEAD, and a handler whose body is multipart — the bridge sends JSON. Include and Exclude are prefixes with a trailing * or exact paths.

Preview is the same plan without an app: the document, the routes and the options in, the list and the warnings out — what the CLI prints.

Your own transport#

Transport is an interface (Send, Recv, Close). Pipe(r, w, closer) builds the line transport over any reader/writer pair, which the tests use with io.Pipe.