ambient context: give every project's model a clock
Some checks failed
CI/CD / lint (pull_request) Successful in 1m16s
CI/CD / test (pull_request) Successful in 1m26s
CI/CD / typecheck (pull_request) Successful in 3m25s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 5m9s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Successful in 1m16s
CI/CD / test (pull_request) Successful in 1m26s
CI/CD / typecheck (pull_request) Successful in 3m25s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 5m9s
CI/CD / publish (pull_request) Has been skipped
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: <date/time> — project: <name>' (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 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
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.
|
||||
*/
|
||||
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}). `
|
||||
|
||||
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)');
|
||||
});
|
||||
});
|
||||
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', () => {
|
||||
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' });
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user