webmcp

package module
v0.2.2 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

Go Reference CI

Server-side WebMCP toolkit for Go net/http — standard library only.

WebMCP is a W3C Community Group proposal that lets a web page register tools an in-browser AI agent can call through document.modelContext. This module is the server side of that: you define tools where your app already knows its routes, sessions and permissions, and a small browser runtime registers them on the pages you choose. When an agent calls a tool, the runtime calls your existing same-origin endpoint with the user's session and CSRF token, so authentication and authorization stay in your app.

  • Tool definitions — NewTool and NewRegistry, validated against the spec's naming, annotation and schema rules.
  • Manifests — Registry.Manifest(names...) for per-page opt-in and Manifest.ScriptTag for script-safe embedding in html/template.
  • Browser runtime — embedded with embed; serve it with RuntimeHandler() and reference it with RuntimeScriptTag (CSP nonce aware).
  • Declarative forms — FormAttrs and ParamAttr for toolname, tooldescription, toolautosubmit, toolparamdescription.
  • Origin Trial — OriginTrial / OriginTrialWithOptions middleware and OriginTrialMetaTag.
go get github.com/seunghan91/webmcp-go@latest

Status: 0.x, tracking the WebMCP Draft CG Report of 2026-10-02. WebMCP runs behind a Chrome origin trial (Chrome 149–156, extension requested to 162) or the chrome://flags/#enable-webmcp-testing flag. The shared runtime is tested in real Chrome 154 by the Ruby reference suite (CSRF-protected writes, blocked redirects, HTTP errors, Turbo navigation, strict CSP).

Language Package Registry
Ruby / Rails (reference) webmcp RubyGems
Go (net/http) webmcp-go pkg.go.dev
Python / Django webmcp-django PyPI
Rust webmcp crates.io

All four emit the same manifest v1 (checked against shared conformance fixtures, fingerprints included) and ship the byte-identical browser runtime.

Coding agents: start with AGENTS.md. LLM summary: llms.txt.

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.

Troubleshooting

Symptom (exact text) Cause Fix
document.modelContext is undefined WebMCP is unavailable or the page is not a secure context Chrome 149+ with the origin trial token (header or <meta http-equiv="origin-trial">) or chrome://flags/#enable-webmcp-testing; serve over HTTPS or localhost
NotAllowedError from registerTool The document may not use the tools Permissions Policy feature (default allowlist self) For cross-origin frames delegate with allow="tools" and make sure ancestor Permissions-Policy headers permit it
UnknownError: Failed to parse input arguments from executeTool Chrome 154 and earlier accept only a JSON string input; object input ships in Chrome 155 Agent side: pass JSON.stringify(input) on Chrome ≤ 154. The runtime's execute receives an object either way
SecurityError from registerTool on an older trial build The response sent Origin-Agent-Cluster: ?0 (requirement removed from the spec on 2026-09-30, still enforced by older builds) Stop sending ?0; enable the OAC opt-out warning to find it
Console: WebMCP: could not register tool "<name>" with InvalidStateError Another script in the same document already registered that name Use unique names; names are unique per document, not per site
Console: WebMCP: unsupported manifest version; no tools registered. Runtime and manifest come from different package versions Upgrade so both use manifest v1 and the same runtime
Tool result error.code: "csrf_token_missing" A non-GET tool (including a read-only POST) has CSRF transport configured but no readable token on the page Render the configured token (Rails csrf_meta_tags, Django {% webmcp_csrf_meta %}, Go/Rust: your own escaped <meta name="csrf-token">) or configure a readable cookie source. Without CSRF transport the runtime skips this check
error.code: "invalid_input" The agent sent an undeclared parameter, a missing required one, or the wrong scalar type Fix the schema or descriptions; the runtime forwards declared parameters only
error.code: "unknown_outcome" A write request failed after dispatch: network error, abort, or a redirect (redirects are never followed) Make the endpoint answer without redirecting (e.g. 401 JSON instead of redirecting to sign-in); never retry automatically
error.code: "network_error" on a read Network failure or redirect rejection after dispatch (an aborted read returns aborted) Check connectivity and answer JSON without redirects; reads may be retried
error.code: "response_too_large" Read response exceeded maxResponseChars Narrow the query or raise the limit; responses are never truncated
dataOmitted: "invalid_response" on a write The endpoint returned 2xx with a non-JSON body Return JSON from write endpoints
Tools from the previous page remain, or none appear, after client-side navigation Turbo is handled automatically; other SPAs (Inertia, React routers) are not After the SPA replaces or removes #webmcp-manifest, await WebMCPRuntime.handle.refresh(). webmcp:mounted only delivers the initial handle. If the first page has no manifest, mount the runtime from your app entry (mount()) and keep that handle
A string-returning tool yields hi instead of "hi" Chrome 154 does not JSON-quote string results (spec says it should) The shared runtime always returns an envelope object, so this only affects hand-written tools
Definition error at boot such as GET endpoints require read_only: true The definition violates a rule above Fix the definition; errors are raised at boot on purpose

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. Real-browser and CSP checks run against the shared runtime in the Ruby reference suite; Go tests cover definitions, manifests, escaping, middleware and the embedded runtime bytes.

Sibling packages: Ruby, Go, Django, Rust.

License

MIT

Documentation

Overview

Package webmcp is a server-side toolkit for WebMCP, the W3C Community Group proposal that lets a web page register tools an in-browser AI agent can call through document.modelContext (registerTool, getTools, executeTool).

WebMCP is not the server-to-server Model Context Protocol (MCP). An MCP server is called by desktop or CLI agents over stdio or HTTP; WebMCP tools live in a browser tab and run with the signed-in user's session. This package covers the server half of WebMCP for net/http applications, using the standard library only:

  • NewTool validates a tool definition at boot (name 1–128 of [A-Za-z0-9_.-], WebMCP annotations read_only/untrusted_content/consequential/debugging, a strict JSON Schema subset, a same-origin endpoint, GET only when read_only).
  • Registry.Manifest selects the tools a page exposes; Manifest.ScriptTag renders them as a script-safe <script type="application/json"> element.
  • RuntimeHandler serves the shared browser runtime (embedded, zero dependencies). It registers the manifest's tools and, when an agent calls one, calls the tool's endpoint with the user's session cookies and CSRF token, without following redirects and without retries.
  • FormAttrs and ParamAttr render the declarative form attributes (toolname, tooldescription, toolautosubmit, toolparamdescription).
  • OriginTrial and OriginTrialMetaTag deliver the Chrome origin trial token.

Your endpoints keep authentication, authorization, CSRF checks and input validation; annotations are hints, not security controls.

The same manifest format and runtime ship in the Ruby (reference), Django and Rust packages: https://github.com/seunghan91/webmcp. Spec baseline: WebMCP Draft CG Report 2026-10-02.

Example

Define a read-only tool, select it for a page, and render the manifest. The page also needs RuntimeScriptTag pointing at RuntimeHandler.

package main

import (
	"encoding/json"
	"fmt"

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

func main() {
	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{
				"completed": map[string]any{"type": "boolean"},
			},
		},
		Annotations: webmcp.Annotations{"read_only": true, "untrusted_content": true},
		Endpoint:    webmcp.Endpoint{Path: "/api/tasks", Method: "GET"},
	})
	if err != nil {
		panic(err)
	}
	registry, err := webmcp.NewRegistry(webmcp.Transport{
		CSRF: &webmcp.CSRF{Source: "meta", Name: "csrf-token", Header: "X-CSRF-Token"},
	})
	if err != nil {
		panic(err)
	}
	if err := registry.Register(tool); err != nil {
		panic(err)
	}
	registry.Freeze()

	manifest, err := registry.Manifest("list_tasks")
	if err != nil {
		panic(err)
	}
	var parsed struct {
		Tools []struct {
			Name        string          `json:"name"`
			Annotations map[string]bool `json:"annotations"`
			Endpoint    struct{ Path, Method string }
		} `json:"tools"`
	}
	raw, _ := json.Marshal(manifest)
	_ = json.Unmarshal(raw, &parsed)
	first := parsed.Tools[0]
	fmt.Println(first.Name, first.Endpoint.Method, first.Endpoint.Path, first.Annotations)
}
Output:
list_tasks GET /api/tasks map[readOnlyHint:true untrustedContentHint:true]

Index

Examples

Constants

View Source
const OriginTrialHeader = "Origin-Trial"

OriginTrialHeader is the origin trial response header name.

View Source
const Version = "0.2.2"

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.

Example (GetRequiresReadOnly)

A GET endpoint must be read-only; NewTool rejects the definition at boot.

package main

import (
	"fmt"

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

func main() {
	_, err := webmcp.NewTool(webmcp.Def{
		Name:        "delete_task",
		Description: "Delete one task.",
		InputSchema: map[string]any{"type": "object"},
		Endpoint:    webmcp.Endpoint{Path: "/api/tasks/delete", Method: "GET"},
	})
	fmt.Println(err != nil)
}
Output:
true

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