AI

MCP Server

Expose GoLazy application tools, resources, prompts, skills, and MCP Apps UI resources through /mcp.

By Guillermo Alvarez - 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.

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.

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,
    Auth:         Auth,
    OAuth:        OAuth,
    MCP:          RegisterMCP,
})

When OAuth is configured, GoLazy protects /mcp with bearer-token validation. Without OAuth, the MCP endpoint is not authenticated by default. Do not expose sensitive tools without OAuth or an explicit MCP authorizer.

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,
        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.

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.

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.