UNPKG

@dudousxd/nestjs-telescope

Version:

Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.

327 lines 14.3 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; // packages/core/src/nest/telescope-mcp.controller.ts import { Body, Controller, Delete, Get, HttpCode, Inject, MethodNotAllowedException, Post, Req, } from '@nestjs/common'; import { durationToMs } from '../config/parse-duration.js'; import { EntryType } from '../entry/entry.js'; import { PulseService } from '../pulse/pulse.service.js'; import { TELESCOPE_OPTIONS, TELESCOPE_STORAGE } from './telescope.options.js'; import { TelescopeService } from './telescope.service.js'; const PROTOCOL_VERSION = '2025-06-18'; const STATS_WINDOW = '1h'; const DEFAULT_LIST_LIMIT = 20; const MAX_LIST_LIMIT = 100; /** Tool catalogue mirrored from the sibling adonis-telescope MCP server. */ const TOOLS = [ { name: 'list_entries', description: 'List recent telescope entries (requests, queries, exceptions, jobs, logs, etc). Filter by type, full-text search, tag or time window. Returns newest first.', inputSchema: { type: 'object', properties: { type: { type: 'string', description: 'Entry type: request, query, exception, client_exception, log, event, mail, job, model, cache, http_client, redis, dump…', }, search: { type: 'string', description: 'Full-text search inside entry content' }, tag: { type: 'string', description: 'Exact tag match, e.g. "status:500" or "slow"' }, sinceMinutes: { type: 'number', description: 'Only entries from the last N minutes' }, limit: { type: 'number', description: 'Max entries (default 20, max 100)' }, }, }, }, { name: 'get_entry', description: 'Get one entry by id with its full batch — every other entry recorded during the same request/job (queries, logs, renders…), i.e. the waterfall.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Entry id' } }, required: ['id'], }, }, { name: 'get_batch', description: 'Get every entry of a batch (one request/job execution) ordered by sequence.', inputSchema: { type: 'object', properties: { batchId: { type: 'string', description: 'Batch id' } }, required: ['batchId'], }, }, { name: 'get_stats', description: 'Aggregate health snapshot of the last hour: request throughput + percentiles, slowest requests and queries, N+1 suspects, exception families, per-route aggregates, cache hit rate, telescope health.', inputSchema: { type: 'object', properties: {} }, }, { name: 'diagnose_exception', description: 'Run the AI diagnosis on an exception entry (probable cause, where to look, suggested fix). Only available when AI is configured.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Exception entry id' } }, required: ['id'], }, }, ]; /** * MCP (Model Context Protocol) server — stateless JSON-RPC over streamable HTTP — * so coding agents (Claude Code, Cursor, …) can debug straight from the captured * data: "why is POST /checkout slow?" → the agent pulls the batch waterfall with * every query. Hand-rolled JSON-RPC (no SDK dependency), backed by the same * storage / pulse / diagnosis APIs as the dashboard. * * Auth is a Bearer token (`mcp: { token }`); without one the endpoint is allowed * only when `NODE_ENV !== 'production'`. Carries NO `@UseGuards(TelescopeGuard)`: * the cookie-session dashboard gate doesn't apply to a header-only agent client, * so this controller enforces its own Bearer check. NestJS has no CSRF guard by * default, so there is nothing CSRF-like to bypass. */ let TelescopeMcpController = class TelescopeMcpController { storage; service; pulse; options; constructor(storage, service, pulse, options) { this.storage = storage; this.service = service; this.pulse = pulse; this.options = options; } // The MCP streamable-HTTP transport opens a GET stream for server→client // notifications; this stateless server has none, so 405 (per the spec). getStream() { throw new MethodNotAllowedException('MCP server is stateless; GET is not supported.'); } // DELETE terminates a session; this stateless server holds none, so 200 (no-op). deleteSession() { return { ok: true }; } async rpc(request, body) { if (!this.isAuthorized(request)) { // JSON-RPC has no transport-level 401; surface the denial as an RPC error // with the conventional id echo so a compliant client can read it. return this.fail(body?.id, -32001, 'Unauthorized: a valid Bearer token is required.'); } return this.handle(body); } /** * Bearer-token gate. With a configured token, the request MUST carry a matching * `Authorization: Bearer <token>` header. Without a token, allow only when * `NODE_ENV !== 'production'` (mirroring the default-open-in-dev dashboard * authorizer); a tokenless config in production is refused. */ isAuthorized(request) { const token = this.configuredToken(); if (token === null) { return process.env.NODE_ENV !== 'production'; } const header = readAuthorizationHeader(request); if (header === null) return false; const match = /^Bearer\s+(.+)$/i.exec(header.trim()); return match !== null && match[1] === token; } /** The configured Bearer token, or `null` when `mcp` is `true`/disabled. */ configuredToken() { const mcp = this.options.mcp; if (mcp === undefined || mcp === false || mcp === true) return null; return mcp.token ?? null; } async handle(body) { const { id, method, params } = body ?? {}; try { switch (method) { case 'initialize': return this.respond(id, { protocolVersion: params?.protocolVersion ?? PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { name: 'nestjs-telescope', version: '0.0.0' }, }); case 'notifications/initialized': case 'notifications/cancelled': // Notifications (no id) get no response body. return null; case 'ping': return this.respond(id, {}); case 'tools/list': return this.respond(id, { tools: TOOLS }); case 'tools/call': { const text = await this.callTool(params?.name, (params?.arguments ?? {})); return this.respond(id, { content: [{ type: 'text', text }] }); } default: return this.fail(id, -32601, `Method not found: ${String(method)}`); } } catch (error) { return this.fail(id, -32603, error instanceof Error ? error.message : String(error)); } } respond(id, result) { return id === undefined || id === null ? null : { jsonrpc: '2.0', id, result }; } fail(id, code, message) { return id === undefined || id === null ? null : { jsonrpc: '2.0', id, error: { code, message } }; } async callTool(name, args) { switch (name) { case 'list_entries': { const query = { ...(args.type !== undefined ? { type: args.type } : {}), ...(args.search !== undefined && args.search !== '' ? { search: args.search } : {}), ...(args.tag !== undefined ? { tag: args.tag } : {}), ...(typeof args.sinceMinutes === 'number' ? { after: new Date(Date.now() - args.sinceMinutes * 60_000) } : {}), limit: clampLimit(args.limit), }; const page = await this.storage.get(query); return JSON.stringify({ entries: page.data.map(slim) }, null, 2); } case 'get_entry': { if (typeof args.id !== 'string') throw new Error('`id` is required.'); const entry = await this.storage.find(args.id); if (!entry) return 'Entry not found (it may have been pruned).'; const { batch, ...rest } = entry; return JSON.stringify({ entry: rest, batch: batch.map(slim) }, null, 2); } case 'get_batch': { if (typeof args.batchId !== 'string') throw new Error('`batchId` is required.'); const batch = await this.storage.batch(args.batchId); if (batch.length === 0) return 'Batch not found.'; return JSON.stringify({ entries: batch }, null, 2); } case 'get_stats': { const stats = await this.pulse.getHealth(durationToMs(STATS_WINDOW)); return JSON.stringify(stats, null, 2); } case 'diagnose_exception': { if (typeof args.id !== 'string') throw new Error('`id` is required.'); const coordinator = this.service.diagnosisCoordinator; if (coordinator === null) return 'AI diagnosis is not configured on this telescope.'; const entry = await this.storage.find(args.id); if (entry === null || (entry.type !== EntryType.Exception && entry.type !== EntryType.ClientException)) { return 'No exception entry with that id.'; } const result = await coordinator.diagnose(entry, 1, false); return result.markdown; } default: throw new Error(`Unknown tool: ${String(name)}`); } } }; __decorate([ Get(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", void 0) ], TelescopeMcpController.prototype, "getStream", null); __decorate([ Delete(), HttpCode(200), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Object) ], TelescopeMcpController.prototype, "deleteSession", null); __decorate([ Post(), HttpCode(200), __param(0, Req()), __param(1, Body()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object, Object]), __metadata("design:returntype", Promise) ], TelescopeMcpController.prototype, "rpc", null); TelescopeMcpController = __decorate([ Controller(), __param(0, Inject(TELESCOPE_STORAGE)), __param(1, Inject(TelescopeService)), __param(2, Inject(PulseService)), __param(3, Inject(TELESCOPE_OPTIONS)), __metadata("design:paramtypes", [Object, TelescopeService, PulseService, Object]) ], TelescopeMcpController); export { TelescopeMcpController }; function clampLimit(limit) { if (typeof limit !== 'number' || !Number.isFinite(limit)) return DEFAULT_LIST_LIMIT; return Math.max(1, Math.min(limit, MAX_LIST_LIMIT)); } /** Compact projection for list payloads — agents drill in via get_entry. */ function slim(entry) { const c = (entry.content ?? {}); let summary; switch (entry.type) { case EntryType.Request: summary = `${String(c.method)} ${String(c.uri)}${String(c.statusCode)}`; break; case EntryType.Query: summary = String(c.sql ?? '').slice(0, 200); break; case EntryType.Exception: case EntryType.ClientException: summary = `${String(c.class ?? c.name)}: ${String(c.message)}`; break; case EntryType.Log: summary = `${String(c.level)}: ${String(c.message)}`; break; case EntryType.Job: summary = `${String(c.name)} (${String(c.status)})`; break; default: summary = safeSummary(c, entry.type); } return { id: entry.id, type: entry.type, batchId: entry.batchId, durationMs: entry.durationMs, createdAt: entry.createdAt instanceof Date ? entry.createdAt.toISOString() : entry.createdAt, tags: entry.tags, summary, }; } function safeSummary(content, type) { try { return JSON.stringify(content).slice(0, 200); } catch { return type; } } /** Read the `Authorization` header off an Express/Fastify request, or `null`. */ function readAuthorizationHeader(request) { if (typeof request !== 'object' || request === null) return null; const headers = request.headers; if (typeof headers !== 'object' || headers === null) return null; const value = headers.authorization; if (typeof value === 'string') return value; if (Array.isArray(value) && typeof value[0] === 'string') return value[0]; return null; } //# sourceMappingURL=telescope-mcp.controller.js.map