271 lines
10 KiB
Go
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, ¶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"}
|
|
}
|