~bigbes/sr-ht-dolt

ref: 12c7ff77f8281cd0ca61177bcf07b9c031a04c97 sr-ht-dolt/mcpsrv/errors.go -rw-r--r-- 5.0 KiB
12c7ff77 — Eugene Blikh ci: publish this build's own coverage and benchmarks 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
package mcpsrv

import (
	"errors"
	"log/slog"

	"github.com/modelcontextprotocol/go-sdk/jsonrpc"
	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)

// The one place this surface decides what a failure *is*, which on MCP is a
// question with two answers rather than a status code.
//
//   - A tool result error (CallToolResult.IsError) is an answer to the agent:
//     the call was understood, executed, and the thing asked for is not there.
//     The SDK produces one out of any ordinary error a handler returns, and the
//     agent reads it as text and decides what to ask next.
//   - A protocol error (a *jsonrpc.Error returned by a handler, which the SDK
//     passes through as the JSON-RPC error of the response) says the call did
//     not produce an answer at all. The client's CallTool returns an error
//     rather than a result, which is exactly right for a store that is down: an
//     agent must not read "Postgres could not answer" as "that database does not
//     exist" and go rewrite its plan around a database that is perfectly real.
//
// The table is missingOrDenied plus a default, and the default is the protocol
// arm on purpose: an unmapped error is a bug in a layer below, and rendering it
// as a tool result would report that bug to the agent as a fact about the data.
//
// # The visibility rule
//
// docs/DESIGN.mcp.md §4.3 runs through both arms and is the reason this file
// exists at all. A database the caller may not read is **not found** —
// indistinguishable from one that does not exist, which is what
// core.NotFoundForPrivate decides and what the browse handlers already answer
// (web/router.go). There is no "forbidden" on this surface and no failure of its
// own shape for a masked database: a tool that reported one would rebuild
// exactly the distinction the 404 exists to erase, and an agent probing names
// would read the difference straight out of the two messages.
//
// The sentence the agent sees is written here, from the arguments the call
// carried, and never from the error's own text: db/ wraps its misses with the
// owner and name it looked up, and echoing that would eventually publish a
// difference between "no such database" and "not yours".

// internalMessage is the message of every protocol error this surface returns.
// The detail is logged, never sent: it names tables, queries and on-disk paths,
// and this endpoint is reachable by anyone holding any valid token — or by
// nobody at all.
const internalMessage = "internal server error"

// missingOrDenied is the answer to a read that resolved to nothing — because the
// database does not exist, or because the visibility rule says this caller may
// not learn that it does.
//
// missing is the sentence the agent sees, and every caller builds it from the
// arguments of the call being answered ("no database ~alice/notes"). That is
// deliberate on both counts: the sentence discloses nothing the caller did not
// already know, and it is one string for both cases by construction rather than
// by two error paths being kept in agreement by hand.
//
// where is the operator's half — the tool name — and appears only in the log
// line of the protocol arm.
func missingOrDenied(err error, where, missing string) error {
	if errors.Is(err, db.ErrNotFound) {
		// A tool result error: the SDK packs an ordinary error returned by a
		// handler into a CallToolResult with IsError set.
		return errors.New(missing)
	}
	return internalError(err, where)
}

// refMiss is the *other* kind of miss, and the reason it is spelled out beside
// missingOrDenied is that the two must never be confused.
//
// missingOrDenied answers about a database the caller may not learn anything
// about, so its sentence says nothing. refMiss answers about a database the
// caller has already been allowed to read, where a ref that resolves to neither
// a branch nor a commit — or, for the tools that name one, a table that is not
// there — is an ordinary fact about that database. Naming it is not a leak: the
// caller is looking straight at it. Masking it instead would send an agent off
// to re-resolve a database that is right there, over a typo in a branch name.
//
// Anything that is not browse's miss takes the protocol arm, for the reason the
// default always does here: an unmapped failure is a bug in a layer below, and
// reporting it as a fact about the data would teach the agent something untrue.
func refMiss(err error, where, missing string) error {
	if errors.Is(err, browse.ErrRefNotFound) {
		return errors.New(missing)
	}
	return internalError(err, where)
}

// internalError logs the cause and returns the protocol error the client sees.
//
// The error goes through scribe.Err, which expands a culpa chain into err.msg,
// err.code and err.hint instead of flattening it with %v.
func internalError(err error, where string) error {
	slog.Error("a tool call failed", "tool", where, scribe.Err(err))
	return &jsonrpc.Error{Code: jsonrpc.CodeInternalError, Message: internalMessage}
}