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