webmcp

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 6, 2026 License: MIT Imports: 16 Imported by: 0

README

webmcp-go

0.2.0 · Spec baseline: WebMCP Draft CG Report 2026-10-02 · Go 1.22+ · Standard library only. Browser test target: Chrome 154 (live verification pending). Go unit results do not establish browser, CSP or production compatibility.

Intent: share identity, project the rest explicitly

Surfaces are intentionally different; share identity, project the rest explicitly. A server MCP tool and its browser counterpart can represent the same feature while intentionally having different schemas, limits, and execution paths.

Difference Server MCP Browser WebMCP Reason
Field names content, id title, task_id Browser agents benefit from names matching visible labels.
Dates ISO 8601 YYYY-MM-DD HH:MM in the user's timezone Match what the user sees.
Result limit 500 20 Keep results useful within the browser's response budget and tab context.
Execution path Service object → database Session cookies → existing web endpoint Preserve session, CSRF and authorization checks.

Identity and descriptions can start from one source. Schema changes, annotations, limits and endpoints are explicit projections. MCP and WebMCP annotations are different sets: destructiveHint does not automatically mean consequentialHint. Even readOnlyHint must be declared again for the browser endpoint.

Quick start

go get github.com/seunghan91/webmcp-go

Define tools and configure transport at boot. NewTool copies metadata; later changes to the definition cannot alter registered tools. Check all returned errors.

package main

import (
    "html/template"
    "log"
    "net/http"

    webmcp "github.com/seunghan91/webmcp-go"
)

func main() {
    limit := 1500
    tool, err := webmcp.NewTool(webmcp.Def{
        Name: "list_tasks",
        Description: "List at most 20 tasks for the signed-in user.",
        InputSchema: map[string]any{
            "type": "object",
            "properties": map[string]any{
                "tags": map[string]any{
                    "type": "array", "items": map[string]any{"type": "string"},
                },
                "completed": map[string]any{"type": "boolean"},
            },
        },
        Annotations: webmcp.Annotations{"read_only": true, "untrusted_content": true},
        Endpoint: webmcp.Endpoint{Path: "/api/tasks", Method: "GET"},
        MaxResponseChars: &limit,
    })
    if err != nil { log.Fatal(err) }
    registry, err := webmcp.NewRegistry(webmcp.Transport{
        CSRF: &webmcp.CSRF{Source: "meta", Name: "csrf-token", Header: "X-CSRF-Token"},
    })
    if err != nil { log.Fatal(err) }
    if err := registry.Register(tool); err != nil { log.Fatal(err) }
    registry.Freeze()

    page := template.Must(template.New("page").Parse(`<!doctype html>
<html><head><title>Tasks</title></head><body>
{{.Manifest}}{{.Runtime}}
</body></html>`))
    mux := http.NewServeMux()
    mux.Handle("/assets/webmcp-runtime.js", webmcp.RuntimeHandler())
    // Mount your existing authenticated /api/tasks endpoint on mux as well.
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        manifest, err := registry.Manifest("list_tasks")
        if err != nil { http.Error(w, err.Error(), 500); return }
        tag, err := manifest.ScriptTag(webmcp.ScriptTagOptions{})
        if err != nil { http.Error(w, err.Error(), 500); return }
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        if err := page.Execute(w, map[string]any{
            "Manifest": tag,
            "Runtime": webmcp.RuntimeScriptTag("/assets/webmcp-runtime.js", ""),
        }); err != nil { log.Print(err) }
    })
    log.Fatal(http.ListenAndServe(":8080", webmcp.OriginTrial("YOUR_TOKEN")(mux)))
}

For writes, your application must render its current CSRF token into an escaped <meta name="csrf-token" content="…">, or explicitly configure Source: "cookie" with the application's cookie name and header. Go has no implicit CSRF defaults; Transport{} contains no CSRF configuration. The copied runtime only checks for a missing token when CSRF transport is configured. Existing endpoints must always perform their own CSRF checks.

Page selection, templates and runtime

Registry.Manifest(names...) includes only listed tools in caller order. With no names it emits tools: []. Unknown names and duplicate registrations/selections return errors. The zero-value registry uses empty transport; NewRegistry validates and snapshots explicit transport. Freeze() prevents later registration.

Def.Title is an optional *string (including an explicitly empty title). Def.MaxResponseChars is an optional positive *int. Empty paramMap and absent optionals are omitted; annotations contain only true hints. Manifests implement the ordinary encoding/json struct contract. Their fingerprints include transport.

Manifest.ScriptTag defaults to data-webmcp-autostart. The embedded runtime starts when that marker is present, with no inline executable JavaScript. Pass a request's CSP nonce to both helpers:

manifestTag, err := manifest.ScriptTag(webmcp.ScriptTagOptions{Nonce: nonce})
runtimeTag := webmcp.RuntimeScriptTag("/assets/webmcp-runtime.js", nonce)
// Handle err, then pass both values to html/template.

For manual startup, set Autostart: &autostart where autostart := false, and import the runtime from an external application module:

import { mount } from "/assets/webmcp-runtime.js";
const handle = mount({ selector: "#webmcp-manifest" });
// After replacing the manifest in an SPA:
await handle.refresh();
// When the owning page/component is removed:
handle.dispose();

Autostart exposes the handle as globalThis.WebMCPRuntime.handle and dispatches webmcp:mounted. Turbo visits trigger refresh automatically. Unsupported browsers are a no-op. RuntimeHandler() serves the embedded file as text/javascript; charset=utf-8 with a quoted SHA-256 ETag and conditional GET/HEAD.

Declarative forms

Register the helpers in html/template.FuncMap:

page := template.Must(template.New("form").Funcs(template.FuncMap{
    "formAttrs": webmcp.FormAttrs,
    "paramAttr": webmcp.ParamAttr,
}).Parse(`<form action="/tasks" method="post" {{formAttrs "create_task" "Create a task" false}}>
    <input name="title" {{paramAttr "Task title"}}>
    <button type="submit">Create</button>
</form>`))

Add the application's usual CSRF form field. Helpers generate only toolname, tooldescription, the boolean toolautosubmit, and toolparamdescription. Names follow the same definition rule. Attribute values are escaped even if converted from a pre-marked-safe template.HTML string. Do not interpolate untrusted input into agent-visible descriptions.

Origin Trial

OriginTrial(token) remains backward compatible. It fills only a missing Origin-Trial header, including when a downstream handler writes the response. Existing empty headers are preserved. An empty token is a pass-through.

middleware := webmcp.OriginTrialWithOptions(token, webmcp.OriginTrialOptions{
    WarnOnOACOptOut: true,
    Logger: slog.Default(),
})
meta := webmcp.OriginTrialMetaTag(token)

The opt-in warning logs once per wrapped handler for Origin-Agent-Cluster: ?0. It never rewrites that header or forces ?1. The meta helper escapes the token and returns empty markup for an empty token.

Security model

The server remains the security boundary. Tools call existing same-origin endpoints with the current session; those endpoints must enforce authorization, CSRF, input validation, range limits and result caps. A schema maximum is metadata, not server enforcement. Annotations are hints, not security controls.

  • Endpoint paths must begin with /; protocol-relative URLs, backslashes, colons and control characters are rejected. The runtime also checks the resolved origin and uses same-origin mode/credentials with redirects rejected.
  • GET requires read_only: true; read-only POST is allowed. Other methods need a CSRF token read at invocation time. Missing tokens stop the request.
  • Only declared input keys are sent. Prototype-related keys are rejected. Explicit param_map destinations cannot collide or target reserved transport fields such as _method, authenticity_token, or csrfmiddlewaretoken.
  • JSON embedding escapes <, >, &, U+2028 and U+2029. This prevents script breakout, including mixed-case closing tags and HTML comments. Nonces and HTML attributes are independently escaped.
  • The runtime returns structured success/error envelopes and never retries. An ambiguous write result is unknown_outcome: verify with the user before retrying. A successful write with unreadable/oversized output stays successful with dataOmitted; an oversized read returns response_too_large. Responses are never silently truncated.

Tool metadata is agent-visible, not just display text. An AI agent reads tooldescription / toolparamdescription as part of its instructions for what the tool does. Do not build these strings from unvalidated user input (profile fields, query params, uploaded file names, etc.); a user-controlled value rendered into tool metadata is a prompt-injection vector that can hijack the agent's behavior. Keep tool names and descriptions as literal strings you write, not values derived at request time from data a visitor controls. Define tools at boot and freeze the registry; HTML escaping alone does not prevent prompt injection.

Conformance

The unchanged fixtures are shared with the Ruby reference. Tests load every fixture and compare parsed manifest entries, including fixed fingerprints. Expected results are never regenerated by the implementation. See conformance/README.md for canonicalization details.

The browser runtime is copied byte-for-byte from the Ruby reference and checked against conformance/RUNTIME.sha256. The Go suite also covers definition validation, immutable snapshots, page selection, XSS vectors, actual template rendering, HTTP asset serving, and middleware behavior. When Node is installed, it runs the copied runtime's GET encoder and checks the result with url.ParseQuery; otherwise only that integration test is skipped.

go vet ./...
go test ./...

Limitations

This is a 0.x subset, not a general JSON Schema rewriting engine. Root schemas allow type: "object", properties, required, and description. Properties are string, number, integer, boolean, or arrays of those scalar types. Property metadata supports enum, description, default, minimum, maximum, maxLength, and maxItems. Nested objects/arrays, $ref, composition and other keywords are rejected. Metadata numbers must be integers within +/-2^53; floats are not accepted. Use integer Go values or json.Decoder.UseNumber() when loading definitions. number inputs may still be fractional at runtime. Enum, bounds and lengths must be enforced by the endpoint.

Names must be 1–128 ASCII letters/digits or _, ., -; descriptions must be nonempty. NewTool warns through Def.Logger (or slog.Default()) above 30 name characters or 500 description characters. Only read_only, untrusted_content, consequential, and debugging annotations are accepted. ParamMap explicitly maps browser property names to endpoint parameters.

GET arrays emit arrayFormat: "repeat" by default (tags=a&tags=b), matching Go's url.Values. Set Endpoint.ArrayFormat: "brackets" for endpoints expecting tags[]=a&tags[]=b. Other methods send JSON bodies.

No cross-origin exposure, automatic response truncation, or MCP SDK bridge is provided. A from_mcp-style projection exists in the Ruby reference and is planned for Go; this phase defines browser tools explicitly and adds no SDK dependency. The spec and Origin Trial can change. Live browser/CSP verification remains outside this Go implementation's validation results.

Sibling packages: Ruby, Go, Django, Rust.

License

MIT

Documentation

Overview

Package webmcp provides framework-agnostic WebMCP manifest v1 definitions, safe template helpers, Origin-Trial middleware and an embedded browser runtime. The server's existing endpoints remain the authorization and CSRF boundary.

Index

Constants

View Source
const OriginTrialHeader = "Origin-Trial"

OriginTrialHeader is the origin trial response header name.

View Source
const Version = "0.2.0"

Version is this package's release version.

Variables

This section is empty.

Functions

func FormAttrs added in v0.2.0

func FormAttrs(tool, description string, autosubmit bool) (template.HTMLAttr, error)

FormAttrs emits only the declarative form attributes defined by WebMCP. Descriptions must be trusted application metadata, not visitor-controlled text.

func OriginTrial

func OriginTrial(token string) func(http.Handler) http.Handler

OriginTrial fills a missing Origin-Trial header when the response is written. It preserves existing headers (even empty ones) including downstream values. An empty token is a pass-through.

func OriginTrialMetaTag added in v0.2.0

func OriginTrialMetaTag(token string) template.HTML

OriginTrialMetaTag renders the token, or nothing for an empty token.

func OriginTrialWithOptions added in v0.2.0

func OriginTrialWithOptions(token string, opts OriginTrialOptions) func(http.Handler) http.Handler

OriginTrialWithOptions adds an opt-in, once-per-handler OAC warning. It never rewrites Origin-Agent-Cluster or forces ?1. Empty tokens remain pass-through.

func ParamAttr added in v0.2.0

func ParamAttr(description string) template.HTMLAttr

ParamAttr renders escaped field metadata for use in html/template.

func RuntimeHandler added in v0.2.0

func RuntimeHandler() http.Handler

RuntimeHandler serves the pinned browser runtime, with a strong SHA-256 ETag. Register it at the URL passed to RuntimeScriptTag.

func RuntimeScriptTag added in v0.2.0

func RuntimeScriptTag(src, nonce string) template.HTML

RuntimeScriptTag loads an external runtime module from the application's URL. src and nonce are escaped independently, including values cast from safe HTML.

Types

type Annotations added in v0.2.0

type Annotations map[string]bool

Annotations accepts only read_only, untrusted_content, consequential, debugging. False hints are omitted from manifests. Hints are not security controls.

type CSRF added in v0.2.0

type CSRF struct {
	Source string `json:"source"`
	Name   string `json:"name"`
	Header string `json:"header"`
}

CSRF locates a fresh token at invocation time. Source is meta or cookie.

type Def added in v0.2.0

type Def struct {
	Name             string         `json:"name"`
	Description      string         `json:"description"`
	Title            *string        `json:"title,omitempty"`
	InputSchema      map[string]any `json:"input_schema"`
	Endpoint         Endpoint       `json:"endpoint"`
	Annotations      Annotations    `json:"annotations,omitempty"`
	MaxResponseChars *int           `json:"max_response_chars,omitempty"`
	Logger           *slog.Logger   `json:"-"` // nil uses slog.Default for budget warnings.
}

Def defines the browser surface explicitly. Optional pointers distinguish absence from an empty title or an invalid zero response limit.

type Endpoint added in v0.2.0

type Endpoint struct {
	Path        string            `json:"path"`
	Method      string            `json:"method"`
	ParamMap    map[string]string `json:"param_map,omitempty"`
	ArrayFormat string            `json:"array_format,omitempty"`
}

Endpoint describes an existing same-origin application route.

type Manifest added in v0.2.0

type Manifest struct {
	WebMCPManifestVersion int              `json:"webmcpManifestVersion"`
	Transport             map[string]any   `json:"transport"`
	Tools                 []map[string]any `json:"tools"`
}

Manifest is a manifest v1 snapshot. Marshal it with encoding/json or render it with ScriptTag. Mutating a snapshot never changes the registry or tools.

func (Manifest) ScriptTag added in v0.2.0

func (m Manifest) ScriptTag(opts ScriptTagOptions) (template.HTML, error)

ScriptTag embeds JSON safely, including HTML-significant characters and U+2028/U+2029. It does not include inline executable JavaScript.

type OriginTrialOptions added in v0.2.0

type OriginTrialOptions struct {
	WarnOnOACOptOut bool
	Logger          *slog.Logger // nil uses slog.Default when warnings are enabled.
}

OriginTrialOptions enables diagnostics for older Origin-Trial builds.

type Registry added in v0.2.0

type Registry struct {
	// contains filtered or unexported fields
}

Registry stores validated tools. Its zero value uses empty transport. Register at boot; Manifest only exposes explicitly requested names. A Registry must not be copied after first use.

func NewRegistry added in v0.2.0

func NewRegistry(transport Transport) (*Registry, error)

NewRegistry validates and snapshots the transport configuration.

func (*Registry) Freeze added in v0.2.0

func (r *Registry) Freeze()

Freeze prevents further registration after application initialization.

func (*Registry) Manifest added in v0.2.0

func (r *Registry) Manifest(names ...string) (Manifest, error)

Manifest selects exactly names in caller order. No names means no tools.

func (*Registry) Register added in v0.2.0

func (r *Registry) Register(tool *Tool) error

Register adds a tool, rejecting invalid tools and duplicate names.

type ScriptTagOptions added in v0.2.0

type ScriptTagOptions struct {
	Nonce     string
	Autostart *bool
}

ScriptTagOptions controls CSP and page startup. Autostart nil defaults to true; a pointer to false omits data-webmcp-autostart.

type Tool added in v0.2.0

type Tool struct {
	// contains filtered or unexported fields
}

Tool is a validated, private snapshot; changing its Def cannot change it. Construct tools with NewTool; the zero value is not valid.

func NewTool added in v0.2.0

func NewTool(def Def) (*Tool, error)

NewTool validates a definition at boot and makes a deeply copied snapshot.

type Transport added in v0.2.0

type Transport struct {
	CSRF *CSRF `json:"csrf,omitempty"`
}

Transport has no framework defaults. Configure CSRF explicitly for writes.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL