Skip to content

Commit 21cd8f0

Browse files
fix(hub)!: close the discovery status oracle and tool name collisions (#19)
The discovery route refuses plain-http loopback targets unless the host sets allowLoopback, and reports an upstream non-2xx as a refusal without its status. McpError carries that status. A catalog listing a tool name twice is refused by tools/list, mcpTools and mcpServers, and a handle or server name containing "." is refused so no tool falls under another server's grant. mcpTools shares mcpServers' server-list check: an empty or repeated name, or a non-https URL, is refused before any request. BREAKING CHANGE: plain-http loopback discovery needs allowLoopback, and handles or server names containing "." are refused.
1 parent 183560e commit 21cd8f0

10 files changed

Lines changed: 218 additions & 23 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Every client call takes `{ fetch, timeoutMs, signal, session }` as its last argu
7070

7171
### Grants
7272

73-
Every call is checked as resource `tool:<name>`. The most specific match wins, and `deny` beats `ask` beats `allow`. `tool:<handle>.<tool>` grants one remote tool and `tool:<handle>.*` covers a whole server.
73+
Every call is checked as resource `tool:<name>`. The most specific match wins, and `deny` beats `ask` beats `allow`. `tool:<handle>.<tool>` grants one remote tool and `tool:<handle>.*` covers a whole server. A handle or server name may not contain `.`, and a catalog may not list a tool name twice, so no tool falls under another server's grant.
7474

7575
List `"<handle>.<tool>"` in `allowWithoutAsk` to let an `allow` grant run that tool without approval. The list is ignored for tools flagged `destructiveHint: true`.
7676

@@ -112,7 +112,7 @@ mountMcpDiscovery(mcpRoutes, {
112112

113113
Mount `mcpRoutes` on the hub app under `/api/tenants/:tenantId`, behind the hub's auth and tenant middleware.
114114

115-
`POST /api/tenants/:tenantId/mcp/discover` with `{ url, credentialId? }` returns `{ data: { serverInfo, tools } }`. `url` must be https (http only on loopback). `credentialId` names a tenant credential whose secret is sent as a bearer; a credential holding `MCP_NO_TOKEN_SENTINEL` from `@corbits/credential-http` sends no `authorization` header. Errors: 400 for a bad body or URL, 404 for an unknown credential or one that is not active or has expired, 422 when the server fails discovery, the secret is not a valid header value, or the request would leave the credential's origin. A 422 carries only this package's own messages; any other failure reads as a generic handshake error, so no response or `onError` text quotes the secret. `requireGrant` is the host's own grant middleware for this route.
115+
`POST /api/tenants/:tenantId/mcp/discover` with `{ url, credentialId? }` returns `{ data: { serverInfo, tools } }`. `url` must be https; plain-http loopback is refused unless the host sets `allowLoopback: true` for local development. `credentialId` names a tenant credential whose secret is sent as a bearer; a credential holding `MCP_NO_TOKEN_SENTINEL` from `@corbits/credential-http` sends no `authorization` header. Errors: 400 for a bad body or URL, 404 for an unknown credential or one that is not active or has expired, 422 when the server fails discovery, the secret is not a valid header value, or the request would leave the credential's origin. A 422 carries only this package's own messages and never the upstream HTTP status; any other failure reads as a generic handshake error, so no response or `onError` text quotes the secret. `requireGrant` is the host's own grant middleware for this route.
116116

117117
When a secret is sent, the fetch is pinned to the origin of the credential's provider `apiBaseUrl`; with no credential or a keyless one, to the URL's origin. Redirects are always refused, so the secret never leaves that origin. Each discovery request times out after 30 seconds.
118118

@@ -175,6 +175,7 @@ Grant the agent's principal `tool:deepwiki.*` for the whole server, or `tool:dee
175175
- Discovery with a credential pins to the credential's provider `apiBaseUrl` origin, not to the requested URL's origin. A credential with a secret whose provider has no `apiBaseUrl` is refused, and a URL on another origin needs `extraOrigins`. No origin is allowed by default.
176176
- `readCredentialSecret` is now `readCredential`, which returns `{ secret, origin? }`. `discoverMcpServer` takes `credential: { secret, origin? }` instead of `secret`.
177177
- `mcpInitialize` returns an `McpSession` (`protocolVersion`, `sessionId?`, `serverInfo?`) and sends `notifications/initialized`. Pass it as `{ session }` to `mcpListTools` and `mcpCallTool`; a stateful server needs it.
178+
- The discovery route refuses plain-http loopback URLs unless `allowLoopback` is set. Handles and server names containing `.`, and catalogs repeating a tool name, are refused. `mcpTools` refuses an empty or repeated server name, and a non-https URL, before any request.
178179
- `@intx/agent` and `@intx/harness` peers are `^0.4.0`.
179180
- Discovery rejects authorization-server metadata whose `issuer` differs from the one the protected resource names.
180181

‎src/client.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,3 +352,29 @@ describe("initialize hardening", () => {
352352
expect((failure as Error).message).toContain("within 50ms");
353353
});
354354
});
355+
356+
describe("tools/list", () => {
357+
test("a catalog with duplicate tool names is refused", async () => {
358+
const server = Bun.serve({
359+
port: 0,
360+
async fetch(req) {
361+
const parsed = RequestIdOnly(await req.json());
362+
if (parsed instanceof type.errors)
363+
return new Response("", { status: 400 });
364+
const tool = { name: "dup", inputSchema: {} };
365+
return Response.json({
366+
jsonrpc: "2.0",
367+
id: parsed.id,
368+
result: { tools: [tool, tool] },
369+
});
370+
},
371+
});
372+
try {
373+
await expect(mcpListTools(server.url.toString())).rejects.toThrow(
374+
/duplicate tool names/,
375+
);
376+
} finally {
377+
void server.stop(true);
378+
}
379+
});
380+
});

‎src/client.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,13 @@ const JsonRpcResponse = type({
5151
});
5252

5353
export class McpError extends Error {
54-
constructor(message: string) {
54+
/** The HTTP status, when the server answered with a non-2xx one. */
55+
readonly status: number | undefined;
56+
57+
constructor(message: string, status?: number) {
5558
super(message);
5659
this.name = "McpError";
60+
this.status = status;
5761
}
5862
}
5963

@@ -123,6 +127,7 @@ async function sendRequest(
123127
await response.body?.cancel();
124128
throw new McpError(
125129
`MCP server ${url} responded ${response.status} to ${method}`,
130+
response.status,
126131
);
127132
}
128133

@@ -188,6 +193,7 @@ async function sendNotification(
188193
if (!response.ok) {
189194
throw new McpError(
190195
`MCP server ${url} responded ${response.status} to ${method}`,
196+
response.status,
191197
);
192198
}
193199
}
@@ -351,6 +357,12 @@ export async function mcpListTools(
351357
`MCP server ${url} sent a malformed tools/list result: ${parsed.summary}`,
352358
);
353359
}
360+
const names = new Set(parsed.tools.map((tool) => tool.name));
361+
if (names.size !== parsed.tools.length) {
362+
throw new McpError(
363+
`MCP server ${url} sent a tools/list result with duplicate tool names`,
364+
);
365+
}
354366
return parsed.tools;
355367
}
356368

‎src/hub/discover.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ function appWith(
2929
readonly status?: string;
3030
readonly expiresAt?: Date | null;
3131
readonly onError?: (error: unknown) => void;
32+
readonly allowLoopback?: boolean;
3233
} = {},
3334
): Hono<TenantEnv> {
3435
const app = new Hono<TenantEnv>();
@@ -74,6 +75,7 @@ function appWith(
7475
},
7576
extraOrigins: opts.extraOrigins,
7677
onError: opts.onError,
78+
allowLoopback: opts.allowLoopback ?? true,
7779
} as unknown as MountMcpDiscoveryOpts;
7880
mountMcpDiscovery(app, mountOpts);
7981
return app;
@@ -337,4 +339,31 @@ describe("POST /mcp/discover", () => {
337339
expect(status).toBe(404);
338340
expect(handle.requestsSeen).toHaveLength(0);
339341
});
342+
343+
test("a plain-http loopback target is refused unless the host allows it", async () => {
344+
handle = startTestMcpServer();
345+
const { status, json } = await post(appWith({}, { allowLoopback: false }), {
346+
url: handle.url,
347+
});
348+
expect(status).toBe(400);
349+
expect(String(json["error"])).toContain("https");
350+
expect(handle.requestsSeen).toHaveLength(0);
351+
});
352+
353+
test("an upstream status is not echoed", async () => {
354+
const server = Bun.serve({
355+
port: 0,
356+
fetch: () => new Response("<html>admin</html>", { status: 403 }),
357+
});
358+
try {
359+
const { status, json } = await post(appWith({}), {
360+
url: new URL("/admin", server.url).href,
361+
});
362+
expect(status).toBe(422);
363+
expect(String(json["error"])).toEndWith("the server refused the request");
364+
expect(String(json["error"])).not.toContain("403");
365+
} finally {
366+
await server.stop(true);
367+
}
368+
});
340369
});

‎src/hub/discover.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export type MountMcpDiscoveryOpts = {
4242
* origin. Passed through to `@corbits/credential-http`. Empty by default.
4343
*/
4444
readonly extraOrigins?: Readonly<Record<string, readonly string[]>>;
45+
/**
46+
* Allow plain-http loopback targets, for local development only. Off by
47+
* default so the route cannot be pointed at the hub's own ports.
48+
*/
49+
readonly allowLoopback?: boolean;
4550
/** Reported when a discovery attempt fails; the caller only sees a message. */
4651
readonly onError?: (error: unknown, context: { url: string }) => void;
4752
};
@@ -205,6 +210,9 @@ export function mountMcpDiscovery(
205210
400,
206211
);
207212
}
213+
if (target.protocol === "http:" && opts.allowLoopback !== true) {
214+
return c.json({ error: "the MCP server URL must be https" }, 400);
215+
}
208216

209217
try {
210218
const found =
@@ -229,11 +237,16 @@ export function mountMcpDiscovery(
229237
return c.json({ data });
230238
} catch (cause) {
231239
// Only this package's own messages are passed on: anything else, such
232-
// as a fetch error, may quote the material the request carried.
240+
// as a fetch error, may quote the material the request carried. An
241+
// upstream status is not echoed, so the route is no port-probing oracle.
233242
const error =
234-
cause instanceof McpError
243+
cause instanceof McpError && cause.status === undefined
235244
? cause
236-
: new McpError("the handshake failed");
245+
: new McpError(
246+
cause instanceof McpError
247+
? "the server refused the request"
248+
: "the handshake failed",
249+
);
237250
opts.onError?.(error, { url: body.url });
238251
return c.json(
239252
{

‎src/naming.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,55 @@
33
// onto identical tool names and identical approval marks.
44

55
import type { McpTool } from "./client.js";
6+
import { parseMcpEndpoint } from "./url.js";
67

78
/** `<server>.<tool>`, the grantable unit: `tool:<server>.*` covers a server. */
89
export function qualifiedName(server: string, tool: string): string {
910
return `${server}.${tool}`;
1011
}
1112

13+
/**
14+
* Refuse a catalog whose names could reach another server's grants: a server
15+
* name with a `.` could make `<server>.<tool>` fall under another server's
16+
* `tool:<other>.*`, and a repeated tool name would make two tools one name.
17+
*/
18+
export function assertCatalogNames(
19+
server: string,
20+
tools: readonly McpTool[],
21+
): void {
22+
if (server.includes(".")) {
23+
throw new Error(
24+
`MCP server name "${server}" must not contain "."; it would overlap another server's grants`,
25+
);
26+
}
27+
const seen = new Set<string>();
28+
for (const tool of tools) {
29+
if (seen.has(tool.name)) {
30+
throw new Error(
31+
`MCP server "${server}" lists the tool "${tool.name}" more than once`,
32+
);
33+
}
34+
seen.add(tool.name);
35+
}
36+
}
37+
38+
/**
39+
* Refuse a server list before any request: every name non-empty and unique,
40+
* so no two servers share a grant prefix, and every URL a permitted endpoint.
41+
*/
42+
export function assertServers(
43+
servers: readonly { name: string; url: string }[],
44+
label: string,
45+
): void {
46+
const seen = new Set<string>();
47+
for (const { name, url } of servers) {
48+
if (name.length === 0) throw new Error(`empty ${label}`);
49+
if (seen.has(name)) throw new Error(`duplicate ${label} "${name}"`);
50+
seen.add(name);
51+
parseMcpEndpoint(url);
52+
}
53+
}
54+
1255
export function toolDescription(tool: McpTool, server: string): string {
1356
const hints = Object.entries(tool.annotations ?? {})
1457
.filter(([, v]) => v === true)

‎src/sidecar-bundle.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,3 +313,40 @@ describe("mcpServers round trip through a mediated handle", () => {
313313
expect(result.content).toContain("was cancelled");
314314
});
315315
});
316+
317+
describe("catalog names", () => {
318+
const tool = (name: string): McpTool => ({ name, inputSchema: {} });
319+
320+
test("a catalog listing a tool twice is refused", () => {
321+
expect(() =>
322+
mcpServers({
323+
servers: [
324+
{
325+
handle: "srv",
326+
url: "https://x.example.test/mcp",
327+
tools: [tool("dup"), tool("dup")],
328+
},
329+
],
330+
}),
331+
).toThrow(/more than once/);
332+
});
333+
334+
test("a handle that would fall under another server's grant is refused", () => {
335+
expect(() =>
336+
mcpServers({
337+
servers: [
338+
{
339+
handle: "srv",
340+
url: "https://x.example.test/mcp",
341+
tools: [tool("read")],
342+
},
343+
{
344+
handle: "srv.read",
345+
url: "https://y.example.test/mcp",
346+
tools: [tool("x")],
347+
},
348+
],
349+
}),
350+
).toThrow(/must not contain "."/);
351+
});
352+
});

‎src/sidecar-bundle.ts‎

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,13 @@ import {
2626
McpToolSchema,
2727
type McpTool,
2828
} from "./client.js";
29-
import { isAskExempt, qualifiedName, toolDescription } from "./naming.js";
30-
import { parseMcpEndpoint } from "./url.js";
29+
import {
30+
assertCatalogNames,
31+
assertServers,
32+
isAskExempt,
33+
qualifiedName,
34+
toolDescription,
35+
} from "./naming.js";
3136

3237
/** The consumer key a host matches a credential binding against. */
3338
export const SIDECAR_BUNDLE_ID = "@corbits/mcp/sidecar-bundle";
@@ -94,21 +99,18 @@ function assertConfig(config: McpServersConfig): void {
9499
if (parsed instanceof type.errors) {
95100
throw new Error(`invalid @corbits/mcp server config: ${parsed.summary}`);
96101
}
97-
const seen = new Set<string>();
98-
for (const server of config.servers) {
99-
if (seen.has(server.handle)) {
100-
throw new Error(
101-
`invalid @corbits/mcp server config: duplicate credential handle "${server.handle}"`,
102-
);
103-
}
104-
seen.add(server.handle);
105-
try {
106-
parseMcpEndpoint(server.url);
107-
} catch (cause) {
108-
throw new Error(
109-
`invalid @corbits/mcp server config: ${cause instanceof Error ? cause.message : String(cause)}`,
110-
);
102+
try {
103+
assertServers(
104+
config.servers.map(({ handle, url }) => ({ name: handle, url })),
105+
"credential handle",
106+
);
107+
for (const server of config.servers) {
108+
assertCatalogNames(server.handle, server.tools);
111109
}
110+
} catch (cause) {
111+
throw new Error(
112+
`invalid @corbits/mcp server config: ${cause instanceof Error ? cause.message : String(cause)}`,
113+
);
112114
}
113115
}
114116

‎src/tool.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,30 @@ describe("mcpTools discovers a live server and floors every tool at ask", () =>
9898
});
9999
});
100100

101+
describe("mcpTools refuses a bad server list before any request", () => {
102+
test("empty, duplicate and non-https entries are refused", async () => {
103+
handle = startTestMcpServer();
104+
const url = handle.url;
105+
await expect(mcpTools({ servers: [{ name: "", url }] })).rejects.toThrow(
106+
"empty MCP server name",
107+
);
108+
await expect(
109+
mcpTools({
110+
servers: [
111+
{ name: "srv", url },
112+
{ name: "srv", url },
113+
],
114+
}),
115+
).rejects.toThrow('duplicate MCP server name "srv"');
116+
await expect(
117+
mcpTools({
118+
servers: [{ name: "srv", url: "http://mcp.example.test/mcp" }],
119+
}),
120+
).rejects.toThrow("must be https");
121+
expect(handle.requestsSeen).toHaveLength(0);
122+
});
123+
});
124+
101125
describe("mcpTools bounds a stalling server", () => {
102126
const env = {
103127
sources: [],

‎src/tool.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@ import {
2020
type McpClientOptions,
2121
type McpTool,
2222
} from "./client.js";
23-
import { isAskExempt, qualifiedName, toolDescription } from "./naming.js";
23+
import {
24+
assertCatalogNames,
25+
assertServers,
26+
isAskExempt,
27+
qualifiedName,
28+
toolDescription,
29+
} from "./naming.js";
2430

2531
export interface McpServerConfig {
2632
/** Namespace prefix for this server's tools: `<name>.<remote-tool-name>`. */
@@ -87,6 +93,7 @@ export async function mcpTools(
8793
): Promise<AnnotatedToolFactory<McpToolsEnv>> {
8894
const allowWithoutAsk = options.allowWithoutAsk ?? [];
8995
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
96+
assertServers(options.servers, "MCP server name");
9097
const discovered: DiscoveredTool[] = [];
9198

9299
for (const server of options.servers) {
@@ -97,6 +104,7 @@ export async function mcpTools(
97104
};
98105
const session = await mcpInitialize(server.url, clientOpts);
99106
const tools = await mcpListTools(server.url, { ...clientOpts, session });
107+
assertCatalogNames(server.name, tools);
100108
for (const tool of tools) {
101109
discovered.push({
102110
server,

0 commit comments

Comments
 (0)