Files
projax/mcp/handler.go
mAi dc50823860 feat(phase 3a mcp): MCP surface so mai/otto/Claude can read+write projax
mcp package (new): minimal JSON-RPC 2.0 + MCP-protocol server, tools
delegate to *store.Store (no business-logic duplication).

- handler.go: handleRPC routes initialize / tools/list / tools/call /
  ping / notifications/initialized; Bearer-token middleware; results
  flow through the standard MCP content[].text envelope; tool errors
  surface as isError: true (transport errors stay JSON-RPC errors).
- tools.go: 10 tools — list_items / get_item / create_item /
  update_item / delete_item / list_links / add_link / remove_link /
  search / tree. Multi-parent in/out — parent_paths[] string array,
  resolved per call. itemView/linkView keep the wire shape snake_case
  and stable.
- mcp_test.go + tools_test.go: protocol primitives (no DB) plus a
  full create → get → search → delete round-trip skipping cleanly
  when the DB env is absent. Multi-parent assertion discovers the
  test pair from the live DB rather than hard-coding a row.

store extensions:
- ListByFilters(SearchFilters) with parent_path/tags/management/kind/
  status/q/has_repo/has_caldav predicates.
- Search(q, limit) ranked across title/slug/aliases/content_md.
- GetByPathOrSlug for callers that don't know the full path.
- SoftDeleteCascade refuses on live descendants unless cascade=true.

web:
- New optional Server.MCP http.Handler. main.go mounts an mcp.Server
  when PROJAX_MCP_TOKEN is set; /mcp/* gets a StripPrefix and bypasses
  the Supabase-cookie auth middleware (its own Bearer auth applies).
- Off cleanly when the token is unset.

ops:
- ~/.claude/mcp/projax.sh stdio→HTTP bridge (NDJSON in, NDJSON out,
  Bearer header).
- .mcp.json adds an http-transport entry for clients that speak
  HTTP+MCP natively.
- deploy/dokploy.yaml advertises PROJAX_MCP_TOKEN as a secret.
- docs/design.md §7 added: tool list, multi-parent semantics, env
  contract, transport + bridge.
2026-05-15 17:59:03 +02:00

287 lines
8.4 KiB
Go

// Package mcp implements a small MCP-protocol server over HTTP. The wire
// format is JSON-RPC 2.0 with the MCP method set (initialize, tools/list,
// tools/call). Designed to be mounted under /mcp/rpc on the projax web
// binary; a stdio bridge (see ~/.claude/mcp/projax.sh) lets standard MCP
// clients talk to it transparently.
package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// ProtocolVersion is the MCP wire version this server speaks. Clients that
// initialize with a different version are still answered; they're expected
// to negotiate down.
const ProtocolVersion = "2024-11-05"
// JSON-RPC 2.0 error codes (subset).
const (
codeParseError = -32700
codeInvalidRequest = -32600
codeMethodNotFound = -32601
codeInvalidParams = -32602
codeInternalError = -32603
)
// Tool describes one callable tool exposed through tools/list and tools/call.
type Tool struct {
Name string // e.g. "list_items"
Description string // one-line description for the client
InputSchema json.RawMessage // JSON-schema object describing the params
Handler ToolHandler
}
// ToolHandler runs the actual work for a tool. params is the raw JSON object
// the client supplied as the tool arguments. Returning a non-nil result is
// wrapped as a structured text content block; returning an error becomes an
// MCP "isError: true" reply.
type ToolHandler func(ctx context.Context, params json.RawMessage) (any, error)
// Server holds the registered tools + the auth token. Mount via Routes() on
// any *http.ServeMux.
type Server struct {
Name string
Version string
Token string // Bearer token; empty means "no auth" (tests only)
Logger *slog.Logger
tools map[string]Tool
}
// New builds an MCP server with no tools registered.
func New(name, version, token string, logger *slog.Logger) *Server {
if logger == nil {
logger = slog.Default()
}
return &Server{
Name: name,
Version: version,
Token: token,
Logger: logger,
tools: map[string]Tool{},
}
}
// Register adds a tool. Duplicate names overwrite.
func (s *Server) Register(t Tool) { s.tools[t.Name] = t }
// Routes registers /rpc on the given mux prefix-relative path. Caller mounts
// at /mcp so the resulting URL is /mcp/rpc.
func (s *Server) Routes(mux *http.ServeMux) {
mux.HandleFunc("POST /rpc", s.handleRPC)
// Friendly GET for ops smoke-testing — never returns secrets.
mux.HandleFunc("GET /rpc", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"server": s.Name,
"version": s.Version,
"protocolVersion": ProtocolVersion,
"tools": s.toolNames(),
"authRequired": s.Token != "",
})
})
}
func (s *Server) toolNames() []string {
out := make([]string, 0, len(s.tools))
for n := range s.tools {
out = append(out, n)
}
return out
}
// jsonRPCReq mirrors the wire shape. Method "notifications/initialized" has
// no id (notification) — we tolerate the missing id field.
type jsonRPCReq struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type jsonRPCResp struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
func (s *Server) handleRPC(w http.ResponseWriter, r *http.Request) {
if s.Token != "" {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
return
}
var req jsonRPCReq
if err := json.Unmarshal(body, &req); err != nil {
s.writeErr(w, nil, codeParseError, "invalid JSON: "+err.Error())
return
}
if req.JSONRPC != "2.0" && req.JSONRPC != "" {
s.writeErr(w, req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
switch req.Method {
case "initialize":
s.writeOK(w, req.ID, s.initializeResult())
case "notifications/initialized":
// Notifications get no response on JSON-RPC, but if the client sent an
// id we humour it with an empty result.
if len(req.ID) > 0 {
s.writeOK(w, req.ID, map[string]any{})
} else {
w.WriteHeader(http.StatusNoContent)
}
case "tools/list":
s.writeOK(w, req.ID, s.toolsListResult())
case "tools/call":
s.handleToolsCall(ctx, w, req)
case "ping":
s.writeOK(w, req.ID, map[string]any{})
default:
s.writeErr(w, req.ID, codeMethodNotFound, "unknown method: "+req.Method)
}
}
func (s *Server) checkAuth(r *http.Request) bool {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
return false
}
return strings.TrimSpace(h[len("Bearer "):]) == s.Token
}
func (s *Server) initializeResult() map[string]any {
return map[string]any{
"protocolVersion": ProtocolVersion,
"capabilities": map[string]any{
"tools": map[string]any{},
},
"serverInfo": map[string]any{
"name": s.Name,
"version": s.Version,
},
}
}
type toolDescriptor struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
}
func (s *Server) toolsListResult() map[string]any {
out := make([]toolDescriptor, 0, len(s.tools))
for _, t := range s.tools {
schema := t.InputSchema
if len(schema) == 0 {
schema = json.RawMessage(`{"type":"object","properties":{}}`)
}
out = append(out, toolDescriptor{
Name: t.Name,
Description: t.Description,
InputSchema: schema,
})
}
return map[string]any{"tools": out}
}
type toolsCallParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
func (s *Server) handleToolsCall(ctx context.Context, w http.ResponseWriter, req jsonRPCReq) {
var p toolsCallParams
if err := json.Unmarshal(req.Params, &p); err != nil {
s.writeErr(w, req.ID, codeInvalidParams, "tools/call params: "+err.Error())
return
}
tool, ok := s.tools[p.Name]
if !ok {
s.writeErr(w, req.ID, codeMethodNotFound, "unknown tool: "+p.Name)
return
}
result, err := tool.Handler(ctx, p.Arguments)
if err != nil {
// Per MCP convention, tool errors stay inside the result envelope with
// isError=true so the client sees them as tool failures, not transport
// failures. JSON-RPC-level errors are reserved for transport problems
// (auth, parse, unknown method).
s.Logger.Warn("mcp tool error", "tool", p.Name, "err", err)
s.writeOK(w, req.ID, map[string]any{
"content": []map[string]any{{
"type": "text",
"text": err.Error(),
}},
"isError": true,
})
return
}
payload, err := json.Marshal(result)
if err != nil {
s.writeErr(w, req.ID, codeInternalError, "marshal result: "+err.Error())
return
}
s.writeOK(w, req.ID, map[string]any{
"content": []map[string]any{{
"type": "text",
"text": string(payload),
}},
"isError": false,
})
}
func (s *Server) writeOK(w http.ResponseWriter, id json.RawMessage, result any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(jsonRPCResp{JSONRPC: "2.0", ID: id, Result: result})
}
func (s *Server) writeErr(w http.ResponseWriter, id json.RawMessage, code int, message string) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(jsonRPCResp{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: message}})
}
// ToolError is returned by ToolHandlers for user-visible failures that should
// flow through the tool-result envelope as isError. Errors that do NOT match
// this type get wrapped automatically — this is just a sentinel for callers
// that want to provide structured user-facing data alongside the message.
type ToolError struct {
Msg string
Data any
}
func (e *ToolError) Error() string { return e.Msg }
// AsToolError returns the ToolError if err is one (or wraps one).
func AsToolError(err error) (*ToolError, bool) {
var te *ToolError
if errors.As(err, &te) {
return te, true
}
return nil, false
}
var _ = fmt.Sprintf // keep import handy if future code uses fmt