MCP Server

MCP (Model Context Protocol) Server #

The INFINI Framework provides built-in Model Context Protocol (MCP) server support, enabling AI assistants like Claude Desktop, Cursor, and any MCP-compatible client to interact with your application’s APIs as callable tools.

Overview #

The framework offers two complementary patterns for exposing MCP tools:

  • Auto-exposure (recommended): Annotate existing REST routes with api.MCPTool() — the framework automatically generates tool schemas, handles JSON-RPC dispatch, and enforces per-tool RBAC.
  • Standalone server: Build a custom MCPServer with typed tool schemas for fine-grained control over parameter descriptions, read-only hints, and loopback handlers.

Both patterns mount on the Streamable HTTP transport at /mcp by default.

Quick Start #

1. Enable MCP in config #

web:
  mcp:
    enabled: true
    # base_path: /mcp       # default endpoint path
    # stateless: true       # stateless mode (no session management)
    # name: "My App"        # server name shown to MCP clients
    # version: "1.0.0"      # server version

2. Annotate routes with MCPTool #

api.HandleUIMethod(api.GET, "/api/things", handler.ListThings, api.RequireLogin(),
    api.MCPTool("list_things", "List all things with optional filtering"))

3. Connect an MCP client #

{
  "mcpServers": {
    "my-app": {
      "url": "http://localhost:29000/mcp",
      "transport": "streamable-http"
    }
  }
}

That’s it — the framework auto-mounts the MCP server, generates tool schemas from your routes, and enforces authentication.


Auto-Exposure Mode #

How it works #

When web.mcp.enabled is true, the framework:

  1. Creates a StreamableHTTPServer and mounts it at web.mcp.base_path (default /mcp).
  2. Walks all registered UI routes tagged with MCPTool (or MCPAuto).
  3. For each tagged route, auto-generates a tool with:
    • Name: the name argument from MCPTool(), or auto-derived from method+path (e.g. get_things_id).
    • Description: the description argument, or a default.
    • Input schema: structured object with path_params, query, headers, body, raw_body.
  4. Each tool call bridges to the real HTTP route via an in-process handler (no network hop).

Minimal example — GET with no parameters #

api.HandleUIMethod(api.GET, "/api/status", handler.GetStatus, api.RequireLogin(),
    api.MCPTool("get_status", "Get the current system status"))

The MCP client sees a tool named get_status with no required arguments.

Path parameters #

api.HandleUIMethod(api.GET, "/api/things/:id", handler.GetThing, api.RequireLogin(),
    api.MCPTool("get_thing", "Get details of a specific thing by ID"))

The tool auto-includes a path_params argument:

{
  "path_params": { "id": "thing-123" }
}

Query parameters #

api.HandleUIMethod(api.GET, "/api/things", handler.ListThings, api.RequireLogin(),
    api.MCPTool("list_things", "List things, optionally filtered by status"))

The client can pass:

{
  "query": { "status": "active", "page": "1" }
}

POST with body #

api.HandleUIMethod(api.POST, "/api/things", handler.CreateThing, api.RequireLogin(),
    api.MCPTool("create_thing", "Create a new thing"))

The client sends:

{
  "body": { "name": "My Thing", "type": "example" }
}

Auto-generated input schema #

For each auto-exposed tool, the framework synthesizes a JSON-schema object:

PropertyTypeDescription
path_paramsobjectURL path parameters (e.g. {id} from /things/:id)
queryobjectURL query string parameters
headersobjectCustom HTTP headers to forward
bodyobjectJSON request body (parsed object)
raw_bodystringRaw request body (for non-JSON payloads)

All properties are optional (additionalProperties: true). The MCP client chooses which to populate.

Tool result structure #

Each tool call returns a structured result:

{
  "status_code": 200,
  "headers": { "Content-Type": "application/json" },
  "body": "{\"id\":\"thing-123\",\"name\":\"...\"}",
  "json": { "id": "thing-123", "name": "..." }
}
  • json is populated only when the response body is valid JSON.
  • HTTP status >= 400 sets isError: true on the tool result.

Configuration Reference #

All MCP settings live under web.mcp in the YAML config:

web:
  mcp:
    enabled: true            # Enable the MCP server (default: false)
    base_path: /mcp          # HTTP endpoint path (default: /mcp)
    name: "My App"           # Server name shown to MCP clients
    version: "1.0.0"         # Server version
    stateless: true          # Stateless mode — no session tracking
    disable_streaming: false # Disable SSE streaming (use plain HTTP responses)
FieldDefaultDescription
enabledfalseMaster switch for the MCP server
base_path/mcpThe HTTP path where the MCP endpoint is mounted
name"<AppName> UI"Server name in MCP protocol handshake
versionApp versionServer version in MCP protocol handshake
statelessfalseStateless mode — each request is independent, no session ID tracking
disable_streamingfalseDisable Server-Sent Events streaming transport

Permission Control #

MCP tools inherit the same RBAC as their underlying routes. When a route is registered with api.RequirePermission(...), the MCP authorization filter automatically:

  1. Hides the tool from clients who lack permission (filtered out of tools/list).
  2. Blocks calls to the tool with a “permission denied” error.
api.HandleUIMethod(api.POST, "/api/things/:id/delete", handler.DeleteThing,
    api.RequireLogin(),
    api.RequirePermission("things", "delete"),
    api.MCPTool("delete_thing", "Delete a thing by ID"))

The built-in MCPAuthorizer filter validates the user’s session from the MCP client’s HTTP headers and checks permissions against the route’s RequirePermission keys. Admin role bypasses all checks.

Custom authorizer #

Register a custom authorizer for advanced scenarios:

api.RegisterMCPToolAuthorizer(func(headers http.Header, options *api.HandlerOptions) bool {
    // Return false to hide/block this tool for this request
    token := headers.Get("Authorization")
    return validateToken(token)
})

Custom Tool Schemas #

For routes where the auto-generated schema is too generic, provide explicit JSON schemas:

api.HandleUIMethod(api.POST, "/api/search", handler.Search,
    api.RequireLogin(),
    api.MCPTool("search", "Full-text search across all documents"),
    api.Label(api.MCPToolInputSchema, json.RawMessage(`{
        "type": "object",
        "properties": {
            "query": { "type": "string", "description": "Search query text" },
            "limit": { "type": "integer", "description": "Max results (default 10)", "default": 10 }
        },
        "required": ["query"]
    }`)),
)

When a custom MCPToolInputSchema is provided, it replaces the auto-generated schema entirely.


Standalone Server Mode #

When you need full control over tool definitions — typed parameters, read-only hints, custom descriptions — build a standalone MCPServer and mount it manually.

When to use this mode #

  • You want typed, named parameters (not generic path_params/query/body)
  • You need readOnlyHint / destructiveHint annotations for GET vs POST
  • You want a separate tool catalog from your REST routes
  • You need loopback handlers that transform parameters before calling internal APIs

Complete example #

import (
    "github.com/mark3labs/mcp-go/mcp"
    "github.com/mark3labs/mcp-go/server"
    "infini.sh/framework/core/api"
)

// 1. Define your tools with typed parameters.
type toolDef struct {
    name   string
    desc   string
    method string
    path   string
    params []paramDef
}

type paramDef struct {
    name     string
    desc     string
    required bool
}

var myTools = []toolDef{
    {
        name:   "search_logs",
        desc:   "Search logs by keyword and time range",
        method: "GET",
        path:   "/api/logs",
        params: []paramDef{
            {name: "q", desc: "Search keyword", required: true},
            {name: "from", desc: "Start time (ISO 8601)"},
            {name: "to", desc: "End time (ISO 8601)"},
        },
    },
}

// 2. Build the MCP server with typed tool schemas.
func buildMCPServer() *server.MCPServer {
    s := server.NewMCPServer("MyApp", "1.0.0",
        server.WithToolCapabilities(true),
        server.WithInstructions("MyApp API — manage things and search logs."),
    )
    for _, def := range myTools {
        def := def
        s.AddTool(buildTool(def), makeHandler(def))
    }
    return s
}

// 3. Generate typed tool schemas with read-only hints.
func buildTool(def toolDef) mcp.Tool {
    opts := []mcp.ToolOption{mcp.WithDescription(def.desc)}
    for _, p := range def.params {
        propOpts := []mcp.PropertyOption{mcp.Description(p.desc)}
        if p.required {
            propOpts = append(propOpts, mcp.Required())
        }
        opts = append(opts, mcp.WithString(p.name, propOpts...))
    }
    // Mark GET tools as read-only for client-side confirmation behavior.
    if def.method == "GET" {
        opts = append(opts,
            mcp.WithReadOnlyHintAnnotation(true),
            mcp.WithDestructiveHintAnnotation(false),
        )
    }
    return mcp.NewTool(def.name, opts...)
}

// 4. Bridge tool calls to your internal REST API via loopback HTTP.
func makeHandler(def toolDef) server.ToolHandlerFunc {
    return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        // Build URL with query params from typed arguments.
        params := req.Params.Arguments.(map[string]interface{})
        u, _ := url.Parse("http://127.0.0.1:29000" + def.path)
        q := u.Query()
        for _, p := range def.params {
            if v, ok := params[p.name]; ok {
                q.Set(p.name, fmt.Sprintf("%v", v))
            }
        }
        u.RawQuery = q.Encode()

        // Call your own REST API.
        resp, err := http.Get(u.String())
        if err != nil {
            return mcp.NewToolResultError(err.Error()), nil
        }
        defer resp.Body.Close()
        body, _ := io.ReadAll(resp.Body)
        if resp.StatusCode >= 400 {
            return mcp.NewToolResultError(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body))), nil
        }
        return mcp.NewToolResultText(string(body)), nil
    }
}

// 5. Mount the server in your route registration.
func setupRoutes(apiServer *api.Handler) {
    mcpServer := server.NewStreamableHTTPServer(buildMCPServer(), server.WithStateLess(true))
    api.HandleUIFuncMethod(api.POST, "/mcp", mcpServer.ServeHTTP)
    // GET returns a help page for browser visitors.
    api.HandleUIFuncMethod(api.GET, "/mcp", serveHelpPage)
}

Browser Compatibility #

Since /mcp is an MCP endpoint (POST + JSON-RPC), browser GET requests will not produce a useful response. Register a GET handler that returns a helpful HTML page:

api.HandleUIFuncMethod(api.GET, "/mcp", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`<h1>MCP Server</h1>
<p>This endpoint is for MCP clients. Use POST with Accept: application/json.</p>`))
})

For invalid POST requests (missing the MCP Accept header), use a global interceptor:

type mcpInterceptor struct{}

func (i *mcpInterceptor) Match(r *http.Request) bool {
    if r.URL.Path != "/mcp" || r.Method != http.MethodPost {
        return false
    }
    accept := r.Header.Get("Accept")
    return !strings.Contains(accept, "application/json") &&
           !strings.Contains(accept, "text/event-stream")
}

func (i *mcpInterceptor) PreHandle(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, error) {
    // Return HTML help page for non-MCP POST requests.
    serveHelpPage(w, r)
    return ctx, fmt.Errorf("response sent") // stop the handler chain
}

func (i *mcpInterceptor) PostHandle(_ context.Context, _ http.ResponseWriter, _ *http.Request) {}
func (i *mcpInterceptor) Name() string { return "mcp-browser-compat" }

// Register:
api.AddGlobalInterceptors(&mcpInterceptor{})

Client Configuration #

Claude Desktop #

Edit claude_desktop_config.json:

{
  "mcpServers": {
    "my-app": {
      "url": "http://localhost:29000/mcp",
      "transport": "streamable-http"
    }
  }
}

Cursor #

Add to Cursor’s MCP settings:

{
  "mcpServers": {
    "my-app": {
      "url": "http://localhost:29000/mcp"
    }
  }
}

curl #

Test the MCP server with curl:

# Initialize handshake
curl -X POST http://localhost:29000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2024-11-05",
      "capabilities": {},
      "clientInfo": {"name": "test", "version": "1.0"}
    }
  }'

# List available tools
curl -X POST http://localhost:29000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/list",
    "params": {}
  }'

# Call a tool
curl -X POST http://localhost:29000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{
    "jsonrpc": "2.0",
    "id": 3,
    "method": "tools/call",
    "params": {
      "name": "list_things",
      "arguments": {
        "query": { "status": "active" }
      }
    }
  }'

Testing #

Unit test pattern #

Test auto-exposed MCP routes using the internal callMCPAutoUIRoute function:

func TestMyMCPTool(t *testing.T) {
    // Register the route (done in your init/setup).
    api.HandleUIMethod(api.GET, "/api/things/:id", testHandler,
        api.MCPTool("get_thing", "Get a thing"))

    // Simulate a tool call.
    result, err := api.CallMCPAutoUIRoute("get_thing", mcp.CallToolParams{
        Arguments: map[string]interface{}{
            "path_params": map[string]interface{}{"id": "thing-1"},
            "query":       map[string]interface{}{"detail": "full"},
        },
    })

    require.NoError(t, err)
    require.False(t, result.IsError)

    // The structured content contains the HTTP response.
    content := result.Content[0].(mcp.TextContent)
    assert.Contains(t, content.Text, "thing-1")
}

Testing authorization #

func TestMCPToolRequiresAuth(t *testing.T) {
    // Without auth headers, the tool call should be denied.
    result, _ := api.CallMCPAutoUIRoute("get_thing", mcp.CallToolParams{
        Arguments: map[string]interface{}{
            "path_params": map[string]interface{}{"id": "thing-1"},
        },
    })
    assert.True(t, result.IsError)
}

API Reference #

Options #

FunctionDescription
api.MCPTool(name, description string)Mark a route as an MCP tool with a custom name and description
api.MCPAuto()Mark a route for auto MCP exposure (name/description auto-generated)
api.Label(api.MCPToolInputSchema, schema)Override the auto-generated input schema
api.Label(api.MCPToolOutputSchema, schema)Set a custom output schema
api.RequirePermission(resource, action)Enforce per-tool RBAC (inherited by MCP layer)

Server functions #

FunctionDescription
api.RegisterMCPToolAuthorizer(fn)Register a custom tool authorization filter
api.WalkMCPAutoUIMethodRoutes(fn)Iterate over all MCP-tagged routes (for introspection)
api.WalkRegisteredUIMethodRoutes(fn)Iterate over all registered UI routes

Constants #

ConstantDescription
api.FeatureMCPAutoFeature flag for auto MCP exposure
api.MCPToolNameLabel key for tool name
api.MCPToolDescriptionLabel key for tool description
api.MCPToolInputSchemaLabel key for custom input schema
api.MCPToolOutputSchemaLabel key for custom output schema
Edit Edit this page