From 979e9720c09fa9fb04d1bfa6a929b497a191332e Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 2 Oct 2026 00:33:20 +0100 Subject: [PATCH] ambient context: give every project's model a clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models consuming projects have no idea what day it is. Three always-on layers from mcplocal plus one from mcpd: - initialize instructions and the begin_session briefing carry '[context] now: — project: ' (frozen at session start by design); - a get_current_time virtual tool (ungated catalogs only, same pattern as read_prompts); - every tools/call result gets a trailing '[context] now: …' content item at the endpoint send site — the only chokepoint that also covers virtual tools, gate intercepts and failure results, and the layer that keeps a days-old chat session current; - mcpd agent/project chat system blocks append the same line each turn. Time renders as UTC plus an optional local timezone from MCPLOCAL_AMBIENT_TZ (IANA name; both services read it; invalid or unset means UTC only). ~15 tokens per stamp, minute precision. Co-Authored-By: Claude Fable 5 --- src/mcpd/src/services/chat.service.ts | 6 + src/mcpd/tests/chat-service-project.test.ts | 5 +- src/mcpd/tests/chat-service.test.ts | 2 + src/mcplocal/src/http/project-mcp-endpoint.ts | 30 +++- .../src/proxymodel/plugins/ambient-context.ts | 56 +++++++ src/mcplocal/src/proxymodel/plugins/gate.ts | 5 + src/mcplocal/tests/ambient-time.test.ts | 75 ++++++++++ .../tests/plugin-ambient-context.test.ts | 117 +++++++++++++++ src/mcplocal/tests/plugin-gate.test.ts | 13 ++ .../project-mcp-endpoint-ambient.test.ts | 138 ++++++++++++++++++ src/shared/src/utils/ambient-time.ts | 93 ++++++++++++ src/shared/src/utils/index.ts | 2 +- 12 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 src/mcplocal/src/proxymodel/plugins/ambient-context.ts create mode 100644 src/mcplocal/tests/ambient-time.test.ts create mode 100644 src/mcplocal/tests/plugin-ambient-context.test.ts create mode 100644 src/mcplocal/tests/project-mcp-endpoint-ambient.test.ts create mode 100644 src/shared/src/utils/ambient-time.ts diff --git a/src/mcpd/src/services/chat.service.ts b/src/mcpd/src/services/chat.service.ts index c02e7f9..19e6a08 100644 --- a/src/mcpd/src/services/chat.service.ts +++ b/src/mcpd/src/services/chat.service.ts @@ -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'; @@ -728,6 +729,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'); @@ -842,6 +846,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'); diff --git a/src/mcpd/tests/chat-service-project.test.ts b/src/mcpd/tests/chat-service-project.test.ts index 886a0e5..204947e 100644 --- a/src/mcpd/tests/chat-service-project.test.ts +++ b/src/mcpd/tests/chat-service-project.test.ts @@ -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).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 () => { diff --git a/src/mcpd/tests/chat-service.test.ts b/src/mcpd/tests/chat-service.test.ts index 42f4f9d..7086a19 100644 --- a/src/mcpd/tests/chat-service.test.ts +++ b/src/mcpd/tests/chat-service.test.ts @@ -388,6 +388,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 () => { diff --git a/src/mcplocal/src/http/project-mcp-endpoint.ts b/src/mcplocal/src/http/project-mcp-endpoint.ts index 9a1340e..605b21a 100644 --- a/src/mcplocal/src/http/project-mcp-endpoint.ts +++ b/src/mcplocal/src/http/project-mcp-endpoint.ts @@ -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'; @@ -190,6 +192,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 +254,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 +548,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( diff --git a/src/mcplocal/src/proxymodel/plugins/ambient-context.ts b/src/mcplocal/src/proxymodel/plugins/ambient-context.ts new file mode 100644 index 0000000..89ec628 --- /dev/null +++ b/src/mcplocal/src/proxymodel/plugins/ambient-context.ts @@ -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()]; + }, + }; +} diff --git a/src/mcplocal/src/proxymodel/plugins/gate.ts b/src/mcplocal/src/proxymodel/plugins/gate.ts index f63ce13..2d42030 100644 --- a/src/mcplocal/src/proxymodel/plugins/gate.ts +++ b/src/mcplocal/src/proxymodel/plugins/gate.ts @@ -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'; @@ -413,6 +414,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}). ` diff --git a/src/mcplocal/tests/ambient-time.test.ts b/src/mcplocal/tests/ambient-time.test.ts new file mode 100644 index 0000000..637146b --- /dev/null +++ b/src/mcplocal/tests/ambient-time.test.ts @@ -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)'); + }); +}); diff --git a/src/mcplocal/tests/plugin-ambient-context.test.ts b/src/mcplocal/tests/plugin-ambient-context.test.ts new file mode 100644 index 0000000..516810b --- /dev/null +++ b/src/mcplocal/tests/plugin-ambient-context.test.ts @@ -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 => { + 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 { + 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'); + }); +}); diff --git a/src/mcplocal/tests/plugin-gate.test.ts b/src/mcplocal/tests/plugin-gate.test.ts index f33c195..49e3a85 100644 --- a/src/mcplocal/tests/plugin-gate.test.ts +++ b/src/mcplocal/tests/plugin-gate.test.ts @@ -199,6 +199,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' }); diff --git a/src/mcplocal/tests/project-mcp-endpoint-ambient.test.ts b/src/mcplocal/tests/project-mcp-endpoint-ambient.test.ts new file mode 100644 index 0000000..21b6e7d --- /dev/null +++ b/src/mcplocal/tests/project-mcp-endpoint-ambient.test.ts @@ -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 => { + 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('../src/http/config.js'); + return { ...actual, loadProjectLlmOverride: vi.fn(() => undefined) }; +}); + +function mockMcpdClient() { + const client: Record = { + 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).mockReturnValue(client); + (client.withToken as ReturnType).mockReturnValue(client); + (client.withTimeout as ReturnType).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(); + } + }); +}); diff --git a/src/shared/src/utils/ambient-time.ts b/src/shared/src/utils/ambient-time.ts new file mode 100644 index 0000000..149d4ba --- /dev/null +++ b/src/shared/src/utils/ambient-time.ts @@ -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'); +} diff --git a/src/shared/src/utils/index.ts b/src/shared/src/utils/index.ts index 39156df..0aa3dd9 100644 --- a/src/shared/src/utils/index.ts +++ b/src/shared/src/utils/index.ts @@ -1,2 +1,2 @@ // Shared utility functions -// Will be expanded as tasks are implemented +export * from './ambient-time.js';