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>
283 lines
8.1 KiB
Go
283 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// Tests the MCP tool surface 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, call each tool.
|
|
|
|
// fakeGateway returns an httptest.Server that responds to the routes
|
|
// mcpd proxies. Each route's handler is configurable via the routes map.
|
|
func fakeGateway(t *testing.T, routes map[string]http.HandlerFunc) *httptest.Server {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
for path, h := range routes {
|
|
mux.HandleFunc(path, h)
|
|
}
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
t.Errorf("fakeGateway: unexpected route %s %s", r.Method, r.URL.Path)
|
|
http.NotFound(w, r)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// connect builds the mcpd server pointed at gatewayURL, connects an
|
|
// in-memory client, and returns a ready-to-use ClientSession.
|
|
func connect(t *testing.T, gatewayURL string) *mcp.ClientSession {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
srv := buildServer(gatewayURL)
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
|
|
|
|
t1, t2 := mcp.NewInMemoryTransports()
|
|
if _, err := srv.Connect(ctx, t1, nil); err != nil {
|
|
t.Fatalf("server connect: %v", err)
|
|
}
|
|
session, err := client.Connect(ctx, t2, nil)
|
|
if err != nil {
|
|
t.Fatalf("client connect: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = session.Close() })
|
|
return session
|
|
}
|
|
|
|
func callTool(t *testing.T, session *mcp.ClientSession, name string, args any) *mcp.CallToolResult {
|
|
t.Helper()
|
|
res, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
|
Name: name,
|
|
Arguments: args,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CallTool %s: %v", name, err)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func resultText(t *testing.T, res *mcp.CallToolResult) string {
|
|
t.Helper()
|
|
if len(res.Content) == 0 {
|
|
t.Fatal("result has no content")
|
|
}
|
|
tc, ok := res.Content[0].(*mcp.TextContent)
|
|
if !ok {
|
|
t.Fatalf("first content is %T, want *mcp.TextContent", res.Content[0])
|
|
}
|
|
return tc.Text
|
|
}
|
|
|
|
func TestListTools(t *testing.T) {
|
|
gw := fakeGateway(t, nil)
|
|
session := connect(t, gw.URL)
|
|
|
|
res, err := session.ListTools(context.Background(), nil)
|
|
if err != nil {
|
|
t.Fatalf("ListTools: %v", err)
|
|
}
|
|
want := map[string]bool{
|
|
"list_datasets": false,
|
|
"get_manifest": false,
|
|
"query_sql": false,
|
|
"embed_text": false,
|
|
"search_vectors": false,
|
|
}
|
|
for _, tool := range res.Tools {
|
|
if _, ok := want[tool.Name]; ok {
|
|
want[tool.Name] = true
|
|
}
|
|
}
|
|
for name, found := range want {
|
|
if !found {
|
|
t.Errorf("expected tool %q exposed by mcpd, not in ListTools", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListDatasets_HappyPath(t *testing.T) {
|
|
gw := fakeGateway(t, map[string]http.HandlerFunc{
|
|
"/v1/catalog/list": func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"manifests":[{"name":"workers","row_count":500000}],"count":1}`))
|
|
},
|
|
})
|
|
session := connect(t, gw.URL)
|
|
|
|
res := callTool(t, session, "list_datasets", listDatasetsArgs{})
|
|
if res.IsError {
|
|
t.Fatalf("expected success, got IsError with: %s", resultText(t, res))
|
|
}
|
|
body := resultText(t, res)
|
|
if !strings.Contains(body, "workers") || !strings.Contains(body, "500000") {
|
|
t.Errorf("response missing expected fields: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestGetManifest_HappyPath(t *testing.T) {
|
|
gw := fakeGateway(t, map[string]http.HandlerFunc{
|
|
"/v1/catalog/manifest/workers": func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"name":"workers","row_count":500000,"schema_fingerprint":"sha256:abc"}`))
|
|
},
|
|
})
|
|
session := connect(t, gw.URL)
|
|
|
|
res := callTool(t, session, "get_manifest", getManifestArgs{Name: "workers"})
|
|
if res.IsError {
|
|
t.Fatalf("expected success, got: %s", resultText(t, res))
|
|
}
|
|
body := resultText(t, res)
|
|
if !strings.Contains(body, "workers") {
|
|
t.Errorf("missing manifest fields in response: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestGetManifest_EmptyName_IsError(t *testing.T) {
|
|
gw := fakeGateway(t, nil) // no handlers — tool should error before hitting gateway
|
|
session := connect(t, gw.URL)
|
|
|
|
res := callTool(t, session, "get_manifest", getManifestArgs{Name: ""})
|
|
if !res.IsError {
|
|
t.Fatal("expected IsError on empty name")
|
|
}
|
|
}
|
|
|
|
func TestQuerySQL_HappyPath(t *testing.T) {
|
|
gw := fakeGateway(t, map[string]http.HandlerFunc{
|
|
"/v1/sql": func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("/v1/sql got %s, want POST", r.Method)
|
|
}
|
|
_, _ = w.Write([]byte(`{"columns":[{"name":"n","type":"BIGINT"}],"rows":[[5]],"row_count":1}`))
|
|
},
|
|
})
|
|
session := connect(t, gw.URL)
|
|
|
|
res := callTool(t, session, "query_sql", querySQLArgs{SQL: "SELECT count(*) FROM workers"})
|
|
if res.IsError {
|
|
t.Fatalf("expected success, got: %s", resultText(t, res))
|
|
}
|
|
if !strings.Contains(resultText(t, res), `"row_count":1`) {
|
|
t.Errorf("response missing row_count field: %s", resultText(t, res))
|
|
}
|
|
}
|
|
|
|
func TestQuerySQL_EmptySQL_IsError(t *testing.T) {
|
|
gw := fakeGateway(t, nil)
|
|
session := connect(t, gw.URL)
|
|
|
|
res := callTool(t, session, "query_sql", querySQLArgs{SQL: " "})
|
|
if !res.IsError {
|
|
t.Fatal("expected IsError on whitespace SQL")
|
|
}
|
|
}
|
|
|
|
func TestEmbedText_HappyPath(t *testing.T) {
|
|
gw := fakeGateway(t, map[string]http.HandlerFunc{
|
|
"/v1/embed": func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"model":"nomic-embed-text","dimension":768,"vectors":[[0.1,0.2]]}`))
|
|
},
|
|
})
|
|
session := connect(t, gw.URL)
|
|
|
|
res := callTool(t, session, "embed_text", embedTextArgs{Texts: []string{"hello"}})
|
|
if res.IsError {
|
|
t.Fatalf("expected success, got: %s", resultText(t, res))
|
|
}
|
|
if !strings.Contains(resultText(t, res), `"dimension":768`) {
|
|
t.Errorf("missing dimension in response: %s", resultText(t, res))
|
|
}
|
|
}
|
|
|
|
func TestEmbedText_EmptyTexts_IsError(t *testing.T) {
|
|
gw := fakeGateway(t, nil)
|
|
session := connect(t, gw.URL)
|
|
|
|
res := callTool(t, session, "embed_text", embedTextArgs{Texts: nil})
|
|
if !res.IsError {
|
|
t.Fatal("expected IsError on empty texts")
|
|
}
|
|
}
|
|
|
|
func TestSearchVectors_HappyPath(t *testing.T) {
|
|
gw := fakeGateway(t, map[string]http.HandlerFunc{
|
|
"/v1/vectors/index/test_idx/search": func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"results":[{"id":"v1","distance":0.001}]}`))
|
|
},
|
|
})
|
|
session := connect(t, gw.URL)
|
|
|
|
res := callTool(t, session, "search_vectors", searchVectorsArgs{
|
|
IndexName: "test_idx",
|
|
Vector: []float32{1, 0, 0, 0},
|
|
K: 5,
|
|
})
|
|
if res.IsError {
|
|
t.Fatalf("expected success, got: %s", resultText(t, res))
|
|
}
|
|
if !strings.Contains(resultText(t, res), `"id":"v1"`) {
|
|
t.Errorf("missing top-1 in response: %s", resultText(t, res))
|
|
}
|
|
}
|
|
|
|
func TestSearchVectors_MissingIndex_IsError(t *testing.T) {
|
|
gw := fakeGateway(t, nil)
|
|
session := connect(t, gw.URL)
|
|
|
|
res := callTool(t, session, "search_vectors", searchVectorsArgs{
|
|
Vector: []float32{1, 0, 0, 0},
|
|
})
|
|
if !res.IsError {
|
|
t.Fatal("expected IsError on missing index_name")
|
|
}
|
|
}
|
|
|
|
func TestSearchVectors_MissingVector_IsError(t *testing.T) {
|
|
gw := fakeGateway(t, nil)
|
|
session := connect(t, gw.URL)
|
|
|
|
res := callTool(t, session, "search_vectors", searchVectorsArgs{
|
|
IndexName: "test_idx",
|
|
})
|
|
if !res.IsError {
|
|
t.Fatal("expected IsError on missing vector")
|
|
}
|
|
}
|
|
|
|
func TestGatewayError_PropagatesAsIsError(t *testing.T) {
|
|
gw := fakeGateway(t, map[string]http.HandlerFunc{
|
|
"/v1/sql": func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte("syntax error: no such table"))
|
|
},
|
|
})
|
|
session := connect(t, gw.URL)
|
|
|
|
res := callTool(t, session, "query_sql", querySQLArgs{SQL: "SELECT * FROM nope"})
|
|
if !res.IsError {
|
|
t.Fatal("expected IsError on gateway 4xx")
|
|
}
|
|
body := resultText(t, res)
|
|
if !strings.Contains(body, "400") {
|
|
t.Errorf("error result should mention status 400, got: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestGatewayUnreachable_PropagatesAsIsError(t *testing.T) {
|
|
// Point at a non-listening port — connect will fail.
|
|
session := connect(t, "http://127.0.0.1:1") // reserved port
|
|
|
|
res := callTool(t, session, "list_datasets", listDatasetsArgs{})
|
|
if !res.IsError {
|
|
t.Fatal("expected IsError on unreachable gateway")
|
|
}
|
|
}
|