Files
pad/internal/mcp/meta.go
T
xarmian df8a3631e7 feat(mcp): v0.2 catalog scaffold + ToolSurfaceVersion + pad_meta tool (TASK-979) (#352)
* feat(mcp): v0.2 catalog scaffold + ToolSurfaceVersion + pad_meta tool (TASK-979)

First commit of TASK-970's 3-stage rollout (PLAN-969). Introduces the
hand-curated v0.2 catalog types (ToolDef, ActionFn, ActionEnv) and ships
one tool — pad_meta — end-to-end. v0.1 cmdhelp-walk surface stays live
alongside; subsequent commits (TASK-980, TASK-981) migrate the rest and
flip v0.1 off.

Architecture record: DOC-978. The fan-out registry sits ABOVE the
dispatcher boundary — Dispatcher / route table are unchanged, so both
ExecDispatcher (stdio) and HTTPHandlerDispatcher (HTTP) inherit the new
shape for free.

Changes:
- internal/mcp/catalog.go (new) — ToolDef, ActionFn, ActionEnv,
  passThrough helper, RegisterCatalog, makeFanOutHandler, structured
  error helpers.
- internal/mcp/catalog_meta.go (new) — pad_meta tool with three inline
  actions: server-info, version, tool-surface (full catalog dump for
  PLAN-943 docs generation).
- internal/mcp/version.go — add ToolSurfaceVersion = "0.2" + matching
  experimentalToolSurfaceKey. Independent of CmdhelpVersion (cmdhelp
  owns CLI help-tree contract; ToolSurfaceVersion owns MCP catalog).
- internal/mcp/meta.go — extend MetaPayload with ToolSurfaceVersion;
  experimentalCapabilities advertises both padCmdhelp + padToolSurface.
- cmd/pad/mcp.go — call RegisterCatalog alongside Register so v0.2
  surface is live.
- Tests: catalog_test.go + catalog_meta_test.go (new); meta_test.go
  + server_test.go updated to assert the new field/capability.

Parent: TASK-970 → PLAN-969.

* fix(mcp): keep ToolSurfaceVersion at "0.1" until catalog is complete per Codex review (round 1)

Codex P1: advertising tool_surface_version=0.2 while the user-visible
surface is still predominantly v0.1 (cmdhelp walker active alongside,
only pad_meta in the catalog) misleads consumers that pin against the
handshake or pad://_meta/version. The padToolSurface namespace would
suggest the full resource/action shape is available when in reality
only pad_meta uses it.

Delay the 0.1 → 0.2 bump to TASK-981 — the commit that retires the
cmdhelp walker and ships the complete catalog. The constant stays
declared so the surface contract is wired through the handshake +
meta resource + pad_meta.tool-surface, the version string just
truthfully reflects "still v0.1" until the catalog is complete.

No test changes needed: every assertion uses the constant, not a
literal "0.2".

Parent: TASK-979 → TASK-970 → PLAN-969.

* fix(mcp): scope pad_meta.tool-surface to v0.2 catalog only per Codex review (round 2)

Codex P1: pad_meta.tool-surface description claimed "Full catalog dump:
every tool" but during PLAN-969's parallel rollout, tools/list contains
both the catalog (currently just pad_meta) AND the cmdhelp walker's
~85 verb tools. Calling the catalog dump "every tool" misleads consumers
who expect a complete enumeration.

Same spirit as round 1's fix: stop claiming what isn't true. The catalog
dump is the v0.2 catalog by design — consumers wanting the complete
advertised surface should read tools/list directly. Hand-mapping the
walker output into the catalog dump would cost duplication for a
surface that's about to disappear in TASK-981.

Wire-level changes:
- Tighten the action description in padMetaToolDescription to say
  "v0.2 catalog dump: every tool managed by the hand-curated catalog"
  and explicitly note tools/list is the source for the complete surface.
- Add rollout_status field to the response payload: "in-progress" while
  ToolSurfaceVersion stays at "0.1", "complete" once TASK-981 bumps it.
  Lets consumers detect the rollout state programmatically.
- Test asserts the new field tracks ToolSurfaceVersion.

Parent: TASK-979 → TASK-970 → PLAN-969.

* fix(mcp): include params in pad_meta.tool-surface dump per Codex review (round 3)

Codex P1: tool description claimed the dump includes each tool's
"input schema" but the payload only emitted name/description/workspace/
actions[]. Misleading for docs generators (TASK-957) that would build
getpad.dev/docs/mcp from this canonical source.

Going with the substantive fix rather than just trimming the
description: include a synthesized params[] per tool entry. Mirrors
what consumers see in tools/list — `action` (always required, enum of
declared action names), `workspace` (when ToolDef.Schema.Workspace=true),
and per-tool ParamDefs.

Synthesizing `action` and `workspace` rather than copying them from
ToolDef makes the dump self-contained: a docs generator doesn't need
to reproduce buildToolFromDef's implicit-param logic separately.

Test asserts each catalog entry has params[] starting with `action`
(enum length matches action handler count) and the right total length
based on Schema.Workspace + Schema.Params.

Parent: TASK-979 → TASK-970 → PLAN-969.
2026-05-01 17:30:27 -04:00

143 lines
5.0 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// MetaPayload is the JSON shape returned by pad://_meta/version.
//
// The fields are explicit (no embedded structs, no omitempty) so
// downstream consumers can pin against a known schema across pad
// releases. Adding fields is safe; renaming or removing them is a
// CmdhelpVersion bump.
type MetaPayload struct {
// PadVersion is the runtime version of the pad binary (e.g. "0.1.5").
PadVersion string `json:"pad_version"`
// CmdhelpVersion is the CLI help-tree stability tier. See the
// CmdhelpVersion constant for the bump policy.
CmdhelpVersion string `json:"cmdhelp_version"`
// ToolSurfaceVersion is the MCP tool catalog stability tier. See
// the ToolSurfaceVersion constant for the bump policy. Independent
// of CmdhelpVersion: the catalog can rev without changing the CLI.
ToolSurfaceVersion string `json:"tool_surface_version"`
// ToolSurfaceStable signals that the tool surface has shipped its
// stability contract. False during pre-release iteration.
ToolSurfaceStable bool `json:"tool_surface_stable"`
// MCPProtocolVersion is the latest MCP wire protocol revision this
// server can negotiate. Sourced from the underlying mcp-go library's
// LATEST_PROTOCOL_VERSION constant so the value never drifts from
// what NewMCPServer actually advertises in the handshake. Surfaced
// so consumers can detect feature support (e.g. RFC 8707 Resource
// Indicators land in the 2025-11-25 revision).
MCPProtocolVersion string `json:"mcp_protocol_version"`
}
// BuildMetaPayload returns the meta payload for the given pad runtime
// version. An empty padVersion falls back to FallbackVersion for the
// same reason serverInfo.version does — empty values confuse some
// clients that display them in their UI.
//
// MCPProtocolVersion is sourced from mcp.LATEST_PROTOCOL_VERSION, which
// is the maximum protocol revision the server can negotiate. If a
// client downgrades during initialize, the per-session negotiated
// version may be lower; the meta document reports the server's
// upper bound, not any specific session.
func BuildMetaPayload(padVersion string) MetaPayload {
if padVersion == "" {
padVersion = FallbackVersion
}
return MetaPayload{
PadVersion: padVersion,
CmdhelpVersion: CmdhelpVersion,
ToolSurfaceVersion: ToolSurfaceVersion,
ToolSurfaceStable: true,
MCPProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
}
}
// RegisterMeta installs the pad://_meta/version static resource on srv
// so MCP clients can discover pad's tool-surface stability tier
// without parsing the (free-form) handshake instructions field.
//
// padVersion is typically the same string passed to NewServer's
// Options.Version (the runtime fullVersion()).
func RegisterMeta(srv *server.MCPServer, padVersion string) {
payload := BuildMetaPayload(padVersion)
resource := mcp.NewResource(
MetaVersionURI,
"pad meta version",
mcp.WithResourceDescription(
"Tool-surface stability metadata for this pad MCP server. "+
"Returns pad runtime version, cmdhelp surface version "+
"(the contract external agents depend on), and the MCP "+
"protocol revision the server pins against.",
),
mcp.WithMIMEType(jsonMIMEType),
)
srv.AddResource(resource, func(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
body, err := json.Marshal(payload)
if err != nil {
// json.Marshal failing on a struct with only string + bool
// fields is so unlikely it would indicate a runtime bug;
// surface as an error rather than panicking the handler.
return nil, fmt.Errorf("marshal meta payload: %w", err)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: req.Params.URI,
MIMEType: jsonMIMEType,
Text: string(body),
},
}, nil
})
}
// experimentalCapabilities returns the map advertised at
// capabilities.experimental in the initialize handshake's result
// envelope. Lets clients discover the cmdhelp + tool-surface tiers in
// one round-trip without reading the meta resource.
//
// Wire shape:
//
// "result": {
// "capabilities": {
// "experimental": {
// "padCmdhelp": {
// "version": "0.1",
// "tool_surface_stable": true
// },
// "padToolSurface": {
// "version": "0.2",
// "tool_surface_stable": true
// }
// },
// ...
// },
// ...
// }
//
// Two independent contracts: padCmdhelp tracks CLI help-tree shape,
// padToolSurface tracks the MCP tool catalog. They version separately
// so a catalog reshape doesn't force a cmdhelp bump and vice versa.
func experimentalCapabilities() map[string]any {
return map[string]any{
experimentalCapabilityKey: map[string]any{
"version": CmdhelpVersion,
"tool_surface_stable": true,
},
experimentalToolSurfaceKey: map[string]any{
"version": ToolSurfaceVersion,
"tool_surface_stable": true,
},
}
}