Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions .github/workflows/site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ name: Site
on:
push:
branches: [main]
# The whole site is one build, but only these inputs can change it. The
# configuration type is in the list because the settings pages are generated
# from it: a change there with a regenerated page is a documentation change
# whether or not anything under site/ was touched by hand.
# The whole site is one build, but only these inputs can change it. Every
# source the generator reads is listed, because a change there with a
# regenerated page is a documentation change whether or not anything under
# site/ was touched by hand: settings come from internal/config, alert
# variables from internal/alert, the roles matrix and route table from
# internal/api plus internal/agent and cmd/fanout, and the MCP tools from
# internal/mcp.
paths:
- "site/**"
- "internal/config/**"
- "internal/alert/**"
- "internal/api/**"
- "internal/agent/**"
- "internal/mcp/**"
- "cmd/fanout/**"
- "cmd/fanout-docgen/**"
- ".github/workflows/site.yml"
# Publishing the current main on demand, without an empty commit.
Expand Down Expand Up @@ -60,8 +68,8 @@ jobs:
cache: npm
cache-dependency-path: site/package-lock.json

# The reference pages are generated from internal/config, internal/alert
# and internal/api, and committed. This workflow triggers on those paths
# The reference pages are generated from the sources listed in this file's
# trigger paths above, and committed. This workflow triggers on those paths
# precisely because they regenerate it — so publishing without checking
# would ship a settings page describing a setting the binary rejects,
# which is the gap this file's header says it exists to close. Restored
Expand Down
12 changes: 10 additions & 2 deletions cmd/fanout-docgen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ func run(source, alertSource, routeDirs, outDir string, check bool) error {
}
pages["roles.mdx"] = rolesPage

// The MCP tool surface, from the server's own tools/list answer rather than
// from its registration calls — the same question a connecting agent asks.
toolsPage, err := renderMCPTools()
if err != nil {
return err
}
pages["mcp-tools.mdx"] = toolsPage

var stale []string
for name, body := range pages {
path := filepath.Join(outDir, name)
Expand Down Expand Up @@ -157,8 +165,8 @@ func run(source, alertSource, routeDirs, outDir string, check bool) error {
}
}
fmt.Printf(
"fanout-docgen: wrote %d page(s) covering %d setting(s), %d alert variable(s) and %d route(s)\n",
len(pages), len(fields), len(alertEnvCount), len(routeCount),
"fanout-docgen: wrote %d page(s) covering %d setting(s), %d alert variable(s), %d route(s) and %d MCP tool(s)\n",
len(pages), len(fields), len(alertEnvCount), len(routeCount), len(toolCount),
)
return nil
}
Expand Down
238 changes: 238 additions & 0 deletions cmd/fanout-docgen/mcptools.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package main

import (
"context"
"fmt"
"strings"

"github.com/labstack/fanout/internal/mcp"
)

// toolCount is set by renderMCPTools so the summary line can report it.
var toolCount []mcp.ToolDoc

// renderMCPTools writes the MCP reference from the server's own `tools/list`
// answer.
//
// The page it replaces was hand-written, and its risk was the same one that had
// already bitten the roles page: it described a tool surface a reader would act
// on, with nothing keeping it level with the server. A tool renamed, or given an
// input, or re-annotated from additive to destructive, would have left the page
// confidently wrong — and "this tool does not modify anything" is a claim
// someone connects an agent on.
func renderMCPTools() ([]byte, error) {
tools, err := mcp.DescribeTools(context.Background())
if err != nil {
return nil, err
}

var b strings.Builder

b.WriteString("---\n")
b.WriteString("title: \"MCP tools\"\n")
b.WriteString("description: \"Every tool a Fanout instance exposes over MCP, with its inputs and whether it changes anything.\"\n")
fmt.Fprintf(&b,
"summary: \"%s served at /mcp, each with its inputs and mutation semantics, taken from the server's own tools/list answer.\"\n",
count(len(tools), "tool"),
)
b.WriteString("read_when:\n")
b.WriteString(" - \"You are connecting an agent and want to know what it can call.\"\n")
b.WriteString(" - \"You need to know which tools mutate state and which cannot.\"\n")
b.WriteString("status: preview\n")
b.WriteString("generated: true\n")
b.WriteString("---\n\n")

b.WriteString("{/* Generated by cmd/fanout-docgen, which asks a running MCP server for its\n")
b.WriteString(" tool list rather than reading the registrations. Edit the generator, not\n")
b.WriteString(" this page. */}\n\n")

fmt.Fprintf(&b,
"A Fanout instance serves MCP at `/mcp`. %s, listed below exactly as the\nserver reports them to a connecting client.\n\n",
count(len(tools), "tool"),
)

// The closed-world sentence below is a safety claim a reader acts on. It is
// asserted in prose, so it has to be checked against the annotations rather
// than trusted: a tool registered without an openWorldHint defaults to
// open-world per the spec, and would otherwise be republished under a
// blanket promise that it reaches nothing else. --check cannot catch this,
// because a regenerated page carrying the same false claim is
// self-consistent.
for _, tool := range tools {
if tool.OpenWorld {
return nil, fmt.Errorf(
"tool %s is open-world (it may reach beyond this instance), but the page "+
"states that every tool is closed-world; either annotate it "+
"OpenWorldHint:false in internal/mcp or stop making the claim for all tools",
tool.Name,
)
}
}

b.WriteString("Every tool is **closed-world**: it reads or writes what this instance holds\n")
b.WriteString("and reaches nothing else.\n\n")

// Each row links to the tool's own section, whose heading is the tool name.
// That only works while the name is already what a slugger would produce
// from it: a name with an uppercase letter or a dot slugs to something else
// and every row link on the page goes dead. Nothing downstream catches a
// dead in-page anchor, so the assumption is checked where it is made.
for _, tool := range tools {
if !anchorSafe(tool.Name) {
return nil, fmt.Errorf(
"tool name %q is not usable as a heading anchor, so the summary table's "+
"link to its section would not resolve; either keep tool names to "+
"lowercase letters, digits and underscores, or slug the anchor in "+
"cmd/fanout-docgen/mcptools.go",
tool.Name,
)
}
}

b.WriteString("| Tool | Changes state |\n")
b.WriteString("|---|---|\n")
for _, tool := range tools {
fmt.Fprintf(&b, "| [`%s`](#%s) | %s |\n", tool.Name, tool.Name, effect(tool))
}
b.WriteString("\n")

for _, tool := range tools {
fmt.Fprintf(&b, "## %s\n\n", tool.Name)
fmt.Fprintf(&b, "**%s** — %s\n\n", mdx(tool.Title), mdx(tool.Description))
fmt.Fprintf(&b, "%s\n\n", effectProse(tool))

if len(tool.Inputs) == 0 {
b.WriteString("Takes no arguments.\n\n")
continue
}

b.WriteString("| Input | Type | Required | Means |\n")
b.WriteString("|---|---|---|---|\n")
for _, input := range tool.Inputs {
required := "no"
if input.Required {
required = "**yes**"
}
description := input.Description
if description == "" {
description = "—"
}
fmt.Fprintf(&b, "| `%s` | `%s` | %s | %s |\n",
input.Name, input.Type, required, cell(description))
}
b.WriteString("\n")
}

b.WriteString("## Authentication\n\n")
b.WriteString("MCP uses OAuth, not a static key, and **not the ingest token** — an agent\n")
b.WriteString("presenting the ingest token is rejected. A client discovers the endpoints,\n")
b.WriteString("registers itself, and is issued a token bound to this instance's resource\n")
b.WriteString("URI. [Connect an agent](/guides/connect-over-mcp) covers the flow.\n\n")

b.WriteString("The dashboard tools act on the authenticated user's **own** dashboards. The\n")
b.WriteString("capability boundary is `dashboards:manage-own`: an agent acts as the user\n")
b.WriteString("whose credential it holds and cannot reach anyone else's. A tool description\n")
b.WriteString("that tells a model when to call it is guidance to the model, not a\n")
b.WriteString("permission check — see [roles](/reference/roles).\n\n")

b.WriteString("## What is not here\n\n")
b.WriteString("No tool writes telemetry, changes configuration, manages users, or edits\n")
b.WriteString("alert rules. Ingest is OTLP only and the rest is HTTP surface — see\n")
b.WriteString("[HTTP routes](/reference/http-routes). An agent connected over MCP can read\n")
b.WriteString("everything the instance knows and manage its own dashboards, and that is the\n")
b.WriteString("whole envelope.\n")

toolCount = tools
return []byte(b.String()), nil
}

// effect renders a tool's mutation semantics for the summary table, from the
// annotations the server sends rather than from the tool's name.
func effect(tool mcp.ToolDoc) string {
if tool.ReadOnly {
return "No — read-only"
}
if tool.Destructive {
return "**Yes — replaces existing state**"
}
return "**Yes — additive**"
}

func effectProse(tool mcp.ToolDoc) string {
switch {
case tool.ReadOnly:
return "Read-only. Calling it changes nothing."
case tool.Destructive && tool.Idempotent:
return "**Replaces existing state.** Calling it twice with the same arguments " +
"leaves the same result as calling it once, but the first call has already " +
"overwritten what was there."
case tool.Destructive:
return "**Replaces existing state.**"
default:
return "**Additive.** It creates something new and alters nothing that exists."
}
}

// count renders "One tool" / "Nine tools" so the opening line reads as prose
// rather than as a field.
func count(n int, noun string) string {
words := []string{
"No", "One", "Two", "Three", "Four", "Five",
"Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve",
}
plural := noun + "s"
if n == 1 {
plural = noun
}
if n < len(words) {
return words[n] + " " + plural
}
return fmt.Sprintf("%d %s", n, plural)
}

// mdx escapes a value interpolated into MDX prose.
//
// Tool titles and descriptions are authored in Go string literals and reach this
// page verbatim. A description containing `<` or `{` would break the MDX build,
// and a backslash would be eaten — so the characters MDX treats as syntax are
// escaped rather than left to chance. Newlines are collapsed because a hard
// break inside a table cell ends the row.
func mdx(value string) string {
replacer := strings.NewReplacer(
"\\", "\\\\",
"<", "\\<",
"{", "\\{",
"\r\n", " ",
"\n", " ",
"\r", " ",
)
return strings.TrimSpace(replacer.Replace(value))
}

// cell escapes a value for a Markdown table cell.
//
// On top of mdx's escaping, a literal pipe has to be escaped or it splits the
// row into phantom columns — output that is wrong rather than broken, so nothing
// downstream would catch it: check-tables.mjs asserts that tables are wrapped,
// not that their rows have the right shape.
func cell(value string) string {
return strings.ReplaceAll(mdx(value), "|", "\\|")
}

// anchorSafe reports whether a name is already what a heading slugger would
// produce from it, so `## name` and `](#name)` agree.
func anchorSafe(name string) bool {
if name == "" {
return false
}
for _, r := range name {
switch {
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
case r == '_' || r == '-':
default:
return false
}
}
return true
}
Loading
Loading