Compare commits
6 Commits
feat/ambie
...
fix/ungate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe00f42bce | ||
| 7bf9d49a02 | |||
|
|
7b70c91c41 | ||
| 23c8218811 | |||
|
|
b2aaa9d18d | ||
| 45b875ae2c |
56
docs/ungated-global.md
Normal file
56
docs/ungated-global.md
Normal 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).
|
||||||
137
src/mcpd/src/bootstrap/ungated-global.ts
Normal file
137
src/mcpd/src/bootstrap/ungated-global.ts
Normal 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}'`);
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ import { PersonalityService } from './services/personality.service.js';
|
|||||||
import { registerPersonalityRoutes } from './routes/personalities.js';
|
import { registerPersonalityRoutes } from './routes/personalities.js';
|
||||||
import { registerWebUi } from './routes/web-ui.js';
|
import { registerWebUi } from './routes/web-ui.js';
|
||||||
import { bootstrapSystemProject } from './bootstrap/system-project.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 { SET_OWN_PASSWORD_OPERATION } from './bootstrap/self-password-permission.js';
|
||||||
import { registerLogRoutes } from './routes/logs.js';
|
import { registerLogRoutes } from './routes/logs.js';
|
||||||
import { errorLogBuffer } from './services/error-log-buffer.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.
|
// pre-existing `Secret` rows are pointed at it. Idempotent per run.
|
||||||
await bootstrapSecretBackends(prisma);
|
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
|
// CUID detection for RBAC name resolution
|
||||||
const CUID_RE = /^c[^\s-]{8,}$/i;
|
const CUID_RE = /^c[^\s-]{8,}$/i;
|
||||||
const nameResolvers: Record<string, { findById(id: string): Promise<{ name: string } | null> }> = {
|
const nameResolvers: Record<string, { findById(id: string): Promise<{ name: string } | null> }> = {
|
||||||
|
|||||||
@@ -49,21 +49,40 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
|
|||||||
reply.code(204);
|
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) => {
|
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/servers', async (request) => {
|
||||||
const project = await service.resolveAndGet(request.params.id);
|
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) => {
|
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/instructions', async (request) => {
|
||||||
const project = await service.resolveAndGet(request.params.id);
|
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 {
|
return {
|
||||||
prompt: project.prompt,
|
prompt: [globalPrompt, project.prompt].filter(Boolean).join('\n\n'),
|
||||||
servers: project.servers.map((ps) => ({
|
servers: [
|
||||||
name: (ps.server as Record<string, unknown>).name as string,
|
...project.servers.map((ps) => ({
|
||||||
description: (ps.server as Record<string, unknown>).description as string,
|
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,
|
||||||
|
})),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { IProjectRepository, ProjectWithRelations } from '../repositories/p
|
|||||||
import type { IMcpServerRepository } from '../repositories/interfaces.js';
|
import type { IMcpServerRepository } from '../repositories/interfaces.js';
|
||||||
import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js';
|
import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js';
|
||||||
import { NotFoundError, ConflictError } from './mcp-server.service.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';
|
import type { McpConfig } from './mcp-config-generator.js';
|
||||||
|
|
||||||
export class ProjectService {
|
export class ProjectService {
|
||||||
@@ -22,6 +23,39 @@ export class ProjectService {
|
|||||||
return project;
|
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. */
|
/** Resolve by ID or name. */
|
||||||
async resolveAndGet(idOrName: string): Promise<ProjectWithRelations> {
|
async resolveAndGet(idOrName: string): Promise<ProjectWithRelations> {
|
||||||
// Try by ID first
|
// Try by ID first
|
||||||
|
|||||||
117
src/mcpd/tests/bootstrap-ungated-global.test.ts
Normal file
117
src/mcpd/tests/bootstrap-ungated-global.test.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -345,4 +345,78 @@ describe('Project Routes', () => {
|
|||||||
expect(body.serverOverrides).toEqual({ ha: { proxyModel: 'ha-special' } });
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ interface McpdServer {
|
|||||||
status?: string;
|
status?: string;
|
||||||
/** Per-server tool-call deadline override, in seconds. Null = use the global. */
|
/** Per-server tool-call deadline override, in seconds. Null = use the global. */
|
||||||
toolCallTimeoutSeconds?: number | null;
|
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.
|
* @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(
|
export async function refreshProjectUpstreams(
|
||||||
router: McpRouter,
|
router: McpRouter,
|
||||||
mcpdClient: McpdClient,
|
mcpdClient: McpdClient,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
authToken?: string,
|
authToken?: string,
|
||||||
): Promise<string[]> {
|
): Promise<ProjectUpstreams> {
|
||||||
const path = `/api/v1/projects/${encodeURIComponent(projectName)}/servers`;
|
const path = `/api/v1/projects/${encodeURIComponent(projectName)}/servers`;
|
||||||
|
|
||||||
let servers: McpdServer[];
|
let servers: McpdServer[];
|
||||||
@@ -54,7 +67,11 @@ export async function refreshProjectUpstreams(
|
|||||||
// Bind a per-request client with the caller's bearer so each McpdUpstream
|
// Bind a per-request client with the caller's bearer so each McpdUpstream
|
||||||
// forwards the same identity that passed project discovery.
|
// forwards the same identity that passed project discovery.
|
||||||
const upstreamClient = authToken ? mcpdClient.withToken(authToken) : mcpdClient;
|
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
|
// Create new router or refresh existing one
|
||||||
const router = existing?.router ?? new McpRouter();
|
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
|
// Resolve project LLM model: local override → mcpd recommendation → global default
|
||||||
const localOverride = loadProjectLlmOverride(projectName);
|
const localOverride = loadProjectLlmOverride(projectName);
|
||||||
@@ -165,6 +165,9 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
|||||||
providerRegistry: effectiveRegistry,
|
providerRegistry: effectiveRegistry,
|
||||||
};
|
};
|
||||||
if (resolvedModel) pluginConfig.modelOverride = resolvedModel;
|
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)
|
// Route gate prompt-selection through a server Llm (mcpd inference proxy)
|
||||||
// so cloud/server keys stay at the k8s level; the local personal-token
|
// so cloud/server keys stay at the k8s level; the local personal-token
|
||||||
// provider is the fallback. See credential-tiering. A dedicated fast
|
// provider is the fallback. See credential-tiering. A dedicated fast
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ export function createAmbientContextPlugin(): ProxyModelPlugin {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async onToolsList(tools) {
|
async onToolsList(tools) {
|
||||||
// A gated catalog is exactly [begin_session] (gate.ts onToolsList);
|
// A gated catalog is [begin_session, ...gate-exempt ungated-global
|
||||||
// begin_session never appears in an ungated list. If the gate's gated
|
// tools] (gate.ts onToolsList); begin_session never appears in an
|
||||||
// list ever changes shape, revisit this marker.
|
// 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 === 'begin_session')) return tools;
|
||||||
if (tools.some((t) => t.name === GET_CURRENT_TIME_TOOL_NAME)) return tools;
|
if (tools.some((t) => t.name === GET_CURRENT_TIME_TOOL_NAME)) return tools;
|
||||||
return [...tools, getCurrentTimeTool()];
|
return [...tools, getCurrentTimeTool()];
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ export interface GatePluginConfig {
|
|||||||
* personal-token provider registry. See credential-tiering principle.
|
* personal-token provider registry. See credential-tiering principle.
|
||||||
*/
|
*/
|
||||||
llmProvider?: string;
|
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;
|
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)
|
// Per-session state tracking (plugin-scoped, not global SessionGate)
|
||||||
const sessionGate = new 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 {
|
return {
|
||||||
name: 'gate',
|
name: 'gate',
|
||||||
description: 'Gated session flow: begin_session → prompt selection → ungate.',
|
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.
|
// canonical `server/tool` would name functions the model cannot call.
|
||||||
try {
|
try {
|
||||||
const tools = await ctx.discoverTools();
|
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):');
|
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)}`);
|
parts.push(` ${sanitizeWireName(t.name)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,9 +181,9 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
|
|||||||
},
|
},
|
||||||
|
|
||||||
async onToolsList(tools, ctx) {
|
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)) {
|
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)
|
// 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) {
|
async onToolCallBefore(toolName, args, request, ctx) {
|
||||||
// If gated and trying to call a real tool, auto-ungate via keyword extraction
|
// If gated and trying to call a real tool, auto-ungate via keyword extraction.
|
||||||
if (sessionGate.isGated(ctx.sessionId)) {
|
// 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 handleGatedIntercept(request, ctx, toolName, args, sessionGate, tagMatcher);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ function setupPluginRouter(opts: {
|
|||||||
/** When set, the heavy provider's complete() rejects — exercises the loud fallback. */
|
/** When set, the heavy provider's complete() rejects — exercises the loud fallback. */
|
||||||
llmError?: boolean;
|
llmError?: boolean;
|
||||||
byteBudget?: number;
|
byteBudget?: number;
|
||||||
|
/** Gate-exempt server names (ungated-global project). */
|
||||||
|
ungatedServers?: string[];
|
||||||
} = {}): { router: McpRouter; mcpdClient: McpdClient } {
|
} = {}): { router: McpRouter; mcpdClient: McpdClient } {
|
||||||
const router = new McpRouter();
|
const router = new McpRouter();
|
||||||
const prompts = opts.prompts ?? samplePrompts;
|
const prompts = opts.prompts ?? samplePrompts;
|
||||||
@@ -105,6 +107,7 @@ function setupPluginRouter(opts: {
|
|||||||
gated: opts.gated !== false,
|
gated: opts.gated !== false,
|
||||||
providerRegistry,
|
providerRegistry,
|
||||||
byteBudget: opts.byteBudget,
|
byteBudget: opts.byteBudget,
|
||||||
|
...(opts.ungatedServers ? { ungatedServers: opts.ungatedServers } : {}),
|
||||||
});
|
});
|
||||||
router.setPlugin(gatePlugin);
|
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', () => {
|
describe('tools/list gating', () => {
|
||||||
it('shows only begin_session when session is gated', async () => {
|
it('shows only begin_session when session is gated', async () => {
|
||||||
const { router } = setupPluginRouter();
|
const { router } = setupPluginRouter();
|
||||||
|
|||||||
@@ -27,9 +27,22 @@ describe('refreshProjectUpstreams', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const registered = await refreshProjectUpstreams(router, client as any, 'smart-home');
|
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('grafana');
|
||||||
expect(router.getUpstreamNames()).toContain('ha');
|
expect(router.getUpstreamNames()).toContain('calcmcp');
|
||||||
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/smart-home/servers');
|
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/smart-home/servers');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,7 +98,7 @@ describe('refreshProjectUpstreams', () => {
|
|||||||
const client = mockMcpdClient([]);
|
const client = mockMcpdClient([]);
|
||||||
|
|
||||||
const registered = await refreshProjectUpstreams(router, client as any, 'empty-project');
|
const registered = await refreshProjectUpstreams(router, client as any, 'empty-project');
|
||||||
expect(registered).toEqual([]);
|
expect(registered.names).toEqual([]);
|
||||||
expect(router.getUpstreamNames()).toHaveLength(0);
|
expect(router.getUpstreamNames()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ vi.mock('../src/discovery.js', () => ({
|
|||||||
close: async () => {},
|
close: async () => {},
|
||||||
isAlive: () => true,
|
isAlive: () => true,
|
||||||
});
|
});
|
||||||
return ['websearch'];
|
return { names: ['websearch'], ungatedServers: [] };
|
||||||
}),
|
}),
|
||||||
// Ungated (content-pipeline style), like chat-web and personal-michal.
|
// Ungated (content-pipeline style), like chat-web and personal-michal.
|
||||||
fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })),
|
fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ vi.mock('../src/discovery.js', () => ({
|
|||||||
close: async () => {},
|
close: async () => {},
|
||||||
isAlive: () => true,
|
isAlive: () => true,
|
||||||
});
|
});
|
||||||
return ['websearch'];
|
return { names: ['websearch'], ungatedServers: [] };
|
||||||
}),
|
}),
|
||||||
// gated: false → no gate plugin, the full catalog is served at initialize
|
// gated: false → no gate plugin, the full catalog is served at initialize
|
||||||
// (the chat-web configuration).
|
// (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
|
// Mock discovery module — we don't want real HTTP calls
|
||||||
vi.mock('../src/discovery.js', () => ({
|
vi.mock('../src/discovery.js', () => ({
|
||||||
refreshProjectUpstreams: vi.fn(async () => ['mock-server']),
|
refreshProjectUpstreams: vi.fn(async () => ({ names: ['mock-server'], ungatedServers: [] })),
|
||||||
fetchProjectLlmConfig: vi.fn(async () => ({})),
|
fetchProjectLlmConfig: vi.fn(async () => ({})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ vi.mock('../src/discovery.js', () => ({
|
|||||||
close: async () => { /* noop */ },
|
close: async () => { /* noop */ },
|
||||||
isAlive: () => true,
|
isAlive: () => true,
|
||||||
});
|
});
|
||||||
return ['blackhole'];
|
return { names: ['blackhole'], ungatedServers: [] };
|
||||||
}),
|
}),
|
||||||
fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })),
|
fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })),
|
||||||
}));
|
}));
|
||||||
|
|||||||
20
templates/calcmcp.yaml
Normal file
20
templates/calcmcp.yaml
Normal 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: []
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
name: ttrss
|
name: ttrss
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
description: Tiny Tiny RSS — feeds, headlines, article content, read/star state, subscribe
|
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
|
transport: STDIO
|
||||||
repositoryUrl: https://mysources.co.uk/michal/ttrss-mcp
|
repositoryUrl: https://mysources.co.uk/michal/ttrss-mcp
|
||||||
# No command: the image's entrypoint IS the MCP server. Custom server; the
|
# No command: the image's entrypoint IS the MCP server. Custom server; the
|
||||||
|
|||||||
Reference in New Issue
Block a user