Files
backend_fiesta/controllers/mcp_test.go
2026-09-23 17:26:13 +05:30

307 lines
11 KiB
Go

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)
}
}
}