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:
saravanakumardb1 2026-03-05 11:47:08 -08:00
parent ee52da219f
commit 027e2163a0
18 changed files with 1038 additions and 0 deletions

View 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
View File

@ -0,0 +1,3 @@
dist/
node_modules/
.env

View 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"
}
}

View 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;
}

View 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);

View 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();
}

View 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
);
}

View File

@ -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 });
},
});

View 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,
});
},
});

View 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,
});
},
});

View 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'),
};
},
});

View 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' });
});
});

View 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' };
}

View 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,
};
}
});
}

View 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;
}

View 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 });

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist"]
}

View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});