Files
xarmian eb896e4469 feat(mcp): server-level instructions advertised in initialize handshake (TASK-971) (#355)
Adds a top-level `instructions` string to the MCP initialize response
so agents know WHEN to reach for pad without having to guess from
tool descriptions alone. The Svelte MCP server in the dogfooding
session that triggered PLAN-969 does this; pad now does too.

Implementation:
- internal/mcp/instructions.md (new) — embedded source content.
  MCP-aware adaptation of skills/pad/SKILL.md's opener: what pad is,
  when to reach for it, the v0.2 tool catalog summary, resource
  cheatsheet, workspace resolution order, ref convention, update flow,
  conventions hint, and the four prompts.
- internal/mcp/instructions.go (new) — //go:embed wrapper exposing
  the content as the Instructions package var.
- internal/mcp/server.go — pass server.WithInstructions(Instructions)
  into NewMCPServer.

Single source of truth: the same string ships in both the local stdio
handshake AND PLAN-943's HTTPHandlerDispatcher (when remote /mcp
mounts in TASK-950 it will reuse the same constant — no docs drift
between local and remote surfaces).

Test: TestServer_InitializeAdvertisesInstructions drives a real
initialize round-trip and asserts the response's Instructions field
equals the embedded source. Sanity-checks the embed didn't truncate.

Parent: TASK-971 → PLAN-969.
2026-05-01 18:11:38 -04:00

141 lines
4.5 KiB
Go

package mcp
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"os/signal"
"syscall"
"github.com/mark3labs/mcp-go/server"
)
// Server is pad's MCP server. It wraps a mark3labs/mcp-go MCPServer
// plus the stdio transport.
//
// TASK-944 lays the bones — handshake-only, no tools / resources /
// prompts yet. Subsequent tasks layer the cmdhelp-derived tool
// registry (TASK-945), resources (TASK-946), and prompts (TASK-947)
// onto MCP() before Run() takes over the transport.
type Server struct {
mcp *server.MCPServer
debug bool
}
// Options configures NewServer. All fields are optional.
type Options struct {
// Version is reported to the client in the initialize handshake's
// serverInfo.version. If empty, FallbackVersion is used.
Version string
// Debug enables verbose stderr logging from the MCP server.
// stdout is reserved for JSON-RPC traffic; logs always go to stderr.
Debug bool
}
// NewServer constructs a pad MCP server. Call Run(ctx) to start it.
//
// The returned Server is wired with tool capabilities advertised but
// no tools registered — that's TASK-945's job. Callers that need to
// register tools / resources / prompts before Run() can do so via
// the MCP() accessor.
func NewServer(opts Options) *Server {
version := opts.Version
if version == "" {
version = FallbackVersion
}
mcp := server.NewMCPServer(
ServerName,
version,
// Declare tool capability up-front so clients know the server
// CAN serve tools. The list stays empty until TASK-945 wires
// in the cmdhelp-derived registry.
server.WithToolCapabilities(true),
// Recover from panics in tool handlers — important once the
// shell-out dispatcher (TASK-945) starts running pad
// subprocesses with arbitrary client-supplied args.
server.WithRecovery(),
// Tool-surface stability tier (TASK-963). Surfaces the cmdhelp
// contract directly in the handshake at
// capabilities.experimental.padCmdhelp, so external agents can
// detect compatibility without reading pad://_meta/version first.
server.WithExperimental(experimentalCapabilities()),
// Server-level instructions (TASK-971). Tells agents WHEN to
// reach for pad and orients them to the tool surface +
// resources before they make their first call. Embedded at
// build time from instructions.md so the source of truth is
// versioned with the binary; HTTPHandlerDispatcher (PLAN-943)
// advertises the same string.
server.WithInstructions(Instructions),
)
return &Server{mcp: mcp, debug: opts.Debug}
}
// MCP returns the underlying mark3labs MCPServer. Use this to register
// tools / resources / prompts BEFORE Run() takes over the stdio
// transport — registrations made after Run() are racy.
func (s *Server) MCP() *server.MCPServer {
return s.mcp
}
// Run starts the stdio transport on os.Stdin / os.Stdout. It blocks
// until ctx is cancelled, stdin reaches EOF, or SIGINT / SIGTERM is
// received. EOF and signals are clean-shutdown paths and return nil.
func (s *Server) Run(ctx context.Context) error {
return s.RunStdio(ctx, os.Stdin, os.Stdout)
}
// RunStdio is Run with explicit stdin / stdout streams. Used by tests
// that drive the server in-process via io.Pipe.
//
// The same shutdown semantics as Run apply: EOF, ctx cancel, and
// closed-pipe are clean (return nil); anything else is wrapped and
// returned.
func (s *Server) RunStdio(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
stdio := server.NewStdioServer(s.mcp)
stdio.SetErrorLogger(s.errLogger())
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// SIGINT / SIGTERM trigger graceful shutdown by cancelling ctx,
// which propagates into stdio.Listen's select loop. Stop the
// notifier on return so we don't leak goroutines / signal handlers
// when the test harness re-runs.
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigChan)
go func() {
select {
case <-sigChan:
cancel()
case <-ctx.Done():
}
}()
err := stdio.Listen(ctx, stdin, stdout)
if err == nil {
return nil
}
// EOF / ctx-cancelled / closed-pipe are clean shutdown paths.
// io.ErrClosedPipe shows up when test harnesses close the input
// half mid-flight — semantically the same as EOF for our purposes.
if errors.Is(err, io.EOF) ||
errors.Is(err, context.Canceled) ||
errors.Is(err, io.ErrClosedPipe) {
return nil
}
return fmt.Errorf("mcp stdio: %w", err)
}
func (s *Server) errLogger() *log.Logger {
prefix := ""
if s.debug {
prefix = "pad-mcp: "
}
return log.New(os.Stderr, prefix, log.LstdFlags)
}