refactor(mcp): replace direct DB access with service layer calls
Replace all db.prepare() calls in mcp/index.ts, mcp/resources.ts, and mcp/tools.ts with calls to the service layer. Add missing service functions: - authService: isDemoUser, verifyMcpToken, verifyJwtToken - adminService: isAddonEnabled - atlasService: listVisitedCountries - tripService: getTripSummary, listTrips with null archived param Also fix getAssignmentWithPlace and formatAssignmentWithPlace to expose place_id, assignment_time, and assignment_end_time at the top level, and fix updateDay to correctly handle null title for clearing. Add comprehensive unit and integration test suite for the MCP layer (821 tests all passing).
This commit is contained in:
@@ -259,6 +259,201 @@ export interface TestInviteToken {
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day Notes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestDayNote {
|
||||
id: number;
|
||||
day_id: number;
|
||||
trip_id: number;
|
||||
text: string;
|
||||
time: string | null;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export function createDayNote(
|
||||
db: Database.Database,
|
||||
dayId: number,
|
||||
tripId: number,
|
||||
overrides: Partial<{ text: string; time: string; icon: string }> = {}
|
||||
): TestDayNote {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, 9999)'
|
||||
).run(dayId, tripId, overrides.text ?? 'Test note', overrides.time ?? null, overrides.icon ?? '📝');
|
||||
return db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid) as TestDayNote;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collab Notes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestCollabNote {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
content: string | null;
|
||||
category: string;
|
||||
color: string;
|
||||
pinned: number;
|
||||
}
|
||||
|
||||
export function createCollabNote(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
userId: number,
|
||||
overrides: Partial<{ title: string; content: string; category: string; color: string }> = {}
|
||||
): TestCollabNote {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO collab_notes (trip_id, user_id, title, content, category, color) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId,
|
||||
userId,
|
||||
overrides.title ?? 'Test Note',
|
||||
overrides.content ?? null,
|
||||
overrides.category ?? 'General',
|
||||
overrides.color ?? '#6366f1'
|
||||
);
|
||||
return db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid) as TestCollabNote;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day Assignments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestDayAssignment {
|
||||
id: number;
|
||||
day_id: number;
|
||||
place_id: number;
|
||||
order_index: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export function createDayAssignment(
|
||||
db: Database.Database,
|
||||
dayId: number,
|
||||
placeId: number,
|
||||
overrides: Partial<{ order_index: number; notes: string }> = {}
|
||||
): TestDayAssignment {
|
||||
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null };
|
||||
const orderIndex = overrides.order_index ?? (maxOrder.max !== null ? maxOrder.max + 1 : 0);
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)'
|
||||
).run(dayId, placeId, orderIndex, overrides.notes ?? null);
|
||||
return db.prepare('SELECT * FROM day_assignments WHERE id = ?').get(result.lastInsertRowid) as TestDayAssignment;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bucket List
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestBucketListItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
country_code: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export function createBucketListItem(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
overrides: Partial<{ name: string; lat: number; lng: number; country_code: string; notes: string }> = {}
|
||||
): TestBucketListItem {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
userId,
|
||||
overrides.name ?? 'Test Destination',
|
||||
overrides.lat ?? null,
|
||||
overrides.lng ?? null,
|
||||
overrides.country_code ?? null,
|
||||
overrides.notes ?? null
|
||||
);
|
||||
return db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid) as TestBucketListItem;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visited Countries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createVisitedCountry(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
countryCode: string
|
||||
): void {
|
||||
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, countryCode.toUpperCase());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day Accommodations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestDayAccommodation {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
place_id: number;
|
||||
start_day_id: number;
|
||||
end_day_id: number;
|
||||
check_in: string | null;
|
||||
check_out: string | null;
|
||||
}
|
||||
|
||||
export function createDayAccommodation(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
placeId: number,
|
||||
startDayId: number,
|
||||
endDayId: number,
|
||||
overrides: Partial<{ check_in: string; check_out: string; confirmation: string }> = {}
|
||||
): TestDayAccommodation {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId,
|
||||
placeId,
|
||||
startDayId,
|
||||
endDayId,
|
||||
overrides.check_in ?? null,
|
||||
overrides.check_out ?? null,
|
||||
overrides.confirmation ?? null
|
||||
);
|
||||
return db.prepare('SELECT * FROM day_accommodations WHERE id = ?').get(result.lastInsertRowid) as TestDayAccommodation;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP Tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export interface TestMcpToken {
|
||||
id: number;
|
||||
tokenHash: string;
|
||||
rawToken: string;
|
||||
}
|
||||
|
||||
export function createMcpToken(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
overrides: Partial<{ name: string; rawToken: string }> = {}
|
||||
): TestMcpToken {
|
||||
const rawToken = overrides.rawToken ?? `trek_test_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
|
||||
const tokenPrefix = rawToken.slice(0, 12);
|
||||
const result = db.prepare(
|
||||
'INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, ?, ?, ?)'
|
||||
).run(userId, tokenHash, tokenPrefix, overrides.name ?? 'Test Token');
|
||||
return { id: result.lastInsertRowid as number, tokenHash, rawToken };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invite Tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createInviteToken(
|
||||
db: Database.Database,
|
||||
overrides: Partial<{ token: string; max_uses: number; expires_at: string; created_by: number }> = {}
|
||||
|
||||
68
server/tests/helpers/mcp-harness.ts
Normal file
68
server/tests/helpers/mcp-harness.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function createMcpHarness(options: McpHarnessOptions): Promise<McpHarness> {
|
||||
const { userId, withResources = true, withTools = true } = options;
|
||||
|
||||
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
|
||||
|
||||
if (withResources) registerResources(server, userId);
|
||||
if (withTools) registerTools(server, userId);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Parse JSON from a callTool result (first text content item). */
|
||||
export function parseToolResult(result: Awaited<ReturnType<Client['callTool']>>): unknown {
|
||||
const text = result.content.find((c: { type: string }) => c.type === 'text') as { type: 'text'; text: string } | undefined;
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user