Compare commits

...

6 Commits

Author SHA1 Message Date
Michal
fe00f42bce fix(ungated-global): create settings secret with a valid backend FK
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m19s
CI/CD / test (pull_request) Failing after 27s
CI/CD / lint (pull_request) Successful in 2m50s
CI/CD / smoke (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / publish (pull_request) Has been skipped
bootstrapUngatedGlobal wrote its seed marker into the mcpctl-system-settings
Secret, creating the row with the default backendId '' when it did not exist.
The Secret_backendId_fkey constraint is enforced at insert time, so on a DB
where that secret was absent, mcpd crash-looped on startup (P2003).

Fix: run the seed after bootstrapSecretBackends (so the default plaintext
backend exists) and set backendId to the resolved default on create. If no
backend exists at all, skip writing the marker rather than crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 01:57:37 +01:00
7bf9d49a02 Merge pull request 'ungated-global: system project whose servers ride every project, pre-gate' (#137) from feat/ungated-global into main
Some checks failed
CI/CD / lint (push) Successful in 1m13s
CI/CD / test (push) Successful in 1m25s
CI/CD / typecheck (push) Successful in 3m6s
CI/CD / smoke (push) Failing after 1m58s
CI/CD / build (push) Successful in 2m11s
CI/CD / publish (push) Has been skipped
2026-10-03 00:46:08 +00:00
Michal
7b70c91c41 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>
2026-10-03 01:45:44 +01:00
23c8218811 Merge pull request 'templates/ttrss: image 1.0.1 (Sec-Fetch fix + isError)' (#136) from fix/ttrss-template-101 into main
Some checks failed
CI/CD / lint (push) Successful in 1m11s
CI/CD / test (push) Successful in 1m40s
CI/CD / typecheck (push) Successful in 2m56s
CI/CD / smoke (push) Failing after 1m58s
CI/CD / build (push) Successful in 2m10s
CI/CD / publish (push) Has been skipped
2026-10-02 22:56:26 +00:00
Michal
b2aaa9d18d templates/ttrss: image 1.0.1 (node:http Sec-Fetch fix + isError)
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m17s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / lint (pull_request) Successful in 2m51s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 5m11s
CI/CD / publish (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 23:56:13 +01:00
45b875ae2c Merge pull request 'ambient context: give every project's model a clock' (#135) from feat/ambient-context into main
Some checks failed
CI/CD / lint (push) Successful in 1m16s
CI/CD / test (push) Successful in 1m25s
CI/CD / typecheck (push) Successful in 3m16s
CI/CD / smoke (push) Failing after 1m57s
CI/CD / build (push) Successful in 2m18s
CI/CD / publish (push) Has been skipped
2026-10-01 23:33:38 +00:00
19 changed files with 606 additions and 28 deletions

56
docs/ungated-global.md Normal file
View File

@@ -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
<https://mysources.co.uk/michal/CalcMCP>) 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).

View File

@@ -0,0 +1,137 @@
/**
* 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';
import { DEFAULT_PLAINTEXT_BACKEND_NAME } from './secret-backends.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<boolean> {
const secret = await prisma.secret.findUnique({ where: { name: SYSTEM_SETTINGS_SECRET } });
if (!secret) return false;
const data = (secret.data ?? {}) as Record<string, unknown>;
return data[SETTING_UNGATED_GLOBAL_SEEDED] === true;
}
async function markSeeded(prisma: PrismaClient): Promise<void> {
const existing = await prisma.secret.findUnique({ where: { name: SYSTEM_SETTINGS_SECRET } });
const data = { ...((existing?.data ?? {}) as Record<string, unknown>), [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 {
// The Secret.backendId FK is enforced at insert time, so a fresh row
// must point at a real backend. bootstrapUngatedGlobal runs after
// bootstrapSecretBackends, so the default plaintext backend exists;
// fall back to any backend if the name ever changes.
const backend =
(await prisma.secretBackend.findUnique({ where: { name: DEFAULT_PLAINTEXT_BACKEND_NAME } }))
?? (await prisma.secretBackend.findFirst({ where: { isDefault: true } }))
?? (await prisma.secretBackend.findFirst());
if (!backend) {
// No backend at all — skip marking rather than crash startup. The seed
// above still ran; worst case it re-runs (idempotent upserts) next boot.
console.warn('ungated-global: no SecretBackend found, seed marker not written');
return;
}
await prisma.secret.create({
data: { name: SYSTEM_SETTINGS_SECRET, data, keyNames, backendId: backend.id },
});
}
}
/**
* 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<void> {
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}'`);
}

View File

@@ -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';
@@ -435,6 +436,11 @@ async function main(): Promise<void> {
// pre-existing `Secret` rows are pointed at it. Idempotent per run.
await bootstrapSecretBackends(prisma);
// Seed-once ungated-global project (default calcmcp server, deletable).
// MUST run after bootstrapSecretBackends: its seed marker lives in the
// mcpctl-system-settings Secret, whose creation needs a valid backend FK.
await bootstrapUngatedGlobal(prisma);
// CUID detection for RBAC name resolution
const CUID_RE = /^c[^\s-]{8,}$/i;
const nameResolvers: Record<string, { findById(id: string): Promise<{ name: string } | null> }> = {

View File

@@ -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<string, unknown>).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<string, unknown>).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) => ({
prompt: [globalPrompt, project.prompt].filter(Boolean).join('\n\n'),
servers: [
...project.servers.map((ps) => ({
name: (ps.server as Record<string, unknown>).name as string,
description: (ps.server as Record<string, unknown>).description as string,
})),
...globals.map((g) => ({
name: g.name as string,
description: g.description as string,
})),
],
};
});
}

View File

@@ -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<Array<Record<string, unknown>>> {
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<string, unknown>),
ungatedGlobal: true,
}));
} catch {
return [];
}
}
/** Prompt of the `ungated-global` project (prepended to every project's instructions). */
async getGlobalPrompt(forProjectName: string): Promise<string | null> {
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<ProjectWithRelations> {
// Try by ID first

View File

@@ -0,0 +1,117 @@
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<string, unknown> | 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 () => ({})),
},
secretBackend: {
findUnique: vi.fn(async () => ({ id: 'backend-default', name: 'default', isDefault: true })),
findFirst: vi.fn(async () => ({ id: 'backend-default', name: 'default', isDefault: true })),
},
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 and a valid backend FK
expect(prisma.secret.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: SYSTEM_SETTINGS_SECRET,
backendId: 'backend-default',
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,
}),
}),
);
});
});

View File

@@ -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<Array<{ name: string; ungatedGlobal?: boolean }>>();
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<Array<{ name: string; ungatedGlobal?: boolean }>>();
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<Array<{ name: string }>>();
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<Array<{ name: string }>>();
expect(body.map((s) => s.name)).toEqual(['ha']);
});
});
});

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' })),
}));

20
templates/calcmcp.yaml Normal file
View File

@@ -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: []

View File

@@ -1,7 +1,7 @@
name: ttrss
version: "1.0.0"
description: Tiny Tiny RSS — feeds, headlines, article content, read/star state, subscribe
dockerImage: "mysources.co.uk/michal/ttrss-mcp:1.0.0"
dockerImage: "mysources.co.uk/michal/ttrss-mcp:1.0.1"
transport: STDIO
repositoryUrl: https://mysources.co.uk/michal/ttrss-mcp
# No command: the image's entrypoint IS the MCP server. Custom server; the