diff --git a/services/mcp-server/.env.example b/services/mcp-server/.env.example new file mode 100644 index 00000000..107c5e35 --- /dev/null +++ b/services/mcp-server/.env.example @@ -0,0 +1,14 @@ +# MCP Server — port 4006 +PORT=4006 +HOST=0.0.0.0 +LOG_LEVEL=info + +# Upstream services +PLATFORM_SERVICE_URL=http://localhost:4003 +EXTRACTION_SERVICE_URL=http://localhost:4005 + +# Auth — same JWT_SECRET as platform-service (tokens issued there are validated here) +JWT_SECRET=change-me-in-production + +# Optional: AKV resolution +AZURE_KEYVAULT_URL= diff --git a/services/mcp-server/.gitignore b/services/mcp-server/.gitignore new file mode 100644 index 00000000..a8b14857 --- /dev/null +++ b/services/mcp-server/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +.env diff --git a/services/mcp-server/package.json b/services/mcp-server/package.json new file mode 100644 index 00000000..a1856510 --- /dev/null +++ b/services/mcp-server/package.json @@ -0,0 +1,28 @@ +{ + "name": "@bytelyst/mcp-server", + "version": "0.1.0", + "private": true, + "description": "ByteLyst MCP Server — exposes platform.telemetry.*, platform.diagnostics.*, extraction.*, and support.* tool namespaces", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bytelyst/config": "workspace:*", + "@bytelyst/errors": "workspace:*", + "@bytelyst/fastify-core": "workspace:*", + "fastify": "^5.2.1", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/services/mcp-server/src/lib/auth.ts b/services/mcp-server/src/lib/auth.ts new file mode 100644 index 00000000..40a63346 --- /dev/null +++ b/services/mcp-server/src/lib/auth.ts @@ -0,0 +1,55 @@ +import { jwtVerify } from 'jose'; +import { config } from './config.js'; + +export type Role = 'viewer' | 'admin' | 'super_admin'; + +export interface JwtPayload { + sub: string; + role?: Role; + productId?: string; + iat?: number; + exp?: number; +} + +/** Minimal request shape used for auth checks (works with FastifyRequest or McpToolRequest) */ +export interface AuthRequest { + headers: { authorization?: string | undefined }; + jwtPayload?: JwtPayload; +} + +// Augment FastifyRequest with jwtPayload for the onRequest hook +import type { FastifyRequest } from 'fastify'; +declare module 'fastify' { + interface FastifyRequest { + jwtPayload?: JwtPayload; + } +} + +export async function parseJwt(req: FastifyRequest): Promise { + const auth = req.headers.authorization; + if (!auth?.startsWith('Bearer ')) return; + try { + const secret = new TextEncoder().encode(config.JWT_SECRET); + const { payload } = await jwtVerify(auth.slice(7), secret); + req.jwtPayload = payload as JwtPayload; + } catch { + // Invalid / expired — auth-required handlers will reject below + } +} + +export function requireAuth(req: AuthRequest): JwtPayload { + if (!req.jwtPayload?.sub) + throw Object.assign(new Error('Authentication required'), { statusCode: 401 }); + return req.jwtPayload as JwtPayload; +} + +export function requireRole(req: AuthRequest, minRole: Role): JwtPayload { + const payload = requireAuth(req); + const order: Role[] = ['viewer', 'admin', 'super_admin']; + const userLevel = order.indexOf(payload.role ?? 'viewer'); + const requiredLevel = order.indexOf(minRole); + if (userLevel < requiredLevel) { + throw Object.assign(new Error(`Role '${minRole}' required`), { statusCode: 403 }); + } + return payload; +} diff --git a/services/mcp-server/src/lib/config.ts b/services/mcp-server/src/lib/config.ts new file mode 100644 index 00000000..6d59b653 --- /dev/null +++ b/services/mcp-server/src/lib/config.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + PORT: z.coerce.number().default(4006), + HOST: z.string().default('0.0.0.0'), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + CORS_ORIGIN: z.string().optional(), + LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), + JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), + PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'), + EXTRACTION_SERVICE_URL: z.string().default('http://localhost:4005'), + /** Max items returned per tool call query (hard cap) */ + QUERY_MAX_LIMIT: z.coerce.number().default(100), + /** Default items per tool call query */ + QUERY_DEFAULT_LIMIT: z.coerce.number().default(20), +}); + +export const config = envSchema.parse(process.env); diff --git a/services/mcp-server/src/lib/extraction-client.ts b/services/mcp-server/src/lib/extraction-client.ts new file mode 100644 index 00000000..bb17a036 --- /dev/null +++ b/services/mcp-server/src/lib/extraction-client.ts @@ -0,0 +1,63 @@ +import { config } from './config.js'; + +export interface ExtractionItem { + extraction_class: string; + extraction_text: string; + attributes?: Record; +} + +export interface ExtractionResult { + extractions: ExtractionItem[]; + metadata: { + modelId: string; + taskId?: string; + durationMs?: number; + cached?: boolean; + }; +} + +export async function extractionRun( + params: { + text: string; + taskId?: string; + modelId?: string; + }, + opts: { requestId?: string } +): Promise { + const url = `${config.EXTRACTION_SERVICE_URL}/api/extract`; + const headers: Record = { + 'Content-Type': 'application/json', + ...(opts.requestId ? { 'x-request-id': opts.requestId } : {}), + }; + const res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(params), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`extraction-service POST /api/extract → ${res.status}: ${body}`); + } + return res.json() as Promise; +} + +export async function extractionModels(opts: { requestId?: string }): Promise { + const url = `${config.EXTRACTION_SERVICE_URL}/api/extract/models`; + const headers: Record = { + ...(opts.requestId ? { 'x-request-id': opts.requestId } : {}), + }; + const res = await fetch(url, { headers, signal: AbortSignal.timeout(5_000) }); + if (!res.ok) throw new Error(`extraction-service GET /api/extract/models → ${res.status}`); + return res.json(); +} + +export async function extractionCacheStats(opts: { requestId?: string }): Promise { + const url = `${config.EXTRACTION_SERVICE_URL}/api/extract/cache-stats`; + const headers: Record = { + ...(opts.requestId ? { 'x-request-id': opts.requestId } : {}), + }; + const res = await fetch(url, { headers, signal: AbortSignal.timeout(5_000) }); + if (!res.ok) throw new Error(`extraction-service GET /api/extract/cache-stats → ${res.status}`); + return res.json(); +} diff --git a/services/mcp-server/src/lib/platform-client.ts b/services/mcp-server/src/lib/platform-client.ts new file mode 100644 index 00000000..6a6fb8c3 --- /dev/null +++ b/services/mcp-server/src/lib/platform-client.ts @@ -0,0 +1,210 @@ +import { config } from './config.js'; + +export interface PlatformClientOptions { + /** Bearer token for the upstream request */ + token?: string; + /** x-request-id to propagate */ + requestId?: string; + /** x-product-id to forward */ + productId?: string; +} + +async function platformFetch( + path: string, + init: RequestInit, + opts: PlatformClientOptions +): Promise { + const url = `${config.PLATFORM_SERVICE_URL}${path}`; + const headers: Record = { + 'Content-Type': 'application/json', + ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}), + ...(opts.requestId ? { 'x-request-id': opts.requestId } : {}), + ...(opts.productId ? { 'x-product-id': opts.productId } : {}), + }; + const res = await fetch(url, { ...init, headers: { ...headers, ...(init.headers ?? {}) } }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`platform-service ${init.method ?? 'GET'} ${path} → ${res.status}: ${body}`); + } + return res.json() as Promise; +} + +// ── Telemetry ───────────────────────────────────────────────────────────────── + +export interface TelemetryQueryResult { + events: unknown[]; + total: number; + continuationToken?: string; +} + +export async function telemetryQuery( + params: { + productId: string; + eventType?: string; + from?: string; + to?: string; + limit?: number; + continuationToken?: string; + }, + opts: PlatformClientOptions +): Promise { + const qs = new URLSearchParams(); + qs.set('productId', params.productId); + if (params.eventType) qs.set('eventType', params.eventType); + if (params.from) qs.set('from', params.from); + if (params.to) qs.set('to', params.to); + qs.set( + 'limit', + String(Math.min(params.limit ?? config.QUERY_DEFAULT_LIMIT, config.QUERY_MAX_LIMIT)) + ); + if (params.continuationToken) qs.set('continuationToken', params.continuationToken); + return platformFetch(`/api/telemetry/query?${qs}`, { method: 'GET' }, opts); +} + +export interface TelemetryCluster { + fingerprint: string; + count: number; + lastSeen: string; + sample: unknown; +} + +export async function telemetryClusters( + params: { productId: string; from?: string; to?: string }, + opts: PlatformClientOptions +): Promise<{ clusters: TelemetryCluster[] }> { + const qs = new URLSearchParams({ productId: params.productId }); + if (params.from) qs.set('from', params.from); + if (params.to) qs.set('to', params.to); + return platformFetch<{ clusters: TelemetryCluster[] }>( + `/api/telemetry/clusters?${qs}`, + { method: 'GET' }, + opts + ); +} + +export async function telemetryMetrics(opts: PlatformClientOptions): Promise { + return platformFetch('/api/telemetry/metrics', { method: 'GET' }, opts); +} + +// ── Diagnostics ─────────────────────────────────────────────────────────────── + +export interface DebugSession { + id: string; + productId: string; + status: string; + collectionLevel: string; + captureLogs: boolean; + captureNetwork: boolean; + captureScreenshots: boolean; + maxDurationMinutes: number; + logCount: number; + traceCount: number; + createdAt: string; + expiresAt: string; + targetUserId?: string; + targetAnonymousId?: string; +} + +export async function diagnosticsListSessions( + params: { + productId?: string; + status?: string; + limit?: number; + offset?: number; + }, + opts: PlatformClientOptions +): Promise<{ sessions: DebugSession[]; total: number }> { + const qs = new URLSearchParams(); + if (params.productId) qs.set('productId', params.productId); + if (params.status) qs.set('status', params.status); + qs.set( + 'limit', + String(Math.min(params.limit ?? config.QUERY_DEFAULT_LIMIT, config.QUERY_MAX_LIMIT)) + ); + if (params.offset) qs.set('offset', String(params.offset)); + return platformFetch<{ sessions: DebugSession[]; total: number }>( + `/api/diagnostics/sessions?${qs}`, + { method: 'GET' }, + opts + ); +} + +export async function diagnosticsCreateSession( + body: { + productId: string; + targetUserId?: string; + targetAnonymousId?: string; + collectionLevel?: 'standard' | 'debug' | 'trace'; + captureLogs?: boolean; + captureNetwork?: boolean; + maxDurationMinutes?: number; + }, + opts: PlatformClientOptions +): Promise { + return platformFetch( + '/api/diagnostics/sessions', + { method: 'POST', body: JSON.stringify(body) }, + opts + ); +} + +export async function diagnosticsGetSession( + sessionId: string, + opts: PlatformClientOptions +): Promise { + return platformFetch( + `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}`, + { method: 'GET' }, + opts + ); +} + +export async function diagnosticsUpdateSession( + sessionId: string, + body: { status?: string; collectionLevel?: string; maxDurationMinutes?: number }, + opts: PlatformClientOptions +): Promise { + return platformFetch( + `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}`, + { method: 'PATCH', body: JSON.stringify(body) }, + opts + ); +} + +export async function diagnosticsGetLogs( + sessionId: string, + params: { level?: string; from?: string; to?: string; limit?: number }, + opts: PlatformClientOptions +): Promise<{ logs: unknown[]; continuationToken?: string }> { + const qs = new URLSearchParams(); + if (params.level) qs.set('level', params.level); + if (params.from) qs.set('from', params.from); + if (params.to) qs.set('to', params.to); + qs.set( + 'limit', + String(Math.min(params.limit ?? config.QUERY_DEFAULT_LIMIT, config.QUERY_MAX_LIMIT)) + ); + return platformFetch<{ logs: unknown[]; continuationToken?: string }>( + `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}/logs?${qs}`, + { method: 'GET' }, + opts + ); +} + +export async function diagnosticsGetTraces( + sessionId: string, + params: { limit?: number; continuationToken?: string }, + opts: PlatformClientOptions +): Promise<{ traces: unknown[]; continuationToken?: string }> { + const qs = new URLSearchParams(); + qs.set( + 'limit', + String(Math.min(params.limit ?? config.QUERY_DEFAULT_LIMIT, config.QUERY_MAX_LIMIT)) + ); + if (params.continuationToken) qs.set('continuationToken', params.continuationToken); + return platformFetch<{ traces: unknown[]; continuationToken?: string }>( + `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}/traces?${qs}`, + { method: 'GET' }, + opts + ); +} diff --git a/services/mcp-server/src/modules/extraction/extraction-tools.ts b/services/mcp-server/src/modules/extraction/extraction-tools.ts new file mode 100644 index 00000000..7560c84c --- /dev/null +++ b/services/mcp-server/src/modules/extraction/extraction-tools.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import { + extractionRun, + extractionModels, + extractionCacheStats, +} from '../../lib/extraction-client.js'; + +registerTool({ + name: 'extraction.run', + description: + 'Run text extraction using the extraction-service. Returns typed ExtractionItem[] array. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + text: z.string().min(1).describe('Text to extract from'), + taskId: z + .string() + .optional() + .describe('Extraction task ID (e.g. triage, memory-insight, reflection-enrichment)'), + modelId: z.string().optional().describe('Override model ID'), + }), + async execute(args, req) { + return extractionRun(args, { requestId: req.id }); + }, +}); + +registerTool({ + name: 'extraction.models', + description: 'List available extraction model providers. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({}), + async execute(_args, req) { + return extractionModels({ requestId: req.id }); + }, +}); + +registerTool({ + name: 'extraction.cacheStats', + description: 'Get extraction result cache statistics. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({}), + async execute(_args, req) { + return extractionCacheStats({ requestId: req.id }); + }, +}); diff --git a/services/mcp-server/src/modules/platform/diagnostics-tools.ts b/services/mcp-server/src/modules/platform/diagnostics-tools.ts new file mode 100644 index 00000000..ed00e838 --- /dev/null +++ b/services/mcp-server/src/modules/platform/diagnostics-tools.ts @@ -0,0 +1,141 @@ +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import { + diagnosticsListSessions, + diagnosticsCreateSession, + diagnosticsGetSession, + diagnosticsUpdateSession, + diagnosticsGetLogs, + diagnosticsGetTraces, +} from '../../lib/platform-client.js'; +import { config } from '../../lib/config.js'; +import type { McpToolRequest } from '../tools/types.js'; + +const tokenOf = (req: McpToolRequest) => req.headers.authorization?.slice(7); + +registerTool({ + name: 'platform.diagnostics.sessions.list', + description: + 'List remote debug sessions. Filter by productId, status, or targetUserId. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().optional().describe('Filter by product ID'), + status: z + .enum(['pending', 'active', 'paused', 'completed', 'cancelled']) + .optional() + .describe('Filter by session status'), + limit: z.coerce.number().min(1).max(config.QUERY_MAX_LIMIT).optional(), + offset: z.coerce.number().min(0).optional(), + }), + async execute(args, req) { + return diagnosticsListSessions(args, { + token: tokenOf(req), + requestId: req.id, + }); + }, +}); + +registerTool({ + name: 'platform.diagnostics.sessions.create', + description: + 'Create a new remote debug session targeting a user or install ID. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().min(1).describe('Product ID'), + targetUserId: z.string().optional().describe('User ID to target'), + targetAnonymousId: z.string().optional().describe('Anonymous install ID to target'), + collectionLevel: z + .enum(['standard', 'debug', 'trace']) + .optional() + .describe('Collection verbosity'), + captureLogs: z.boolean().optional().describe('Capture log entries'), + captureNetwork: z.boolean().optional().describe('Capture network requests'), + maxDurationMinutes: z.coerce.number().min(5).max(1440).optional().describe('Session TTL'), + }), + async execute(args, req) { + return diagnosticsCreateSession(args, { + token: tokenOf(req), + requestId: req.id, + productId: args.productId, + }); + }, +}); + +registerTool({ + name: 'platform.diagnostics.sessions.get', + description: 'Get details of a specific debug session by ID. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + sessionId: z.string().min(1).describe('Debug session ID'), + }), + async execute(args, req) { + return diagnosticsGetSession(args.sessionId, { + token: tokenOf(req), + requestId: req.id, + }); + }, +}); + +registerTool({ + name: 'platform.diagnostics.sessions.update', + description: + 'Update status or config of a debug session (e.g., pause or complete). Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + sessionId: z.string().min(1).describe('Debug session ID'), + status: z + .enum(['pending', 'active', 'paused', 'completed', 'cancelled']) + .optional() + .describe('New session status'), + collectionLevel: z.enum(['standard', 'debug', 'trace']).optional(), + maxDurationMinutes: z.coerce.number().min(5).max(1440).optional(), + }), + async execute(args, req) { + const { sessionId, ...body } = args; + return diagnosticsUpdateSession(sessionId, body, { + token: tokenOf(req), + requestId: req.id, + }); + }, +}); + +registerTool({ + name: 'platform.diagnostics.sessions.getLogs', + description: 'Retrieve captured log entries for a debug session. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + sessionId: z.string().min(1).describe('Debug session ID'), + level: z + .enum(['debug', 'info', 'warn', 'error', 'fatal']) + .optional() + .describe('Filter by log level'), + from: z.string().optional().describe('ISO 8601 start time'), + to: z.string().optional().describe('ISO 8601 end time'), + limit: z.coerce.number().min(1).max(config.QUERY_MAX_LIMIT).optional(), + }), + async execute(args, req) { + const { sessionId, ...params } = args; + return diagnosticsGetLogs(sessionId, params, { + token: tokenOf(req), + requestId: req.id, + }); + }, +}); + +registerTool({ + name: 'platform.diagnostics.sessions.getTraces', + description: 'Retrieve OpenTelemetry trace spans for a debug session. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + sessionId: z.string().min(1).describe('Debug session ID'), + limit: z.coerce.number().min(1).max(config.QUERY_MAX_LIMIT).optional(), + continuationToken: z.string().optional().describe('Pagination cursor'), + }), + async execute(args, req) { + const { sessionId, ...params } = args; + return diagnosticsGetTraces(sessionId, params, { + token: tokenOf(req), + requestId: req.id, + }); + }, +}); diff --git a/services/mcp-server/src/modules/platform/telemetry-tools.ts b/services/mcp-server/src/modules/platform/telemetry-tools.ts new file mode 100644 index 00000000..86d1faf2 --- /dev/null +++ b/services/mcp-server/src/modules/platform/telemetry-tools.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import { telemetryQuery, telemetryClusters, telemetryMetrics } from '../../lib/platform-client.js'; +import { config } from '../../lib/config.js'; +import type { McpToolRequest } from '../tools/types.js'; + +const tokenOf = (req: McpToolRequest) => req.headers.authorization?.slice(7); + +registerTool({ + name: 'platform.telemetry.query', + description: + 'Query raw telemetry events for a product. Returns paginated event list. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().min(1).describe('Product ID to scope the query'), + eventType: z.string().optional().describe('Filter by event type'), + from: z.string().optional().describe('ISO 8601 start time'), + to: z.string().optional().describe('ISO 8601 end time'), + limit: z.coerce.number().min(1).max(config.QUERY_MAX_LIMIT).optional().describe('Page size'), + continuationToken: z.string().optional().describe('Pagination cursor from previous response'), + }), + async execute(args, req) { + return telemetryQuery(args, { + token: tokenOf(req), + requestId: req.id, + productId: args.productId, + }); + }, +}); + +registerTool({ + name: 'platform.telemetry.clusters', + description: + 'Get error clusters (fingerprinted error groups) for a product. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().min(1).describe('Product ID to scope the query'), + from: z.string().optional().describe('ISO 8601 start time'), + to: z.string().optional().describe('ISO 8601 end time'), + }), + async execute(args, req) { + return telemetryClusters(args, { + token: tokenOf(req), + requestId: req.id, + productId: args.productId, + }); + }, +}); + +registerTool({ + name: 'platform.telemetry.metrics', + description: 'Get ingestion metrics for the telemetry pipeline. Requires admin role.', + requiredRole: 'admin', + inputSchema: z.object({}), + async execute(_args, req) { + return telemetryMetrics({ + token: tokenOf(req), + requestId: req.id, + }); + }, +}); diff --git a/services/mcp-server/src/modules/support/debug-pack.ts b/services/mcp-server/src/modules/support/debug-pack.ts new file mode 100644 index 00000000..a938eaeb --- /dev/null +++ b/services/mcp-server/src/modules/support/debug-pack.ts @@ -0,0 +1,126 @@ +import { z } from 'zod'; +import { registerTool } from '../tools/registry.js'; +import { telemetryClusters, diagnosticsCreateSession } from '../../lib/platform-client.js'; +import type { McpToolRequest } from '../tools/types.js'; + +const tokenOf = (req: McpToolRequest) => req.headers.authorization?.slice(7); + +registerTool({ + name: 'support.createDebugPack', + description: [ + 'Compound tool: gathers telemetry clusters for a product + optionally creates a live diagnostics', + 'session targeting a specific user. Returns a structured debug pack artifact with cluster refs,', + 'optional session ID, and a markdown summary. Requires admin role.', + ].join(' '), + requiredRole: 'admin', + inputSchema: z.object({ + productId: z.string().min(1).describe('Product ID to scope the debug pack'), + targetUserId: z + .string() + .optional() + .describe('User ID to target for a live diagnostics session (omit to skip session creation)'), + targetAnonymousId: z + .string() + .optional() + .describe('Anonymous install ID to target (alternative to targetUserId)'), + from: z.string().optional().describe('ISO 8601 start time for cluster query'), + to: z.string().optional().describe('ISO 8601 end time for cluster query'), + collectionLevel: z + .enum(['standard', 'debug', 'trace']) + .optional() + .describe('Diagnostics session collection level (default: debug)'), + reason: z.string().optional().describe('Human-readable reason for creating this debug pack'), + }), + async execute(args, req) { + const opts = { + token: tokenOf(req), + requestId: req.id, + productId: args.productId, + }; + + const generatedAt = new Date().toISOString(); + + // Step 1: collect error clusters + let clusters: unknown[] = []; + let clusterError: string | undefined; + try { + const result = await telemetryClusters( + { productId: args.productId, from: args.from, to: args.to }, + opts + ); + clusters = result.clusters ?? []; + } catch (err) { + clusterError = err instanceof Error ? err.message : String(err); + } + + // Step 2: optionally create a diagnostics session + let session: { id: string; status: string; expiresAt: string } | undefined; + let sessionError: string | undefined; + const wantsSession = !!(args.targetUserId || args.targetAnonymousId); + if (wantsSession) { + try { + session = await diagnosticsCreateSession( + { + productId: args.productId, + targetUserId: args.targetUserId, + targetAnonymousId: args.targetAnonymousId, + collectionLevel: args.collectionLevel ?? 'debug', + captureLogs: true, + captureNetwork: true, + maxDurationMinutes: 60, + }, + opts + ); + } catch (err) { + sessionError = err instanceof Error ? err.message : String(err); + } + } + + // Step 3: build markdown summary + const lines: string[] = [ + `# Debug Pack — ${args.productId}`, + `**Generated:** ${generatedAt}`, + args.reason ? `**Reason:** ${args.reason}` : '', + '', + `## Error Clusters (${clusters.length})`, + ]; + + if (clusterError) { + lines.push(`> ⚠️ Cluster fetch failed: ${clusterError}`); + } else if (clusters.length === 0) { + lines.push('No error clusters found in the requested time range.'); + } else { + for (const c of clusters.slice(0, 10)) { + const cl = c as { fingerprint?: string; count?: number; lastSeen?: string }; + lines.push( + `- **${cl.fingerprint ?? '(unknown)'}** — ${cl.count ?? '?'} events, last seen ${cl.lastSeen ?? '?'}` + ); + } + if (clusters.length > 10) lines.push(`… and ${clusters.length - 10} more clusters`); + } + + lines.push(''); + lines.push('## Diagnostics Session'); + if (!wantsSession) { + lines.push('No target user specified — diagnostics session not created.'); + } else if (sessionError) { + lines.push(`> ⚠️ Session creation failed: ${sessionError}`); + } else if (session) { + lines.push(`- **Session ID:** ${session.id}`); + lines.push(`- **Status:** ${session.status}`); + lines.push(`- **Expires:** ${session.expiresAt}`); + } + + return { + productId: args.productId, + generatedAt, + reason: args.reason, + clusterCount: clusters.length, + clusterError, + clusters: clusters.slice(0, 10), + session: session ?? null, + sessionError, + summary: lines.filter(l => l !== '').join('\n'), + }; + }, +}); diff --git a/services/mcp-server/src/modules/tools/registry.test.ts b/services/mcp-server/src/modules/tools/registry.test.ts new file mode 100644 index 00000000..67f63758 --- /dev/null +++ b/services/mcp-server/src/modules/tools/registry.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { registerTool, getTool, listTools } from './registry.js'; + +describe('tool registry', () => { + it('registers and retrieves a tool by name', () => { + registerTool({ + name: 'test.hello', + description: 'A test tool', + requiredRole: 'viewer', + inputSchema: z.object({ name: z.string() }), + async execute(args) { + return { hello: args.name }; + }, + }); + const tool = getTool('test.hello'); + expect(tool).toBeDefined(); + expect(tool?.name).toBe('test.hello'); + expect(tool?.requiredRole).toBe('viewer'); + }); + + it('listTools returns all registered tools with meta', () => { + const all = listTools(); + const found = all.find(t => t.name === 'test.hello'); + expect(found).toBeDefined(); + expect(found?.description).toBe('A test tool'); + expect(found?.inputSchema).toHaveProperty('type', 'object'); + }); + + it('getTool returns undefined for unknown tool', () => { + expect(getTool('does.not.exist')).toBeUndefined(); + }); +}); + +describe('tool execute', () => { + it('executes with validated args', async () => { + registerTool({ + name: 'test.echo', + description: 'Echo', + requiredRole: 'admin', + inputSchema: z.object({ value: z.string() }), + async execute(args) { + return { echoed: args.value }; + }, + }); + + const tool = getTool('test.echo')!; + const result = await tool.execute({ value: 'ping' }, {} as never); + expect(result).toEqual({ echoed: 'ping' }); + }); +}); diff --git a/services/mcp-server/src/modules/tools/registry.ts b/services/mcp-server/src/modules/tools/registry.ts new file mode 100644 index 00000000..ede169b0 --- /dev/null +++ b/services/mcp-server/src/modules/tools/registry.ts @@ -0,0 +1,52 @@ +import type { McpTool, McpToolMeta } from './types.js'; +import type { ZodTypeAny } from 'zod'; + +const tools = new Map(); + +export function registerTool(tool: McpTool): void { + tools.set(tool.name, tool); +} + +export function getTool(name: string): McpTool | undefined { + return tools.get(name); +} + +export function listTools(): McpToolMeta[] { + return Array.from(tools.values()).map(t => ({ + name: t.name, + description: t.description, + requiredRole: t.requiredRole, + inputSchema: zodToJsonSchema(t.inputSchema), + })); +} + +/** Minimal Zod → JSON Schema converter (object shapes only — sufficient for tool docs) */ +function zodToJsonSchema(schema: ZodTypeAny): Record { + try { + const def = ( + schema as unknown as { _def: { typeName: string; shape?: () => Record } } + )._def; + if (def.typeName === 'ZodObject' && def.shape) { + const shape = def.shape(); + const properties: Record = {}; + const required: string[] = []; + for (const [key, field] of Object.entries(shape)) { + const fieldDef = ( + field as unknown as { + _def: { typeName: string; description?: string; innerType?: unknown }; + } + )._def; + const isOptional = fieldDef.typeName === 'ZodOptional'; + properties[key] = { + type: 'string', + description: fieldDef.description ?? key, + }; + if (!isOptional) required.push(key); + } + return { type: 'object', properties, required }; + } + } catch { + // fall through + } + return { type: 'object' }; +} diff --git a/services/mcp-server/src/modules/tools/routes.ts b/services/mcp-server/src/modules/tools/routes.ts new file mode 100644 index 00000000..c0e11771 --- /dev/null +++ b/services/mcp-server/src/modules/tools/routes.ts @@ -0,0 +1,58 @@ +import type { FastifyInstance } from 'fastify'; +import { requireRole } from '../../lib/auth.js'; +import { listTools, getTool } from './registry.js'; +import type { McpCallRequest, McpCallResponse, McpToolRequest } from './types.js'; + +export async function toolRoutes(app: FastifyInstance) { + /** List all registered tools */ + app.get('/tools', async req => { + requireRole(req, 'viewer'); + return { tools: listTools() }; + }); + + /** Execute a tool by name */ + app.post('/tools/call', async (req, reply): Promise => { + const body = req.body as McpCallRequest; + + if (!body?.name) { + return reply.status(400).send({ error: 'Missing tool name' }) as never; + } + + const tool = getTool(body.name); + if (!tool) { + return reply.status(404).send({ error: `Tool '${body.name}' not found` }) as never; + } + + // Role check + requireRole(req, tool.requiredRole); + + // Validate input + const parsed = tool.inputSchema.safeParse(body.arguments ?? {}); + if (!parsed.success) { + return reply.status(422).send({ + error: 'Invalid tool arguments', + details: parsed.error.flatten(), + }) as never; + } + + // Audit log + req.log.info( + { tool: body.name, userId: req.jwtPayload?.sub, requestId: req.id }, + 'mcp tool call' + ); + + try { + const result = await tool.execute(parsed.data, req as unknown as McpToolRequest); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + req.log.error({ tool: body.name, err }, 'mcp tool error'); + return { + content: [{ type: 'text', text: `Error: ${message}` }], + isError: true, + }; + } + }); +} diff --git a/services/mcp-server/src/modules/tools/types.ts b/services/mcp-server/src/modules/tools/types.ts new file mode 100644 index 00000000..c5d51320 --- /dev/null +++ b/services/mcp-server/src/modules/tools/types.ts @@ -0,0 +1,52 @@ +import type { ZodTypeAny, z } from 'zod'; + +/** + * Minimal request context passed to tool execute functions. + * Avoids a direct FastifyRequest dependency inside tool modules. + */ +export interface McpToolRequest { + /** Fastify request ID */ + id: string; + /** HTTP headers */ + headers: { authorization?: string | undefined }; + /** JWT payload decoded by the auth hook */ + jwtPayload?: { sub?: string; role?: string; productId?: string }; +} + +/** + * A single MCP tool definition. + * inputSchema is a Zod object schema whose shape describes the tool's arguments. + */ +export interface McpTool { + /** Fully-qualified tool name, e.g. "platform.telemetry.query" */ + name: string; + description: string; + /** Minimum role required to call this tool */ + requiredRole: 'viewer' | 'admin' | 'super_admin'; + /** Zod schema for input validation */ + inputSchema: TInput; + /** Execute the tool. Must return a JSON-serialisable value. */ + execute(args: z.infer, req: McpToolRequest): Promise; +} + +/** What we return for tools/list */ +export interface McpToolMeta { + name: string; + description: string; + requiredRole: string; + inputSchema: Record; +} + +/** tools/call request body */ +export interface McpCallRequest { + name: string; + arguments: Record; + /** Optional caller-supplied request ID; falls back to req.id */ + requestId?: string; +} + +/** tools/call response (MCP content format) */ +export interface McpCallResponse { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} diff --git a/services/mcp-server/src/server.ts b/services/mcp-server/src/server.ts new file mode 100644 index 00000000..21202ffb --- /dev/null +++ b/services/mcp-server/src/server.ts @@ -0,0 +1,44 @@ +/** + * ByteLyst MCP Server — port 4006 + * + * Exposes tool namespaces: + * platform.telemetry.* — query events, clusters, metrics + * platform.diagnostics.* — manage debug sessions, read logs/traces + * extraction.* — run extraction, list models, cache stats + * support.* — compound tools (createDebugPack) + * + * Auth: JWT Bearer tokens issued by platform-service (same JWT_SECRET). + * Role gating: viewer / admin / super_admin per tool. + */ + +import { createServiceApp, startService } from '@bytelyst/fastify-core'; +import { config } from './lib/config.js'; +import { parseJwt } from './lib/auth.js'; +import { toolRoutes } from './modules/tools/routes.js'; + +// Register all tool namespaces (side-effect: populates the tool registry) +import './modules/platform/telemetry-tools.js'; +import './modules/platform/diagnostics-tools.js'; +import './modules/extraction/extraction-tools.js'; +import './modules/support/debug-pack.js'; + +const app = await createServiceApp({ + name: 'mcp-server', + version: '0.1.0', + description: + 'ByteLyst MCP Server — platform.telemetry.*, platform.diagnostics.*, extraction.*, support.*', + corsOrigin: config.CORS_ORIGIN, + swagger: { + title: 'ByteLyst MCP Server', + description: 'MCP tool execution endpoint for the ByteLyst platform', + port: config.PORT, + }, +}); + +// Parse JWT on every request (best-effort) +app.addHook('onRequest', parseJwt); + +// Register tool routes +await app.register(toolRoutes, { prefix: '/api' }); + +await startService(app, { port: config.PORT, host: config.HOST }); diff --git a/services/mcp-server/tsconfig.json b/services/mcp-server/tsconfig.json new file mode 100644 index 00000000..18b7457e --- /dev/null +++ b/services/mcp-server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "dist"] +} diff --git a/services/mcp-server/vitest.config.ts b/services/mcp-server/vitest.config.ts new file mode 100644 index 00000000..8e730d50 --- /dev/null +++ b/services/mcp-server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +});