Skip to content

Commit ebccebb

Browse files
alistair3149claude
andauthored
Stop quoting the address a redirect sent the query to (#569)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 543386a commit ebccebb

3 files changed

Lines changed: 178 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
2626
- `delete-page` no longer sends an empty deletion reason when the wiki sets `attributeEdits: false` and the call gave no comment. MediaWiki recorded that empty reason verbatim, leaving a blank deletion log entry; with no reason sent at all it can autogenerate its own `content was: …` reason instead.
2727
- `update-page` no longer advertises itself as idempotent: in `mode='append'` and `mode='prepend'` it never was, so a client replaying a call whose result never arrived adds the content a second time. A replace resends the same content rather than adding to it.
2828
- `upload-file-from-url` and `update-file-from-url` no longer leak a connection when they refuse a source URL whose declared size is over `MCP_UPLOAD_MAX_BYTES`. Each refused call held one connection open for as long as the server ran.
29+
- A connection failure from `wikibase-query` no longer quotes the address a redirect sent the query to. A `307` or `308` moves the query to a second address that inherits the endpoint's path, and the failure printed that address in full; it now reads as the query service, or as its host alone when it lies elsewhere. Text the query service wrote itself still reaches the caller as the service wrote it.
2930
- A host correcting its clock no longer changes what the server does with elapsed time: rate-limit allowances, the shutdown grace window, the readiness and extension-detection caches, and the window a hosted sign-in has to finish. Measured against the wall clock, a backwards NTP step could refuse a caller that had barely touched its rate-limit allowance with a `Retry-After` of up to an hour, and a forwards step could end a graceful shutdown early, aborting the tool calls it was waiting for.
3031

3132
## [0.16.0] - 2026-07-30

src/tools/extensions/wikibase/sparql.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ const MAX_SERVICE_MESSAGE_CHARS = 500;
2020
/** A line break inside a cell, which `GROUP_CONCAT` puts there routinely. */
2121
const LINE_BREAK = /\r\n|[\r\n]/g;
2222

23+
/** An absolute URL sitting in a message, running to the first space. */
24+
const ABSOLUTE_URL = /https?:\/\/\S+/g;
25+
26+
/**
27+
* What a URL is called once the parts that can hold a token are gone. Reads as
28+
* the subject of the sentence a transport error puts it in.
29+
*/
30+
const QUERY_SERVICE = "the wiki's query service";
31+
2332
/** A query service failure already classified into an MCP error category. */
2433
export class SparqlError extends Error {
2534
public constructor(
@@ -157,7 +166,7 @@ function classifyQueryFailure(err: unknown, endpoint: string): SparqlError {
157166
);
158167
}
159168
const message = err instanceof Error ? err.message : String(err);
160-
return new SparqlError('upstream_failure', withoutEndpoint(message, endpoint));
169+
return new SparqlError('upstream_failure', transportMessage(message, endpoint));
161170
}
162171

163172
/**
@@ -172,13 +181,58 @@ function originOf(target: string): string {
172181
}
173182

174183
/**
175-
* The endpoint named rather than quoted. A transport error quotes the URL it
176-
* failed on, and a query service echoes the request URI into its own error page.
177-
* That URL is the operator's to know: it can carry a token in its path or query,
178-
* and it reaches the caller and the logs from here.
184+
* A message this server wrote about a request it made itself, with every URL in
185+
* it cut back. Substituting the endpoint would reach only the endpoint: a 307 or
186+
* 308 is followed, and a relative Location resolves against the endpoint's path,
187+
* so the hop after it fails on a URL that carries the endpoint's token under a
188+
* spelling no substitution finds. Reading each URL for what it is catches the
189+
* endpoint too, so the substitution has no work left to do here.
190+
*
191+
* Only this server's own messages come through here. A query service's
192+
* diagnostics are the caller's IRIs quoted back, and cutting those back would
193+
* answer a malformed query by deleting the term it complained about.
194+
*/
195+
function transportMessage(message: string, endpoint: string): string {
196+
const service = endpointOrigin(endpoint);
197+
return message.replace(ABSOLUTE_URL, (url) => urlAsRead(url, service));
198+
}
199+
200+
/**
201+
* The origin the endpoint's own URLs carry, or nothing when the wiki published an
202+
* address that is not one. Read the way the transport reads it, since a wiki
203+
* names a server it answers under either scheme without a scheme of its own.
204+
*/
205+
function endpointOrigin(endpoint: string): string | undefined {
206+
try {
207+
return originOf(endpoint.startsWith('//') ? `https:${endpoint}` : endpoint);
208+
} catch {
209+
return undefined;
210+
}
211+
}
212+
213+
/**
214+
* A URL from the endpoint's own origin is the endpoint reached by another path,
215+
* and is named as one. Any other keeps its origin, which is how an operator sees
216+
* the host a redirect went to, or the host to add to `MCP_TRUSTED_HOSTS`. What
217+
* does not parse cannot be cut back, so it goes entirely.
218+
*/
219+
function urlAsRead(url: string, service: string | undefined): string {
220+
try {
221+
const origin = originOf(url);
222+
return origin === service ? QUERY_SERVICE : origin;
223+
} catch {
224+
return QUERY_SERVICE;
225+
}
226+
}
227+
228+
/**
229+
* The endpoint named rather than quoted, for the query service's own error page,
230+
* which echoes the request URI into it. That URL is the operator's to know: it
231+
* can carry a token in its path or query, and it reaches the caller and the logs
232+
* from here.
179233
*/
180234
function withoutEndpoint(message: string, endpoint: string): string {
181-
return endpoint === '' ? message : message.split(endpoint).join("the wiki's query service");
235+
return endpoint === '' ? message : message.split(endpoint).join(QUERY_SERVICE);
182236
}
183237

184238
function categoryForStatus(status: number): ErrorCategory {

tests/tools/extensions/wikibase/sparql.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,123 @@ describe('runSparqlQuery', () => {
309309
expect(error.message).toBe("request to the wiki's query service failed");
310310
});
311311

312+
// A 307 or 308 is followed, and a relative Location keeps the endpoint's path,
313+
// so the hop after it fails on a URL that carries the same token and is not a
314+
// substring the endpoint substitution can find.
315+
it('names a redirect-derived URL as the endpoint in an unclassified failure', async () => {
316+
const endpoint = 'https://query.example.org/hunter2/sparql';
317+
vi.mocked(postForm).mockRejectedValue(
318+
new FetchError(
319+
'request to https://query.example.org/hunter2/results failed, reason: socket hang up',
320+
'system',
321+
),
322+
);
323+
324+
const error = await failureOf(runSparqlQuery(endpoint, CATS, MANY_ROWS));
325+
326+
expect(error.message).toBe(
327+
"request to the wiki's query service failed, reason: socket hang up",
328+
);
329+
});
330+
331+
// A Location that resolves to the endpoint plus something derives a URL the
332+
// endpoint is a prefix of, which a substitution takes the front off rather than
333+
// missing outright — leaving what the redirect appended standing.
334+
it('names a redirect-derived URL that extends the endpoint', async () => {
335+
const endpoint = 'https://query.example.org/hunter2/sparql';
336+
vi.mocked(postForm).mockRejectedValue(
337+
new FetchError(
338+
'request to https://query.example.org/hunter2/sparql?sig=hunter3 failed, reason: socket hang up',
339+
'system',
340+
),
341+
);
342+
343+
const error = await failureOf(runSparqlQuery(endpoint, CATS, MANY_ROWS));
344+
345+
expect(error.message).toBe(
346+
"request to the wiki's query service failed, reason: socket hang up",
347+
);
348+
});
349+
350+
it('cuts back every URL in a message, not only the first one it meets', async () => {
351+
const endpoint = 'https://query.example.org/hunter2/sparql';
352+
vi.mocked(postForm).mockRejectedValue(
353+
new Error(
354+
'request to https://query.example.org/hunter2/a failed after https://query.example.org/hunter2/b',
355+
),
356+
);
357+
358+
const error = await failureOf(runSparqlQuery(endpoint, CATS, MANY_ROWS));
359+
360+
expect(error.message).toBe(
361+
"request to the wiki's query service failed after the wiki's query service",
362+
);
363+
});
364+
365+
// A wiki names a server it answers under either scheme without a scheme of its
366+
// own, and the transport reads that as `https`.
367+
it('names a URL from a protocol-relative endpoint as the endpoint', async () => {
368+
vi.mocked(postForm).mockRejectedValue(
369+
new Error('request to https://query.example.org/hunter2/results failed'),
370+
);
371+
372+
const error = await failureOf(
373+
runSparqlQuery('//query.example.org/hunter2/sparql', CATS, MANY_ROWS),
374+
);
375+
376+
expect(error.message).toBe("request to the wiki's query service failed");
377+
});
378+
379+
// An endpoint that will not parse leaves nothing to compare a URL against, and
380+
// that must cost the other URLs in the message their host, not spread the
381+
// endpoint's name over hosts that are not it.
382+
it('keeps a foreign host when the wiki publishes an endpoint that is not a URL', async () => {
383+
vi.mocked(postForm).mockRejectedValue(
384+
new Error('Refusing to fetch URL: https://backend.internal/hunter2/results'),
385+
);
386+
387+
const error = await failureOf(runSparqlQuery('/query/sparql', CATS, MANY_ROWS));
388+
389+
expect(error.message).toBe('Refusing to fetch URL: https://backend.internal');
390+
});
391+
392+
it('names what it cannot read as a URL rather than passing it through', async () => {
393+
const endpoint = 'https://query.example.org/hunter2/sparql';
394+
vi.mocked(postForm).mockRejectedValue(new Error('request to https://[hunter2/results failed'));
395+
396+
const error = await failureOf(runSparqlQuery(endpoint, CATS, MANY_ROWS));
397+
398+
expect(error.message).toBe("request to the wiki's query service failed");
399+
});
400+
401+
// A hop to another host is refused by address before it is sent, and only the
402+
// host names what the operator has to allow.
403+
it('keeps the host of a URL from outside the endpoint, without its path', async () => {
404+
const endpoint = 'https://query.example.org/hunter2/sparql';
405+
vi.mocked(postForm).mockRejectedValue(
406+
new Error(
407+
'Refusing to fetch URL resolving to non-public address 10.0.0.1 (private); the server operator must add the host to MCP_TRUSTED_HOSTS to allow it: https://backend.internal/hunter2/results',
408+
),
409+
);
410+
411+
const error = await failureOf(runSparqlQuery(endpoint, CATS, MANY_ROWS));
412+
413+
expect(error.message).toContain('https://backend.internal');
414+
expect(error.message).toContain('MCP_TRUSTED_HOSTS');
415+
expect(error.message).not.toContain('hunter2');
416+
});
417+
418+
// A parser message is the caller's own query quoted back, so the IRIs in it are
419+
// the complaint rather than something to hide.
420+
it('leaves the IRIs of the query alone in a service message', async () => {
421+
const body = 'Encountered "<http://example.org/p>" at line 1, column 30.';
422+
vi.mocked(postForm).mockRejectedValue(new HttpStatusError(400, ENDPOINT, body));
423+
424+
const error = await failureOf(runSparqlQuery(ENDPOINT, CATS, MANY_ROWS));
425+
426+
expect(error.message).toBe(body);
427+
});
428+
312429
// A query service that echoes the request URI into its error page hands the
313430
// token straight back, and the service message reaches the caller and the logs.
314431
it('names the endpoint rather than quoting it back in a service message', async () => {

0 commit comments

Comments
 (0)