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
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:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' })),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 () => ({})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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' })),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user