From 7b70c91c4138d333d3ff927ca7445a7d9a86c402 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 3 Oct 2026 01:45:44 +0100 Subject: [PATCH] ungated-global: system project whose servers ride every project, pre-gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ungated-global.md | 56 ++++++++ src/mcpd/src/bootstrap/ungated-global.ts | 124 ++++++++++++++++++ src/mcpd/src/main.ts | 3 + src/mcpd/src/routes/projects.ts | 35 +++-- src/mcpd/src/services/project.service.ts | 34 +++++ .../tests/bootstrap-ungated-global.test.ts | 112 ++++++++++++++++ src/mcpd/tests/project-routes.test.ts | 74 +++++++++++ src/mcplocal/src/discovery.ts | 21 ++- src/mcplocal/src/http/project-mcp-endpoint.ts | 5 +- .../src/proxymodel/plugins/ambient-context.ts | 6 +- src/mcplocal/src/proxymodel/plugins/gate.ts | 34 ++++- src/mcplocal/tests/plugin-gate.test.ts | 60 +++++++++ src/mcplocal/tests/project-discovery.test.ts | 19 ++- .../project-mcp-endpoint-ambient.test.ts | 2 +- .../project-mcp-endpoint-wire-names.test.ts | 2 +- .../tests/project-mcp-endpoint.test.ts | 2 +- .../tests/toolcall-deadline-e2e.test.ts | 2 +- templates/calcmcp.yaml | 20 +++ 18 files changed, 584 insertions(+), 27 deletions(-) create mode 100644 docs/ungated-global.md create mode 100644 src/mcpd/src/bootstrap/ungated-global.ts create mode 100644 src/mcpd/tests/bootstrap-ungated-global.test.ts create mode 100644 templates/calcmcp.yaml diff --git a/docs/ungated-global.md b/docs/ungated-global.md new file mode 100644 index 0000000..b77c0d7 --- /dev/null +++ b/docs/ungated-global.md @@ -0,0 +1,56 @@ +# ungated-global: servers under every project, before the gate + +`ungated-global` is a system project with one special property: **every server +attached to it is exposed under every project's MCP endpoint, in front of the +gate** — its tools are listed and callable before `begin_session`, and the +project's `prompt` is prepended to every project's instructions. + +Fresh mcpd instances seed it **once** with the default `calcmcp` server +(scientific calculator, patched fork of +[thinkitpossible/CalcMCP](https://github.com/thinkitpossible/CalcMCP) at +) and a prompt telling models to never +do math themselves and always use calcmcp. + +## Semantics + +- **Seed-once, not resurrect**: the first successful mcpd startup writes + `ungatedGlobalSeeded: true` into the `mcpctl-system-settings` secret. After + that, nothing is ever re-created — deletions are durable: + - `mcpctl delete server calcmcp -p ungated-global` detaches the default + server (delete the server resource itself to also stop its instance); + - deleting the whole `ungated-global` project turns the feature off. + To re-create the default by hand there is a `calcmcp` template: + `mcpctl create server calcmcp --from-template calcmcp`, then attach it to + a (re-created) `ungated-global` project. +- **Merging** happens in mcpd: + - `GET /api/v1/projects/:id/servers` appends ungated-global's servers, + flagged `ungatedGlobal: true`. A server attached to the project directly + wins (no duplicate, no flag). + - `GET /api/v1/projects/:id/instructions` prepends ungated-global's + `prompt` and appends its servers to the server list. +- **Gate exemption** happens in mcplocal: discovery passes the flagged names + into the gate plugin (`ungatedServers`); while a session is gated, + `tools/list` returns `begin_session` **plus** the exempt servers' tools, and + calls to them pass through without triggering the gate intercept or + ungating the session. Initialize instructions list them under + "Available immediately (no begin_session required)". + +## Editing the prompt + +The pre-gate directive lives on the project itself: + +```bash +mcpctl patch project ungated-global --prompt "…" +``` + +Caveat: clients that ignore MCP initialize instructions (e.g. LibreChat uses +config-literal serverInstructions) never see project prompts — for those, the +carrier is the tool/server descriptions themselves, which is why calcmcp's +server description also says "use it for every mathematical calculation". + +## Security note + +Anything attached to ungated-global is reachable from **every** project by +**every** token that can reach any project endpoint. Only attach servers that +are safe in every context: no secrets, no egress, no side effects. calcmcp is +pure compute (no env, no network). diff --git a/src/mcpd/src/bootstrap/ungated-global.ts b/src/mcpd/src/bootstrap/ungated-global.ts new file mode 100644 index 0000000..0225d15 --- /dev/null +++ b/src/mcpd/src/bootstrap/ungated-global.ts @@ -0,0 +1,124 @@ +/** + * Bootstrap the `ungated-global` system project. + * + * Servers attached to this project are merged into EVERY project's MCP + * endpoint, in front of the gate: their tools are listed and callable + * before begin_session, and the project's prompt is prepended to every + * project's instructions. See docs/ungated-global.md. + * + * Seeding is ONCE-EVER, not idempotent-per-startup like the other + * bootstraps: the whole point of `mcpctl delete server calcmcp -p + * ungated-global` is that the default stays gone. A marker key in the + * `mcpctl-system-settings` secret records that seeding already happened, + * so later startups leave admin deletions alone. + */ + +import type { PrismaClient } from '@prisma/client'; +import { SYSTEM_SETTINGS_SECRET } from './system-settings.js'; + +/** Well-known project whose servers are exposed under every project, pre-gate. */ +export const UNGATED_GLOBAL_PROJECT_NAME = 'ungated-global'; + +/** Settings key: set after the one-time seed so deletions stay deleted. */ +export const SETTING_UNGATED_GLOBAL_SEEDED = 'ungatedGlobalSeeded'; + +/** Well-known email for the system user (shared with bootstrapSystemProject). */ +const SYSTEM_USER_EMAIL = 'system@mcpctl.local'; + +const DEFAULT_SERVER = { + name: 'calcmcp', + description: + 'Scientific calculator (SymPy/NumPy/SciPy): symbolic algebra, calculus, ' + + 'equation & ODE solving, linear algebra, statistics, FFT. ' + + 'Use it for EVERY mathematical calculation instead of computing yourself.', + dockerImage: 'mysources.co.uk/michal/calcmcp:1.1.0', + transport: 'STDIO' as const, + repositoryUrl: 'https://mysources.co.uk/michal/CalcMCP', + healthCheck: { + tool: 'symbolic_tool', + arguments: { operation: 'simplify', expression: '1+1' }, + intervalSeconds: 300, + timeoutSeconds: 10, + }, +}; + +const DEFAULT_PROMPT = `Mathematical reliability rule: never perform mathematical calculations yourself, not even simple-looking arithmetic. Always use the calcmcp tools (symbolic_tool, numpy_tool, scipy_tool) for every calculation — arithmetic, algebra, calculus, equations, matrices, statistics. They are available immediately, no begin_session needed. Better safe than sorry: if an answer involves numbers, compute it with calcmcp.`; + +async function isSeeded(prisma: PrismaClient): Promise { + const secret = await prisma.secret.findUnique({ where: { name: SYSTEM_SETTINGS_SECRET } }); + if (!secret) return false; + const data = (secret.data ?? {}) as Record; + return data[SETTING_UNGATED_GLOBAL_SEEDED] === true; +} + +async function markSeeded(prisma: PrismaClient): Promise { + const existing = await prisma.secret.findUnique({ where: { name: SYSTEM_SETTINGS_SECRET } }); + const data = { ...((existing?.data ?? {}) as Record), [SETTING_UNGATED_GLOBAL_SEEDED]: true }; + const keyNames = Object.keys(data).sort(); + if (existing) { + await prisma.secret.update({ + where: { name: SYSTEM_SETTINGS_SECRET }, + data: { data, keyNames, version: existing.version + 1 }, + }); + } else { + // backendId '' is repointed at the default plaintext backend by + // bootstrapSecretBackends — same convention as pre-existing rows. + await prisma.secret.create({ + data: { name: SYSTEM_SETTINGS_SECRET, data, keyNames }, + }); + } +} + +/** + * Seed the ungated-global project with its default calcmcp server, once ever. + * Safe to call on every startup; after the first successful seed (or after an + * admin deletes any of it) nothing is re-created. + */ +export async function bootstrapUngatedGlobal(prisma: PrismaClient): Promise { + if (await isSeeded(prisma)) return; + + const systemUser = await prisma.user.upsert({ + where: { email: SYSTEM_USER_EMAIL }, + create: { + email: SYSTEM_USER_EMAIL, + name: 'System', + passwordHash: '!locked', + role: 'USER', + }, + update: {}, + }); + + const project = await prisma.project.upsert({ + where: { name: UNGATED_GLOBAL_PROJECT_NAME }, + create: { + name: UNGATED_GLOBAL_PROJECT_NAME, + description: 'System project: servers here are exposed under every project, before the gate', + prompt: DEFAULT_PROMPT, + gated: false, + ownerId: systemUser.id, + }, + update: {}, + }); + + const server = await prisma.mcpServer.upsert({ + where: { name: DEFAULT_SERVER.name }, + create: { + name: DEFAULT_SERVER.name, + description: DEFAULT_SERVER.description, + dockerImage: DEFAULT_SERVER.dockerImage, + transport: DEFAULT_SERVER.transport, + repositoryUrl: DEFAULT_SERVER.repositoryUrl, + healthCheck: DEFAULT_SERVER.healthCheck, + }, + update: {}, + }); + + await prisma.projectServer.upsert({ + where: { projectId_serverId: { projectId: project.id, serverId: server.id } }, + create: { projectId: project.id, serverId: server.id }, + update: {}, + }); + + await markSeeded(prisma); + console.log(`Seeded ${UNGATED_GLOBAL_PROJECT_NAME} project with default server '${DEFAULT_SERVER.name}'`); +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index ce2bd17..070527b 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -60,6 +60,7 @@ import { PersonalityService } from './services/personality.service.js'; import { registerPersonalityRoutes } from './routes/personalities.js'; import { registerWebUi } from './routes/web-ui.js'; import { bootstrapSystemProject } from './bootstrap/system-project.js'; +import { bootstrapUngatedGlobal } from './bootstrap/ungated-global.js'; import { SET_OWN_PASSWORD_OPERATION } from './bootstrap/self-password-permission.js'; import { registerLogRoutes } from './routes/logs.js'; import { errorLogBuffer } from './services/error-log-buffer.js'; @@ -413,6 +414,8 @@ async function main(): Promise { await bootstrapSystemProject(prisma); // PR-4: bootstrap system-owned global skills (e.g. propose-learnings). await bootstrapSystemSkills(prisma); + // Seed-once ungated-global project (default calcmcp server, deletable). + await bootstrapUngatedGlobal(prisma); // Repositories const serverRepo = new McpServerRepository(prisma); diff --git a/src/mcpd/src/routes/projects.ts b/src/mcpd/src/routes/projects.ts index c49ed37..a4aac10 100644 --- a/src/mcpd/src/routes/projects.ts +++ b/src/mcpd/src/routes/projects.ts @@ -49,21 +49,40 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ reply.code(204); }); - // List servers in a project (for mcplocal discovery) + // List servers in a project (for mcplocal discovery). Servers of the + // ungated-global system project ride along for every project, flagged + // `ungatedGlobal: true` so mcplocal exempts them from the gate. A server + // attached to the project directly wins over its global copy. app.get<{ Params: { id: string } }>('/api/v1/projects/:id/servers', async (request) => { const project = await service.resolveAndGet(request.params.id); - return project.servers.map((ps) => ps.server); + const own = project.servers.map((ps) => ps.server); + const ownNames = new Set(own.map((s) => (s as Record).name as string)); + const globals = (await service.getGlobalServers(project.name)) + .filter((g) => !ownNames.has(g.name as string)); + return [...own, ...globals]; }); - // Get project instructions for LLM (prompt + server list) + // Get project instructions for LLM (prompt + server list). The + // ungated-global project's prompt is prepended so its directive (e.g. + // "always use calcmcp for math") reaches every project's model pre-gate. app.get<{ Params: { id: string } }>('/api/v1/projects/:id/instructions', async (request) => { const project = await service.resolveAndGet(request.params.id); + const globalPrompt = await service.getGlobalPrompt(project.name); + const ownNames = new Set(project.servers.map((ps) => (ps.server as Record).name as string)); + const globals = (await service.getGlobalServers(project.name)) + .filter((g) => !ownNames.has(g.name as string)); return { - prompt: project.prompt, - servers: project.servers.map((ps) => ({ - name: (ps.server as Record).name as string, - description: (ps.server as Record).description as string, - })), + prompt: [globalPrompt, project.prompt].filter(Boolean).join('\n\n'), + servers: [ + ...project.servers.map((ps) => ({ + name: (ps.server as Record).name as string, + description: (ps.server as Record).description as string, + })), + ...globals.map((g) => ({ + name: g.name as string, + description: g.description as string, + })), + ], }; }); } diff --git a/src/mcpd/src/services/project.service.ts b/src/mcpd/src/services/project.service.ts index d1d982c..97ae671 100644 --- a/src/mcpd/src/services/project.service.ts +++ b/src/mcpd/src/services/project.service.ts @@ -2,6 +2,7 @@ import type { IProjectRepository, ProjectWithRelations } from '../repositories/p import type { IMcpServerRepository } from '../repositories/interfaces.js'; import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js'; import { NotFoundError, ConflictError } from './mcp-server.service.js'; +import { UNGATED_GLOBAL_PROJECT_NAME } from '../bootstrap/ungated-global.js'; import type { McpConfig } from './mcp-config-generator.js'; export class ProjectService { @@ -22,6 +23,39 @@ export class ProjectService { return project; } + /** + * Servers of the `ungated-global` system project, for merging into another + * project's server list / instructions. Returns [] when asked for the + * global project itself (no self-merge), when the project was deleted, or + * on any lookup failure — global servers are an additive convenience and + * must never break a project endpoint. + */ + async getGlobalServers(forProjectName: string): Promise>> { + if (forProjectName === UNGATED_GLOBAL_PROJECT_NAME) return []; + try { + const global = await this.projectRepo.findByName(UNGATED_GLOBAL_PROJECT_NAME); + if (global === null) return []; + return global.servers.map((ps) => ({ + ...(ps.server as unknown as Record), + ungatedGlobal: true, + })); + } catch { + return []; + } + } + + /** Prompt of the `ungated-global` project (prepended to every project's instructions). */ + async getGlobalPrompt(forProjectName: string): Promise { + if (forProjectName === UNGATED_GLOBAL_PROJECT_NAME) return null; + try { + const global = await this.projectRepo.findByName(UNGATED_GLOBAL_PROJECT_NAME); + const prompt = global?.prompt?.trim(); + return prompt ? prompt : null; + } catch { + return null; + } + } + /** Resolve by ID or name. */ async resolveAndGet(idOrName: string): Promise { // Try by ID first diff --git a/src/mcpd/tests/bootstrap-ungated-global.test.ts b/src/mcpd/tests/bootstrap-ungated-global.test.ts new file mode 100644 index 0000000..9123f1a --- /dev/null +++ b/src/mcpd/tests/bootstrap-ungated-global.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + bootstrapUngatedGlobal, + UNGATED_GLOBAL_PROJECT_NAME, + SETTING_UNGATED_GLOBAL_SEEDED, +} from '../src/bootstrap/ungated-global.js'; +import { SYSTEM_SETTINGS_SECRET } from '../src/bootstrap/system-settings.js'; +import type { PrismaClient } from '@prisma/client'; + +function mockPrisma(opts: { settingsData?: Record | null } = {}): PrismaClient { + const secretRow = opts.settingsData + ? { id: 'secret-1', name: SYSTEM_SETTINGS_SECRET, data: opts.settingsData, version: 3 } + : null; + + return { + secret: { + findUnique: vi.fn(async () => secretRow), + update: vi.fn(async () => ({})), + create: vi.fn(async () => ({})), + }, + user: { + upsert: vi.fn(async () => ({ id: 'sys-user-id', email: 'system@mcpctl.local' })), + }, + project: { + upsert: vi.fn(async (args: { where: { name: string } }) => ({ id: 'proj-ug', name: args.where.name })), + }, + mcpServer: { + upsert: vi.fn(async (args: { where: { name: string } }) => ({ id: 'srv-calc', name: args.where.name })), + }, + projectServer: { + upsert: vi.fn(async () => ({})), + }, + } as unknown as PrismaClient; +} + +describe('bootstrapUngatedGlobal', () => { + let prisma: PrismaClient; + + beforeEach(() => { + prisma = mockPrisma(); + }); + + it('seeds the ungated-global project with the calcmcp server and attaches it', async () => { + await bootstrapUngatedGlobal(prisma); + + expect(prisma.project.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { name: UNGATED_GLOBAL_PROJECT_NAME }, + create: expect.objectContaining({ gated: false, prompt: expect.stringContaining('calcmcp') }), + }), + ); + expect(prisma.mcpServer.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { name: 'calcmcp' }, + create: expect.objectContaining({ + dockerImage: expect.stringContaining('calcmcp'), + transport: 'STDIO', + }), + }), + ); + expect(prisma.projectServer.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { projectId_serverId: { projectId: 'proj-ug', serverId: 'srv-calc' } }, + }), + ); + }); + + it('marks the seed as done in the system settings secret', async () => { + await bootstrapUngatedGlobal(prisma); + + // Settings secret didn't exist → created with the marker key + expect(prisma.secret.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: SYSTEM_SETTINGS_SECRET, + data: expect.objectContaining({ [SETTING_UNGATED_GLOBAL_SEEDED]: true }), + }), + }), + ); + }); + + it('does NOT re-seed when the marker is set (deletions stay deleted)', async () => { + prisma = mockPrisma({ settingsData: { [SETTING_UNGATED_GLOBAL_SEEDED]: true } }); + + await bootstrapUngatedGlobal(prisma); + + expect(prisma.project.upsert).not.toHaveBeenCalled(); + expect(prisma.mcpServer.upsert).not.toHaveBeenCalled(); + expect(prisma.projectServer.upsert).not.toHaveBeenCalled(); + expect(prisma.secret.update).not.toHaveBeenCalled(); + expect(prisma.secret.create).not.toHaveBeenCalled(); + }); + + it('preserves existing settings keys when writing the marker', async () => { + prisma = mockPrisma({ settingsData: { allowSelfPasswordChange: false } }); + + await bootstrapUngatedGlobal(prisma); + + expect(prisma.secret.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { name: SYSTEM_SETTINGS_SECRET }, + data: expect.objectContaining({ + data: { + allowSelfPasswordChange: false, + [SETTING_UNGATED_GLOBAL_SEEDED]: true, + }, + version: 4, + }), + }), + ); + }); +}); diff --git a/src/mcpd/tests/project-routes.test.ts b/src/mcpd/tests/project-routes.test.ts index c21757e..e3c119d 100644 --- a/src/mcpd/tests/project-routes.test.ts +++ b/src/mcpd/tests/project-routes.test.ts @@ -345,4 +345,78 @@ describe('Project Routes', () => { expect(body.serverOverrides).toEqual({ ha: { proxyModel: 'ha-special' } }); }); }); + + describe('ungated-global merge', () => { + const mkPs = (id: string, name: string, description = '') => + ({ id: `ps-${id}`, projectId: 'x', serverId: id, createdAt: new Date(), server: { id, name, description } }) as never; + + function repoWithGlobal(ownServers: never[] = []) { + const repo = mockProjectRepo(); + vi.mocked(repo.findById).mockImplementation(async (id: string) => + id === 'p1' ? makeProject({ id: 'p1', name: 'my-proj', prompt: 'own prompt', servers: ownServers }) : null); + vi.mocked(repo.findByName).mockImplementation(async (name: string) => { + if (name === 'ungated-global') { + return makeProject({ + id: 'p-ug', + name: 'ungated-global', + prompt: 'Always use calcmcp for math.', + gated: false, + servers: [mkPs('srv-calc', 'calcmcp', 'scientific calculator')], + }); + } + return null; + }); + return repo; + } + + it('GET /servers appends global servers flagged ungatedGlobal', async () => { + await createApp(repoWithGlobal([mkPs('srv-ha', 'ha')])); + const res = await app.inject({ method: 'GET', url: '/api/v1/projects/p1/servers' }); + expect(res.statusCode).toBe(200); + const body = res.json>(); + expect(body.map((s) => s.name)).toEqual(['ha', 'calcmcp']); + expect(body[0]!.ungatedGlobal).toBeUndefined(); + expect(body[1]!.ungatedGlobal).toBe(true); + }); + + it('GET /servers does not duplicate a server also attached directly', async () => { + await createApp(repoWithGlobal([mkPs('srv-calc', 'calcmcp')])); + const res = await app.inject({ method: 'GET', url: '/api/v1/projects/p1/servers' }); + const body = res.json>(); + expect(body).toHaveLength(1); + expect(body[0]!.name).toBe('calcmcp'); + expect(body[0]!.ungatedGlobal).toBeUndefined(); + }); + + it('GET /servers on ungated-global itself does not self-merge', async () => { + const repo = repoWithGlobal(); + vi.mocked(repo.findById).mockImplementation(async (id: string) => + id === 'p-ug' + ? makeProject({ id: 'p-ug', name: 'ungated-global', servers: [mkPs('srv-calc', 'calcmcp')] }) + : null); + await createApp(repo); + const res = await app.inject({ method: 'GET', url: '/api/v1/projects/p-ug/servers' }); + const body = res.json>(); + expect(body).toHaveLength(1); + }); + + it('GET /instructions prepends the global prompt and lists global servers', async () => { + await createApp(repoWithGlobal([mkPs('srv-ha', 'ha', 'home assistant')])); + const res = await app.inject({ method: 'GET', url: '/api/v1/projects/p1/instructions' }); + expect(res.statusCode).toBe(200); + const body = res.json<{ prompt: string; servers: Array<{ name: string }> }>(); + expect(body.prompt).toBe('Always use calcmcp for math.\n\nown prompt'); + expect(body.servers.map((s) => s.name)).toEqual(['ha', 'calcmcp']); + }); + + it('GET /servers is unchanged when ungated-global does not exist', async () => { + const repo = mockProjectRepo(); + vi.mocked(repo.findById).mockImplementation(async (id: string) => + id === 'p1' ? makeProject({ id: 'p1', name: 'my-proj', servers: [mkPs('srv-ha', 'ha')] }) : null); + await createApp(repo); + const res = await app.inject({ method: 'GET', url: '/api/v1/projects/p1/servers' }); + const body = res.json>(); + expect(body.map((s) => s.name)).toEqual(['ha']); + }); + }); }); diff --git a/src/mcplocal/src/discovery.ts b/src/mcplocal/src/discovery.ts index e150a84..951a127 100644 --- a/src/mcplocal/src/discovery.ts +++ b/src/mcplocal/src/discovery.ts @@ -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 { +): Promise { 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), + }; } /** diff --git a/src/mcplocal/src/http/project-mcp-endpoint.ts b/src/mcplocal/src/http/project-mcp-endpoint.ts index 605b21a..68fb73d 100644 --- a/src/mcplocal/src/http/project-mcp-endpoint.ts +++ b/src/mcplocal/src/http/project-mcp-endpoint.ts @@ -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 diff --git a/src/mcplocal/src/proxymodel/plugins/ambient-context.ts b/src/mcplocal/src/proxymodel/plugins/ambient-context.ts index 89ec628..caf1b72 100644 --- a/src/mcplocal/src/proxymodel/plugins/ambient-context.ts +++ b/src/mcplocal/src/proxymodel/plugins/ambient-context.ts @@ -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()]; diff --git a/src/mcplocal/src/proxymodel/plugins/gate.ts b/src/mcplocal/src/proxymodel/plugins/gate.ts index 2d42030..a0e8e26 100644 --- a/src/mcplocal/src/proxymodel/plugins/gate.ts +++ b/src/mcplocal/src/proxymodel/plugins/gate.ts @@ -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; diff --git a/src/mcplocal/tests/plugin-gate.test.ts b/src/mcplocal/tests/plugin-gate.test.ts index 49e3a85..1ee3116 100644 --- a/src/mcplocal/tests/plugin-gate.test.ts +++ b/src/mcplocal/tests/plugin-gate.test.ts @@ -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(); diff --git a/src/mcplocal/tests/project-discovery.test.ts b/src/mcplocal/tests/project-discovery.test.ts index 0348d4a..7c1d670 100644 --- a/src/mcplocal/tests/project-discovery.test.ts +++ b/src/mcplocal/tests/project-discovery.test.ts @@ -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); }); }); diff --git a/src/mcplocal/tests/project-mcp-endpoint-ambient.test.ts b/src/mcplocal/tests/project-mcp-endpoint-ambient.test.ts index 21b6e7d..fd98143 100644 --- a/src/mcplocal/tests/project-mcp-endpoint-ambient.test.ts +++ b/src/mcplocal/tests/project-mcp-endpoint-ambient.test.ts @@ -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' })), diff --git a/src/mcplocal/tests/project-mcp-endpoint-wire-names.test.ts b/src/mcplocal/tests/project-mcp-endpoint-wire-names.test.ts index f1f2b3c..c063bec 100644 --- a/src/mcplocal/tests/project-mcp-endpoint-wire-names.test.ts +++ b/src/mcplocal/tests/project-mcp-endpoint-wire-names.test.ts @@ -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). diff --git a/src/mcplocal/tests/project-mcp-endpoint.test.ts b/src/mcplocal/tests/project-mcp-endpoint.test.ts index d5540b4..aa3feed 100644 --- a/src/mcplocal/tests/project-mcp-endpoint.test.ts +++ b/src/mcplocal/tests/project-mcp-endpoint.test.ts @@ -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 () => ({})), })); diff --git a/src/mcplocal/tests/toolcall-deadline-e2e.test.ts b/src/mcplocal/tests/toolcall-deadline-e2e.test.ts index f69bc2c..e8a3d2d 100644 --- a/src/mcplocal/tests/toolcall-deadline-e2e.test.ts +++ b/src/mcplocal/tests/toolcall-deadline-e2e.test.ts @@ -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' })), })); diff --git a/templates/calcmcp.yaml b/templates/calcmcp.yaml new file mode 100644 index 0000000..db4a648 --- /dev/null +++ b/templates/calcmcp.yaml @@ -0,0 +1,20 @@ +name: calcmcp +version: "1.1.0" +description: Scientific calculator (SymPy/NumPy/SciPy) — symbolic algebra, calculus, equation & ODE solving, linear algebra, statistics, FFT +dockerImage: "mysources.co.uk/michal/calcmcp:1.1.0" +transport: STDIO +repositoryUrl: https://mysources.co.uk/michal/CalcMCP +# Patched fork of github.com/thinkitpossible/CalcMCP: proper MCP inputSchema, +# English tool docs, eval() removed, complex eigenvalues serialize, symbolic +# dsolve added. Pure compute — needs no env, no secrets, no egress. +# Seeded by default into the ungated-global project (see docs/ungated-global.md); +# this template exists so a deleted default can be re-created by hand: +# mcpctl create server calcmcp --from-template calcmcp +healthCheck: + tool: symbolic_tool + arguments: + operation: simplify + expression: "1+1" + intervalSeconds: 300 + timeoutSeconds: 10 +env: []