Files
TREK/server/tests/helpers/mcp-harness.ts
T
Maurice ba7d55f999 Finish the contract ratchet, the upload bridge and the client leftovers
BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.

Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:

- TagsController.create answers { error: 'Tag name is required' }, pinned
  byte-for-byte in two tests, and the pipe formats as `field: message`. The
  DTO leaves name optional and the handler keeps the check that owns the
  message. Same shape for the booking-import confirm body, which additionally
  gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
  and check that BEFORE looking at the body. A schema that rejected first
  would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
  documented reset. Typing it as string[] would have removed a working admin
  action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
  it dislikes, so the client falls back to straight lines. A 400 there would
  turn a graceful degradation into a visible error.

files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.

PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.

typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
2026-08-10 19:54:46 +02:00

98 lines
4.1 KiB
TypeScript

/**
* MCP test harness.
*
* Creates an McpServer + MCP Client connected via InMemoryTransport for unit testing
* tools and resources without HTTP overhead.
*
* Usage:
* const harness = await createMcpHarness({ userId, registerTools: true });
* const result = await harness.client.callTool({ name: 'create_trip', arguments: { title: 'Test' } });
* await harness.cleanup();
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import { registerResources } from '../../src/mcp/resources';
import { registerTools } from '../../src/mcp/tools';
import { setMcpRegistry } from '../../src/mcp/registry-handoff';
import { createMcpTestRegistry } from './mcp-test-controllers';
export interface McpHarness {
client: Client;
server: McpServer;
cleanup: () => Promise<void>;
}
export interface McpHarnessOptions {
userId: number;
/** Register read-only resources (default: true) */
withResources?: boolean;
/** Register read-write tools (default: true) */
withTools?: boolean;
/** OAuth scopes to restrict tools; null = full access (default: null) */
scopes?: string[] | null;
/** Whether the session is authenticated via a static API token (default: false) */
isStaticToken?: boolean;
/** Fire-once deprecation-notice closure (default: registerTools' () => null) */
getDeprecationNotice?: () => string | null;
}
export async function createMcpHarness(options: McpHarnessOptions): Promise<McpHarness> {
const { userId, withResources = true, withTools = true, scopes = null, isStaticToken = false, getDeprecationNotice } = options;
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
// registerResources gates the journey resources on the same scope list the
// tools use, so the harness has to hand it over as well.
if (withResources) registerResources(server, userId, scopes ?? null);
if (withTools) {
// In production bootstrap.ts hands the Nest-discovered registry to
// registerTools after app.init(); the harness has no Nest app, so it
// builds the same registry by hand (see mcp-test-controllers.ts).
// registerTools' own ctx construction stays exercised.
setMcpRegistry(createMcpTestRegistry());
registerTools(server, userId, scopes ?? null, isStaticToken, getDeprecationNotice);
}
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'test-client', version: '1.0.0' });
await server.connect(serverTransport);
await client.connect(clientTransport);
const cleanup = async () => {
try { await client.close(); } catch { /* ignore */ }
try { await server.close(); } catch { /* ignore */ }
};
return { client, server, cleanup };
}
/**
* callTool is declared as a union of the current result shape and the legacy
* `toolResult` compatibility shape. Reading `content` off the union yields
* unknown, because the compatibility branch only has an index signature.
*/
type ToolCallResult = Awaited<ReturnType<Client['callTool']>>;
type ToolCallContentResult = Extract<ToolCallResult, { content: unknown[] }>;
type ToolTextContent = Extract<ToolCallContentResult['content'][number], { type: 'text' }>;
/** Parse JSON from a callTool result (first text content item). */
export function parseToolResult(result: ToolCallResult): unknown {
// Our own McpServer always answers with the content shape, never the
// compatibility one, so pick that branch of the union.
const { content } = result as ToolCallContentResult;
const text = content.find((c): c is ToolTextContent => c.type === 'text');
if (!text) throw new Error('No text content in tool result');
return JSON.parse(text.text);
}
/** Parse JSON from a readResource result (first content item). */
export function parseResourceResult(result: Awaited<ReturnType<Client['readResource']>>): unknown {
const item = result.contents[0] as { text?: string } | undefined;
if (!item?.text) throw new Error('No text content in resource result');
return JSON.parse(item.text);
}