agent
This commit is contained in:
@@ -15,8 +15,9 @@ import (
|
||||
|
||||
// Nearle Buddy's HTTP surface.
|
||||
//
|
||||
// POST /v1/web/assistant/ask a question → an answer, and what it ran
|
||||
// GET /v1/web/assistant/status is this switched on here?
|
||||
// POST /v1/web/assistant/ask a question → an answer, and what it ran
|
||||
// POST /v1/web/assistant/approve a card the person pressed → the change, made
|
||||
// GET /v1/web/assistant/status is this switched on here?
|
||||
//
|
||||
// ── Where the caller comes from ─────────────────────────────────────────────
|
||||
//
|
||||
@@ -33,6 +34,13 @@ func NewAssistantController(assistant services.AssistantService) *AssistantContr
|
||||
return &AssistantController{assistant: assistant}
|
||||
}
|
||||
|
||||
type assistantApproveRequest struct {
|
||||
Agent string `json:"agent"`
|
||||
// The card exactly as it was handed out. Opaque to the console — it is
|
||||
// signed, and anything the browser changed stops it verifying.
|
||||
Card string `json:"card"`
|
||||
}
|
||||
|
||||
type assistantAskRequest struct {
|
||||
// Which agent to ask. The console sends the one matching the page the panel
|
||||
// is sitting beside; empty means orders, the only one phase 2 ships.
|
||||
@@ -98,6 +106,48 @@ func (ctl *AssistantController) Ask(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Approve performs a change the person pressed the button on.
|
||||
//
|
||||
// Its own endpoint, not a flag on /ask, because it is a different kind of act:
|
||||
// no question, no model, no conversation. The card names the action and the
|
||||
// session names the person, and the registry re-checks both against the live
|
||||
// database before anything is written.
|
||||
func (ctl *AssistantController) Approve(c *fiber.Ctx) error {
|
||||
var req assistantApproveRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return assistantRefuse(c, http.StatusBadRequest, "Invalid request body")
|
||||
}
|
||||
if strings.TrimSpace(req.Card) == "" {
|
||||
return assistantRefuse(c, http.StatusBadRequest, "Nothing to approve.")
|
||||
}
|
||||
|
||||
caller, ok := callerFrom(c)
|
||||
if !ok {
|
||||
return assistantRefuse(c, http.StatusUnauthorized, "Sign in again to approve this.")
|
||||
}
|
||||
|
||||
agent := strings.TrimSpace(req.Agent)
|
||||
if agent == "" {
|
||||
agent = "orders"
|
||||
}
|
||||
|
||||
ctx, cancel := services.WithTimeout(c.Context())
|
||||
defer cancel()
|
||||
|
||||
answer, err := ctl.assistant.Approve(ctx, agent, req.Card, caller)
|
||||
if err != nil {
|
||||
// A refused approval is a business outcome, not a server fault: the card
|
||||
// expired, somebody else already approved it, the request was withdrawn.
|
||||
// The person needs the reason, and the console renders it beside the
|
||||
// card rather than as an error page.
|
||||
return assistantRefuse(c, http.StatusConflict, err.Error())
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true, "message": "Success", "details": answer,
|
||||
})
|
||||
}
|
||||
|
||||
// callerFrom turns a verified session into a tool caller.
|
||||
//
|
||||
// The one place the two vocabularies meet. Staff (`issuperadmin`) carry no
|
||||
|
||||
270
controllers/mcpController.go
Normal file
270
controllers/mcpController.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nearle/services"
|
||||
"nearle/services/tools"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// The MCP door.
|
||||
//
|
||||
// A second way into the same registry. An outside client — Claude Desktop, an
|
||||
// IDE, another service — speaks Model Context Protocol and reaches exactly the
|
||||
// tools Nearle Buddy reaches, through exactly the same checks.
|
||||
//
|
||||
// ── Why it is a door and not a second implementation ────────────────────────
|
||||
//
|
||||
// `tools/list` is `Registry.Definitions`, and `tools/call` is `Registry.Call`.
|
||||
// Nothing here knows what a tool does, what a tenant is, or how a scope is
|
||||
// enforced. If this file grew its own idea of any of those, the two doors would
|
||||
// drift and one of them would be the unguarded one — which is the usual way a
|
||||
// system with two entrances ends up with one that skips the checks.
|
||||
//
|
||||
// ── The session is the same session ─────────────────────────────────────────
|
||||
//
|
||||
// Mounted under `/v1/web`, so `middleware.WebAuth` has already verified a
|
||||
// console token and parked the claims before this runs. There is no second
|
||||
// credential and no API key: whoever holds a console session gets exactly what
|
||||
// that session gets, and somebody with no session gets nothing.
|
||||
//
|
||||
// ── Read-only, deliberately ─────────────────────────────────────────────────
|
||||
//
|
||||
// Write tools are filtered out of both `tools/list` and `tools/call`. A write
|
||||
// resolves into an approval card, and the card is a thing a PERSON reads in the
|
||||
// console — the quantity, the branch, the id — before pressing a button. An MCP
|
||||
// client has no way to render that, and handing it a card to approve on its own
|
||||
// would turn a human gate into a JSON field. So the door offers the reads and
|
||||
// says plainly that changes happen in the console.
|
||||
type MCPController struct {
|
||||
registry *tools.Registry
|
||||
agents map[string]services.Agent
|
||||
assistant services.AssistantService
|
||||
}
|
||||
|
||||
func NewMCPController(registry *tools.Registry, agents map[string]services.Agent) *MCPController {
|
||||
return &MCPController{registry: registry, agents: agents}
|
||||
}
|
||||
|
||||
// The protocol version this speaks. Sent back on initialize so a client that
|
||||
// expects something else can say so rather than failing later on a shape it
|
||||
// did not anticipate.
|
||||
const mcpProtocolVersion = "2024-11-05"
|
||||
|
||||
/* ── JSON-RPC 2.0 ──────────────────────────────────────────────────────── */
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// The JSON-RPC codes this uses. Only the ones with a real meaning here — a
|
||||
// server that returns -32603 for everything tells a client nothing.
|
||||
const (
|
||||
rpcParseError = -32700
|
||||
rpcInvalidRequest = -32600
|
||||
rpcMethodNotFound = -32601
|
||||
rpcInvalidParams = -32602
|
||||
rpcInternalError = -32603
|
||||
)
|
||||
|
||||
func rpcOK(c *fiber.Ctx, id json.RawMessage, result any) error {
|
||||
// HTTP 200 even for a JSON-RPC error, which is the protocol's own
|
||||
// convention: the transport succeeded, and the error is in the envelope.
|
||||
return c.Status(http.StatusOK).JSON(rpcResponse{JSONRPC: "2.0", ID: id, Result: result})
|
||||
}
|
||||
|
||||
func rpcFail(c *fiber.Ctx, id json.RawMessage, code int, message string) error {
|
||||
return c.Status(http.StatusOK).JSON(rpcResponse{
|
||||
JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: message},
|
||||
})
|
||||
}
|
||||
|
||||
/* ── The endpoint ──────────────────────────────────────────────────────── */
|
||||
|
||||
// Handle serves one JSON-RPC request.
|
||||
func (ctl *MCPController) Handle(c *fiber.Ctx) error {
|
||||
var req rpcRequest
|
||||
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||
return rpcFail(c, nil, rpcParseError, "that is not valid JSON")
|
||||
}
|
||||
if req.Method == "" {
|
||||
return rpcFail(c, req.ID, rpcInvalidRequest, "no method")
|
||||
}
|
||||
|
||||
// A notification — a request with no id — expects no response at all.
|
||||
// `initialized` is the one every client sends after the handshake, and
|
||||
// answering it with a result is a protocol error on our side.
|
||||
if len(req.ID) == 0 {
|
||||
return c.SendStatus(http.StatusAccepted)
|
||||
}
|
||||
|
||||
caller, ok := callerFrom(c)
|
||||
if !ok {
|
||||
return rpcFail(c, req.ID, rpcInvalidRequest,
|
||||
"this door needs a console session; sign in to Nearle and use that token")
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
return rpcOK(c, req.ID, fiber.Map{
|
||||
"protocolVersion": mcpProtocolVersion,
|
||||
// Tools only. No resources, no prompts, no sampling — claiming a
|
||||
// capability this does not have makes a client fail on a call that
|
||||
// looked supported.
|
||||
"capabilities": fiber.Map{"tools": fiber.Map{}},
|
||||
"serverInfo": fiber.Map{"name": "nearle", "version": "1"},
|
||||
"instructions": "Read-only access to this merchant's own shop data. " +
|
||||
"Changes are made in the Nearle console, where they are confirmed by a person.",
|
||||
})
|
||||
|
||||
case "tools/list":
|
||||
return rpcOK(c, req.ID, fiber.Map{"tools": ctl.list(c)})
|
||||
|
||||
case "tools/call":
|
||||
return ctl.call(c, req, caller)
|
||||
|
||||
default:
|
||||
return rpcFail(c, req.ID, rpcMethodNotFound, "this server does not do "+req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// list is Definitions, with writes removed and the key renamed.
|
||||
//
|
||||
// MCP spells it `inputSchema`; the registry speaks `input_schema` because that
|
||||
// is what reads clearly and what the model gateway already converts from. The
|
||||
// rename happens here rather than in the registry so neither door dictates the
|
||||
// other's vocabulary.
|
||||
func (ctl *MCPController) list(c *fiber.Ctx) []fiber.Map {
|
||||
agent := ctl.agentFor(c)
|
||||
defined := ctl.registry.Definitions(tools.Agent{Name: agent.Name, Tools: agent.Tools})
|
||||
|
||||
out := make([]fiber.Map, 0, len(defined))
|
||||
for _, definition := range defined {
|
||||
name, _ := definition["name"].(string)
|
||||
// A write is not described at all, rather than described and refused.
|
||||
// A client told about a tool it will always be denied reads that as the
|
||||
// server malfunctioning.
|
||||
if ctl.isWrite(name) {
|
||||
continue
|
||||
}
|
||||
out = append(out, fiber.Map{
|
||||
"name": definition["name"],
|
||||
"description": definition["description"],
|
||||
"inputSchema": definition["input_schema"],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (ctl *MCPController) call(c *fiber.Ctx, req rpcRequest, caller tools.Caller) error {
|
||||
var params struct {
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"arguments"`
|
||||
}
|
||||
if len(req.Params) > 0 {
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
return rpcFail(c, req.ID, rpcInvalidParams, "arguments are not valid JSON")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(params.Name) == "" {
|
||||
return rpcFail(c, req.ID, rpcInvalidParams, "no tool named")
|
||||
}
|
||||
|
||||
// Checked before the registry sees it. The registry would refuse a write
|
||||
// anyway — it returns a proposal rather than performing one — but a card
|
||||
// handed to a client with nothing to render it is worse than a plain "not
|
||||
// here", and this keeps the two doors' answers honest about why.
|
||||
if ctl.isWrite(params.Name) {
|
||||
return rpcFail(c, req.ID, rpcInvalidParams,
|
||||
params.Name+" changes data, and changes are confirmed by a person in the Nearle console")
|
||||
}
|
||||
|
||||
agent := ctl.agentFor(c)
|
||||
ctx, cancel := services.WithTimeout(c.Context())
|
||||
defer cancel()
|
||||
|
||||
result, err := ctl.registry.Call(ctx, tools.Agent{Name: agent.Name, Tools: agent.Tools},
|
||||
params.Name, params.Args, caller)
|
||||
if err != nil {
|
||||
// A refusal is returned as a tool result with `isError`, not as a
|
||||
// JSON-RPC error. The distinction is the protocol's: a transport fault
|
||||
// is an RPC error, and "that tool needs a branch" is an answer the
|
||||
// client should show its user.
|
||||
if errors.Is(err, tools.ErrUnknownTool) || errors.Is(err, tools.ErrNotAllowed) {
|
||||
return rpcFail(c, req.ID, rpcMethodNotFound, err.Error())
|
||||
}
|
||||
return rpcOK(c, req.ID, fiber.Map{
|
||||
"isError": true,
|
||||
"content": []fiber.Map{{"type": "text", "text": err.Error()}},
|
||||
})
|
||||
}
|
||||
|
||||
// The rows go back as JSON text, which is what MCP carries and what a model
|
||||
// on the other end reads most reliably. `note` and `covers` ride alongside
|
||||
// rather than inside, so an instruction about truncation cannot be mistaken
|
||||
// for a row.
|
||||
payload := fiber.Map{"rows": result.Rows, "count": result.Count}
|
||||
if result.Scope != "" {
|
||||
payload["covers"] = result.Scope
|
||||
}
|
||||
if result.Truncated {
|
||||
payload["truncated"] = true
|
||||
}
|
||||
if result.Note != "" {
|
||||
payload["note"] = result.Note
|
||||
}
|
||||
if result.Source != "" {
|
||||
payload["see"] = result.Source
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return rpcFail(c, req.ID, rpcInternalError, "the result could not be encoded")
|
||||
}
|
||||
return rpcOK(c, req.ID, fiber.Map{
|
||||
"content": []fiber.Map{{"type": "text", "text": string(encoded)}},
|
||||
})
|
||||
}
|
||||
|
||||
// isWrite reports whether a tool changes anything.
|
||||
func (ctl *MCPController) isWrite(name string) bool {
|
||||
tool, ok := ctl.registry.Tool(name)
|
||||
return ok && tool.Scope == tools.ScopeWrite
|
||||
}
|
||||
|
||||
// agentFor picks which agent's allow-list applies.
|
||||
//
|
||||
// An MCP client has no page to sit beside, so there is no route to read one
|
||||
// from. It gets `console` — the broadest of the read agents, matching what a
|
||||
// person sees on the overview — and it is still an allow-list rather than
|
||||
// "every tool": a door with no agent at all would be wider than any of the ones
|
||||
// the console offers.
|
||||
func (ctl *MCPController) agentFor(*fiber.Ctx) services.Agent {
|
||||
if agent, ok := ctl.agents["console"]; ok {
|
||||
return agent
|
||||
}
|
||||
// Named rather than defaulted to everything: a deployment whose agent files
|
||||
// do not define `console` gets a door that lists nothing, which is visible,
|
||||
// rather than one that offers the lot.
|
||||
return services.Agent{Name: "mcp"}
|
||||
}
|
||||
306
controllers/mcp_test.go
Normal file
306
controllers/mcp_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user