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