coverage~bigbes/sr-ht-doltede3b0bbmcpsrv/errors.go

Coverage
100.0% 8/8 statements
Δ
Blob
714639b
Uncovered nothing — every instrumented line ran
1 package mcpsrv
2
3 import (
4 "errors"
5 "log/slog"
6
7 "github.com/modelcontextprotocol/go-sdk/jsonrpc"
8 "go.bigb.es/auxilia/scribe"
9
10 "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
11 "sourcecraft.dev/bigbes/sr-ht-dolt/db"
12 )
13
14 // The one place this surface decides what a failure *is*, which on MCP is a
15 // question with two answers rather than a status code.
16 //
17 // - A tool result error (CallToolResult.IsError) is an answer to the agent:
18 // the call was understood, executed, and the thing asked for is not there.
19 // The SDK produces one out of any ordinary error a handler returns, and the
20 // agent reads it as text and decides what to ask next.
21 // - A protocol error (a *jsonrpc.Error returned by a handler, which the SDK
22 // passes through as the JSON-RPC error of the response) says the call did
23 // not produce an answer at all. The client's CallTool returns an error
24 // rather than a result, which is exactly right for a store that is down: an
25 // agent must not read "Postgres could not answer" as "that database does not
26 // exist" and go rewrite its plan around a database that is perfectly real.
27 //
28 // The table is missingOrDenied plus a default, and the default is the protocol
29 // arm on purpose: an unmapped error is a bug in a layer below, and rendering it
30 // as a tool result would report that bug to the agent as a fact about the data.
31 //
32 // # The visibility rule
33 //
34 // docs/DESIGN.mcp.md §4.3 runs through both arms and is the reason this file
35 // exists at all. A database the caller may not read is **not found** —
36 // indistinguishable from one that does not exist, which is what
37 // core.NotFoundForPrivate decides and what the browse handlers already answer
38 // (web/router.go). There is no "forbidden" on this surface and no failure of its
39 // own shape for a masked database: a tool that reported one would rebuild
40 // exactly the distinction the 404 exists to erase, and an agent probing names
41 // would read the difference straight out of the two messages.
42 //
43 // The sentence the agent sees is written here, from the arguments the call
44 // carried, and never from the error's own text: db/ wraps its misses with the
45 // owner and name it looked up, and echoing that would eventually publish a
46 // difference between "no such database" and "not yours".
47
48 // internalMessage is the message of every protocol error this surface returns.
49 // The detail is logged, never sent: it names tables, queries and on-disk paths,
50 // and this endpoint is reachable by anyone holding any valid token — or by
51 // nobody at all.
52 const internalMessage = "internal server error"
53
54 // missingOrDenied is the answer to a read that resolved to nothing — because the
55 // database does not exist, or because the visibility rule says this caller may
56 // not learn that it does.
57 //
58 // missing is the sentence the agent sees, and every caller builds it from the
59 // arguments of the call being answered ("no database ~alice/notes"). That is
60 // deliberate on both counts: the sentence discloses nothing the caller did not
61 // already know, and it is one string for both cases by construction rather than
62 // by two error paths being kept in agreement by hand.
63 //
64 // where is the operator's half — the tool name — and appears only in the log
65 // line of the protocol arm.
66 21 func missingOrDenied(err error, where, missing string) error {
67 21 if errors.Is(err, db.ErrNotFound) {
68 20 // A tool result error: the SDK packs an ordinary error returned by a
69 20 // handler into a CallToolResult with IsError set.
70 20 return errors.New(missing)
71 20 }
72 1 return internalError(err, where)
73 }
74
75 // refMiss is the *other* kind of miss, and the reason it is spelled out beside
76 // missingOrDenied is that the two must never be confused.
77 //
78 // missingOrDenied answers about a database the caller may not learn anything
79 // about, so its sentence says nothing. refMiss answers about a database the
80 // caller has already been allowed to read, where a ref that resolves to neither
81 // a branch nor a commit — or, for the tools that name one, a table that is not
82 // there — is an ordinary fact about that database. Naming it is not a leak: the
83 // caller is looking straight at it. Masking it instead would send an agent off
84 // to re-resolve a database that is right there, over a typo in a branch name.
85 //
86 // Anything that is not browse's miss takes the protocol arm, for the reason the
87 // default always does here: an unmapped failure is a bug in a layer below, and
88 // reporting it as a fact about the data would teach the agent something untrue.
89 13 func refMiss(err error, where, missing string) error {
90 13 if errors.Is(err, browse.ErrRefNotFound) {
91 10 return errors.New(missing)
92 10 }
93 3 return internalError(err, where)
94 }
95
96 // internalError logs the cause and returns the protocol error the client sees.
97 //
98 // The error goes through scribe.Err, which expands a culpa chain into err.msg,
99 // err.code and err.hint instead of flattening it with %v.
100 7 func internalError(err error, where string) error {
101 7 slog.Error("a tool call failed", "tool", where, scribe.Err(err))
102 7 return &jsonrpc.Error{Code: jsonrpc.CodeInternalError, Message: internalMessage}
103 7 }