Compare commits
9 Commits
0076fa0d37
...
fix/ttrss-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2aaa9d18d | ||
| 45b875ae2c | |||
|
|
979e9720c0 | ||
| f9ef4ebcb9 | |||
|
|
1180bbdc1d | ||
|
|
709068ce68 | ||
|
|
c1f0117b80 | ||
| 0515f99e17 | |||
|
|
6fbd9c19fc |
@@ -22,6 +22,7 @@
|
|||||||
* adapter default. `extra` is forwarded as-is for provider-specific knobs.
|
* adapter default. `extra` is forwarded as-is for provider-specific knobs.
|
||||||
*/
|
*/
|
||||||
import type { ChatMessage } from '@prisma/client';
|
import type { ChatMessage } from '@prisma/client';
|
||||||
|
import { ambientContextLine } from '@mcpctl/shared';
|
||||||
import type { AgentService } from './agent.service.js';
|
import type { AgentService } from './agent.service.js';
|
||||||
import type { LlmService } from './llm.service.js';
|
import type { LlmService } from './llm.service.js';
|
||||||
import { effectivePoolName } from './llm.service.js';
|
import { effectivePoolName } from './llm.service.js';
|
||||||
@@ -728,6 +729,9 @@ export class ChatService {
|
|||||||
...sortedProjectPrompts.map((p) => p.content),
|
...sortedProjectPrompts.map((p) => p.content),
|
||||||
...personalityPromptContents,
|
...personalityPromptContents,
|
||||||
mergedParams.systemAppend ?? '',
|
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)
|
.filter((s) => s.length > 0)
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
@@ -842,6 +846,8 @@ export class ChatService {
|
|||||||
baseSystem,
|
baseSystem,
|
||||||
...projectPrompts.map((p) => p.content),
|
...projectPrompts.map((p) => p.content),
|
||||||
mergedParams.systemAppend ?? '',
|
mergedParams.systemAppend ?? '',
|
||||||
|
// Rebuilt every turn — the model's clock (same env as mcplocal).
|
||||||
|
ambientContextLine(),
|
||||||
]
|
]
|
||||||
.filter((s) => s.length > 0)
|
.filter((s) => s.length > 0)
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
|
|||||||
@@ -155,10 +155,11 @@ describe('ChatService — project chat', () => {
|
|||||||
expect(t.projectId).toBe('proj-1');
|
expect(t.projectId).toBe('proj-1');
|
||||||
expect(t.agentId).toBeNull();
|
expect(t.agentId).toBeNull();
|
||||||
expect(t.ownerId).toBe('owner-1');
|
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];
|
const sys = (adapter.infer as ReturnType<typeof vi.fn>).mock.calls[0][0].body.messages[0];
|
||||||
expect(sys.role).toBe('system');
|
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 () => {
|
it('routes tool calls to the project dispatcher with projectId', async () => {
|
||||||
|
|||||||
@@ -388,6 +388,8 @@ describe('ChatService', () => {
|
|||||||
expect(text.indexOf('HIGH prompt')).toBeLessThan(text.indexOf('LOW prompt'));
|
expect(text.indexOf('HIGH prompt')).toBeLessThan(text.indexOf('LOW prompt'));
|
||||||
// Agent's own system prompt leads.
|
// Agent's own system prompt leads.
|
||||||
expect(text.indexOf('You are a helpful agent.')).toBeLessThan(text.indexOf('HIGH prompt'));
|
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 () => {
|
it('refuses tool calls when the agent has no project attached', async () => {
|
||||||
|
|||||||
@@ -167,12 +167,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) {
|
for (const server of servers) {
|
||||||
if (!currentNames.has(server.name)) {
|
|
||||||
const upstream = new McpdUpstream(server.id, server.name, toolClient, server.description, discoveryClient);
|
const upstream = new McpdUpstream(server.id, server.name, toolClient, server.description, discoveryClient);
|
||||||
router.addUpstream(upstream);
|
router.addUpstream(upstream);
|
||||||
}
|
|
||||||
// Applied on every sync, not just on first registration: raising a
|
// Applied on every sync, not just on first registration: raising a
|
||||||
// server's timeout should take effect at the next refresh rather than
|
// server's timeout should take effect at the next refresh rather than
|
||||||
// requiring the upstream to be dropped and rebuilt.
|
// requiring the upstream to be dropped and rebuilt.
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import type { TrafficCapture } from './traffic.js';
|
|||||||
import { LLMProviderAdapter } from '../proxymodel/llm-adapter.js';
|
import { LLMProviderAdapter } from '../proxymodel/llm-adapter.js';
|
||||||
import { FileCache } from '../proxymodel/file-cache.js';
|
import { FileCache } from '../proxymodel/file-cache.js';
|
||||||
import { createDefaultPlugin } from '../proxymodel/plugins/default.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 { createAgentsPlugin } from '../proxymodel/plugins/agents.js';
|
||||||
import { createFavouriteIndexPlugin } from '../proxymodel/plugins/favourite-index.js';
|
import { createFavouriteIndexPlugin } from '../proxymodel/plugins/favourite-index.js';
|
||||||
import { composePlugins } from '../proxymodel/plugins/compose.js';
|
import { composePlugins } from '../proxymodel/plugins/compose.js';
|
||||||
@@ -190,6 +192,11 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
|||||||
// project show up as virtual MCP servers in tools/list, regardless of
|
// project show up as virtual MCP servers in tools/list, regardless of
|
||||||
// which proxymodel the project is using.
|
// which proxymodel the project is using.
|
||||||
chain.push(createAgentsPlugin());
|
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));
|
router.setPlugin(composePlugins(chain));
|
||||||
|
|
||||||
// Fetch project instructions and set on router. Must ride the CALLER's
|
// Fetch project instructions and set on router. Must ride the CALLER's
|
||||||
@@ -247,6 +254,24 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
|||||||
return response;
|
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.
|
* Turn a deadline or an unexpected throw into something the client can read.
|
||||||
*
|
*
|
||||||
@@ -523,7 +548,10 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.send(
|
await transport.send(
|
||||||
maybeAnnotateRecreation(response, sid, method) as unknown as JSONRPCMessage,
|
appendAmbientTrailer(
|
||||||
|
maybeAnnotateRecreation(response, sid, method),
|
||||||
|
method,
|
||||||
|
) as unknown as JSONRPCMessage,
|
||||||
);
|
);
|
||||||
} catch (sendErr) {
|
} catch (sendErr) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface PluginContextDeps {
|
|||||||
cache: CacheProvider;
|
cache: CacheProvider;
|
||||||
log: StageLogger;
|
log: StageLogger;
|
||||||
discoverTools: () => Promise<ToolDefinition[]>;
|
discoverTools: () => Promise<ToolDefinition[]>;
|
||||||
|
getToolDiscoveryFailures?: () => Array<{ server: string; message: string }>;
|
||||||
routeToUpstream: (request: JsonRpcRequest) => Promise<JsonRpcResponse>;
|
routeToUpstream: (request: JsonRpcRequest) => Promise<JsonRpcResponse>;
|
||||||
fetchPromptIndex: () => Promise<PromptIndexEntry[]>;
|
fetchPromptIndex: () => Promise<PromptIndexEntry[]>;
|
||||||
getSystemPrompt: (name: string, fallback: string) => Promise<string>;
|
getSystemPrompt: (name: string, fallback: string) => Promise<string>;
|
||||||
@@ -96,6 +97,10 @@ export class PluginContextImpl implements PluginSessionContext {
|
|||||||
return this.deps.discoverTools();
|
return this.deps.discoverTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getToolDiscoveryFailures(): Array<{ server: string; message: string }> {
|
||||||
|
return this.deps.getToolDiscoveryFailures?.() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
routeToUpstream(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
routeToUpstream(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
||||||
return this.deps.routeToUpstream(request);
|
return this.deps.routeToUpstream(request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface PluginSessionContext {
|
|||||||
|
|
||||||
// Upstream access
|
// Upstream access
|
||||||
discoverTools(): Promise<ToolDefinition[]>;
|
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>;
|
routeToUpstream(request: JsonRpcRequest): Promise<JsonRpcResponse>;
|
||||||
|
|
||||||
// Prompt access
|
// Prompt access
|
||||||
|
|||||||
56
src/mcplocal/src/proxymodel/plugins/ambient-context.ts
Normal file
56
src/mcplocal/src/proxymodel/plugins/ambient-context.ts
Normal 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 exactly [begin_session] (gate.ts onToolsList);
|
||||||
|
// begin_session never appears in an ungated list. If the gate's gated
|
||||||
|
// list ever changes shape, revisit this marker.
|
||||||
|
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()];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
*
|
*
|
||||||
* This plugin replaces the hardcoded gate logic in router.ts.
|
* This plugin replaces the hardcoded gate logic in router.ts.
|
||||||
*/
|
*/
|
||||||
|
import { ambientContextLine } from '@mcpctl/shared';
|
||||||
import type { JsonRpcRequest, JsonRpcResponse } from '../../types.js';
|
import type { JsonRpcRequest, JsonRpcResponse } from '../../types.js';
|
||||||
import type { ToolDefinition } from '../types.js';
|
import type { ToolDefinition } from '../types.js';
|
||||||
import type { ProxyModelPlugin, PluginSessionContext } from '../plugin.js';
|
import type { ProxyModelPlugin, PluginSessionContext } from '../plugin.js';
|
||||||
@@ -127,6 +128,7 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
|
|||||||
parts.push(` ${sanitizeWireName(t.name)}`);
|
parts.push(` ${sanitizeWireName(t.name)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
parts.push(...formatDiscoveryFailures(ctx.getToolDiscoveryFailures()));
|
||||||
} catch {
|
} catch {
|
||||||
// Tool discovery is optional
|
// Tool discovery is optional
|
||||||
}
|
}
|
||||||
@@ -177,6 +179,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 ──
|
// ── begin_session tool definition ──
|
||||||
|
|
||||||
function getBeginSessionTool(llmSelector: LlmPromptSelector | null): ToolDefinition {
|
function getBeginSessionTool(llmSelector: LlmPromptSelector | null): ToolDefinition {
|
||||||
@@ -396,6 +414,10 @@ async function handleBeginSession(
|
|||||||
// Build response
|
// Build response
|
||||||
const responseParts: string[] = [];
|
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) {
|
if (degradedReason !== null) {
|
||||||
responseParts.push(
|
responseParts.push(
|
||||||
`⚠ Smart prompt-selection unavailable (${degradedReason}). `
|
`⚠ Smart prompt-selection unavailable (${degradedReason}). `
|
||||||
@@ -442,6 +464,7 @@ async function handleBeginSession(
|
|||||||
responseParts.push(` ${sanitizeWireName(t.name)}`);
|
responseParts.push(` ${sanitizeWireName(t.name)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
responseParts.push(...formatDiscoveryFailures(ctx.getToolDiscoveryFailures()));
|
||||||
} catch {
|
} catch {
|
||||||
// Tool discovery is optional
|
// Tool discovery is optional
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export class McpRouter {
|
|||||||
// `tools/list` from re-hitting slow/dead upstreams and absorbs negative results so one
|
// `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.
|
// dead server only stalls the first POST, not every subsequent one.
|
||||||
private listCache = new Map<string, ListCacheEntry>();
|
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_POSITIVE_TTL_MS = 30_000;
|
||||||
private readonly LIST_CACHE_NEGATIVE_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}`),
|
error: (msg: string) => console.error(`[plugin:${sessionId.slice(0, 8)}] ${msg}`),
|
||||||
},
|
},
|
||||||
discoverTools: () => this.discoverTools(),
|
discoverTools: () => this.discoverTools(),
|
||||||
|
getToolDiscoveryFailures: () => this.getToolDiscoveryFailures(),
|
||||||
routeToUpstream: (request) => this.routeNamespacedCall(request, 'name', this.toolToServer),
|
routeToUpstream: (request) => this.routeNamespacedCall(request, 'name', this.toolToServer),
|
||||||
fetchPromptIndex: () => this.fetchPromptIndex(),
|
fetchPromptIndex: () => this.fetchPromptIndex(),
|
||||||
getSystemPrompt: (name, fallback) => this.getSystemPrompt(name, fallback),
|
getSystemPrompt: (name, fallback) => this.getSystemPrompt(name, fallback),
|
||||||
@@ -329,7 +332,7 @@ export class McpRouter {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
let cachedCount = 0;
|
let cachedCount = 0;
|
||||||
let freshCount = 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
|
// Discover tools from all servers in parallel so one slow server doesn't block the rest
|
||||||
const entries = [...this.upstreams.entries()];
|
const entries = [...this.upstreams.entries()];
|
||||||
@@ -369,7 +372,7 @@ export class McpRouter {
|
|||||||
const cached = result.value.cached;
|
const cached = result.value.cached;
|
||||||
if (cached.kind === 'err') {
|
if (cached.kind === 'err') {
|
||||||
cachedCount++;
|
cachedCount++;
|
||||||
failed.push(serverName);
|
failed.push({ server: serverName, message: cached.message });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
response = { jsonrpc: '2.0', id: `cached-${serverName}`, result: cached.result };
|
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';
|
const message = (response.error as { message?: string }).message ?? 'unknown error';
|
||||||
this.setListCacheEntry(serverName, 'tools/list', { kind: 'err', message, fetchedAt: Date.now() });
|
this.setListCacheEntry(serverName, 'tools/list', { kind: 'err', message, fetchedAt: Date.now() });
|
||||||
console.warn(`[discoverTools] ${serverName}: ${message}`);
|
console.warn(`[discoverTools] ${serverName}: ${message}`);
|
||||||
failed.push(serverName);
|
failed.push({ server: serverName, message });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (response.result !== undefined) {
|
if (response.result !== undefined) {
|
||||||
@@ -410,16 +413,27 @@ export class McpRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.toolDiscoveryFailures = failed;
|
||||||
|
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
const elapsed = Date.now() - started;
|
const elapsed = Date.now() - started;
|
||||||
const project = this.projectName ? ` project=${this.projectName}` : '';
|
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`);
|
console.info(`[discoverTools]${project} fresh=${freshCount} cached=${cachedCount}${failedStr} elapsed=${elapsed}ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return allTools;
|
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.
|
* Discover resources from all upstreams by calling resources/list on each.
|
||||||
* Shares the per-server list cache with `discoverTools`.
|
* Shares the per-server list cache with `discoverTools`.
|
||||||
|
|||||||
75
src/mcplocal/tests/ambient-time.test.ts
Normal file
75
src/mcplocal/tests/ambient-time.test.ts
Normal 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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
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';
|
import { McpRouter } from '../src/router.js';
|
||||||
|
|
||||||
function mockMcpdClient(servers: Array<{ id: string; name: string; transport: string }>) {
|
function mockMcpdClient(servers: Array<{ id: string; name: string; transport: string }>) {
|
||||||
@@ -69,3 +69,61 @@ describe('refreshUpstreams', () => {
|
|||||||
expect(router.getUpstreamNames()).toHaveLength(0);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
117
src/mcplocal/tests/plugin-ambient-context.test.ts
Normal file
117
src/mcplocal/tests/plugin-ambient-context.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -199,6 +199,19 @@ describe('Gate Plugin via setPlugin()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('begin_session', () => {
|
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 () => {
|
it('falls back with a visible warning when the LLM selector errors (no silent degrade)', async () => {
|
||||||
const { router } = setupPluginRouter({ withLlm: true, llmError: true });
|
const { router } = setupPluginRouter({ withLlm: true, llmError: true });
|
||||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||||
|
|||||||
138
src/mcplocal/tests/project-mcp-endpoint-ambient.test.ts
Normal file
138
src/mcplocal/tests/project-mcp-endpoint-ambient.test.ts
Normal 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 ['websearch'];
|
||||||
|
}),
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
93
src/shared/src/utils/ambient-time.ts
Normal file
93
src/shared/src/utils/ambient-time.ts
Normal 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');
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
// Shared utility functions
|
// Shared utility functions
|
||||||
// Will be expanded as tasks are implemented
|
export * from './ambient-time.js';
|
||||||
|
|||||||
26
templates/dav.yaml
Normal file
26
templates/dav.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: dav
|
||||||
|
version: "1.0.0"
|
||||||
|
description: CalDAV/CardDAV/VTODO (Stalwart or any RFC 4791/6352 server) — calendars, events, tasks, contacts
|
||||||
|
# NOT packageName: dav-mcp declares tsdav/tsdav-utils as GitHub-URL deps, so
|
||||||
|
# `npx -y dav-mcp` dies with `spawn git ENOENT` on the git-less node-runner,
|
||||||
|
# and the upstream ghcr image starts the HTTP server as PID 1. This image
|
||||||
|
# bakes the install and execs the stdio entrypoint (NODE_OPTIONS baked in).
|
||||||
|
dockerImage: "mysources.co.uk/michal/dav-mcp:4.0.1-stdio1"
|
||||||
|
transport: STDIO
|
||||||
|
repositoryUrl: https://mysources.co.uk/michal/dav-mcp-image
|
||||||
|
healthCheck:
|
||||||
|
# A real CalDAV PROPFIND: a pass proves server URL and credentials.
|
||||||
|
tool: list_calendars
|
||||||
|
arguments: {}
|
||||||
|
intervalSeconds: 60
|
||||||
|
timeoutSeconds: 10
|
||||||
|
env:
|
||||||
|
- name: CALDAV_SERVER_URL
|
||||||
|
description: DAV base URL (e.g. http://stalwart.stalwart.svc.cluster.local:8080)
|
||||||
|
required: true
|
||||||
|
- name: CALDAV_USERNAME
|
||||||
|
description: DAV username
|
||||||
|
required: true
|
||||||
|
- name: CALDAV_PASSWORD
|
||||||
|
description: DAV password (use an app password)
|
||||||
|
required: true
|
||||||
45
templates/jmap.yaml
Normal file
45
templates/jmap.yaml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: jmap
|
||||||
|
version: "1.0.0"
|
||||||
|
description: JMAP email (Stalwart) — search/read/organize + drafts; send and delete gated off by env
|
||||||
|
dockerImage: "mysources.co.uk/michal/jmap-mcp:0.6.5-drafts3"
|
||||||
|
transport: STDIO
|
||||||
|
repositoryUrl: https://mysources.co.uk/michal/jmap-mcp
|
||||||
|
# No command: the image's entrypoint IS the MCP server (deno run), mcpd
|
||||||
|
# attaches to PID 1 stdin/stdout. Fork of wyattjoh/jmap-mcp adding
|
||||||
|
# create_draft/create_draft_reply, JMAP_DISABLE_* gating, and Basic auth
|
||||||
|
# (jmap-jam upstream only speaks Bearer).
|
||||||
|
healthCheck:
|
||||||
|
# A real Mailbox/get: a pass proves session URL, auth, and account
|
||||||
|
# resolution, not just process liveness.
|
||||||
|
tool: get_mailboxes
|
||||||
|
arguments: {}
|
||||||
|
intervalSeconds: 60
|
||||||
|
timeoutSeconds: 10
|
||||||
|
env:
|
||||||
|
- name: JMAP_SESSION_URL
|
||||||
|
description: JMAP session URL (e.g. http://stalwart.stalwart.svc.cluster.local:8080/.well-known/jmap)
|
||||||
|
required: true
|
||||||
|
- name: JMAP_BEARER_TOKEN
|
||||||
|
description: Bearer token. Provide either this OR JMAP_USERNAME + JMAP_PASSWORD.
|
||||||
|
required: false
|
||||||
|
- name: JMAP_USERNAME
|
||||||
|
description: Username for HTTP Basic auth (e.g. with a Stalwart app password)
|
||||||
|
required: false
|
||||||
|
- name: JMAP_PASSWORD
|
||||||
|
description: Password/app password for HTTP Basic auth
|
||||||
|
required: false
|
||||||
|
- name: JMAP_ACCOUNT_ID
|
||||||
|
description: Account ID (auto-detected when empty)
|
||||||
|
required: false
|
||||||
|
- name: JMAP_DISABLE_SUBMISSION
|
||||||
|
description: "true = do not register send_email/reply_to_email (drafts only)"
|
||||||
|
required: false
|
||||||
|
defaultValue: "true"
|
||||||
|
- name: JMAP_DISABLE_DELETE
|
||||||
|
description: "true = do not register delete_emails (move to Trash still works)"
|
||||||
|
required: false
|
||||||
|
defaultValue: "true"
|
||||||
|
- name: JMAP_REBASE_SESSION_URLS
|
||||||
|
description: "true = rewrite the session's apiUrl/downloadUrl/uploadUrl/eventSourceUrl onto the session URL's origin. Required over a ClusterIP: Stalwart advertises its publicUrl and the hairpinned call 403s at Envoy."
|
||||||
|
required: false
|
||||||
|
defaultValue: "true"
|
||||||
24
templates/karakeep.yaml
Normal file
24
templates/karakeep.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: karakeep
|
||||||
|
version: "1.0.0"
|
||||||
|
description: Karakeep bookmarks — search, read, create, lists, tags, highlights
|
||||||
|
packageName: "@karakeep/mcp"
|
||||||
|
runtime: node
|
||||||
|
transport: STDIO
|
||||||
|
repositoryUrl: https://github.com/karakeep-app/karakeep
|
||||||
|
healthCheck:
|
||||||
|
# A real API call (lists all lists): a pass proves address and API key.
|
||||||
|
tool: get-lists
|
||||||
|
arguments: {}
|
||||||
|
intervalSeconds: 60
|
||||||
|
timeoutSeconds: 10
|
||||||
|
env:
|
||||||
|
- name: KARAKEEP_API_ADDR
|
||||||
|
description: Karakeep base URL (e.g. http://karakeep-web.karakeep.svc.cluster.local:3000)
|
||||||
|
required: true
|
||||||
|
- name: KARAKEEP_API_KEY
|
||||||
|
description: API key created in the Karakeep UI (Settings → API Keys)
|
||||||
|
required: true
|
||||||
|
- name: NODE_OPTIONS
|
||||||
|
description: V8 heap cap; Node sizes its heap off host RAM, not the cgroup limit
|
||||||
|
required: false
|
||||||
|
defaultValue: "--max-old-space-size=384"
|
||||||
26
templates/ttrss.yaml
Normal file
26
templates/ttrss.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: ttrss
|
||||||
|
version: "1.0.0"
|
||||||
|
description: Tiny Tiny RSS — feeds, headlines, article content, read/star state, subscribe
|
||||||
|
dockerImage: "mysources.co.uk/michal/ttrss-mcp:1.0.1"
|
||||||
|
transport: STDIO
|
||||||
|
repositoryUrl: https://mysources.co.uk/michal/ttrss-mcp
|
||||||
|
# No command: the image's entrypoint IS the MCP server. Custom server; the
|
||||||
|
# public tt-rss MCPs only take a pre-made session id and die when it expires.
|
||||||
|
# This one logs in with user/password and re-logs-in on NOT_LOGGED_IN.
|
||||||
|
healthCheck:
|
||||||
|
# A real getCategories call: a pass proves URL, login, and that the
|
||||||
|
# user's "Enable API access" preference is on.
|
||||||
|
tool: list_categories
|
||||||
|
arguments: {}
|
||||||
|
intervalSeconds: 60
|
||||||
|
timeoutSeconds: 10
|
||||||
|
env:
|
||||||
|
- name: TTRSS_URL
|
||||||
|
description: Full API endpoint (e.g. http://ttrss.ttrss.svc.cluster.local/api/)
|
||||||
|
required: true
|
||||||
|
- name: TTRSS_USER
|
||||||
|
description: tt-rss user with "Enable API access" set in preferences
|
||||||
|
required: true
|
||||||
|
- name: TTRSS_PASSWORD
|
||||||
|
description: That user's password
|
||||||
|
required: true
|
||||||
Reference in New Issue
Block a user