mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
eb896e4469
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.
141 lines
4.5 KiB
Go
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)
|
|
}
|