root 8f4c16fab1 mcpd: Go MCP SDK port — replaces Bun mcp-server tool surface
New cmd/mcpd binary using github.com/modelcontextprotocol/go-sdk
v1.5.0 over stdio transport. Exposes Lakehouse capabilities as MCP
tools: list_datasets, get_manifest, query_sql, embed_text,
search_vectors. Each tool proxies to the gateway via HTTP.

Replaces the MCP-tool subset of the Rust system's Bun mcp-server.ts
(the audit's "split this 2520-line empire" finding from R-005). HTTP
demo routes (the staffing co-pilot UI at /api/intelligence/*,
/headshots/*, etc.) stay Bun until G5 cutover — those are demo-
specific and depend on matrix-indexer signals not yet ported.

Architecture:
  cmd/mcpd/main.go (235 LoC)
    main() reads --gateway flag, builds server via buildServer(),
    runs on StdioTransport. Each tool's args is a typed struct with
    jsonschema tags (the SDK's canonical pattern); reflection
    generates the JSON Schema automatically.

    gatewayClient: thin HTTP wrapper over the configured gateway URL.
    30s per-request timeout. 16 MiB tool-response cap. Non-2xx
    surfaces as IsError CallToolResult (NOT as transport error) so
    the LLM caller sees the error text and can decide how to react.

    proxy() handles GET + POST + JSON body uniformly. errorResult()
    + jsonResult() helpers normalize CallToolResult shape.

  cmd/mcpd/main_test.go (13 test funcs)
    Tests the full MCP wire end-to-end without a subprocess: spin
    up a fake gateway via httptest, build the MCP server pointed at
    it, connect a client via in-memory transports (NewInMemoryTransports),
    call each tool. Each tool gets:
      - happy path (gateway returns 200 → tool returns content)
      - input validation (missing required fields → IsError)
      - upstream error (gateway 4xx → tool returns IsError)
    Plus TestListTools verifies all 5 tools register; TestGatewayUnreachable
    verifies network-level failures surface as IsError, not panics.

Setup for Claude Desktop / Code documented in README:
  {
    "mcpServers": {
      "lakehouse": {
        "command": "/path/to/bin/mcpd",
        "args": ["--gateway", "http://127.0.0.1:3110"]
      }
    }
  }

Verified:
  go test -count=1 ./cmd/mcpd/  — 13/13 green
  just verify                    — vet + test + 9 smokes 35s

Out of scope for this commit:
  - Resources (mcp.AddResource): not needed yet; tools cover the
    interactive surface. Add when an LLM-side use case shows up.
  - Prompts (mcp.AddPrompt): same.
  - Streamable transports (HTTP, SSE): stdio is the universal one;
    streamable can be added with srv.Run(ctx, &mcp.StreamableHTTPHandler{})
    swap if a daemon-mode deploy makes sense.
  - mcpd inside the daemon-supervised stack: it's stdio-only and
    spawned by the MCP client, not run as a service. Adding a
    daemon-mode (HTTP transport on a port) is a follow-up if MCP
    consumers want long-lived sessions.

This is a tool-surface only port. The Bun mcp-server.ts also serves
HTTP demo routes (/api/catalog/datasets, /intelligence/*, /headshots/*)
that depend on the matrix-indexer signals from the Rust system; those
stay Bun until G5 cutover when the staffing co-pilot service ports
to Go.

Direct deps added:
  github.com/modelcontextprotocol/go-sdk v1.5.0

Transitive (resolved by go mod tidy):
  github.com/google/jsonschema-go        v0.4.2
  github.com/yosida95/uritemplate/v3     v3.0.2
  golang.org/x/oauth2                    v0.35.0
  github.com/segmentio/encoding          v0.5.4
  github.com/golang-jwt/jwt/v5           v5.3.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:00:38 -05:00

269 lines
8.7 KiB
Go

// mcpd is the Model Context Protocol server that exposes Lakehouse
// capabilities as MCP tools. Replaces the Bun mcp-server.ts surface
// (the MCP-tool-only subset; HTTP demo routes stay Bun until G5).
//
// Tools exposed:
// list_datasets GET /v1/catalog/list
// get_manifest GET /v1/catalog/manifest/<name>
// query_sql POST /v1/sql
// embed_text POST /v1/embed
// search_vectors POST /v1/vectors/index/<name>/search
//
// Transport: StdioTransport (the universal MCP transport — Claude
// Desktop, Claude Code, MCP CLI all speak this). Other transports
// (SSE, HTTP) can be added later by changing the Run call.
//
// Setup for Claude Desktop / Claude Code:
// bin/mcpd --gateway http://127.0.0.1:3110
// (configure your client to spawn this binary as an MCP server)
//
// Why not in cmd/gateway: separation of concerns. Gateway is HTTP
// for direct-API callers; mcpd is stdio for MCP consumers. Keeping
// them separate means each can be deployed / restarted / monitored
// without affecting the other.
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func main() {
gatewayURL := flag.String("gateway", "http://127.0.0.1:3110",
"Gateway URL (where mcpd proxies all tool calls)")
flag.Parse()
srv := buildServer(*gatewayURL)
if err := srv.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatalf("mcpd: %v", err)
}
}
// buildServer assembles the MCP server with all tools wired against
// the given gateway URL. Extracted from main() so tests can build
// the server with a test gateway URL.
func buildServer(gatewayURL string) *mcp.Server {
srv := mcp.NewServer(&mcp.Implementation{
Name: "lakehouse",
Version: "v0.1.0",
}, nil)
gw := newGatewayClient(gatewayURL)
mcp.AddTool(srv, &mcp.Tool{
Name: "list_datasets",
Description: "List all datasets registered in the catalog. " +
"Returns dataset_id, name, schema_fingerprint, row_count " +
"per dataset.",
}, gw.listDatasets)
mcp.AddTool(srv, &mcp.Tool{
Name: "get_manifest",
Description: "Fetch the manifest for a single dataset by name. " +
"Includes schema fingerprint, parquet object keys, row count, " +
"created_at_unix_ns.",
}, gw.getManifest)
mcp.AddTool(srv, &mcp.Tool{
Name: "query_sql",
Description: "Execute a SQL query against the registered datasets. " +
"Returns columns + rows. SQL is interpreted by DuckDB; standard " +
"SQL plus DuckDB-specific functions (read_parquet, etc.) work.",
}, gw.querySQL)
mcp.AddTool(srv, &mcp.Tool{
Name: "embed_text",
Description: "Embed one or more texts via the configured embedding " +
"model (default: nomic-embed-text). Returns one vector per text " +
"in the same order as the input.",
}, gw.embedText)
mcp.AddTool(srv, &mcp.Tool{
Name: "search_vectors",
Description: "Find the top-K nearest neighbors of a query vector " +
"in the named HNSW index. K defaults to 10 if omitted.",
}, gw.searchVectors)
return srv
}
// gatewayClient holds the HTTP client + base URL for proxying tool
// calls to the Go gateway. Per-tool latency is on the order of a
// gateway round-trip; the 30s timeout accommodates the slowest
// expected SQL query without holding stdio sessions indefinitely.
type gatewayClient struct {
baseURL string
hc *http.Client
}
func newGatewayClient(baseURL string) *gatewayClient {
return &gatewayClient{
baseURL: strings.TrimRight(baseURL, "/"),
hc: &http.Client{Timeout: 30 * time.Second},
}
}
// ── tool argument structs (jsonschema tags drive schema generation) ──
type listDatasetsArgs struct{}
type getManifestArgs struct {
Name string `json:"name" jsonschema:"the dataset name to fetch"`
}
type querySQLArgs struct {
SQL string `json:"sql" jsonschema:"the SQL query to execute"`
}
type embedTextArgs struct {
Texts []string `json:"texts" jsonschema:"the texts to embed"`
Model string `json:"model,omitempty" jsonschema:"optional model name (defaults to embedd's configured default)"`
}
type searchVectorsArgs struct {
IndexName string `json:"index_name" jsonschema:"the index to search"`
Vector []float32 `json:"vector" jsonschema:"the query vector"`
K int `json:"k,omitempty" jsonschema:"top-K to return (default 10)"`
}
// ── tool handlers ──
func (g *gatewayClient) listDatasets(ctx context.Context, _ *mcp.CallToolRequest, _ listDatasetsArgs) (*mcp.CallToolResult, any, error) {
body, err := g.proxy(ctx, http.MethodGet, "/v1/catalog/list", nil)
if err != nil {
return errorResult(err), nil, nil
}
return jsonResult(body), nil, nil
}
func (g *gatewayClient) getManifest(ctx context.Context, _ *mcp.CallToolRequest, args getManifestArgs) (*mcp.CallToolResult, any, error) {
if args.Name == "" {
return errorResult(fmt.Errorf("name is required")), nil, nil
}
path := "/v1/catalog/manifest/" + url.PathEscape(args.Name)
body, err := g.proxy(ctx, http.MethodGet, path, nil)
if err != nil {
return errorResult(err), nil, nil
}
return jsonResult(body), nil, nil
}
func (g *gatewayClient) querySQL(ctx context.Context, _ *mcp.CallToolRequest, args querySQLArgs) (*mcp.CallToolResult, any, error) {
if strings.TrimSpace(args.SQL) == "" {
return errorResult(fmt.Errorf("sql is required")), nil, nil
}
reqBody, _ := json.Marshal(map[string]string{"sql": args.SQL})
body, err := g.proxy(ctx, http.MethodPost, "/v1/sql", reqBody)
if err != nil {
return errorResult(err), nil, nil
}
return jsonResult(body), nil, nil
}
func (g *gatewayClient) embedText(ctx context.Context, _ *mcp.CallToolRequest, args embedTextArgs) (*mcp.CallToolResult, any, error) {
if len(args.Texts) == 0 {
return errorResult(fmt.Errorf("texts is required")), nil, nil
}
payload := map[string]any{"texts": args.Texts}
if args.Model != "" {
payload["model"] = args.Model
}
reqBody, _ := json.Marshal(payload)
body, err := g.proxy(ctx, http.MethodPost, "/v1/embed", reqBody)
if err != nil {
return errorResult(err), nil, nil
}
return jsonResult(body), nil, nil
}
func (g *gatewayClient) searchVectors(ctx context.Context, _ *mcp.CallToolRequest, args searchVectorsArgs) (*mcp.CallToolResult, any, error) {
if args.IndexName == "" {
return errorResult(fmt.Errorf("index_name is required")), nil, nil
}
if len(args.Vector) == 0 {
return errorResult(fmt.Errorf("vector is required")), nil, nil
}
payload := map[string]any{"vector": args.Vector}
if args.K > 0 {
payload["k"] = args.K
}
reqBody, _ := json.Marshal(payload)
path := "/v1/vectors/index/" + url.PathEscape(args.IndexName) + "/search"
body, err := g.proxy(ctx, http.MethodPost, path, reqBody)
if err != nil {
return errorResult(err), nil, nil
}
return jsonResult(body), nil, nil
}
// proxy makes a request to the gateway and returns the response body
// on success. Non-2xx status codes return an error with the body
// preview in the message — surfaced to the MCP client as a tool error
// rather than a transport-level failure.
func (g *gatewayClient) proxy(ctx context.Context, method, path string, body []byte) ([]byte, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, g.baseURL+path, rdr)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := g.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("call gateway: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) // 16 MiB tool-response cap
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
preview := respBody
if len(preview) > 512 {
preview = preview[:512]
}
return nil, fmt.Errorf("gateway %s %s: status %d: %s",
method, path, resp.StatusCode, string(preview))
}
return respBody, nil
}
// errorResult wraps an error as an MCP tool error result. The MCP
// protocol distinguishes "tool ran but reported failure" (returned
// in CallToolResult.IsError + content) from "tool threw" (returned
// as the third return value). We use the former so the LLM caller
// sees the error text and can decide how to react, rather than
// surfacing the error as transport noise.
func errorResult(err error) *mcp.CallToolResult {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{
&mcp.TextContent{Text: err.Error()},
},
}
}
// jsonResult wraps a JSON byte slice as a successful tool result.
// The content is text — MCP clients render it; LLMs parse it as
// JSON when their tool config indicates so.
func jsonResult(body []byte) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(body)},
},
}
}