Skip to content

Commit 4fd43ff

Browse files
authored
Support large MCP portal catalogs (#170)
1 parent bd0aa2d commit 4fd43ff

12 files changed

Lines changed: 512 additions & 237 deletions

File tree

packages/gatekeeper-mcp-portal/README.md

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,21 @@ every system the organization has connected in one click.
2828
pinning different tools of one upstream server share both the name and the endpoint, so the scope is
2929
the only thing that distinguishes them.
3030

31-
The session API — a typed method per described tool, plus `callTool`, `getActionResult`, and `listTools` — is
32-
the same as [`gatekeeper-mcp`](../gatekeeper-mcp/README.md#what-it-provides).
33-
34-
Scoping to one server also shrinks what the agent reads: a 57-tool portal generates 57 tool
35-
signatures unscoped, and 12 when scoped to one server.
31+
The session API — a typed method per described tool, plus `callTool`, `getActionResult`, and
32+
`listTools` with progressive search/name options — is the same as
33+
[`gatekeeper-mcp`](../gatekeeper-mcp/README.md#what-it-provides).
34+
35+
A server-wide grant can cover more tools than one catalog describes, so its authority is not limited
36+
to the ones with generated signatures. `listTools({ search })` searches beyond the bounded preview
37+
and returns up to 20 compact matches within the shared 5,000-tool / 4 MiB discovery scan;
38+
`listTools({ name })` loads one exact bounded definition, and `callTool` resolves that name under the
39+
same bound before dispatch. Every path rejects names outside the
40+
binding's scope before loading the catalog or contacting the endpoint. Discovery and read results are
41+
recorded as observations; writes retain the ordinary approval flow.
42+
43+
Scoping to one server also shrinks what the agent reads: only that server's tools are rendered as
44+
signatures, and only as many as the budget describes. Additional tools are discovered on demand
45+
within the explicit scan limits above; exceeding a limit fails rather than pretending a tool is absent.
3646

3747
## Configuration
3848

@@ -45,6 +55,10 @@ signatures unscoped, and 12 when scoped to one server.
4555
| `MCP_PORTAL_TRUST_ANNOTATIONS` | `true` to let upstream tool annotations drive auto-approval. Off by default; see below. |
4656
| `MCP_ALLOW_INSECURE` | `"true"` to disable the endpoint checks entirely: permits `http://` **and** private, loopback, link-local, and cloud-metadata hosts, for the portal and every OAuth URL discovered from it. Local dev only. |
4757

58+
The portal must expose upstream tools directly. Use a portal where Code Mode is off or opt-in, or
59+
append `?codemode=off` when its policy is default-on. Enforced Code Mode is unsupported. Do not add
60+
an `optimize_context` parameter or opt in to Code Mode on `MCP_PORTAL_URL`.
61+
4862
Only `MCP_ALLOW_INSECURE` is set in the repo's `wrangler.jsonc`, pinned to `"false"` so the default
4963
is explicit rather than merely absent. None of the others is, and a portal URL committed there would
5064
become the default for every deployment of this repo and would send their users' OAuth flows to
@@ -91,10 +105,10 @@ pinned — which tools:
91105

92106
```
93107
Server · Which server behind this portal to grant. Its tools appear next.
94-
[ 🔍 GitHub ] 12 tools · 8 read-only, 4 need approval
108+
[ 🔍 GitHub ]
95109
96110
Tools · Choose how much of this server the Gadget may call.
97-
(•) All tools Every tool this server offers (12 today), including ones it adds later.
111+
(•) All tools Every tool this server offers, including ones it adds later.
98112
( ) Choose tools Only the tools you tick. Anything else is refused, including tools added later.
99113
100114
Allowed tools · Read-only tools return data straight away. The rest queue for your approval.
@@ -130,17 +144,37 @@ recovered from two facts in the portal's documented contract:
130144

131145
Detection is a capability probe — does the endpoint offer `portal_list_servers`? — not a hostname
132146
match, so it works for a custom portal hostname and for any other aggregator adopting the
133-
convention. A *truncated* catalog counts as a portal whether or not the probe tool is in it:
147+
convention. A *truncated* listing counts as a portal whether or not the probe tool is in it:
134148
`tools/list` is unordered, so concluding "not a portal" because the evidence fell past the cut would
135-
fail open on the `portal_*` exclusion below. Truncation is reported by `listTools` rather than
136-
inferred from the tool count, because either cap can stop it — `MAX_TOOLS_PER_SERVER` (200) or the
137-
96 KiB UTF-8 catalog budget, and the latter can cut a catalog of verbose tools short while leaving an
149+
fail open on the `portal_*` exclusion below. Truncation is reported by the client rather than
150+
inferred from the tool count, because either cap can stop a listing — the count the caller asked for,
151+
or the 96 KiB UTF-8 budget, and the latter can cut a listing of verbose tools short while leaving an
138152
array that looks complete. The byte budget leaves 32 KiB below Durable Object's per-value limit for
139153
the cache wrapper and serialization overhead; if storage nevertheless rejects the cache value, the
140154
fresh catalog is still used for that operation rather than turning a cache miss into a failure.
141155

156+
### Surveying a portal too large for one catalog
157+
158+
The configurator normally gets server names directly from `portal_list_servers`, without surveying
159+
every upstream tool. If that response is unavailable or only partly understood, it falls back to a
160+
**name-only tool index** of up to 1,000 entries. The index detects the portal and recovers server
161+
membership from tool-name prefixes; it carries no descriptions, schemas, or policy claims, so the
162+
96 KiB result budget covers as many names as possible. A truncated fallback cannot establish the
163+
complete server list and blocks the form rather than presenting a partial list as complete.
164+
165+
After a server is selected, a separate filtered scan returns up to 200 compact summaries from that
166+
server. Each summary carries its bounded title, description, and annotations, and is classified
167+
through the shared `tools.ts` trust boundary before it becomes a read-versus-approval label. The
168+
filter is applied before result budgets, so unrelated servers cannot crowd the selected one out.
169+
Only the returned prefix is offered for an individual-tool grant; additional tools require the
170+
server-wide grant, and a call resolves the full definition before approval or dispatch.
171+
172+
Index entries are typed separately (`IndexedTool`) to distinguish name-only survey results from tool
173+
definitions rendered into approval prompts or handed to an agent.
174+
142175
The server list is advisory: it supplies display names and ordering while tool-name prefixes remain
143-
the authority on membership, and a failed call degrades to bare ids. The gatekeeper makes that call
176+
the authority on membership, and a failed call degrades to bare ids recovered from tool prefixes.
177+
The gatekeeper makes that call
144178
while building a form, so it does not pass through the approval queue. Failing to reach the portal
145179
at all is different, and blocks the grant rather than falling back to the bare endpoint — the
146180
configurator reports it and stays unsubmittable.

packages/gatekeeper-mcp-portal/__tests__/config.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
isPortalToolGrantable,
4+
portalCatalogValidationMode,
35
portalResource,
46
portalAuthRequiresReconnect,
57
portalServer,
68
portalTokenFor,
79
portalTrust,
810
readPortalConfig,
911
requirePortalServerScope,
12+
toolGrantOptions,
1013
} from "../src/config.js";
1114

1215
function env(overrides: Record<string, string> = {}): Env {
@@ -168,6 +171,38 @@ describe("requirePortalServerScope", () => {
168171
// Pinned-and-empty denies everything, which is fail-closed and fine to mint.
169172
expect(() => requirePortalServerScope({ serverId: "github", tools: [] })).not.toThrow();
170173
});
174+
175+
it("rejects invalid names and cross-server tools before endpoint discovery", () => {
176+
expect(() => requirePortalServerScope({ serverId: "" })).toThrow(/server id/i);
177+
expect(() => requirePortalServerScope({ serverId: "x".repeat(600) })).toThrow(/server id/i);
178+
expect(() => requirePortalServerScope({ serverId: "github", tools: [""] }))
179+
.toThrow(/tool name/i);
180+
expect(() => requirePortalServerScope({ serverId: "github", tools: ["jira_search"] }))
181+
.toThrow(/does not belong/i);
182+
expect(() => requirePortalServerScope({
183+
serverId: "portal", tools: ["portal_toggle_servers"],
184+
})).toThrow(/portal management tool/i);
185+
});
186+
});
187+
188+
describe("portal catalog validation", () => {
189+
it("selects exact-tool validation for a non-empty pinned grant", () => {
190+
expect(portalCatalogValidationMode(
191+
{ serverId: "github", tools: ["github_search"] }, [])).toBe("named-tools");
192+
});
193+
194+
it("uses the same server evidence for empty pinned and server-wide grants", () => {
195+
for (const scope of [{ serverId: "github", tools: [] }, { serverId: "github" }]) {
196+
expect(portalCatalogValidationMode(scope, [])).toBe("server-evidence");
197+
expect(portalCatalogValidationMode(scope, [{ id: "github" }])).toBe("reported-server");
198+
}
199+
});
200+
201+
it("excludes portal-native and cross-server tools from portal grants", () => {
202+
expect(isPortalToolGrantable("github_search", "github")).toBe(true);
203+
expect(isPortalToolGrantable("linear_search", "github")).toBe(false);
204+
expect(isPortalToolGrantable("portal_toggle_servers", "portal")).toBe(false);
205+
});
171206
});
172207

173208
describe("portalTokenFor", () => {
@@ -223,3 +258,55 @@ describe("portalTokenFor", () => {
223258
expect(portalTokenFor(configured, "https://gw.example.com/mcp")).toBeNull();
224259
});
225260
});
261+
262+
describe("toolGrantOptions", () => {
263+
const tools = [
264+
{ name: "google_list_events", annotations: { readOnlyHint: true } },
265+
{ name: "google_delete_event", annotations: { readOnlyHint: false } },
266+
{ name: "google_send_mail" },
267+
];
268+
269+
it("classifies each bounded summary from the annotation runtime policy uses", () => {
270+
const options = toolGrantOptions({
271+
serverId: "google",
272+
tools,
273+
trust: "byo",
274+
});
275+
expect(options.map(option => [option.value, option.meta])).toEqual([
276+
["google_list_events", "read-only"],
277+
["google_delete_event", "needs approval"],
278+
["google_send_mail", "needs approval"],
279+
]);
280+
});
281+
282+
it("uses summary text and degrades to the bare name without it", () => {
283+
const options = toolGrantOptions({
284+
serverId: "google",
285+
tools: [{
286+
name: "google_list_events", title: "List events",
287+
description: "Lists calendar events.\nSecond line ignored.",
288+
annotations: { readOnlyHint: true },
289+
}, ...tools.slice(1)],
290+
trust: "byo",
291+
});
292+
expect(options[0]).toEqual({
293+
value: "google_list_events",
294+
title: "List events",
295+
subtitle: "Lists calendar events.",
296+
meta: "read-only",
297+
});
298+
// No detail for this one: the prefix is still stripped, and no description is invented.
299+
expect(options[1]).toMatchObject({ title: "delete_event", subtitle: undefined });
300+
});
301+
302+
it("does not let a portal's annotations drive auto-approval labels on an unvetted portal", () => {
303+
// `meta` reports only read-versus-action, which is the distinction the person granting acts on.
304+
// Auto-approval needs a vetted deployment as well, and is not something this form claims.
305+
for (const trust of ["vetted", "byo"] as const) {
306+
const options = toolGrantOptions({ serverId: "google", tools, trust });
307+
expect(options.map(option => option.meta))
308+
.toEqual(["read-only", "needs approval", "needs approval"]);
309+
}
310+
});
311+
312+
});

packages/gatekeeper-mcp-portal/__tests__/configurator-ui.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ vi.mock("@gadgets/configurator-ui", () => ({
1919

2020
type Values = McpServerConfiguratorValues;
2121

22-
// The module caches tool lists at module scope, which is safe only because the host hands it a fresh
22+
// The module caches the server list at module scope, which is safe only because the host hands it a fresh
2323
// iframe -- and so a fresh realm -- for every account and resource pattern. Re-importing per test
2424
// reproduces that; without it one test's cached list is served to the next.
2525
async function loadSpec() {
@@ -88,7 +88,7 @@ describe("portal configurator", () => {
8888
expect(rendered).not.toContain("CheckboxList");
8989
});
9090

91-
it("keeps an empty portal ungrantable instead of serializing its future servers", async () => {
91+
it("shows corrective guidance when the endpoint exposes no direct upstream tools", async () => {
9292
const ui = {
9393
getEndpoint: async () => "https://gw.example.com/mcp",
9494
listServerOptions: async () => [],
@@ -99,11 +99,12 @@ describe("portal configurator", () => {
9999
}, ui);
100100

101101
app.render();
102-
await vi.waitFor(() => expect(app.values.endpointKind).toBe("portal"));
102+
await vi.waitFor(() => expect(app.values.endpointKind).toBe("empty"));
103103
expect(app.values.server).toBeNull();
104104
expect(spec.isReady({ values: app.values })).toBe(false);
105-
await expect(spec.resourceUrl({ values: app.values, ui } as never))
106-
.rejects.toThrow(/Choose a server/);
105+
const rendered = JSON.stringify(app.render());
106+
expect(rendered).toContain("codemode=off");
107+
expect(rendered).not.toContain("Could not reach the portal");
107108
});
108109

109110
it("shows every tool as a disabled preview for an all-tools grant", () => {

packages/gatekeeper-mcp-portal/src/config.ts

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
// connector instead of offering a dead end. See the README.
77

88
import type { SupportedResource } from "@gadgets/workshop-shared/gatekeeper";
9+
import type { ConfiguratorUIOption } from "@gadgets/configurator-ui";
910
import type { ConnectedServer, ServerAuthKind } from "@gadgets/mcp-shared/account";
10-
import type { ToolScope } from "@gadgets/mcp-shared/scope";
11+
import { isValidToolName, type McpTool } from "@gadgets/mcp-shared/client";
12+
import { scopeAllows, type ToolScope } from "@gadgets/mcp-shared/scope";
1113
import { fetchOptions } from "@gadgets/mcp-shared/fetch";
1214
import { sameEndpoint } from "@gadgets/mcp-shared/scope";
13-
import type { ServerTrust } from "@gadgets/mcp-shared/tools";
15+
import { isPortalNativeTool, type PortalServer } from "@gadgets/mcp-shared/portal";
16+
import { classifyTool, type ServerTrust } from "@gadgets/mcp-shared/tools";
1417

1518
/** The configured portal, once the deployment's vars have been read and validated. */
1619
export type PortalConfig = {
@@ -86,13 +89,69 @@ export function readPortalConfig(env: Env): PortalConfig | null {
8689
* This is the enforcement, not the configurator. The form refuses to *emit* such a URL, but a
8790
* resource URL is not only ever produced by the form: an agent passes a concrete one to
8891
* `requestConnection`, and any URL under the portal's origin reaches `getGatekeeperClassFor`. A
89-
* rule that lives only in the iframe is a suggestion; the facet is minted here.
92+
* rule that lives only in the iframe is a suggestion; the facet is minted here. The assertion
93+
* narrows the scope so callers do not need to repeat the invariant.
9094
*/
91-
export function requirePortalServerScope(scope: ToolScope): void {
92-
if (scope.serverId !== undefined) return;
93-
throw new Error(
94-
"A portal grant has to name one of the servers behind the portal. Granting the portal itself " +
95-
"would cover every system connected to it, including ones added later.");
95+
export function requirePortalServerScope(
96+
scope: ToolScope,
97+
): asserts scope is ToolScope & { serverId: string } {
98+
if (scope.serverId === undefined) {
99+
throw new Error(
100+
"A portal grant has to name one of the servers behind the portal. Granting the portal itself " +
101+
"would cover every system connected to it, including ones added later.");
102+
}
103+
if (!isValidToolName(scope.serverId)) throw new Error("Invalid portal server id.");
104+
for (const name of scope.tools ?? []) {
105+
if (!isValidToolName(name)) throw new Error("Invalid MCP tool name.");
106+
if (isPortalNativeTool(name)) {
107+
throw new Error(`Portal management tool "${name}" cannot be granted.`);
108+
}
109+
if (!isPortalToolGrantable(name, scope.serverId)) {
110+
throw new Error(`Tool "${name}" does not belong to portal server "${scope.serverId}".`);
111+
}
112+
}
113+
}
114+
115+
/** Whether one tool may appear in a grant for this portal server. */
116+
export function isPortalToolGrantable(name: string, serverId: string): boolean {
117+
return scopeAllows({ serverId }, name, true);
118+
}
119+
120+
/** Which catalog evidence is needed to validate one portal scope. */
121+
export function portalCatalogValidationMode(
122+
scope: ToolScope & { serverId: string },
123+
reportedServers: readonly Pick<PortalServer, "id">[],
124+
): "named-tools" | "reported-server" | "server-evidence" {
125+
if ((scope.tools?.length ?? 0) > 0) return "named-tools";
126+
return reportedServers.some(server => server.id === scope.serverId)
127+
? "reported-server"
128+
: "server-evidence";
129+
}
130+
131+
/**
132+
* Renders one upstream server's tools as choices on the grant form.
133+
*
134+
* The bounded summaries carry both display text and the annotations classification is decided from,
135+
* so the picker cannot disagree with runtime policy merely because a full schema did not fit.
136+
*/
137+
export function toolGrantOptions(args: {
138+
serverId: string;
139+
tools: readonly McpTool[];
140+
trust: ServerTrust;
141+
}): ConfiguratorUIOption[] {
142+
return args.tools.map(tool => {
143+
return {
144+
value: tool.name,
145+
// Within a chosen server the `{server_id}_` prefix is noise, so it is shown stripped while
146+
// `value` keeps the wire name the grant is actually recorded with.
147+
title: tool.title ?? tool.name.slice(args.serverId.length + 1),
148+
subtitle: tool.description?.split(/\r?\n/)[0],
149+
// Surfaced here so the person granting can see, per tool, whether calls will interrupt them.
150+
meta: classifyTool(tool, args.trust).mode === "read"
151+
? "read-only"
152+
: "needs approval",
153+
};
154+
});
96155
}
97156

98157
/** The single resource type this connector offers, scoped to the configured portal's origin. */

0 commit comments

Comments
 (0)