browser-broker 0.3.1 → 0.4.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/README.md +11 -3
- package/dist/package.json +1 -1
- package/dist/src/tool/session.js +96 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,9 +92,17 @@ every run — so a published release arrives without anything being pulled or re
|
|
|
92
92
|
}
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
`npx` costs a registry round-trip on every spawn — **measured at ~1.1s warm and ~3.7s cold**, against
|
|
96
|
+
~0.4s for a path on disk. Against a typical 30s MCP connect timeout that is ample headroom, so prefer
|
|
97
|
+
this form even on a machine that develops the service: a config file shared between machines cannot
|
|
98
|
+
carry an absolute path that is correct on all of them.
|
|
99
|
+
|
|
100
|
+
⚠️ **If handshakes start timing out, prune the npx cache before blaming npx.** An unpruned 837MB
|
|
101
|
+
`_npx` cache once pushed spawn cost to 9.7–33.2s and blew a 30s connect timeout outright. The cost is
|
|
102
|
+
the cache, not the mechanism. Two related traps: `--prefer-offline` can serve a packument that
|
|
103
|
+
predates a release, so npx resolves a version it then cannot fetch (`ETARGET`); and the local npm
|
|
104
|
+
cache lags the registry independently, so `npm cache clean --force` is the fix when `npm view` and
|
|
105
|
+
`npx` disagree about what exists.
|
|
98
106
|
|
|
99
107
|
### From a checkout
|
|
100
108
|
|
package/dist/package.json
CHANGED
package/dist/src/tool/session.js
CHANGED
|
@@ -70,6 +70,13 @@ export function listTools() {
|
|
|
70
70
|
required: tool.arguments
|
|
71
71
|
.filter((argument) => argument.required)
|
|
72
72
|
.map((argument) => argument.name),
|
|
73
|
+
// **The schema says what the surface enforces.** `handleRequest`
|
|
74
|
+
// refuses a call carrying a name the tool does not declare, so the
|
|
75
|
+
// schema advertising anything less would describe a more permissive
|
|
76
|
+
// surface than the one that answers — and a validating client would
|
|
77
|
+
// forward a call it could have caught itself. Saying it here lets the
|
|
78
|
+
// contract be read rather than discovered by refusal.
|
|
79
|
+
additionalProperties: false,
|
|
73
80
|
},
|
|
74
81
|
})),
|
|
75
82
|
};
|
|
@@ -159,6 +166,88 @@ function asContent(value) {
|
|
|
159
166
|
* them there with its own name carried alongside the integer. That design is
|
|
160
167
|
* untouched.
|
|
161
168
|
*/
|
|
169
|
+
/**
|
|
170
|
+
* Names a tool call carries that the tool does not declare.
|
|
171
|
+
*
|
|
172
|
+
* Returns them in the order the caller wrote them, so the message names what
|
|
173
|
+
* the caller can see in its own request rather than a re-sorted set.
|
|
174
|
+
*/
|
|
175
|
+
function undeclaredArguments(tool, args) {
|
|
176
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
const declared = new Set(tool.arguments.map((argument) => argument.name));
|
|
180
|
+
return Object.keys(args).filter((key) => !declared.has(key));
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Refuse a call carrying arguments the tool does not declare.
|
|
184
|
+
*
|
|
185
|
+
* ── Why this is a refusal rather than silent acceptance ─────────────────
|
|
186
|
+
*
|
|
187
|
+
* The record of arguments reaches the bridge as an opaque set of names, and
|
|
188
|
+
* the bridge reads the ones its operation knows. **A name no tool declares is
|
|
189
|
+
* therefore invisible to every layer that could object to it** — so without
|
|
190
|
+
* this guard it is dropped without a word while the call answers `accepted`.
|
|
191
|
+
*
|
|
192
|
+
* The shape that makes the cost concrete: `browser_evaluate` sent a `resize`
|
|
193
|
+
* alongside its `expression`. `resize` is not a capability that failed — the
|
|
194
|
+
* tool declares exactly `lease_key` and `expression`, so it is a name the
|
|
195
|
+
* tool has never had. Unguarded, the evaluation runs against the viewport the
|
|
196
|
+
* page already has, the reply says `accepted`, and the caller reads a number
|
|
197
|
+
* produced by a resize that never happened.
|
|
198
|
+
*
|
|
199
|
+
* That is the defect `scripts/check-argument-reachability.mjs` exists to
|
|
200
|
+
* prevent, arriving from the other side. Its header states the cost in this
|
|
201
|
+
* repository's own words: an inert argument "does not merely fail to help: it
|
|
202
|
+
* **manufactures evidence, and the evidence is not marked as manufactured**".
|
|
203
|
+
* There, a *declared* name was read by nothing; here, an *undeclared* name is
|
|
204
|
+
* accepted by everything. Both hand a caller a truthful-looking success for a
|
|
205
|
+
* request that was partly ignored, and a caller cannot tell either from a
|
|
206
|
+
* call that worked. The reachability check guards the first direction
|
|
207
|
+
* statically; this guards the second at the only point that can see it.
|
|
208
|
+
*
|
|
209
|
+
* **The alternative — accept and warn — was rejected deliberately.** A
|
|
210
|
+
* warning rides on a response whose `outcome` still reads `accepted` and
|
|
211
|
+
* whose `isError` is still false, so a caller that branches on those two
|
|
212
|
+
* fields — which is how a client decides whether its request happened —
|
|
213
|
+
* cannot see the warning at all, and the false conclusion survives.
|
|
214
|
+
* The one caller a warning reaches is a caller already reading the prose,
|
|
215
|
+
* which is the caller least likely to have made the mistake. This surface's
|
|
216
|
+
* premise is that a refusal is the service working (§5.6) and that the tool
|
|
217
|
+
* list is the contract a caller discovers by trying — an argument name that
|
|
218
|
+
* is not rejected cannot be distinguished from one that is supported.
|
|
219
|
+
*
|
|
220
|
+
* **The compatibility cost is real and is accepted.** 0.3.1 is published, so
|
|
221
|
+
* a caller sending a stray key gets a refusal where it got a success. That
|
|
222
|
+
* caller's stray key was doing nothing then either: the behaviour it believed
|
|
223
|
+
* it had is the behaviour it never had, and the refusal is the first time it
|
|
224
|
+
* is told. Nothing that sends only declared names changes at all, and the
|
|
225
|
+
* conformance case table — authored in the service's spelling — is entirely
|
|
226
|
+
* within the declared names, so no in-tree caller moves.
|
|
227
|
+
*
|
|
228
|
+
* ── The message names the keys, because a refusal that does not is the
|
|
229
|
+
* defect ───────────────────────────────────────────────────────────────────
|
|
230
|
+
*
|
|
231
|
+
* It lists the offending names **and** what the tool does declare. A caller
|
|
232
|
+
* that mistyped sees its typo beside the word it meant, and one that invented
|
|
233
|
+
* a capability sees the whole of what is on offer, which is the fact it
|
|
234
|
+
* needed. Neither has to call `tools/list` to act on this.
|
|
235
|
+
*/
|
|
236
|
+
function undeclaredArgumentRefusal(tool, offending) {
|
|
237
|
+
const plural = offending.length === 1 ? 'argument' : 'arguments';
|
|
238
|
+
const declared = tool.arguments.map((argument) => argument.name);
|
|
239
|
+
const offered = declared.length === 0
|
|
240
|
+
? `${tool.name} takes no arguments.`
|
|
241
|
+
: `${tool.name} takes: ${declared.join(', ')}.`;
|
|
242
|
+
return refusalResult({
|
|
243
|
+
code: 'malformed_call',
|
|
244
|
+
rule: 'call.arguments_declared',
|
|
245
|
+
message: `${tool.name} does not take the ${plural} ${offending.map((key) => `"${key}"`).join(', ')}. ` +
|
|
246
|
+
`${offered} An undeclared argument is refused rather than ignored, because a call that ` +
|
|
247
|
+
`silently dropped it would report success for a request that did not happen.`,
|
|
248
|
+
details: { tool: tool.name, undeclared: offending, declared },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
162
251
|
function refusalResult(refusal) {
|
|
163
252
|
const structured = {
|
|
164
253
|
outcome: 'refused',
|
|
@@ -218,6 +307,13 @@ export async function handleRequest(request, options) {
|
|
|
218
307
|
},
|
|
219
308
|
};
|
|
220
309
|
}
|
|
310
|
+
// Checked before the service is called, so an undeclared name cannot reach
|
|
311
|
+
// an operation and be dropped there. See {@link undeclaredArgumentRefusal}
|
|
312
|
+
// for why this refuses rather than warns, and what that costs.
|
|
313
|
+
const undeclared = undeclaredArguments(tool, params['arguments']);
|
|
314
|
+
if (undeclared.length > 0) {
|
|
315
|
+
return { id: request.id, result: undeclaredArgumentRefusal(tool, undeclared) };
|
|
316
|
+
}
|
|
221
317
|
const outcome = await toolStdioAdapter.invoke(options.service, tool.operation, {
|
|
222
318
|
name,
|
|
223
319
|
arguments: params['arguments'],
|
package/package.json
CHANGED