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

271 lines
10 KiB
Go

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, &params); 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"}
}