Skip to content

Commit e116df0

Browse files
authored
Merge pull request #48 from rollbar/brianr/add-get-replay
Add get-replay tool
2 parents ce102eb + 240abfb commit e116df0

23 files changed

+719
-36
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ This MCP server implementes the `stdio` server type, which means your AI tool (e
2525

2626
`list-items(environment)`: List items filtered by status, environment and a search query.
2727

28+
`get-replay(environment, sessionId, replayId, delivery?)`: Retrieve session replay metadata and payload for a specific session in the configured project. By default the tool writes the replay JSON to a temporary file (under your system temp directory) and returns the path so any client can inspect it. Set `delivery="resource"` to receive a `rollbar://replay/<environment>/<sessionId>/<replayId>` link for MCP-aware clients. Example prompt: `Fetch the replay 789 from session abc in staging`.
29+
2830
`update-item(itemId, status?, level?, title?, assignedUserId?, resolvedInVersion?, snoozed?, teamId?)`: Update an item's properties including status, level, title, assignment, and more. Example prompt: `Mark Rollbar item #123456 as resolved` or `Assign item #123456 to user ID 789`. (Requires `write` scope)
2931

3032
## How to Use
@@ -111,3 +113,4 @@ Configure your `.vscode/mcp.json` as follows:
111113
}
112114
```
113115

116+
Or using a local development installation - see CONTRIBUTING.md.

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
44
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
55
import { registerAllTools } from "./tools/index.js";
6+
import { registerAllResources } from "./resources/index.js";
67

78
// Create server instance
89
const server = new McpServer({
@@ -29,11 +30,15 @@ const server = new McpServer({
2930
description:
3031
"List all items in the Rollbar project with optional search and filtering",
3132
},
33+
"get-replay": {
34+
description: "Get replay data for a specific session replay in Rollbar",
35+
},
3236
},
3337
},
3438
});
3539

3640
// Register all tools
41+
registerAllResources(server);
3742
registerAllTools(server);
3843

3944
async function main() {

src/resources/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { registerReplayResource } from "./replay-resource.js";
3+
4+
export function registerAllResources(server: McpServer) {
5+
registerReplayResource(server);
6+
}
7+
8+
export {
9+
buildReplayResourceUri,
10+
cacheReplayData,
11+
fetchReplayData,
12+
} from "./replay-resource.js";

src/resources/replay-resource.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import type {
3+
McpServer,
4+
ReadResourceTemplateCallback,
5+
} from "@modelcontextprotocol/sdk/server/mcp.js";
6+
import { ROLLBAR_API_BASE } from "../config.js";
7+
import { makeRollbarRequest } from "../utils/api.js";
8+
import { RollbarApiResponse } from "../types/index.js";
9+
10+
const REPLAY_URI_TEMPLATE =
11+
"rollbar://replay/{environment}/{sessionId}/{replayId}";
12+
const REPLAY_RESOURCE_NAME = "rollbar-session-replay";
13+
const REPLAY_RESOURCE_TITLE = "Rollbar Session Replay";
14+
const REPLAY_MIME_TYPE = "application/json";
15+
const CACHE_TTL_MS = 5 * 60 * 1000;
16+
17+
type ReplayCacheEntry = {
18+
data: unknown;
19+
expiresAt: number;
20+
};
21+
22+
const replayCache = new Map<string, ReplayCacheEntry>();
23+
const registeredServers = new WeakSet<McpServer>();
24+
25+
function normalizeTemplateVariable(
26+
value: string | string[] | undefined,
27+
): string {
28+
if (Array.isArray(value)) {
29+
return value[0] ?? "";
30+
}
31+
32+
return value ?? "";
33+
}
34+
35+
function buildReplayApiUrl(
36+
environment: string,
37+
sessionId: string,
38+
replayId: string,
39+
): string {
40+
return `${ROLLBAR_API_BASE}/environment/${encodeURIComponent(
41+
environment,
42+
)}/session/${encodeURIComponent(sessionId)}/replay/${encodeURIComponent(
43+
replayId,
44+
)}`;
45+
}
46+
47+
export function buildReplayResourceUri(
48+
environment: string,
49+
sessionId: string,
50+
replayId: string,
51+
): string {
52+
return `rollbar://replay/${encodeURIComponent(
53+
environment,
54+
)}/${encodeURIComponent(sessionId)}/${encodeURIComponent(replayId)}`;
55+
}
56+
57+
export function cacheReplayData(uri: string, data: unknown) {
58+
replayCache.set(uri, { data, expiresAt: Date.now() + CACHE_TTL_MS });
59+
}
60+
61+
function getCachedReplayData(uri: string) {
62+
const cached = replayCache.get(uri);
63+
if (!cached) {
64+
return undefined;
65+
}
66+
67+
if (cached.expiresAt < Date.now()) {
68+
replayCache.delete(uri);
69+
return undefined;
70+
}
71+
72+
return cached.data;
73+
}
74+
75+
export async function fetchReplayData(
76+
environment: string,
77+
sessionId: string,
78+
replayId: string,
79+
): Promise<unknown> {
80+
const replayUrl = buildReplayApiUrl(environment, sessionId, replayId);
81+
82+
const replayResponse = await makeRollbarRequest<RollbarApiResponse<unknown>>(
83+
replayUrl,
84+
"get-replay",
85+
);
86+
87+
if (replayResponse.err !== 0) {
88+
const errorMessage =
89+
replayResponse.message || `Unknown error (code: ${replayResponse.err})`;
90+
throw new Error(`Rollbar API returned error: ${errorMessage}`);
91+
}
92+
93+
return replayResponse.result;
94+
}
95+
96+
const readReplayResource: ReadResourceTemplateCallback = async (
97+
uri,
98+
variables,
99+
) => {
100+
const environmentValue = normalizeTemplateVariable(variables.environment);
101+
const sessionValue = normalizeTemplateVariable(variables.sessionId);
102+
const replayValue = normalizeTemplateVariable(variables.replayId);
103+
104+
const environment = environmentValue
105+
? decodeURIComponent(environmentValue)
106+
: "";
107+
const sessionId = sessionValue ? decodeURIComponent(sessionValue) : "";
108+
const replayId = replayValue ? decodeURIComponent(replayValue) : "";
109+
110+
if (!environment || !sessionId || !replayId) {
111+
throw new Error("Invalid replay resource URI");
112+
}
113+
114+
const resourceUri = buildReplayResourceUri(environment, sessionId, replayId);
115+
const cached = getCachedReplayData(resourceUri);
116+
117+
const replayData =
118+
cached !== undefined
119+
? cached
120+
: await fetchReplayData(environment, sessionId, replayId);
121+
122+
if (cached === undefined) {
123+
cacheReplayData(resourceUri, replayData);
124+
}
125+
126+
return {
127+
contents: [
128+
{
129+
uri: uri.toString(),
130+
mimeType: REPLAY_MIME_TYPE,
131+
text: JSON.stringify(replayData),
132+
},
133+
],
134+
};
135+
};
136+
137+
export function registerReplayResource(server: McpServer) {
138+
if (registeredServers.has(server)) {
139+
return;
140+
}
141+
142+
const template = new ResourceTemplate(REPLAY_URI_TEMPLATE, {
143+
list: () => ({ resources: [] }),
144+
});
145+
146+
server.resource(
147+
REPLAY_RESOURCE_NAME,
148+
template,
149+
{
150+
title: REPLAY_RESOURCE_TITLE,
151+
description:
152+
"Session replay payloads returned from the Rollbar Replay API.",
153+
mimeType: REPLAY_MIME_TYPE,
154+
},
155+
readReplayResource,
156+
);
157+
158+
registeredServers.add(server);
159+
}

src/tools/get-deployments.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function registerGetDeploymentsTool(server: McpServer) {
3333
content: [
3434
{
3535
type: "text",
36-
text: JSON.stringify(deployments, null, 2),
36+
text: JSON.stringify(deployments),
3737
},
3838
],
3939
};

src/tools/get-item-details.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function registerGetItemDetailsTool(server: McpServer) {
5050
content: [
5151
{
5252
type: "text",
53-
text: JSON.stringify(item, null, 2),
53+
text: JSON.stringify(item),
5454
},
5555
],
5656
};
@@ -73,7 +73,7 @@ export function registerGetItemDetailsTool(server: McpServer) {
7373
content: [
7474
{
7575
type: "text",
76-
text: JSON.stringify(responseData, null),
76+
text: JSON.stringify(responseData),
7777
},
7878
],
7979
};

src/tools/get-replay.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { mkdir, writeFile } from "node:fs/promises";
2+
import path from "node:path";
3+
import { tmpdir } from "node:os";
4+
import { z } from "zod";
5+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6+
import {
7+
buildReplayResourceUri,
8+
cacheReplayData,
9+
fetchReplayData,
10+
} from "../resources/index.js";
11+
12+
function buildResourceLinkDescription(
13+
environment: string,
14+
sessionId: string,
15+
replayId: string,
16+
) {
17+
return `Session replay payload for session ${sessionId} (${environment}) replay ${replayId}.`;
18+
}
19+
20+
const DELIVERY_MODE = z.enum(["resource", "file"]);
21+
const REPLAY_FILE_DIRECTORY = path.join(tmpdir(), "rollbar-mcp-replays");
22+
23+
function sanitizeForFilename(value: string) {
24+
return value.replace(/[^a-z0-9-_]+/gi, "-").replace(/-+/g, "-");
25+
}
26+
27+
async function writeReplayToFile(
28+
replayData: unknown,
29+
environment: string,
30+
sessionId: string,
31+
replayId: string,
32+
) {
33+
await mkdir(REPLAY_FILE_DIRECTORY, { recursive: true });
34+
const uniqueSuffix = `${Date.now()}-${Math.random()
35+
.toString(36)
36+
.slice(2, 10)}`;
37+
const fileName = [
38+
"replay",
39+
sanitizeForFilename(environment),
40+
sanitizeForFilename(sessionId),
41+
sanitizeForFilename(replayId),
42+
uniqueSuffix,
43+
]
44+
.filter(Boolean)
45+
.join("_")
46+
.replace(/_+/g, "_")
47+
.concat(".json");
48+
49+
const filePath = path.join(REPLAY_FILE_DIRECTORY, fileName);
50+
await writeFile(filePath, JSON.stringify(replayData, null, 2), "utf8");
51+
return filePath;
52+
}
53+
54+
export function registerGetReplayTool(server: McpServer) {
55+
server.tool(
56+
"get-replay",
57+
"Get replay data for a specific session replay in Rollbar",
58+
{
59+
environment: z
60+
.string()
61+
.min(1)
62+
.describe("Environment name (e.g., production)"),
63+
sessionId: z
64+
.string()
65+
.min(1)
66+
.describe("Session identifier that owns the replay"),
67+
replayId: z.string().min(1).describe("Replay identifier to retrieve"),
68+
delivery: DELIVERY_MODE.optional().describe(
69+
"How to return the replay payload. Defaults to 'file' (writes JSON to a temp file); 'resource' returns a rollbar:// link.",
70+
),
71+
},
72+
async ({ environment, sessionId, replayId, delivery }) => {
73+
const deliveryMode = delivery ?? "file";
74+
75+
const replayData = await fetchReplayData(
76+
environment,
77+
sessionId,
78+
replayId,
79+
);
80+
81+
const resourceUri = buildReplayResourceUri(
82+
environment,
83+
sessionId,
84+
replayId,
85+
);
86+
87+
cacheReplayData(resourceUri, replayData);
88+
89+
if (deliveryMode === "file") {
90+
const filePath = await writeReplayToFile(
91+
replayData,
92+
environment,
93+
sessionId,
94+
replayId,
95+
);
96+
97+
return {
98+
content: [
99+
{
100+
type: "text",
101+
text: `Replay ${replayId} for session ${sessionId} in ${environment} saved to ${filePath}. This file is not automatically deleted—remove it when finished or rerun with delivery="resource" for a rollbar:// link.`,
102+
},
103+
],
104+
};
105+
}
106+
107+
return {
108+
content: [
109+
{
110+
type: "text",
111+
text: `Replay ${replayId} for session ${sessionId} in ${environment} is available as ${resourceUri}. Use read-resource to download the JSON payload.`,
112+
},
113+
{
114+
type: "resource_link",
115+
name: resourceUri,
116+
title: `Replay ${replayId}`,
117+
uri: resourceUri,
118+
description: buildResourceLinkDescription(
119+
environment,
120+
sessionId,
121+
replayId,
122+
),
123+
mimeType: "application/json",
124+
},
125+
],
126+
};
127+
},
128+
);
129+
}

src/tools/get-top-items.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function registerGetTopItemsTool(server: McpServer) {
3333
content: [
3434
{
3535
type: "text",
36-
text: JSON.stringify(topItems, null, 2),
36+
text: JSON.stringify(topItems),
3737
},
3838
],
3939
};

src/tools/get-version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function registerGetVersionTool(server: McpServer) {
3434
content: [
3535
{
3636
type: "text",
37-
text: JSON.stringify(versionData, null, 2),
37+
text: JSON.stringify(versionData),
3838
},
3939
],
4040
};

src/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { registerGetVersionTool } from "./get-version.js";
55
import { registerGetTopItemsTool } from "./get-top-items.js";
66
import { registerListItemsTool } from "./list-items.js";
77
import { registerUpdateItemTool } from "./update-item.js";
8+
import { registerGetReplayTool } from "./get-replay.js";
89

910
export function registerAllTools(server: McpServer) {
1011
registerGetItemDetailsTool(server);
@@ -13,4 +14,5 @@ export function registerAllTools(server: McpServer) {
1314
registerGetTopItemsTool(server);
1415
registerListItemsTool(server);
1516
registerUpdateItemTool(server);
17+
registerGetReplayTool(server);
1618
}

0 commit comments

Comments
 (0)