@firetrace/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bill Zhang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @firetrace/mcp
2
+
3
+ Model Context Protocol server for [FireTrace](https://github.com/IdkwhatImD0ing/FireTrace). Lets an AI agent list, inspect, record, and delete LLM traces through a scoped project API key.
4
+
5
+ ## stdio bridge
6
+
7
+ ```bash
8
+ FIRETRACE_ENDPOINT=https://your-deployment.vercel.app \
9
+ FIRETRACE_API_KEY=ft_live_... \
10
+ npx -y @firetrace/mcp
11
+ ```
12
+
13
+ The bridge validates the key against `GET /api/v1/key`, then exposes only the tools the key's scopes allow (`traces:read`, `traces:write`, `traces:delete`). Diagnostics go to stderr; stdout is the MCP stream.
14
+
15
+ Most clients can instead talk to the deployment directly at `POST /api/mcp` with the same bearer key. See `docs/mcp.md` in the repository for client configuration and the tool reference.
16
+
17
+ ## Library use
18
+
19
+ ```ts
20
+ import { createFireTraceMcpServer, HttpBackend } from "@firetrace/mcp";
21
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
+
23
+ const backend = new HttpBackend({
24
+ endpoint: "https://your-deployment.vercel.app",
25
+ apiKey: "ft_live_...",
26
+ });
27
+ await backend.init();
28
+ const server = createFireTraceMcpServer(backend);
29
+ await server.connect(new StdioServerTransport());
30
+ ```
31
+
32
+ Implement `TraceBackend` to put the same tools over any other store.
33
+
34
+ MIT.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // stdio MCP server bridging to a FireTrace deployment over its REST API.
3
+ // Configure with FIRETRACE_ENDPOINT and FIRETRACE_API_KEY.
4
+ import "../dist/cli.js";
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The storage-agnostic contract the MCP tools are built on. The dashboard
3
+ * implements it directly on Firestore (remote MCP endpoint); the stdio CLI
4
+ * implements it over the REST API. Shapes mirror the REST responses.
5
+ */
6
+ export type KeyScope = "traces:write" | "traces:read" | "traces:delete";
7
+ export interface ListTracesQuery {
8
+ status?: string;
9
+ model?: string;
10
+ /** Exact trace name. */
11
+ name?: string;
12
+ /** One tag the trace must carry. */
13
+ tag?: string;
14
+ /** Environment stamped from the ingesting key, or `unassigned` for traces without one. */
15
+ environment?: string;
16
+ sessionId?: string;
17
+ userId?: string;
18
+ /** newest (default), slowest or costliest; the latter two only with status/model/name/tag/environment. */
19
+ sort?: "newest" | "slowest" | "costliest";
20
+ /** Inclusive ISO-8601 lower bound on startedAt. */
21
+ from?: string;
22
+ /** Inclusive ISO-8601 upper bound on startedAt. */
23
+ to?: string;
24
+ limit?: number;
25
+ /** nextCursor from a previous page. */
26
+ cursor?: string;
27
+ }
28
+ export interface UsageLike {
29
+ inputTokens?: number;
30
+ outputTokens?: number;
31
+ totalTokens?: number;
32
+ }
33
+ export interface TraceSummaryLike {
34
+ id: string;
35
+ name: string;
36
+ status: string;
37
+ /** Copied from the ingesting key; null = unassigned. */
38
+ environment?: string | null;
39
+ startedAt: string;
40
+ /** Null while the trace is still running (status `running`). */
41
+ endedAt: string | null;
42
+ durationMs: number | null;
43
+ provider?: string | null;
44
+ model?: string | null;
45
+ sessionId?: string | null;
46
+ userId?: string | null;
47
+ tags?: string[];
48
+ usage?: UsageLike;
49
+ costUsd?: number | null;
50
+ spanCount: number;
51
+ errorCount: number;
52
+ /** Newest score per name. */
53
+ scores?: Record<string, {
54
+ value: number | string | boolean;
55
+ dataType: string;
56
+ }>;
57
+ }
58
+ export interface TracePageLike {
59
+ traces: TraceSummaryLike[];
60
+ nextCursor: string | null;
61
+ prevCursor?: string | null;
62
+ pageSize?: number;
63
+ }
64
+ export interface SpanLike {
65
+ id: string;
66
+ parentSpanId: string | null;
67
+ name: string;
68
+ kind: string;
69
+ status: string;
70
+ startedAt: string;
71
+ endedAt: string;
72
+ durationMs: number;
73
+ provider?: string | null;
74
+ model?: string | null;
75
+ input?: unknown;
76
+ output?: unknown;
77
+ attributes?: Record<string, unknown>;
78
+ events?: Array<{
79
+ name: string;
80
+ timestamp: string;
81
+ attributes?: unknown;
82
+ }>;
83
+ usage?: UsageLike | null;
84
+ costUsd?: number | null;
85
+ }
86
+ export interface TraceDetailLike {
87
+ trace: TraceSummaryLike & {
88
+ input?: unknown;
89
+ output?: unknown;
90
+ metadata?: unknown;
91
+ };
92
+ spans: SpanLike[];
93
+ /** Every score of the trace, newest first. */
94
+ scores?: ScoreLike[];
95
+ }
96
+ /** A judgement attached to a trace after the run: a rating, a verdict, an eval result. */
97
+ export interface ScoreInputLike {
98
+ /** Letters, digits, '_' and '-', at most 64 characters. */
99
+ name: string;
100
+ dataType: "numeric" | "categorical" | "boolean";
101
+ /** A number for numeric, a string for categorical, a boolean for boolean. */
102
+ value: number | string | boolean;
103
+ comment?: string;
104
+ spanId?: string;
105
+ }
106
+ export interface ScoreLike {
107
+ id: string;
108
+ traceId: string;
109
+ spanId?: string | null;
110
+ name: string;
111
+ dataType: string;
112
+ value: number | string | boolean;
113
+ comment?: string | null;
114
+ source: string;
115
+ evaluatorId?: string | null;
116
+ createdAt: string;
117
+ }
118
+ export interface ListScoresQuery {
119
+ /** Only this trace's scores (its full history, newest first). */
120
+ traceId?: string;
121
+ /** Only scores with this name. */
122
+ name?: string;
123
+ /** Only scores whose trace is in this environment (`unassigned` for none); ignored with traceId. */
124
+ environment?: string;
125
+ limit?: number;
126
+ /** nextCursor from a previous page. */
127
+ cursor?: string;
128
+ }
129
+ export interface ScorePageLike {
130
+ scores: ScoreLike[];
131
+ nextCursor: string | null;
132
+ }
133
+ export interface ProjectLike {
134
+ id: string;
135
+ name: string;
136
+ slug?: string;
137
+ description?: string;
138
+ traceCount: number;
139
+ spanCount: number;
140
+ estimatedBytes: number;
141
+ lastTraceAt: string | null;
142
+ createdAt?: string;
143
+ storage?: {
144
+ limitBytes: number;
145
+ level: string;
146
+ };
147
+ }
148
+ export interface RecordResult {
149
+ ok: boolean;
150
+ traceId: string;
151
+ spanCount: number;
152
+ duplicate: boolean;
153
+ }
154
+ export interface MetadataPatchResult {
155
+ traceId: string;
156
+ /** The full merged metadata. */
157
+ metadata: Record<string, unknown>;
158
+ /** False when the merge matched what was stored; nothing was written. */
159
+ changed: boolean;
160
+ }
161
+ export interface TraceBackend {
162
+ /** Scopes carried by the authenticated key; decides which tools are registered. */
163
+ readonly scopes: readonly string[];
164
+ readonly projectId: string;
165
+ getProject(): Promise<ProjectLike>;
166
+ listTraces(query: ListTracesQuery): Promise<TracePageLike>;
167
+ /** Null when the trace does not exist in this project. */
168
+ getTrace(traceId: string): Promise<TraceDetailLike | null>;
169
+ /** Body in the ingestion format `{ schemaVersion: 1, trace }`. */
170
+ recordTrace(body: unknown): Promise<RecordResult>;
171
+ /**
172
+ * Shallow-merge keys into a stored trace's metadata, the one mutable part of
173
+ * a trace. Throws a BackendError with status 404 when it does not exist.
174
+ */
175
+ patchTraceMetadata(traceId: string, metadata: Record<string, unknown>): Promise<MetadataPatchResult>;
176
+ /** Throws a BackendError with status 404 when the trace does not exist. */
177
+ deleteTrace(traceId: string): Promise<void>;
178
+ /** Attach a score to a stored trace. Throws a BackendError with status 404 when it does not exist. */
179
+ addScore(traceId: string, input: ScoreInputLike): Promise<ScoreLike>;
180
+ /** Scores of one trace, or across the project, newest first. */
181
+ listScores(query: ListScoresQuery): Promise<ScorePageLike>;
182
+ /** JSON Schema for the ingestion request body. */
183
+ ingestSchema(): Promise<unknown>;
184
+ }
185
+ /** Error carrying an HTTP-style status and a stable code; tools render it verbatim. */
186
+ export declare class BackendError extends Error {
187
+ readonly status: number;
188
+ readonly code: string;
189
+ constructor(status: number, code: string, message: string);
190
+ }
191
+ export declare function hasScope(backend: TraceBackend, scope: KeyScope): boolean;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The storage-agnostic contract the MCP tools are built on. The dashboard
3
+ * implements it directly on Firestore (remote MCP endpoint); the stdio CLI
4
+ * implements it over the REST API. Shapes mirror the REST responses.
5
+ */
6
+ /** Error carrying an HTTP-style status and a stable code; tools render it verbatim. */
7
+ export class BackendError extends Error {
8
+ status;
9
+ code;
10
+ constructor(status, code, message) {
11
+ super(message);
12
+ this.status = status;
13
+ this.code = code;
14
+ this.name = "BackendError";
15
+ }
16
+ }
17
+ export function hasScope(backend, scope) {
18
+ return backend.scopes.includes(scope);
19
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,30 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { HttpBackend } from "./http-backend.js";
3
+ import { createFireTraceMcpServer } from "./server.js";
4
+ /**
5
+ * stdio entry point: `FIRETRACE_ENDPOINT=https://... FIRETRACE_API_KEY=ft_live_... firetrace-mcp`
6
+ * Everything on stdout is the MCP stream; diagnostics go to stderr.
7
+ */
8
+ async function main() {
9
+ const endpoint = process.env.FIRETRACE_ENDPOINT?.trim();
10
+ const apiKey = process.env.FIRETRACE_API_KEY?.trim();
11
+ if (!endpoint || !apiKey) {
12
+ console.error("firetrace-mcp: set FIRETRACE_ENDPOINT (deployment origin) and FIRETRACE_API_KEY (ft_live_...).");
13
+ process.exit(2);
14
+ }
15
+ const backend = new HttpBackend({ endpoint, apiKey });
16
+ try {
17
+ const info = await backend.init();
18
+ console.error(`firetrace-mcp: key ${info.keyId} → project ${info.projectId} (scopes: ${info.scopes.join(", ")})`);
19
+ }
20
+ catch (err) {
21
+ console.error(`firetrace-mcp: could not validate the key against ${endpoint}: ${err.message}`);
22
+ process.exit(1);
23
+ }
24
+ const server = createFireTraceMcpServer(backend, { version: "0.1.0" });
25
+ await server.connect(new StdioServerTransport());
26
+ }
27
+ main().catch((err) => {
28
+ console.error(`firetrace-mcp: ${err.stack ?? err}`);
29
+ process.exit(1);
30
+ });
@@ -0,0 +1,46 @@
1
+ import { type ListScoresQuery, type ListTracesQuery, type MetadataPatchResult, type ProjectLike, type RecordResult, type ScoreInputLike, type ScoreLike, type ScorePageLike, type TraceBackend, type TraceDetailLike, type TracePageLike } from "./backend.ts";
2
+ export interface HttpBackendOptions {
3
+ /** Deployment origin, e.g. https://tracing.art3m1s.me */
4
+ endpoint: string;
5
+ /** ft_live_... key. Its scopes decide which tools the server exposes. */
6
+ apiKey: string;
7
+ fetch?: typeof fetch;
8
+ /** Per-request timeout in milliseconds (default 30 000). */
9
+ timeoutMs?: number;
10
+ }
11
+ interface KeyInfo {
12
+ keyId: string;
13
+ projectId: string;
14
+ scopes: string[];
15
+ expiresAt: string | null;
16
+ /** Absent on deployments that predate environments. */
17
+ environment?: string | null;
18
+ }
19
+ /**
20
+ * TraceBackend over the REST API. Used by the stdio CLI so any MCP client
21
+ * (Claude Desktop, Cursor, Claude Code...) can talk to a deployment without
22
+ * Firebase credentials.
23
+ */
24
+ export declare class HttpBackend implements TraceBackend {
25
+ private readonly endpoint;
26
+ private readonly apiKey;
27
+ private readonly fetchImpl;
28
+ private readonly timeoutMs;
29
+ private info;
30
+ constructor(options: HttpBackendOptions);
31
+ /** Validate the key and learn its scopes. Must run before building the server. */
32
+ init(): Promise<KeyInfo>;
33
+ get scopes(): readonly string[];
34
+ get projectId(): string;
35
+ getProject(): Promise<ProjectLike>;
36
+ listTraces(query: ListTracesQuery): Promise<TracePageLike>;
37
+ getTrace(traceId: string): Promise<TraceDetailLike | null>;
38
+ recordTrace(body: unknown): Promise<RecordResult>;
39
+ patchTraceMetadata(traceId: string, metadata: Record<string, unknown>): Promise<MetadataPatchResult>;
40
+ deleteTrace(traceId: string): Promise<void>;
41
+ addScore(traceId: string, input: ScoreInputLike): Promise<ScoreLike>;
42
+ listScores(query: ListScoresQuery): Promise<ScorePageLike>;
43
+ ingestSchema(): Promise<unknown>;
44
+ private request;
45
+ }
46
+ export {};
@@ -0,0 +1,132 @@
1
+ import { BackendError, } from "./backend.js";
2
+ /**
3
+ * TraceBackend over the REST API. Used by the stdio CLI so any MCP client
4
+ * (Claude Desktop, Cursor, Claude Code...) can talk to a deployment without
5
+ * Firebase credentials.
6
+ */
7
+ export class HttpBackend {
8
+ endpoint;
9
+ apiKey;
10
+ fetchImpl;
11
+ timeoutMs;
12
+ info = null;
13
+ constructor(options) {
14
+ if (!/^https?:\/\//.test(options.endpoint)) {
15
+ throw new Error("FIRETRACE_ENDPOINT must be an http(s) origin.");
16
+ }
17
+ if (!/^ft_live_[0-9a-f]{16}_[0-9a-f]{64}$/.test(options.apiKey)) {
18
+ throw new Error("FIRETRACE_API_KEY does not look like a FireTrace key (ft_live_...).");
19
+ }
20
+ this.endpoint = options.endpoint.replace(/\/+$/, "");
21
+ this.apiKey = options.apiKey;
22
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
23
+ this.timeoutMs = options.timeoutMs ?? 30_000;
24
+ }
25
+ /** Validate the key and learn its scopes. Must run before building the server. */
26
+ async init() {
27
+ this.info = await this.request("GET", "/api/v1/key");
28
+ return this.info;
29
+ }
30
+ get scopes() {
31
+ return this.info?.scopes ?? [];
32
+ }
33
+ get projectId() {
34
+ return this.info?.projectId ?? "";
35
+ }
36
+ getProject() {
37
+ return this.request("GET", "/api/v1/project");
38
+ }
39
+ listTraces(query) {
40
+ const sp = new URLSearchParams();
41
+ for (const [k, v] of Object.entries(query)) {
42
+ if (v === undefined || v === null || v === "")
43
+ continue;
44
+ sp.set(k === "cursor" ? "after" : k, String(v));
45
+ }
46
+ const qs = sp.toString();
47
+ return this.request("GET", `/api/v1/traces${qs ? `?${qs}` : ""}`);
48
+ }
49
+ async getTrace(traceId) {
50
+ try {
51
+ return await this.request("GET", `/api/v1/traces/${traceId}`);
52
+ }
53
+ catch (err) {
54
+ if (err instanceof BackendError && err.status === 404)
55
+ return null;
56
+ throw err;
57
+ }
58
+ }
59
+ recordTrace(body) {
60
+ return this.request("POST", "/api/v1/traces", body);
61
+ }
62
+ patchTraceMetadata(traceId, metadata) {
63
+ return this.request("PATCH", `/api/v1/traces/${traceId}`, { metadata });
64
+ }
65
+ async deleteTrace(traceId) {
66
+ await this.request("DELETE", `/api/v1/traces/${traceId}`);
67
+ }
68
+ async addScore(traceId, input) {
69
+ const res = await this.request("POST", `/api/v1/traces/${traceId}/scores`, input);
70
+ return res.score;
71
+ }
72
+ async listScores(query) {
73
+ if (query.traceId) {
74
+ const res = await this.request("GET", `/api/v1/traces/${query.traceId}/scores`);
75
+ const scores = query.name ? res.scores.filter((s) => s.name === query.name) : res.scores;
76
+ return { scores, nextCursor: null };
77
+ }
78
+ const sp = new URLSearchParams();
79
+ if (query.name)
80
+ sp.set("name", query.name);
81
+ if (query.environment)
82
+ sp.set("environment", query.environment);
83
+ if (query.limit)
84
+ sp.set("limit", String(query.limit));
85
+ if (query.cursor)
86
+ sp.set("after", query.cursor);
87
+ const qs = sp.toString();
88
+ return this.request("GET", `/api/v1/scores${qs ? `?${qs}` : ""}`);
89
+ }
90
+ async ingestSchema() {
91
+ const doc = await this.request("GET", "/api/v1/openapi.json");
92
+ return doc.components?.schemas?.IngestRequest ?? doc;
93
+ }
94
+ async request(method, path, body) {
95
+ const controller = new AbortController();
96
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
97
+ let res;
98
+ try {
99
+ res = await this.fetchImpl(`${this.endpoint}${path}`, {
100
+ method,
101
+ headers: {
102
+ authorization: `Bearer ${this.apiKey}`,
103
+ accept: "application/json",
104
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
105
+ },
106
+ body: body !== undefined ? JSON.stringify(body) : undefined,
107
+ signal: controller.signal,
108
+ });
109
+ }
110
+ catch (err) {
111
+ throw new BackendError(0, "network_error", `Request to ${path} failed: ${err.message}`);
112
+ }
113
+ finally {
114
+ clearTimeout(timer);
115
+ }
116
+ const textBody = await res.text();
117
+ let parsed = null;
118
+ if (textBody) {
119
+ try {
120
+ parsed = JSON.parse(textBody);
121
+ }
122
+ catch {
123
+ parsed = null;
124
+ }
125
+ }
126
+ if (!res.ok) {
127
+ const e = parsed?.error;
128
+ throw new BackendError(res.status, e?.code ?? `http_${res.status}`, e?.message ?? `HTTP ${res.status} from ${path}`);
129
+ }
130
+ return parsed;
131
+ }
132
+ }
@@ -0,0 +1,3 @@
1
+ export { BackendError, hasScope, type KeyScope, type ListScoresQuery, type ListTracesQuery, type MetadataPatchResult, type ProjectLike, type RecordResult, type ScoreInputLike, type ScoreLike, type ScorePageLike, type SpanLike, type TraceBackend, type TraceDetailLike, type TracePageLike, type TraceSummaryLike, type UsageLike, } from "./backend.ts";
2
+ export { HttpBackend, type HttpBackendOptions } from "./http-backend.ts";
3
+ export { createFireTraceMcpServer, truncateDeep, type FireTraceMcpOptions } from "./server.ts";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { BackendError, hasScope, } from "./backend.js";
2
+ export { HttpBackend } from "./http-backend.js";
3
+ export { createFireTraceMcpServer, truncateDeep } from "./server.js";
@@ -0,0 +1,14 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type TraceBackend } from "./backend.ts";
3
+ export interface FireTraceMcpOptions {
4
+ /** Reported to clients during initialize. */
5
+ version?: string;
6
+ name?: string;
7
+ }
8
+ /** Recursively cap string lengths so a tool result stays readable for a model. */
9
+ export declare function truncateDeep(value: unknown, maxChars: number): unknown;
10
+ /**
11
+ * Build an MCP server over a TraceBackend. Only tools the key's scopes allow
12
+ * are registered, so an agent never sees a tool it cannot call.
13
+ */
14
+ export declare function createFireTraceMcpServer(backend: TraceBackend, options?: FireTraceMcpOptions): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,453 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { hasScope, } from "./backend.js";
4
+ const TRACE_ID = z
5
+ .string()
6
+ .regex(/^[0-9a-fA-F]{32}$/, "traceId is 32 hex characters")
7
+ .transform((s) => s.toLowerCase());
8
+ /** Trace statuses: `running` is a streamed trace that has not ended. Spans are always finished. */
9
+ const STATUS = z.enum(["ok", "error", "unset", "running"]);
10
+ const SPAN_STATUS = z.enum(["ok", "error", "unset"]);
11
+ const SCORE_NAME = z
12
+ .string()
13
+ .regex(/^[A-Za-z0-9_-]{1,64}$/, "letters, digits, '_' and '-' only, at most 64 characters");
14
+ function ms(n) {
15
+ if (n === null)
16
+ return "—"; // still running; the status column says so
17
+ if (n < 1000)
18
+ return `${n}ms`;
19
+ if (n < 60_000)
20
+ return `${(n / 1000).toFixed(2)}s`;
21
+ return `${(n / 60_000).toFixed(1)}m`;
22
+ }
23
+ /** Recursively cap string lengths so a tool result stays readable for a model. */
24
+ export function truncateDeep(value, maxChars) {
25
+ if (typeof value === "string") {
26
+ return value.length > maxChars
27
+ ? `${value.slice(0, maxChars)}…[+${value.length - maxChars} chars truncated]`
28
+ : value;
29
+ }
30
+ if (Array.isArray(value))
31
+ return value.map((v) => truncateDeep(v, maxChars));
32
+ if (value && typeof value === "object") {
33
+ const out = {};
34
+ for (const [k, v] of Object.entries(value))
35
+ out[k] = truncateDeep(v, maxChars);
36
+ return out;
37
+ }
38
+ return value;
39
+ }
40
+ function text(body, structured) {
41
+ return structured
42
+ ? { content: [{ type: "text", text: body }], structuredContent: structured }
43
+ : { content: [{ type: "text", text: body }] };
44
+ }
45
+ function failure(err) {
46
+ const e = err;
47
+ const code = typeof e?.code === "string" ? e.code : "error";
48
+ const status = typeof e?.status === "number" ? ` (HTTP ${e.status})` : "";
49
+ const message = e?.message ?? String(err);
50
+ return { content: [{ type: "text", text: `${code}${status}: ${message}` }], isError: true };
51
+ }
52
+ function summarizeLine(t) {
53
+ const model = t.model ? ` · ${t.model}` : "";
54
+ const environment = t.environment ? ` [${t.environment}]` : "";
55
+ return `${t.id} ${t.startedAt} ${t.status.padEnd(9)} ${ms(t.durationMs).padStart(8)} ${t.name}${model}${environment} (${t.spanCount} spans, ${t.errorCount} errors)`;
56
+ }
57
+ function spanOutline(detail) {
58
+ const byParent = new Map();
59
+ for (const s of detail.spans) {
60
+ const list = byParent.get(s.parentSpanId) ?? [];
61
+ list.push(s);
62
+ byParent.set(s.parentSpanId, list);
63
+ }
64
+ const ids = new Set(detail.spans.map((s) => s.id));
65
+ const roots = detail.spans.filter((s) => !s.parentSpanId || !ids.has(s.parentSpanId));
66
+ const lines = [];
67
+ const walk = (span, depth) => {
68
+ const flag = span.status === "error" ? " ✗" : "";
69
+ lines.push(`${" ".repeat(depth)}- ${span.id} [${span.kind}] ${span.name} ${ms(span.durationMs)}${span.model ? ` · ${span.model}` : ""}${flag}`);
70
+ for (const child of byParent.get(span.id) ?? [])
71
+ if (child !== span)
72
+ walk(child, depth + 1);
73
+ };
74
+ for (const r of roots)
75
+ walk(r, 0);
76
+ return lines.join("\n");
77
+ }
78
+ /**
79
+ * Build an MCP server over a TraceBackend. Only tools the key's scopes allow
80
+ * are registered, so an agent never sees a tool it cannot call.
81
+ */
82
+ export function createFireTraceMcpServer(backend, options = {}) {
83
+ const server = new McpServer({ name: options.name ?? "firetrace", version: options.version ?? "0.1.0" }, {
84
+ instructions: [
85
+ "FireTrace stores completed LLM/agent traces (a trace is a tree of spans).",
86
+ `This key belongs to project ${backend.projectId} with scopes: ${backend.scopes.join(", ") || "none"}.`,
87
+ "Use list_traces to find traces, get_trace for the full span tree, find_spans to locate specific spans, and record_trace to store a new trace (call get_ingest_schema first if unsure of the shape). add_score attaches a judgement made after the run (a rating, a verdict, an eval result) as a queryable score; list_scores reads scores back. patch_trace_metadata merges free-form keys into a trace's metadata.",
88
+ 'Every trace carries the environment of the key that recorded it (for example production, preview or development; null when the key has none). Pass environment to list_traces and list_scores to keep environments apart, or environment="unassigned" for traces without one.',
89
+ "A stored trace is immutable apart from its metadata and scores; deletion is explicit and permanent.",
90
+ ].join(" "),
91
+ });
92
+ const canRead = hasScope(backend, "traces:read");
93
+ const canWrite = hasScope(backend, "traces:write");
94
+ const canDelete = hasScope(backend, "traces:delete");
95
+ if (canRead) {
96
+ server.registerTool("get_project", {
97
+ title: "Get project",
98
+ description: "Return the project this key belongs to: name, trace and span counts, estimated storage, and the last trace time.",
99
+ inputSchema: {},
100
+ annotations: { readOnlyHint: true, idempotentHint: true },
101
+ }, async () => {
102
+ try {
103
+ const p = await backend.getProject();
104
+ const summary = [
105
+ `Project ${p.name} (${p.id})`,
106
+ p.description ? p.description : null,
107
+ `${p.traceCount} traces, ${p.spanCount} spans, ~${(p.estimatedBytes / 1_000_000).toFixed(2)} MB stored${p.storage ? ` (${p.storage.level}, limit ${(p.storage.limitBytes / 1_000_000).toFixed(0)} MB)` : ""}`,
108
+ `Last trace: ${p.lastTraceAt ?? "never"}`,
109
+ `Key scopes: ${backend.scopes.join(", ")}`,
110
+ ]
111
+ .filter(Boolean)
112
+ .join("\n");
113
+ return text(summary, p);
114
+ }
115
+ catch (err) {
116
+ return failure(err);
117
+ }
118
+ });
119
+ server.registerTool("list_traces", {
120
+ title: "List traces",
121
+ description: "List traces newest first (or slowest/costliest first) with optional filters (all combine with AND). Returns one line per trace plus a cursor for the next page. Times are ISO-8601 UTC.",
122
+ inputSchema: {
123
+ status: STATUS.optional().describe("Only traces with this status"),
124
+ model: z.string().max(200).optional().describe("Exact model name, e.g. gpt-5"),
125
+ name: z.string().max(500).optional().describe("Exact trace name"),
126
+ tag: z.string().max(64).optional().describe("One tag the trace must carry"),
127
+ environment: z
128
+ .string()
129
+ .max(40)
130
+ .optional()
131
+ .describe('Environment stamped from the recording key (e.g. production, preview), or "unassigned" for traces without one'),
132
+ sessionId: z.string().max(200).optional(),
133
+ userId: z.string().max(200).optional(),
134
+ sort: z
135
+ .enum(["newest", "slowest", "costliest"])
136
+ .optional()
137
+ .describe("Ordering; slowest and costliest combine only with status, model, name, tag and environment"),
138
+ from: z
139
+ .string()
140
+ .datetime({ offset: true })
141
+ .optional()
142
+ .describe("Inclusive lower bound on startedAt"),
143
+ to: z
144
+ .string()
145
+ .datetime({ offset: true })
146
+ .optional()
147
+ .describe("Inclusive upper bound on startedAt"),
148
+ limit: z.number().int().min(1).max(200).optional().describe("Page size, default 20"),
149
+ cursor: z.string().max(500).optional().describe("nextCursor from a previous call"),
150
+ },
151
+ annotations: { readOnlyHint: true, idempotentHint: true },
152
+ }, async (input) => {
153
+ try {
154
+ const query = { ...input, limit: input.limit ?? 20 };
155
+ const page = await backend.listTraces(query);
156
+ const header = page.traces.length === 0
157
+ ? "No traces match."
158
+ : `${page.traces.length} trace(s), newest first. Columns: id, startedAt, status, duration, name · model, spans/errors.`;
159
+ const lines = page.traces.map(summarizeLine);
160
+ const footer = page.nextCursor
161
+ ? `More available: call again with cursor="${page.nextCursor}".`
162
+ : "End of results.";
163
+ return text([header, ...lines, footer].join("\n"), {
164
+ traces: page.traces.map((t) => ({
165
+ id: t.id,
166
+ name: t.name,
167
+ status: t.status,
168
+ environment: t.environment ?? null,
169
+ startedAt: t.startedAt,
170
+ durationMs: t.durationMs,
171
+ model: t.model ?? null,
172
+ sessionId: t.sessionId ?? null,
173
+ userId: t.userId ?? null,
174
+ spanCount: t.spanCount,
175
+ errorCount: t.errorCount,
176
+ costUsd: t.costUsd ?? null,
177
+ usage: t.usage ?? null,
178
+ })),
179
+ nextCursor: page.nextCursor,
180
+ });
181
+ }
182
+ catch (err) {
183
+ return failure(err);
184
+ }
185
+ });
186
+ server.registerTool("get_trace", {
187
+ title: "Get trace",
188
+ description: "Return one trace with its full span tree: an outline first, then the trace and spans as JSON. Long strings are truncated to maxChars (default 2000); pass a larger value or use find_spans + get_trace with a small maxSpans to focus.",
189
+ inputSchema: {
190
+ traceId: TRACE_ID,
191
+ maxChars: z
192
+ .number()
193
+ .int()
194
+ .min(50)
195
+ .max(200_000)
196
+ .optional()
197
+ .describe("Per-string truncation, default 2000"),
198
+ maxSpans: z
199
+ .number()
200
+ .int()
201
+ .min(1)
202
+ .max(500)
203
+ .optional()
204
+ .describe("Cap on spans included in the JSON, default 100"),
205
+ includeContent: z
206
+ .boolean()
207
+ .optional()
208
+ .describe("Include span input/output/attributes (default true). false returns timing and status only."),
209
+ },
210
+ annotations: { readOnlyHint: true, idempotentHint: true },
211
+ }, async (input) => {
212
+ try {
213
+ const detail = await backend.getTrace(input.traceId);
214
+ if (!detail)
215
+ return failure({
216
+ status: 404,
217
+ code: "not_found",
218
+ message: `No trace ${input.traceId} in this project.`,
219
+ });
220
+ const maxSpans = input.maxSpans ?? 100;
221
+ const include = input.includeContent ?? true;
222
+ const spans = detail.spans.slice(0, maxSpans).map((s) => include
223
+ ? s
224
+ : {
225
+ id: s.id,
226
+ parentSpanId: s.parentSpanId,
227
+ name: s.name,
228
+ kind: s.kind,
229
+ status: s.status,
230
+ startedAt: s.startedAt,
231
+ endedAt: s.endedAt,
232
+ durationMs: s.durationMs,
233
+ model: s.model ?? null,
234
+ usage: s.usage ?? null,
235
+ });
236
+ const omitted = detail.spans.length - spans.length;
237
+ const body = truncateDeep({
238
+ trace: detail.trace,
239
+ spans,
240
+ ...(detail.scores?.length ? { scores: detail.scores } : {}),
241
+ }, input.maxChars ?? 2000);
242
+ const outline = spanOutline(detail);
243
+ const parts = [
244
+ `Trace ${detail.trace.id} "${detail.trace.name}" — ${detail.trace.status}, ${ms(detail.trace.durationMs)}, ${detail.spans.length} spans, ${detail.trace.errorCount} errors, started ${detail.trace.startedAt}.`,
245
+ "Span outline:",
246
+ outline || "(no spans)",
247
+ omitted > 0
248
+ ? `JSON below includes the first ${spans.length} spans; ${omitted} omitted (raise maxSpans).`
249
+ : "",
250
+ JSON.stringify(body, null, 1),
251
+ ].filter(Boolean);
252
+ return text(parts.join("\n\n"));
253
+ }
254
+ catch (err) {
255
+ return failure(err);
256
+ }
257
+ });
258
+ server.registerTool("find_spans", {
259
+ title: "Find spans",
260
+ description: "Locate spans inside one trace by kind, status, or name substring without loading their content. Use before get_trace when a trace is large.",
261
+ inputSchema: {
262
+ traceId: TRACE_ID,
263
+ kind: z
264
+ .string()
265
+ .max(40)
266
+ .optional()
267
+ .describe("llm, agent, tool, chain, retriever, embedding, reranker, custom"),
268
+ status: SPAN_STATUS.optional(),
269
+ nameContains: z
270
+ .string()
271
+ .max(200)
272
+ .optional()
273
+ .describe("Case-insensitive substring of the span name"),
274
+ limit: z.number().int().min(1).max(500).optional().describe("Default 50"),
275
+ },
276
+ annotations: { readOnlyHint: true, idempotentHint: true },
277
+ }, async (input) => {
278
+ try {
279
+ const detail = await backend.getTrace(input.traceId);
280
+ if (!detail)
281
+ return failure({
282
+ status: 404,
283
+ code: "not_found",
284
+ message: `No trace ${input.traceId} in this project.`,
285
+ });
286
+ const needle = input.nameContains?.toLowerCase();
287
+ const matches = detail.spans.filter((s) => (!input.kind || s.kind === input.kind) &&
288
+ (!input.status || s.status === input.status) &&
289
+ (!needle || s.name.toLowerCase().includes(needle)));
290
+ const shown = matches.slice(0, input.limit ?? 50);
291
+ const lines = shown.map((s) => `${s.id} parent=${s.parentSpanId ?? "-"} [${s.kind}] ${s.status.padEnd(9)} ${ms(s.durationMs).padStart(8)} ${s.name}${s.model ? ` · ${s.model}` : ""}`);
292
+ const header = `${matches.length} of ${detail.spans.length} spans match${shown.length < matches.length ? ` (showing ${shown.length})` : ""}.`;
293
+ return text([header, ...lines].join("\n"), {
294
+ spans: shown.map((s) => ({
295
+ id: s.id,
296
+ parentSpanId: s.parentSpanId,
297
+ name: s.name,
298
+ kind: s.kind,
299
+ status: s.status,
300
+ durationMs: s.durationMs,
301
+ model: s.model ?? null,
302
+ })),
303
+ total: matches.length,
304
+ });
305
+ }
306
+ catch (err) {
307
+ return failure(err);
308
+ }
309
+ });
310
+ server.registerTool("list_scores", {
311
+ title: "List scores",
312
+ description: "Scores are judgements attached to traces after the run (ratings, review verdicts, evaluator results): a name, a numeric/categorical/boolean value and an optional comment. Pass traceId for one trace's full history, or name to see one score across the project, newest first.",
313
+ inputSchema: {
314
+ traceId: TRACE_ID.optional().describe("Only this trace's scores"),
315
+ name: SCORE_NAME.optional().describe("Only scores with this name"),
316
+ environment: z
317
+ .string()
318
+ .max(40)
319
+ .optional()
320
+ .describe('Only scores whose trace is in this environment ("unassigned" for none); ignored with traceId'),
321
+ limit: z.number().int().min(1).max(500).optional().describe("Page size, default 50"),
322
+ cursor: z.string().max(500).optional().describe("nextCursor from a previous call"),
323
+ },
324
+ annotations: { readOnlyHint: true, idempotentHint: true },
325
+ }, async (input) => {
326
+ try {
327
+ const page = await backend.listScores({ ...input, limit: input.limit ?? 50 });
328
+ const header = page.scores.length === 0
329
+ ? "No scores match."
330
+ : `${page.scores.length} score(s), newest first. Columns: id, createdAt, traceId, name=value, source, comment.`;
331
+ const lines = page.scores.map((s) => {
332
+ const comment = s.comment
333
+ ? ` ${s.comment.length > 120 ? `${s.comment.slice(0, 120)}…` : s.comment}`
334
+ : "";
335
+ return `${s.id} ${s.createdAt} ${s.traceId} ${s.name}=${JSON.stringify(s.value)} ${s.source}${comment}`;
336
+ });
337
+ const footer = page.nextCursor
338
+ ? `More available: call again with cursor="${page.nextCursor}".`
339
+ : "End of results.";
340
+ return text([header, ...lines, footer].join("\n"), {
341
+ scores: page.scores,
342
+ nextCursor: page.nextCursor,
343
+ });
344
+ }
345
+ catch (err) {
346
+ return failure(err);
347
+ }
348
+ });
349
+ }
350
+ if (canWrite) {
351
+ server.registerTool("get_ingest_schema", {
352
+ title: "Get ingest schema",
353
+ description: "JSON Schema for the body accepted by record_trace ({ schemaVersion: 1, trace }). Consult it before recording a trace by hand.",
354
+ inputSchema: {},
355
+ annotations: { readOnlyHint: true, idempotentHint: true },
356
+ }, async () => {
357
+ try {
358
+ const schema = await backend.ingestSchema();
359
+ return text(JSON.stringify(schema, null, 1));
360
+ }
361
+ catch (err) {
362
+ return failure(err);
363
+ }
364
+ });
365
+ server.registerTool("record_trace", {
366
+ title: "Record trace",
367
+ description: "Store one complete, immutable trace. `trace` must follow the ingestion schema: id (32 hex), name, status, startedAt/endedAt (ISO-8601), and spans[] each with id (16 hex), parentSpanId, name, kind, status, startedAt, endedAt. Resending an identical trace is a no-op; reusing an id with different content is rejected. Omitting both endedAt and status stores a running trace that the REST API's spans/end endpoints complete later (status alone without endedAt is rejected).",
368
+ inputSchema: {
369
+ trace: z.record(z.string(), z.unknown()).describe("The trace object (not the envelope)"),
370
+ },
371
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
372
+ }, async (input) => {
373
+ try {
374
+ const result = await backend.recordTrace({ schemaVersion: 1, trace: input.trace });
375
+ const verb = result.duplicate ? "Already stored (identical duplicate)" : "Stored";
376
+ return text(`${verb}: trace ${result.traceId} with ${result.spanCount} spans.`, {
377
+ ...result,
378
+ });
379
+ }
380
+ catch (err) {
381
+ return failure(err);
382
+ }
383
+ });
384
+ server.registerTool("add_score", {
385
+ title: "Add score",
386
+ description: "Attach a score to a stored trace: a judgement made after the run, such as a rating, a review verdict or an evaluator's result. Scores are indexed and listable (unlike metadata). Each has a name (letters, digits, '_' and '-'), a dataType with a matching value (numeric → number, categorical → string, boolean → boolean) and an optional comment explaining it. Scores are append-only: adding the same name again records a newer score, and the trace's summary shows the newest per name.",
387
+ inputSchema: {
388
+ traceId: TRACE_ID,
389
+ name: SCORE_NAME.describe("Score name, e.g. accuracy, helpful, topic"),
390
+ dataType: z.enum(["numeric", "categorical", "boolean"]),
391
+ value: z
392
+ .union([z.number(), z.string().max(200), z.boolean()])
393
+ .describe("Must match dataType"),
394
+ comment: z.string().max(2000).optional().describe("Why this score was given"),
395
+ spanId: z
396
+ .string()
397
+ .regex(/^[0-9a-fA-F]{16}$/)
398
+ .optional()
399
+ .describe("Scope the score to one span instead of the whole trace"),
400
+ },
401
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
402
+ }, async (input) => {
403
+ try {
404
+ const { traceId, ...score } = input;
405
+ const stored = await backend.addScore(traceId, score);
406
+ return text(`Added score ${stored.name}=${JSON.stringify(stored.value)} (${stored.id}) to trace ${traceId}.`, { ...stored });
407
+ }
408
+ catch (err) {
409
+ return failure(err);
410
+ }
411
+ });
412
+ server.registerTool("patch_trace_metadata", {
413
+ title: "Patch trace metadata",
414
+ description: "Shallow-merge keys into a stored trace's metadata for free-form facts that only exist after the run (a business outcome, a link to a ticket). For ratings, verdicts and eval results prefer add_score, which is indexed. A key in the patch replaces that top-level key outright; keys not mentioned are left alone; concurrent writers on one key are last-writer-wins. Everything else about the trace, spans included, stays immutable. Metadata is not indexed, so it cannot be searched or filtered by.",
415
+ inputSchema: {
416
+ traceId: TRACE_ID,
417
+ metadata: z
418
+ .record(z.string(), z.unknown())
419
+ .describe("Keys to merge into the trace's existing metadata"),
420
+ },
421
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
422
+ }, async (input) => {
423
+ try {
424
+ const result = await backend.patchTraceMetadata(input.traceId, input.metadata);
425
+ const verb = result.changed ? "Merged into" : "Already matched";
426
+ return text(`${verb} the metadata of trace ${input.traceId}; it now has ${Object.keys(result.metadata).length} keys.`, { ...result });
427
+ }
428
+ catch (err) {
429
+ return failure(err);
430
+ }
431
+ });
432
+ }
433
+ if (canDelete) {
434
+ server.registerTool("delete_trace", {
435
+ title: "Delete trace",
436
+ description: "Permanently delete one trace and all of its spans. Irreversible. Requires confirm=true.",
437
+ inputSchema: {
438
+ traceId: TRACE_ID,
439
+ confirm: z.literal(true).describe("Must be true; guards against accidental deletion"),
440
+ },
441
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
442
+ }, async (input) => {
443
+ try {
444
+ await backend.deleteTrace(input.traceId);
445
+ return text(`Deleted trace ${input.traceId}.`, { ok: true, traceId: input.traceId });
446
+ }
447
+ catch (err) {
448
+ return failure(err);
449
+ }
450
+ });
451
+ }
452
+ return server;
453
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@firetrace/mcp",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for FireTrace: lets AI agents list, read, record, and delete traces through a scoped API key.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "bin": {
16
+ "firetrace-mcp": "./bin/firetrace-mcp.mjs"
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.30.0",
28
+ "zod": "^4.1.13"
29
+ },
30
+ "keywords": [
31
+ "firetrace",
32
+ "mcp",
33
+ "model-context-protocol",
34
+ "llm",
35
+ "tracing"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.json",
39
+ "typecheck": "tsc -p tsconfig.json --noEmit"
40
+ }
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ export {
2
+ BackendError,
3
+ hasScope,
4
+ type KeyScope,
5
+ type ListScoresQuery,
6
+ type ListTracesQuery,
7
+ type MetadataPatchResult,
8
+ type ProjectLike,
9
+ type RecordResult,
10
+ type ScoreInputLike,
11
+ type ScoreLike,
12
+ type ScorePageLike,
13
+ type SpanLike,
14
+ type TraceBackend,
15
+ type TraceDetailLike,
16
+ type TracePageLike,
17
+ type TraceSummaryLike,
18
+ type UsageLike,
19
+ } from "./backend.ts";
20
+ export { HttpBackend, type HttpBackendOptions } from "./http-backend.ts";
21
+ export { createFireTraceMcpServer, truncateDeep, type FireTraceMcpOptions } from "./server.ts";