AI
MCP Server
Expose GoLazy application tools, resources, prompts, skills, and MCP Apps UI resources through HTTP or a local stdio subprocess.
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.