mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
ba7d55f999
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.
98 lines
4.1 KiB
TypeScript
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);
|
|
}
|