feat(mcp-server): scaffold @bytelyst/mcp-server on port 4006 with 10 tools across 4 namespaces
- platform.telemetry.{query,clusters,metrics}
- platform.diagnostics.sessions.{list,create,get,update,getLogs,getTraces}
- extraction.{run,models,cacheStats}
- support.createDebugPack (compound: clusters + optional diag session + markdown summary)
- Role-gated (viewer/admin/super_admin), JWT auth via platform-service secret
- Proxies to platform-service:4003 and extraction-service:4005
- 4 Vitest tests passing, tsc --noEmit clean
This commit is contained in:
parent
ee52da219f
commit
027e2163a0
14
services/mcp-server/.env.example
Normal file
14
services/mcp-server/.env.example
Normal file
@ -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=
|
||||
3
services/mcp-server/.gitignore
vendored
Normal file
3
services/mcp-server/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.env
|
||||
28
services/mcp-server/package.json
Normal file
28
services/mcp-server/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
55
services/mcp-server/src/lib/auth.ts
Normal file
55
services/mcp-server/src/lib/auth.ts
Normal file
@ -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<void> {
|
||||
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;
|
||||
}
|
||||
18
services/mcp-server/src/lib/config.ts
Normal file
18
services/mcp-server/src/lib/config.ts
Normal file
@ -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);
|
||||
63
services/mcp-server/src/lib/extraction-client.ts
Normal file
63
services/mcp-server/src/lib/extraction-client.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { config } from './config.js';
|
||||
|
||||
export interface ExtractionItem {
|
||||
extraction_class: string;
|
||||
extraction_text: string;
|
||||
attributes?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<ExtractionResult> {
|
||||
const url = `${config.EXTRACTION_SERVICE_URL}/api/extract`;
|
||||
const headers: Record<string, string> = {
|
||||
'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<ExtractionResult>;
|
||||
}
|
||||
|
||||
export async function extractionModels(opts: { requestId?: string }): Promise<unknown> {
|
||||
const url = `${config.EXTRACTION_SERVICE_URL}/api/extract/models`;
|
||||
const headers: Record<string, string> = {
|
||||
...(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<unknown> {
|
||||
const url = `${config.EXTRACTION_SERVICE_URL}/api/extract/cache-stats`;
|
||||
const headers: Record<string, string> = {
|
||||
...(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();
|
||||
}
|
||||
210
services/mcp-server/src/lib/platform-client.ts
Normal file
210
services/mcp-server/src/lib/platform-client.ts
Normal file
@ -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<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
opts: PlatformClientOptions
|
||||
): Promise<T> {
|
||||
const url = `${config.PLATFORM_SERVICE_URL}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
'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<T>;
|
||||
}
|
||||
|
||||
// ── 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<TelemetryQueryResult> {
|
||||
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<TelemetryQueryResult>(`/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<unknown> {
|
||||
return platformFetch<unknown>('/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<DebugSession> {
|
||||
return platformFetch<DebugSession>(
|
||||
'/api/diagnostics/sessions',
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
opts
|
||||
);
|
||||
}
|
||||
|
||||
export async function diagnosticsGetSession(
|
||||
sessionId: string,
|
||||
opts: PlatformClientOptions
|
||||
): Promise<DebugSession> {
|
||||
return platformFetch<DebugSession>(
|
||||
`/api/diagnostics/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ method: 'GET' },
|
||||
opts
|
||||
);
|
||||
}
|
||||
|
||||
export async function diagnosticsUpdateSession(
|
||||
sessionId: string,
|
||||
body: { status?: string; collectionLevel?: string; maxDurationMinutes?: number },
|
||||
opts: PlatformClientOptions
|
||||
): Promise<DebugSession> {
|
||||
return platformFetch<DebugSession>(
|
||||
`/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
|
||||
);
|
||||
}
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
141
services/mcp-server/src/modules/platform/diagnostics-tools.ts
Normal file
141
services/mcp-server/src/modules/platform/diagnostics-tools.ts
Normal file
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
61
services/mcp-server/src/modules/platform/telemetry-tools.ts
Normal file
61
services/mcp-server/src/modules/platform/telemetry-tools.ts
Normal file
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
126
services/mcp-server/src/modules/support/debug-pack.ts
Normal file
126
services/mcp-server/src/modules/support/debug-pack.ts
Normal file
@ -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'),
|
||||
};
|
||||
},
|
||||
});
|
||||
51
services/mcp-server/src/modules/tools/registry.test.ts
Normal file
51
services/mcp-server/src/modules/tools/registry.test.ts
Normal file
@ -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' });
|
||||
});
|
||||
});
|
||||
52
services/mcp-server/src/modules/tools/registry.ts
Normal file
52
services/mcp-server/src/modules/tools/registry.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import type { McpTool, McpToolMeta } from './types.js';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
const tools = new Map<string, McpTool>();
|
||||
|
||||
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<string, unknown> {
|
||||
try {
|
||||
const def = (
|
||||
schema as unknown as { _def: { typeName: string; shape?: () => Record<string, ZodTypeAny> } }
|
||||
)._def;
|
||||
if (def.typeName === 'ZodObject' && def.shape) {
|
||||
const shape = def.shape();
|
||||
const properties: Record<string, unknown> = {};
|
||||
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' };
|
||||
}
|
||||
58
services/mcp-server/src/modules/tools/routes.ts
Normal file
58
services/mcp-server/src/modules/tools/routes.ts
Normal file
@ -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<McpCallResponse> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
52
services/mcp-server/src/modules/tools/types.ts
Normal file
52
services/mcp-server/src/modules/tools/types.ts
Normal file
@ -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<TInput extends ZodTypeAny = ZodTypeAny> {
|
||||
/** 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<TInput>, req: McpToolRequest): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** What we return for tools/list */
|
||||
export interface McpToolMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
requiredRole: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** tools/call request body */
|
||||
export interface McpCallRequest {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
/** 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;
|
||||
}
|
||||
44
services/mcp-server/src/server.ts
Normal file
44
services/mcp-server/src/server.ts
Normal file
@ -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 });
|
||||
9
services/mcp-server/tsconfig.json
Normal file
9
services/mcp-server/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist"]
|
||||
}
|
||||
8
services/mcp-server/vitest.config.ts
Normal file
8
services/mcp-server/vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user