package controllers import ( "context" "encoding/json" "net/http/httptest" "strings" "testing" "nearle/middleware" "nearle/services" "nearle/services/tools" "nearle/utils" "github.com/gofiber/fiber/v2" ) // The MCP door, held to the same rules as the console's. // // The point of these is not that JSON-RPC is spelled correctly — it is that a // second entrance did not arrive with its own, looser idea of who may read what. func readTool(name string) tools.Tool { return tools.Tool{ Name: name, Description: "a read tool with a description long enough to choose by, for testing", Scope: tools.ScopeRead, Schema: tools.Schema{Fields: []tools.Field{{ Name: "limit", Description: "how many", Kind: tools.KindInt, Min: 1, Max: 50, Default: 10, }}}, Handler: func(_ context.Context, req tools.Request) (tools.Result, error) { return tools.Result{ Rows: []map[string]any{{"id": 1}}, Count: 1, Scope: "all branches", Source: "/admin/dispatch", }, nil }, } } func writeToolFor(t *testing.T, name string) tools.Tool { t.Helper() return tools.WriteTool( tools.Tool{ Name: name, Description: "a write tool with a description long enough to choose by, for testing", Schema: tools.Schema{}, }, func(context.Context, tools.Request) (tools.Proposal, error) { return tools.Proposal{Summary: "change something"}, nil }, func(context.Context, tools.Request) (tools.Result, error) { t.Fatal("a write executed through the MCP door") return tools.Result{}, nil }) } // mcpApp mounts the door with a session already verified, as WebAuth would. func mcpApp(t *testing.T, claims *utils.WebClaims, toolset ...tools.Tool) *fiber.App { t.Helper() registry := tools.New(nil) names := make([]string, 0, len(toolset)) for _, tool := range toolset { if err := registry.Register(tool); err != nil { t.Fatalf("registering %s: %v", tool.Name, err) } names = append(names, tool.Name) } agents := map[string]services.Agent{"console": {Name: "console", Tools: names}} ctl := NewMCPController(registry, agents) app := fiber.New() app.Post("/mcp", func(c *fiber.Ctx) error { if claims != nil { c.Locals(middleware.WebLocalsKey, *claims) } return ctl.Handle(c) }) return app } func rpc(t *testing.T, app *fiber.App, body string) map[string]any { t.Helper() req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, -1) if err != nil { t.Fatalf("calling: %v", err) } if resp.StatusCode == fiber.StatusAccepted { return nil } var out map[string]any if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decoding: %v", err) } return out } var session = &utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172} /* ── The handshake ─────────────────────────────────────────────────────── */ func TestInitializeClaimsOnlyWhatItCanDo(t *testing.T) { // Claiming a capability this does not have makes a client fail later, on a // call that looked supported. app := mcpApp(t, session, readTool("stuck")) out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"initialize"}`) result, _ := out["result"].(map[string]any) caps, _ := result["capabilities"].(map[string]any) if _, ok := caps["tools"]; !ok { t.Fatalf("tools not offered: %v", caps) } for _, unsupported := range []string{"resources", "prompts", "sampling"} { if _, claimed := caps[unsupported]; claimed { t.Fatalf("claimed %q, which this server does not do", unsupported) } } } func TestANotificationGetsNoResponse(t *testing.T) { // `initialized` arrives with no id after every handshake. Answering it with // a result is a protocol error on our side. app := mcpApp(t, session, readTool("stuck")) if out := rpc(t, app, `{"jsonrpc":"2.0","method":"notifications/initialized"}`); out != nil { t.Fatalf("a notification was answered: %v", out) } } func TestAnUnknownMethodIsRefusedByName(t *testing.T) { app := mcpApp(t, session, readTool("stuck")) out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"resources/list"}`) rpcErr, _ := out["error"].(map[string]any) if rpcErr == nil { t.Fatalf("an unsupported method succeeded: %v", out) } if !strings.Contains(rpcErr["message"].(string), "resources/list") { t.Fatalf("the refusal does not say what was asked for: %v", rpcErr) } } /* ── The same door, the same guard ─────────────────────────────────────── */ func TestNoSessionMeansNoTools(t *testing.T) { // There is no API key and no second credential. Whoever holds a console // session gets what that session gets; somebody with none gets nothing. app := mcpApp(t, nil, readTool("stuck")) out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck"}}`) if out["error"] == nil { t.Fatalf("an unauthenticated call was answered: %v", out) } } func TestTheDoorOffersOnlyTheAgentsAllowList(t *testing.T) { // The registry's allow-list, not a second one written here. registry := tools.New(nil) _ = registry.Register(readTool("stuck")) _ = registry.Register(readTool("secret")) agents := map[string]services.Agent{"console": {Name: "console", Tools: []string{"stuck"}}} ctl := NewMCPController(registry, agents) app := fiber.New() app.Post("/mcp", func(c *fiber.Ctx) error { c.Locals(middleware.WebLocalsKey, *session) return ctl.Handle(c) }) out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`) result, _ := out["result"].(map[string]any) listed, _ := result["tools"].([]any) if len(listed) != 1 { t.Fatalf("the door listed %d tools, not the agent's one", len(listed)) } // And calling the one it did not list is refused. denied := rpc(t, app, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"secret"}}`) if denied["error"] == nil { t.Fatalf("a tool off the allow-list was callable: %v", denied) } } func TestTheCallerComesFromTheSessionNotTheRequest(t *testing.T) { // Same property as the console door: the model, or whatever is driving this // client, has no say in whose data is read. var seen tools.Caller tool := readTool("stuck") tool.Handler = func(_ context.Context, req tools.Request) (tools.Result, error) { seen = req.Caller return tools.Result{Count: 0, Scope: "all branches"}, nil } app := mcpApp(t, session, tool) rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck","arguments":{"tenantid":916}}}`) if seen.Tenantid != 1147 { t.Fatalf("the tool ran for tenant %d", seen.Tenantid) } } /* ── Read-only ─────────────────────────────────────────────────────────── */ func TestAWriteIsNotEvenListed(t *testing.T) { // Described and then refused reads to a client as the server malfunctioning. app := mcpApp(t, session, readTool("stuck"), writeToolFor(t, "change_something")) out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`) result, _ := out["result"].(map[string]any) for _, listed := range result["tools"].([]any) { entry, _ := listed.(map[string]any) if entry["name"] == "change_something" { t.Fatal("a write tool was offered over MCP") } } } func TestAWriteCannotBeCalledAndTheRefusalSaysWhere(t *testing.T) { // The write's execute half fails the test if it runs. The refusal has to // point somewhere useful, or a person is stuck. app := mcpApp(t, session, readTool("stuck"), writeToolFor(t, "change_something")) out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"change_something"}}`) rpcErr, _ := out["error"].(map[string]any) if rpcErr == nil { t.Fatalf("a write was accepted over MCP: %v", out) } if !strings.Contains(rpcErr["message"].(string), "console") { t.Fatalf("the refusal does not say where changes happen: %v", rpcErr) } } /* ── Results ───────────────────────────────────────────────────────────── */ func TestAResultCarriesItsRowsAndItsCaveats(t *testing.T) { tool := readTool("stuck") tool.Handler = func(context.Context, tools.Request) (tools.Result, error) { return tools.Result{ Rows: []map[string]any{{"id": 1}}, Count: 60, Truncated: true, Note: "60 jobs are waiting; the 50 longest are listed.", Scope: "all branches", Source: "/admin/dispatch", }, nil } app := mcpApp(t, session, tool) out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck"}}`) result, _ := out["result"].(map[string]any) content, _ := result["content"].([]any) first, _ := content[0].(map[string]any) text, _ := first["text"].(string) var payload map[string]any if err := json.Unmarshal([]byte(text), &payload); err != nil { t.Fatalf("the content is not JSON: %v", err) } for _, want := range []string{"rows", "count", "covers", "truncated", "note", "see"} { if _, ok := payload[want]; !ok { t.Fatalf("the result dropped %q: %v", want, payload) } } } func TestARefusedToolIsAResultNotATransportError(t *testing.T) { // The protocol's own distinction: a transport fault is an RPC error, and // "that tool needs a branch" is an answer the client should show its user. app := mcpApp(t, session, readTool("stuck")) out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck","arguments":{"limit":999}}}`) if out["error"] != nil { t.Fatalf("a bad argument was reported as a transport fault: %v", out["error"]) } result, _ := out["result"].(map[string]any) if result["isError"] != true { t.Fatalf("a refusal was reported as success: %v", result) } } func TestTheSchemaIsSpelledTheWayMCPExpects(t *testing.T) { // The registry says `input_schema`; MCP says `inputSchema`. The rename lives // at the door so neither side dictates the other's vocabulary. app := mcpApp(t, session, readTool("stuck")) out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`) result, _ := out["result"].(map[string]any) first, _ := result["tools"].([]any)[0].(map[string]any) if _, ok := first["inputSchema"]; !ok { t.Fatalf("no inputSchema on a listed tool: %v", first) } if _, stillSnake := first["input_schema"]; stillSnake { t.Fatal("the registry's spelling leaked through the door") } } func TestMalformedJSONIsRefusedWithoutPanicking(t *testing.T) { app := mcpApp(t, session, readTool("stuck")) for _, body := range []string{"", "{", "not json", `{"jsonrpc":"2.0","id":1}`} { out := rpc(t, app, body) if out != nil && out["error"] == nil && out["result"] == nil { t.Fatalf("%q produced neither a result nor an error", body) } } }