307 lines
11 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|