Forge MCP is the Model Context Protocol extension for Forge, the Go backend framework. You add it to your app and every HTTP route you register shows up as an MCP-style tool, with a name, a description from the route summary and a JSON Schema for its path parameters, query and body. You can also publish MCP resources and prompts with your own Go readers and generators. The endpoints are plain JSON over HTTP, and calling a route-backed tool does not run your handler yet, so read Status before you connect an AI agent to it.
Documentation · Quick start · Features · Endpoints · Resources and prompts · Status · Configuration · FAQ
You need Go 1.26 or later and a Go module:
go get github.com/xraph/forge@latest
go get github.com/xraph/forge/extensions/mcp@latestSave this as main.go:
package main
import (
"log"
"net/http"
"github.com/xraph/forge"
"github.com/xraph/forge/extensions/mcp"
)
func main() {
app := forge.NewApp(forge.AppConfig{
Name: "orders-api",
Version: "1.0.0",
HTTPAddress: ":8080",
Extensions: []forge.Extension{
mcp.NewExtension(
mcp.WithExcludePatterns([]string{"/_/*"}),
),
},
})
r := app.Router()
err := r.GET("/orders/:id", func(ctx forge.Context) error {
return ctx.JSON(http.StatusOK, map[string]string{"id": ctx.Param("id")})
}, forge.WithSummary("Fetch one order by ID"))
if err != nil {
log.Fatal(err)
}
err = r.POST("/orders", func(ctx forge.Context) error {
return ctx.JSON(http.StatusCreated, map[string]string{"status": "created"})
}, forge.WithSummary("Create an order"))
if err != nil {
log.Fatal(err)
}
if err := app.Run(); err != nil {
log.Fatal(err)
}
}Run it and list the tools:
go mod tidy
go run .
# in another terminal
curl http://localhost:8080/_/mcp/info
curl http://localhost:8080/_/mcp/tools/_/mcp/info returns the server name and version (the app's, unless you set your own) and its capabilities. /_/mcp/tools returns two tools, sorted by name:
{"tools":[
{"name":"create_orders","description":"Create an order","inputSchema":{"type":"object","properties":{"body":{"type":"object","description":"Request body"},"query":{"type":"object","description":"Query parameters (optional)","additionalProperties":true}}}},
{"name":"get_orders_id","description":"Fetch one order by ID","inputSchema":{"type":"object","properties":{"id":{"type":"string","description":"Path parameter: id"},"query":{"type":"object","description":"Query parameters (optional)","additionalProperties":true}},"required":["id"]}}
]}The exclude pattern keeps Forge's own /_/health, /_/metrics and friends out of the list. The MCP endpoints under the base path are always skipped.
| You need | Forge MCP gives you |
|---|---|
| A tool catalogue for an AI assistant | Every route becomes a tool at startup, named from its method and path, described by its WithSummary text |
| Input schemas | A JSON Schema per tool with required path parameters, a query object, and a body object for POST, PUT and PATCH |
| Control over what's exposed | ExcludePatterns and IncludePatterns with exact paths or /prefix/* globs, plus a ToolPrefix for names |
| MCP resources | RegisterResource and RegisterResourceReader, served from /resources and /resources/read |
| MCP prompts | RegisterPrompt and RegisterPromptGenerator, served from /prompts and /prompts/:name |
| Deterministic output | Tool lists are sorted by name, and query strings and prompt arguments are built in sorted key order |
| Metrics | mcp_tools_total and mcp_tool_calls_total{status} through Forge's metrics |
With the default base path /_/mcp:
| Method and path | What it does |
|---|---|
GET /_/mcp/info |
Server name, version and capabilities |
GET /_/mcp/tools |
{"tools": [...]} |
POST /_/mcp/tools/:name |
Calls a tool. Body: {"name": "...", "arguments": {...}}. See Status |
GET /_/mcp/resources |
Lists resources, when EnableResources is on |
POST /_/mcp/resources/read |
Reads one. Body: {"uri": "..."} |
GET /_/mcp/prompts |
Lists prompts, when EnablePrompts is on |
POST /_/mcp/prompts/:name |
Renders one. Body: {"arguments": {...}} |
Tool names are the HTTP verb mapped to a word (GET becomes get, POST becomes create, PUT becomes update, PATCH becomes patch, DELETE becomes delete), then the path with slashes turned into underscores and the : or {} around parameters dropped. So DELETE /users/:id is delete_users_id, and with WithToolPrefix("shop_") it's shop_delete_users_id. A name longer than MaxToolNameLength (64) is skipped with a warning in the log.
Resources and prompts are the parts you fill in yourself. Turn them on, then register them from a start hook, once the MCP service exists:
app := forge.NewApp(forge.AppConfig{
Name: "orders-api",
Extensions: []forge.Extension{
mcp.NewExtension(mcp.WithResources(true), mcp.WithPrompts(true)),
},
})
err := forge.OnStarted(app, "mcp-content", func(ctx context.Context, a forge.App) error {
svc, err := forge.Inject[*mcp.MCPService](a.Container())
if err != nil {
return err
}
srv := svc.Server()
if err := srv.RegisterResource(&mcp.Resource{
URI: "orders://schema",
Name: "Order schema",
MimeType: "application/json",
}); err != nil {
return err
}
if err := srv.RegisterResourceReader("orders://schema",
func(ctx context.Context, r *mcp.Resource) (mcp.Content, error) {
return mcp.Content{Type: "text", MimeType: r.MimeType, Text: `{"id":"string","total":"number"}`}, nil
}); err != nil {
return err
}
if err := srv.RegisterPrompt(&mcp.Prompt{
Name: "summarize_order",
Description: "Summarize an order for a support agent",
Arguments: []mcp.PromptArgument{{Name: "id", Required: true}},
}); err != nil {
return err
}
return srv.RegisterPromptGenerator("summarize_order",
func(ctx context.Context, p *mcp.Prompt, args map[string]any) ([]mcp.PromptMessage, error) {
return []mcp.PromptMessage{{
Role: "user",
Content: []mcp.Content{{Type: "text", Text: fmt.Sprintf("Summarize order %v in two lines.", args["id"])}},
}}, nil
})
})Then POST /_/mcp/resources/read with {"uri":"orders://schema"} runs your reader, and POST /_/mcp/prompts/summarize_order with {"arguments":{"id":"42"}} runs your generator. A resource without a reader returns its name, URI and description as text. A prompt without a generator returns one user message built from its name, arguments and description.
You should know three things before you build on this extension.
Calling a route-backed tool does not call your route. POST /_/mcp/tools/get_orders_id with {"arguments":{"id":"42"}} substitutes the path parameters and query, then returns the text Tool executed: GET /orders/42 as the tool result. Your handler never runs. Tools you add by hand with Server().RegisterTool are listed but cannot be called at all, because they have no route behind them.
The transport is plain JSON over HTTP. There is no JSON-RPC 2.0 framing, no initialize handshake, and no stdio or Streamable HTTP transport, so an MCP client such as Claude Desktop cannot connect to these endpoints directly. You would need a small adapter in between.
WithAuth and WithRateLimit set config fields and validation checks them (auth needs at least one token), but the extension does not apply them to its endpoints. AuthMiddleware(cfg, logger) and RateLimitMiddleware(cfg, logger, metrics) are exported as forge.PureMiddleware for you to wire up, or you can put the base path behind your gateway's auth. Until then, treat /_/mcp as public.
You can configure the extension in code with options, or in YAML under extensions.mcp (the extension also reads a top-level mcp key).
| Option | Sets | Default |
|---|---|---|
WithEnabled(bool) |
Enabled. When false the extension registers nothing |
true |
WithBasePath(string) |
BasePath |
/_/mcp |
WithServerInfo(name, version) |
ServerName, ServerVersion |
the app's name and version |
WithAutoExposeRoutes(bool) |
AutoExposeRoutes |
true |
WithToolPrefix(string) |
ToolPrefix |
empty |
WithExcludePatterns([]string) |
ExcludePatterns, checked first |
none |
WithIncludePatterns([]string) |
IncludePatterns. When set, only matching routes are exposed |
none |
WithResources(bool), WithPrompts(bool) |
EnableResources, EnablePrompts |
false |
WithAuth(header, tokens) |
RequireAuth, AuthHeader, AuthTokens (see Status) |
off, Authorization |
WithRateLimit(int) |
RateLimitPerMinute (see Status) |
0, unlimited |
WithRequireConfig(bool) |
Fail at startup if no YAML config is found | false |
WithConfig(mcp.Config) |
Replaces the whole config |
MaxToolNameLength (64), SchemaCache (true) and EnableMetrics (true) have no option of their own; set them through WithConfig. NewExtensionWithConfig(cfg) is the same as NewExtension(WithConfig(cfg)).
The YAML keys are the snake_case field names:
extensions:
mcp:
tool_prefix: shop_
exclude_patterns: ["/_/*", "/internal/*"]
enable_prompts: trueOlder configs spelled them as Go field names (ToolPrefix). Those still work, and each one logs a warning at startup telling you the new key.
Options you set in code win over the file, and the file wins over DefaultConfig(), so BasePath: /mcp or Enabled: false in YAML takes effect with a plain mcp.NewExtension(). Fields you leave out keep their defaults. One edge case: an option set to exactly its default value looks the same as no option at all, so the file wins for that field.
Forge reads config.yaml only when EnableConfigAutoDiscovery is on. forge.DefaultAppConfig() turns it on, but an AppConfig literal like the one in the quick start leaves it off.
| Topic | Where |
|---|---|
| Overview | MCP docs |
| Features and usage | MCP features |
| Configuration reference | MCP configuration |
| Function reference | MCP functions |
| Go API | pkg.go.dev |
| The protocol itself | Model Context Protocol specification |
The MCP server example is a user-management API with OpenAPI turned on and the MCP extension exposing its routes. It has its own go.mod, so run it from its directory with GOWORK=off go run ., then query /_/mcp/tools on port 8087.
Add mcp.NewExtension() to your Forge app and give your routes a forge.WithSummary. The tool list at /_/mcp/tools then describes every route with a name and input schema. Read Status first: tool calls do not run your handlers yet, and the transport is not the JSON-RPC one MCP clients speak.
Not directly. The endpoints are JSON over HTTP without the JSON-RPC 2.0 framing or the stdio and Streamable HTTP transports the protocol defines, so you would need an adapter.
Use WithExcludePatterns([]string{"/_/*", "/internal/*"}). A pattern is an exact path or a prefix ending in /*. If you'd rather list what's allowed, use WithIncludePatterns and only matching routes become tools.
Not by default, and the auth settings are not enforced on the endpoints today. Wire mcp.AuthMiddleware yourself or protect the base path at your gateway.
MCP is its own Go module inside the Forge repository. Build, vet and test it from its directory the way CI does:
cd extensions/mcp
GOWORK=off go build ./...
GOWORK=off go vet ./...
GOWORK=off go test -short -count=1 ./...
golangci-lint run ./...Forge's AGENTS.md has the pinned lint version and the rest of the repository conventions. Commits follow Conventional Commits, for example fix(mcp): ....
Forge MCP is part of Forge and is available under the Apache License 2.0.