Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 46 additions & 12 deletions packages/gatekeeper-mcp-portal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,21 @@ every system the organization has connected in one click.
pinning different tools of one upstream server share both the name and the endpoint, so the scope is
the only thing that distinguishes them.

The session API — a typed method per described tool, plus `callTool`, `getActionResult`, and `listTools` — is
the same as [`gatekeeper-mcp`](../gatekeeper-mcp/README.md#what-it-provides).

Scoping to one server also shrinks what the agent reads: a 57-tool portal generates 57 tool
signatures unscoped, and 12 when scoped to one server.
The session API — a typed method per described tool, plus `callTool`, `getActionResult`, and
`listTools` with progressive search/name options — is the same as
[`gatekeeper-mcp`](../gatekeeper-mcp/README.md#what-it-provides).

A server-wide grant can cover more tools than one catalog describes, so its authority is not limited
to the ones with generated signatures. `listTools({ search })` searches beyond the bounded preview
and returns up to 20 compact matches within the shared 5,000-tool / 4 MiB discovery scan;
`listTools({ name })` loads one exact bounded definition, and `callTool` resolves that name under the
same bound before dispatch. Every path rejects names outside the
binding's scope before loading the catalog or contacting the endpoint. Discovery and read results are
recorded as observations; writes retain the ordinary approval flow.

Scoping to one server also shrinks what the agent reads: only that server's tools are rendered as
signatures, and only as many as the budget describes. Additional tools are discovered on demand
within the explicit scan limits above; exceeding a limit fails rather than pretending a tool is absent.

## Configuration

Expand All @@ -45,6 +55,10 @@ signatures unscoped, and 12 when scoped to one server.
| `MCP_PORTAL_TRUST_ANNOTATIONS` | `true` to let upstream tool annotations drive auto-approval. Off by default; see below. |
| `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. |

The portal must expose upstream tools directly. Use a portal where Code Mode is off or opt-in, or
append `?codemode=off` when its policy is default-on. Enforced Code Mode is unsupported. Do not add
an `optimize_context` parameter or opt in to Code Mode on `MCP_PORTAL_URL`.

Only `MCP_ALLOW_INSECURE` is set in the repo's `wrangler.jsonc`, pinned to `"false"` so the default
is explicit rather than merely absent. None of the others is, and a portal URL committed there would
become the default for every deployment of this repo and would send their users' OAuth flows to
Expand Down Expand Up @@ -91,10 +105,10 @@ pinned — which tools:

```
Server · Which server behind this portal to grant. Its tools appear next.
[ 🔍 GitHub ] 12 tools · 8 read-only, 4 need approval
[ 🔍 GitHub ]

Tools · Choose how much of this server the Gadget may call.
(•) All tools Every tool this server offers (12 today), including ones it adds later.
(•) All tools Every tool this server offers, including ones it adds later.
( ) Choose tools Only the tools you tick. Anything else is refused, including tools added later.

Allowed tools · Read-only tools return data straight away. The rest queue for your approval.
Expand Down Expand Up @@ -130,17 +144,37 @@ recovered from two facts in the portal's documented contract:

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

### Surveying a portal too large for one catalog

The configurator normally gets server names directly from `portal_list_servers`, without surveying
every upstream tool. If that response is unavailable or only partly understood, it falls back to a
**name-only tool index** of up to 1,000 entries. The index detects the portal and recovers server
membership from tool-name prefixes; it carries no descriptions, schemas, or policy claims, so the
96 KiB result budget covers as many names as possible. A truncated fallback cannot establish the
complete server list and blocks the form rather than presenting a partial list as complete.

After a server is selected, a separate filtered scan returns up to 200 compact summaries from that
server. Each summary carries its bounded title, description, and annotations, and is classified
through the shared `tools.ts` trust boundary before it becomes a read-versus-approval label. The
filter is applied before result budgets, so unrelated servers cannot crowd the selected one out.
Only the returned prefix is offered for an individual-tool grant; additional tools require the
server-wide grant, and a call resolves the full definition before approval or dispatch.

Index entries are typed separately (`IndexedTool`) to distinguish name-only survey results from tool
definitions rendered into approval prompts or handed to an agent.

The server list is advisory: it supplies display names and ordering while tool-name prefixes remain
the authority on membership, and a failed call degrades to bare ids. The gatekeeper makes that call
the authority on membership, and a failed call degrades to bare ids recovered from tool prefixes.
The gatekeeper makes that call
while building a form, so it does not pass through the approval queue. Failing to reach the portal
at all is different, and blocks the grant rather than falling back to the bare endpoint — the
configurator reports it and stays unsubmittable.
Expand Down
87 changes: 87 additions & 0 deletions packages/gatekeeper-mcp-portal/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { describe, expect, it } from "vitest";
import {
isPortalToolGrantable,
portalCatalogValidationMode,
portalResource,
portalAuthRequiresReconnect,
portalServer,
portalTokenFor,
portalTrust,
readPortalConfig,
requirePortalServerScope,
toolGrantOptions,
} from "../src/config.js";

function env(overrides: Record<string, string> = {}): Env {
Expand Down Expand Up @@ -168,6 +171,38 @@ describe("requirePortalServerScope", () => {
// Pinned-and-empty denies everything, which is fail-closed and fine to mint.
expect(() => requirePortalServerScope({ serverId: "github", tools: [] })).not.toThrow();
});

it("rejects invalid names and cross-server tools before endpoint discovery", () => {
expect(() => requirePortalServerScope({ serverId: "" })).toThrow(/server id/i);
expect(() => requirePortalServerScope({ serverId: "x".repeat(600) })).toThrow(/server id/i);
expect(() => requirePortalServerScope({ serverId: "github", tools: [""] }))
.toThrow(/tool name/i);
expect(() => requirePortalServerScope({ serverId: "github", tools: ["jira_search"] }))
.toThrow(/does not belong/i);
expect(() => requirePortalServerScope({
serverId: "portal", tools: ["portal_toggle_servers"],
})).toThrow(/portal management tool/i);
});
});

describe("portal catalog validation", () => {
it("selects exact-tool validation for a non-empty pinned grant", () => {
expect(portalCatalogValidationMode(
{ serverId: "github", tools: ["github_search"] }, [])).toBe("named-tools");
});

it("uses the same server evidence for empty pinned and server-wide grants", () => {
for (const scope of [{ serverId: "github", tools: [] }, { serverId: "github" }]) {
expect(portalCatalogValidationMode(scope, [])).toBe("server-evidence");
expect(portalCatalogValidationMode(scope, [{ id: "github" }])).toBe("reported-server");
}
});

it("excludes portal-native and cross-server tools from portal grants", () => {
expect(isPortalToolGrantable("github_search", "github")).toBe(true);
expect(isPortalToolGrantable("linear_search", "github")).toBe(false);
expect(isPortalToolGrantable("portal_toggle_servers", "portal")).toBe(false);
});
});

describe("portalTokenFor", () => {
Expand Down Expand Up @@ -223,3 +258,55 @@ describe("portalTokenFor", () => {
expect(portalTokenFor(configured, "https://gw.example.com/mcp")).toBeNull();
});
});

describe("toolGrantOptions", () => {
const tools = [
{ name: "google_list_events", annotations: { readOnlyHint: true } },
{ name: "google_delete_event", annotations: { readOnlyHint: false } },
{ name: "google_send_mail" },
];

it("classifies each bounded summary from the annotation runtime policy uses", () => {
const options = toolGrantOptions({
serverId: "google",
tools,
trust: "byo",
});
expect(options.map(option => [option.value, option.meta])).toEqual([
["google_list_events", "read-only"],
["google_delete_event", "needs approval"],
["google_send_mail", "needs approval"],
]);
});

it("uses summary text and degrades to the bare name without it", () => {
const options = toolGrantOptions({
serverId: "google",
tools: [{
name: "google_list_events", title: "List events",
description: "Lists calendar events.\nSecond line ignored.",
annotations: { readOnlyHint: true },
}, ...tools.slice(1)],
trust: "byo",
});
expect(options[0]).toEqual({
value: "google_list_events",
title: "List events",
subtitle: "Lists calendar events.",
meta: "read-only",
});
// No detail for this one: the prefix is still stripped, and no description is invented.
expect(options[1]).toMatchObject({ title: "delete_event", subtitle: undefined });
});

it("does not let a portal's annotations drive auto-approval labels on an unvetted portal", () => {
// `meta` reports only read-versus-action, which is the distinction the person granting acts on.
// Auto-approval needs a vetted deployment as well, and is not something this form claims.
for (const trust of ["vetted", "byo"] as const) {
const options = toolGrantOptions({ serverId: "google", tools, trust });
expect(options.map(option => option.meta))
.toEqual(["read-only", "needs approval", "needs approval"]);
}
});

});
11 changes: 6 additions & 5 deletions packages/gatekeeper-mcp-portal/__tests__/configurator-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vi.mock("@gadgets/configurator-ui", () => ({

type Values = McpServerConfiguratorValues;

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

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

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

it("shows every tool as a disabled preview for an all-tools grant", () => {
Expand Down
75 changes: 67 additions & 8 deletions packages/gatekeeper-mcp-portal/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
// connector instead of offering a dead end. See the README.

import type { SupportedResource } from "@gadgets/workshop-shared/gatekeeper";
import type { ConfiguratorUIOption } from "@gadgets/configurator-ui";
import type { ConnectedServer, ServerAuthKind } from "@gadgets/mcp-shared/account";
import type { ToolScope } from "@gadgets/mcp-shared/scope";
import { isValidToolName, type McpTool } from "@gadgets/mcp-shared/client";
import { scopeAllows, type ToolScope } from "@gadgets/mcp-shared/scope";
import { fetchOptions } from "@gadgets/mcp-shared/fetch";
import { sameEndpoint } from "@gadgets/mcp-shared/scope";
import type { ServerTrust } from "@gadgets/mcp-shared/tools";
import { isPortalNativeTool, type PortalServer } from "@gadgets/mcp-shared/portal";
import { classifyTool, type ServerTrust } from "@gadgets/mcp-shared/tools";

/** The configured portal, once the deployment's vars have been read and validated. */
export type PortalConfig = {
Expand Down Expand Up @@ -86,13 +89,69 @@ export function readPortalConfig(env: Env): PortalConfig | null {
* This is the enforcement, not the configurator. The form refuses to *emit* such a URL, but a
* resource URL is not only ever produced by the form: an agent passes a concrete one to
* `requestConnection`, and any URL under the portal's origin reaches `getGatekeeperClassFor`. A
* rule that lives only in the iframe is a suggestion; the facet is minted here.
* rule that lives only in the iframe is a suggestion; the facet is minted here. The assertion
* narrows the scope so callers do not need to repeat the invariant.
*/
export function requirePortalServerScope(scope: ToolScope): void {
if (scope.serverId !== undefined) return;
throw new Error(
"A portal grant has to name one of the servers behind the portal. Granting the portal itself " +
"would cover every system connected to it, including ones added later.");
export function requirePortalServerScope(
scope: ToolScope,
): asserts scope is ToolScope & { serverId: string } {
if (scope.serverId === undefined) {
throw new Error(
"A portal grant has to name one of the servers behind the portal. Granting the portal itself " +
"would cover every system connected to it, including ones added later.");
}
if (!isValidToolName(scope.serverId)) throw new Error("Invalid portal server id.");
for (const name of scope.tools ?? []) {
if (!isValidToolName(name)) throw new Error("Invalid MCP tool name.");
if (isPortalNativeTool(name)) {
throw new Error(`Portal management tool "${name}" cannot be granted.`);
}
if (!isPortalToolGrantable(name, scope.serverId)) {
throw new Error(`Tool "${name}" does not belong to portal server "${scope.serverId}".`);
}
}
}

/** Whether one tool may appear in a grant for this portal server. */
export function isPortalToolGrantable(name: string, serverId: string): boolean {
return scopeAllows({ serverId }, name, true);
}

/** Which catalog evidence is needed to validate one portal scope. */
export function portalCatalogValidationMode(
scope: ToolScope & { serverId: string },
reportedServers: readonly Pick<PortalServer, "id">[],
): "named-tools" | "reported-server" | "server-evidence" {
if ((scope.tools?.length ?? 0) > 0) return "named-tools";
return reportedServers.some(server => server.id === scope.serverId)
? "reported-server"
: "server-evidence";
}

/**
* Renders one upstream server's tools as choices on the grant form.
*
* The bounded summaries carry both display text and the annotations classification is decided from,
* so the picker cannot disagree with runtime policy merely because a full schema did not fit.
*/
export function toolGrantOptions(args: {
serverId: string;
tools: readonly McpTool[];
trust: ServerTrust;
}): ConfiguratorUIOption[] {
return args.tools.map(tool => {
return {
value: tool.name,
// Within a chosen server the `{server_id}_` prefix is noise, so it is shown stripped while
// `value` keeps the wire name the grant is actually recorded with.
title: tool.title ?? tool.name.slice(args.serverId.length + 1),
subtitle: tool.description?.split(/\r?\n/)[0],
// Surfaced here so the person granting can see, per tool, whether calls will interrupt them.
meta: classifyTool(tool, args.trust).mode === "read"
? "read-only"
: "needs approval",
};
});
}

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