Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ This MCP server implementes the `stdio` server type, which means your AI tool (e

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

`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`.

`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)

## How to Use
Expand Down Expand Up @@ -111,3 +113,4 @@ Configure your `.vscode/mcp.json` as follows:
}
```

Or using a local development installation - see CONTRIBUTING.md.
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerAllTools } from "./tools/index.js";
import { registerAllResources } from "./resources/index.js";

// Create server instance
const server = new McpServer({
Expand All @@ -29,11 +30,15 @@ const server = new McpServer({
description:
"List all items in the Rollbar project with optional search and filtering",
},
"get-replay": {
description: "Get replay data for a specific session replay in Rollbar",
},
},
},
});

// Register all tools
registerAllResources(server);
registerAllTools(server);

async function main() {
Expand Down
12 changes: 12 additions & 0 deletions src/resources/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerReplayResource } from "./replay-resource.js";

export function registerAllResources(server: McpServer) {
registerReplayResource(server);
}

export {
buildReplayResourceUri,
cacheReplayData,
fetchReplayData,
} from "./replay-resource.js";
159 changes: 159 additions & 0 deletions src/resources/replay-resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import type {
McpServer,
ReadResourceTemplateCallback,
} from "@modelcontextprotocol/sdk/server/mcp.js";
import { ROLLBAR_API_BASE } from "../config.js";
import { makeRollbarRequest } from "../utils/api.js";
import { RollbarApiResponse } from "../types/index.js";

const REPLAY_URI_TEMPLATE =
"rollbar://replay/{environment}/{sessionId}/{replayId}";
const REPLAY_RESOURCE_NAME = "rollbar-session-replay";
const REPLAY_RESOURCE_TITLE = "Rollbar Session Replay";
const REPLAY_MIME_TYPE = "application/json";
const CACHE_TTL_MS = 5 * 60 * 1000;

type ReplayCacheEntry = {
data: unknown;
expiresAt: number;
};

const replayCache = new Map<string, ReplayCacheEntry>();
const registeredServers = new WeakSet<McpServer>();

function normalizeTemplateVariable(
value: string | string[] | undefined,
): string {
if (Array.isArray(value)) {
return value[0] ?? "";
}

return value ?? "";
}

function buildReplayApiUrl(
environment: string,
sessionId: string,
replayId: string,
): string {
return `${ROLLBAR_API_BASE}/environment/${encodeURIComponent(
environment,
)}/session/${encodeURIComponent(sessionId)}/replay/${encodeURIComponent(
replayId,
)}`;
}

export function buildReplayResourceUri(
environment: string,
sessionId: string,
replayId: string,
): string {
return `rollbar://replay/${encodeURIComponent(
environment,
)}/${encodeURIComponent(sessionId)}/${encodeURIComponent(replayId)}`;
}

export function cacheReplayData(uri: string, data: unknown) {
replayCache.set(uri, { data, expiresAt: Date.now() + CACHE_TTL_MS });
}

function getCachedReplayData(uri: string) {
const cached = replayCache.get(uri);
if (!cached) {
return undefined;
}

if (cached.expiresAt < Date.now()) {
replayCache.delete(uri);
return undefined;
}

return cached.data;
}

export async function fetchReplayData(
environment: string,
sessionId: string,
replayId: string,
): Promise<unknown> {
const replayUrl = buildReplayApiUrl(environment, sessionId, replayId);

const replayResponse = await makeRollbarRequest<RollbarApiResponse<unknown>>(
replayUrl,
"get-replay",
);

if (replayResponse.err !== 0) {
const errorMessage =
replayResponse.message || `Unknown error (code: ${replayResponse.err})`;
throw new Error(`Rollbar API returned error: ${errorMessage}`);
}

return replayResponse.result;
}

const readReplayResource: ReadResourceTemplateCallback = async (
uri,
variables,
) => {
const environmentValue = normalizeTemplateVariable(variables.environment);
const sessionValue = normalizeTemplateVariable(variables.sessionId);
const replayValue = normalizeTemplateVariable(variables.replayId);

const environment = environmentValue
? decodeURIComponent(environmentValue)
: "";
const sessionId = sessionValue ? decodeURIComponent(sessionValue) : "";
const replayId = replayValue ? decodeURIComponent(replayValue) : "";

if (!environment || !sessionId || !replayId) {
throw new Error("Invalid replay resource URI");
}

const resourceUri = buildReplayResourceUri(environment, sessionId, replayId);
const cached = getCachedReplayData(resourceUri);

const replayData =
cached !== undefined
? cached
: await fetchReplayData(environment, sessionId, replayId);

if (cached === undefined) {
cacheReplayData(resourceUri, replayData);
}

return {
contents: [
{
uri: uri.toString(),
mimeType: REPLAY_MIME_TYPE,
text: JSON.stringify(replayData),
},
],
};
};

export function registerReplayResource(server: McpServer) {
if (registeredServers.has(server)) {
return;
}

const template = new ResourceTemplate(REPLAY_URI_TEMPLATE, {
list: () => ({ resources: [] }),
});

server.resource(
REPLAY_RESOURCE_NAME,
template,
{
title: REPLAY_RESOURCE_TITLE,
description:
"Session replay payloads returned from the Rollbar Replay API.",
mimeType: REPLAY_MIME_TYPE,
},
readReplayResource,
);

registeredServers.add(server);
}
2 changes: 1 addition & 1 deletion src/tools/get-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function registerGetDeploymentsTool(server: McpServer) {
content: [
{
type: "text",
text: JSON.stringify(deployments, null, 2),
text: JSON.stringify(deployments),
},
],
};
Expand Down
4 changes: 2 additions & 2 deletions src/tools/get-item-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function registerGetItemDetailsTool(server: McpServer) {
content: [
{
type: "text",
text: JSON.stringify(item, null, 2),
text: JSON.stringify(item),
},
],
};
Expand All @@ -73,7 +73,7 @@ export function registerGetItemDetailsTool(server: McpServer) {
content: [
{
type: "text",
text: JSON.stringify(responseData, null),
text: JSON.stringify(responseData),
},
],
};
Expand Down
129 changes: 129 additions & 0 deletions src/tools/get-replay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { tmpdir } from "node:os";
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
buildReplayResourceUri,
cacheReplayData,
fetchReplayData,
} from "../resources/index.js";

function buildResourceLinkDescription(
environment: string,
sessionId: string,
replayId: string,
) {
return `Session replay payload for session ${sessionId} (${environment}) replay ${replayId}.`;
}

const DELIVERY_MODE = z.enum(["resource", "file"]);
const REPLAY_FILE_DIRECTORY = path.join(tmpdir(), "rollbar-mcp-replays");

function sanitizeForFilename(value: string) {
return value.replace(/[^a-z0-9-_]+/gi, "-").replace(/-+/g, "-");
}

async function writeReplayToFile(
replayData: unknown,
environment: string,
sessionId: string,
replayId: string,
) {
await mkdir(REPLAY_FILE_DIRECTORY, { recursive: true });
const uniqueSuffix = `${Date.now()}-${Math.random()
.toString(36)
.slice(2, 10)}`;
const fileName = [
"replay",
sanitizeForFilename(environment),
sanitizeForFilename(sessionId),
sanitizeForFilename(replayId),
uniqueSuffix,
]
.filter(Boolean)
.join("_")
.replace(/_+/g, "_")
.concat(".json");

const filePath = path.join(REPLAY_FILE_DIRECTORY, fileName);
await writeFile(filePath, JSON.stringify(replayData, null, 2), "utf8");
return filePath;
}

export function registerGetReplayTool(server: McpServer) {
server.tool(
"get-replay",
"Get replay data for a specific session replay in Rollbar",
{
environment: z
.string()
.min(1)
.describe("Environment name (e.g., production)"),
sessionId: z
.string()
.min(1)
.describe("Session identifier that owns the replay"),
replayId: z.string().min(1).describe("Replay identifier to retrieve"),
delivery: DELIVERY_MODE.optional().describe(
"How to return the replay payload. Defaults to 'file' (writes JSON to a temp file); 'resource' returns a rollbar:// link.",
),
},
async ({ environment, sessionId, replayId, delivery }) => {
const deliveryMode = delivery ?? "file";

const replayData = await fetchReplayData(
environment,
sessionId,
replayId,
);

const resourceUri = buildReplayResourceUri(
environment,
sessionId,
replayId,
);

cacheReplayData(resourceUri, replayData);

if (deliveryMode === "file") {
const filePath = await writeReplayToFile(
replayData,
environment,
sessionId,
replayId,
);

return {
content: [
{
type: "text",
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.`,
},
],
};
}

return {
content: [
{
type: "text",
text: `Replay ${replayId} for session ${sessionId} in ${environment} is available as ${resourceUri}. Use read-resource to download the JSON payload.`,
},
{
type: "resource_link",
name: resourceUri,
title: `Replay ${replayId}`,
uri: resourceUri,
description: buildResourceLinkDescription(
environment,
sessionId,
replayId,
),
mimeType: "application/json",
},
],
};
},
);
}
2 changes: 1 addition & 1 deletion src/tools/get-top-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function registerGetTopItemsTool(server: McpServer) {
content: [
{
type: "text",
text: JSON.stringify(topItems, null, 2),
text: JSON.stringify(topItems),
},
],
};
Expand Down
2 changes: 1 addition & 1 deletion src/tools/get-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function registerGetVersionTool(server: McpServer) {
content: [
{
type: "text",
text: JSON.stringify(versionData, null, 2),
text: JSON.stringify(versionData),
},
],
};
Expand Down
2 changes: 2 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { registerGetVersionTool } from "./get-version.js";
import { registerGetTopItemsTool } from "./get-top-items.js";
import { registerListItemsTool } from "./list-items.js";
import { registerUpdateItemTool } from "./update-item.js";
import { registerGetReplayTool } from "./get-replay.js";

export function registerAllTools(server: McpServer) {
registerGetItemDetailsTool(server);
Expand All @@ -13,4 +14,5 @@ export function registerAllTools(server: McpServer) {
registerGetTopItemsTool(server);
registerListItemsTool(server);
registerUpdateItemTool(server);
registerGetReplayTool(server);
}
Loading