feat: MCP Streamable HTTP server with full tool set

Wire go-sdk Streamable HTTP at /mcp with stamp, verify, hash_report,
signup, login, session, create_user. Route global/CID to VeraeTime and
local|shared to FileLedger; resolve Authorization bearer via authctx.
Flags/env for transport, host, port, require-bearer, URLs, ledger path.
This commit is contained in:
George Lambert 2026-09-29 12:07:44 -04:00
commit d379931bd1

View file

@ -0,0 +1,107 @@
package server_test
import (
"context"
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/ledger"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/server"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const sha256Empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
func TestStreamableHTTP_InitializeListStampLocal(t *testing.T) {
t.Parallel()
dir := t.TempDir()
led, err := ledger.Open(filepath.Join(dir, "ledger.json"))
if err != nil {
t.Fatal(err)
}
authPath := filepath.Join(dir, "auth.properties")
if err := os.WriteFile(authPath, []byte("username=x\npassword=y\n"), 0o600); err != nil {
t.Fatal(err)
}
srv, err := server.New(server.Options{
Ledger: led,
AuthPath: authPath,
GlobalURL: "http://127.0.0.1:1", // unused for local stamp
RequireBearer: false,
JSONResponse: true,
Stateless: true,
})
if err != nil {
t.Fatal(err)
}
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
ctx := context.Background()
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.1"}, nil)
session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: httpServer.URL + "/mcp"}, nil)
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { _ = session.Close() })
tools, err := session.ListTools(ctx, nil)
if err != nil {
t.Fatalf("tools/list: %v", err)
}
wantTools := []string{"stamp", "verify", "hash_report", "signup", "login", "session", "create_user"}
got := map[string]bool{}
for _, tool := range tools.Tools {
got[tool.Name] = true
}
for _, name := range wantTools {
if !got[name] {
t.Fatalf("tools/list missing %q; got %#v", name, got)
}
}
res, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "stamp",
Arguments: map[string]any{
"sha256": sha256Empty,
"scope": "local",
},
})
if err != nil {
t.Fatalf("tools/call stamp: %v", err)
}
if res.IsError {
t.Fatalf("stamp tool error: %+v", res.Content)
}
raw, err := json.Marshal(res.StructuredContent)
if err != nil {
t.Fatal(err)
}
var out struct {
Receipts []struct {
Hash string `json:"hash"`
Scope string `json:"scope"`
} `json:"receipts"`
}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode structured: %v raw=%s", err, raw)
}
if len(out.Receipts) != 1 {
t.Fatalf("want 1 receipt, got %s", raw)
}
if out.Receipts[0].Hash != sha256Empty {
t.Fatalf("hash=%q", out.Receipts[0].Hash)
}
if out.Receipts[0].Scope != "local" {
t.Fatalf("scope=%q", out.Receipts[0].Scope)
}
if !strings.Contains(string(raw), "receipt_id") {
t.Fatalf("missing receipt_id in %s", raw)
}
}