feat(ingestion): add MCP server skeleton with tools/list
Adds an MCP HTTP handler under ingestion/internal/mcp. Implements initialize, tools/list, and the JSON-RPC notification skip from prior work. Tool dispatch is stubbed (returns unknown-tool error) and will be filled in by subsequent tasks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
75
ingestion/internal/mcp/handlers.go
Normal file
75
ingestion/internal/mcp/handlers.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// tools returns the tool descriptors. Handler bodies for each tool are filled
|
||||||
|
// in subsequent tasks; this file currently only provides the descriptors.
|
||||||
|
func (s *Server) tools() []map[string]any {
|
||||||
|
str := func(desc string) map[string]any {
|
||||||
|
return map[string]any{"type": "string", "description": desc}
|
||||||
|
}
|
||||||
|
int_ := func(desc string) map[string]any {
|
||||||
|
return map[string]any{"type": "integer", "description": desc}
|
||||||
|
}
|
||||||
|
schema := func(required []string, props map[string]any) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"type": "object", "required": required, "properties": props,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
return []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "brain_query",
|
||||||
|
"description": "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files.",
|
||||||
|
"inputSchema": schema([]string{"query"}, map[string]any{
|
||||||
|
"query": str("search terms"),
|
||||||
|
"limit": int_("max results, default 5"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_write",
|
||||||
|
"description": "Write a raw knowledge note to brain/knowledge/.",
|
||||||
|
"inputSchema": schema([]string{"content"}, map[string]any{
|
||||||
|
"content": str("markdown content"),
|
||||||
|
"filename": str("optional filename"),
|
||||||
|
"type": str("optional frontmatter type"),
|
||||||
|
"domain": str("optional frontmatter domain"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_ingest_raw",
|
||||||
|
"description": "Ingest pre-structured pages into the brain wiki, bypassing the LLM extraction step.",
|
||||||
|
"inputSchema": schema([]string{"source", "pages"}, map[string]any{
|
||||||
|
"source": str("source name"),
|
||||||
|
"pages": map[string]any{"type": "array"},
|
||||||
|
"dry_run": map[string]any{"type": "boolean"},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_ingest",
|
||||||
|
"description": "Ingest content into the brain wiki via the LLM extraction pipeline.",
|
||||||
|
"inputSchema": schema([]string{}, map[string]any{
|
||||||
|
"content": str("raw content; required when path is empty"),
|
||||||
|
"source": str("source name; required when path is empty"),
|
||||||
|
"path": str("file path; mutually exclusive with content+source"),
|
||||||
|
"dry_run": map[string]any{"type": "boolean"},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "session_log",
|
||||||
|
"description": "Append a structured entry to brain/sessions/<session_id>.jsonl.",
|
||||||
|
"inputSchema": schema([]string{"session_id"}, map[string]any{
|
||||||
|
"session_id": str("session identifier"),
|
||||||
|
"skill": str("skill name"),
|
||||||
|
"phase": str("phase within the skill"),
|
||||||
|
"project_root": str("absolute project root"),
|
||||||
|
"final_status": str("ok | error | skipped"),
|
||||||
|
"file_path": str("optional file produced"),
|
||||||
|
"model_used": str("optional model identifier"),
|
||||||
|
"duration_ms": int_("optional duration in ms"),
|
||||||
|
"message": str("optional free-text"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
119
ingestion/internal/mcp/server.go
Normal file
119
ingestion/internal/mcp/server.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||||
|
// Exposed tools: brain_query, brain_write, brain_ingest, brain_ingest_raw, session_log.
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
|
)
|
||||||
|
|
||||||
|
type request struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID any `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params json.RawMessage `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type response struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID any `json:"id,omitempty"`
|
||||||
|
Result any `json:"result,omitempty"`
|
||||||
|
Error *rpcError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server handles MCP JSON-RPC over HTTP for the ingestion service.
|
||||||
|
type Server struct {
|
||||||
|
brainDir string
|
||||||
|
pipeline pipeline.Config
|
||||||
|
llm pipeline.CompleteFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
||||||
|
// LLM-backed pipeline; llm may be nil for non-LLM tools only.
|
||||||
|
func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.CompleteFunc) *Server {
|
||||||
|
cfg := pipeline.Config{}
|
||||||
|
if pipelineCfg != nil {
|
||||||
|
cfg = *pipelineCfg
|
||||||
|
}
|
||||||
|
return &Server{brainDir: brainDir, pipeline: cfg, llm: llm}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req request
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, nil, -32700, "parse error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON-RPC 2.0 notifications (no id) must not receive a response.
|
||||||
|
if req.ID == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result any
|
||||||
|
var rpcErr *rpcError
|
||||||
|
|
||||||
|
switch req.Method {
|
||||||
|
case "initialize":
|
||||||
|
result = map[string]any{
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||||
|
"serverInfo": map[string]any{"name": "ingestion-brain", "version": "0.1.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tools/list":
|
||||||
|
result = map[string]any{"tools": s.tools()}
|
||||||
|
|
||||||
|
case "tools/call":
|
||||||
|
var p struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments json.RawMessage `json:"arguments"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||||
|
rpcErr = &rpcError{Code: -32602, Message: "invalid params"}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out, err := s.handleCall(r.Context(), p.Name, p.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result = map[string]any{
|
||||||
|
"content": []map[string]any{{"type": "text", "text": string(out)}},
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
rpcErr = &rpcError{Code: -32601, Message: "method not found: " + req.Method}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: req.ID,
|
||||||
|
Result: result,
|
||||||
|
Error: rpcErr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, id any, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(response{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: id,
|
||||||
|
Error: &rpcError{Code: code, Message: msg},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCall dispatches a tools/call. Stub for Task 1; expanded in later tasks.
|
||||||
|
func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
return nil, fmt.Errorf("unknown tool: %s", name)
|
||||||
|
}
|
||||||
91
ingestion/internal/mcp/server_test.go
Normal file
91
ingestion/internal/mcp/server_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func body(t *testing.T, v any) *bytes.Buffer {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return bytes.NewBuffer(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerInitialize(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||||
|
"params": map[string]any{},
|
||||||
|
}))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
result := resp["result"].(map[string]any)
|
||||||
|
assert.Equal(t, "2024-11-05", result["protocolVersion"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerToolsList(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
||||||
|
}))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
tools := resp["result"].(map[string]any)["tools"].([]any)
|
||||||
|
names := make([]string, 0, len(tools))
|
||||||
|
for _, t := range tools {
|
||||||
|
names = append(names, t.(map[string]any)["name"].(string))
|
||||||
|
}
|
||||||
|
assert.ElementsMatch(t, []string{
|
||||||
|
"brain_query", "brain_write", "brain_ingest_raw", "brain_ingest", "session_log",
|
||||||
|
}, names)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerNotificationGetsNoBody(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
|
"jsonrpc": "2.0", "method": "notifications/initialized",
|
||||||
|
}))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
assert.Empty(t, strings.TrimSpace(rr.Body.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerUnknownMethodReturnsError(t *testing.T) {
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 3, "method": "unknown/method",
|
||||||
|
}))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
require.NotNil(t, resp["error"])
|
||||||
|
errObj := resp["error"].(map[string]any)
|
||||||
|
assert.Equal(t, float64(-32601), errObj["code"])
|
||||||
|
assert.Contains(t, errObj["message"].(string), "unknown/method")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user