Skip to content

Commit 3ae77a5

Browse files
feat: MCP (#864)
1 parent 04c8607 commit 3ae77a5

File tree

8 files changed

+1071
-642
lines changed

8 files changed

+1071
-642
lines changed

packages/sidecar/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@
2727
"types": "./dist/src/vite-plugin.d.ts"
2828
},
2929
"./constants": {
30-
"import": "./src/constants.js",
31-
"types": "./dist/constants.d.ts"
30+
"import": "./dist/src/constants.js",
31+
"types": "./dist/src/constants.d.ts"
3232
}
3333
},
3434
"dependencies": {
3535
"@jridgewell/trace-mapping": "^0.3.25",
36+
"@modelcontextprotocol/sdk": "^1.16.0",
37+
"@sentry/core": "^9.22.0",
3638
"@sentry/node": "^8.49.0",
3739
"kleur": "^4.1.5",
3840
"launch-editor": "^2.9.1"

packages/sidecar/src/main.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import { createWriteStream, readFileSync } from "node:fs";
22
import { type IncomingMessage, type Server, type ServerResponse, createServer, get } from "node:http";
33
import { extname, join, resolve } from "node:path";
44
import { createGunzip, createInflate } from "node:zlib";
5+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
56
import { addEventProcessor, captureException, getTraceData, startSpan } from "@sentry/node";
67
import launchEditor from "launch-editor";
78
import { CONTEXT_LINES_ENDPOINT, DEFAULT_PORT, SERVER_IDENTIFIER } from "./constants.js";
89
import { contextLinesHandler } from "./contextlines.js";
910
import { type SidecarLogger, activateLogger, enableDebugLogging, logger } from "./logger.js";
11+
import { createMcpInstance } from "./mcp/index.js";
1012
import { MessageBuffer } from "./messageBuffer.js";
11-
12-
type Payload = [string, Buffer];
13+
import type { Payload } from "./utils.js";
1314

1415
type IncomingPayloadCallback = (body: string) => void;
1516

@@ -74,7 +75,7 @@ const SPOTLIGHT_HEADERS = {
7475

7576
const enableCORS = (handler: RequestHandler): RequestHandler =>
7677
withTracing(
77-
(req: IncomingMessage, res: ServerResponse, pathname?: string, searchParams?: URLSearchParams) => {
78+
async (req: IncomingMessage, res: ServerResponse, pathname?: string, searchParams?: URLSearchParams) => {
7879
const headers = {
7980
...CORS_HEADERS,
8081
...SPOTLIGHT_HEADERS,
@@ -316,21 +317,30 @@ function logSpotlightUrl(port: number): void {
316317
logger.info(`You can open: http://localhost:${port} to see the Spotlight overlay directly`);
317318
}
318319

319-
function startServer(
320+
async function startServer(
320321
buffer: MessageBuffer<Payload>,
321322
port: number,
322323
basePath?: string,
323324
filesToServe?: Record<string, Buffer>,
324325
incomingPayload?: IncomingPayloadCallback,
325-
): Server {
326+
): Promise<Server> {
326327
if (basePath && !filesToServe) {
327328
filesToServe = {
328329
"/src/index.html": readFileSync(join(basePath, "src/index.html")),
329330
"/assets/main.js": readFileSync(join(basePath, "assets/main.js")),
330331
};
331332
}
333+
334+
// MCP Setup
335+
const transport = new StreamableHTTPServerTransport({
336+
sessionIdGenerator: undefined,
337+
});
338+
const mcp = createMcpInstance(buffer);
339+
await mcp.connect(transport);
340+
332341
const ROUTES: [RegExp, RequestHandler][] = [
333342
[/^\/health$/, handleHealthRequest],
343+
[/^\/mcp$/, enableCORS((req, res) => transport.handleRequest(req, res))],
334344
[/^\/clear$/, enableCORS(handleClearRequest)],
335345
[/^\/stream$|^\/api\/\d+\/envelope\/?$/, enableCORS(streamRequestHandler(buffer, incomingPayload))],
336346
[/^\/open$/, enableCORS(openRequestHandler(basePath))],
@@ -494,15 +504,15 @@ export function setupSidecar({
494504
sidecarPort = typeof port === "string" ? Number(port) : port;
495505
}
496506

497-
isSidecarRunning(sidecarPort).then((isRunning: boolean) => {
507+
isSidecarRunning(sidecarPort).then(async (isRunning: boolean) => {
498508
if (isRunning) {
499509
logger.info(`Sidecar is already running on port ${sidecarPort}`);
500510
const hasSpotlightUI = (filesToServe && "/src/index.html" in filesToServe) || (!filesToServe && basePath);
501511
if (hasSpotlightUI) {
502512
logSpotlightUrl(sidecarPort);
503513
}
504514
} else if (!serverInstance) {
505-
serverInstance = startServer(buffer, sidecarPort, basePath, filesToServe, incomingPayload);
515+
serverInstance = await startServer(buffer, sidecarPort, basePath, filesToServe, incomingPayload);
506516
}
507517
});
508518
}

packages/sidecar/src/mcp/index.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import type { TextContent } from "@modelcontextprotocol/sdk/types.js";
3+
import type { ErrorEvent } from "@sentry/core";
4+
import { MessageBuffer } from "../messageBuffer.js";
5+
import type { Payload } from "../utils.js";
6+
import { processEnvelope } from "./parsing.js";
7+
8+
export function createMcpInstance(buffer: MessageBuffer<Payload>) {
9+
const mcp = new McpServer({
10+
name: "spotlight-mcp",
11+
version: String(process.env.npm_package_version),
12+
});
13+
14+
const errorsBuffer = new MessageBuffer<Payload>(10);
15+
buffer.subscribe((item: Payload) => {
16+
errorsBuffer.put(item);
17+
});
18+
19+
mcp.tool(
20+
"get_errors",
21+
"Fetches the most recent errors from Spotlight debugger. Returns error details, stack traces, and request details for immediate debugging context.",
22+
async () => {
23+
const envelopes = errorsBuffer.read();
24+
errorsBuffer.clear();
25+
26+
if (envelopes.length === 0) {
27+
return {
28+
content: [
29+
{
30+
type: "text",
31+
text: "No recent errors found in Spotlight. This might be because the application started successfully, but runtime issues only appear when you interact with specific pages or features.\n\nAsk the user to navigate to the page where they're experiencing the issue to reproduce it, that way we can get that in the Spotlight debugger. So if you want to check for errors again, just ask me to do that.",
32+
},
33+
],
34+
};
35+
}
36+
37+
const errors = envelopes
38+
.map(([contentType, data]) => processEnvelope({ contentType, data }))
39+
.sort((a, b) => {
40+
const a_sent_at = a.envelope[0].sent_at as string;
41+
const b_sent_at = b.envelope[0].sent_at as string;
42+
if (a_sent_at < b_sent_at) return 1;
43+
if (a_sent_at > b_sent_at) return -1;
44+
return 0;
45+
});
46+
47+
const content: TextContent[] = [];
48+
for (const error of errors) {
49+
const {
50+
envelope: [, [[{ type }, payload]]],
51+
} = error;
52+
53+
if (type === "event" && isErrorEvent(payload)) {
54+
content.push({
55+
type: "text",
56+
text: JSON.stringify({
57+
exception: payload.exception,
58+
level: payload.level,
59+
request: payload.request,
60+
}),
61+
});
62+
}
63+
}
64+
65+
return {
66+
content,
67+
};
68+
},
69+
);
70+
71+
// TODO: Add tool for performance tracing
72+
// TODO: Add tool for profiling data
73+
74+
return mcp;
75+
}
76+
77+
function isErrorEvent(payload: unknown): payload is ErrorEvent {
78+
return typeof payload === "object" && payload !== null && "exception" in payload;
79+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// This file is a copy of utils from @spotlightjs/overlay to parse envelopes from Buffer
2+
import type { Envelope, EnvelopeItem } from "@sentry/core";
3+
4+
type RawEventContext = {
5+
contentType: string;
6+
data: string | Uint8Array;
7+
};
8+
9+
export function processEnvelope(rawEvent: RawEventContext) {
10+
let buffer = typeof rawEvent.data === "string" ? Uint8Array.from(rawEvent.data, c => c.charCodeAt(0)) : rawEvent.data;
11+
12+
function readLine(length?: number) {
13+
const cursor = length ?? getLineEnd(buffer);
14+
const line = buffer.subarray(0, cursor);
15+
buffer = buffer.subarray(cursor + 1);
16+
return line;
17+
}
18+
19+
const envelopeHeader = parseJSONFromBuffer(readLine()) as Envelope[0];
20+
21+
const items: EnvelopeItem[] = [];
22+
while (buffer.length) {
23+
const itemHeader = parseJSONFromBuffer(readLine()) as EnvelopeItem[0];
24+
const payloadLength = itemHeader.length;
25+
const itemPayloadRaw = readLine(payloadLength);
26+
27+
let itemPayload: EnvelopeItem[1];
28+
try {
29+
itemPayload = parseJSONFromBuffer(itemPayloadRaw);
30+
// data sanitization
31+
if (itemHeader.type) {
32+
// @ts-expect-error ts(2339) -- We should really stop adding type to payloads
33+
itemPayload.type = itemHeader.type;
34+
}
35+
} catch (err) {
36+
itemPayload = itemPayloadRaw;
37+
console.error(err);
38+
}
39+
40+
items.push([itemHeader, itemPayload] as EnvelopeItem);
41+
}
42+
43+
const envelope = [envelopeHeader, items] as Envelope;
44+
45+
return {
46+
envelope,
47+
};
48+
}
49+
50+
function getLineEnd(data: Uint8Array): number {
51+
let end = data.indexOf(0xa);
52+
if (end === -1) {
53+
end = data.length;
54+
}
55+
56+
return end;
57+
}
58+
59+
export function parseJSONFromBuffer<T = unknown>(data: Uint8Array): T {
60+
try {
61+
return JSON.parse(new TextDecoder().decode(data)) as T;
62+
} catch (err) {
63+
console.error(err);
64+
return {} as T;
65+
}
66+
}

packages/sidecar/src/messageBuffer.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@ export class MessageBuffer<T> {
6666
this.head = 0;
6767
this.readers = new Map<string, (item: T) => void>();
6868
}
69+
70+
read(): T[] {
71+
const result: T[] = [];
72+
const start = this.head;
73+
const end = this.writePos;
74+
for (let i = start; i < end; i++) {
75+
const item = this.items[i % this.size];
76+
if (item !== undefined) {
77+
result.push(item[1]);
78+
}
79+
}
80+
return result;
81+
}
6982
}
7083

7184
function generateUuidv4(): string {

packages/sidecar/src/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type Payload = [string, Buffer];

packages/sidecar/tsdown.config.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
1-
import { builtinModules } from "node:module";
21
import { defineConfig } from "tsdown";
3-
import packageJson from "./package.json";
4-
5-
const dependencies = Object.keys({
6-
...packageJson.dependencies,
7-
...packageJson.devDependencies,
8-
});
92

103
export default defineConfig({
114
entry: ["./src/main.ts", "./src/vite-plugin.ts", "./src/constants.ts", "./server.ts"],
125
sourcemap: true,
13-
external: [...dependencies, ...builtinModules.map(x => `node:${x}`)],
146
});

0 commit comments

Comments
 (0)