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