browser-broker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- package/package.json +58 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { toolStdioAdapter } from "./adapter.js";
|
|
2
|
+
import { encodeMessage, METHODS, decodeMessage } from "./protocol.js";
|
|
3
|
+
import { serveSession } from "./session.js";
|
|
4
|
+
import { TOOL_DEFINITIONS } from "./tools.js";
|
|
5
|
+
/**
|
|
6
|
+
* How the conformance suite drives the tool surface.
|
|
7
|
+
*
|
|
8
|
+
* ── It goes through the real session loop, and that is the whole point ───
|
|
9
|
+
*
|
|
10
|
+
* The command line's driver builds an argv and calls the real entry point.
|
|
11
|
+
* This one builds a **line on the wire** and runs the real
|
|
12
|
+
* {@link serveSession}, then reads the outcome back out of the line that came
|
|
13
|
+
* off it. So every part of this route that could hold a rule of its own is
|
|
14
|
+
* exercised: the framing, the decode, the method dispatch, the tool lookup,
|
|
15
|
+
* the argument shaping, and the never-returned rule.
|
|
16
|
+
*
|
|
17
|
+
* **The hollow version is worth naming so nobody reinvents it.** A driver
|
|
18
|
+
* that called `toolStdioAdapter.invoke` directly — or worse, called the
|
|
19
|
+
* service — would produce a green matrix while testing none of that, and it
|
|
20
|
+
* would pass. That is the "tested a local copy of the logic so the shipped
|
|
21
|
+
* code was never exercised" failure this repository has caught before.
|
|
22
|
+
*
|
|
23
|
+
* ── One session per case, because that is the deployment ────────────────
|
|
24
|
+
*
|
|
25
|
+
* `MILESTONES.md` #27: the service is spawned by its caller, serves that
|
|
26
|
+
* session and exits with it. A driver that kept one long-lived session across
|
|
27
|
+
* the whole matrix would be measuring an arrangement this design does not
|
|
28
|
+
* have, and it would hide any state accidentally held between calls — the one
|
|
29
|
+
* bug this lifecycle is chosen to make impossible. So each case gets its own
|
|
30
|
+
* session, opened and ended around the single call.
|
|
31
|
+
*
|
|
32
|
+
* The process boundary is deliberately **not** crossed here: the matrix runs
|
|
33
|
+
* in process, and a smaller spawned smoke subset proves the wiring
|
|
34
|
+
* separately.
|
|
35
|
+
*/
|
|
36
|
+
/** Turn a neutral case input into this route's own vocabulary: a tool call. */
|
|
37
|
+
export function toolCallFor(testCase) {
|
|
38
|
+
const tool = TOOL_DEFINITIONS.find((entry) => entry.operation === testCase.operation);
|
|
39
|
+
if (tool === undefined) {
|
|
40
|
+
throw new Error(`the tool surface has no tool for "${testCase.operation}"`);
|
|
41
|
+
}
|
|
42
|
+
const args = {};
|
|
43
|
+
for (const [key, value] of Object.entries(testCase.input)) {
|
|
44
|
+
if (value === undefined || value === null) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// This transport carries JSON, so unlike the command line it can express
|
|
48
|
+
// a structure. What it cannot express is a value with no JSON
|
|
49
|
+
// representation, and coercing one would put something meaningless on the
|
|
50
|
+
// wire and then assert against whatever came back — a comparison that
|
|
51
|
+
// runs, means nothing, and passes.
|
|
52
|
+
if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') {
|
|
53
|
+
throw new TypeError(`case "${testCase.name}" gives ${key} a value the tool surface cannot carry`);
|
|
54
|
+
}
|
|
55
|
+
// The case table is authored in the service's own spelling, and the
|
|
56
|
+
// arguments the tool takes are the same names (§3), so this is a copy
|
|
57
|
+
// rather than a translation. Where a route's spelling differs — the
|
|
58
|
+
// command line's hyphens — the translation lives in that route's driver.
|
|
59
|
+
args[key] = value;
|
|
60
|
+
}
|
|
61
|
+
return { name: tool.name, arguments: args };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Read the outcome back out of the line the session wrote.
|
|
65
|
+
*
|
|
66
|
+
* A refusal arrives as a **successful response carrying a refusal**, never as
|
|
67
|
+
* a protocol error (`session.ts`), so a protocol error here is a real failure
|
|
68
|
+
* of this route rather than something to reinterpret as a refusal. Raising it
|
|
69
|
+
* is what keeps the two apart in the matrix: a route that reported refusals
|
|
70
|
+
* as protocol errors would be a route with its own rules, which is the exact
|
|
71
|
+
* thing #30 asserts against.
|
|
72
|
+
*/
|
|
73
|
+
export function outcomeFrom(line) {
|
|
74
|
+
if (line === undefined) {
|
|
75
|
+
throw new Error('the session wrote no response');
|
|
76
|
+
}
|
|
77
|
+
const decoded = JSON.parse(line);
|
|
78
|
+
if (decoded === null || typeof decoded !== 'object') {
|
|
79
|
+
throw new Error('the session wrote something that is not a message');
|
|
80
|
+
}
|
|
81
|
+
const message = decoded;
|
|
82
|
+
if (message['error'] !== undefined) {
|
|
83
|
+
const error = message['error'];
|
|
84
|
+
throw new Error(`the tool surface answered with a protocol error (${String(error.code)}): ${String(error.message)}`);
|
|
85
|
+
}
|
|
86
|
+
const result = message['result'];
|
|
87
|
+
if (result === null || typeof result !== 'object') {
|
|
88
|
+
throw new Error('the response carries no result');
|
|
89
|
+
}
|
|
90
|
+
// The outcome is read out of `structuredContent`, which is where a
|
|
91
|
+
// `tools/call` result carries its machine-readable half. **This is not a
|
|
92
|
+
// second spelling of the domain object** — the result root holds `content`,
|
|
93
|
+
// `structuredContent` and `isError`, which is the specification's shape and
|
|
94
|
+
// the only shape a client can read.
|
|
95
|
+
//
|
|
96
|
+
// Reading it here does not make the wire shape *asserted*: this function
|
|
97
|
+
// normalises a route's answer back into a neutral outcome so the matrix can
|
|
98
|
+
// compare two routes, and it would go on doing that faithfully if `content`
|
|
99
|
+
// vanished tomorrow. That is exactly how the missing content array shipped
|
|
100
|
+
// — the suite compared outcomes at the service layer and both routes agreed.
|
|
101
|
+
// The shape itself is asserted by its own test, against the bytes.
|
|
102
|
+
const structured = result['structuredContent'];
|
|
103
|
+
if (structured === null || typeof structured !== 'object') {
|
|
104
|
+
throw new Error('the result carries no structured content');
|
|
105
|
+
}
|
|
106
|
+
const record = structured;
|
|
107
|
+
if (record['outcome'] === 'accepted') {
|
|
108
|
+
return { outcome: 'accepted', value: (record['value'] ?? {}) };
|
|
109
|
+
}
|
|
110
|
+
if (record['outcome'] === 'refused') {
|
|
111
|
+
return {
|
|
112
|
+
outcome: 'refused',
|
|
113
|
+
code: String(record['code']),
|
|
114
|
+
rule: String(record['rule']),
|
|
115
|
+
// The sentence is worded for this transport and is never compared
|
|
116
|
+
// across routes (§3.14), so it is carried but not read back for the
|
|
117
|
+
// comparison.
|
|
118
|
+
message: typeof record['message'] === 'string' ? record['message'] : '',
|
|
119
|
+
...(record['details'] === undefined
|
|
120
|
+
? {}
|
|
121
|
+
: { details: record['details'] }),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`the result names no outcome: ${line}`);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* One line in, as an async iterable, because that is what the loop reads.
|
|
128
|
+
*
|
|
129
|
+
* The loop's input is asynchronous because a real standard input is; a
|
|
130
|
+
* driver supplying one line has nothing to wait for, so this adapts a value
|
|
131
|
+
* to the shape rather than pretending to be asynchronous.
|
|
132
|
+
*/
|
|
133
|
+
function oneLine(line) {
|
|
134
|
+
return {
|
|
135
|
+
[Symbol.asyncIterator]: () => {
|
|
136
|
+
let sent = false;
|
|
137
|
+
return {
|
|
138
|
+
next: () => {
|
|
139
|
+
if (sent) {
|
|
140
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
141
|
+
}
|
|
142
|
+
sent = true;
|
|
143
|
+
return Promise.resolve({ done: false, value: line });
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** The tool surface's conformance driver. */
|
|
150
|
+
export const toolStdioConformanceDriver = {
|
|
151
|
+
adapter: toolStdioAdapter,
|
|
152
|
+
run: async (service, testCase, observe) => {
|
|
153
|
+
const written = [];
|
|
154
|
+
// Built through the surface's own encoder rather than by hand, because
|
|
155
|
+
// the framing is the thing under test as much as the dispatch is.
|
|
156
|
+
const request = encodeMessage({
|
|
157
|
+
id: 1,
|
|
158
|
+
method: METHODS.callTool,
|
|
159
|
+
params: toolCallFor(testCase),
|
|
160
|
+
});
|
|
161
|
+
// Confirm the line this driver built is one the surface's own decoder
|
|
162
|
+
// accepts. Without this a malformed request would be answered with a
|
|
163
|
+
// protocol error, `outcomeFrom` would raise, and the case would fail with
|
|
164
|
+
// a message about the surface rather than about the driver.
|
|
165
|
+
const decoded = decodeMessage(request);
|
|
166
|
+
if (decoded.kind !== 'request') {
|
|
167
|
+
// Every line this driver builds carries an identifier and a method, so
|
|
168
|
+
// neither of the other two readings is reachable from here. The reason
|
|
169
|
+
// is named anyway rather than reported as a bare kind, because the
|
|
170
|
+
// whole point of the check is that a driver defect should say so.
|
|
171
|
+
const why = decoded.kind === 'malformed' ? decoded.why : 'it decoded as a notification';
|
|
172
|
+
throw new Error(`the driver built a line the surface rejects: ${why}`);
|
|
173
|
+
}
|
|
174
|
+
await serveSession(oneLine(request), {
|
|
175
|
+
service,
|
|
176
|
+
streams: { write: (line) => written.push(line) },
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
outcome: outcomeFrom(written[0]),
|
|
180
|
+
driverCalls: observe.driverCalls(),
|
|
181
|
+
liveClaimCount: observe.liveClaimCount(),
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
};
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire format the tool surface speaks, implemented directly.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this is written out rather than taken from a package ────────────
|
|
5
|
+
*
|
|
6
|
+
* `MILESTONES.md`'s binding for this row: no runtime dependency is added for
|
|
7
|
+
* it, because the protocol is a documented wire format and a framework would
|
|
8
|
+
* be a design regression rather than a convenience. The whole of what a
|
|
9
|
+
* caller needs is here: newline-delimited JSON objects on standard input and
|
|
10
|
+
* standard output, an integer or string identifier correlating a response
|
|
11
|
+
* with its request, and a small fixed set of method names.
|
|
12
|
+
*
|
|
13
|
+
* The cost of that choice is stated rather than implied: this implements the
|
|
14
|
+
* subset this service uses — the handshake, listing the tools and calling one
|
|
15
|
+
* — and nothing else. A caller sending a method outside that set gets an
|
|
16
|
+
* explicit `method_not_found` rather than silence, which is the part that
|
|
17
|
+
* makes the subset honest instead of merely small.
|
|
18
|
+
*
|
|
19
|
+
* ── The envelope is JSON-RPC 2.0, because a client will not speak anything ─
|
|
20
|
+
*
|
|
21
|
+
* The subset above was, for a time, *only* those last two methods, framed as
|
|
22
|
+
* bare JSON objects. That was reachable by a program written against this
|
|
23
|
+
* file and by nothing else: a Model Context Protocol client opens with
|
|
24
|
+
* `initialize`, waits for the server's `protocolVersion`, `capabilities` and
|
|
25
|
+
* `serverInfo`, sends `notifications/initialized`, and only then asks what
|
|
26
|
+
* tools exist. Against a surface with no `initialize` the very first message
|
|
27
|
+
* is answered `method_not_found` and the client hangs up — so ten working,
|
|
28
|
+
* conformance-proven tools sat behind a doorway that did not open.
|
|
29
|
+
*
|
|
30
|
+
* So the envelope here is the real one: `jsonrpc: "2.0"` on every message,
|
|
31
|
+
* the identifier echoed, `result` exclusive-or `error`, and errors carrying
|
|
32
|
+
* JSON-RPC's *numeric* codes. Implemented against Model Context Protocol
|
|
33
|
+
* revision {@link PROTOCOL_VERSION}
|
|
34
|
+
* (https://modelcontextprotocol.io/specification/2025-06-18).
|
|
35
|
+
*
|
|
36
|
+
* ── Two code spaces, deliberately, and both of them travel ──────────────
|
|
37
|
+
*
|
|
38
|
+
* JSON-RPC requires a small fixed set of integers. This surface already had a
|
|
39
|
+
* vocabulary of its own — `method_not_found`, `tool_not_found`,
|
|
40
|
+
* `malformed_call`, `unexpected_failure` — and those distinctions are
|
|
41
|
+
* load-bearing rather than decorative: `SCHEMA.md` and two build checks read
|
|
42
|
+
* them by name, and the paragraph on {@link ProtocolError} explains why a
|
|
43
|
+
* caller must be able to tell a typo from a capacity refusal.
|
|
44
|
+
*
|
|
45
|
+
* Collapsing the vocabulary into five integers would have thrown that away to
|
|
46
|
+
* satisfy a transport. So **both travel**: {@link ProtocolError.code} keeps
|
|
47
|
+
* the name, and {@link toJsonRpcCode} maps it onto the integer the transport
|
|
48
|
+
* requires. A generic client reads the integer and behaves correctly; a
|
|
49
|
+
* caller who knows this service reads the name and keeps every distinction it
|
|
50
|
+
* had before. The mapping is the only thing that is new, and it is one
|
|
51
|
+
* function rather than a rewrite.
|
|
52
|
+
*
|
|
53
|
+
* ── Framing: one JSON object per line ───────────────────────────────────
|
|
54
|
+
*
|
|
55
|
+
* A line is a message. That is the whole framing rule, and it is chosen over
|
|
56
|
+
* a length-prefixed header for a reason worth keeping: a line-delimited
|
|
57
|
+
* stream is readable by a person watching it, debuggable with ordinary text
|
|
58
|
+
* tools, and has exactly one failure mode — a message containing a raw
|
|
59
|
+
* newline. {@link encodeMessage} is where that is prevented, by serialising
|
|
60
|
+
* without pretty-printing, and {@link decodeMessage} refuses a line it cannot
|
|
61
|
+
* parse rather than skipping it silently.
|
|
62
|
+
*/
|
|
63
|
+
/** The JSON-RPC version string every message on this surface carries. */
|
|
64
|
+
export const JSONRPC_VERSION = '2.0';
|
|
65
|
+
/**
|
|
66
|
+
* The Model Context Protocol revision this surface implements.
|
|
67
|
+
*
|
|
68
|
+
* Named as a constant because it is answered to a client during negotiation
|
|
69
|
+
* and asserted by a test; a version that lived only in a string literal
|
|
70
|
+
* inside a handler would drift from the one the documentation claims.
|
|
71
|
+
*/
|
|
72
|
+
export const PROTOCOL_VERSION = '2025-06-18';
|
|
73
|
+
/**
|
|
74
|
+
* The revisions this surface will agree to speak.
|
|
75
|
+
*
|
|
76
|
+
* Ordered newest first, which is what makes {@link negotiateProtocolVersion}
|
|
77
|
+
* able to answer "the newest thing we both know" without a second list. A
|
|
78
|
+
* revision is added here only when this surface has actually been made to
|
|
79
|
+
* speak it — the point of the list is that it is a claim, not a wish.
|
|
80
|
+
*/
|
|
81
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = [PROTOCOL_VERSION, '2025-03-26'];
|
|
82
|
+
/**
|
|
83
|
+
* JSON-RPC's own error codes, which are integers and are not negotiable.
|
|
84
|
+
*
|
|
85
|
+
* These are the transport's, not this service's. They exist so a client that
|
|
86
|
+
* has never heard of this service still behaves correctly — retry, report,
|
|
87
|
+
* give up — and the names above are what a caller that *has* heard of it
|
|
88
|
+
* reads instead. See {@link toJsonRpcCode} for why both travel.
|
|
89
|
+
*/
|
|
90
|
+
export const JSONRPC_ERROR_CODES = {
|
|
91
|
+
parseError: -32700,
|
|
92
|
+
invalidRequest: -32600,
|
|
93
|
+
methodNotFound: -32601,
|
|
94
|
+
invalidParams: -32602,
|
|
95
|
+
internalError: -32603,
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* This surface's refusal vocabulary, mapped onto the transport's integers.
|
|
99
|
+
*
|
|
100
|
+
* **The mapping is many-to-one and that is the point.** `tool_not_found` and
|
|
101
|
+
* `method_not_found` are genuinely different facts — one means the caller
|
|
102
|
+
* mistyped a tool, the other that it spoke a method this surface does not
|
|
103
|
+
* implement — and JSON-RPC has one integer for both. Rather than pick a
|
|
104
|
+
* winner, the name survives on {@link ProtocolError.code} and the integer is
|
|
105
|
+
* derived here. Nothing reading the name loses a distinction; nothing reading
|
|
106
|
+
* the integer sees a code it does not recognise.
|
|
107
|
+
*
|
|
108
|
+
* An unrecognised name maps to `internalError`, which is the honest answer:
|
|
109
|
+
* a code this function has not been taught about is, from the transport's
|
|
110
|
+
* point of view, this surface failing to describe itself.
|
|
111
|
+
*/
|
|
112
|
+
export function toJsonRpcCode(code) {
|
|
113
|
+
switch (code) {
|
|
114
|
+
case 'method_not_found':
|
|
115
|
+
case 'tool_not_found':
|
|
116
|
+
return JSONRPC_ERROR_CODES.methodNotFound;
|
|
117
|
+
case 'malformed_message':
|
|
118
|
+
return JSONRPC_ERROR_CODES.invalidRequest;
|
|
119
|
+
case 'malformed_call':
|
|
120
|
+
case 'unsupported_protocol_version':
|
|
121
|
+
return JSONRPC_ERROR_CODES.invalidParams;
|
|
122
|
+
default:
|
|
123
|
+
return JSONRPC_ERROR_CODES.internalError;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Agree a revision with the caller.
|
|
128
|
+
*
|
|
129
|
+
* The rule the specification gives is short: answer with the caller's own
|
|
130
|
+
* revision when this surface speaks it, and otherwise answer with the newest
|
|
131
|
+
* one it does speak. **The second branch is not a failure** — a client asking
|
|
132
|
+
* for something newer than this surface knows is told what is on offer and
|
|
133
|
+
* decides for itself whether to continue, which is the difference between a
|
|
134
|
+
* negotiation and a rejection. So this returns a version in every case and
|
|
135
|
+
* never throws: a handshake that crashed on an unfamiliar version string
|
|
136
|
+
* would break on the next revision of the specification rather than on
|
|
137
|
+
* anything wrong with the caller.
|
|
138
|
+
*/
|
|
139
|
+
export function negotiateProtocolVersion(requested) {
|
|
140
|
+
if (typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
|
|
141
|
+
return requested;
|
|
142
|
+
}
|
|
143
|
+
return PROTOCOL_VERSION;
|
|
144
|
+
}
|
|
145
|
+
/** The methods this surface answers. Anything else is `method_not_found`. */
|
|
146
|
+
export const METHODS = {
|
|
147
|
+
/** Open the session: negotiate a revision and describe the server. */
|
|
148
|
+
initialize: 'initialize',
|
|
149
|
+
/** Enumerate the tools, with their descriptions and argument schemas. */
|
|
150
|
+
listTools: 'tools/list',
|
|
151
|
+
/** Call one tool by name. */
|
|
152
|
+
callTool: 'tools/call',
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* The notifications this surface accepts — and answers with silence.
|
|
156
|
+
*
|
|
157
|
+
* `notifications/initialized` is the client saying the handshake is complete.
|
|
158
|
+
* There is nothing to do with it and nothing to send back; accepting it
|
|
159
|
+
* without replying is the whole of the requirement.
|
|
160
|
+
*
|
|
161
|
+
* `notifications/cancelled` is listed because a client may send it at any
|
|
162
|
+
* time and a surface that treated it as an unknown *method* would try to
|
|
163
|
+
* answer a notification — the one thing a notification must never draw. This
|
|
164
|
+
* surface answers a call when it finishes, so there is no work to interrupt;
|
|
165
|
+
* ignoring it is both correct and complete.
|
|
166
|
+
*/
|
|
167
|
+
export const NOTIFICATIONS = {
|
|
168
|
+
initialized: 'notifications/initialized',
|
|
169
|
+
cancelled: 'notifications/cancelled',
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Serialise a message to one line.
|
|
173
|
+
*
|
|
174
|
+
* `JSON.stringify` without an indent argument emits no newline of its own,
|
|
175
|
+
* and every newline inside a string value is escaped as `\n` by the
|
|
176
|
+
* serialiser, so the result is guaranteed to be a single line. That guarantee
|
|
177
|
+
* is the framing, so it is asserted by a test rather than assumed here.
|
|
178
|
+
*/
|
|
179
|
+
export function encodeMessage(message) {
|
|
180
|
+
return JSON.stringify(withEnvelope(message));
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Put the JSON-RPC envelope on a message on its way out.
|
|
184
|
+
*
|
|
185
|
+
* Two properties are enforced here rather than trusted to every call site,
|
|
186
|
+
* because both are the kind of thing that is right nine times and wrong once:
|
|
187
|
+
*
|
|
188
|
+
* **`jsonrpc: "2.0"` leads.** Key order is not semantically meaningful, but
|
|
189
|
+
* this stream is read by people as well as programs — that is the stated
|
|
190
|
+
* reason the framing is lines rather than length prefixes — and a message
|
|
191
|
+
* whose first field names the protocol is one a reader can identify at a
|
|
192
|
+
* glance.
|
|
193
|
+
*
|
|
194
|
+
* **`result` and `error` are exclusive.** JSON-RPC requires exactly one, and
|
|
195
|
+
* a response carrying both is the ambiguity a client cannot resolve. So an
|
|
196
|
+
* error wins and `result` is dropped, rather than both being written and the
|
|
197
|
+
* contradiction shipped; and an error is rewritten to carry the transport's
|
|
198
|
+
* integer in `code` with this surface's own name preserved beside it, which
|
|
199
|
+
* is the whole of the two-code-spaces bargain in {@link toJsonRpcCode}.
|
|
200
|
+
*/
|
|
201
|
+
function withEnvelope(message) {
|
|
202
|
+
if ('method' in message) {
|
|
203
|
+
return { jsonrpc: JSONRPC_VERSION, ...message };
|
|
204
|
+
}
|
|
205
|
+
if (message.error !== undefined) {
|
|
206
|
+
return {
|
|
207
|
+
jsonrpc: JSONRPC_VERSION,
|
|
208
|
+
id: message.id,
|
|
209
|
+
error: {
|
|
210
|
+
code: toJsonRpcCode(message.error.code),
|
|
211
|
+
message: message.error.message,
|
|
212
|
+
// The name is kept, in the place JSON-RPC reserves for exactly this.
|
|
213
|
+
// A caller reading `error.code` gets the integer it expects; one that
|
|
214
|
+
// knows this service reads the name and keeps the distinction the
|
|
215
|
+
// integer cannot carry.
|
|
216
|
+
data: { code: message.error.code },
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return { jsonrpc: JSONRPC_VERSION, id: message.id, result: message.result };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Read one line as a request.
|
|
224
|
+
*
|
|
225
|
+
* **A malformed line is reported, never skipped.** A surface that ignored
|
|
226
|
+
* what it could not parse would leave a caller waiting forever for a response
|
|
227
|
+
* to a message the surface decided not to mention — and from the caller's
|
|
228
|
+
* side that is indistinguishable from a hang. So this returns a description
|
|
229
|
+
* of the problem, and the loop above answers with it where there is an
|
|
230
|
+
* identifier to answer to.
|
|
231
|
+
*
|
|
232
|
+
* ── Why the id KEY's presence is checked separately from its value ───────
|
|
233
|
+
*
|
|
234
|
+
* `record['id']` on an object with no `id` property and `record['id']` on
|
|
235
|
+
* one with `id: null` are both `undefined` in JavaScript, so a check that
|
|
236
|
+
* reads only the value cannot tell "there is no id to answer to" from "there
|
|
237
|
+
* is an id, and it is unusable". Those are different situations: the first
|
|
238
|
+
* is a notification-shaped line with genuinely nobody to answer; the second
|
|
239
|
+
* is a request-shaped line whose id this surface cannot echo back, but can
|
|
240
|
+
* still answer with `id: null` — JSON-RPC's own way of saying "I received
|
|
241
|
+
* this, and could not identify it". Losing that distinction is what let a
|
|
242
|
+
* caller sending `id: null` or `id: true` — a malformed request, not a
|
|
243
|
+
* notification — be read as a notification and dropped, indistinguishable
|
|
244
|
+
* from a hang.
|
|
245
|
+
*/
|
|
246
|
+
export function decodeMessage(line) {
|
|
247
|
+
let parsed;
|
|
248
|
+
try {
|
|
249
|
+
parsed = JSON.parse(line);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return { kind: 'malformed', id: undefined, why: 'the line is not JSON' };
|
|
253
|
+
}
|
|
254
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
255
|
+
return { kind: 'malformed', id: undefined, why: 'a message is a JSON object' };
|
|
256
|
+
}
|
|
257
|
+
const record = parsed;
|
|
258
|
+
const hasIdKey = 'id' in record;
|
|
259
|
+
const rawId = record['id'];
|
|
260
|
+
const id = typeof rawId === 'number' || typeof rawId === 'string' ? rawId : undefined;
|
|
261
|
+
const method = record['method'];
|
|
262
|
+
const params = record['params'];
|
|
263
|
+
const paramsAreWrong = params !== undefined && (params === null || typeof params !== 'object');
|
|
264
|
+
// **A message with no id KEY at all but a method is a NOTIFICATION, not a
|
|
265
|
+
// malformed message**, and reading it as the latter is how a surface ends
|
|
266
|
+
// up either answering one or refusing the handshake that follows it. The
|
|
267
|
+
// key's absence — not merely an unusable value — is the signal, so it is
|
|
268
|
+
// tested before anything is concluded from it.
|
|
269
|
+
if (!hasIdKey && typeof method === 'string') {
|
|
270
|
+
if (paramsAreWrong) {
|
|
271
|
+
// Nobody to answer — a notification has no identifier by construction —
|
|
272
|
+
// so the loop above logs this rather than replying to it.
|
|
273
|
+
return { kind: 'malformed', id: undefined, why: 'params, when present, is an object' };
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
kind: 'notification',
|
|
277
|
+
notification: {
|
|
278
|
+
method,
|
|
279
|
+
...(params === undefined ? {} : { params: params }),
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (id === undefined) {
|
|
284
|
+
// The id key is present (checked above) but its value is neither a
|
|
285
|
+
// number nor a string — `null`, `true`, an object, an array. There is an
|
|
286
|
+
// id to answer to; this surface simply cannot echo the caller's own
|
|
287
|
+
// value back, so it answers with `id: null` rather than staying silent.
|
|
288
|
+
// Absent the id key entirely, this line would have taken the
|
|
289
|
+
// notification branch above and never reached here.
|
|
290
|
+
return {
|
|
291
|
+
kind: 'malformed',
|
|
292
|
+
id: hasIdKey ? null : undefined,
|
|
293
|
+
why: 'a message carries an id, which is a number or a string',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (typeof method !== 'string') {
|
|
297
|
+
return { kind: 'malformed', id, why: 'a message carries a method, which is a string' };
|
|
298
|
+
}
|
|
299
|
+
if (paramsAreWrong) {
|
|
300
|
+
return { kind: 'malformed', id, why: 'params, when present, is an object' };
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
kind: 'request',
|
|
304
|
+
request: {
|
|
305
|
+
id,
|
|
306
|
+
method,
|
|
307
|
+
...(params === undefined ? {} : { params: params }),
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|