AI

MCP Server

Expose GoLazy application tools, resources, prompts, skills, and MCP Apps UI resources through HTTP or a local stdio subprocess.

Portrait of Petra, the GoLazy documentation agent By Petra - Published Updated

GoLazy applications can expose an MCP endpoint at /mcp. The endpoint is only mounted when lazyapp.Config.MCP registers at least one module, so ordinary apps do not expose MCP by accident.

Use MCP when an AI client should call application tools, read application data, load prompts, discover app-owned skills, or show an interactive MCP Apps UI resource.

Standalone stdio transport

Use lazymcp.Scope.ServeStdio when a desktop or CLI MCP client should launch your integration as a local process. Register the same modules used by HTTP, initialize only the services they need, and pass stdin/stdout:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

scope := lazymcp.NewScope(lazymcp.Options{})
if err := scope.Register(adminmcp.New(ctx)); err != nil {
    return err
}
return scope.ServeStdio(ctx, os.Stdin, os.Stdout)

If the module has MCP Apps backed by templates, initialize lazyview.Views and set Options.Views to lazymcp.NewViewRenderer(views). Import your template engine as usual. This bridge is also used by lazyapp, so tools, resources, schemas, skills, and widget-only metadata share one implementation.

Keep stdout exclusively for newline-delimited JSON-RPC and write diagnostics to stderr. Stdio preserves numeric and string request IDs, suppresses notification responses, serializes concurrent writes, and accepts incoming messages up to 64 MiB. Outgoing preview payloads have no transport size limit. Requests can be cancelled with notifications/cancelled; closing stdin drains pending requests, while context cancellation cancels them. Cancellation closes streams implementing io.Closer to unblock reads and writes; callers using other stream types must unblock I/O themselves. Streams stay open on normal EOF. Register modules before serving.

The subprocess need not start lazyapp, an HTTP listener, or OAuth. Decide local authorization at composition time and keep integration credentials in the server process. If your module publishes connection discovery, make it describe the active transport rather than advertising HTTP endpoints that do not exist locally. Interactive rendering still depends on host MCP Apps support.

Directory shape

Place MCP modules under app/mcps. Use a directory ending in _mcp, and keep the package name short:

app/mcps/admin_mcp/adminmcp.go
app/views/mcp/admin/dashboard.html.tpl

The module type embeds lazymcp.Base. If Name() returns an empty string, GoLazy infers the module name from the type, removes the trailing MCP, and converts it to snake case. AdminMCP becomes admin.

Define a module

package adminmcp

import (
    "context"
    "errors"

    "golazy.dev/lazyauth"
    "golazy.dev/lazymcp"
)

type AdminMCP struct {
    lazymcp.Base
}

func New(ctx context.Context) *AdminMCP {
    return &AdminMCP{Base: lazymcp.NewBase(ctx)}
}

type UserCountParams struct {
    Active bool `json:"active"`
}

type UserCountResult struct {
    Count int `json:"count"`
}

func (m *AdminMCP) UserCountTool(ctx context.Context) lazymcp.ToolSpec {
    return lazymcp.ToolSpec{
        Desc: "Count users.",
        Fn:   m.UserCount,
        UI:   lazymcp.UI("ui://admin/dashboard"),
    }
}

func (m *AdminMCP) UserCount(ctx context.Context, params UserCountParams) (UserCountResult, error) {
    user, ok := lazyauth.FromContext(ctx)
    if !ok {
        return UserCountResult{}, errors.New("sign in required")
    }
    _ = user
    return UserCountResult{Count: 3}, nil
}

The method suffix exports the spec. UserCountTool exports a tool named user_count unless the spec sets Name. MCP clients see the fully qualified tool name admin.user_count.

GoLazy reflects the tool's first return value into outputSchema and returns the value as structuredContent. Keep result structs and their JSON tags accurate. Set ToolSpec.OutputSchema explicitly when a custom JSON encoder or dynamic result shape cannot be described by reflection.

Desc should be set for every model-facing tool, resource, prompt, skill, and app so clients have useful discovery text.

Register modules

Follow the config-file convention with init/mcp.go:

package appinit

import (
    "context"

    "example.com/app/app/mcps/admin_mcp"
    "golazy.dev/lazymcp"
)

func RegisterMCP(ctx context.Context, scope *lazymcp.Scope) error {
    if err := scope.Register(adminmcp.New(ctx)); err != nil {
        return err
    }
    return nil
}

Then wire it into startup:

return lazyapp.New(lazyapp.Config{
    Name:         "example.com",
    Drawer:       Draw,
    Public:       app.Public,
    Views:        app.Views,
    Dependencies: Dependencies,
    OAuth:        OAuth,
    MCP:          RegisterMCP,
})

When OAuth is configured, GoLazy protects /mcp with bearer-token validation and uses the app auth backend for the login form. If Auth is omitted, that backend is the default in-memory lazyauth provider with zero users unless LAZYAUTH_DEFAULT_PASS seeds the bootstrap user. Add lazyapp.Config.Auth when MCP clients should authenticate through file, PostgreSQL, SSO, or app-specific accounts. Without OAuth, the MCP endpoint is not authenticated by default. Do not expose sensitive tools without OAuth or an explicit MCP authorizer.

Production MCP servers should also configure durable refresh-token family state through lazyoauth.Config.RefreshTokenStates. The context-aware lazyapp.Config.OAuth hook can resolve a PostgreSQL-backed lazyoauth.RefreshTokenStateStore initialized by application dependencies. The signed refresh token remains self-contained; the database stores only its family ID, current jti, expiry, and revocation flag. This lets a client refresh after a pod replacement while preserving rotation and replay detection. Read Authentication And OAuth for the interface and transaction contract.

Export resources and prompts

Resources return data that clients can read:

func (m *AdminMCP) UsersResource(ctx context.Context) lazymcp.ResourceSpec {
    return lazymcp.ResourceSpec{
        Name:     "users",
        URI:      "app://admin/users",
        Desc:     "Current user summary.",
        MIMEType: "application/json",
        Read: func(ctx context.Context) (lazymcp.ResourceContent, error) {
            return lazymcp.ResourceContent{
                Text:     `{"count":3}`,
                MIMEType: "application/json",
            }, nil
        },
    }
}

Prompts return messages, or generate them from arguments:

func (m *AdminMCP) AuditPrompt(ctx context.Context) lazymcp.PromptSpec {
    return lazymcp.PromptSpec{
        Desc:     "Ask for an audit summary.",
        Messages: lazymcp.UserMessages("Summarize the latest admin audit events."),
    }
}

Modules can also implement aggregate methods such as Tools(ctx), Resources(ctx), Prompts(ctx), Skills(ctx), or Apps(ctx) when the specs are better built as slices.

Export skills

Skills are served through MCP resources. A module can expose an embedded skill directory:

func (m *AdminMCP) OncallSkill(ctx context.Context) lazymcp.SkillSpec {
    return lazymcp.SkillSpec{
        Path: "oncall",
        FS:   oncallSkillFS,
    }
}

Clients can read skill://index.json, then fetch skill://admin/oncall/SKILL.md and supporting files. GoLazy also supports resources/directory/read for skill directories.

Export MCP Apps

MCP Apps let a server predeclare UI resources with ui:// URIs and link tools to those resources. The MCP Apps proposal standardizes that shape for interactive UI resources in MCP hosts. GoLazy renders app UI from app/views/mcp/<module>/...:

func (m *AdminMCP) DashboardApp(ctx context.Context) lazymcp.AppSpec {
    return lazymcp.AppSpec{
        Name:        "dashboard",
        Desc:        "Admin dashboard.",
        View:        "dashboard",
        UseLayout:   true,
        Domain:      "https://mcp.example.com",
        CSP: lazymcp.AppCSP{
            ConnectDomains:  []string{"https://api.example.com"},
            ResourceDomains: []string{"https://static.example.com"},
        },
        Permissions: lazymcp.AppPermissions{Tools: []string{"admin.user_count"}},
    }
}

The view lives at:

app/views/mcp/admin/dashboard.html.tpl

If UseLayout is true and no layout is set, GoLazy uses mcp_app. You can also set HTML directly on AppSpec for a static UI resource.

Set Domain to a dedicated HTTPS origin for App submission. Declare only the origins the widget actually uses in CSP.ConnectDomains, ResourceDomains, and optional FrameDomains; empty slices are a valid policy for a self-contained widget. GoLazy attaches the UI metadata to both resources/list and the App's resources/read content, using standard MCP Apps fields and ChatGPT-compatible aliases.

UI-enabled tools should still return useful text or structured content. Not every MCP host supports interactive UI, and text-only clients should still get an answer.

Keep structuredContent concise because the model reads it. For large or widget-only values, return a typed result that implements lazymcp.ResultMetadata and exclude those values from JSON:

type DashboardResult struct {
    Count int   `json:"count"`
    Rows  []Row `json:"-"`
}

func (result DashboardResult) MCPResultMeta() map[string]any {
    return map[string]any{"rows": result.Rows}
}

GoLazy writes that map to the tool result's top-level _meta. Standard MCP Apps receive it in ui/notifications/tool-result; ChatGPT also preserves the result envelope in window.openai.toolResponseMetadata. Widget-only does not mean secret: users can inspect _meta, so never put credentials or server tokens there.

Authorize modules

When a validated JWT is present, the default MCP authorizer reads the mcps extra claim. Tokens with {"mcps":["admin"]} can access the admin module. Tokens with {"mcps":["*"]} can access every module. Tokens without that claim cannot access any module.

Override this with lazyapp.Config.MCPOptions.Authorizer when the app needs custom policy:

MCPOptions: lazymcp.Options{
    Authorizer: func(ctx context.Context, module string) bool {
        claims, ok := lazyjwt.ClaimsFromContext(ctx)
        return ok && claims.HasScope("mcp:"+module)
    },
},

The MCP tool implementation should still enforce operation-level policy before reading or changing application data.