diff --git a/README.md b/README.md index 4aa91ed..c6c9a7d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,26 @@ It is based on [Cloudflare's work towards remote MCPs](https://blog.cloudflare.c ## Getting Started +### Stdio vs Remote + +While this repository is primarily servicing a remote MCP use-case, we also support a stdio transport. + +Note: This is currently a draft and not available via a distribution. + +You will need to ensure your token is provisioned with the necessary scopes. As of writing this is: + +``` +org:read project:read project:write team:read team:write event:read +``` + +You can find the canonical reference to the needed scopes in the [source code](https://github.com/getsentry/sentry-mcp/blob/main/src/routes/auth.ts). + +Launching the stdio transport will just require you to bind `SENTRY_AUTH_TOKEN` and run the provided script: + +```shell +SENTRY_AUTH_TOKEN= npm run start:stdio +``` + ### Self-Hosted Sentry You can override the `SENTRY_URL` env variable to set your base Sentry url: diff --git a/package.json b/package.json index f48e454..0a5c9a0 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.0.1", "private": true, "scripts": { + "build": "tsc", "deploy": "wrangler deploy", "dev": "wrangler dev", "format": "biome format --write", @@ -10,6 +11,7 @@ "lint:fix": "biome lint --fix", "inspector": "pnpx @modelcontextprotocol/inspector@latest", "start": "wrangler dev", + "start:stdio": "tsx dist/mcp/start-stdio.js", "cf-typegen": "wrangler types", "postinstall": "simple-git-hooks", "test": "vitest", @@ -43,7 +45,7 @@ "pre-commit": "pnpm exec lint-staged --concurrent false" }, "lint-staged": { - "*": ["biome format --write"], + "**/*.{ts,tsx,json}": ["biome format --write"], "**/*.{ts,tsx}": ["biome lint --fix"] } } diff --git a/src/index.ts b/src/index.ts index 8f8095a..0b956ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,16 @@ import { withSentry } from "@sentry/cloudflare"; import OAuthProvider from "@cloudflare/workers-oauth-provider"; import app from "./app"; -import SentryMCP from "./mcp/server"; +import SentryMCPWorker from "./mcp/transports/cloudflare-worker"; import { SCOPES } from "./routes/auth"; // required for Durable Objects -export { default as SentryMCP } from "./mcp/server"; +export { SentryMCPWorker }; const oAuthProvider = new OAuthProvider({ apiRoute: "/sse", // @ts-ignore - apiHandler: SentryMCP.mount("/sse"), + apiHandler: SentryMCPWorker.mount("/sse"), // @ts-ignore defaultHandler: app, authorizeEndpoint: "/authorize", diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2e0c518..99324a8 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,55 +1,49 @@ -import { McpAgent } from "agents/mcp"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { Props } from "../types"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ServerContext } from "../types"; import { logError } from "../lib/logging"; import { TOOL_DEFINITIONS, TOOL_HANDLERS } from "./tools"; -// Context from the auth process, encrypted & stored in the auth token -// and provided to the DurableMCP as this.props -export default class SentryMCP extends McpAgent { - server = new McpServer({ - name: "Sentry MCP", - version: "0.1.0", - }); +export function configureServer(server: McpServer, context: ServerContext) { + server.server.onerror = (error) => { + logError(error); + }; - async init() { - for (const tool of TOOL_DEFINITIONS) { - const handler = TOOL_HANDLERS[tool.name]; + for (const tool of TOOL_DEFINITIONS) { + const handler = TOOL_HANDLERS[tool.name]; - this.server.tool( - tool.name as string, - tool.description, - tool.paramsSchema ? tool.paramsSchema : {}, - async (...args) => { - try { - // TODO(dcramer): I'm too dumb to figure this out - // @ts-ignore - const output = await handler(this.props, ...args); + server.tool( + tool.name as string, + tool.description, + tool.paramsSchema ? tool.paramsSchema : {}, + async (...args) => { + try { + // TODO(dcramer): I'm too dumb to figure this out + // @ts-ignore + const output = await handler(context, ...args); - return { - content: [ - { - type: "text", - text: output, - }, - ], - }; - } catch (error) { - logError(error); - return { - content: [ - { - type: "text", - text: `**Error**\n\nIt looks like there was a problem communicating with the Sentry API:\n\n${ - error instanceof Error ? error.message : String(error) - }`, - }, - ], - isError: true, - }; - } - }, - ); - } + return { + content: [ + { + type: "text", + text: output, + }, + ], + }; + } catch (error) { + logError(error); + return { + content: [ + { + type: "text", + text: `**Error**\n\nIt looks like there was a problem communicating with the Sentry API:\n\n${ + error instanceof Error ? error.message : String(error) + }`, + }, + ], + isError: true, + }; + } + }, + ); } } diff --git a/src/mcp/start-stdio.ts b/src/mcp/start-stdio.ts new file mode 100644 index 0000000..1310ac5 --- /dev/null +++ b/src/mcp/start-stdio.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { startStdio } from "./transports/stdio.js"; + +const accessToken = process.env.SENTRY_AUTH_TOKEN; + +if (!accessToken) { + console.error("SENTRY_AUTH_TOKEN is not set"); + process.exit(1); +} + +const server = new McpServer({ + name: "Sentry MCP", + version: "0.1.0", +}); + +// XXX: we could do what we're doing in routes/auth.ts and pass the context +// identically, but we don't really need userId and userName yet +startStdio(server, { + accessToken, + organizationSlug: null, +}).catch((err) => { + console.error("Server error:", err); + process.exit(1); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index f2f9944..eefd0f2 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1,4 +1,4 @@ -import type { Props } from "../types"; +import type { ServerContext } from "../types"; import { z } from "zod"; import type { SentryErrorEntrySchema, @@ -66,7 +66,7 @@ export const TOOL_DEFINITIONS = [ "- View all teams in a Sentry organization", ].join("\n"), paramsSchema: { - organizationSlug: ParamOrganizationSlug, + organizationSlug: ParamOrganizationSlug.optional(), }, }, { @@ -78,7 +78,7 @@ export const TOOL_DEFINITIONS = [ "- View all projects in a Sentry organization", ].join("\n"), paramsSchema: { - organizationSlug: ParamOrganizationSlug, + organizationSlug: ParamOrganizationSlug.optional(), }, }, { @@ -131,7 +131,7 @@ export const TOOL_DEFINITIONS = [ "- Create a new team in a Sentry organization", ].join("\n"), paramsSchema: { - organizationSlug: ParamOrganizationSlug, + organizationSlug: ParamOrganizationSlug.optional(), name: z.string().describe("The name of the team to create."), }, }, @@ -178,7 +178,7 @@ export type ToolHandler = ( ) => Promise; export type ToolHandlerExtended = ( - props: Props, + context: ServerContext, params: ToolParams, extra: RequestHandlerExtra, ) => Promise; @@ -188,8 +188,8 @@ export type ToolHandlers = { }; export const TOOL_HANDLERS = { - list_organizations: async (props) => { - const apiService = new SentryApiService(props.accessToken); + list_organizations: async (context) => { + const apiService = new SentryApiService(context.accessToken); const organizations = await apiService.listOrganizations(); let output = "# Organizations\n\n"; @@ -197,11 +197,15 @@ export const TOOL_HANDLERS = { return output; }, - list_teams: async (props, { organizationSlug }) => { - const apiService = new SentryApiService(props.accessToken); + list_teams: async (context, { organizationSlug }) => { + const apiService = new SentryApiService(context.accessToken); + + if (!organizationSlug && context.organizationSlug) { + organizationSlug = context.organizationSlug; + } if (!organizationSlug) { - organizationSlug = props.organizationSlug; + throw new Error("Organization slug is required"); } const teams = await apiService.listTeams(organizationSlug); @@ -211,11 +215,15 @@ export const TOOL_HANDLERS = { return output; }, - list_projects: async (props, { organizationSlug }) => { - const apiService = new SentryApiService(props.accessToken); + list_projects: async (context, { organizationSlug }) => { + const apiService = new SentryApiService(context.accessToken); + + if (!organizationSlug && context.organizationSlug) { + organizationSlug = context.organizationSlug; + } if (!organizationSlug) { - organizationSlug = props.organizationSlug; + throw new Error("Organization slug is required"); } const projects = await apiService.listProjects(organizationSlug); @@ -225,8 +233,11 @@ export const TOOL_HANDLERS = { return output; }, - get_error_details: async (props, { issueId, issueUrl, organizationSlug }) => { - const apiService = new SentryApiService(props.accessToken); + get_error_details: async ( + context, + { issueId, issueUrl, organizationSlug }, + ) => { + const apiService = new SentryApiService(context.accessToken); if (issueUrl) { const resolved = extractIssueId(issueUrl); @@ -241,8 +252,12 @@ export const TOOL_HANDLERS = { throw new Error("Either issueId or issueUrl must be provided"); } + if (!organizationSlug && context.organizationSlug) { + organizationSlug = context.organizationSlug; + } + if (!organizationSlug) { - organizationSlug = props.organizationSlug; + throw new Error("Organization slug is required"); } const event = await apiService.getLatestEventForIssue({ @@ -269,13 +284,17 @@ export const TOOL_HANDLERS = { }, search_errors_in_file: async ( - props, + context, { filename, sortBy, organizationSlug }, ) => { - const apiService = new SentryApiService(props.accessToken); + const apiService = new SentryApiService(context.accessToken); + + if (!organizationSlug && context.organizationSlug) { + organizationSlug = context.organizationSlug; + } if (!organizationSlug) { - organizationSlug = props.organizationSlug; + throw new Error("Organization slug is required"); } const eventList = await apiService.searchErrors({ @@ -305,11 +324,15 @@ export const TOOL_HANDLERS = { return output; }, - create_team: async (props, { organizationSlug, name }) => { - const apiService = new SentryApiService(props.accessToken); + create_team: async (context, { organizationSlug, name }) => { + const apiService = new SentryApiService(context.accessToken); + + if (!organizationSlug && context.organizationSlug) { + organizationSlug = context.organizationSlug; + } if (!organizationSlug) { - organizationSlug = props.organizationSlug; + throw new Error("Organization slug is required"); } const team = await apiService.createTeam({ @@ -326,13 +349,17 @@ export const TOOL_HANDLERS = { }, create_project: async ( - props, + context, { organizationSlug, teamSlug, name, platform }, ) => { - const apiService = new SentryApiService(props.accessToken); + const apiService = new SentryApiService(context.accessToken); + + if (!organizationSlug && context.organizationSlug) { + organizationSlug = context.organizationSlug; + } if (!organizationSlug) { - organizationSlug = props.organizationSlug; + throw new Error("Organization slug is required"); } const [project, clientKey] = await apiService.createProject({ diff --git a/src/mcp/transports/cloudflare-worker.ts b/src/mcp/transports/cloudflare-worker.ts new file mode 100644 index 0000000..e648765 --- /dev/null +++ b/src/mcp/transports/cloudflare-worker.ts @@ -0,0 +1,21 @@ +import { McpAgent } from "agents/mcp"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { WorkerProps } from "../../types"; +import { configureServer } from "../server"; + +// Context from the auth process, encrypted & stored in the auth token +// and provided to the DurableMCP as this.props +export default class SentryMCPWorker extends McpAgent< + Env, + unknown, + WorkerProps +> { + server = new McpServer({ + name: "Sentry MCP", + version: "0.1.0", + }); + + async init() { + configureServer(this.server, this.props); + } +} diff --git a/src/mcp/transports/stdio.ts b/src/mcp/transports/stdio.ts new file mode 100644 index 0000000..c7a1105 --- /dev/null +++ b/src/mcp/transports/stdio.ts @@ -0,0 +1,10 @@ +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { configureServer } from "../server"; +import type { ServerContext } from "../../types"; + +export async function startStdio(server: McpServer, context: ServerContext) { + const transport = new StdioServerTransport(); + configureServer(server, context); + await server.connect(transport); +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 9f8b2ed..0ff134b 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -7,7 +7,7 @@ import { exchangeCodeForAccessToken, getUpstreamAuthorizeUrl, } from "../lib/oauth"; -import type { Props } from "../types"; +import type { WorkerProps } from "../types"; import { SentryApiService } from "../lib/sentry-api"; export const SENTRY_AUTH_URL = "/oauth/authorize/"; @@ -105,7 +105,7 @@ export default new Hono<{ name: payload.user.name, accessToken: payload.access_token, organizationSlug: orgsList[0].slug, - } as Props, + } as WorkerProps, }); return Response.redirect(redirectTo); diff --git a/src/routes/home.tsx b/src/routes/home.tsx index 7d9aa89..855df57 100644 --- a/src/routes/home.tsx +++ b/src/routes/home.tsx @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import type SentryMCP from "../mcp/server"; +import type SentryMCPWorker from "../mcp/transports/cloudflare-worker"; import { css, Style } from "hono/css"; import { TOOL_DEFINITIONS } from "../mcp/tools"; @@ -145,7 +145,7 @@ const globalStyles = css` export default new Hono<{ Bindings: Env & { - MCP_OBJECT: DurableObjectNamespace; + MCP_OBJECT: DurableObjectNamespace; }; }>().get("/", async (c) => { const mcpSnippet = JSON.stringify( diff --git a/src/types.ts b/src/types.ts index 5d3f6ee..32f14fc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,9 @@ -export type Props = { +export type ServerContext = { + accessToken: string; + organizationSlug: string | null; +}; + +export type WorkerProps = ServerContext & { id: string; name: string; - accessToken: string; - organizationSlug: string; }; diff --git a/tsconfig.json b/tsconfig.json index 213224c..b9900dc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,43 +1,29 @@ { + "include": ["src/**/*", "worker-configuration.d.ts"], + "exclude": ["node_modules"], "compilerOptions": { - /* Visit https://aka.ms/tsconfig.json to read more about this file */ - - /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ "target": "es2021", - /* Specify a set of bundled library declaration files that describe the target runtime environment. */ "lib": ["es2021"], - /* Specify what JSX code is generated. */ "jsx": "react-jsx", "jsxImportSource": "hono/jsx", - /* Specify what module code is generated. */ + "rootDir": "./src", + "outDir": "./dist", + "module": "es2022", - /* Specify how TypeScript looks up a file from a given module specifier. */ "moduleResolution": "Bundler", - /* Specify type package names to be included without being referenced in a source file. */ "types": ["@cloudflare/vitest-pool-workers"], - /* Enable importing .json files */ "resolveJsonModule": true, - /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ "allowJs": true, - /* Enable error reporting in type-checked JavaScript files. */ "checkJs": false, - /* Disable emitting files from a compilation. */ - "noEmit": true, - - /* Ensure that each file can be safely transpiled without relying on other imports. */ "isolatedModules": true, - /* Allow 'import x from y' when a module doesn't have a default export. */ "allowSyntheticDefaultImports": true, - /* Ensure that casing is correct in imports. */ "forceConsistentCasingInFileNames": true, - /* Enable all strict type-checking options. */ "strict": true, - /* Skip type checking all .d.ts files. */ "skipLibCheck": true } } diff --git a/wrangler.jsonc b/wrangler.jsonc index 263f9c2..d6cb445 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -10,7 +10,7 @@ "compatibility_flags": ["nodejs_compat"], "migrations": [ { - "new_sqlite_classes": ["SentryMCP"], + "new_sqlite_classes": ["SentryMCPWorker"], "tag": "v1" } ],