Merge origin/main into feat/agentic-teams

Conflict in src/mcpd/src/main.ts: both sides touched the import block.
Kept this branch's mapUrlToPermission plus main's bootstrapUngatedGlobal;
dropped main's SET_OWN_PASSWORD_OPERATION import — this branch moved that
usage into middleware/url-permission.ts, which imports it itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-10-09 15:32:03 +01:00
50 changed files with 1621 additions and 63 deletions

View File

@@ -49,6 +49,7 @@ const ServerSpecSchema = z.object({
// nullable: `get server -o yaml` emits null for servers with no override,
// and that YAML must apply back unchanged.
toolCallTimeoutSeconds: z.number().int().min(1).max(3600).nullable().optional(),
memoryLimitMb: z.number().int().min(64).max(16384).nullable().optional(),
});
const SecretSpecSchema = z.object({

View File

@@ -375,6 +375,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--health-check-interval <seconds>', 'Readiness probe interval in seconds (default 60)')
.option('--health-check-timeout <seconds>', 'Readiness probe timeout in seconds (default 10)')
.option('--tool-call-timeout <seconds>', 'Per-server tool-call deadline in seconds (default: mcplocal\'s global deadline)')
.option('--memory-limit-mb <mb>', 'Per-server memory ceiling in MiB (default: 512). Raise it for a server that drives a browser or holds a large index — at the default it is OOMKilled with no error, just a restart')
.option('--health-check-failure-threshold <count>', 'Consecutive failures before the instance is marked unhealthy (default 3)')
.option('--secret-delivery <mode>', 'How secret env reaches the container: env (default, value written into the pod spec) or injector (pod fetches its own secrets from OpenBao under a scoped identity)')
.option('--entrypoint <argv>', 'Comma-separated argv to exec under the injector wrapper. Required for --secret-delivery injector on a dockerImage server, whose ENTRYPOINT mcpd cannot introspect')
@@ -458,6 +459,9 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
if (opts.toolCallTimeout !== undefined) {
body.toolCallTimeoutSeconds = parsePositiveInt('--tool-call-timeout', opts.toolCallTimeout as string);
}
if (opts.memoryLimitMb !== undefined) {
body.memoryLimitMb = parsePositiveInt('--memory-limit-mb', opts.memoryLimitMb as string);
}
if (opts.packageName) body.packageName = opts.packageName;
if (opts.runtime) body.runtime = opts.runtime;
if (opts.dockerImage) body.dockerImage = opts.dockerImage;

View File

@@ -0,0 +1,9 @@
-- Per-server memory ceiling, in MiB.
--
-- NULL keeps today's behaviour exactly: the server gets the orchestrator's
-- DEFAULT_MEMORY_LIMIT (512 MiB). That default is fine for a server that
-- proxies an API, and fatal for one that drives a browser — docs-mcp-server
-- scrapes with headless Chromium, sits at ~228 MiB idle, and crosses 512 MiB
-- within seconds of the first scrape. The pod is OOMKilled and restarts, so
-- the only trace is a restart count and an empty job queue.
ALTER TABLE "McpServer" ADD COLUMN "memoryLimitMb" INTEGER;

View File

@@ -98,6 +98,13 @@ model McpServer {
/// genuinely slow tools; the deadline exists so a wedged call answers instead
/// of hanging, not to cut off honest work.
toolCallTimeoutSeconds Int?
/// Per-server memory ceiling in MiB. Null = the orchestrator's 512 MiB
/// default. Raise it for a server that legitimately needs more than that:
/// docs-mcp-server scrapes with headless Chromium and is OOMKilled at the
/// default before it indexes a single page, with nothing in the logs to say
/// so — the pod simply restarts and the job queue is gone.
memoryLimitMb Int?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

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

@@ -65,6 +65,7 @@ import { registerPersonalityRoutes } from './routes/personalities.js';
import { registerWebUi } from './routes/web-ui.js';
import { bootstrapSystemProject } from './bootstrap/system-project.js';
import { mapUrlToPermission } from './middleware/url-permission.js';
import { bootstrapUngatedGlobal } from './bootstrap/ungated-global.js';
import { registerLogRoutes } from './routes/logs.js';
import { errorLogBuffer } from './services/error-log-buffer.js';
import { bootstrapSystemSkills } from './bootstrap/system-skills.js';
@@ -286,6 +287,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

@@ -37,6 +37,7 @@ export class McpServerRepository implements IMcpServerRepository {
secretDelivery: data.secretDelivery,
entrypoint: (data.entrypoint ?? Prisma.DbNull) as Prisma.InputJsonValue,
toolCallTimeoutSeconds: data.toolCallTimeoutSeconds ?? null,
memoryLimitMb: data.memoryLimitMb ?? null,
},
});
}
@@ -59,6 +60,7 @@ export class McpServerRepository implements IMcpServerRepository {
if (data.secretDelivery !== undefined) updateData['secretDelivery'] = data.secretDelivery;
if (data.entrypoint !== undefined) updateData['entrypoint'] = (data.entrypoint ?? Prisma.JsonNull) as Prisma.InputJsonValue;
if (data.toolCallTimeoutSeconds !== undefined) updateData['toolCallTimeoutSeconds'] = data.toolCallTimeoutSeconds;
if (data.memoryLimitMb !== undefined) updateData['memoryLimitMb'] = data.memoryLimitMb;
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
}

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) => ({
name: (ps.server as Record<string, unknown>).name as string,
description: (ps.server as Record<string, unknown>).description as string,
})),
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

@@ -22,6 +22,7 @@
* adapter default. `extra` is forwarded as-is for provider-specific knobs.
*/
import type { ChatMessage } from '@prisma/client';
import { ambientContextLine } from '@mcpctl/shared';
import type { AgentService } from './agent.service.js';
import type { LlmService } from './llm.service.js';
import { effectivePoolName } from './llm.service.js';
@@ -772,6 +773,9 @@ export class ChatService {
...sortedProjectPrompts.map((p) => p.content),
...personalityPromptContents,
mergedParams.systemAppend ?? '',
// Rebuilt every turn, so the agent's clock stays current. Reads the
// same MCPLOCAL_AMBIENT_TZ env as mcplocal (see shared/ambient-time).
ambientContextLine(),
]
.filter((s) => s.length > 0)
.join('\n\n');
@@ -916,6 +920,8 @@ export class ChatService {
baseSystem,
...projectPrompts.map((p) => p.content),
mergedParams.systemAppend ?? '',
// Rebuilt every turn — the model's clock (same env as mcplocal).
ambientContextLine(),
]
.filter((s) => s.length > 0)
.join('\n\n');

View File

@@ -481,6 +481,16 @@ export class InstanceService {
spec.containerPort = server.containerPort ?? 3000;
}
// Per-server memory ceiling. Null leaves DEFAULT_MEMORY_LIMIT (512 MiB)
// in place, which is right for an API proxy and fatal for a server that
// drives a browser: docs-mcp-server was OOMKilled seconds into every
// scrape, and an OOMKill leaves no error — the pod restarts, the
// in-memory job queue is gone, and the instance reports healthy again.
const memoryLimitMb = server.memoryLimitMb as number | null;
if (memoryLimitMb !== null && memoryLimitMb !== undefined) {
spec.memoryLimit = memoryLimitMb * 1024 * 1024;
}
// Volumes are keyed on the server, not this instance: `spec.name` carries
// the instance id and a claim named after it would be torn down on the
// next server edit — exactly when the data has to survive.

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

@@ -54,6 +54,10 @@ export const CreateMcpServerSchema = z.object({
// the deadline exists so a wedged call answers instead of hanging, and a
// value beyond that is indistinguishable from no deadline at all.
toolCallTimeoutSeconds: z.number().int().min(1).max(3600).optional(),
// Per-server memory ceiling in MiB. Floor 64 because nothing useful runs
// below it; ceiling 16384 so a typo cannot ask for a pod no node can
// schedule, which fails as a silently Pending instance rather than an error.
memoryLimitMb: z.number().int().min(64).max(16384).optional(),
}).refine(
(s) => s.volumes.length === 0 || s.replicas <= 1,
{
@@ -91,6 +95,7 @@ export const UpdateMcpServerSchema = z.object({
secretDelivery: SecretDeliverySchema.optional(),
entrypoint: z.array(z.string()).nullable().optional(),
toolCallTimeoutSeconds: z.number().int().min(1).max(3600).nullable().optional(),
memoryLimitMb: z.number().int().min(64).max(16384).nullable().optional(),
});
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;

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

@@ -155,10 +155,11 @@ describe('ChatService — project chat', () => {
expect(t.projectId).toBe('proj-1');
expect(t.agentId).toBeNull();
expect(t.ownerId).toBe('owner-1');
// system message = project.prompt then HIGH then LOW
// system message = project.prompt then HIGH then LOW, then the ambient
// context line (the model's clock, appended every turn).
const sys = (adapter.infer as ReturnType<typeof vi.fn>).mock.calls[0][0].body.messages[0];
expect(sys.role).toBe('system');
expect(sys.content).toBe('Project system prompt.\n\nHIGH\n\nLOW');
expect(sys.content).toMatch(/^Project system prompt\.\n\nHIGH\n\nLOW\n\n\[context\] now: /);
});
it('routes tool calls to the project dispatcher with projectId', async () => {

View File

@@ -397,6 +397,8 @@ describe('ChatService', () => {
expect(text.indexOf('HIGH prompt')).toBeLessThan(text.indexOf('LOW prompt'));
// Agent's own system prompt leads.
expect(text.indexOf('You are a helpful agent.')).toBeLessThan(text.indexOf('HIGH prompt'));
// The ambient context line (the model's clock) closes the block.
expect(text).toMatch(/\n\n\[context\] now: .+$/);
});
it('refuses tool calls when the agent has no project attached', async () => {

View File

@@ -70,7 +70,7 @@ function mockOrchestrator(): McpOrchestrator {
};
}
function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null; volumes: unknown }> = {}) {
function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null; volumes: unknown; memoryLimitMb: number | null }> = {}) {
return {
id: overrides.id ?? 'srv-1',
name: overrides.name ?? 'slack',
@@ -83,6 +83,7 @@ function makeServer(overrides: Partial<{ id: string; name: string; replicas: num
externalUrl: overrides.externalUrl ?? null,
command: overrides.command ?? null,
containerPort: overrides.containerPort ?? null,
memoryLimitMb: overrides.memoryLimitMb ?? null,
replicas: overrides.replicas ?? 1,
env: [],
version: 1,
@@ -191,6 +192,37 @@ describe('InstanceService', () => {
});
});
describe('memory limit', () => {
// A server that drives a browser (docs-mcp-server scrapes with headless
// Chromium) is OOMKilled seconds into its first job at the orchestrator's
// 512 MiB default, and an OOMKill says nothing: the pod restarts, the
// in-memory job queue is gone, and the instance reads healthy again.
it('converts the declared MiB ceiling into the spec, in bytes', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({
name: 'docs',
memoryLimitMb: 2048,
}));
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
await service.reconcile('srv-1');
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0];
expect(spec.memoryLimit).toBe(2048 * 1024 * 1024);
});
it('leaves the spec free of a limit when the server declares none', async () => {
// Absent, not zero: the orchestrator falls back to DEFAULT_MEMORY_LIMIT,
// so every existing server keeps exactly the limit it has today.
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({}));
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
await service.reconcile('srv-1');
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0];
expect(spec.memoryLimit).toBeUndefined();
});
});
describe('reconcile', () => {
it('starts instances when below desired replicas', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 2 }));

View File

@@ -45,4 +45,31 @@ describe('McpServerRepository field mapping', () => {
} as never);
expect(create.mock.calls[0]?.[0].data).toMatchObject({ secretDelivery: 'injector' });
});
it('persists memoryLimitMb on update', async () => {
const { spy, update } = prismaSpy();
await new McpServerRepository(spy).update('id1', { memoryLimitMb: 2048 });
expect(update.mock.calls[0]?.[0].data).toMatchObject({ memoryLimitMb: 2048 });
});
it('persists memoryLimitMb on create, and nulls it when absent', async () => {
const { spy, create } = prismaSpy();
await new McpServerRepository(spy).create({
name: 'x', description: '', transport: 'STDIO', replicas: 1, env: [], volumes: [],
memoryLimitMb: 2048,
} as never);
expect(create.mock.calls[0]?.[0].data).toMatchObject({ memoryLimitMb: 2048 });
const second = prismaSpy();
await new McpServerRepository(second.spy).create({
name: 'y', description: '', transport: 'STDIO', replicas: 1, env: [], volumes: [],
} as never);
expect(second.create.mock.calls[0]?.[0].data).toMatchObject({ memoryLimitMb: null });
});
it('leaves memoryLimitMb untouched on update when not supplied', async () => {
const { spy, update } = prismaSpy();
await new McpServerRepository(spy).update('id1', { description: 'x' });
expect(update.mock.calls[0]?.[0].data ?? {}).not.toHaveProperty('memoryLimitMb');
});
});

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),
};
}
/**
@@ -167,12 +184,16 @@ function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdS
}
}
// Add/update upstreams for each server
// Add/update upstreams for each server. Always rebuild, even when an
// upstream with this name already exists: McpdUpstream binds the caller's
// bearer token (and the server id) at construction, and a cached project
// router outlives both. Reusing the old object meant a token rotation left
// every pre-rotation server silently 401-ing on tools/list — attached,
// healthy, zero tools, no error — while servers attached after the rotation
// worked, because only THEY got an upstream carrying the fresh token.
for (const server of servers) {
if (!currentNames.has(server.name)) {
const upstream = new McpdUpstream(server.id, server.name, toolClient, server.description, discoveryClient);
router.addUpstream(upstream);
}
const upstream = new McpdUpstream(server.id, server.name, toolClient, server.description, discoveryClient);
router.addUpstream(upstream);
// Applied on every sync, not just on first registration: raising a
// server's timeout should take effect at the next refresh rather than
// requiring the upstream to be dropped and rebuilt.

View File

@@ -22,6 +22,8 @@ import type { TrafficCapture } from './traffic.js';
import { LLMProviderAdapter } from '../proxymodel/llm-adapter.js';
import { FileCache } from '../proxymodel/file-cache.js';
import { createDefaultPlugin } from '../proxymodel/plugins/default.js';
import { createAmbientContextPlugin } from '../proxymodel/plugins/ambient-context.js';
import { ambientContextLine } from '@mcpctl/shared';
import { createAgentsPlugin } from '../proxymodel/plugins/agents.js';
import { createFavouriteIndexPlugin } from '../proxymodel/plugins/favourite-index.js';
import { composePlugins } from '../proxymodel/plugins/compose.js';
@@ -92,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);
@@ -163,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
@@ -190,6 +195,11 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
// project show up as virtual MCP servers in tools/list, regardless of
// which proxymodel the project is using.
chain.push(createAgentsPlugin());
// Ambient context LAST: its onToolsList must see the gate's finished
// catalog (append get_current_time only when ungated), and its
// date/project instruction line belongs at the end of the merged
// instructions. Always on for every project.
chain.push(createAmbientContextPlugin());
router.setPlugin(composePlugins(chain));
// Fetch project instructions and set on router. Must ride the CALLER's
@@ -247,6 +257,24 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
return response;
}
/**
* Append the ambient "now" line to every tools/call result as a SEPARATE
* trailing content item — never mutate existing items, so clients that
* JSON.parse a single text item keep working. Endpoint-level on purpose:
* this is the only chokepoint that also covers virtual tools (which bypass
* plugin onToolCallAfter), gate intercepts, drill-down pages, and
* failureResponse results. Initialize instructions carry the same line but
* freeze at session start; this trailer is what keeps a days-old chat
* session's model on today's date.
*/
function appendAmbientTrailer(response: unknown, method?: string): unknown {
if (method !== 'tools/call') return response;
const r = response as { result?: { content?: unknown } };
if (!r.result || !Array.isArray(r.result.content)) return response;
r.result.content.push({ type: 'text', text: ambientContextLine() });
return response;
}
/**
* Turn a deadline or an unexpected throw into something the client can read.
*
@@ -523,7 +551,10 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
try {
await transport.send(
maybeAnnotateRecreation(response, sid, method) as unknown as JSONRPCMessage,
appendAmbientTrailer(
maybeAnnotateRecreation(response, sid, method),
method,
) as unknown as JSONRPCMessage,
);
} catch (sendErr) {
console.error(

View File

@@ -19,6 +19,7 @@ export interface PluginContextDeps {
cache: CacheProvider;
log: StageLogger;
discoverTools: () => Promise<ToolDefinition[]>;
getToolDiscoveryFailures?: () => Array<{ server: string; message: string }>;
routeToUpstream: (request: JsonRpcRequest) => Promise<JsonRpcResponse>;
fetchPromptIndex: () => Promise<PromptIndexEntry[]>;
getSystemPrompt: (name: string, fallback: string) => Promise<string>;
@@ -96,6 +97,10 @@ export class PluginContextImpl implements PluginSessionContext {
return this.deps.discoverTools();
}
getToolDiscoveryFailures(): Array<{ server: string; message: string }> {
return this.deps.getToolDiscoveryFailures?.() ?? [];
}
routeToUpstream(request: JsonRpcRequest): Promise<JsonRpcResponse> {
return this.deps.routeToUpstream(request);
}

View File

@@ -36,6 +36,8 @@ export interface PluginSessionContext {
// Upstream access
discoverTools(): Promise<ToolDefinition[]>;
/** Servers that contributed no tools on the last discoverTools run, with the error. */
getToolDiscoveryFailures(): Array<{ server: string; message: string }>;
routeToUpstream(request: JsonRpcRequest): Promise<JsonRpcResponse>;
// Prompt access

View File

@@ -0,0 +1,56 @@
/**
* Ambient Context plugin — gives every project's model a clock.
*
* Always on, composed LAST in the project plugin chain so that:
* - onToolsList sees the gate's (and favourite-index's) finished list and
* only appends get_current_time to UNGATED catalogs;
* - its onInitialize line lands at the end of the merged instructions.
*
* The per-result "[context] now: …" trailer deliberately does NOT live here:
* virtual tools bypass plugin onToolCallAfter entirely (router dispatches
* them first), so the only chokepoint covering every tools/call result is
* endpoint-level — see appendAmbientTrailer in http/project-mcp-endpoint.ts.
*/
import { ambientContextLine, formatCurrentTimeResult } from '@mcpctl/shared';
import type { ProxyModelPlugin } from '../plugin.js';
import type { ToolDefinition } from '../types.js';
export const GET_CURRENT_TIME_TOOL_NAME = 'get_current_time';
function getCurrentTimeTool(): ToolDefinition {
return {
name: GET_CURRENT_TIME_TOOL_NAME,
description:
'Current date and time, in UTC and the configured local timezone. '
+ 'Call this whenever a task depends on today\'s date or the current time.',
inputSchema: { type: 'object', properties: {} },
};
}
export function createAmbientContextPlugin(): ProxyModelPlugin {
return {
name: 'ambient-context',
async onSessionCreate(ctx) {
// Registered unconditionally: virtual dispatch precedes the gate
// intercept, so the tool is callable even while gated (same as
// read_prompts today). Visibility is controlled in onToolsList.
ctx.registerTool(getCurrentTimeTool(), async () => ({
content: [{ type: 'text', text: formatCurrentTimeResult() }],
}));
},
async onInitialize(_request, ctx) {
return { instructions: `${ambientContextLine()} — project: ${ctx.projectName}` };
},
async onToolsList(tools) {
// 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

@@ -7,6 +7,7 @@
*
* This plugin replaces the hardcoded gate logic in router.ts.
*/
import { ambientContextLine } from '@mcpctl/shared';
import type { JsonRpcRequest, JsonRpcResponse } from '../../types.js';
import type { ToolDefinition } from '../types.js';
import type { ProxyModelPlugin, PluginSessionContext } from '../plugin.js';
@@ -54,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;
@@ -72,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.',
@@ -121,12 +135,21 @@ 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) {
parts.push('\nAvailable MCP server tools (accessible after begin_session):');
for (const t of tools) {
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 gatedTools) {
parts.push(` ${sanitizeWireName(t.name)}`);
}
}
parts.push(...formatDiscoveryFailures(ctx.getToolDiscoveryFailures()));
} catch {
// Tool discovery is optional
}
@@ -158,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)
@@ -168,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;
@@ -177,6 +201,22 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
};
}
/**
* A partial catalog must never look like a healthy one. When attached servers
* contributed no tools, say so in-band — the client has no other channel to
* learn that six servers silently vanished from its tool list.
*/
function formatDiscoveryFailures(failures: Array<{ server: string; message: string }>): string[] {
if (failures.length === 0) return [];
const parts: string[] = [];
parts.push(`\n⚠ WARNING: ${failures.length} attached server(s) contributed NO tools to this catalog:`);
for (const f of failures) {
parts.push(` - ${f.server}: ${f.message}`);
}
parts.push('The tool list above is INCOMPLETE. Report this to the user instead of treating a missing tool as nonexistent.');
return parts;
}
// ── begin_session tool definition ──
function getBeginSessionTool(llmSelector: LlmPromptSelector | null): ToolDefinition {
@@ -396,6 +436,10 @@ async function handleBeginSession(
// Build response
const responseParts: string[] = [];
// The model starting this session has no clock; the briefing anchors it.
// (The endpoint's per-result trailer keeps later turns current.)
responseParts.push(`${ambientContextLine()}\n`);
if (degradedReason !== null) {
responseParts.push(
`⚠ Smart prompt-selection unavailable (${degradedReason}). `
@@ -442,6 +486,7 @@ async function handleBeginSession(
responseParts.push(` ${sanitizeWireName(t.name)}`);
}
}
responseParts.push(...formatDiscoveryFailures(ctx.getToolDiscoveryFailures()));
} catch {
// Tool discovery is optional
}

View File

@@ -75,6 +75,8 @@ export class McpRouter {
// `tools/list` from re-hitting slow/dead upstreams and absorbs negative results so one
// dead server only stalls the first POST, not every subsequent one.
private listCache = new Map<string, ListCacheEntry>();
/** Per-server failures from the most recent discoverTools run. */
private toolDiscoveryFailures: Array<{ server: string; message: string }> = [];
private readonly LIST_CACHE_POSITIVE_TTL_MS = 30_000;
private readonly LIST_CACHE_NEGATIVE_TTL_MS = 30_000;
@@ -176,6 +178,7 @@ export class McpRouter {
error: (msg: string) => console.error(`[plugin:${sessionId.slice(0, 8)}] ${msg}`),
},
discoverTools: () => this.discoverTools(),
getToolDiscoveryFailures: () => this.getToolDiscoveryFailures(),
routeToUpstream: (request) => this.routeNamespacedCall(request, 'name', this.toolToServer),
fetchPromptIndex: () => this.fetchPromptIndex(),
getSystemPrompt: (name, fallback) => this.getSystemPrompt(name, fallback),
@@ -329,7 +332,7 @@ export class McpRouter {
const started = Date.now();
let cachedCount = 0;
let freshCount = 0;
const failed: string[] = [];
const failed: Array<{ server: string; message: string }> = [];
// Discover tools from all servers in parallel so one slow server doesn't block the rest
const entries = [...this.upstreams.entries()];
@@ -369,7 +372,7 @@ export class McpRouter {
const cached = result.value.cached;
if (cached.kind === 'err') {
cachedCount++;
failed.push(serverName);
failed.push({ server: serverName, message: cached.message });
continue;
}
response = { jsonrpc: '2.0', id: `cached-${serverName}`, result: cached.result };
@@ -381,7 +384,7 @@ export class McpRouter {
const message = (response.error as { message?: string }).message ?? 'unknown error';
this.setListCacheEntry(serverName, 'tools/list', { kind: 'err', message, fetchedAt: Date.now() });
console.warn(`[discoverTools] ${serverName}: ${message}`);
failed.push(serverName);
failed.push({ server: serverName, message });
continue;
}
if (response.result !== undefined) {
@@ -410,16 +413,27 @@ export class McpRouter {
}
}
this.toolDiscoveryFailures = failed;
if (entries.length > 0) {
const elapsed = Date.now() - started;
const project = this.projectName ? ` project=${this.projectName}` : '';
const failedStr = failed.length > 0 ? ` failed=[${failed.join(',')}]` : '';
const failedStr = failed.length > 0 ? ` failed=[${failed.map((f) => f.server).join(',')}]` : '';
console.info(`[discoverTools]${project} fresh=${freshCount} cached=${cachedCount}${failedStr} elapsed=${elapsed}ms`);
}
return allTools;
}
/**
* Servers that contributed no tools on the most recent `discoverTools` run,
* with the error that silenced them. A partial catalog must never look like
* a healthy one: the gate reads this to warn the client in-band.
*/
getToolDiscoveryFailures(): Array<{ server: string; message: string }> {
return this.toolDiscoveryFailures;
}
/**
* Discover resources from all upstreams by calling resources/list on each.
* Shares the per-server list cache with `discoverTools`.

View File

@@ -0,0 +1,75 @@
import { describe, it, expect, afterEach } from 'vitest';
import {
AMBIENT_TZ_ENV,
ambientTz,
formatAmbientNow,
ambientContextLine,
formatCurrentTimeResult,
} from '@mcpctl/shared';
const SUMMER = new Date('2026-07-01T09:30:00Z'); // London = BST, +01:00
const WINTER = new Date('2026-01-15T09:30:00Z'); // London = GMT, +00:00
afterEach(() => {
delete process.env[AMBIENT_TZ_ENV];
});
describe('ambientTz', () => {
it('returns undefined when unset', () => {
delete process.env[AMBIENT_TZ_ENV];
expect(ambientTz()).toBeUndefined();
});
it('returns a valid IANA name', () => {
process.env[AMBIENT_TZ_ENV] = 'Europe/London';
expect(ambientTz()).toBe('Europe/London');
});
it('falls back to undefined on an invalid name without throwing', () => {
process.env[AMBIENT_TZ_ENV] = 'Not/AZone';
expect(ambientTz()).toBeUndefined();
});
});
describe('formatAmbientNow', () => {
it('renders UTC-only when no tz', () => {
expect(formatAmbientNow(SUMMER, undefined)).toBe('Wed 2026-07-01 09:30 UTC');
});
it('renders local + UTC in summer (BST +01:00)', () => {
expect(formatAmbientNow(SUMMER, 'Europe/London'))
.toBe('Wed 2026-07-01 10:30 +01:00 Europe/London (09:30 UTC)');
});
it('renders local + UTC in winter (GMT +00:00)', () => {
expect(formatAmbientNow(WINTER, 'Europe/London'))
.toBe('Thu 2026-01-15 09:30 +00:00 Europe/London (09:30 UTC)');
});
it('stays short (~15 tokens)', () => {
expect(formatAmbientNow(SUMMER, 'Europe/London').length).toBeLessThan(70);
});
});
describe('ambientContextLine', () => {
it('prefixes with [context] now:', () => {
delete process.env[AMBIENT_TZ_ENV];
expect(ambientContextLine(SUMMER)).toBe('[context] now: Wed 2026-07-01 09:30 UTC');
});
});
describe('formatCurrentTimeResult', () => {
it('includes UTC ISO and epoch, no local line when tz unset', () => {
delete process.env[AMBIENT_TZ_ENV];
const text = formatCurrentTimeResult(SUMMER);
expect(text).toContain('UTC: 2026-07-01T09:30:00.000Z');
expect(text).toContain(`Unix epoch seconds: ${Math.floor(SUMMER.getTime() / 1000)}`);
expect(text).not.toContain('Europe/London');
});
it('includes the local rendering when tz set', () => {
process.env[AMBIENT_TZ_ENV] = 'Europe/London';
const text = formatCurrentTimeResult(SUMMER);
expect(text).toContain('Europe/London: Wed 2026-07-01 10:30 +01:00 Europe/London (09:30 UTC)');
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { refreshUpstreams } from '../src/discovery.js';
import { refreshUpstreams, refreshProjectUpstreams } from '../src/discovery.js';
import { McpRouter } from '../src/router.js';
function mockMcpdClient(servers: Array<{ id: string; name: string; transport: string }>) {
@@ -69,3 +69,61 @@ describe('refreshUpstreams', () => {
expect(router.getUpstreamNames()).toHaveLength(0);
});
});
describe('refreshProjectUpstreams', () => {
/**
* Regression: a cached project router outlives the caller's bearer token.
* Upstreams used to be created only for NEW server names, so after a token
* rotation every pre-existing upstream kept posting the old token — six
* healthy servers 401'd on tools/list forever while newly attached ones
* worked. Every refresh must rebind existing upstreams to the fresh token.
*/
it('rebinds existing upstreams to the current auth token on every refresh', async () => {
const router = new McpRouter();
const servers = [{ id: 'srv-1', name: 'gitea', transport: 'stdio' }];
// Base client: withToken(t) mints a child client whose posts we can count.
const children = new Map<string, ReturnType<typeof mockMcpdClient>>();
const base = mockMcpdClient(servers);
(base as any).forward = vi.fn(async () => ({ status: 200, body: servers }));
(base as any).withToken = vi.fn((t: string) => {
if (!children.has(t)) {
const child = mockMcpdClient(servers);
child.post = vi.fn(async () => ({ result: { tools: [{ name: 'merge_pull_request' }] } }));
children.set(t, child);
}
return children.get(t);
});
await refreshProjectUpstreams(router, base as any, 'sre', 'old-token');
await refreshProjectUpstreams(router, base as any, 'sre', 'new-token');
const tools = await router.discoverTools();
expect(tools.map((t) => t.name)).toEqual(['gitea/merge_pull_request']);
// The proxy call must ride the NEW token's client, not the stale one.
expect(children.get('new-token')!.post).toHaveBeenCalledWith('/api/v1/mcp/proxy', expect.anything());
expect(children.get('old-token')!.post).not.toHaveBeenCalled();
});
it('updates the server id when a server was recreated under the same name', async () => {
const router = new McpRouter();
const base = mockMcpdClient([]);
const posts: unknown[] = [];
const child = mockMcpdClient([]);
child.post = vi.fn(async (_path: string, body: unknown) => {
posts.push(body);
return { result: { tools: [] } };
});
(base as any).withToken = vi.fn(() => child);
(base as any).forward = vi.fn()
.mockResolvedValueOnce({ status: 200, body: [{ id: 'srv-old', name: 'gitea', transport: 'stdio' }] })
.mockResolvedValueOnce({ status: 200, body: [{ id: 'srv-new', name: 'gitea', transport: 'stdio' }] });
await refreshProjectUpstreams(router, base as any, 'sre', 't');
await refreshProjectUpstreams(router, base as any, 'sre', 't');
await router.discoverTools();
expect(posts).toHaveLength(1);
expect((posts[0] as { serverId: string }).serverId).toBe('srv-new');
});
});

View File

@@ -0,0 +1,117 @@
/**
* ambient-context plugin tests — get_current_time visibility (gated vs
* ungated, no double-append), callable result, and the initialize
* instruction line. Driven through a real McpRouter with the gate composed
* first, mirroring project-mcp-endpoint wiring (ambient-context LAST).
*/
import { describe, it, expect, vi } from 'vitest';
import { McpRouter } from '../src/router.js';
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
import type { McpdClient } from '../src/http/mcpd-client.js';
import { createGatePlugin } from '../src/proxymodel/plugins/gate.js';
import { composePlugins } from '../src/proxymodel/plugins/compose.js';
import {
createAmbientContextPlugin,
GET_CURRENT_TIME_TOOL_NAME,
} from '../src/proxymodel/plugins/ambient-context.js';
import type { LLMProviderAdapter } from '../src/proxymodel/llm-adapter.js';
import { MemoryCache } from '../src/proxymodel/cache.js';
function mockUpstream(name: string, tools: Array<{ name: string }>): UpstreamConnection {
return {
name,
isAlive: vi.fn(() => true),
close: vi.fn(async () => {}),
onNotification: vi.fn(),
send: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
if (req.method === 'tools/list') return { jsonrpc: '2.0', id: req.id, result: { tools } };
if (req.method === 'resources/list') return { jsonrpc: '2.0', id: req.id, result: { resources: [] } };
if (req.method === 'prompts/list') return { jsonrpc: '2.0', id: req.id, result: { prompts: [] } };
return { jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Not found' } };
}),
} as UpstreamConnection;
}
function mockMcpdClient(): McpdClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
forward: vi.fn(async () => ({ status: 200, body: {} })),
withHeaders: vi.fn(function (this: McpdClient) { return this; }),
} as unknown as McpdClient;
}
function setup(opts: { gated?: boolean } = {}) {
const router = new McpRouter();
router.setPromptConfig(mockMcpdClient(), 'test-project');
router.setPlugin(composePlugins([
createGatePlugin({ gated: opts.gated ?? false, providerRegistry: null }),
createAmbientContextPlugin(),
]));
router.setProxyModel('default', { complete: async () => '', available: () => false } as unknown as LLMProviderAdapter, new MemoryCache());
router.addUpstream(mockUpstream('k8s', [{ name: 'get_pods' }]));
return router;
}
async function listTools(router: McpRouter, sessionId = 's1'): Promise<string[]> {
const res = await router.route({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, { sessionId });
return (res.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
}
describe('ambient-context plugin', () => {
it('appends get_current_time to an ungated catalog, once', async () => {
const router = setup();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const names = await listTools(router);
expect(names).toContain('k8s/get_pods');
expect(names.filter((n) => n === GET_CURRENT_TIME_TOOL_NAME)).toHaveLength(1);
// Second list: still exactly one.
const again = await listTools(router);
expect(again.filter((n) => n === GET_CURRENT_TIME_TOOL_NAME)).toHaveLength(1);
});
it('does not leak get_current_time into a gated catalog', async () => {
const router = setup({ gated: true });
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const names = await listTools(router);
expect(names).toEqual(['begin_session']);
});
it('lists get_current_time after begin_session ungates the session', async () => {
const router = setup({ gated: true });
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { description: 'testing ambient context' } } },
{ sessionId: 's1' },
);
const names = await listTools(router);
expect(names).toContain(GET_CURRENT_TIME_TOOL_NAME);
});
it('get_current_time returns the UTC time', async () => {
const router = setup();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: GET_CURRENT_TIME_TOOL_NAME, arguments: {} } },
{ sessionId: 's1' },
);
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
expect(text).toMatch(/^UTC: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/m);
expect(text).toContain('Unix epoch seconds:');
});
it('adds the context line and project name to initialize instructions', async () => {
const router = setup();
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('[context] now:');
expect(instructions).toContain('project: test-project');
});
});

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();
@@ -199,6 +259,19 @@ describe('Gate Plugin via setPlugin()', () => {
});
describe('begin_session', () => {
it('opens the briefing with the ambient context line (the model has no clock)', async () => {
const { router } = setupPluginRouter();
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
const res = await router.route(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } },
{ sessionId: 's1' },
);
const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text);
expect(text).toMatch(/^\[context\] now: /);
});
it('falls back with a visible warning when the LLM selector errors (no silent degrade)', async () => {
const { router } = setupPluginRouter({ withLlm: true, llmError: true });
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });

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

@@ -0,0 +1,138 @@
import { describe, it, expect, vi } from 'vitest';
import Fastify from 'fastify';
import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js';
import type { McpRouter } from '../src/router.js';
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
/**
* Ambient-context E2E on /projects/:name/mcp: every tools/call result —
* upstream AND virtual — carries a trailing `[context] now:` content item,
* initialize instructions carry the same line, tools/list stays clean, and
* get_current_time is served on an ungated project.
*/
vi.mock('../src/discovery.js', () => ({
refreshProjectUpstreams: vi.fn(async (router: McpRouter) => {
router.addUpstream({
name: 'websearch',
send: async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
if (req.method === 'tools/list') {
return {
jsonrpc: '2.0',
id: req.id,
result: { tools: [{ name: 'fetch_content', description: 'fetch a page', inputSchema: { type: 'object' } }] },
};
}
if (req.method === 'tools/call') {
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: 'page body' }] } };
}
return { jsonrpc: '2.0', id: req.id, result: {} };
},
close: async () => {},
isAlive: () => true,
});
return { names: ['websearch'], ungatedServers: [] };
}),
// Ungated (content-pipeline style), like chat-web and personal-michal.
fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })),
}));
vi.mock('../src/http/config.js', async () => {
const actual = await vi.importActual<typeof import('../src/http/config.js')>('../src/http/config.js');
return { ...actual, loadProjectLlmOverride: vi.fn(() => undefined) };
});
function mockMcpdClient() {
const client: Record<string, unknown> = {
baseUrl: 'http://test:3100',
token: 'test-token',
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(),
delete: vi.fn(),
forward: vi.fn(async () => ({ status: 200, body: [] })),
withHeaders: vi.fn(),
withToken: vi.fn(),
withTimeout: vi.fn(),
};
(client.withHeaders as ReturnType<typeof vi.fn>).mockReturnValue(client);
(client.withToken as ReturnType<typeof vi.fn>).mockReturnValue(client);
(client.withTimeout as ReturnType<typeof vi.fn>).mockReturnValue(client);
return client;
}
function parseSse(body: string): JsonRpcResponse {
const dataLine = body.split('\n').find((l) => l.startsWith('data: '));
if (!dataLine) throw new Error(`no SSE data line in: ${body}`);
return JSON.parse(dataLine.slice('data: '.length)) as JsonRpcResponse;
}
const CONTEXT_PREFIX = '[context] now:';
describe('registerProjectMcpEndpoint ambient context', () => {
it('stamps instructions, tool results (upstream and virtual), but not tools/list', async () => {
const app = Fastify();
registerProjectMcpEndpoint(app, mockMcpdClient() as never);
await app.ready();
try {
const headers = {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
};
const init = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers,
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } } },
});
expect(init.statusCode).toBe(200);
const initResponse = parseSse(init.body);
const instructions = (initResponse.result as { instructions?: string }).instructions ?? '';
expect(instructions).toContain(CONTEXT_PREFIX);
expect(instructions).toContain('project: chat-web');
// The initialize RESPONSE itself is not trailed (no result.content).
expect(JSON.stringify((initResponse.result as { content?: unknown }).content ?? null)).not.toContain(CONTEXT_PREFIX);
const sessionId = init.headers['mcp-session-id'] as string;
const sessionHeaders = { ...headers, 'mcp-session-id': sessionId };
const list = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 2, method: 'tools/list' },
});
const listResponse = parseSse(list.body);
const names = (listResponse.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
expect(names).toContain('get_current_time');
// tools/list is never trailed.
expect(JSON.stringify(listResponse.result)).not.toContain(CONTEXT_PREFIX);
// Upstream tool call: original content intact, trailer appended LAST.
const call = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } } },
});
const callContent = (parseSse(call.body).result as { content: Array<{ type: string; text: string }> }).content;
expect(callContent.length).toBeGreaterThanOrEqual(2);
expect(callContent[0]?.text).toContain('page body');
expect(callContent[callContent.length - 1]?.text).toMatch(/^\[context\] now: /);
// Virtual tool call (bypasses plugin onToolCallAfter): still trailed.
const timeCall = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'get_current_time', arguments: {} } },
});
const timeContent = (parseSse(timeCall.body).result as { content: Array<{ text: string }> }).content;
expect(timeContent[0]?.text).toContain('UTC:');
expect(timeContent[timeContent.length - 1]?.text).toMatch(/^\[context\] now: /);
} finally {
await app.close();
}
});
});

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

View File

@@ -0,0 +1,93 @@
/**
* Ambient time rendering — gives models a clock.
*
* Models consuming projects have no idea what day it is, so mcplocal stamps a
* short context line into initialize instructions, the begin_session
* briefing, and every tools/call result, and mcpd appends it to agent system
* blocks. One env var, `MCPLOCAL_AMBIENT_TZ` (an IANA name like
* "Europe/London", read by BOTH services despite the mcplocal-flavoured
* name), adds a local rendering next to UTC; unset or invalid means UTC only.
*
* Minute precision on purpose: the line stays ~15 tokens and does not churn
* within a minute.
*/
export const AMBIENT_TZ_ENV = 'MCPLOCAL_AMBIENT_TZ';
let warnedInvalidTz: string | undefined;
/**
* The configured IANA timezone, or undefined for UTC-only rendering.
* Read at call time (not module load) so tests and config changes apply
* without a restart. An invalid name warns once and behaves as unset.
*/
export function ambientTz(env: NodeJS.ProcessEnv = process.env): string | undefined {
const tz = env[AMBIENT_TZ_ENV]?.trim();
if (!tz) return undefined;
try {
new Intl.DateTimeFormat('en-GB', { timeZone: tz });
return tz;
} catch {
if (warnedInvalidTz !== tz) {
warnedInvalidTz = tz;
console.warn(`[ambient-time] invalid ${AMBIENT_TZ_ENV} '${tz}', falling back to UTC`);
}
return undefined;
}
}
const pad = (n: number): string => String(n).padStart(2, '0');
function utcParts(now: Date): { date: string; time: string; weekday: string } {
const weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][now.getUTCDay()] as string;
return {
weekday,
date: `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}`,
time: `${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}`,
};
}
/**
* One line of "now", ~15 tokens:
* tz set: `Thu 2026-10-02 10:30 +01:00 Europe/London (09:30 UTC)`
* tz unset: `Thu 2026-10-02 09:30 UTC`
*/
export function formatAmbientNow(now: Date = new Date(), tz: string | undefined = ambientTz()): string {
const utc = utcParts(now);
if (!tz) return `${utc.weekday} ${utc.date} ${utc.time} UTC`;
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
weekday: 'short',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZoneName: 'longOffset',
}).formatToParts(now);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
// "GMT+01:00" → "+01:00"; plain "GMT" (winter London) → "+00:00".
const rawOffset = get('timeZoneName');
const offset = rawOffset === 'GMT' ? '+00:00' : rawOffset.replace(/^(GMT|UTC)/, '');
// Intl can render "24:00" for midnight with hour12:false; normalize.
const hour = get('hour') === '24' ? '00' : get('hour');
return `${get('weekday')} ${get('year')}-${get('month')}-${get('day')} ${hour}:${get('minute')} ${offset} ${tz} (${utc.time} UTC)`;
}
/** The ambient context line stamped into instructions and tool results. */
export function ambientContextLine(now: Date = new Date()): string {
return `[context] now: ${formatAmbientNow(now)}`;
}
/** Multi-line body for the get_current_time virtual tool. */
export function formatCurrentTimeResult(now: Date = new Date()): string {
const tz = ambientTz();
const lines = [
`UTC: ${now.toISOString()}`,
...(tz ? [`${tz}: ${formatAmbientNow(now, tz)}`] : []),
`Unix epoch seconds: ${Math.floor(now.getTime() / 1000)}`,
];
return lines.join('\n');
}

View File

@@ -1,2 +1,2 @@
// Shared utility functions
// Will be expanded as tasks are implemented
export * from './ambient-time.js';