Agent-readiness
AI agents (and scanners like isitagentready.com)
look for a small set of well-known discovery artifacts before they can use a
site: a curated /llms.txt, an A2A agent card, sitemap + robots, Link
response headers pointing at all of it, and markdown content negotiation.
GoFastr already ships the plumbing: MCP tools, an OpenAPI spec, per-screen
markdown docs, sitemap, robots. Getting agent-ready mostly means adding
the discovery layer that makes those capabilities findable.
Every piece below is opt-in and additive: existing robots/sitemap/openapi/ llm.md behavior is unchanged. Turn the sane defaults on in one call, or wire each piece granularly.
One-call bundle
uihost.WithAgentReady + framework.WithMCP is the full agent-ready shape:
package main import ( "github.com/DonaldMurillo/gofastr/core-ui/app" "github.com/DonaldMurillo/gofastr/framework" "github.com/DonaldMurillo/gofastr/framework/uihost" ) func main() { site := app.NewApp("Acme") // …register your screens on site… host := uihost.New(site, uihost.WithAgentReady(uihost.AgentReadyConfig{ BaseURL: "https://example.com", Title: "Acme", Summary: "Acme is a billing console. MCP tools live at /mcp.", AgentCard: &uihost.AgentCardConfig{ Name: "Acme Agent", Description: "Operator agent for the Acme billing console.", MCPEndpoint: "/mcp", }, })) // WithMCP auto-mounts /mcp (POST JSON-RPC + GET SSE) so the host // doesn't hand-wire the route the agent card advertises. fwApp := framework.NewUIHostApp(host, framework.WithMCP()) fwApp.Start(":8080") }
That serves /llms.txt, /.well-known/agent-card.json (+ legacy
/.well-known/agent.json), AI-bot-aware /robots.txt, and emits Link
response headers on every HTML page. WithPublicLLMMD (already common) makes
the bundle's default /llms.txt link the per-screen markdown index, and
enables markdown content negotiation when WithMarkdownNegotiation is added.
The pieces
/llms.txt: curated markdown index (llmstxt.org)
uihost.WithLLMsTxt("Acme", "A billing console.", []uihost.LLMsTxtSection{ {Title: "Docs", Links: []uihost.LLMsTxtLink{ {Name: "Index", URL: "/llm-pages.md", Notes: "every screen"}, {Name: "API", URL: "/openapi.json"}, }}, {Title: "Optional", Links: []uihost.LLMsTxtLink{ {Name: "Changelog", URL: "/changelog.md"}, }}, })
The file is markdown per the spec: an H1 title, a > blockquote summary, then
one ## Section per file-list of - [name](url): notes. A section titled
Optional is the spec's skippable-context list. When no sections are passed
(and the bundle is on with WithPublicLLMMD), a default Docs section links
the app's /llm-pages.md index, which itself enumerates every screen and its
per-screen /llm.md doc.
The bundle adds three content pieces on top of the file-list sections:
WhenToUserenders a## When to usesection — one or two plain sentences naming the best-fit use cases. This is the phrasing scanners (and agents deciding whether your service fits their task) read first.CLIrenders a## CLIsection with the install command in a fenced code block plus a docs link, so an agent reaches for the scriptable path instead of scraping the UI:
uihost.WithAgentReady(uihost.AgentReadyConfig{ Title: "Acme", WhenToUse: "Reach for Acme when you need invoices issued or " + "voided programmatically.", CLI: &uihost.CLIToolConfig{ Name: "acme", Install: "npm install -g @acme/cli", Docs: "/docs/cli", }, })
- Auto-links: when
OpenAPIEndpointis set the index gains an## APIsection linking it, and when the agent card'sMCPEndpointis set it gains an## MCPsection linking the endpoint and/.well-known/mcp.json. Sections you wrote yourself win: if yourSectionsalready link a path, the auto-section for it is not appended.
Setting WhenToUse or CLI alone turns /llms.txt on — both only render
inside the index, so configuring one is itself the opt-in.
/llms-full.txt: the full-corpus tier
The llmstxt.org convention has two tiers: /llms.txt is the small index
an agent fetches first; /llms-full.txt is the whole docs corpus in one
markdown file, for agents that want everything in a single request
instead of following links. Serve it by passing the concatenated
markdown:
uihost.WithLLMsFullTxt(fullCorpusMarkdown) // or, via the bundle: uihost.WithAgentReady(uihost.AgentReadyConfig{ Title: "Acme", FullText: fullCorpusMarkdown, })
The content is served verbatim as text/plain. Nothing links it
automatically. Add a Sections entry pointing at /llms-full.txt so
agents reading the index can find it. The docs site in examples/site
does exactly this: its /llms.txt indexes every embedded framework
doc as a raw markdown URL, and its /llms-full.txt is the whole
corpus concatenated.
A2A agent card (Agent2Agent v1.0)
/.well-known/agent-card.json describes the agent's identity, service
endpoint, capabilities, and skills, conforming to the A2A v1.0 AgentCard
(camelCase JSON keys per ADR-001; supportedInterfaces and skills are
REQUIRED and always present). The service endpoint lives in
supportedInterfaces[].url. There is no top-level url in v1.0. When
MCPEndpoint is set, that endpoint is advertised as the JSON-RPC
interface (it genuinely speaks JSON-RPC: initialize and tools/list
work), and a derived mcp skill points agents at it.
AgentCardConfig field | Purpose |
|---|---|
Name (required) | Human-readable agent name. |
Description | Short summary. |
Version | Software version; defaults 1.0.0. |
URL | Fallback for the supportedInterfaces[].url when MCPEndpoint is unset; defaults to the resolved base URL. |
MCPEndpoint | e.g. "/mcp", advertised as supportedInterfaces[].url (baseURL + endpoint), plus a derived mcp skill + a Link: rel="service" header. |
A2AEndpoint | e.g. "/a2a", advertised as supportedInterfaces[].url INSTEAD of the MCP endpoint, with streaming + pushNotifications forced true. Requires the exchange the path implies: framework.WithA2A (see the A2A task exchange). |
Streaming, PushNotifications | Capability flags (default false). |
SecuritySchemes | OpenAPI-style schemes under securitySchemes; omitted when nil. |
DefaultInputModes, DefaultOutputModes | MIME types; default ["text/plain"]. |
SigningKeys | Sign the card (A2A §8.4) + serve /.well-known/jwks.json; requires a pinned BaseURL — see Signed agent cards. |
Each AgentSkill may override the card's MIME defaults with InputModes
and OutputModes (inputModes/outputModes in JSON, omitted when empty).
App.A2ASkills() preserves these modes, including application/json for
entity skills that require a JSON data part.
Signed agent cards (A2A v1.0 §8.4)
Set AgentCardConfig.SigningKeys and the card gains a signatures
array: one JWS (RFC 7515) per key, each computed over the RFC 8785
canonical form of the card with signatures excluded — exactly the
construction A2A v1.0 §8.4 specifies, so off-the-shelf A2A clients can
verify it. The verification keys are published as a JWK Set at
/.well-known/jwks.json, and each signature's protected header
(alg, typ:"JOSE", kid, jku) points at that endpoint.
edKey := /* ed25519.PrivateKey or *ecdsa.PrivateKey (P-256/P-384/P-521) */ uihost.WithAgentReady(uihost.AgentReadyConfig{ BaseURL: "https://agents.example.com", AgentCard: &uihost.AgentCardConfig{ Name: "My Agent", MCPEndpoint: "/mcp", SigningKeys: []uihost.AgentCardSigningKey{{KeyID: "2026-08", Signer: edKey}}, }, })
AgentCardSigningKey field | Purpose |
|---|---|
KeyID | JWS kid + JWKS kid. Empty defaults to the key's RFC 7638 SHA-256 thumbprint, so one key always maps to one kid. |
Signer | ed25519.PrivateKey (EdDSA) or *ecdsa.PrivateKey on P-256/P-384/P-521 (ES256/ES384/ES512). Anything else fails the boot — the framework will not guess an algorithm for an unmodeled key type. |
Rotation: list several keys. Each adds one signatures entry and one
JWK, so a client that trusts any listed key keeps verifying across a
key change; remove the old entry once clients have rotated.
Signing pins the base URL. A signed card whose URLs derive from the
request's Host header would hand an attacker a validly signed card
pointing at their own host. When SigningKeys is set, an explicit
WithAgentReady{BaseURL} (or WithSitemap{BaseURL}) is required;
without one the host panics at mount instead of ever serving a
request-derived signed card.
How a client verifies: fetch the card, note each
signatures[].protected header (kid, jku, alg), fetch the JWKS
at jku over HTTPS, select the key by kid, delete the signatures
member, canonicalize the rest with JCS (RFC 8785), and verify the
signature over BASE64URL(protected) "." BASE64URL(canonicalCard) with
the header's algorithm. A worked verifier (Node WebCrypto, sharing no
code with the framework) lives at
framework/uihost/testdata/signing/verify-card.mjs.
AI-bot-aware robots
WithAgentReady{AllowAIBots: boolPtr(true)} augments /robots.txt with
explicit per-crawler rules (GPTBot, ClaudeBot, Google-Extended, CCBot, …) so
the site reads as agent-friendly to scanners; false denies them. It merges
into the existing WithRobots config regardless of option order. When
allowed, the bots are listed as consecutive User-agent: lines in the
main group, so they inherit the host's Allow/Disallow rules. A
standalone Allow: / group would shadow path-specific exclusions, since
RFC 9309 applies only a crawler's most-specific group. When denied,
each bot gets its own Disallow: / group.
Link: response headers
WithAgentReady (or WithAgentLinkHeaders) emits a Link header on every
HTML page advertising the configured artifacts: rel="sitemap",
rel="llms-txt", rel="agent-card", rel="service" (the MCP endpoint),
rel="service-desc" (the OpenAPI spec, when OpenAPIEndpoint is set), and
rel="alternate" type text/markdown (the page's /llm.md). Absolute URLs
use the resolved base URL (see below).
Markdown content negotiation
WithMarkdownNegotiation() makes any HTML page serve its markdown rendering
when the request's Accept header prefers text/markdown (the Cloudflare
convention). Requires WithPublicLLMMD so the per-screen renderers are
available. Requests without the Accept header are unaffected.
While negotiation is enabled, both variants of a page URL — the markdown
response and the HTML one — carry Vary: Accept, so a shared cache keys
on the Accept header and never replays the markdown body to a browser (or
the HTML body to an agent). Pages on hosts without the opt-in carry no
Vary: Accept, since the URL has a single representation.
Page markdown is rendered for the caller, the way the HTML is: policy,
Load and the signed-in session decide what it holds. So the negotiated
markdown, /llm-pages.md and every screen's llm.md answer
Cache-Control: private, no-store with Vary: Cookie, the headers the
HTML page carries, and no shared cache stores one reader's doc for the
next.
The markdown answers with the status the HTML would: a page that draws
its not-found state answers 404 in either, a page whose policy blocks
answers the block's status over the withheld doc, and a page whose
policy redirects answers the same 303. The per-screen /llm.md route is
a catalog entry and keeps answering 200 with the withheld doc.
Every page-markdown answer is per caller: the negotiated twin, the
per-screen /llm.md (exact, pattern and concrete dynamic URLs) and the
/llm-pages.md index all run the policy chain with the live request,
and the per-URL ones run the page's Load. So they answer the way the
HTML page does, Cache-Control: no-store plus Vary: Cookie, and a
shared cache never replays a signed-in reader's markdown to the next
caller. /llms.txt and /llms-full.txt run nothing per caller and keep
Cache-Control: no-cache.
Agent-friendly 404s (RFC 9457 + markdown recovery)
A request that matches no route gets a 404 shaped like the client asked:
Accept: application/json(orapplication/problem+json) → anapplication/problem+jsonbody per RFC 9457:{"type":"about:blank", "title":"Not Found","status":404,"detail":"…"}. This arm is always on — a machine-readable error shape is error-format correctness, not an agent feature. A browser-styleAccept(text/html,…,*/*) still gets the HTML 404, customWithNotFoundScreenincluded.Accept: text/markdown, when markdown negotiation is enabled → atext/markdownbody with recovery links ([Home](/),[Site map](/sitemap.xml),[llms.txt](/llms.txt)).
Every 404 arm carries Vary: Accept. Neither machine arm reflects the
requested path back into the body — the markdown arm deliberately says
nothing about the URL, and the problem document omits it, so a hostile path
can never land unescaped in a machine-readable error.
Organization JSON-LD (WithOrganization)
Scanners (and agents doing due diligence) look for an Organization schema
with a contactPoint and a PostalAddress to verify the business behind a
site is real and reachable. uihost.WithOrganization declares it once; the
host embeds it as an <script type="application/ld+json"> block in every
full page head:
uihost.WithOrganization(uihost.OrganizationConfig{ Name: "Acme Inc", URL: "https://example.com", Email: "support@example.com", // and/or Phone Address: uihost.PostalAddress{ Street: "1 Main St", Locality: "Springfield", Region: "IL", PostalCode: "62701", Country: "US", }, })
Logo and SameAs (GitHub/LinkedIn profile URLs) are optional. The JSON is
built with encoding/json, so host-supplied strings are escaped and cannot
break out of the data block; unset pieces (contactPoint, address) are
omitted rather than emitted empty, and ContactType defaults to
"customer support".
Per-screen llm.md carries the screen's SEO
The per-screen /llm.md document (and the negotiated-markdown response)
opens with a YAML front-matter block mirroring the same screen's HTML
<head> metadata: title, description, canonical, robots,
og_title / og_description / og_image, twitter_card /
twitter_title, hreflang (a list), and schema_types (the JSON-LD
@type names). The values are resolved from the same
ScreenSEO bundle + per-concern interfaces (ScreenCanonical,
ScreenRobots, ScreenHreflangs, ScreenSchema) the head renders from,
so a crawler and an LLM see one consistent metadata set per route.
Screens with no SEO declarations get no front-matter, and the markdown is
unchanged. A static export writes the same front matter:
UIHost.LLMMDWithSEO(ctx, path, screen, doc) turns the doc
app.ScreenLLMMDForPath rendered into the served one. See
SEO for the per-screen interfaces.
Every URL gets its own doc
Each page's llm.md is built per URL, not from one shared pattern doc:
GET /about/llm.md, GET /products/42/llm.md (or
/docs/getting-started/llm.md on a catch-all route) builds the same
per-request instance the page render uses, the route match and its
resolvers → SetParams → DI → Load, so the markdown carries that
page's loaded title and rendered content, including a Load that reads
a route resolver. The static exporter does the same for every exact
route and every URL a screen's StaticPaths enumerates, and SPA partial
responses carry the post-Load title in X-Gofastr-Title, so in-app
navigation to a dynamic page updates the browser title correctly. All of
it sits behind the WithPublicLLMMD opt-in.
The doc answers 404 wherever the page does: a resolver or Load
answering app.ErrNotFound (also when Load read the resolver and
dropped its error), an outlet declared FallbackNotFound that nothing
fills, or a status set with app.SetStatus or ScreenStatusCode. The
static exporter writes no llm.md for such a page. Any other failure
degrades to the pattern-level doc rather than erroring.
Context-aware page content uses the request's translator and locale,
including the unprefixed default locale, header- or cookie-negotiated
locales, and pattern-level fallback docs. Links receive a locale prefix
only when that locale's URLs have one. The standalone
app.ScreenLLMMDHandler also renders per request when a translator or
locale is attached; without either or a locale prefix, it reuses its
background-context document.
Both standalone handlers, app.ScreenLLMMDHandler and
app.AppLLMMDHandler, send Cache-Control: private, no-store and add
Vary: Cookie without replacing existing Vary fields. Their policy- and
locale-dependent bodies must not be retained by a shared cache.
Inline code in markdown preserves the HTML's displayed code: entities
decode once, so <div> becomes <div> inside an inert code span,
while double-encoded &lt; remains the literal text <. Tag-shaped
code and backticks cannot become live markup or terminate their span.
Every markdown surface evaluates the screen's policy chain with the live
request: a non-Allow decision, or a region guard (an outlet's, an area's,
or a fill's policy) that redirects, serves a metadata-free "withheld" doc
(route path and type only: no title, description, SEO front matter, or
content),
and the /llm-pages.md index lists policy-gated screens path-only. An
authenticated agent whose request passes the policy sees the full docs.
The pattern-level surfaces (the index, the /llm.md beside a route
pattern, ScreenLLMMDHandler) ask (*App).PatternPolicy: a static
path runs its full policy phase, region guards and resolvers included,
and a dynamic pattern under a layout that carries any region guard, or
in a group that Requires a resolver, is withheld, because a guard or
a required resolver decides per URL. The per-URL doc of such a
page still runs its guards and serves.
Rendered values cannot inject markdown structure
Every value that goes into a served markdown document — page content
converted from HTML, screen titles and descriptions (including the
per-instance titles dynamic routes load), index table cells — is decoded
and escaped per slot on the way in: code blocks are fenced one backtick
longer than any run inside them, link text and destinations escape
brackets and parentheses, table cells escape pipes and line breaks, and
one-line header fields keep their first line only. Entity content that
carries ``` , ](https://…), |, or a newline stays data in the
document instead of becoming a live fence, link, column, or heading.
Headers in the raw bytes (\], \|) render as the plain characters.
MCP auto-mount (framework.WithMCP)
framework.WithMCP() exposes app.MCP at /mcp over Streamable HTTP (POST
JSON-RPC + GET Server-Sent Events), replacing the manual
fwApp.Router().Handle("POST", "/mcp", fwApp.MCP). Combined with
WithMCPIntrospection(), the nine tools that read the running app's state
are reachable at the canonical endpoint the agent card advertises:
app_routes, app_plugins, app_batteries, app_modules, app_config,
app_readiness, app_goroutine_leaks, app_routines, app_unmounted
(the unmounted copies the binary was built with). The embedded
framework docs are a separate option, framework.WithMCPTools(mcptools.Register) from
framework/docs/mcptools, which adds framework_docs_list,
framework_docs_get and framework_docs_search; package framework does not
import the docs corpus, so the app opts in. WithMCPTools takes any
func(*mcp.Server) error: the framework runs it during init after every
plugin and battery Init and before the introspection set, and a
registrar error (a tool-name collision included) fails the boot. Alongside them sits the contract catalog
(contracts_list, contracts_explain, contracts_capabilities), which
describes what the framework requires of the app's own code. Under
gofastr dev the catalog gains a working half: contracts_verify runs
the analyzers over the app's source and returns structured findings, and
contracts_fix applies one rule's autofixes. Neither is registered
outside the dev loop, since both touch local source files.
Explicit WithMCPIntrospection() does not suppress these dev tools;
generated apps receive them too. GOFASTR_DEV_MCP=0 or a production
environment keeps them off even when introspection was explicitly enabled.
Calling WithMCP and manually mounting /mcp
panics with a route conflict. Pick one. Generated apps ship with
WithMCP, WithMCPIntrospection and WithMCPTools(mcptools.Register) wired.
framework.WithMCPControl() adds the mutating counterpart:
app_module_enable / app_module_disable toggle registered modules on the
running app (persisted through the module store, dependency-checked). Keep
it off any /mcp reachable by untrusted callers.
When you opt in explicitly, both control tools require a caller holding
the admin role: they run behind framework.MCPRequireRole("admin")
as an mcp.WithToolGate precondition, which refuses a request with no
identity, an embed grant, a caller an access Decider denies, and a
caller without the role. The role is read from the user's
GetRoles() []string (battery/auth's User has it) or from the roles
access middleware put on the context, the same rule /.debug uses. Make
sure the app's session/JWT middleware runs on the /mcp route, or every
call comes back asking for a caller. An identity alone was the old gate,
and on an app with open sign-up it let any account turn a module off for
everyone. When your operators hold another role, replace the gate:
framework.NewApp( framework.WithMCP(), framework.WithMCPControl(), framework.WithMCPControlGate(framework.MCPRequireRole("ops")), )
An API token carries its owner's roles, so MCPRequireRole narrows a
scope-restricted caller (an API token or embed grant) to the roles its
scopes name: it passes only with access.RoleScope(role)
("role:admin"; "role:*" and "*:*" cover every role) for a role
that admits it. A customers:read token an admin minted for a
reporting job does not reach the control tools. Sessions and JWTs carry
no scopes and are unaffected.
framework.MCPRequireUser() and MCPRequireRole both consult the
Decider the way battery/auth's MCPUser and MCPRole do: the caller's
roles, access.RoleGateCapability and the zero Ref, so
DecisionDeny refuses the call.
A gated tool is also hidden from tools/list for callers who cannot
invoke it. That matters more than it sounds: tools/list used to run with
no gate at all, so an anonymous POST came back with every tool's
inputSchema, and for entity CRUD tools those schemas are built from live
entity definitions, naming every entity and every non-Hidden field with
its type and enum set. The call refused; the schema was already out.
Prefer mcp.WithToolGate(gate) as a RegisterTool option over the older
mcp.Gated(gate, handler) wrapper. Gated wraps the handler, so it only
ever reached tools/call. The listing never consulted it.
app.MCP.RegisterTool("orders_refund", "…", schema, refundHandler, mcp.WithToolGate(auth.MCPRole("support")))
When the whole endpoint is private, close it in one place instead:
framework.NewApp( framework.WithMCP(), framework.WithMCPGate(framework.MCPRequireUser()), )
NewApp installs the gate after every option has run, so it lands on the
server WithMCPServer supplies whichever option comes first.
WithMCPGate covers the whole data surface: tools/list, tools/call,
resources/list, resources/read, resources/templates/list,
prompts/list and prompts/get. The initialize handshake and ping stay open by
design: they carry only the protocol version, capability booleans and the
server name, and a client that cannot handshake cannot present credentials.
The gofastr dev loop is exempt: it turns these tools on with no auth
configured at all, so a gate would only lock the dev loop out of its own
app. Its exposure is bounded on the other axis instead: dev refuses
to register the control tools when the listener is not loopback. Bind
to localhost, or set GOFASTR_DEV_MCP_EXPOSE=1 to accept the risk.
The same applies to the app_* introspection tools and contracts_verify
when dev turned them on; an explicit WithMCPIntrospection() keeps them.
The check reads the address the server actually binds: Start(""),
":8080" and a bare "8080" all listen on every interface (Start("")
binds :http, port 80), so each counts as exposed. A listener handed to
App.Serve is judged by its network: an in-process one (memory,
pipe) is local, and a unix socket is exposed whatever its path.
The transport's loopback Host pin is a browser control (it stops DNS
rebinding); it does nothing against a direct TCP client, which sets
Host freely. That is why the bind matters too.
Auth splits by tool kind: entity CRUD tools re-dispatch
through the router, so session/JWT auth, owner scoping, and RBAC apply
exactly as they do over HTTP (the caller's Cookie/Authorization from the /mcp
request carries through). Directly registered tools, meaning custom
app.MCP.RegisterTool handlers and Endpoint.MCPHandler twins, run
without route middleware, so they carry their own gate.
Endpoint.MCPHandler twins default to requiring an authenticated
caller. An Endpoint has two front doors for one operation: Handler
inherits the route's middleware chain, MCPHandler does not. An endpoint
behind auth.RequireRole("editor") was therefore role-checked over HTTP
and ungated over MCP. Declare something stricter with
Endpoint.MCPGate, or opt out with Endpoint.Public: true for an
endpoint that really is anonymous. Public opens the HTTP route too,
which otherwise answers 401 to a caller with no user. An API token
reaches a twin only with <table>:read (GET, HEAD) or <table>:write
(any other method) among its scopes, whichever gate decides it:
entity.Endpoint{ Method: "POST", Path: "{id}/publish", MCP: true, Handler: publishHTTP, MCPHandler: publishTool, MCPGate: auth.MCPRole("editor"), // else: any authenticated caller }
For your own RegisterTool calls, gate them per-caller with
mcp.WithToolGate + battery/auth's auth.MCPUser() / auth.MCPRole(...)
(see plugins).
Process modules (issue #37) add a third tool kind alongside the entity
CRUD tools and directly-registered handlers. Each tool a process module
exposes is registered under a reserved module. prefix,
module.<name>.<tool>, so two modules cannot collide and every call is
attributable to its owning module. A disabled module's tools are omitted
from tools/list and refused by tools/call (the composite call gate);
an enabled-but-down module's tools stay listed but return a retryable
temp-unavailable error while the child is not Ready. A tool call forwards
to the child through the same capability broker as the module's HTTP
routes. The agent's authority is delegated identically, and there is no
separate tool-permission vocabulary. See process modules.
The dev loop implies all of it but the docs tools. Under gofastr dev (GOFASTR_DEV),
framework.NewApp auto-enables the mount, introspection, and control
(the framework_docs_* tools still need WithMCPTools(mcptools.Register));
battery/log auto-registers its log_recent / log_filter /
log_metrics / log_set_level debug tools; and every CRUD-enabled
entity serves its {entity}_list/get/create/update/delete data tools
without per-entity mcp: true, with zero options: the local dev loop
is livereload for agents. Opt out with GOFASTR_DEV_MCP=0 (mirrors
GOFASTR_DEV_LIVERELOAD=0); a production GOFASTR_ENV always wins.
A dev-implied mount yields to a hand-wired /mcp route instead of
panicking, so older scaffolds keep working under gofastr dev.
Rich tool results, resources, and MCP Apps
A tool handler returns any. By default a plain value is JSON-marshaled
into a single {type:"text"} block (unchanged). To emit richer content,
return one of core/mcp's result types:
// An image block: every MCP client renders it inline (no token bomb from // a base64 string smuggled through text): return mcp.ImageResult{Data: pngBytes, MimeType: "image/png"}, nil // Structured output (validated against a declared outputSchema) plus // explicit blocks. A structured-only result still mirrors a text block for // clients that don't read structuredContent: return mcp.ToolResult{ Structured: map[string]any{"count": 3}, Content: []mcp.Content{mcp.TextContent("3 matches")}, }, nil
Declare a tool's output shape and attach _meta at registration with
options:
app.MCP.RegisterTool(name, desc, inputSchema, handler, mcp.WithOutputSchema(schema), // → tools/list.outputSchema mcp.WithToolMeta(map[string]any{ // → tools/list._meta (verbatim) "ui": map[string]any{"resourceUri": "ui://app/widget.html"}, }), )
Resources. app.MCP.RegisterResource(uri, name, mimeType, contents)
serves a resource via resources/list + resources/read; registering any
resource makes initialize advertise the resources capability. The
contents func runs per read and may return text or a binary blob (base64 on
the wire). Attach resource _meta with mcp.WithResourceMeta(...). Note
resources are not covered by the tool call gate: mcp.Gated /
auth.MCPUser gate tool handlers, not resources/read. Public content (an
MCP App's widget HTML) needs no gating; to serve sensitive or per-caller
data, add mcp.WithResourceGate(gate) (the resource-side analogue of
mcp.WithToolGate, where auth.MCPUser() / auth.MCPRole(...) work as gates).
It runs before the contents func on every read and hides the resource from
resources/list for a caller it refuses.
MCP Apps. The MCP Apps extension
lets a tool declare an interactive HTML widget the host renders in a
sandboxed iframe. framework.WithMCPApp wires both halves in one call:
the ui:// resource carrying the HTML, and the tool whose _meta links
to it (with the ChatGPT Apps SDK openai/outputTemplate compat alias).
framework.WithMCPApp(mcp.AppConfig{ Name: "studio", Description: "Open the studio widget.", InputSchema: schema, Handler: studioTool, ResourceURI: "ui://myapp/studio.html", HTML: studioHTML, // self-contained, inline JS/CSS CSP: mcp.AppCSP{ // rides on the resource's _meta.ui ConnectDomains: []string{"https://api.openweathermap.org"}, ResourceDomains: []string{"https://cdn.jsdelivr.net"}, }, })
CSP is the spec's structured object, not a policy string: the host turns
each allowlist into the matching iframe directive (connectDomains →
connect-src; resourceDomains → img-src/script-src/style-src/
font-src/media-src; frameDomains → frame-src; baseUriDomains →
base-uri). An empty or omitted field allows no external origins, and a
zero AppCSP emits no csp key at all.
The widget HTML is the app author's job (a single vanilla-JS file needs no
build step); mcp.WidgetDocument assembles it so the widget client's
script URL cannot drift — see the agent-host contract
for the full authoring walk-through and the host-theme convention.
WithMCPApp is an explicit opt-in registered during
InitPlugins, so a duplicate tool name or resource uri is a hard build
error. Requires the /mcp server to be mounted (WithMCP, or the dev
auto-mount).
OAuth Protected Resource (RFC 9728)
When the app exposes OAuth-token-protected resources (e.g. battery/auth's JWT
bearer API), framework.WithOAuthProtectedResource serves
/.well-known/oauth-protected-resource so a client can discover which
authorization servers mint accepted tokens, the supported scopes, and how to
present a bearer token:
framework.WithOAuthProtectedResource(framework.OAuthProtectedResourceConfig{ Resource: "https://api.example.com", AuthorizationServers: []string{"https://auth.example.com"}, ScopesSupported: []string{"read", "write"}, })
When Resource has a path (https://api.example.com/mcp), the document is
also served at /.well-known/oauth-protected-resource/mcp, the URL RFC 9728
§3.1 has a client build for it.
Resource is required and must be an absolute HTTPS URL with a hostname,
no userinfo, and no fragment. A query is allowed but discouraged. Each
AuthorizationServers entry must be an absolute HTTPS issuer URL with a
hostname and no userinfo, query, or fragment; an issuer path is allowed.
Start, Serve and Run reject invalid metadata configuration before
booting, under any host.
The framework serves the document; emitting the companion
WWW-Authenticate: … resource_metadata=… header on 401s (RFC 9728 §5) is left
to the host's auth middleware so it can be scoped to the exact token-protected
routes.
Scanner-conformance endpoints (isitagentready.com)
The framework auto-serves the well-known artifacts the isitagentready scanner scores, so a host wiring the basics passes without per-route work:
| Check | Endpoint | When served |
|---|---|---|
| API Catalog (RFC 9727) | /.well-known/api-catalog (linkset+json) | when the app has entities (/openapi.json exists) |
| MCP Server Card (SEP-2127) | /.well-known/mcp/server-card.json + spec-reserved /mcp/server-card + /.well-known/mcp/catalog.json | when WithMCP exposes /mcp |
| MCP Manifest | /.well-known/mcp.json — flat endpoint/transport fields AND a nested mcpServers map, both naming /mcp with streamable-http | when WithMCP exposes /mcp |
| Agent Skills Index | /.well-known/agent-skills/index.json | always (empty list passes; WithAgentSkills adds entries) |
| OAuth Authorization Server (RFC 8414) | /.well-known/oauth-authorization-server | opt-in (WithOAuthAuthorizationServer) |
| Content Signals | Content-Signal: line in robots.txt | AgentReadyConfig.ContentSignals |
| Auth.md (WorkOS profile) | /auth.md (markdown) + agent_auth block in the OAuth AS metadata | opt-in (WithAuthMD) |
framework.WithAgentSkills([]framework.AgentSkillEntry{{ Name: "code-review", Description: "Review code.", URL: "/.well-known/agent-skills/code-review/SKILL.md", Digest: "sha256:...", }}) framework.WithOAuthAuthorizationServer(framework.OAuthAuthorizationServerConfig{ Issuer: "https://auth.example", TokenEndpoint: "https://auth.example/token", })
All 11 scored isitagentready checks are covered: robots.txt, Sitemap,
Link headers, Markdown negotiation, AI bot rules, Content Signals, API
Catalog, OAuth Protected Resource, MCP Server Card, Agent Skills Index,
and OAuth Authorization Server (6 always-on via the bundle; the rest
opt-in / conditional). The production scanner also lists: A2A card
(covered: /.well-known/agent-card.json), Auth.md (WithAuthMD), Web
Bot Auth (WithWebBotAuth: the site publishes a JWKS at
/.well-known/http-message-signatures-directory so it can sign its own
outbound requests), UCP (WithUCP → /.well-known/ucp), and ACP
(WithACP → /.well-known/acp.json). Not buildable as served routes:
DNS-AID (DNS SVCB/HTTPS + DNSSEC), x402 (HTTP 402 payment middleware),
MPP (payment execution + an x-payment-info OpenAPI extension needing a
payment backend), WebMCP (client-side browser API), ap2 (server-only).
Base URL resolution
All absolute discovery URLs (agent card url, Link header targets) use one
canonical origin, resolved in this order: WithAgentReady{BaseURL}, then
WithSitemap{BaseURL}, then the per-request scheme + the request's own Host.
Set one origin and every artifact stays consistent. Behind a proxy,
X-Forwarded-Proto is honored for the scheme, but X-Forwarded-Host is
deliberately not: it is client-settable and reflecting it into the
Link: rel="service" header would be a cache-poisoning primitive, so the
addressed Host is used instead. Set BaseURL explicitly when your proxy
rewrites the host.
Signed cards are the exception to the per-request fallback: with
SigningKeys set, an explicit BaseURL is required and the host
refuses to mount without one (see Signed agent cards).
Granular options
| Option | Serves |
|---|---|
uihost.WithAgentReady(cfg) | Bundle: llms.txt + card + AI-bot robots + Link headers (incl. OpenAPI service-desc when cfg.OpenAPIEndpoint is set, e.g. "/openapi.json"). |
uihost.WithLLMsTxt(title, summary, sections) | /llms.txt only. |
uihost.WithLLMsFullTxt(content) | /llms-full.txt only (full-corpus tier, served verbatim). |
uihost.WithAgentCard(cfg) | /.well-known/agent-card.json + agent.json alias. |
| (card signing) | With AgentCardConfig.SigningKeys: /.well-known/jwks.json (the card's verification keys). |
uihost.WithAgentLinkHeaders() | Link: headers on HTML only. |
uihost.WithMarkdownNegotiation() | Accept: text/markdown → markdown. |
uihost.WithOrganization(cfg) | Organization JSON-LD (contactPoint + PostalAddress) in every full page head. |
framework.WithMCP() | Auto-mount /mcp (Streamable HTTP) + discovery well-knowns (server card, /.well-known/mcp.json manifest). |
framework.WithMCPIntrospection() | The read-only app_* tools (routes, plugins, batteries, modules, config, readiness, goroutine leaks, routines) plus the contracts_* catalog. |
framework.WithMCPTools(register) | Run a func(*mcp.Server) error at init, after plugin/battery Init and before introspection; mcptools.Register from framework/docs/mcptools adds the framework_docs_* tools. |
framework.WithMCPApp(cfg) | Register an MCP App: a ui:// HTML widget resource + its linking tool. |
framework.WithOAuthProtectedResource(cfg) | RFC 9728 metadata doc. |
framework.WithAuthMD(cfg) | /auth.md + agent_auth block. |
framework.WithWebBotAuth(cfg) | /.well-known/http-message-signatures-directory JWKS; with cfg.Verify set, inbound verification middleware (experimental). |
framework.WithAgentSkills(skills) | /.well-known/agent-skills/index.json. |
framework.WithOAuthAuthorizationServer(cfg) | RFC 8414 AS metadata. |
framework.WithUCP(cfg) / framework.WithACP(cfg) | /.well-known/ucp / /.well-known/acp.json. |
framework.WithA2A(cfg) | Mount the A2A v1.0 task exchange at /a2a (default) with derived entity skills; see the A2A task exchange. |
Common mistakes
- Forgetting
WithMCP(or a manual/mcpmount). The agent card can advertise/mcp, but if nothing serves it the endpoint 404s. The bundle does not mount MCP for you. Callframework.WithMCP()alongside it./llms.txtlinks the well-known MCP manifest only whenWithMCPmounts it. A manual/mcpmount still gets its endpoint link, not a nonexistent manifest link. Manual hosts serving a manifest can declare its path withUIHost.SetMCPManifestPathbefore serving requests. - Advertising markdown negotiation without
WithPublicLLMMD.WithMarkdownNegotiationrenders via the per-screen LLM doc, which only exists when markdown rendering is public. Without it, the negotiated response falls through to HTML. - Hand-writing per-route
/llm.mdlinks in/llms.txt. Non-screen routes (/api/*,/healthz,/.well-known/*) have no markdown. Link the/llm-pages.mdindex instead (the default does this). - Calling
WithMCPand also mounting/mcpby hand. Route conflict → panic at startup. Use one. - Serving
/llms-full.txtwithout linking it from/llms.txt. Agents start at the index; a full-corpus file nothing points to won't be found. Add aSectionsentry with URL/llms-full.txt. - Mixing
WithAgentReadywith granular agent-ready options is safe.WithAgentReadymerges into whatever a granular option (WithMarkdownNegotiation,WithLLMsTxt,WithAgentCard,WithAgentLinkHeaders) already installed: the bundle wins for every field it explicitly sets, and a field it leaves unset preserves the granular value. Empty strings, nil slices/maps/pointers, and false card capability flags are unset; non-nil empty slices/maps can explicitly clear collections. A title-derived card preserves a granular version and MCP/A2A endpoints. Apply granular baselines before the bundle; a later granular surface option replaces that surface's configuration. SoWithMarkdownNegotiation()beforeWithAgentReady{Title: …}keeps content negotiation on; you can equally enable it via the bundle'sContentNegotiationfield. (Both still requireWithPublicLLMMD, per the note above.) - Signing the card without pinning
BaseURL. Boot fails on purpose. A signature over a request-derived URL would make an attacker-suppliedHostheader produce a validly-signed card pointing at the attacker's endpoint. SetWithAgentReady{BaseURL: ...}(or the sitemap's) and the panic disappears.
What this deliberately does not do
- The card alone is not a task server. By default the card
advertises the MCP endpoint (
/mcp) insupportedInterfacesand is structurally conformant, but GoFastr serves MCP tool calls (tools/list,tools/call), not the A2A task lifecycle. To serve the actual exchange behind the card — SendMessage, tasks, streaming, push — addframework.WithA2Aand point the card'sA2AEndpointat it; see the A2A task exchange. - No DNS-AID. DNS TXT records for AI discovery are infra/DNS, not framework code. Add them at your registrar/host.
- Inbound Web Bot Auth verification is opt-in and experimental.
WithWebBotAuthpublishes the site's signing JWKS by default; set itsVerifyfield to also verify RFC 9421 signatures on inbound requests (framework.VerifiedAgent), tracking draft-meunier-webbotauth-httpsig-protocol-02. Seeweb-bot-auth.md. - No x402 / MPP payment. These need real payment middleware (HTTP 402 + payment requirements) or a payment backend; the framework serves discovery docs (UCP/ACP) but not payment execution.