ungated-global: system project whose servers ride every project, pre-gate
Some checks failed
CI/CD / lint (pull_request) Successful in 1m12s
CI/CD / test (pull_request) Successful in 1m26s
CI/CD / typecheck (pull_request) Successful in 3m22s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 5m15s
CI/CD / publish (pull_request) Has been skipped

New seed-once system project `ungated-global`: every server attached to it
is merged into every project's MCP endpoint in front of the gate (listed
and callable before begin_session), and its project prompt is prepended to
every project's instructions.

- mcpd: bootstrapUngatedGlobal seeds the project once-ever (marker key
  `ungatedGlobalSeeded` in mcpctl-system-settings), with default server
  `calcmcp` (patched CalcMCP fork, mysources.co.uk/michal/calcmcp:1.1.0)
  and a "never do math yourself, always use calcmcp" prompt. Deletions are
  durable: `mcpctl delete server calcmcp -p ungated-global` stays deleted.
- mcpd: GET /projects/:id/servers appends global servers flagged
  `ungatedGlobal: true` (direct attachment wins, no self-merge);
  GET /projects/:id/instructions prepends the global prompt and lists the
  global servers.
- mcplocal: discovery surfaces the flagged names; the gate plugin exempts
  them — gated tools/list returns begin_session + exempt tools, calls to
  them pass through without ungating, and initialize instructions list
  them under "Available immediately (no begin_session required)".
- templates/calcmcp.yaml so a deleted default can be re-created by hand.
- docs/ungated-global.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-10-03 01:45:44 +01:00
parent 23c8218811
commit 7b70c91c41
18 changed files with 584 additions and 27 deletions

View File

@@ -11,6 +11,12 @@ interface McpdServer {
status?: string;
/** Per-server tool-call deadline override, in seconds. Null = use the global. */
toolCallTimeoutSeconds?: number | null;
/**
* True when the server comes from the `ungated-global` system project
* rather than this project's own attachments. mcplocal exempts such
* servers from the gate: their tools are listed and callable pre-begin_session.
*/
ungatedGlobal?: boolean;
}
/**
@@ -28,12 +34,19 @@ export async function refreshUpstreams(router: McpRouter, mcpdClient: McpdClient
*
* @param authToken - Optional bearer token forwarded to mcpd for RBAC checks.
*/
export interface ProjectUpstreams {
/** Names of all registered upstream servers. */
names: string[];
/** Subset that came from the ungated-global project (gate-exempt). */
ungatedServers: string[];
}
export async function refreshProjectUpstreams(
router: McpRouter,
mcpdClient: McpdClient,
projectName: string,
authToken?: string,
): Promise<string[]> {
): Promise<ProjectUpstreams> {
const path = `/api/v1/projects/${encodeURIComponent(projectName)}/servers`;
let servers: McpdServer[];
@@ -54,7 +67,11 @@ export async function refreshProjectUpstreams(
// Bind a per-request client with the caller's bearer so each McpdUpstream
// forwards the same identity that passed project discovery.
const upstreamClient = authToken ? mcpdClient.withToken(authToken) : mcpdClient;
return syncUpstreams(router, upstreamClient, servers);
const names = await syncUpstreams(router, upstreamClient, servers);
return {
names,
ungatedServers: servers.filter((s) => s.ungatedGlobal === true).map((s) => s.name),
};
}
/**

View File

@@ -94,7 +94,7 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
// Create new router or refresh existing one
const router = existing?.router ?? new McpRouter();
await refreshProjectUpstreams(router, mcpdClient, projectName, authToken);
const { ungatedServers } = await refreshProjectUpstreams(router, mcpdClient, projectName, authToken);
// Resolve project LLM model: local override → mcpd recommendation → global default
const localOverride = loadProjectLlmOverride(projectName);
@@ -165,6 +165,9 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
providerRegistry: effectiveRegistry,
};
if (resolvedModel) pluginConfig.modelOverride = resolvedModel;
// Servers from the ungated-global project bypass the gate: listed and
// callable before begin_session, in every project.
if (ungatedServers.length > 0) pluginConfig.ungatedServers = ungatedServers;
// Route gate prompt-selection through a server Llm (mcpd inference proxy)
// so cloud/server keys stay at the k8s level; the local personal-token
// provider is the fallback. See credential-tiering. A dedicated fast

View File

@@ -45,9 +45,9 @@ export function createAmbientContextPlugin(): ProxyModelPlugin {
},
async onToolsList(tools) {
// A gated catalog is exactly [begin_session] (gate.ts onToolsList);
// begin_session never appears in an ungated list. If the gate's gated
// list ever changes shape, revisit this marker.
// A gated catalog is [begin_session, ...gate-exempt ungated-global
// tools] (gate.ts onToolsList); begin_session never appears in an
// ungated list, so its presence remains the gated marker.
if (tools.some((t) => t.name === 'begin_session')) return tools;
if (tools.some((t) => t.name === GET_CURRENT_TIME_TOOL_NAME)) return tools;
return [...tools, getCurrentTimeTool()];

View File

@@ -55,6 +55,12 @@ export interface GatePluginConfig {
* personal-token provider registry. See credential-tiering principle.
*/
llmProvider?: string;
/**
* Server names (canonical, as in `server/tool`) exempt from the gate —
* typically servers of the ungated-global project. Their tools are listed
* and callable while the session is still gated.
*/
ungatedServers?: string[];
}
const MAX_RESPONSE_CHARS = 24_000;
@@ -73,6 +79,13 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
// Per-session state tracking (plugin-scoped, not global SessionGate)
const sessionGate = new SessionGate();
// Gate-exempt servers (ungated-global project): tools pass the gate untouched.
const ungatedSet = new Set(config.ungatedServers ?? []);
const isGateExempt = (toolName: string): boolean => {
const slash = toolName.indexOf('/');
return slash > 0 && ungatedSet.has(toolName.slice(0, slash));
};
return {
name: 'gate',
description: 'Gated session flow: begin_session → prompt selection → ungate.',
@@ -122,9 +135,17 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
// canonical `server/tool` would name functions the model cannot call.
try {
const tools = await ctx.discoverTools();
if (tools.length > 0) {
const exempt = tools.filter((t) => isGateExempt(t.name));
const gatedTools = tools.filter((t) => !isGateExempt(t.name));
if (exempt.length > 0) {
parts.push('\nAvailable immediately (no begin_session required):');
for (const t of exempt) {
parts.push(` ${sanitizeWireName(t.name)}`);
}
}
if (gatedTools.length > 0) {
parts.push('\nAvailable MCP server tools (accessible after begin_session):');
for (const t of tools) {
for (const t of gatedTools) {
parts.push(` ${sanitizeWireName(t.name)}`);
}
}
@@ -160,9 +181,9 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
},
async onToolsList(tools, ctx) {
// When gated, only show begin_session
// When gated, show begin_session plus any gate-exempt (ungated-global) tools
if (sessionGate.isGated(ctx.sessionId)) {
return [getBeginSessionTool(llmSelector)];
return [getBeginSessionTool(llmSelector), ...tools.filter((t) => isGateExempt(t.name))];
}
// When ungated, show upstream tools + read_prompts + propose_prompt (no begin_session)
@@ -170,8 +191,9 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
},
async onToolCallBefore(toolName, args, request, ctx) {
// If gated and trying to call a real tool, auto-ungate via keyword extraction
if (sessionGate.isGated(ctx.sessionId)) {
// If gated and trying to call a real tool, auto-ungate via keyword extraction.
// Gate-exempt (ungated-global) tools pass straight through without ungating.
if (sessionGate.isGated(ctx.sessionId) && !isGateExempt(toolName)) {
return handleGatedIntercept(request, ctx, toolName, args, sessionGate, tagMatcher);
}
return null;

View File

@@ -74,6 +74,8 @@ function setupPluginRouter(opts: {
/** When set, the heavy provider's complete() rejects — exercises the loud fallback. */
llmError?: boolean;
byteBudget?: number;
/** Gate-exempt server names (ungated-global project). */
ungatedServers?: string[];
} = {}): { router: McpRouter; mcpdClient: McpdClient } {
const router = new McpRouter();
const prompts = opts.prompts ?? samplePrompts;
@@ -105,6 +107,7 @@ function setupPluginRouter(opts: {
gated: opts.gated !== false,
providerRegistry,
byteBudget: opts.byteBudget,
...(opts.ungatedServers ? { ungatedServers: opts.ungatedServers } : {}),
});
router.setPlugin(gatePlugin);
@@ -160,6 +163,63 @@ describe('Gate Plugin via setPlugin()', () => {
});
});
describe('gate-exempt (ungated-global) servers', () => {
it('lists exempt server tools alongside begin_session while gated', async () => {
const { router } = setupPluginRouter({ ungatedServers: ['calcmcp'] });
router.addUpstream(mockUpstream('calcmcp', { tools: [{ name: 'symbolic_tool' }] }));
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
{ sessionId: 's1' },
);
const names = (res.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
expect(names).toContain('begin_session');
expect(names).toContain('calcmcp/symbolic_tool');
expect(names).not.toContain('ha/get_entities');
});
it('calls an exempt tool while gated without intercept and stays gated', async () => {
const { router } = setupPluginRouter({ ungatedServers: ['calcmcp'] });
router.addUpstream(mockUpstream('calcmcp', { tools: [{ name: 'symbolic_tool' }] }));
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const callRes = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'calcmcp/symbolic_tool', arguments: { operation: 'simplify', expression: '1+1' } } },
{ sessionId: 's1' },
);
const content = (callRes.result as { content: Array<{ text: string }> }).content;
// Real upstream result, not a gate intercept briefing (the router
// strips the server prefix when forwarding to the upstream)
expect(content[0]!.text).toBe('Called symbolic_tool');
// Session must STILL be gated: non-exempt catalog stays hidden
const toolsRes = await router.route(
{ jsonrpc: '2.0', id: 3, method: 'tools/list' },
{ sessionId: 's1' },
);
const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
expect(names).toContain('begin_session');
});
it('lists exempt tools under "Available immediately" in initialize instructions', async () => {
const { router } = setupPluginRouter({ ungatedServers: ['calcmcp'] });
router.addUpstream(mockUpstream('calcmcp', { tools: [{ name: 'symbolic_tool' }] }));
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
const res = await router.route(
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
{ sessionId: 's1' },
);
const instructions = (res.result as { instructions?: string }).instructions ?? '';
expect(instructions).toContain('Available immediately (no begin_session required):');
expect(instructions).toContain('calcmcp_symbolic_tool');
expect(instructions).toContain('Available MCP server tools (accessible after begin_session):');
expect(instructions).toContain('ha_get_entities');
});
});
describe('tools/list gating', () => {
it('shows only begin_session when session is gated', async () => {
const { router } = setupPluginRouter();

View File

@@ -27,9 +27,22 @@ describe('refreshProjectUpstreams', () => {
]);
const registered = await refreshProjectUpstreams(router, client as any, 'smart-home');
expect(registered).toEqual(['grafana', 'ha']);
expect(registered.names).toEqual(['grafana', 'ha']);
expect(registered.ungatedServers).toEqual([]);
});
it('reports servers flagged ungatedGlobal as gate-exempt', async () => {
const router = new McpRouter();
const client = mockMcpdClient([
{ id: 'srv-1', name: 'grafana', transport: 'stdio' },
{ id: 'srv-2', name: 'calcmcp', transport: 'stdio', ungatedGlobal: true },
]);
const registered = await refreshProjectUpstreams(router, client as any, 'smart-home');
expect(registered.names).toEqual(['grafana', 'calcmcp']);
expect(registered.ungatedServers).toEqual(['calcmcp']);
expect(router.getUpstreamNames()).toContain('grafana');
expect(router.getUpstreamNames()).toContain('ha');
expect(router.getUpstreamNames()).toContain('calcmcp');
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/smart-home/servers');
});
@@ -85,7 +98,7 @@ describe('refreshProjectUpstreams', () => {
const client = mockMcpdClient([]);
const registered = await refreshProjectUpstreams(router, client as any, 'empty-project');
expect(registered).toEqual([]);
expect(registered.names).toEqual([]);
expect(router.getUpstreamNames()).toHaveLength(0);
});
});

View File

@@ -31,7 +31,7 @@ vi.mock('../src/discovery.js', () => ({
close: async () => {},
isAlive: () => true,
});
return ['websearch'];
return { names: ['websearch'], ungatedServers: [] };
}),
// Ungated (content-pipeline style), like chat-web and personal-michal.
fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })),

View File

@@ -40,7 +40,7 @@ vi.mock('../src/discovery.js', () => ({
close: async () => {},
isAlive: () => true,
});
return ['websearch'];
return { names: ['websearch'], ungatedServers: [] };
}),
// gated: false → no gate plugin, the full catalog is served at initialize
// (the chat-web configuration).

View File

@@ -5,7 +5,7 @@ import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js'
// Mock discovery module — we don't want real HTTP calls
vi.mock('../src/discovery.js', () => ({
refreshProjectUpstreams: vi.fn(async () => ['mock-server']),
refreshProjectUpstreams: vi.fn(async () => ({ names: ['mock-server'], ungatedServers: [] })),
fetchProjectLlmConfig: vi.fn(async () => ({})),
}));

View File

@@ -30,7 +30,7 @@ vi.mock('../src/discovery.js', () => ({
close: async () => { /* noop */ },
isAlive: () => true,
});
return ['blackhole'];
return { names: ['blackhole'], ungatedServers: [] };
}),
fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })),
}));