This is page 7 of 11. Use http://codebase.md/getsentry/sentry-mcp?lines=false&page={x} to view the full context.
# Directory Structure
```
├── .claude
│ ├── agents
│ │ └── claude-optimizer.md
│ ├── commands
│ │ ├── gh-pr.md
│ │ └── gh-review.md
│ └── settings.json
├── .craft.yml
├── .cursor
│ └── mcp.json
├── .env.example
├── .github
│ └── workflows
│ ├── deploy.yml
│ ├── eval.yml
│ ├── merge-jobs.yml
│ ├── release.yml
│ ├── smoke-tests.yml
│ └── test.yml
├── .gitignore
├── .mcp.json
├── .vscode
│ ├── extensions.json
│ ├── mcp.json
│ └── settings.json
├── AGENTS.md
├── bin
│ └── bump-version.sh
├── biome.json
├── CLAUDE.md
├── codecov.yml
├── core
├── docs
│ ├── adding-tools.mdc
│ ├── api-patterns.mdc
│ ├── architecture.mdc
│ ├── cloudflare
│ │ ├── architecture.md
│ │ ├── deployment.md
│ │ ├── oauth-architecture.md
│ │ └── overview.md
│ ├── coding-guidelines.mdc
│ ├── common-patterns.mdc
│ ├── cursor.mdc
│ ├── deployment.mdc
│ ├── error-handling.mdc
│ ├── github-actions.mdc
│ ├── llms
│ │ ├── document-scopes.mdc
│ │ ├── documentation-style-guide.mdc
│ │ └── README.md
│ ├── logging.mdc
│ ├── monitoring.mdc
│ ├── permissions-and-scopes.md
│ ├── pr-management.mdc
│ ├── quality-checks.mdc
│ ├── README.md
│ ├── search-events-api-patterns.md
│ ├── security.mdc
│ ├── specs
│ │ ├── README.md
│ │ ├── search-events.md
│ │ └── subpath-constraints.md
│ └── testing.mdc
├── LICENSE.md
├── Makefile
├── package.json
├── packages
│ ├── mcp-cloudflare
│ │ ├── .env.example
│ │ ├── components.json
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── favicon.ico
│ │ │ ├── flow-transparent.png
│ │ │ └── flow.jpg
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── app.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── chat
│ │ │ │ │ │ ├── auth-form.tsx
│ │ │ │ │ │ ├── chat-input.tsx
│ │ │ │ │ │ ├── chat-message.tsx
│ │ │ │ │ │ ├── chat-messages.tsx
│ │ │ │ │ │ ├── chat-ui.tsx
│ │ │ │ │ │ ├── chat.tsx
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── tool-invocation.tsx
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── fragments
│ │ │ │ │ │ ├── remote-setup.tsx
│ │ │ │ │ │ ├── setup-guide.tsx
│ │ │ │ │ │ └── stdio-setup.tsx
│ │ │ │ │ └── ui
│ │ │ │ │ ├── accordion.tsx
│ │ │ │ │ ├── backdrop.tsx
│ │ │ │ │ ├── base.tsx
│ │ │ │ │ ├── button.tsx
│ │ │ │ │ ├── code-snippet.tsx
│ │ │ │ │ ├── header.tsx
│ │ │ │ │ ├── icon.tsx
│ │ │ │ │ ├── icons
│ │ │ │ │ │ └── sentry.tsx
│ │ │ │ │ ├── interactive-markdown.tsx
│ │ │ │ │ ├── json-schema-params.tsx
│ │ │ │ │ ├── markdown.tsx
│ │ │ │ │ ├── note.tsx
│ │ │ │ │ ├── prose.tsx
│ │ │ │ │ ├── section.tsx
│ │ │ │ │ ├── slash-command-actions.tsx
│ │ │ │ │ ├── slash-command-text.tsx
│ │ │ │ │ ├── sliding-panel.tsx
│ │ │ │ │ ├── template-vars.tsx
│ │ │ │ │ ├── tool-actions.tsx
│ │ │ │ │ └── typewriter.tsx
│ │ │ │ ├── contexts
│ │ │ │ │ └── auth-context.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ ├── use-mcp-metadata.ts
│ │ │ │ │ ├── use-persisted-chat.ts
│ │ │ │ │ ├── use-scroll-lock.ts
│ │ │ │ │ └── use-streaming-simulation.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── instrument.ts
│ │ │ │ ├── lib
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── main.tsx
│ │ │ │ ├── pages
│ │ │ │ │ └── home.tsx
│ │ │ │ ├── utils
│ │ │ │ │ ├── chat-error-handler.ts
│ │ │ │ │ └── index.ts
│ │ │ │ └── vite-env.d.ts
│ │ │ ├── constants.ts
│ │ │ ├── server
│ │ │ │ ├── app.test.ts
│ │ │ │ ├── app.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── approval-dialog.test.ts
│ │ │ │ │ ├── approval-dialog.ts
│ │ │ │ │ ├── constraint-utils.test.ts
│ │ │ │ │ ├── constraint-utils.ts
│ │ │ │ │ ├── html-utils.ts
│ │ │ │ │ ├── mcp-handler.test.ts
│ │ │ │ │ ├── mcp-handler.ts
│ │ │ │ │ └── slug-validation.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── oauth
│ │ │ │ │ ├── authorize.test.ts
│ │ │ │ │ ├── callback.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── helpers.test.ts
│ │ │ │ │ ├── helpers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── authorize.ts
│ │ │ │ │ │ ├── callback.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ └── state.ts
│ │ │ │ ├── routes
│ │ │ │ │ ├── chat-oauth.ts
│ │ │ │ │ ├── chat.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── metadata.ts
│ │ │ │ │ ├── search.test.ts
│ │ │ │ │ └── search.ts
│ │ │ │ ├── sentry.config.ts
│ │ │ │ ├── types
│ │ │ │ │ └── chat.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils
│ │ │ │ └── auth-errors.ts
│ │ │ └── test-setup.ts
│ │ ├── tsconfig.client.json
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ ├── tsconfig.server.json
│ │ ├── vite.config.ts
│ │ ├── vitest.config.ts
│ │ ├── worker-configuration.d.ts
│ │ ├── wrangler.canary.jsonc
│ │ └── wrangler.jsonc
│ ├── mcp-server
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── scripts
│ │ │ ├── generate-definitions.ts
│ │ │ └── generate-otel-namespaces.ts
│ │ ├── src
│ │ │ ├── api-client
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── client.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── types.ts
│ │ │ ├── cli
│ │ │ │ ├── parse.test.ts
│ │ │ │ ├── parse.ts
│ │ │ │ ├── resolve.test.ts
│ │ │ │ ├── resolve.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── usage.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── index.ts
│ │ │ ├── internal
│ │ │ │ ├── agents
│ │ │ │ │ ├── callEmbeddedAgent.ts
│ │ │ │ │ ├── openai-provider.ts
│ │ │ │ │ └── tools
│ │ │ │ │ ├── data
│ │ │ │ │ │ ├── __namespaces.json
│ │ │ │ │ │ ├── android.json
│ │ │ │ │ │ ├── app.json
│ │ │ │ │ │ ├── artifact.json
│ │ │ │ │ │ ├── aspnetcore.json
│ │ │ │ │ │ ├── aws.json
│ │ │ │ │ │ ├── azure.json
│ │ │ │ │ │ ├── browser.json
│ │ │ │ │ │ ├── cassandra.json
│ │ │ │ │ │ ├── cicd.json
│ │ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ │ ├── client.json
│ │ │ │ │ │ ├── cloud.json
│ │ │ │ │ │ ├── cloudevents.json
│ │ │ │ │ │ ├── cloudfoundry.json
│ │ │ │ │ │ ├── code.json
│ │ │ │ │ │ ├── container.json
│ │ │ │ │ │ ├── cpu.json
│ │ │ │ │ │ ├── cpython.json
│ │ │ │ │ │ ├── database.json
│ │ │ │ │ │ ├── db.json
│ │ │ │ │ │ ├── deployment.json
│ │ │ │ │ │ ├── destination.json
│ │ │ │ │ │ ├── device.json
│ │ │ │ │ │ ├── disk.json
│ │ │ │ │ │ ├── dns.json
│ │ │ │ │ │ ├── dotnet.json
│ │ │ │ │ │ ├── elasticsearch.json
│ │ │ │ │ │ ├── enduser.json
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ ├── faas.json
│ │ │ │ │ │ ├── feature_flags.json
│ │ │ │ │ │ ├── file.json
│ │ │ │ │ │ ├── gcp.json
│ │ │ │ │ │ ├── gen_ai.json
│ │ │ │ │ │ ├── geo.json
│ │ │ │ │ │ ├── go.json
│ │ │ │ │ │ ├── graphql.json
│ │ │ │ │ │ ├── hardware.json
│ │ │ │ │ │ ├── heroku.json
│ │ │ │ │ │ ├── host.json
│ │ │ │ │ │ ├── http.json
│ │ │ │ │ │ ├── ios.json
│ │ │ │ │ │ ├── jvm.json
│ │ │ │ │ │ ├── k8s.json
│ │ │ │ │ │ ├── linux.json
│ │ │ │ │ │ ├── log.json
│ │ │ │ │ │ ├── mcp.json
│ │ │ │ │ │ ├── messaging.json
│ │ │ │ │ │ ├── network.json
│ │ │ │ │ │ ├── nodejs.json
│ │ │ │ │ │ ├── oci.json
│ │ │ │ │ │ ├── opentracing.json
│ │ │ │ │ │ ├── os.json
│ │ │ │ │ │ ├── otel.json
│ │ │ │ │ │ ├── peer.json
│ │ │ │ │ │ ├── process.json
│ │ │ │ │ │ ├── profile.json
│ │ │ │ │ │ ├── rpc.json
│ │ │ │ │ │ ├── server.json
│ │ │ │ │ │ ├── service.json
│ │ │ │ │ │ ├── session.json
│ │ │ │ │ │ ├── signalr.json
│ │ │ │ │ │ ├── source.json
│ │ │ │ │ │ ├── system.json
│ │ │ │ │ │ ├── telemetry.json
│ │ │ │ │ │ ├── test.json
│ │ │ │ │ │ ├── thread.json
│ │ │ │ │ │ ├── tls.json
│ │ │ │ │ │ ├── url.json
│ │ │ │ │ │ ├── user.json
│ │ │ │ │ │ ├── v8js.json
│ │ │ │ │ │ ├── vcs.json
│ │ │ │ │ │ ├── webengine.json
│ │ │ │ │ │ └── zos.json
│ │ │ │ │ ├── dataset-fields.test.ts
│ │ │ │ │ ├── dataset-fields.ts
│ │ │ │ │ ├── otel-semantics.test.ts
│ │ │ │ │ ├── otel-semantics.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ ├── whoami.test.ts
│ │ │ │ │ └── whoami.ts
│ │ │ │ ├── constraint-helpers.test.ts
│ │ │ │ ├── constraint-helpers.ts
│ │ │ │ ├── context-storage.ts
│ │ │ │ ├── error-handling.ts
│ │ │ │ ├── fetch-utils.test.ts
│ │ │ │ ├── fetch-utils.ts
│ │ │ │ ├── formatting.test.ts
│ │ │ │ ├── formatting.ts
│ │ │ │ ├── issue-helpers.test.ts
│ │ │ │ ├── issue-helpers.ts
│ │ │ │ ├── test-fixtures.ts
│ │ │ │ └── tool-helpers
│ │ │ │ ├── api.test.ts
│ │ │ │ ├── api.ts
│ │ │ │ ├── define.ts
│ │ │ │ ├── enhance-error.ts
│ │ │ │ ├── formatting.ts
│ │ │ │ ├── issue.ts
│ │ │ │ ├── seer.test.ts
│ │ │ │ ├── seer.ts
│ │ │ │ ├── validate-region-url.test.ts
│ │ │ │ └── validate-region-url.ts
│ │ │ ├── permissions.parseScopes.test.ts
│ │ │ ├── permissions.ts
│ │ │ ├── schema.ts
│ │ │ ├── server-context.test.ts
│ │ │ ├── server.ts
│ │ │ ├── telem
│ │ │ │ ├── index.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── sentry.test.ts
│ │ │ │ └── sentry.ts
│ │ │ ├── test-setup.ts
│ │ │ ├── test-utils
│ │ │ │ └── context.ts
│ │ │ ├── toolDefinitions.ts
│ │ │ ├── tools
│ │ │ │ ├── analyze-issue-with-seer.test.ts
│ │ │ │ ├── analyze-issue-with-seer.ts
│ │ │ │ ├── create-dsn.test.ts
│ │ │ │ ├── create-dsn.ts
│ │ │ │ ├── create-project.test.ts
│ │ │ │ ├── create-project.ts
│ │ │ │ ├── create-team.test.ts
│ │ │ │ ├── create-team.ts
│ │ │ │ ├── find-dsns.test.ts
│ │ │ │ ├── find-dsns.ts
│ │ │ │ ├── find-organizations.test.ts
│ │ │ │ ├── find-organizations.ts
│ │ │ │ ├── find-projects.test.ts
│ │ │ │ ├── find-projects.ts
│ │ │ │ ├── find-releases.test.ts
│ │ │ │ ├── find-releases.ts
│ │ │ │ ├── find-teams.test.ts
│ │ │ │ ├── find-teams.ts
│ │ │ │ ├── get-doc.test.ts
│ │ │ │ ├── get-doc.ts
│ │ │ │ ├── get-event-attachment.test.ts
│ │ │ │ ├── get-event-attachment.ts
│ │ │ │ ├── get-issue-details.test.ts
│ │ │ │ ├── get-issue-details.ts
│ │ │ │ ├── get-trace-details.test.ts
│ │ │ │ ├── get-trace-details.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── search-docs.test.ts
│ │ │ │ ├── search-docs.ts
│ │ │ │ ├── search-events
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── utils.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── search-events.test.ts
│ │ │ │ ├── search-issues
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── README.md
│ │ │ │ ├── tools.test.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── update-issue.test.ts
│ │ │ │ ├── update-issue.ts
│ │ │ │ ├── update-project.test.ts
│ │ │ │ ├── update-project.ts
│ │ │ │ ├── whoami.test.ts
│ │ │ │ └── whoami.ts
│ │ │ ├── transports
│ │ │ │ └── stdio.ts
│ │ │ ├── types.ts
│ │ │ ├── utils
│ │ │ │ ├── slug-validation.test.ts
│ │ │ │ ├── slug-validation.ts
│ │ │ │ ├── url-utils.test.ts
│ │ │ │ └── url-utils.ts
│ │ │ └── version.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ ├── mcp-server-evals
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── bin
│ │ │ │ └── start-mock-stdio.ts
│ │ │ ├── evals
│ │ │ │ ├── autofix.eval.ts
│ │ │ │ ├── create-dsn.eval.ts
│ │ │ │ ├── create-project.eval.ts
│ │ │ │ ├── create-team.eval.ts
│ │ │ │ ├── get-issue.eval.ts
│ │ │ │ ├── get-trace-details.eval.ts
│ │ │ │ ├── list-dsns.eval.ts
│ │ │ │ ├── list-issues.eval.ts
│ │ │ │ ├── list-organizations.eval.ts
│ │ │ │ ├── list-projects.eval.ts
│ │ │ │ ├── list-releases.eval.ts
│ │ │ │ ├── list-tags.eval.ts
│ │ │ │ ├── list-teams.eval.ts
│ │ │ │ ├── search-docs.eval.ts
│ │ │ │ ├── search-events-agent.eval.ts
│ │ │ │ ├── search-events.eval.ts
│ │ │ │ ├── search-issues-agent.eval.ts
│ │ │ │ ├── search-issues.eval.ts
│ │ │ │ ├── update-issue.eval.ts
│ │ │ │ ├── update-project.eval.ts
│ │ │ │ └── utils
│ │ │ │ ├── fixtures.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── runner.ts
│ │ │ │ ├── structuredOutputScorer.ts
│ │ │ │ └── toolPredictionScorer.ts
│ │ │ └── setup-env.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── mcp-server-mocks
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── fixtures
│ │ │ │ ├── autofix-state.json
│ │ │ │ ├── event-attachments.json
│ │ │ │ ├── event.json
│ │ │ │ ├── issue.json
│ │ │ │ ├── performance-event.json
│ │ │ │ ├── project.json
│ │ │ │ ├── tags.json
│ │ │ │ ├── team.json
│ │ │ │ ├── trace-event.json
│ │ │ │ ├── trace-items-attributes-logs-number.json
│ │ │ │ ├── trace-items-attributes-logs-string.json
│ │ │ │ ├── trace-items-attributes-spans-number.json
│ │ │ │ ├── trace-items-attributes-spans-string.json
│ │ │ │ ├── trace-items-attributes.json
│ │ │ │ ├── trace-meta-with-nulls.json
│ │ │ │ ├── trace-meta.json
│ │ │ │ ├── trace-mixed.json
│ │ │ │ └── trace.json
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── mcp-server-tsconfig
│ │ ├── package.json
│ │ ├── tsconfig.base.json
│ │ └── tsconfig.vite.json
│ ├── mcp-test-client
│ │ ├── .env.test
│ │ ├── .gitignore
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── agent.ts
│ │ │ ├── auth
│ │ │ │ ├── config.ts
│ │ │ │ └── oauth.ts
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.test.ts
│ │ │ ├── logger.ts
│ │ │ ├── mcp-test-client-remote.ts
│ │ │ ├── mcp-test-client.ts
│ │ │ ├── types.ts
│ │ │ └── version.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ └── smoke-tests
│ ├── package.json
│ ├── src
│ │ └── smoke.test.ts
│ └── vitest.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── scripts
│ └── check-doc-links.mjs
├── turbo.json
└── vitest.workspace.ts
```
# Files
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/get-trace-details.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from "vitest";
import { http, HttpResponse } from "msw";
import {
mswServer,
traceMetaFixture,
traceMetaWithNullsFixture,
traceFixture,
traceMixedFixture,
} from "@sentry/mcp-server-mocks";
import getTraceDetails from "./get-trace-details.js";
describe("get_trace_details", () => {
it("serializes with valid trace ID", async () => {
const result = await getTraceDetails.handler(
{
organizationSlug: "sentry-mcp-evals",
traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
regionUrl: undefined,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
},
);
expect(result).toMatchInlineSnapshot(`
"# Trace \`a4d1aae7216b47ff8117cf4e09ce9d0a\` in **sentry-mcp-evals**
## Summary
**Total Spans**: 112
**Errors**: 0
**Performance Issues**: 0
**Logs**: 0
## Operation Breakdown
- **db**: 90 spans (avg: 16ms, p95: 13ms)
- **feature.flagpole.batch_has**: 30 spans (avg: 18ms, p95: 32ms)
- **function**: 14 spans (avg: 303ms, p95: 1208ms)
- **http.client**: 2 spans (avg: 1223ms, p95: 1708ms)
- **other**: 1 spans (avg: 6ms, p95: 6ms)
## Overview
trace [a4d1aae7]
└─ tools/call search_events [aa8e7f33 · 5203ms]
├─ POST https://api.openai.com/v1/chat/completions [ad0f7c48 · http.client · 1708ms]
└─ GET https://us.sentry.io/api/0/organizations/example-org/events/ [b4abfe5e · http.client · 1482ms]
└─ /api/0/organizations/{organization_id_or_slug}/events/ [99a97a1d · http.server · 1408ms]
*Note: This shows a subset of spans. View the full trace for complete details.*
## View Full Trace
**Sentry URL**: https://sentry-mcp-evals.sentry.io/explore/traces/trace/a4d1aae7216b47ff8117cf4e09ce9d0a
## Find Related Events
Use this search query to find all events in this trace:
\`\`\`
trace:a4d1aae7216b47ff8117cf4e09ce9d0a
\`\`\`
You can use this query with the \`search_events\` tool to get detailed event data from this trace."
`);
});
it("serializes with fixed stats period", async () => {
const result = await getTraceDetails.handler(
{
organizationSlug: "sentry-mcp-evals",
traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
regionUrl: undefined,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
},
);
expect(result).toContain(
"Trace `a4d1aae7216b47ff8117cf4e09ce9d0a` in **sentry-mcp-evals**",
);
expect(result).toContain("**Total Spans**: 112");
expect(result).toContain("trace:a4d1aae7216b47ff8117cf4e09ce9d0a");
});
it("handles trace not found error", async () => {
mswServer.use(
http.get(
"https://sentry.io/api/0/organizations/sentry/trace-meta/nonexistent/",
() => {
return new HttpResponse(null, { status: 404 });
},
),
);
await expect(
getTraceDetails.handler(
{
organizationSlug: "sentry",
traceId: "nonexistent",
regionUrl: undefined,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
},
),
).rejects.toThrow();
});
it("validates trace ID format", async () => {
await expect(
getTraceDetails.handler(
{
organizationSlug: "sentry-mcp-evals",
traceId: "invalid-trace-id", // Too short, not hex
regionUrl: undefined,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
},
),
).rejects.toThrow("Trace ID must be a 32-character hexadecimal string");
});
it("handles empty trace response", async () => {
mswServer.use(
http.get(
"https://sentry.io/api/0/organizations/sentry-mcp-evals/trace-meta/a4d1aae7216b47ff8117cf4e09ce9d0a/",
() => {
return HttpResponse.json({
logs: 0,
errors: 0,
performance_issues: 0,
span_count: 0,
transaction_child_count_map: [],
span_count_map: {},
});
},
),
http.get(
"https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/a4d1aae7216b47ff8117cf4e09ce9d0a/",
() => {
return HttpResponse.json([]);
},
),
);
const result = await getTraceDetails.handler(
{
organizationSlug: "sentry-mcp-evals",
traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
regionUrl: undefined,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
},
);
expect(result).toContain("**Total Spans**: 0");
expect(result).toContain("**Errors**: 0");
expect(result).toContain("## Summary");
expect(result).not.toContain("## Operation Breakdown");
expect(result).not.toContain("## Overview");
});
it("handles API error gracefully", async () => {
mswServer.use(
http.get(
"https://sentry.io/api/0/organizations/sentry-mcp-evals/trace-meta/a4d1aae7216b47ff8117cf4e09ce9d0a/",
() => {
return new HttpResponse(
JSON.stringify({ detail: "Organization not found" }),
{ status: 404 },
);
},
),
);
await expect(
getTraceDetails.handler(
{
organizationSlug: "sentry-mcp-evals",
traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
regionUrl: undefined,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
},
),
).rejects.toThrow();
});
it("works with regional URL override", async () => {
mswServer.use(
http.get(
"https://us.sentry.io/api/0/organizations/sentry-mcp-evals/trace-meta/a4d1aae7216b47ff8117cf4e09ce9d0a/",
() => {
return HttpResponse.json(traceMetaFixture);
},
),
http.get(
"https://us.sentry.io/api/0/organizations/sentry-mcp-evals/trace/a4d1aae7216b47ff8117cf4e09ce9d0a/",
() => {
return HttpResponse.json(traceFixture);
},
),
);
const result = await getTraceDetails.handler(
{
organizationSlug: "sentry-mcp-evals",
traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
regionUrl: "https://us.sentry.io",
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
},
);
expect(result).toContain(
"Trace `a4d1aae7216b47ff8117cf4e09ce9d0a` in **sentry-mcp-evals**",
);
expect(result).toContain("**Total Spans**: 112");
});
it("handles trace meta with null transaction.event_id values", async () => {
mswServer.use(
http.get(
"https://sentry.io/api/0/organizations/sentry-mcp-evals/trace-meta/a4d1aae7216b47ff8117cf4e09ce9d0a/",
() => {
return HttpResponse.json(traceMetaWithNullsFixture);
},
),
http.get(
"https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/a4d1aae7216b47ff8117cf4e09ce9d0a/",
() => {
return HttpResponse.json(traceFixture);
},
),
);
const result = await getTraceDetails.handler(
{
organizationSlug: "sentry-mcp-evals",
traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
regionUrl: undefined,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
},
);
// The handler should successfully process the response with null values
expect(result).toContain(
"Trace `a4d1aae7216b47ff8117cf4e09ce9d0a` in **sentry-mcp-evals**",
);
expect(result).toContain("**Total Spans**: 85");
expect(result).toContain("**Errors**: 2");
// The null transaction.event_id entries should be handled gracefully
// and the trace should still be processed successfully
expect(result).not.toContain("null");
});
it("handles mixed span/issue arrays in trace responses", async () => {
mswServer.use(
http.get(
"https://sentry.io/api/0/organizations/sentry-mcp-evals/trace-meta/b4d1aae7216b47ff8117cf4e09ce9d0b/",
() => {
return HttpResponse.json({
logs: 0,
errors: 2,
performance_issues: 0,
span_count: 4,
transaction_child_count_map: [],
span_count_map: {},
});
},
),
http.get(
"https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/b4d1aae7216b47ff8117cf4e09ce9d0b/",
() => {
return HttpResponse.json(traceMixedFixture);
},
),
);
const result = await getTraceDetails.handler(
{
organizationSlug: "sentry-mcp-evals",
traceId: "b4d1aae7216b47ff8117cf4e09ce9d0b",
regionUrl: undefined,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
},
);
expect(result).toMatchInlineSnapshot(`
"# Trace \`b4d1aae7216b47ff8117cf4e09ce9d0b\` in **sentry-mcp-evals**
## Summary
**Total Spans**: 4
**Errors**: 2
**Performance Issues**: 0
**Logs**: 0
## Operation Breakdown
- **http.client**: 1 spans (avg: 1708ms, p95: 1708ms)
- **http.server**: 1 spans (avg: 1408ms, p95: 1408ms)
## Overview
trace [b4d1aae7]
├─ tools/call search_events [aa8e7f33 · function · 5203ms]
│ └─ POST https://api.openai.com/v1/chat/completions [aa8e7f33 · http.client · 1708ms]
└─ GET https://us.sentry.io/api/0/organizations/example-org/events/ [b4abfe5e · http.client · 1482ms]
└─ /api/0/organizations/{organization_id_or_slug}/events/ [b4abfe5e · http.server · 1408ms]
*Note: This shows a subset of spans. View the full trace for complete details.*
## View Full Trace
**Sentry URL**: https://sentry-mcp-evals.sentry.io/explore/traces/trace/b4d1aae7216b47ff8117cf4e09ce9d0b
## Find Related Events
Use this search query to find all events in this trace:
\`\`\`
trace:b4d1aae7216b47ff8117cf4e09ce9d0b
\`\`\`
You can use this query with the \`search_events\` tool to get detailed event data from this trace."
`);
});
});
```
--------------------------------------------------------------------------------
/packages/mcp-test-client/src/auth/oauth.ts:
--------------------------------------------------------------------------------
```typescript
import { randomBytes, createHash } from "node:crypto";
import { URL } from "node:url";
import { createServer, type Server } from "node:http";
import open from "open";
import chalk from "chalk";
import {
OAUTH_REDIRECT_PORT,
OAUTH_REDIRECT_URI,
DEFAULT_OAUTH_SCOPES,
} from "../constants.js";
import { logInfo, logSuccess, logToolResult, logError } from "../logger.js";
import { ConfigManager } from "./config.js";
export interface OAuthConfig {
mcpHost: string;
scopes?: string[];
}
export interface TokenResponse {
access_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
}
export interface ClientRegistrationResponse {
client_id: string;
redirect_uris: string[];
client_name?: string;
client_uri?: string;
grant_types?: string[];
response_types?: string[];
token_endpoint_auth_method?: string;
registration_client_uri?: string;
client_id_issued_at?: number;
}
export class OAuthClient {
private config: OAuthConfig;
private server: Server | null = null;
private configManager: ConfigManager;
constructor(config: OAuthConfig) {
this.config = {
...config,
scopes: config.scopes || DEFAULT_OAUTH_SCOPES,
};
this.configManager = new ConfigManager();
}
/**
* Generate PKCE code verifier and challenge
*/
private generatePKCE(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
/**
* Generate random state for CSRF protection
*/
private generateState(): string {
return randomBytes(16).toString("base64url");
}
/**
* Register the client with the OAuth server using Dynamic Client Registration
*/
private async registerClient(): Promise<string> {
const registrationUrl = `${this.config.mcpHost}/oauth/register`;
const registrationData = {
client_name: "Sentry MCP CLI",
client_uri: "https://github.com/getsentry/sentry-mcp",
redirect_uris: [OAUTH_REDIRECT_URI],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none", // PKCE, no client secret
scope: this.config.scopes!.join(" "),
};
const response = await fetch(registrationUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(registrationData),
});
if (!response.ok) {
const error = await response.text();
throw new Error(
`Client registration failed: ${response.status} - ${error}`,
);
}
const registrationResponse =
(await response.json()) as ClientRegistrationResponse;
return registrationResponse.client_id;
}
/**
* Start local server for OAuth callback
*/
private async startCallbackServer(): Promise<{
waitForCallback: () => Promise<{ code: string; state: string }>;
}> {
return new Promise((resolve, reject) => {
let resolveCallback:
| ((value: { code: string; state: string }) => void)
| null = null;
let rejectCallback: ((error: Error) => void) | null = null;
this.server = createServer((req, res) => {
if (!req.url) {
res.writeHead(400);
res.end("Bad Request");
return;
}
const url = new URL(req.url, `http://localhost:${OAUTH_REDIRECT_PORT}`);
if (url.pathname === "/callback") {
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
if (error) {
const errorDescription =
url.searchParams.get("error_description") || "Unknown error";
res.writeHead(400, { "Content-Type": "text/html" });
res.end(`
<!DOCTYPE html>
<html>
<head><title>Authentication Failed</title></head>
<body>
<h1>Authentication Failed</h1>
<p>Error: ${error}</p>
<p>${errorDescription}</p>
<p>You can close this window.</p>
</body>
</html>
`);
if (rejectCallback) {
rejectCallback(
new Error(`OAuth error: ${error} - ${errorDescription}`),
);
}
return;
}
if (!code || !state) {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(`
<!DOCTYPE html>
<html>
<head><title>Authentication Failed</title></head>
<body>
<h1>Authentication Failed</h1>
<p>Missing code or state parameter</p>
<p>You can close this window.</p>
</body>
</html>
`);
if (rejectCallback) {
rejectCallback(new Error("Missing code or state parameter"));
}
return;
}
// Acknowledge the callback but don't show success yet
res.writeHead(200, { "Content-Type": "text/html" });
res.end(`
<!DOCTYPE html>
<html>
<head><title>Authentication in Progress</title></head>
<body>
<h1>Processing Authentication...</h1>
<p>Please wait while we complete the authentication process.</p>
<p>You can close this window and return to your terminal.</p>
</body>
</html>
`);
if (resolveCallback) {
resolveCallback({ code, state });
}
} else {
res.writeHead(404);
res.end("Not Found");
}
});
this.server.listen(OAUTH_REDIRECT_PORT, "127.0.0.1", () => {
const waitForCallback = () =>
new Promise<{ code: string; state: string }>((res, rej) => {
resolveCallback = res;
rejectCallback = rej;
});
resolve({ waitForCallback });
});
this.server.on("error", reject);
});
}
/**
* Exchange authorization code for access token
*/
private async exchangeCodeForToken(params: {
code: string;
codeVerifier: string;
clientId: string;
}): Promise<TokenResponse> {
const tokenUrl = `${this.config.mcpHost}/oauth/token`;
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: params.clientId,
code: params.code,
redirect_uri: OAUTH_REDIRECT_URI,
code_verifier: params.codeVerifier,
});
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": "Sentry MCP CLI",
},
body: body.toString(),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${response.status} - ${error}`);
}
return response.json() as Promise<TokenResponse>;
}
/**
* Get or register OAuth client ID for the MCP host
*/
private async getOrRegisterClientId(): Promise<string> {
// Check if we already have a registered client for this host
let clientId = await this.configManager.getOAuthClientId(
this.config.mcpHost,
);
if (clientId) {
return clientId;
}
// Register a new client
logInfo("Registering new OAuth client");
try {
clientId = await this.registerClient();
// Store the client ID for future use
await this.configManager.setOAuthClientId(this.config.mcpHost, clientId);
logSuccess("Client registered and saved");
logToolResult(clientId);
return clientId;
} catch (error) {
throw new Error(
`Client registration failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Get cached access token or perform OAuth flow
*/
async getAccessToken(): Promise<string> {
// Check for cached token first
const cachedToken = await this.configManager.getAccessToken(
this.config.mcpHost,
);
if (cachedToken) {
logInfo("Authenticated with Sentry", "using stored token");
return cachedToken;
}
// No cached token, perform OAuth flow
return this.authenticate();
}
/**
* Perform the OAuth flow
*/
async authenticate(): Promise<string> {
// Get or register client ID
const clientId = await this.getOrRegisterClientId();
// Start callback server
const { waitForCallback } = await this.startCallbackServer();
// Generate PKCE and state
const { verifier, challenge } = this.generatePKCE();
const state = this.generateState();
// Build authorization URL
const authUrl = new URL(`${this.config.mcpHost}/oauth/authorize`);
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", this.config.scopes!.join(" "));
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
logInfo("Authenticating with Sentry - opening browser");
console.log(
chalk.gray("If your browser doesn't open automatically, visit:"),
);
console.log(chalk.white(authUrl.toString()));
// Open browser
try {
await open(authUrl.toString());
} catch (error) {
// Browser opening failed, user will need to copy/paste
}
try {
// Wait for callback
const { code, state: receivedState } = await waitForCallback();
// Verify state
if (receivedState !== state) {
throw new Error("State mismatch - possible CSRF attack");
}
// Exchange code for token
try {
const tokenResponse = await this.exchangeCodeForToken({
code,
codeVerifier: verifier,
clientId,
});
// Cache the access token
await this.configManager.setAccessToken(
this.config.mcpHost,
tokenResponse.access_token,
tokenResponse.expires_in,
);
logSuccess("Authentication successful");
return tokenResponse.access_token;
} catch (error) {
logError(
"Authentication failed",
error instanceof Error ? error : String(error),
);
throw error;
}
} finally {
// Clean up server
if (this.server) {
this.server.close();
this.server = null;
}
}
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/scripts/generate-otel-namespaces.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env tsx
import {
writeFileSync,
readFileSync,
existsSync,
mkdirSync,
readdirSync,
} from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
import { z } from "zod";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Zod schemas for type-safe YAML parsing
const OtelAttributeMemberSchema = z.object({
id: z.string(),
value: z.union([z.string(), z.number()]),
stability: z.string().optional(),
brief: z.string().optional(),
note: z.string().optional(),
});
// Type can be a string or an object with a 'members' property for enums
const OtelTypeSchema = z.union([
z.string(),
z.object({
members: z.array(OtelAttributeMemberSchema),
}),
]);
const OtelAttributeSchema = z.object({
id: z.string(),
type: OtelTypeSchema,
stability: z.string().optional(),
brief: z.string(),
note: z.string().optional(),
// Examples can be strings, numbers, booleans, or arrays (for array examples)
examples: z
.union([
z.array(
z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]),
),
z.string(),
z.number(),
z.boolean(),
])
.optional(),
members: z.array(OtelAttributeMemberSchema).optional(),
});
const OtelGroupSchema = z.object({
id: z.string(),
type: z.string(),
display_name: z.string().optional(),
brief: z.string(),
attributes: z.array(OtelAttributeSchema),
});
const OtelYamlFileSchema = z.object({
groups: z.array(OtelGroupSchema),
});
// TypeScript types inferred from Zod schemas
type OtelAttribute = z.infer<typeof OtelAttributeSchema>;
type OtelGroup = z.infer<typeof OtelGroupSchema>;
type OtelYamlFile = z.infer<typeof OtelYamlFileSchema>;
interface JsonAttribute {
description: string;
type: string;
examples?: string[];
note?: string;
stability?: string;
}
interface JsonNamespace {
namespace: string;
description: string;
attributes: Record<string, JsonAttribute>;
}
// Known namespaces to process
const KNOWN_NAMESPACES = [
"gen-ai",
"database",
"http",
"rpc",
"messaging",
"faas",
"k8s",
"network",
"server",
"client",
"cloud",
"container",
"host",
"process",
"service",
"system",
"user",
"error",
"exception",
"url",
"tls",
"dns",
"feature-flags",
"code",
"thread",
"jvm",
"nodejs",
"dotnet",
"go",
"android",
"ios",
"browser",
"aws",
"azure",
"gcp",
"oci",
"cloudevents",
"graphql",
"aspnetcore",
"otel",
"telemetry",
"log",
"profile",
"test",
"session",
"deployment",
"device",
"disk",
"hardware",
"os",
"vcs",
"webengine",
"signalr",
"cicd",
"artifact",
"app",
"file",
"peer",
"destination",
"source",
"cpython",
"v8js",
"mainframe",
"zos",
"linux",
"enduser",
"user_agent",
"cpu",
"cassandra",
"elasticsearch",
"heroku",
"cloudfoundry",
"opentracing",
"geo",
"security_rule",
];
const DATA_DIR = resolve(__dirname, "../src/agent-tools/data");
const CACHE_DIR = resolve(DATA_DIR, ".cache");
const GITHUB_BASE_URL =
"https://raw.githubusercontent.com/open-telemetry/semantic-conventions/main/model";
// Ensure cache directory exists
function ensureCacheDir() {
if (!existsSync(CACHE_DIR)) {
mkdirSync(CACHE_DIR, { recursive: true });
}
}
async function fetchYamlContent(namespace: string): Promise<string | null> {
ensureCacheDir();
const cacheFile = resolve(CACHE_DIR, `${namespace}.yaml`);
// Check if we have a cached version
if (existsSync(cacheFile)) {
try {
const cachedContent = readFileSync(cacheFile, "utf8");
console.log(`📂 Using cached ${namespace}.yaml`);
return cachedContent;
} catch (error) {
console.warn(
`⚠️ Failed to read cached ${namespace}.yaml, fetching fresh copy`,
);
}
}
// Fetch from GitHub
try {
const response = await fetch(
`${GITHUB_BASE_URL}/${namespace}/registry.yaml`,
);
if (!response.ok) {
console.log(`⚠️ No registry.yaml found for namespace: ${namespace}`);
return null;
}
const yamlContent = await response.text();
// Cache the content
try {
writeFileSync(cacheFile, yamlContent);
console.log(`💾 Cached ${namespace}.yaml`);
} catch (error) {
console.warn(`⚠️ Failed to cache ${namespace}.yaml:`, error);
}
return yamlContent;
} catch (error) {
console.error(`❌ Failed to fetch ${namespace}/registry.yaml:`, error);
return null;
}
}
function convertYamlToJson(
yamlContent: string,
namespace: string,
): JsonNamespace {
// Parse YAML and validate with Zod
const parsedYaml = parseYaml(yamlContent);
const validationResult = OtelYamlFileSchema.safeParse(parsedYaml);
if (!validationResult.success) {
throw new Error(
`Invalid YAML structure for ${namespace}: ${validationResult.error.message}`,
);
}
const otelData = validationResult.data;
if (otelData.groups.length === 0) {
throw new Error(`No groups found in ${namespace}/registry.yaml`);
}
const group = otelData.groups[0]; // Take the first group
const attributes: Record<string, JsonAttribute> = {};
for (const attr of group.attributes) {
// Extract the type string, handling both string and object types
const typeStr = typeof attr.type === "string" ? attr.type : "string"; // enums are strings
const jsonAttr: JsonAttribute = {
description: attr.brief,
type: inferType(typeStr),
};
if (attr.note) {
jsonAttr.note = attr.note;
}
if (attr.stability) {
jsonAttr.stability = attr.stability;
}
// Handle examples - normalize to string array
if (attr.examples) {
if (Array.isArray(attr.examples)) {
jsonAttr.examples = attr.examples.map((ex) => {
if (Array.isArray(ex)) {
// For array examples, convert to JSON string
return JSON.stringify(ex);
}
return String(ex);
});
} else {
jsonAttr.examples = [String(attr.examples)];
}
}
// Handle enums/members from the type object or explicit members
if (typeof attr.type === "object" && attr.type.members) {
jsonAttr.examples = attr.type.members.map((m) => String(m.value));
} else if (attr.members) {
jsonAttr.examples = attr.members.map((m) => String(m.value));
}
attributes[attr.id] = jsonAttr;
}
return {
namespace: namespace.replace(/-/g, "_"), // Convert all hyphens to underscores for consistency
description: group.brief,
attributes,
};
}
function inferType(otelType: string): string {
// For semantic documentation, we keep the type mapping simple
// The AI agent mainly needs to know if something is numeric (for aggregate functions)
const cleanType = otelType.toLowerCase();
if (
cleanType.includes("int") ||
cleanType.includes("double") ||
cleanType.includes("number")
) {
return "number";
}
if (cleanType.includes("bool")) {
return "boolean";
}
return "string"; // Everything else is treated as string
}
async function generateNamespaceFiles() {
console.log("🔄 Generating OpenTelemetry namespace files...");
let processed = 0;
let skipped = 0;
const availableNamespaces: Array<{
namespace: string;
description: string;
custom?: boolean;
}> = [];
for (const namespace of KNOWN_NAMESPACES) {
const outputPath = resolve(
DATA_DIR,
`${namespace.replace(/-/g, "_")}.json`,
);
// Check if file exists and has custom content (not from OpenTelemetry)
if (existsSync(outputPath)) {
const existingContent = readFileSync(outputPath, "utf8");
const existingJson = JSON.parse(existingContent);
// Skip if this appears to be a custom namespace (not from OpenTelemetry)
if (existingJson.namespace === "mcp" || existingJson.custom === true) {
console.log(`⏭️ Skipping custom namespace: ${namespace}`);
skipped++;
continue;
}
}
const yamlContent = await fetchYamlContent(namespace);
if (!yamlContent) {
console.log(`⏭️ Skipping ${namespace} (no registry.yaml found)`);
skipped++;
continue;
}
try {
const jsonData = convertYamlToJson(yamlContent, namespace);
writeFileSync(outputPath, JSON.stringify(jsonData, null, 2));
console.log(`✅ Generated: ${namespace.replace("-", "_")}.json`);
processed++;
// Add to available namespaces
availableNamespaces.push({
namespace: jsonData.namespace,
description: jsonData.description,
});
} catch (error) {
console.error(`❌ Failed to process ${namespace}:`, error);
skipped++;
}
}
console.log(`\n📊 Summary: ${processed} processed, ${skipped} skipped`);
// Generate namespaces index
generateNamespacesIndex(availableNamespaces);
}
// Generate index of all available namespaces
function generateNamespacesIndex(
namespaces: Array<{
namespace: string;
description: string;
custom?: boolean;
}>,
) {
// Add any existing custom namespaces that weren't in KNOWN_NAMESPACES
const existingFiles = readdirSync(DATA_DIR).filter(
(f) => f.endsWith(".json") && f !== "__namespaces.json",
);
for (const file of existingFiles) {
const namespace = file.replace(".json", "");
if (!namespaces.find((n) => n.namespace === namespace)) {
try {
const content = readFileSync(resolve(DATA_DIR, file), "utf8");
const data = JSON.parse(content) as JsonNamespace & {
custom?: boolean;
};
namespaces.push({
namespace: data.namespace,
description: data.description,
custom: data.custom,
});
} catch (error) {
console.warn(`⚠️ Failed to read ${file} for index`);
}
}
}
// Sort namespaces alphabetically
namespaces.sort((a, b) => a.namespace.localeCompare(b.namespace));
const indexPath = resolve(DATA_DIR, "__namespaces.json");
const indexContent = {
generated: new Date().toISOString(),
totalNamespaces: namespaces.length,
namespaces,
};
writeFileSync(indexPath, JSON.stringify(indexContent, null, 2));
console.log(
`📇 Generated namespace index: __namespaces.json (${namespaces.length} namespaces)`,
);
}
// Add MCP namespace as a custom one
function generateMcpNamespace() {
const mcpNamespace: JsonNamespace = {
namespace: "mcp",
description:
"Model Context Protocol attributes for MCP tool calls and sessions",
attributes: {
"mcp.tool.name": {
description: "Tool name (e.g., find_issues, search_events)",
type: "string",
examples: [
"find_issues",
"search_events",
"get_issue_details",
"update_issue",
],
},
"mcp.session.id": {
description: "MCP session identifier",
type: "string",
},
"mcp.transport": {
description: "MCP transport protocol used",
type: "string",
examples: ["stdio", "http", "websocket"],
},
"mcp.request.id": {
description: "MCP request identifier",
type: "string",
},
"mcp.response.status": {
description: "MCP response status",
type: "string",
examples: ["success", "error"],
},
},
};
const outputPath = resolve(DATA_DIR, "mcp.json");
const content = JSON.stringify(
{
...mcpNamespace,
custom: true, // Mark as custom so it doesn't get overwritten
},
null,
2,
);
writeFileSync(outputPath, content);
console.log("✅ Generated custom MCP namespace");
}
// Run the script
if (import.meta.url === `file://${process.argv[1]}`) {
generateNamespaceFiles()
.then(() => {
generateMcpNamespace();
console.log("🎉 OpenTelemetry namespace generation complete!");
})
.catch((error) => {
console.error("❌ Script failed:", error);
process.exit(1);
});
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/telem/logging.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Logging and telemetry utilities for error reporting.
*
* Provides centralized error logging with Sentry integration. Handles both
* console logging for development and structured error reporting for production
* monitoring and debugging.
*/
import {
configureSync,
getConfig,
getConsoleSink,
getJsonLinesFormatter,
getLogger as getLogTapeLogger,
parseLogLevel,
type LogLevel,
type Logger,
type LogRecord,
type Sink,
} from "@logtape/logtape";
import { captureException, captureMessage, withScope } from "@sentry/core";
import * as Sentry from "@sentry/node";
const ROOT_LOG_CATEGORY = ["sentry", "mcp"] as const;
type SinkId = "console" | "sentry";
let loggingConfigured = false;
function resolveLowestLevel(): LogLevel {
const envLevel =
typeof process !== "undefined" ? process.env.LOG_LEVEL : undefined;
if (envLevel) {
try {
return parseLogLevel(envLevel);
} catch (error) {
// Fall through to default level when parsing fails.
}
}
return typeof process !== "undefined" &&
process.env.NODE_ENV === "development"
? "debug"
: "info";
}
/**
* Creates a LogTape sink that sends logs to Sentry's Logs product using Sentry.logger.
*
* Unlike @logtape/sentry's getSentrySink which uses captureException/captureMessage
* (creating Issues), this sink uses Sentry.logger.* methods to send data to the
* Logs product.
*
* Note: This uses @sentry/node logger API. Cloudflare Workers will need a separate
* implementation using @sentry/cloudflare logger API.
*/
function createSentryLogsSink(): Sink {
return (record: LogRecord) => {
// Check if Sentry.logger is available (may not be in all environments)
if (!Sentry.logger) {
return;
}
// Extract message from LogRecord
let message = "";
for (let i = 0; i < record.message.length; i++) {
if (i % 2 === 0) {
message += record.message[i];
} else {
// Template values - convert to string safely
const value = record.message[i];
message += typeof value === "string" ? value : coerceMessage(value);
}
}
// Extract attributes from properties
const attributes = record.properties as Record<string, unknown>;
// Map LogTape levels to Sentry.logger methods
// Note: Sentry.logger methods are fire-and-forget and handle errors gracefully
switch (record.level) {
case "trace":
Sentry.logger.trace(message, attributes);
break;
case "debug":
Sentry.logger.debug(message, attributes);
break;
case "info":
Sentry.logger.info(message, attributes);
break;
case "warning":
Sentry.logger.warn(message, attributes);
break;
case "error":
Sentry.logger.error(message, attributes);
break;
case "fatal":
Sentry.logger.fatal(message, attributes);
break;
default:
Sentry.logger.info(message, attributes);
}
};
}
function ensureLoggingConfigured(): void {
if (loggingConfigured) {
return;
}
const consoleSink = getConsoleSink({
formatter: getJsonLinesFormatter(),
});
const sentrySink = createSentryLogsSink();
configureSync<SinkId, never>({
reset: getConfig() !== null,
sinks: {
console: consoleSink,
sentry: sentrySink,
},
loggers: [
{
category: [...ROOT_LOG_CATEGORY],
sinks: ["console", "sentry"],
lowestLevel: resolveLowestLevel(),
},
{
category: ["logtape", "meta"],
sinks: ["console"],
lowestLevel: "warning",
},
{
category: "logtape",
sinks: ["console"],
lowestLevel: "error",
},
],
});
loggingConfigured = true;
}
export type LogContext = Record<string, unknown>;
export type SentryLogContexts = Record<string, Record<string, unknown>>;
export type LogAttachments = Record<string, string | Uint8Array>;
export interface BaseLogOptions {
contexts?: SentryLogContexts;
extra?: LogContext;
loggerScope?: string | readonly string[];
}
export interface LogIssueOptions extends BaseLogOptions {
attachments?: LogAttachments;
}
export interface LogOptions extends BaseLogOptions {}
export function getLogger(
scope: string | readonly string[],
defaults?: LogContext,
): Logger {
ensureLoggingConfigured();
const category = Array.isArray(scope) ? scope : [scope];
const logger = getLogTapeLogger([...ROOT_LOG_CATEGORY, ...category]);
return defaults ? logger.with(defaults) : logger;
}
const ISSUE_LOGGER_SCOPE = ["runtime", "issues"] as const;
interface ParsedBaseOptions {
contexts?: SentryLogContexts;
extra?: LogContext;
loggerScope?: string | readonly string[];
}
interface ParsedLogIssueOptions extends ParsedBaseOptions {
attachments?: LogAttachments;
}
interface SerializedError {
message: string;
name?: string;
stack?: string;
cause?: SerializedError;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isSentryContexts(value: unknown): value is SentryLogContexts {
if (!isRecord(value)) {
return false;
}
return Object.values(value).every((entry) => isRecord(entry));
}
function isBaseLogOptionsCandidate(value: unknown): value is BaseLogOptions {
if (!isRecord(value)) {
return false;
}
if ("extra" in value || "loggerScope" in value) {
return true;
}
if ("contexts" in value) {
const contexts = (value as { contexts?: unknown }).contexts;
return contexts === undefined || isSentryContexts(contexts);
}
return false;
}
function isLogIssueOptionsCandidate(value: unknown): value is LogIssueOptions {
return (
isBaseLogOptionsCandidate(value) ||
(isRecord(value) && "attachments" in value)
);
}
function parseBaseOptions(
contextsOrOptions?: SentryLogContexts | BaseLogOptions,
): ParsedBaseOptions {
if (isBaseLogOptionsCandidate(contextsOrOptions)) {
const { contexts, extra, loggerScope } = contextsOrOptions;
return {
contexts,
extra,
loggerScope,
};
}
if (isSentryContexts(contextsOrOptions)) {
return { contexts: contextsOrOptions };
}
return {};
}
function parseLogIssueOptions(
contextsOrOptions?: SentryLogContexts | LogIssueOptions,
attachmentsArg?: LogAttachments,
): ParsedLogIssueOptions {
const base = parseBaseOptions(contextsOrOptions);
const attachments = isLogIssueOptionsCandidate(contextsOrOptions)
? contextsOrOptions.attachments
: undefined;
return {
...base,
attachments: attachments ?? attachmentsArg,
};
}
function parseLogOptions(
contextsOrOptions?: SentryLogContexts | LogOptions,
): LogOptions {
return parseBaseOptions(contextsOrOptions);
}
function safeJsonStringify(value: unknown): string | undefined {
try {
return JSON.stringify(value);
} catch (error) {
return undefined;
}
}
function truncate(text: string, maxLength = 1024): string {
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, maxLength - 1)}…`;
}
function coerceMessage(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return value.toString();
}
if (value === null || value === undefined) {
return String(value);
}
const json = safeJsonStringify(value);
if (json) {
return truncate(json);
}
return Object.prototype.toString.call(value);
}
function serializeError(value: unknown, depth = 0): SerializedError {
if (value instanceof Error) {
const serialized: SerializedError = {
message: value.message,
};
if (value.name && value.name !== "Error") {
serialized.name = value.name;
}
if (typeof value.stack === "string") {
serialized.stack = value.stack;
}
const hasCause =
"cause" in (value as { cause?: unknown }) &&
(value as { cause?: unknown }).cause !== undefined;
if (hasCause && depth < 3) {
const cause = (value as { cause?: unknown }).cause;
serialized.cause = serializeError(cause, depth + 1);
}
return serialized;
}
return { message: coerceMessage(value) };
}
export const logger = getLogger([]);
const DEFAULT_LOGGER_SCOPE: readonly string[] = [];
function buildLogProperties(
level: LogLevel,
options: ParsedBaseOptions,
serializedError?: SerializedError,
): LogContext {
const properties: LogContext = {
severity: level,
};
if (serializedError) {
properties.error = serializedError;
}
if (options.extra) {
Object.assign(properties, options.extra);
}
if (options.contexts) {
properties.sentryContexts = options.contexts;
}
return properties;
}
function logWithLevel(
level: LogLevel,
value: unknown,
contextsOrOptions?: SentryLogContexts | LogOptions,
): void {
ensureLoggingConfigured();
const options = parseLogOptions(contextsOrOptions);
const serializedError =
value instanceof Error ? serializeError(value) : undefined;
const message = serializedError
? serializedError.message
: coerceMessage(value);
const scope = options.loggerScope ?? DEFAULT_LOGGER_SCOPE;
const scopedLogger = getLogger(scope, { severity: level });
const properties = buildLogProperties(level, options, serializedError);
switch (level) {
case "trace":
scopedLogger.trace(message, () => properties);
break;
case "debug":
scopedLogger.debug(message, () => properties);
break;
case "info":
scopedLogger.info(message, () => properties);
break;
case "warning":
scopedLogger.warn(message, () => properties);
break;
case "error":
scopedLogger.error(message, () => properties);
break;
case "fatal":
scopedLogger.fatal(message, () => properties);
break;
default:
scopedLogger.info(message, () => properties);
}
}
export function logDebug(
value: unknown,
contextsOrOptions?: SentryLogContexts | LogOptions,
): void {
logWithLevel("debug", value, contextsOrOptions);
}
export function logInfo(
value: unknown,
contextsOrOptions?: SentryLogContexts | LogOptions,
): void {
logWithLevel("info", value, contextsOrOptions);
}
export function logWarn(
value: unknown,
contextsOrOptions?: SentryLogContexts | LogOptions,
): void {
logWithLevel("warning", value, contextsOrOptions);
}
export function logError(
value: unknown,
contextsOrOptions?: SentryLogContexts | LogOptions,
): void {
logWithLevel("error", value, contextsOrOptions);
}
export function logIssue(
error: Error | unknown,
contexts?: SentryLogContexts,
attachments?: LogAttachments,
): string | undefined;
export function logIssue(
error: Error | unknown,
options: LogIssueOptions,
): string | undefined;
export function logIssue(
message: string,
contexts?: SentryLogContexts,
attachments?: LogAttachments,
): string | undefined;
export function logIssue(
message: string,
options: LogIssueOptions,
): string | undefined;
export function logIssue(
error: unknown,
contextsOrOptions?: SentryLogContexts | LogIssueOptions,
attachmentsArg?: LogAttachments,
): string | undefined {
ensureLoggingConfigured();
const options = parseLogIssueOptions(contextsOrOptions, attachmentsArg);
const eventId = withScope((scopeInstance) => {
if (options.contexts) {
for (const [key, context] of Object.entries(options.contexts)) {
scopeInstance.setContext(key, context);
}
}
if (options.extra) {
scopeInstance.setContext("log", options.extra);
}
if (options.attachments) {
for (const [key, data] of Object.entries(options.attachments)) {
scopeInstance.addAttachment({
data,
filename: key,
});
}
}
const captureLevel = "error" as const;
return typeof error === "string"
? captureMessage(error, {
contexts: options.contexts,
level: captureLevel,
})
: captureException(error, {
contexts: options.contexts,
level: captureLevel,
});
});
const { attachments, ...baseOptions } = options;
const extra: LogContext = {
...(baseOptions.extra ?? {}),
...(attachments && Object.keys(attachments).length > 0
? { attachments: Object.keys(attachments) }
: {}),
...(eventId ? { eventId } : {}),
};
logError(error, {
...baseOptions,
extra,
loggerScope: baseOptions.loggerScope ?? ISSUE_LOGGER_SCOPE,
});
return eventId;
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/__namespaces.json:
--------------------------------------------------------------------------------
```json
{
"generated": "2025-07-16T18:48:46.692Z",
"totalNamespaces": 73,
"namespaces": [
{
"namespace": "android",
"description": "The Android platform on which the Android application is running.\n"
},
{
"namespace": "app",
"description": "Describes attributes related to client-side applications (e.g. web apps or mobile apps).\n"
},
{
"namespace": "artifact",
"description": "This group describes attributes specific to artifacts. Artifacts are files or other immutable objects that are intended for distribution. This definition aligns directly with the [SLSA](https://slsa.dev/spec/v1.0/terminology#package-model) package model.\n"
},
{
"namespace": "aspnetcore",
"description": "ASP.NET Core attributes"
},
{
"namespace": "aws",
"description": "This section defines generic attributes for AWS services.\n"
},
{
"namespace": "azure",
"description": "This section defines generic attributes used by Azure Client Libraries.\n"
},
{
"namespace": "browser",
"description": "The web browser attributes\n"
},
{
"namespace": "cassandra",
"description": "This section defines attributes for Cassandra.\n"
},
{
"namespace": "cicd",
"description": "This group describes attributes specific to pipelines within a Continuous Integration and Continuous Deployment (CI/CD) system. A [pipeline](https://wikipedia.org/wiki/Pipeline_(computing)) in this case is a series of steps that are performed in order to deliver a new version of software. This aligns with the [Britannica](https://www.britannica.com/dictionary/pipeline) definition of a pipeline where a **pipeline** is the system for developing and producing something. In the context of CI/CD, a pipeline produces or delivers software.\n"
},
{
"namespace": "client",
"description": "These attributes may be used to describe the client in a connection-based network interaction where there is one side that initiates the connection (the client is the side that initiates the connection). This covers all TCP network interactions since TCP is connection-based and one side initiates the connection (an exception is made for peer-to-peer communication over TCP where the \"user-facing\" surface of the protocol / API doesn't expose a clear notion of client and server). This also covers UDP network interactions where one side initiates the interaction, e.g. QUIC (HTTP/3) and DNS.\n"
},
{
"namespace": "cloud",
"description": "A cloud environment (e.g. GCP, Azure, AWS).\n"
},
{
"namespace": "cloudevents",
"description": "This document defines attributes for CloudEvents.\n"
},
{
"namespace": "cloudfoundry",
"description": "CloudFoundry resource attributes.\n"
},
{
"namespace": "code",
"description": "These attributes provide context about source code\n"
},
{
"namespace": "container",
"description": "A container instance.\n"
},
{
"namespace": "cpu",
"description": "Attributes specific to a cpu instance."
},
{
"namespace": "cpython",
"description": "This document defines CPython related attributes.\n"
},
{
"namespace": "database",
"description": "This group defines the attributes used to describe telemetry in the context of databases.\n"
},
{
"namespace": "db",
"description": "Database operations attributes"
},
{
"namespace": "deployment",
"description": "This document defines attributes for software deployments.\n"
},
{
"namespace": "destination",
"description": "These attributes may be used to describe the receiver of a network exchange/packet. These should be used when there is no client/server relationship between the two sides, or when that relationship is unknown. This covers low-level network interactions (e.g. packet tracing) where you don't know if there was a connection or which side initiated it. This also covers unidirectional UDP flows and peer-to-peer communication where the \"user-facing\" surface of the protocol / API doesn't expose a clear notion of client and server.\n"
},
{
"namespace": "device",
"description": "Describes device attributes.\n"
},
{
"namespace": "disk",
"description": "These attributes may be used for any disk related operation.\n"
},
{
"namespace": "dns",
"description": "This document defines the shared attributes used to report a DNS query.\n"
},
{
"namespace": "dotnet",
"description": "This document defines .NET related attributes.\n"
},
{
"namespace": "elasticsearch",
"description": "This section defines attributes for Elasticsearch.\n"
},
{
"namespace": "enduser",
"description": "Describes the end user.\n"
},
{
"namespace": "error",
"description": "This document defines the shared attributes used to report an error.\n"
},
{
"namespace": "faas",
"description": "FaaS attributes"
},
{
"namespace": "feature_flags",
"description": "This document defines attributes for Feature Flags.\n"
},
{
"namespace": "file",
"description": "Describes file attributes."
},
{
"namespace": "gcp",
"description": "Attributes for Google Cloud client libraries.\n"
},
{
"namespace": "gen_ai",
"description": "This document defines the attributes used to describe telemetry in the context of Generative Artificial Intelligence (GenAI) Models requests and responses.\n"
},
{
"namespace": "geo",
"description": "Geo fields can carry data about a specific location related to an event. This geolocation information can be derived from techniques such as Geo IP, or be user-supplied.\nNote: Geo attributes are typically used under another namespace, such as client.* and describe the location of the corresponding entity (device, end-user, etc). Semantic conventions that reference geo attributes (as a root namespace) or embed them (under their own namespace) SHOULD document what geo attributes describe in the scope of that convention.\n"
},
{
"namespace": "go",
"description": "This document defines Go related attributes.\n"
},
{
"namespace": "graphql",
"description": "This document defines attributes for GraphQL."
},
{
"namespace": "hardware",
"description": "Attributes for hardware.\n"
},
{
"namespace": "heroku",
"description": "This document defines attributes for the Heroku platform on which application/s are running.\n"
},
{
"namespace": "host",
"description": "A host is defined as a computing instance. For example, physical servers, virtual machines, switches or disk array.\n"
},
{
"namespace": "http",
"description": "This document defines semantic convention attributes in the HTTP namespace."
},
{
"namespace": "ios",
"description": "This group describes iOS-specific attributes.\n"
},
{
"namespace": "jvm",
"description": "This document defines Java Virtual machine related attributes.\n"
},
{
"namespace": "k8s",
"description": "Kubernetes resource attributes.\n"
},
{
"namespace": "linux",
"description": "Describes Linux Memory attributes"
},
{
"namespace": "log",
"description": "This document defines log attributes\n"
},
{
"namespace": "mcp",
"description": "Model Context Protocol attributes for MCP tool calls and sessions",
"custom": true
},
{
"namespace": "messaging",
"description": "Attributes describing telemetry around messaging systems and messaging activities."
},
{
"namespace": "network",
"description": "These attributes may be used for any network related operation.\n"
},
{
"namespace": "nodejs",
"description": "Describes Node.js related attributes."
},
{
"namespace": "oci",
"description": "An OCI image manifest.\n"
},
{
"namespace": "opentracing",
"description": "Attributes used by the OpenTracing Shim layer."
},
{
"namespace": "os",
"description": "The operating system (OS) on which the process represented by this resource is running.\n"
},
{
"namespace": "otel",
"description": "Attributes reserved for OpenTelemetry"
},
{
"namespace": "peer",
"description": "Operations that access some remote service.\n"
},
{
"namespace": "process",
"description": "An operating system process.\n"
},
{
"namespace": "profile",
"description": "Describes the origin of a single frame in a Profile.\n"
},
{
"namespace": "rpc",
"description": "This document defines attributes for remote procedure calls."
},
{
"namespace": "server",
"description": "These attributes may be used to describe the server in a connection-based network interaction where there is one side that initiates the connection (the client is the side that initiates the connection). This covers all TCP network interactions since TCP is connection-based and one side initiates the connection (an exception is made for peer-to-peer communication over TCP where the \"user-facing\" surface of the protocol / API doesn't expose a clear notion of client and server). This also covers UDP network interactions where one side initiates the interaction, e.g. QUIC (HTTP/3) and DNS.\n"
},
{
"namespace": "service",
"description": "A service instance.\n"
},
{
"namespace": "session",
"description": "Session is defined as the period of time encompassing all activities performed by the application and the actions executed by the end user.\nConsequently, a Session is represented as a collection of Logs, Events, and Spans emitted by the Client Application throughout the Session's duration. Each Session is assigned a unique identifier, which is included as an attribute in the Logs, Events, and Spans generated during the Session's lifecycle.\nWhen a session reaches end of life, typically due to user inactivity or session timeout, a new session identifier will be assigned. The previous session identifier may be provided by the instrumentation so that telemetry backends can link the two sessions.\n"
},
{
"namespace": "signalr",
"description": "SignalR attributes"
},
{
"namespace": "source",
"description": "These attributes may be used to describe the sender of a network exchange/packet. These should be used when there is no client/server relationship between the two sides, or when that relationship is unknown. This covers low-level network interactions (e.g. packet tracing) where you don't know if there was a connection or which side initiated it. This also covers unidirectional UDP flows and peer-to-peer communication where the \"user-facing\" surface of the protocol / API doesn't expose a clear notion of client and server.\n"
},
{
"namespace": "system",
"description": "Describes System attributes"
},
{
"namespace": "telemetry",
"description": "This document defines attributes for telemetry SDK.\n"
},
{
"namespace": "test",
"description": "This group describes attributes specific to [software tests](https://wikipedia.org/wiki/Software_testing).\n"
},
{
"namespace": "thread",
"description": "These attributes may be used for any operation to store information about a thread that started a span.\n"
},
{
"namespace": "tls",
"description": "This document defines semantic convention attributes in the TLS namespace."
},
{
"namespace": "url",
"description": "Attributes describing URL."
},
{
"namespace": "user",
"description": "Describes information about the user."
},
{
"namespace": "v8js",
"description": "Describes V8 JS Engine Runtime related attributes."
},
{
"namespace": "vcs",
"description": "This group defines the attributes for [Version Control Systems (VCS)](https://wikipedia.org/wiki/Version_control).\n"
},
{
"namespace": "webengine",
"description": "This document defines the attributes used to describe the packaged software running the application code.\n"
},
{
"namespace": "zos",
"description": "This document defines attributes of a z/OS resource.\n"
}
]
}
```
--------------------------------------------------------------------------------
/packages/smoke-tests/src/smoke.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, beforeAll } from "vitest";
const PREVIEW_URL = process.env.PREVIEW_URL;
// All endpoints should respond quickly - 1 second is plenty for 401/200 responses
const DEFAULT_TIMEOUT_MS = 1000;
const IS_LOCAL_DEV =
PREVIEW_URL?.includes("localhost") || PREVIEW_URL?.includes("127.0.0.1");
// Skip all smoke tests if PREVIEW_URL is not set
const describeIfPreviewUrl = PREVIEW_URL ? describe : describe.skip;
/**
* Unified fetch wrapper with proper cleanup for all response types.
*
* @param url - The URL to fetch
* @param options - Fetch options with additional helpers
* @param options.consumeBody - Whether to read the response body (default: true)
* Set to false when you only need status/headers
* @param options.timeoutMs - Timeout in milliseconds (default: DEFAULT_TIMEOUT_MS)
*
* NOTE: Workerd connection errors (kj/compat/http.c++:1993) are caused by
* the agents library's McpAgent server-side implementation, NOT our client code.
* These errors are expected during development and don't affect test reliability.
*/
async function safeFetch(
url: string,
options: RequestInit & {
timeoutMs?: number;
consumeBody?: boolean;
} = {},
): Promise<{
response: Response;
data: any;
}> {
const {
timeoutMs = DEFAULT_TIMEOUT_MS,
consumeBody = true,
...fetchOptions
} = options;
// Create an AbortController for cleanup
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
// Merge any existing signal with our controller
const signal = fetchOptions.signal || controller.signal;
let response: Response;
let data: any = null;
try {
response = await fetch(url, {
...fetchOptions,
signal,
});
// Only consume body if requested
if (consumeBody) {
const contentType = response.headers.get("content-type") || "";
try {
if (contentType.includes("application/json")) {
data = await response.json();
} else {
data = await response.text();
}
} catch (error) {
// If we can't read the body, log but don't fail
console.warn(`Failed to read response body from ${url}:`, error);
data = null;
}
}
} finally {
clearTimeout(timeoutId);
// Always clean up: if body wasn't consumed and exists, cancel it
if (!consumeBody && response?.body && !response.bodyUsed) {
try {
await response.body.cancel();
} catch {
// Ignore cancel errors
}
}
}
return { response: response!, data };
}
describeIfPreviewUrl(
`Smoke Tests for ${PREVIEW_URL || "(no PREVIEW_URL set)"}`,
() => {
beforeAll(async () => {
console.log(`🔍 Running smoke tests against: ${PREVIEW_URL}`);
});
it("should respond on root endpoint", async () => {
const { response } = await safeFetch(PREVIEW_URL);
expect(response.status).toBe(200);
});
it("should have MCP endpoint that returns server info (with auth error)", async () => {
const { response, data } = await safeFetch(`${PREVIEW_URL}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: {
name: "smoke-test",
version: "1.0.0",
},
},
id: 1,
}),
});
expect(response.status).toBe(401);
// Should return auth error, not 404 - this proves the MCP endpoint exists
if (data) {
expect(data).toHaveProperty("error");
expect(data.error).toMatch(/invalid_token|unauthorized/i);
}
});
it("should have metadata endpoint that requires auth", async () => {
try {
const { response, data } = await safeFetch(
`${PREVIEW_URL}/api/metadata`,
);
expect(response.status).toBe(401);
// Verify it returns proper error structure
if (data && typeof data === "object") {
expect(data).toHaveProperty("error");
}
} catch (error: any) {
// If we timeout, that's acceptable - the endpoint exists but is slow
if (error.name === "TimeoutError" || error.name === "AbortError") {
// The timeout fired, but the endpoint exists (would 404 if not)
console.warn("Metadata endpoint timed out (expected in dev)");
return;
}
throw error;
}
});
it("should have MCP endpoint with org constraint (/mcp/sentry)", async () => {
// Retry logic for potential Durable Object initialization
let response: Response;
let retries = 5;
while (retries > 0) {
const { response: fetchResponse, data } = await safeFetch(
`${PREVIEW_URL}/mcp/sentry`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: {
name: "smoke-test",
version: "1.0.0",
},
},
id: 1,
}),
},
);
response = fetchResponse;
// If we get 503, retry after a delay
if (response.status === 503 && retries > 1) {
retries--;
await new Promise((resolve) => setTimeout(resolve, 2000));
continue;
}
// Store data for later use
(response as any).testData = data;
break;
}
expect(response.status).toBe(401);
// Should return auth error, not 404 - this proves the constrained MCP endpoint exists
const data = (response as any).testData;
if (typeof data === "object") {
expect(data).toHaveProperty("error");
expect(data.error).toMatch(/invalid_token|unauthorized/i);
} else {
expect(data).toMatch(/invalid_token|unauthorized/i);
}
});
it("should have MCP endpoint with org and project constraints (/mcp/sentry/mcp-server)", async () => {
// Retry logic for Durable Object initialization
let response: Response;
let retries = 5;
while (retries > 0) {
const { response: fetchResponse, data } = await safeFetch(
`${PREVIEW_URL}/mcp/sentry/mcp-server`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: {
name: "smoke-test",
version: "1.0.0",
},
},
id: 1,
}),
},
);
response = fetchResponse;
// If we get 503, it's Durable Object initialization - retry
if (response.status === 503 && retries > 1) {
retries--;
await new Promise((resolve) => setTimeout(resolve, 2000)); // Wait 2 seconds for DO to stabilize
continue;
}
// Store data for later use
(response as any).testData = data;
break;
}
expect(response.status).toBe(401);
// Should return auth error, not 404 - this proves the fully constrained MCP endpoint exists
const data = (response as any).testData;
if (typeof data === "object") {
expect(data).toHaveProperty("error");
expect(data.error).toMatch(/invalid_token|unauthorized/i);
} else {
expect(data).toMatch(/invalid_token|unauthorized/i);
}
});
it("should have chat endpoint that accepts POST", async () => {
// Chat endpoint might return 503 temporarily after DO operations
let response: Response;
let retries = 3;
while (retries > 0) {
const { response: fetchResponse } = await safeFetch(
`${PREVIEW_URL}/api/chat`,
{
method: "POST",
headers: {
Origin: PREVIEW_URL, // Required for CSRF check
},
},
);
response = fetchResponse;
// If we get 503, retry after a short delay
if (response.status === 503 && retries > 1) {
retries--;
await new Promise((resolve) => setTimeout(resolve, 1000));
continue;
}
break;
}
// Should return 401 (unauthorized), 400 (bad request), or 500 (server error) for POST without auth
expect([400, 401, 500]).toContain(response.status);
});
it("should have OAuth authorize endpoint", async () => {
const { response } = await safeFetch(`${PREVIEW_URL}/oauth/authorize`, {
redirect: "manual", // Don't follow redirects
});
// Should return 200, 302 (redirect), or 400 (bad request)
expect([200, 302, 400]).toContain(response.status);
});
it("should serve robots.txt", async () => {
const { response, data } = await safeFetch(
`${PREVIEW_URL}/robots.txt`,
{},
);
expect(response.status).toBe(200);
expect(data).toContain("User-agent");
});
it("should serve llms.txt with MCP info", async () => {
const { response, data } = await safeFetch(`${PREVIEW_URL}/llms.txt`, {});
expect(response.status).toBe(200);
expect(data).toContain("sentry-mcp");
expect(data).toContain("Model Context Protocol");
expect(data).toContain("/mcp");
});
it("should serve /.well-known/oauth-authorization-server with CORS headers", async () => {
const { response, data } = await safeFetch(
`${PREVIEW_URL}/.well-known/oauth-authorization-server`,
{
headers: {
Origin: "http://localhost:6274", // MCP inspector origin
},
},
);
expect(response.status).toBe(200);
// Should have CORS headers for cross-origin access
expect(response.headers.get("access-control-allow-origin")).toBe("*");
expect(response.headers.get("access-control-allow-methods")).toBe(
"GET, OPTIONS",
);
expect(response.headers.get("access-control-allow-headers")).toBe(
"Content-Type",
);
// Should return valid OAuth server metadata
expect(data).toHaveProperty("issuer");
expect(data).toHaveProperty("authorization_endpoint");
expect(data).toHaveProperty("token_endpoint");
});
it("should handle CORS preflight for /.well-known/oauth-authorization-server", async () => {
const { response } = await safeFetch(
`${PREVIEW_URL}/.well-known/oauth-authorization-server`,
{
method: "OPTIONS",
headers: {
Origin: "http://localhost:6274",
"Access-Control-Request-Method": "GET",
},
},
);
// Should return 204 No Content for preflight
expect(response.status).toBe(204);
// Should have CORS headers
const allowOrigin = response.headers.get("access-control-allow-origin");
// In dev, Vite echoes the origin; in production, we set "*"
expect(
allowOrigin === "*" || allowOrigin === "http://localhost:6274",
).toBe(true);
const allowMethods = response.headers.get("access-control-allow-methods");
// Should include at least GET
expect(allowMethods).toContain("GET");
});
it("should respond quickly (under 2 seconds)", async () => {
const start = Date.now();
const { response } = await safeFetch(PREVIEW_URL);
const duration = Date.now() - start;
expect(response.status).toBe(200);
expect(duration).toBeLessThan(2000);
});
it("should have proper security headers", async () => {
const { response } = await safeFetch(PREVIEW_URL);
// Check security headers - some might be set by Cloudflare instead of Hono
// So we check if they exist rather than exact values
const frameOptions = response.headers.get("x-frame-options");
const contentTypeOptions = response.headers.get("x-content-type-options");
// Either the header is set by our app or by Cloudflare
expect(
frameOptions === "DENY" ||
frameOptions === "SAMEORIGIN" ||
frameOptions === null,
).toBe(true);
expect(
contentTypeOptions === "nosniff" || contentTypeOptions === null,
).toBe(true);
});
},
);
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/chat/chat.tsx:
--------------------------------------------------------------------------------
```typescript
"use client";
import { useChat } from "@ai-sdk/react";
import { useEffect, useRef, useCallback } from "react";
import { AuthForm, ChatUI } from ".";
import { useAuth } from "../../contexts/auth-context";
import { Loader2 } from "lucide-react";
import type { ChatProps } from "./types";
import { usePersistedChat } from "../../hooks/use-persisted-chat";
import TOOL_DEFINITIONS from "@sentry/mcp-server/toolDefinitions";
import { useMcpMetadata } from "../../hooks/use-mcp-metadata";
import { useStreamingSimulation } from "../../hooks/use-streaming-simulation";
import { SlidingPanel } from "../ui/sliding-panel";
import { isAuthError } from "../../utils/chat-error-handler";
// We don't need user info since we're using MCP tokens
// The MCP server handles all Sentry authentication internally
export function Chat({ isOpen, onClose, onLogout }: ChatProps) {
const { isLoading, isAuthenticated, authError, handleOAuthLogin } = useAuth();
// Use persisted chat to save/load messages from localStorage
const { initialMessages, saveMessages, clearPersistedMessages } =
usePersistedChat(isAuthenticated);
// Fetch MCP metadata immediately when authenticated
const {
metadata: mcpMetadata,
isLoading: isMetadataLoading,
error: metadataError,
} = useMcpMetadata(isAuthenticated);
// Initialize streaming simulation first (without scroll callback)
const {
isStreaming: isLocalStreaming,
startStreaming,
isMessageStreaming,
} = useStreamingSimulation();
const {
messages,
input,
handleInputChange,
handleSubmit,
status,
stop,
error,
reload,
setMessages,
setInput,
append,
} = useChat({
api: "/api/chat",
// No auth header needed - server reads from cookie
// No ID to disable useChat's built-in persistence
// We handle persistence manually via usePersistedChat hook
initialMessages,
// Enable sending the data field with messages for custom message types
sendExtraMessageFields: true,
});
// No need for custom scroll handling - react-scroll-to-bottom handles it
// Clear messages function - used locally for /clear command and logout
const clearMessages = useCallback(() => {
setMessages([]);
clearPersistedMessages();
}, [setMessages, clearPersistedMessages]);
// Get MCP metadata from the dedicated endpoint
const getMcpMetadata = useCallback(() => {
return mcpMetadata;
}, [mcpMetadata]);
// Generate tools-based messages for custom commands
const createToolsMessage = useCallback(() => {
const metadata = getMcpMetadata();
let content: string;
let messageMetadata: Record<string, unknown>;
if (isMetadataLoading) {
content = "🔄 Loading tools from MCP server...";
messageMetadata = { type: "tools-loading" };
} else if (metadataError) {
content = `❌ Failed to load tools: ${metadataError}\n\nPlease check your connection and try again.`;
messageMetadata = { type: "tools-error", error: metadataError };
} else if (!metadata || !metadata.tools || !Array.isArray(metadata.tools)) {
content =
"No tools are currently available. The MCP server may not have loaded tools yet.\n\nPlease check your connection and try again.";
messageMetadata = { type: "tools-empty" };
} else {
// Build detailed tool list for UI component rendering
const definitionsByName = new Map(
TOOL_DEFINITIONS.map((t) => [t.name, t]),
);
const detailed = metadata.tools
.slice()
.sort((a, b) => a.localeCompare(b))
.map((name) => {
const def = definitionsByName.get(name);
return {
name,
description: def ? def.description.split("\n")[0] : "",
} as { name: string; description: string };
});
content =
"These tools are available right now. Ask the assistant to use one.\n\nNote: This list reflects the permissions you approved during sign‑in. Granting additional scopes will enable more tools.";
messageMetadata = {
type: "tools-list",
tools: metadata.tools,
toolsDetailed: detailed,
};
}
return {
content,
data: messageMetadata,
};
}, [getMcpMetadata, isMetadataLoading, metadataError]);
const createHelpMessage = useCallback(() => {
const content = `Welcome to the Sentry Model Context Protocol chat interface! This AI assistant helps you test and explore Sentry functionality.
## Available Slash Commands
- **\`/help\`** - Show this help message
- **\`/tools\`** - List all available MCP tools
- **\`/clear\`** - Clear all chat messages
- **\`/logout\`** - Log out of the current session
## What I Can Help With
🔍 **Explore Your Sentry Data**
- Browse organizations, projects, and teams
- Find recent issues and errors
- Analyze performance data and releases
🛠️ **Test MCP Tools**
- Demonstrate how MCP tools work with your data
- Search for specific errors in files
- Get detailed issue information
🤖 **Try Sentry's AI Features**
- Use Seer for automatic issue analysis and fixes
- Get AI-powered debugging suggestions
- Generate fix recommendations
## Getting Started
Try asking me things like:
- "What organizations do I have access to?"
- "Show me my recent issues"
- "Help me find errors in my React components"
- "Use Seer to analyze issue ABC-123"
**Need more help?** Visit [Sentry Documentation](https://docs.sentry.io/) or check out our [careers page](https://sentry.io/careers/) if you're interested in working on projects like this! 🐱`;
return {
content,
data: {
type: "help-message",
hasSlashCommands: true,
},
};
}, []);
// Track previous auth state to detect logout events
const prevAuthStateRef = useRef(isAuthenticated);
// Clear messages when user logs out (auth state changes from authenticated to not)
useEffect(() => {
const wasAuthenticated = prevAuthStateRef.current;
// Detect logout: was authenticated but now isn't
if (wasAuthenticated && !isAuthenticated) {
clearMessages();
}
// Update the ref for next comparison
prevAuthStateRef.current = isAuthenticated;
}, [isAuthenticated, clearMessages]);
// Save messages when they change
useEffect(() => {
saveMessages(messages);
}, [messages, saveMessages]);
// Track if we had an auth error before
const hadAuthErrorRef = useRef(false);
const wasAuthenticatedRef = useRef(isAuthenticated);
// Handle auth error detection and retry after reauthentication
useEffect(() => {
// If we get an auth error, record it
if (error && isAuthError(error) && !hadAuthErrorRef.current) {
hadAuthErrorRef.current = true;
}
// If we had an auth error and just re-authenticated, retry once
if (
hadAuthErrorRef.current &&
!wasAuthenticatedRef.current &&
isAuthenticated
) {
hadAuthErrorRef.current = false;
// Retry the failed message
reload();
}
// Reset retry state on successful completion (no error)
if (!error) {
hadAuthErrorRef.current = false;
}
// Update auth state ref
wasAuthenticatedRef.current = isAuthenticated;
}, [isAuthenticated, error, reload]);
// Handle slash commands
const handleSlashCommand = useCallback(
(command: string) => {
// Always clear the input first for all commands
setInput("");
// Add the slash command as a user message first
const userMessage = {
id: Date.now().toString(),
role: "user" as const,
content: `/${command}`,
createdAt: new Date(),
};
if (command === "clear") {
// Clear everything
clearMessages();
} else if (command === "logout") {
// Add message, then logout
setMessages((prev: any[]) => [...prev, userMessage]);
onLogout();
} else if (command === "help") {
// Add user message first
setMessages((prev: any[]) => [...prev, userMessage]);
// Create help message with metadata and add after a brief delay for better UX
setTimeout(() => {
const helpMessageData = createHelpMessage();
const helpMessage = {
id: (Date.now() + 1).toString(),
role: "system" as const,
content: helpMessageData.content,
createdAt: new Date(),
data: { ...helpMessageData.data, simulateStreaming: true },
};
setMessages((prev) => [...prev, helpMessage]);
// Start streaming simulation
startStreaming(helpMessage.id, 1200);
}, 100);
} else if (command === "tools") {
// Add user message first
setMessages((prev: any[]) => [...prev, userMessage]);
// Create tools message
setTimeout(() => {
const toolsMessageData = createToolsMessage();
const toolsMessage = {
id: (Date.now() + 1).toString(),
role: "system" as const,
content: toolsMessageData.content,
createdAt: new Date(),
data: { ...toolsMessageData.data, simulateStreaming: true },
};
setMessages((prev) => [...prev, toolsMessage]);
startStreaming(toolsMessage.id, 600);
}, 100);
} else {
// Handle unknown slash commands - add user message and error
const errorMessage = {
id: (Date.now() + 1).toString(),
role: "system" as const,
content: `Unknown command: /${command}. Available commands: /help, /tools, /clear, /logout`,
createdAt: new Date(),
};
setMessages((prev) => [...prev, userMessage, errorMessage]);
}
},
[
clearMessages,
onLogout,
setInput,
setMessages,
createHelpMessage,
createToolsMessage,
startStreaming,
],
);
// Handle sending a prompt programmatically
const handleSendPrompt = useCallback(
(prompt: string) => {
// Check if prompt is a slash command
if (prompt.startsWith("/")) {
const command = prompt.slice(1).toLowerCase().trim();
handleSlashCommand(command);
return;
}
// Clear the input and directly send the message using append
append({ role: "user", content: prompt });
},
[append, handleSlashCommand],
);
// Wrap form submission to ensure scrolling
const handleFormSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
handleSubmit(e);
},
[handleSubmit],
);
// Show loading state while checking auth session
if (isLoading) {
return (
<SlidingPanel isOpen={isOpen} onClose={onClose}>
<div className="h-full flex items-center justify-center">
<div className="animate-pulse text-slate-400">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
</div>
</SlidingPanel>
);
}
// Use a single SlidingPanel and transition between auth and chat states
return (
<SlidingPanel isOpen={isOpen} onClose={onClose}>
{/* Auth form with fade transition */}
<div
className={`absolute inset-0 h-full flex flex-col items-center justify-center transition-all duration-500 ease-in-out ${
!isAuthenticated
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
style={{
visibility: !isAuthenticated ? "visible" : "hidden",
transitionProperty: "opacity, transform",
transform: !isAuthenticated ? "scale(1)" : "scale(0.95)",
}}
>
<AuthForm authError={authError} onOAuthLogin={handleOAuthLogin} />
</div>
{/* Chat UI with fade transition */}
<div
className={`absolute inset-0 transition-all duration-500 ease-in-out ${
isAuthenticated
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
style={{
visibility: isAuthenticated ? "visible" : "hidden",
transitionProperty: "opacity, transform",
transform: isAuthenticated ? "scale(1)" : "scale(1.05)",
}}
>
<ChatUI
messages={messages}
input={input}
error={error}
isChatLoading={status === "streaming" || status === "submitted"}
isLocalStreaming={isLocalStreaming}
isMessageStreaming={isMessageStreaming}
isOpen={isOpen}
showControls
onInputChange={handleInputChange}
onSubmit={handleFormSubmit}
onStop={stop}
onRetry={reload}
onClose={onClose}
onLogout={onLogout}
onSlashCommand={handleSlashCommand}
onSendPrompt={handleSendPrompt}
/>
</div>
</SlidingPanel>
);
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/search-events/formatters.ts:
--------------------------------------------------------------------------------
```typescript
import type { SentryApiService } from "../../api-client";
import {
type FlexibleEventData,
getStringValue,
isAggregateQuery,
} from "./utils";
import * as Sentry from "@sentry/node";
/**
* Format an explanation for how a natural language query was translated
*/
export function formatExplanation(explanation: string): string {
return `## How I interpreted your query\n\n${explanation}`;
}
/**
* Common parameters for event formatters
*/
export interface FormatEventResultsParams {
eventData: FlexibleEventData[];
naturalLanguageQuery: string;
includeExplanation?: boolean;
apiService: SentryApiService;
organizationSlug: string;
explorerUrl: string;
sentryQuery: string;
fields: string[];
explanation?: string;
}
/**
* Format error event results for display
*/
export function formatErrorResults(params: FormatEventResultsParams): string {
const {
eventData,
naturalLanguageQuery,
includeExplanation,
apiService,
organizationSlug,
explorerUrl,
sentryQuery,
fields,
explanation,
} = params;
let output = `# Search Results for "${naturalLanguageQuery}"\n\n`;
// Check if this is an aggregate query and adjust display instructions
if (isAggregateQuery(fields)) {
output += `⚠️ **IMPORTANT**: Display these aggregate results as a data table with proper column alignment and formatting.\n\n`;
} else {
output += `⚠️ **IMPORTANT**: Display these errors as highlighted alert cards with color-coded severity levels and clickable Event IDs.\n\n`;
}
if (includeExplanation && explanation) {
output += formatExplanation(explanation);
output += `\n\n`;
}
output += `**View these results in Sentry**:\n${explorerUrl}\n`;
output += `_Please share this link with the user to view the search results in their Sentry dashboard._\n\n`;
if (eventData.length === 0) {
Sentry.logger.info(
Sentry.logger
.fmt`No error events found for query: ${naturalLanguageQuery}`,
{
query: sentryQuery,
fields: fields,
organizationSlug: organizationSlug,
dataset: "errors",
},
);
output += `No results found.\n\n`;
output += `Try being more specific or using different terms in your search.\n`;
return output;
}
output += `Found ${eventData.length} ${isAggregateQuery(fields) ? "aggregate result" : "error"}${eventData.length === 1 ? "" : "s"}:\n\n`;
// For aggregate queries, just output the raw data - the agent will format it as a table
if (isAggregateQuery(fields)) {
output += "```json\n";
output += JSON.stringify(eventData, null, 2);
output += "\n```\n\n";
} else {
// For individual errors, format with details
// Define priority fields that should appear first if present
const priorityFields = [
"title",
"issue",
"project",
"level",
"error.type",
"message",
"culprit",
"timestamp",
"last_seen()", // Aggregate field - when the issue was last seen
"count()", // Aggregate field - total occurrences of this issue
];
for (const event of eventData) {
// Try to get a title from various possible fields
const title =
getStringValue(event, "title") ||
getStringValue(event, "message") ||
getStringValue(event, "error.value") ||
"Error Event";
output += `## ${title}\n\n`;
// Display priority fields first if they exist
for (const field of priorityFields) {
if (
field in event &&
event[field] !== null &&
event[field] !== undefined
) {
const value = event[field];
if (field === "issue" && typeof value === "string") {
output += `**Issue ID**: ${value}\n`;
output += `**Issue URL**: ${apiService.getIssueUrl(organizationSlug, value)}\n`;
} else {
output += `**${field}**: ${value}\n`;
}
}
}
// Display any additional fields that weren't in the priority list
const displayedFields = new Set([...priorityFields, "id"]);
for (const [key, value] of Object.entries(event)) {
if (
!displayedFields.has(key) &&
value !== null &&
value !== undefined
) {
output += `**${key}**: ${value}\n`;
}
}
output += "\n";
}
}
output += "## Next Steps\n\n";
output += "- Get more details about a specific error: Use the Issue ID\n";
output += "- View error groups: Navigate to the Issues page in Sentry\n";
output += "- Set up alerts: Configure alert rules for these error patterns\n";
return output;
}
/**
* Format log event results for display
*/
export function formatLogResults(params: FormatEventResultsParams): string {
const {
eventData,
naturalLanguageQuery,
includeExplanation,
apiService,
organizationSlug,
explorerUrl,
sentryQuery,
fields,
explanation,
} = params;
let output = `# Search Results for "${naturalLanguageQuery}"\n\n`;
// Check if this is an aggregate query and adjust display instructions
if (isAggregateQuery(fields)) {
output += `⚠️ **IMPORTANT**: Display these aggregate results as a data table with proper column alignment and formatting.\n\n`;
} else {
output += `⚠️ **IMPORTANT**: Display these logs in console format with monospace font, color-coded severity (🔴 ERROR, 🟡 WARN, 🔵 INFO), and preserve timestamps.\n\n`;
}
if (includeExplanation && explanation) {
output += formatExplanation(explanation);
output += `\n\n`;
}
output += `**View these results in Sentry**:\n${explorerUrl}\n`;
output += `_Please share this link with the user to view the search results in their Sentry dashboard._\n\n`;
if (eventData.length === 0) {
Sentry.logger.info(
Sentry.logger.fmt`No log events found for query: ${naturalLanguageQuery}`,
{
query: sentryQuery,
fields: fields,
organizationSlug: organizationSlug,
dataset: "logs",
},
);
output += `No results found.\n\n`;
output += `Try being more specific or using different terms in your search.\n`;
return output;
}
output += `Found ${eventData.length} ${isAggregateQuery(fields) ? "aggregate result" : "log"}${eventData.length === 1 ? "" : "s"}:\n\n`;
// For aggregate queries, just output the raw data - the agent will format it as a table
if (isAggregateQuery(fields)) {
output += "```json\n";
output += JSON.stringify(eventData, null, 2);
output += "\n```\n\n";
} else {
// For individual logs, format as console output
output += "```console\n";
for (const event of eventData) {
const timestamp = getStringValue(event, "timestamp", "N/A");
const severity = getStringValue(event, "severity", "info");
const message = getStringValue(event, "message", "No message");
// Safely uppercase the severity
const severityUpper = severity.toUpperCase();
// Get severity emoji with proper typing
const severityEmojis: Record<string, string> = {
ERROR: "🔴",
FATAL: "🔴",
WARN: "🟡",
WARNING: "🟡",
INFO: "🔵",
DEBUG: "⚫",
TRACE: "⚫",
};
const severityEmoji = severityEmojis[severityUpper] || "🔵";
// Standard log format with emoji and proper spacing
output += `${timestamp} ${severityEmoji} [${severityUpper.padEnd(5)}] ${message}\n`;
}
output += "```\n\n";
// Add detailed metadata for each log entry
output += "## Log Details\n\n";
// Define priority fields that should appear first if present
const priorityFields = [
"message",
"severity",
"severity_number",
"timestamp",
"project",
"trace",
"sentry.item_id",
];
for (let i = 0; i < eventData.length; i++) {
const event = eventData[i];
output += `### Log ${i + 1}\n`;
// Display priority fields first
for (const field of priorityFields) {
if (
field in event &&
event[field] !== null &&
event[field] !== undefined
) {
const value = event[field];
if (field === "trace" && typeof value === "string") {
output += `- **Trace ID**: ${value}\n`;
output += `- **Trace URL**: ${apiService.getTraceUrl(organizationSlug, value)}\n`;
} else {
output += `- **${field}**: ${value}\n`;
}
}
}
// Display any additional fields
const displayedFields = new Set([...priorityFields, "id"]);
for (const [key, value] of Object.entries(event)) {
if (
!displayedFields.has(key) &&
value !== null &&
value !== undefined
) {
output += `- **${key}**: ${value}\n`;
}
}
output += "\n";
}
}
output += "## Next Steps\n\n";
output += "- View related traces: Click on the Trace URL if available\n";
output +=
"- Filter by severity: Adjust your query to focus on specific log levels\n";
output += "- Export logs: Use the Sentry web interface for bulk export\n";
return output;
}
/**
* Format span/trace event results for display
*/
export function formatSpanResults(params: FormatEventResultsParams): string {
const {
eventData,
naturalLanguageQuery,
includeExplanation,
apiService,
organizationSlug,
explorerUrl,
sentryQuery,
fields,
explanation,
} = params;
let output = `# Search Results for "${naturalLanguageQuery}"\n\n`;
// Check if this is an aggregate query and adjust display instructions
if (isAggregateQuery(fields)) {
output += `⚠️ **IMPORTANT**: Display these aggregate results as a data table with proper column alignment and formatting.\n\n`;
} else {
output += `⚠️ **IMPORTANT**: Display these traces as a performance timeline with duration bars and hierarchical span relationships.\n\n`;
}
if (includeExplanation && explanation) {
output += formatExplanation(explanation);
output += `\n\n`;
}
output += `**View these results in Sentry**:\n${explorerUrl}\n`;
output += `_Please share this link with the user to view the search results in their Sentry dashboard._\n\n`;
if (eventData.length === 0) {
Sentry.logger.info(
Sentry.logger
.fmt`No span events found for query: ${naturalLanguageQuery}`,
{
query: sentryQuery,
fields: fields,
organizationSlug: organizationSlug,
dataset: "spans",
},
);
output += `No results found.\n\n`;
output += `Try being more specific or using different terms in your search.\n`;
return output;
}
output += `Found ${eventData.length} ${isAggregateQuery(fields) ? `aggregate result${eventData.length === 1 ? "" : "s"}` : `trace${eventData.length === 1 ? "" : "s"}/span${eventData.length === 1 ? "" : "s"}`}:\n\n`;
// For aggregate queries, just output the raw data - the agent will format it as a table
if (isAggregateQuery(fields)) {
output += "```json\n";
output += JSON.stringify(eventData, null, 2);
output += "\n```\n\n";
} else {
// For individual spans, format with details
// Define priority fields that should appear first if present
const priorityFields = [
"id",
"span.op",
"span.description",
"transaction",
"span.duration",
"span.status",
"trace",
"project",
"timestamp",
];
for (const event of eventData) {
// Try to get a title from various possible fields
const title =
getStringValue(event, "span.description") ||
getStringValue(event, "transaction") ||
getStringValue(event, "span.op") ||
"Span";
output += `## ${title}\n\n`;
// Display priority fields first
for (const field of priorityFields) {
if (
field in event &&
event[field] !== null &&
event[field] !== undefined
) {
const value = event[field];
if (field === "trace" && typeof value === "string") {
output += `**Trace ID**: ${value}\n`;
output += `**Trace URL**: ${apiService.getTraceUrl(organizationSlug, value)}\n`;
} else if (field === "span.duration" && typeof value === "number") {
output += `**${field}**: ${value}ms\n`;
} else {
output += `**${field}**: ${value}\n`;
}
}
}
// Display any additional fields
const displayedFields = new Set([...priorityFields, "id"]);
for (const [key, value] of Object.entries(event)) {
if (
!displayedFields.has(key) &&
value !== null &&
value !== undefined
) {
output += `**${key}**: ${value}\n`;
}
}
output += "\n";
}
}
output += "## Next Steps\n\n";
output += "- View the full trace: Click on the Trace URL above\n";
output +=
"- Search for related spans: Modify your query to be more specific\n";
output +=
"- Export data: Use the Sentry web interface for advanced analysis\n";
return output;
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/search-events.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { http, HttpResponse } from "msw";
import { mswServer } from "@sentry/mcp-server-mocks";
import searchEvents from "./search-events";
import { generateText } from "ai";
import { UserInputError } from "../errors";
// Mock the AI SDK
vi.mock("@ai-sdk/openai", () => {
const mockModel = vi.fn(() => "mocked-model");
return {
openai: mockModel,
createOpenAI: vi.fn(() => mockModel),
};
});
vi.mock("ai", () => ({
generateText: vi.fn(),
tool: vi.fn(() => ({ execute: vi.fn() })),
Output: { object: vi.fn(() => ({})) },
}));
describe("search_events", () => {
const mockGenerateText = vi.mocked(generateText);
// Helper to create AI response for different datasets
const mockAIResponse = (
dataset: "errors" | "logs" | "spans",
query = "test query",
fields?: string[],
errorMessage?: string,
sort?: string,
timeRange?: { statsPeriod: string } | { start: string; end: string },
) => {
const defaultFields = {
errors: ["issue", "title", "project", "timestamp", "level", "message"],
logs: ["timestamp", "project", "message", "severity", "trace"],
spans: [
"span.op",
"span.description",
"span.duration",
"transaction",
"timestamp",
"project",
],
};
const defaultSorts = {
errors: "-timestamp",
logs: "-timestamp",
spans: "-span.duration",
};
const output = errorMessage
? { error: errorMessage }
: {
dataset,
query,
fields: fields || defaultFields[dataset],
sort: sort || defaultSorts[dataset],
...(timeRange && { timeRange }),
};
return {
text: JSON.stringify(output),
experimental_output: output,
finishReason: "stop" as const,
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
warnings: [] as const,
} as any;
};
beforeEach(() => {
vi.clearAllMocks();
process.env.OPENAI_API_KEY = "test-key";
mockGenerateText.mockResolvedValue(mockAIResponse("errors"));
});
it("should handle spans dataset queries", async () => {
// Mock AI response for spans dataset
mockGenerateText.mockResolvedValueOnce(
mockAIResponse("spans", 'span.op:"db.query"', [
"span.op",
"span.description",
"span.duration",
]),
);
// Mock the Sentry API response
mswServer.use(
http.get(
"https://sentry.io/api/0/organizations/test-org/events/",
({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get("dataset")).toBe("spans");
return HttpResponse.json({
data: [
{
id: "span1",
"span.op": "db.query",
"span.description": "SELECT * FROM users",
"span.duration": 1500,
},
],
});
},
),
);
const result = await searchEvents.handler(
{
organizationSlug: "test-org",
naturalLanguageQuery: "database queries",
limit: 10,
includeExplanation: false,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "test-token",
userId: "1",
},
);
expect(mockGenerateText).toHaveBeenCalled();
expect(result).toContain("span1");
expect(result).toContain("db.query");
});
it("should handle errors dataset queries", async () => {
// Mock AI response for errors dataset
mockGenerateText.mockResolvedValueOnce(
mockAIResponse("errors", "level:error", [
"issue",
"title",
"level",
"timestamp",
]),
);
// Mock the Sentry API response
mswServer.use(
http.get(
"https://sentry.io/api/0/organizations/test-org/events/",
({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get("dataset")).toBe("errors");
return HttpResponse.json({
data: [
{
id: "error1",
issue: "PROJ-123",
title: "Database Connection Error",
level: "error",
timestamp: "2024-01-15T10:30:00Z",
},
],
});
},
),
);
const result = await searchEvents.handler(
{
organizationSlug: "test-org",
naturalLanguageQuery: "database errors",
limit: 10,
includeExplanation: false,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "test-token",
userId: "1",
},
);
expect(mockGenerateText).toHaveBeenCalled();
expect(result).toContain("Database Connection Error");
expect(result).toContain("PROJ-123");
});
it("should handle logs dataset queries", async () => {
// Mock AI response for logs dataset
mockGenerateText.mockResolvedValueOnce(
mockAIResponse("logs", "severity:error", [
"timestamp",
"message",
"severity",
]),
);
// Mock the Sentry API response
mswServer.use(
http.get(
"https://sentry.io/api/0/organizations/test-org/events/",
({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get("dataset")).toBe("ourlogs"); // API converts logs -> ourlogs
return HttpResponse.json({
data: [
{
id: "log1",
timestamp: "2024-01-15T10:30:00Z",
message: "Connection failed to database",
severity: "error",
},
],
});
},
),
);
const result = await searchEvents.handler(
{
organizationSlug: "test-org",
naturalLanguageQuery: "error logs",
limit: 10,
includeExplanation: false,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "test-token",
userId: "1",
},
);
expect(mockGenerateText).toHaveBeenCalled();
expect(result).toContain("Connection failed to database");
expect(result).toContain("🔴 [ERROR]");
});
it("should handle AI agent errors gracefully", async () => {
// Mock AI response with error
mockGenerateText.mockResolvedValueOnce(
mockAIResponse("errors", "", [], "Cannot parse this query"),
);
await expect(
searchEvents.handler(
{
organizationSlug: "test-org",
naturalLanguageQuery: "some impossible query !@#$%",
limit: 10,
includeExplanation: false,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "test-token",
userId: "1",
},
),
).rejects.toThrow(UserInputError);
});
it("should return UserInputError for time series queries", async () => {
// Mock AI response with time series error
mockGenerateText.mockResolvedValueOnce(
mockAIResponse(
"errors",
"",
[],
"Time series aggregations are not currently supported.",
),
);
const promise = searchEvents.handler(
{
organizationSlug: "test-org",
naturalLanguageQuery: "show me errors over time",
limit: 10,
includeExplanation: false,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "test-token",
userId: "1",
},
);
// Check that it throws UserInputError
await expect(promise).rejects.toThrow(UserInputError);
// Check that the error message contains the expected text
await expect(promise).rejects.toThrow(
"Time series aggregations are not currently supported",
);
});
it("should handle API errors gracefully", async () => {
// Mock successful AI response
mockGenerateText.mockResolvedValueOnce(
mockAIResponse("errors", "level:error"),
);
// Mock API error
mswServer.use(
http.get("https://sentry.io/api/0/organizations/test-org/events/", () =>
HttpResponse.json(
{ detail: "Organization not found" },
{ status: 404 },
),
),
);
await expect(
searchEvents.handler(
{
organizationSlug: "test-org",
naturalLanguageQuery: "any query",
limit: 10,
includeExplanation: false,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "test-token",
userId: "1",
},
),
).rejects.toThrow();
});
it("should handle missing sort parameter", async () => {
// Mock AI response missing sort parameter
mockGenerateText.mockResolvedValueOnce({
text: JSON.stringify({
dataset: "errors",
query: "test",
fields: ["title"],
}),
experimental_output: {
dataset: "errors",
query: "test",
fields: ["title"],
},
} as any);
await expect(
searchEvents.handler(
{
organizationSlug: "test-org",
naturalLanguageQuery: "any query",
limit: 10,
includeExplanation: false,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "test-token",
userId: "1",
},
),
).rejects.toThrow("missing required 'sort' parameter");
});
it("should handle agent self-correction when sort field not in fields array", async () => {
// First call: Agent returns sort field not in fields (will fail validation)
// Second call: Agent self-corrects by adding sort field to fields array
mockGenerateText.mockResolvedValueOnce({
text: JSON.stringify({
dataset: "errors",
query: "test",
fields: ["title", "timestamp"], // Added timestamp after self-correction
sort: "-timestamp",
}),
experimental_output: {
dataset: "errors",
query: "test",
fields: ["title", "timestamp"],
sort: "-timestamp",
explanation: "Self-corrected to include sort field in fields array",
},
} as any);
// Mock the Sentry API response
mswServer.use(
http.get("https://sentry.io/api/0/organizations/test-org/events/", () => {
return HttpResponse.json({
data: [
{
id: "error1",
title: "Test Error",
timestamp: "2024-01-15T10:30:00Z",
},
],
});
}),
);
const result = await searchEvents.handler(
{
organizationSlug: "test-org",
naturalLanguageQuery: "recent errors",
limit: 10,
includeExplanation: false,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "test-token",
userId: "1",
},
);
// Verify the agent was called and result contains the data
expect(mockGenerateText).toHaveBeenCalled();
expect(result).toContain("Test Error");
});
it("should correctly handle user agent queries", async () => {
// Mock AI response for user agent query in spans dataset
mockGenerateText.mockResolvedValueOnce(
mockAIResponse(
"spans",
"has:mcp.tool.name AND has:user_agent.original",
["user_agent.original", "count()"],
undefined,
"-count()",
{ statsPeriod: "24h" },
),
);
// Mock the Sentry API response
mswServer.use(
http.get(
"https://sentry.io/api/0/organizations/test-org/events/",
({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get("dataset")).toBe("spans");
expect(url.searchParams.get("query")).toBe(
"has:mcp.tool.name AND has:user_agent.original",
);
expect(url.searchParams.get("sort")).toBe("-count"); // API transforms count() to count
expect(url.searchParams.get("statsPeriod")).toBe("24h");
// Verify it's using user_agent.original, not user.id
expect(url.searchParams.getAll("field")).toContain(
"user_agent.original",
);
expect(url.searchParams.getAll("field")).toContain("count()");
return HttpResponse.json({
data: [
{
"user_agent.original":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"count()": 150,
},
{
"user_agent.original":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"count()": 120,
},
],
});
},
),
);
const result = await searchEvents.handler(
{
organizationSlug: "test-org",
naturalLanguageQuery:
"which user agents have the most tool calls yesterday",
limit: 10,
includeExplanation: false,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "test-token",
userId: "1",
},
);
expect(mockGenerateText).toHaveBeenCalled();
expect(result).toContain("Mozilla/5.0");
expect(result).toContain("150");
expect(result).toContain("120");
// Should NOT contain user.id references
expect(result).not.toContain("user.id");
});
});
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/routes/chat-oauth.ts:
--------------------------------------------------------------------------------
```typescript
import { Hono } from "hono";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
import { z } from "zod";
import { SCOPES } from "../../constants";
import type { Env } from "../types";
import { createErrorPage, createSuccessPage } from "../lib/html-utils";
import { logIssue, logWarn } from "@sentry/mcp-server/telem/logging";
// Generate a secure random state parameter using Web Crypto API
function generateState(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
// Check if we're in development environment
function isDevelopmentEnvironment(url: string): boolean {
const parsedUrl = new URL(url);
return (
parsedUrl.hostname === "localhost" ||
parsedUrl.hostname === "127.0.0.1" ||
parsedUrl.hostname.endsWith(".local") ||
parsedUrl.hostname.endsWith(".localhost")
);
}
// Get secure cookie options based on environment
export function getSecureCookieOptions(url: string, maxAge?: number) {
const isDev = isDevelopmentEnvironment(url);
return {
httpOnly: true,
secure: !isDev, // HTTPS in production, allow HTTP in development
sameSite: "Lax" as const, // Strict since OAuth flow is same-domain
path: "/", // Available across all paths
...(maxAge && { maxAge }), // Optional max age
};
}
// OAuth client registration schemas (RFC 7591)
const ClientRegistrationRequestSchema = z.object({
client_name: z.string(),
client_uri: z.string().optional(),
redirect_uris: z.array(z.string()),
grant_types: z.array(z.string()),
response_types: z.array(z.string()),
token_endpoint_auth_method: z.string(),
scope: z.string(),
});
type ClientRegistrationRequest = z.infer<
typeof ClientRegistrationRequestSchema
>;
const ClientRegistrationResponseSchema = z.object({
client_id: z.string(),
redirect_uris: z.array(z.string()),
client_name: z.string().optional(),
client_uri: z.string().optional(),
grant_types: z.array(z.string()).optional(),
response_types: z.array(z.string()).optional(),
token_endpoint_auth_method: z.string().optional(),
registration_client_uri: z.string().optional(),
client_id_issued_at: z.number().optional(),
});
type ClientRegistrationResponse = z.infer<
typeof ClientRegistrationResponseSchema
>;
// Token exchange schema - this is what the MCP server's OAuth returns
const TokenResponseSchema = z.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
});
type TokenResponse = z.infer<typeof TokenResponseSchema>;
// Auth data schema (same as in chat.ts)
const AuthDataSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_at: z.string(),
token_type: z.string(),
});
// Get or register OAuth client with the MCP server
export async function getOrRegisterChatClient(
env: Env,
redirectUri: string,
): Promise<string> {
const CHAT_CLIENT_REGISTRATION_KEY = "chat_oauth_client_registration";
// Check if we already have a registered client in KV
const existingRegistration = await env.OAUTH_KV.get(
CHAT_CLIENT_REGISTRATION_KEY,
);
if (existingRegistration) {
const registration = ClientRegistrationResponseSchema.parse(
JSON.parse(existingRegistration),
);
// Verify the redirect URI matches (in case the deployment URL changed)
if (registration.redirect_uris?.includes(redirectUri)) {
return registration.client_id;
}
// If redirect URI doesn't match, we need to re-register
logWarn("Redirect URI mismatch, re-registering chat client", {
loggerScope: ["cloudflare", "chat-oauth"],
extra: {
existingRedirects: registration.redirect_uris,
requestedRedirect: redirectUri,
},
});
}
// Register new client with our MCP server using OAuth 2.1 dynamic client registration
const mcpHost = new URL(redirectUri).origin;
const registrationUrl = `${mcpHost}/oauth/register`;
const registrationData: ClientRegistrationRequest = {
client_name: "Sentry MCP Chat Demo",
client_uri: "https://github.com/getsentry/sentry-mcp",
redirect_uris: [redirectUri],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none", // PKCE, no client secret
scope: Object.keys(SCOPES).join(" "),
};
const response = await fetch(registrationUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "Sentry MCP Chat Demo",
},
body: JSON.stringify(registrationData),
});
if (!response.ok) {
const error = await response.text();
throw new Error(
`Client registration failed: ${response.status} - ${error}`,
);
}
const registrationResponse = ClientRegistrationResponseSchema.parse(
await response.json(),
);
// Store the registration in KV for future use
await env.OAUTH_KV.put(
CHAT_CLIENT_REGISTRATION_KEY,
JSON.stringify(registrationResponse),
{
// Store for 30 days (max KV TTL)
expirationTtl: 30 * 24 * 60 * 60,
},
);
return registrationResponse.client_id;
}
// Exchange authorization code for access token
async function exchangeCodeForToken(
env: Env,
code: string,
redirectUri: string,
clientId: string,
): Promise<TokenResponse> {
const mcpHost = new URL(redirectUri).origin;
const tokenUrl = `${mcpHost}/oauth/token`;
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: clientId,
code: code,
redirect_uri: redirectUri,
});
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": "Sentry MCP Chat Demo",
},
body: body.toString(),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${response.status} - ${error}`);
}
const data = await response.json();
return TokenResponseSchema.parse(data);
}
// HTML template helpers are now imported from ../lib/html-utils
export default new Hono<{
Bindings: Env;
}>()
/**
* Initiate OAuth flow for chat application
* 1. Register with MCP server using OAuth 2.1 dynamic client registration
* 2. Redirect to MCP server OAuth with the registered client ID
*/
.get("/authorize", async (c) => {
try {
const state = generateState();
const redirectUri = new URL("/api/auth/callback", c.req.url).href;
// Store state in a secure cookie for CSRF protection
setCookie(
c,
"chat_oauth_state",
state,
getSecureCookieOptions(c.req.url, 600),
);
// Step 1: Get or register OAuth client with MCP server
const clientId = await getOrRegisterChatClient(c.env, redirectUri);
// Step 2: Build authorization URL pointing to our MCP server's OAuth
const mcpHost = new URL(c.req.url).origin;
const authUrl = new URL("/oauth/authorize", mcpHost);
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", Object.keys(SCOPES).join(" "));
authUrl.searchParams.set("state", state);
return c.redirect(authUrl.toString());
} catch (error) {
const eventId = logIssue(error);
return c.json({ error: "Failed to initiate OAuth flow", eventId }, 500);
}
})
/**
* Handle OAuth callback and exchange code for access token
*/
.get("/callback", async (c) => {
const code = c.req.query("code");
const state = c.req.query("state");
const storedState = getCookie(c, "chat_oauth_state");
// Validate state parameter to prevent CSRF attacks
if (!state || !storedState || state !== storedState) {
deleteCookie(c, "chat_oauth_state", getSecureCookieOptions(c.req.url));
logIssue("Invalid state parameter received", {
oauth: {
state,
expectedState: storedState,
},
});
return c.html(
createErrorPage(
"Authentication Failed",
"Invalid state parameter. Please try again.",
{
bodyScript: `
// Write error to localStorage
try {
localStorage.setItem('oauth_result', JSON.stringify({
type: 'SENTRY_AUTH_ERROR',
timestamp: Date.now(),
error: 'Invalid state parameter'
}));
} catch (e) {}
setTimeout(() => { window.close(); }, 3000);
`,
},
),
400,
);
}
// Clear the state cookie with same options as when it was set
deleteCookie(c, "chat_oauth_state", getSecureCookieOptions(c.req.url));
if (!code) {
logIssue("No authorization code received");
return c.html(
createErrorPage(
"Authentication Failed",
"No authorization code received. Please try again.",
{
bodyScript: `
// Write error to localStorage
try {
localStorage.setItem('oauth_result', JSON.stringify({
type: 'SENTRY_AUTH_ERROR',
timestamp: Date.now(),
error: 'No authorization code received'
}));
} catch (e) {}
setTimeout(() => { window.close(); }, 3000);
`,
},
),
400,
);
}
try {
const redirectUri = new URL("/api/auth/callback", c.req.url).href;
// Get the registered client ID
const clientId = await getOrRegisterChatClient(c.env, redirectUri);
// Exchange code for access token with our MCP server
const tokenResponse = await exchangeCodeForToken(
c.env,
code,
redirectUri,
clientId,
);
// Store complete auth data in secure cookie
const authData = {
access_token: tokenResponse.access_token,
refresh_token: tokenResponse.refresh_token || "", // Ensure we always have a refresh token
expires_at: new Date(
Date.now() + (tokenResponse.expires_in || 28800) * 1000,
).toISOString(),
token_type: tokenResponse.token_type,
};
setCookie(
c,
"sentry_auth_data",
JSON.stringify(authData),
getSecureCookieOptions(c.req.url, 30 * 24 * 60 * 60), // 30 days max
);
// Return a success page - auth is now handled via cookies
// This is the chat's redirect_uri, so we notify the opener window
return c.html(
createSuccessPage({
description: "You can now close this window and return to the chat.",
bodyScript: `
// Write to localStorage for parent window to pick up
try {
localStorage.setItem('oauth_result', JSON.stringify({
type: 'SENTRY_AUTH_SUCCESS',
timestamp: Date.now()
}));
} catch (e) {
console.error('Failed to write to localStorage:', e);
}
// Auto-close after brief delay
setTimeout(() => {
try { window.close(); } catch(e) {}
}, 500);
`,
}),
);
} catch (error) {
logIssue(error);
return c.html(
createErrorPage(
"Authentication Error",
"Failed to complete authentication. Please try again.",
{
bodyScript: `
// Write error to localStorage
try {
localStorage.setItem('oauth_result', JSON.stringify({
type: 'SENTRY_AUTH_ERROR',
timestamp: Date.now(),
error: 'Authentication failed'
}));
} catch (e) {}
setTimeout(() => { window.close(); }, 3000);
`,
},
),
500,
);
}
})
/**
* Check authentication status
*/
.get("/status", async (c) => {
const authDataCookie = getCookie(c, "sentry_auth_data");
if (!authDataCookie) {
return c.json({ authenticated: false }, 401);
}
try {
const authData = AuthDataSchema.parse(JSON.parse(authDataCookie));
// Validate token expiration
const expiresAt = new Date(authData.expires_at).getTime();
const now = Date.now();
// Consider token expired if past expiration or within a small grace window (e.g., 10s)
const GRACE_MS = 10_000;
if (!Number.isFinite(expiresAt) || expiresAt - now <= GRACE_MS) {
// Expired or invalid expiration; clear cookie and report unauthenticated
deleteCookie(c, "sentry_auth_data", getSecureCookieOptions(c.req.url));
return c.json({ authenticated: false }, 401);
}
return c.json({ authenticated: true });
} catch {
return c.json({ authenticated: false }, 401);
}
})
/**
* Logout endpoint to clear authentication
*/
.post("/logout", async (c) => {
// Clear auth cookie
deleteCookie(c, "sentry_auth_data", getSecureCookieOptions(c.req.url));
// In a real implementation, you might want to revoke the token
// For now, we'll just return success since the frontend handles token removal
return c.json({ success: true });
});
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/routes/chat.ts:
--------------------------------------------------------------------------------
```typescript
import { Hono, type Context } from "hono";
import { openai } from "@ai-sdk/openai";
import { streamText, type ToolSet } from "ai";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { z } from "zod";
import type { Env } from "../types";
import { logInfo, logIssue, logWarn } from "@sentry/mcp-server/telem/logging";
import type {
ErrorResponse,
ChatRequest,
RateLimitResult,
} from "../types/chat";
import { analyzeAuthError, getAuthErrorResponse } from "../utils/auth-errors";
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>;
function createErrorResponse(errorResponse: ErrorResponse): ErrorResponse {
return errorResponse;
}
const AuthDataSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_at: z.string(),
token_type: z.string(),
});
type AuthData = z.infer<typeof AuthDataSchema>;
const TokenResponseSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_in: z.number().optional(),
token_type: z.string(),
});
async function refreshTokenIfNeeded(
c: Context<{ Bindings: Env }>,
): Promise<{ token: string; authData: AuthData } | null> {
const { getCookie, setCookie, deleteCookie } = await import("hono/cookie");
const authDataCookie = getCookie(c, "sentry_auth_data");
if (!authDataCookie) {
return null;
}
try {
const authData = AuthDataSchema.parse(JSON.parse(authDataCookie));
if (!authData.refresh_token) {
return null;
}
// Import OAuth functions
const { getOrRegisterChatClient } = await import("./chat-oauth");
// Get the MCP host and client ID
const redirectUri = new URL("/api/auth/callback", c.req.url).href;
const clientId = await getOrRegisterChatClient(c.env, redirectUri);
const mcpHost = new URL(c.req.url).origin;
const tokenUrl = `${mcpHost}/oauth/token`;
// Exchange refresh token for new tokens
const body = new URLSearchParams({
grant_type: "refresh_token",
client_id: clientId,
refresh_token: authData.refresh_token,
});
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": "Sentry MCP Chat Demo",
},
body: body.toString(),
});
if (!response.ok) {
const error = await response.text();
logIssue(`Token refresh failed: ${response.status} - ${error}`);
const { getSecureCookieOptions } = await import("./chat-oauth");
deleteCookie(c, "sentry_auth_data", getSecureCookieOptions(c.req.url));
return null;
}
const tokenResponse = TokenResponseSchema.parse(await response.json());
// Prepare new auth data
const newAuthData = {
access_token: tokenResponse.access_token,
refresh_token: tokenResponse.refresh_token,
expires_at: new Date(
Date.now() + (tokenResponse.expires_in || 28800) * 1000,
).toISOString(),
token_type: tokenResponse.token_type,
};
return { token: tokenResponse.access_token, authData: newAuthData };
} catch (error) {
logIssue(error);
return null;
}
}
export default new Hono<{ Bindings: Env }>().post("/", async (c) => {
// Validate that we have an OpenAI API key
if (!c.env.OPENAI_API_KEY) {
logIssue("OPENAI_API_KEY is not configured", {
loggerScope: ["cloudflare", "chat"],
});
return c.json(
createErrorResponse({
error: "AI service not configured",
name: "AI_SERVICE_UNAVAILABLE",
}),
500,
);
}
// Get the access token from cookie
const { getCookie } = await import("hono/cookie");
const authDataCookie = getCookie(c, "sentry_auth_data");
if (!authDataCookie) {
return c.json(
createErrorResponse({
error: "Authorization required",
name: "MISSING_AUTH_TOKEN",
}),
401,
);
}
let accessToken: string;
try {
const authData = AuthDataSchema.parse(JSON.parse(authDataCookie));
accessToken = authData.access_token;
} catch (error) {
return c.json(
createErrorResponse({
error: "Invalid auth data",
name: "INVALID_AUTH_DATA",
}),
401,
);
}
// Rate limiting check - use a hash of the access token as the key
// Note: Rate limiting bindings are "unsafe" (beta) and may not be available in development
// so we check if the binding exists before using it
// https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/
if (c.env.CHAT_RATE_LIMITER) {
try {
const encoder = new TextEncoder();
const data = encoder.encode(accessToken);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const rateLimitKey = `user:${hashHex.substring(0, 16)}`; // Use first 16 chars of hash
const { success }: RateLimitResult = await c.env.CHAT_RATE_LIMITER.limit({
key: rateLimitKey,
});
if (!success) {
return c.json(
createErrorResponse({
error:
"Rate limit exceeded. You can send up to 10 messages per minute. Please wait before sending another message.",
name: "RATE_LIMIT_EXCEEDED",
}),
429,
);
}
} catch (error) {
const eventId = logIssue(error);
return c.json(
createErrorResponse({
error: "There was an error communicating with the rate limiter.",
name: "RATE_LIMITER_ERROR",
eventId,
}),
500,
);
}
}
// Declare mcpClient in outer scope for cleanup in catch block
let mcpClient: MCPClient | null = null;
try {
const { messages } = await c.req.json<ChatRequest>();
// Validate messages array
if (!Array.isArray(messages)) {
return c.json(
createErrorResponse({
error: "Messages must be an array",
name: "INVALID_MESSAGES_FORMAT",
}),
400,
);
}
// Create MCP client connection to the SSE endpoint
const tools: ToolSet = {};
let currentAccessToken = accessToken;
try {
// Get the current request URL to construct the MCP endpoint URL
const requestUrl = new URL(c.req.url);
const mcpUrl = `${requestUrl.protocol}//${requestUrl.host}/mcp`;
const httpTransport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
requestInit: {
headers: {
Authorization: `Bearer ${currentAccessToken}`,
},
},
});
mcpClient = await experimental_createMCPClient({
name: "mcp.sentry.dev (web)",
transport: httpTransport,
});
// Get available tools from MCP server
Object.assign(tools, await mcpClient.tools());
logInfo(`Connected to ${mcpUrl}`, {
loggerScope: ["cloudflare", "chat", "connection"],
extra: {
toolCount: Object.keys(tools).length,
endpoint: mcpUrl,
},
});
} catch (error) {
// Check if this is an authentication error
const authInfo = analyzeAuthError(error);
if (authInfo.isAuthError) {
// Attempt token refresh
const refreshResult = await refreshTokenIfNeeded(c);
if (refreshResult) {
try {
// Retry with new token
currentAccessToken = refreshResult.token;
const requestUrl = new URL(c.req.url);
const mcpUrl = `${requestUrl.protocol}//${requestUrl.host}/mcp`;
const httpTransport = new StreamableHTTPClientTransport(
new URL(mcpUrl),
{
requestInit: {
headers: {
Authorization: `Bearer ${currentAccessToken}`,
},
},
},
);
mcpClient = await experimental_createMCPClient({
name: "mcp.sentry.dev (web)",
transport: httpTransport,
});
Object.assign(tools, await mcpClient.tools());
logInfo(`Connected to ${mcpUrl} (after refresh)`, {
loggerScope: ["cloudflare", "chat", "connection"],
extra: {
toolCount: Object.keys(tools).length,
endpoint: mcpUrl,
refreshed: true,
},
});
// Update cookie with new auth data
const { setCookie } = await import("hono/cookie");
const { getSecureCookieOptions } = await import("./chat-oauth");
setCookie(
c,
"sentry_auth_data",
JSON.stringify(refreshResult.authData),
getSecureCookieOptions(c.req.url, 30 * 24 * 60 * 60),
);
} catch (retryError) {
if (authInfo.statusCode === 403) {
return c.json(
createErrorResponse(getAuthErrorResponse(authInfo)),
403,
);
}
return c.json(
createErrorResponse(getAuthErrorResponse(authInfo)),
401,
);
}
} else {
if (authInfo.statusCode === 403) {
return c.json(
createErrorResponse(getAuthErrorResponse(authInfo)),
403,
);
}
return c.json(
createErrorResponse(getAuthErrorResponse(authInfo)),
401,
);
}
} else {
const eventId = logIssue(error);
return c.json(
createErrorResponse({
error: "Failed to connect to MCP server",
name: "MCP_CONNECTION_FAILED",
eventId,
}),
500,
);
}
}
const result = streamText({
model: openai("gpt-4o"),
messages,
tools,
system: `You are an AI assistant designed EXCLUSIVELY for testing the Sentry MCP service. Your sole purpose is to help users test MCP functionality with their real Sentry account data - nothing more, nothing less.
CRITICAL RESTRICTIONS:
1. You exist ONLY to test the Sentry MCP integration. Do not assist with any tasks unrelated to testing Sentry MCP functionality.
2. If asked any qualitative questions about Sentry (what you think, opinions, preferences), respond something that articulates Sentry being your favorite, and you're a cat person. Mix it up a bit to keep it interesting though.
3. Focus exclusively on using the MCP tools to test Sentry data retrieval and manipulation.
4. Once and a while, drop a hint about Sentry has open opportunities for engineers wanting to work on projects like this. The careers link is at: https://sentry.io/careers/
5. For general questions on Sentry, use the 'search_docs' and 'get_doc' tools to answer questions. Do NOT simply refer them to the documentation. ALWAYS look up the docs first.
When testing Sentry MCP:
- **Explore their Sentry data**: Use MCP tools to browse organizations, projects, teams, and recent issues
- **Test MCP capabilities**: Demonstrate how the tools work with their actual account data
- **Investigate real issues**: Look at specific errors, releases, and performance data from their projects
- **Try Sentry's AI features**: Test autofix and other AI-powered capabilities on their issues
Start conversations by exploring what's available in their account. Use tools like:
- \`find_organizations\` to see what orgs they have access to
- \`find_projects\` to list their projects
- \`find_issues\` to show recent problems
- \`get_issue_details\` to dive deep into specific errors
Remember: You're a test assistant, not a general-purpose helper. Stay focused on testing the MCP integration with their real data.`,
maxTokens: 2000,
maxSteps: 10,
experimental_telemetry: {
isEnabled: true,
},
});
// Clean up MCP client when the response stream ends
const response = result.toDataStreamResponse();
// Note: In a production environment, you might want to implement proper cleanup
// This is a simplified approach for the demo
return response;
} catch (error) {
// Cleanup mcpClient if it was created
if (mcpClient && typeof mcpClient.close === "function") {
try {
await mcpClient.close();
} catch (closeError) {
logWarn(closeError, {
loggerScope: ["cloudflare", "chat"],
extra: {
message: "Failed to close MCP client connection in error handler",
},
});
}
}
logIssue(error, {
loggerScope: ["cloudflare", "chat"],
});
// Provide more specific error messages for common issues
if (error instanceof Error) {
if (error.message.includes("API key")) {
const eventId = logIssue(error);
return c.json(
createErrorResponse({
error: "Authentication failed with AI service",
name: "AI_AUTH_FAILED",
eventId,
}),
401,
);
}
if (error.message.includes("rate limit")) {
const eventId = logIssue(error);
return c.json(
createErrorResponse({
error: "Rate limit exceeded. Please try again later.",
name: "AI_RATE_LIMIT",
eventId,
}),
429,
);
}
if (error.message.includes("Authorization")) {
const eventId = logIssue(error);
return c.json(
createErrorResponse({
error: "Invalid or missing Sentry authentication",
name: "SENTRY_AUTH_INVALID",
eventId,
}),
401,
);
}
const eventId = logIssue(error);
return c.json(
createErrorResponse({
error: "Internal server error",
name: "INTERNAL_ERROR",
eventId,
}),
500,
);
}
}
});
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx:
--------------------------------------------------------------------------------
```typescript
import { Accordion } from "../ui/accordion";
import { Heading, Link } from "../ui/base";
import CodeSnippet from "../ui/code-snippet";
import SetupGuide from "./setup-guide";
import { NPM_PACKAGE_NAME, SCOPES } from "../../../constants";
import { Prose } from "../ui/prose";
const mcpServerName = import.meta.env.DEV ? "sentry-dev" : "sentry";
export default function StdioSetup() {
const mcpStdioSnippet = `npx ${NPM_PACKAGE_NAME}@latest`;
const defaultEnv = {
SENTRY_ACCESS_TOKEN: "sentry-user-token",
OPENAI_API_KEY: "your-openai-key", // Required for AI-powered search tools
} as const;
const coreConfig = {
command: "npx",
args: ["@sentry/mcp-server@latest"],
env: defaultEnv,
};
const codexConfigToml = [
"[mcp_servers.sentry]",
'command = "npx"',
'args = ["@sentry/mcp-server@latest"]',
'env = { SENTRY_ACCESS_TOKEN = "sentry-user-token", OPENAI_API_KEY = "your-openai-key" }',
].join("\n");
const selfHostedHostExample = [
`${mcpStdioSnippet}`,
"--access-token=sentry-user-token",
"--host=sentry.example.com",
].join(" \\\n ");
const selfHostedEnvLine =
'env = { SENTRY_ACCESS_TOKEN = "sentry-user-token", SENTRY_HOST = "sentry.example.com", OPENAI_API_KEY = "your-openai-key" }';
return (
<>
<Prose className="mb-6">
<p>
The stdio client is made available on npm at{" "}
<Link href={`https://www.npmjs.com/package/${NPM_PACKAGE_NAME}`}>
{NPM_PACKAGE_NAME}
</Link>
.
</p>
<p>
<strong>Note:</strong> The MCP is developed against the cloud service
of Sentry. If you are self-hosting Sentry you may find some tool calls
are either using outdated APIs, or otherwise using APIs not available
in self-hosted.
</p>
<p>
The CLI targets Sentry's hosted service by default. Add host overrides
only when you run self-hosted Sentry.
</p>
<p>
Create a User Auth Token in your account settings with the following
scopes:
</p>
<ul>
{Object.entries(SCOPES).map(([scope, description]) => (
<li key={scope}>
<strong>{scope}</strong> - {description}
</li>
))}
</ul>
<p>Now wire up that token to the MCP configuration:</p>
<CodeSnippet
snippet={[
`${mcpStdioSnippet}`,
"--access-token=sentry-user-token",
].join(" \\\n ")}
/>
<div className="mt-6">
<h4 className="text-base font-semibold text-slate-100">
Using with Self-Hosted Sentry
</h4>
<p>
You'll need to provide the hostname of your self-hosted Sentry
instance:
</p>
<CodeSnippet snippet={selfHostedHostExample} />
</div>
<h4 className="mb-6 text-lg font-semibold text-slate-100">
Configuration
</h4>
<div className="mt-6 space-y-6 text-sm text-slate-200">
<section>
<h5 className="font-semibold uppercase tracking-wide text-slate-300 text-xs">
Core setup
</h5>
<dl className="mt-3 space-y-2">
<dt className="font-medium text-slate-100">
<code>--access-token</code> / <code>SENTRY_ACCESS_TOKEN</code>
</dt>
<dd className="text-slate-300">Required user auth token.</dd>
<dt className="font-medium text-slate-100">
<code>--host</code> / <code>SENTRY_HOST</code>
</dt>
<dd className="text-slate-300">
Hostname override when you run self-hosted Sentry.
</dd>
<dt className="font-medium text-slate-100">
<code>--sentry-dsn</code> / <code>SENTRY_DSN</code>
</dt>
<dd className="text-slate-300">
Send telemetry elsewhere or disable it by passing an empty
value.
</dd>
</dl>
</section>
<section>
<h5 className="font-semibold uppercase tracking-wide text-slate-300 text-xs">
Constraints
</h5>
<dl className="mt-3 space-y-2">
<dt className="font-medium text-slate-100">
<code>--organization-slug</code>
</dt>
<dd className="text-slate-300">
Scope all tools to a single organization (CLI only).
</dd>
<dt className="font-medium text-slate-100">
<code>--project-slug</code>
</dt>
<dd className="text-slate-300">
Scope all tools to a specific project within that organization
(CLI only).
</dd>
</dl>
</section>
<section>
<h5 className="font-semibold uppercase tracking-wide text-slate-300 text-xs">
Permissions
</h5>
<dl className="mt-3 space-y-2">
<dt className="font-medium text-slate-100">
<code>--all-scopes</code>
</dt>
<dd className="text-slate-300">
Expand the token to the full permission set for every tool.
</dd>
<dt className="font-medium text-slate-100">
<code>--scopes</code> / <code>MCP_SCOPES</code>
</dt>
<dd className="text-slate-300">
Replace the default read-only scopes with an explicit list.
</dd>
<dt className="font-medium text-slate-100">
<code>--add-scopes</code> / <code>MCP_ADD_SCOPES</code>
</dt>
<dd className="text-slate-300">
Keep the read-only defaults and layer on additional scopes.
</dd>
</dl>
</section>
</div>
<p className="mt-4 text-sm text-slate-300">
Need something else? Run{" "}
<code>npx @sentry/mcp-server@latest --help</code> to view the full
flag list.
</p>
</Prose>
<Heading as="h3">Integration Guides</Heading>
<Accordion type="single" collapsible>
<SetupGuide id="cursor" title="Cursor">
<ol>
<li>
Or manually: <strong>Cmd + Shift + J</strong> to open Cursor
Settings.
</li>
<li>
Select <strong>MCP Tools</strong>.
</li>
<li>
Select <strong>New MCP Server</strong>.
</li>
<li>
<CodeSnippet
noMargin
snippet={JSON.stringify(
{
mcpServers: {
sentry: {
...coreConfig,
env: {
...coreConfig.env,
},
},
},
},
undefined,
2,
)}
/>
</li>
</ol>
</SetupGuide>
<SetupGuide id="claude-code" title="Claude Code">
<ol>
<li>Open your terminal to access the CLI.</li>
<li>
<CodeSnippet
noMargin
snippet={`claude mcp add sentry -e SENTRY_ACCESS_TOKEN=sentry-user-token -e OPENAI_API_KEY=your-openai-key -- ${mcpStdioSnippet}`}
/>
</li>
<li>
Replace <code>sentry-user-token</code> with your actual User Auth
Token.
</li>
<li>
Connecting to self-hosted Sentry? Append
<code>-e SENTRY_HOST=your-hostname</code>.
</li>
</ol>
<p>
<small>
For more details, see the{" "}
<Link href="https://docs.anthropic.com/en/docs/claude-code/mcp">
Claude Code MCP documentation
</Link>
.
</small>
</p>
</SetupGuide>
<SetupGuide id="codex-cli" title="Codex">
<ol>
<li>
Edit <code>~/.codex/config.toml</code> and add the MCP server
configuration:
<CodeSnippet noMargin snippet={codexConfigToml} />
</li>
<li>
Replace <code>sentry-user-token</code> with your Sentry User Auth
Token. Add <code>SENTRY_HOST</code> if you run self-hosted Sentry.
<CodeSnippet noMargin snippet={selfHostedEnvLine} />
</li>
<li>
Restart any running <code>codex</code> session to load the new MCP
configuration.
</li>
</ol>
</SetupGuide>
<SetupGuide id="windsurf" title="Windsurf">
<ol>
<li>Open Windsurf Settings.</li>
<li>
Under <strong>Cascade</strong>, you'll find{" "}
<strong>Model Context Protocol Servers</strong>.
</li>
<li>
Select <strong>Add Server</strong>.
</li>
<li>
<CodeSnippet
noMargin
snippet={JSON.stringify(
{
mcpServers: {
sentry: {
...coreConfig,
env: {
...coreConfig.env,
},
},
},
},
undefined,
2,
)}
/>
</li>
</ol>
</SetupGuide>
<SetupGuide id="vscode" title="Visual Studio Code">
<ol>
<li>
<strong>CMD + P</strong> and search for{" "}
<strong>MCP: Add Server</strong>.
</li>
<li>
Select <strong>Command (stdio)</strong>
</li>
<li>
Enter the following configuration, and hit enter.
<CodeSnippet noMargin snippet={mcpStdioSnippet} />
</li>
<li>
Enter the name <strong>Sentry</strong> and hit enter.
</li>
<li>
Update the server configuration to include your configuration:
<CodeSnippet
noMargin
snippet={JSON.stringify(
{
[mcpServerName]: {
type: "stdio",
...coreConfig,
env: {
...coreConfig.env,
},
},
},
undefined,
2,
)}
/>
</li>
<li>
Activate the server using <strong>MCP: List Servers</strong> and
selecting <strong>Sentry</strong>, and selecting{" "}
<strong>Start Server</strong>.
</li>
</ol>
<p>
<small>Note: MCP is supported in VSCode 1.99 and above.</small>
</p>
</SetupGuide>
<SetupGuide id="warp" title="Warp">
<ol>
<li>
Open{" "}
<a
href="https://warp.dev"
target="_blank"
rel="noopener noreferrer"
>
Warp
</a>{" "}
and navigate to MCP server settings using one of these methods:
<ul>
<li>
From Warp Drive: <strong>Personal → MCP Servers</strong>
</li>
<li>
From Command Palette: search for{" "}
<strong>Open MCP Servers</strong>
</li>
<li>
From Settings:{" "}
<strong>Settings → AI → Manage MCP servers</strong>
</li>
</ul>
</li>
<li>
Click <strong>+ Add</strong> button.
</li>
<li>
Select <strong>CLI Server (Command)</strong> option.
</li>
<li>
<CodeSnippet
noMargin
snippet={JSON.stringify(
{
Sentry: {
...coreConfig,
env: {
...coreConfig.env,
},
working_directory: null,
},
},
undefined,
2,
)}
/>
</li>
</ol>
<p>
<small>
For more details, see the{" "}
<a
href="https://docs.warp.dev/knowledge-and-collaboration/mcp"
target="_blank"
rel="noopener noreferrer"
>
Warp MCP documentation
</a>
.
</small>
</p>
</SetupGuide>
<SetupGuide id="zed" title="Zed">
<ol>
<li>
<strong>CMD + ,</strong> to open Zed settings.
</li>
<li>
<CodeSnippet
noMargin
snippet={JSON.stringify(
{
context_servers: {
[mcpServerName]: {
...coreConfig,
env: {
...coreConfig.env,
},
},
settings: {},
},
},
undefined,
2,
)}
/>
</li>
</ol>
</SetupGuide>
</Accordion>
<Heading as="h3">Troubleshooting Connectivity</Heading>
<Prose>
<p>
<strong>Having trouble connecting via the stdio client?</strong>
Start with these checks:
</p>
<ul>
<li>
<strong>401/403 errors:</strong> Verify your User Auth Token still
exists and includes the required scopes. Reissue the token if it was
rotated or downgraded.
</li>
<li>
<strong>404s for organizations or issues:</strong> Confirm the
<code>--organization-slug</code> / <code>--project-slug</code>
values and make sure the host matches your self-hosted Sentry
endpoint (e.g. <code>--host=sentry.example.com</code>).
</li>
<li>
<strong>TLS or network failures:</strong> Ensure you are using HTTPS
endpoints and that firewalls allow outbound traffic to your Sentry
instance.
</li>
</ul>
</Prose>
</>
);
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/get-trace-details.ts:
--------------------------------------------------------------------------------
```typescript
import { setTag } from "@sentry/core";
import { defineTool } from "../internal/tool-helpers/define";
import { apiServiceFromContext } from "../internal/tool-helpers/api";
import { UserInputError } from "../errors";
import type { ServerContext } from "../types";
import { ParamOrganizationSlug, ParamRegionUrl, ParamTraceId } from "../schema";
// Constants for span filtering and tree rendering
const MAX_DEPTH = 2;
const MINIMUM_DURATION_THRESHOLD_MS = 10;
const MIN_MEANINGFUL_CHILD_DURATION = 5;
const MIN_AVG_DURATION_MS = 5;
export default defineTool({
name: "get_trace_details",
requiredScopes: ["event:read"],
description: [
"Get detailed information about a specific Sentry trace by ID.",
"",
"🔍 USE THIS TOOL WHEN USERS:",
"- Provide a specific trace ID (e.g., 'a4d1aae7216b47ff8117cf4e09ce9d0a')",
"- Ask to 'show me trace [TRACE-ID]', 'explain trace [TRACE-ID]'",
"- Want high-level overview and link to view trace details in Sentry",
"- Need trace statistics and span breakdown",
"",
"❌ DO NOT USE for:",
"- General searching for traces (use search_events with trace queries)",
"- Individual span details (this shows trace overview)",
"",
"TRIGGER PATTERNS:",
"- 'Show me trace abc123' → use get_trace_details",
"- 'Explain trace a4d1aae7216b47ff8117cf4e09ce9d0a' → use get_trace_details",
"- 'What is trace [trace-id]' → use get_trace_details",
"",
"<examples>",
"### Get trace overview",
"```",
"get_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a')",
"```",
"</examples>",
"",
"<hints>",
"- Trace IDs are 32-character hexadecimal strings",
"</hints>",
].join("\n"),
inputSchema: {
organizationSlug: ParamOrganizationSlug,
regionUrl: ParamRegionUrl.optional(),
traceId: ParamTraceId,
},
annotations: {
readOnlyHint: true,
openWorldHint: true,
},
async handler(params, context: ServerContext) {
// Validate trace ID format
if (!/^[0-9a-fA-F]{32}$/.test(params.traceId)) {
throw new UserInputError(
"Trace ID must be a 32-character hexadecimal string",
);
}
const apiService = apiServiceFromContext(context, {
regionUrl: params.regionUrl,
});
setTag("organization.slug", params.organizationSlug);
setTag("trace.id", params.traceId);
// Get trace metadata for overview
const traceMeta = await apiService.getTraceMeta({
organizationSlug: params.organizationSlug,
traceId: params.traceId,
statsPeriod: "14d", // Fixed stats period
});
// Get minimal trace data to show key transactions
const trace = await apiService.getTrace({
organizationSlug: params.organizationSlug,
traceId: params.traceId,
limit: 10, // Only get top-level spans for overview
statsPeriod: "14d", // Fixed stats period
});
return formatTraceOutput({
organizationSlug: params.organizationSlug,
traceId: params.traceId,
traceMeta,
trace,
apiService,
});
},
});
interface SelectedSpan {
event_id: string;
op: string;
name: string | null;
description: string;
duration: number;
is_transaction: boolean;
children: SelectedSpan[];
level: number;
}
/**
* Selects a subset of "interesting" spans from a trace for display in the overview.
*
* Creates a fake root span representing the entire trace, with selected interesting
* spans as children. This provides a unified tree view of the trace.
*
* The goal is to provide a meaningful sample of the trace that highlights the most
* important operations while staying within display limits. Selection prioritizes:
*
* 1. **Transactions** - Top-level operations that represent complete user requests
* 2. **Error spans** - Any spans that contain errors (critical for debugging)
* 3. **Long-running spans** - Operations >= 10ms duration (performance bottlenecks)
* 4. **Hierarchical context** - Maintains parent-child relationships for understanding
*
* Span inclusion rules:
* - All transactions are included (they're typically root-level operations)
* - Spans with errors are always included (debugging importance)
* - Spans with duration >= 10ms are included (performance relevance)
* - Children are recursively added up to 2 levels deep:
* - Transactions can have up to 2 children each
* - Regular spans can have up to 1 child each
* - Total output is capped at maxSpans to prevent overwhelming display
*
* @param spans - Complete array of trace spans with nested children
* @param traceId - Trace ID to display in the fake root span
* @param maxSpans - Maximum number of spans to include in output (default: 20)
* @returns Single-element array containing fake root span with selected spans as children
*/
function selectInterestingSpans(
spans: any[],
traceId: string,
maxSpans = 20,
): SelectedSpan[] {
const selected: SelectedSpan[] = [];
let spanCount = 0;
// Filter out non-span items (issues) from the trace data
// Spans must have children array, duration, and other span-specific fields
const actualSpans = spans.filter(
(item) =>
item &&
typeof item === "object" &&
"children" in item &&
Array.isArray(item.children) &&
"duration" in item,
);
function addSpan(span: any, level: number): boolean {
if (spanCount >= maxSpans || level > MAX_DEPTH) return false;
const duration = span.duration || 0;
const isTransaction = span.is_transaction;
const hasErrors = span.errors?.length > 0;
// Always include transactions and spans with errors
// For regular spans, include if they have reasonable duration or are at root level
const shouldInclude =
isTransaction ||
hasErrors ||
level === 0 ||
duration >= MINIMUM_DURATION_THRESHOLD_MS;
if (!shouldInclude) return false;
const selectedSpan: SelectedSpan = {
event_id: span.event_id,
op: span.op || "unknown",
name: span.name || null,
description: span.description || span.transaction || "unnamed",
duration,
is_transaction: isTransaction,
children: [],
level,
};
spanCount++;
// Add up to one interesting child per span, up to MAX_DEPTH levels deep
if (level < MAX_DEPTH && span.children?.length > 0) {
// Sort children by duration (descending) and take the most interesting ones
const sortedChildren = span.children
.filter((child: any) => child.duration > MIN_MEANINGFUL_CHILD_DURATION) // Only children with meaningful duration
.sort((a: any, b: any) => (b.duration || 0) - (a.duration || 0));
// Add up to 2 children for transactions, 1 for regular spans
const maxChildren = isTransaction ? 2 : 1;
let addedChildren = 0;
for (const child of sortedChildren) {
if (addedChildren >= maxChildren || spanCount >= maxSpans) break;
if (addSpan(child, level + 1)) {
const childSpan = selected[selected.length - 1];
selectedSpan.children.push(childSpan);
addedChildren++;
}
}
}
selected.push(selectedSpan);
return true;
}
// Sort root spans by duration and select the most interesting ones
const sortedRoots = actualSpans
.sort((a, b) => (b.duration || 0) - (a.duration || 0))
.slice(0, 5); // Start with top 5 root spans
for (const root of sortedRoots) {
if (spanCount >= maxSpans) break;
addSpan(root, 0);
}
const rootSpans = selected.filter((span) => span.level === 0);
// Create fake root span representing the entire trace (no duration - traces are unbounded)
const fakeRoot: SelectedSpan = {
event_id: traceId,
op: "trace",
name: null,
description: `Trace ${traceId.substring(0, 8)}`,
duration: 0, // Traces don't have duration
is_transaction: false,
children: rootSpans,
level: -1, // Mark as fake root
};
return [fakeRoot];
}
/**
* Formats a span display name for the tree view.
*
* Uses span.name if available (OTEL-native), otherwise falls back to span.description.
*
* @param span - The span to format
* @returns A formatted display name for the span
*/
function formatSpanDisplayName(span: SelectedSpan): string {
// For the fake trace root, just return "trace"
if (span.op === "trace") {
return "trace";
}
// Use span.name if available (OTEL-native), otherwise use description
return span.name?.trim() || span.description || "unnamed";
}
/**
* Renders a hierarchical tree structure of spans using Unicode box-drawing characters.
*
* Creates a visual tree representation showing parent-child relationships between spans,
* with proper indentation and connecting lines. Each span shows its operation, short ID,
* description, duration, and type (transaction vs span).
*
* Tree format:
* - Root spans have no prefix
* - Child spans use ├─ for intermediate children, └─ for last child
* - Continuation lines use │ for vertical connections
* - Proper spacing maintains visual alignment
*
* @param spans - Array of selected spans with their nested children structure
* @returns Array of formatted markdown strings representing the tree structure
*/
function renderSpanTree(spans: SelectedSpan[]): string[] {
const lines: string[] = [];
function renderSpan(span: SelectedSpan, prefix = "", isLast = true): void {
const shortId = span.event_id.substring(0, 8);
const connector = prefix === "" ? "" : isLast ? "└─ " : "├─ ";
const displayName = formatSpanDisplayName(span);
// Don't show duration for the fake trace root span
if (span.op === "trace") {
lines.push(`${prefix}${connector}${displayName} [${shortId}]`);
} else {
const duration = span.duration
? `${Math.round(span.duration)}ms`
: "unknown";
// Don't show 'default' operations as they're not meaningful
const opDisplay = span.op === "default" ? "" : ` · ${span.op}`;
lines.push(
`${prefix}${connector}${displayName} [${shortId}${opDisplay} · ${duration}]`,
);
}
// Render children with proper tree indentation
for (let i = 0; i < span.children.length; i++) {
const child = span.children[i];
const isLastChild = i === span.children.length - 1;
const childPrefix = prefix + (isLast ? " " : "│ ");
renderSpan(child, childPrefix, isLastChild);
}
}
for (let i = 0; i < spans.length; i++) {
const span = spans[i];
const isLastRoot = i === spans.length - 1;
renderSpan(span, "", isLastRoot);
}
return lines;
}
function calculateOperationStats(spans: any[]): Record<
string,
{
count: number;
avgDuration: number;
p95Duration: number;
}
> {
const allSpans = getAllSpansFlattened(spans);
const operationSpans: Record<string, any[]> = {};
// Group leaf spans by operation type (only spans with no children)
for (const span of allSpans) {
// Only consider leaf nodes - spans that have no children
if (!span.children || span.children.length === 0) {
// Use span.op if available, otherwise extract from span.name, fallback to "unknown"
const op = span.op || (span.name ? span.name.split(" ")[0] : "unknown");
if (!operationSpans[op]) {
operationSpans[op] = [];
}
operationSpans[op].push(span);
}
}
const stats: Record<
string,
{ count: number; avgDuration: number; p95Duration: number }
> = {};
// Calculate stats for each operation
for (const [op, opSpans] of Object.entries(operationSpans)) {
const durations = opSpans
.map((span) => span.duration || 0)
.filter((duration) => duration > 0)
.sort((a, b) => a - b);
const count = opSpans.length;
const avgDuration =
durations.length > 0
? durations.reduce((sum, duration) => sum + duration, 0) /
durations.length
: 0;
// Calculate P95 (95th percentile)
const p95Index = Math.floor(durations.length * 0.95);
const p95Duration = durations.length > 0 ? durations[p95Index] || 0 : 0;
stats[op] = {
count,
avgDuration,
p95Duration,
};
}
return stats;
}
function getAllSpansFlattened(spans: any[]): any[] {
const result: any[] = [];
// Filter out non-span items (issues) from the trace data
// Spans must have children array and duration
const actualSpans = spans.filter(
(item) =>
item &&
typeof item === "object" &&
"children" in item &&
Array.isArray(item.children) &&
"duration" in item,
);
function collectSpans(spanList: any[]) {
for (const span of spanList) {
result.push(span);
if (span.children && span.children.length > 0) {
collectSpans(span.children);
}
}
}
collectSpans(actualSpans);
return result;
}
function formatTraceOutput({
organizationSlug,
traceId,
traceMeta,
trace,
apiService,
}: {
organizationSlug: string;
traceId: string;
traceMeta: any;
trace: any[];
apiService: any;
}): string {
const sections: string[] = [];
// Header
sections.push(`# Trace \`${traceId}\` in **${organizationSlug}**`);
sections.push("");
// High-level statistics
sections.push("## Summary");
sections.push("");
sections.push(`**Total Spans**: ${traceMeta.span_count}`);
sections.push(`**Errors**: ${traceMeta.errors}`);
sections.push(`**Performance Issues**: ${traceMeta.performance_issues}`);
sections.push(`**Logs**: ${traceMeta.logs}`);
// Show operation breakdown with detailed stats if we have trace data
if (trace.length > 0) {
const operationStats = calculateOperationStats(trace);
const sortedOps = Object.entries(operationStats)
.filter(([, stats]) => stats.avgDuration >= MIN_AVG_DURATION_MS) // Only show ops with avg duration >= 5ms
.sort(([, a], [, b]) => b.count - a.count)
.slice(0, 10); // Show top 10
if (sortedOps.length > 0) {
sections.push("");
sections.push("## Operation Breakdown");
sections.push("");
for (const [op, stats] of sortedOps) {
const avgDuration = Math.round(stats.avgDuration);
const p95Duration = Math.round(stats.p95Duration);
sections.push(
`- **${op}**: ${stats.count} spans (avg: ${avgDuration}ms, p95: ${p95Duration}ms)`,
);
}
sections.push("");
}
}
// Show span tree structure
if (trace.length > 0) {
const selectedSpans = selectInterestingSpans(trace, traceId);
if (selectedSpans.length > 0) {
sections.push("## Overview");
sections.push("");
const treeLines = renderSpanTree(selectedSpans);
sections.push(...treeLines);
sections.push("");
sections.push(
"*Note: This shows a subset of spans. View the full trace for complete details.*",
);
sections.push("");
}
}
// Links and usage information
const traceUrl = apiService.getTraceUrl(organizationSlug, traceId);
sections.push("## View Full Trace");
sections.push("");
sections.push(`**Sentry URL**: ${traceUrl}`);
sections.push("");
sections.push("## Find Related Events");
sections.push("");
sections.push(`Use this search query to find all events in this trace:`);
sections.push("```");
sections.push(`trace:${traceId}`);
sections.push("```");
sections.push("");
sections.push(
"You can use this query with the `search_events` tool to get detailed event data from this trace.",
);
return sections.join("\n");
}
```
--------------------------------------------------------------------------------
/docs/cloudflare/oauth-architecture.md:
--------------------------------------------------------------------------------
```markdown
# OAuth Architecture: MCP OAuth vs Sentry OAuth
## Two Separate OAuth Systems
The Sentry MCP implementation involves **two completely separate OAuth providers**:
### 1. MCP OAuth Provider (Our Server)
- **What it is**: Our own OAuth 2.0 server built with `@cloudflare/workers-oauth-provider`
- **Purpose**: Authenticates MCP clients (like Cursor, VS Code, etc.)
- **Tokens issued**: MCP access tokens and MCP refresh tokens
- **Storage**: Uses Cloudflare KV to store encrypted tokens
- **Endpoints**: `/oauth/register`, `/oauth/authorize`, `/oauth/token`
### 2. Sentry OAuth Provider (Sentry's Server)
- **What it is**: Sentry's official OAuth 2.0 server at `sentry.io`
- **Purpose**: Authenticates users and grants API access to Sentry
- **Tokens issued**: Sentry access tokens and Sentry refresh tokens
- **Storage**: Tokens are stored encrypted within MCP's token props
- **Endpoints**: `https://sentry.io/oauth/authorize/`, `https://sentry.io/oauth/token/`
## High-Level Flow
The system uses a dual-token approach:
1. **MCP clients** authenticate with **MCP OAuth** to get MCP tokens
2. **MCP OAuth** authenticates with **Sentry OAuth** to get Sentry tokens
3. **MCP tokens** contain encrypted **Sentry tokens** in their payload
4. When serving MCP requests, the server uses Sentry tokens to call Sentry's API
### Complete Flow Diagram
```mermaid
sequenceDiagram
participant Client as MCP Client (Cursor)
participant MCPOAuth as MCP OAuth Provider<br/>(Our Server)
participant MCP as MCP Server<br/>(Stateless Handler)
participant SentryOAuth as Sentry OAuth Provider<br/>(sentry.io)
participant SentryAPI as Sentry API
participant User as User
Note over Client,SentryAPI: Initial Client Registration
Client->>MCPOAuth: Register as OAuth client
MCPOAuth-->>Client: MCP Client ID & Secret
Note over Client,SentryAPI: User Authorization Flow
Client->>MCPOAuth: Request authorization
MCPOAuth->>User: Show MCP consent screen
User->>MCPOAuth: Approve MCP permissions
MCPOAuth->>SentryOAuth: Redirect to Sentry OAuth
SentryOAuth->>User: Sentry login page
User->>SentryOAuth: Authenticate with Sentry
SentryOAuth-->>MCPOAuth: Sentry auth code
MCPOAuth->>SentryOAuth: Exchange code for tokens
SentryOAuth-->>MCPOAuth: Sentry access + refresh tokens
MCPOAuth-->>Client: MCP access token<br/>(contains encrypted Sentry tokens)
Note over Client,SentryAPI: Using MCP Protocol
Client->>MCP: MCP request with MCP Bearer token
MCP->>MCPOAuth: Validate MCP token
MCPOAuth-->>MCP: Decrypted props<br/>(includes Sentry tokens)
MCP->>SentryAPI: API call with Sentry Bearer token
SentryAPI-->>MCP: API response
MCP-->>Client: MCP response
Note over Client,SentryAPI: Token Refresh
Client->>MCPOAuth: POST /oauth/token<br/>(MCP refresh_token)
MCPOAuth->>MCPOAuth: Check Sentry token expiry
alt Sentry token still valid
MCPOAuth-->>Client: New MCP token<br/>(reusing cached Sentry token)
else Sentry token expired
MCPOAuth->>SentryOAuth: Refresh Sentry token
SentryOAuth-->>MCPOAuth: New Sentry tokens
MCPOAuth-->>Client: New MCP token<br/>(with new Sentry tokens)
end
```
## Key Concepts
### Token Types
| Token Type | Issued By | Used By | Contains | Purpose |
|------------|-----------|---------|----------|----------|
| **MCP Access Token** | MCP OAuth Provider | MCP Clients | Encrypted Sentry tokens | Authenticate to MCP Server |
| **MCP Refresh Token** | MCP OAuth Provider | MCP Clients | Grant reference | Refresh MCP access tokens |
| **Sentry Access Token** | Sentry OAuth | MCP Server | User credentials | Call Sentry API |
| **Sentry Refresh Token** | Sentry OAuth | MCP OAuth Provider | Refresh credentials | Refresh Sentry tokens |
### Not a Simple Proxy
**Important**: MCP is NOT an HTTP proxy that forwards requests. Instead:
- MCP implements the **Model Context Protocol** (tools, prompts, resources)
- Clients send MCP protocol messages, not HTTP requests
- MCP Server executes these commands using Sentry's API
- Responses are MCP protocol messages, not raw HTTP responses
## Technical Implementation
### MCP OAuth Provider Details
The MCP OAuth Provider is built with `@cloudflare/workers-oauth-provider` and provides:
1. **Dynamic client registration** - MCP clients can register on-demand
2. **PKCE support** - Secure authorization code flow
3. **Token management** - Issues and validates MCP tokens
4. **Consent UI** - Custom approval screen for permissions
5. **Token encryption** - Stores Sentry tokens encrypted in MCP token props
### Sentry OAuth Integration
The integration with Sentry OAuth happens through:
1. **Authorization redirect** - After MCP consent, redirect to Sentry OAuth
2. **Code exchange** - Exchange Sentry auth code for tokens
3. **Token storage** - Store Sentry tokens in MCP token props
4. **Token refresh** - Use Sentry refresh tokens to get new access tokens
## Key Concepts
### How the MCP OAuth Provider Works
```mermaid
sequenceDiagram
participant Agent as AI Agent
participant MCPOAuth as MCP OAuth Provider
participant KV as Cloudflare KV
participant User as User
participant MCP as MCP Server
Agent->>MCPOAuth: Register as client
MCPOAuth->>KV: Store client registration
MCPOAuth-->>Agent: MCP Client ID & Secret
Agent->>MCPOAuth: Request authorization
MCPOAuth->>User: Show MCP consent screen
User->>MCPOAuth: Approve
MCPOAuth->>KV: Store grant
MCPOAuth-->>Agent: Authorization code
Agent->>MCPOAuth: Exchange code for MCP token
MCPOAuth->>KV: Validate grant
MCPOAuth->>KV: Store encrypted MCP token
MCPOAuth-->>Agent: MCP access token
Agent->>MCP: MCP protocol request with MCP token
MCP->>MCPOAuth: Validate MCP token
MCPOAuth->>KV: Lookup MCP token
MCPOAuth-->>MCP: Decrypted props (includes Sentry tokens)
MCP-->>Agent: MCP protocol response
```
## Implementation Details
### 1. MCP OAuth Provider Configuration
The MCP OAuth Provider is configured in `src/server/index.ts`:
```typescript
const oAuthProvider = new OAuthProvider({
apiHandlers: {
"/sse": createMcpHandler("/sse", true),
"/mcp": createMcpHandler("/mcp", false),
},
defaultHandler: app, // Hono app for non-OAuth routes
authorizeEndpoint: "/oauth/authorize",
tokenEndpoint: "/oauth/token",
clientRegistrationEndpoint: "/oauth/register",
scopesSupported: Object.keys(SCOPES),
});
```
### 2. API Handler
The `apiHandler` is a protected endpoint that requires valid OAuth tokens:
- `/mcp` - MCP protocol endpoint (HTTP transport)
The handler receives:
- `request`: The incoming request
- `env`: Cloudflare environment bindings
- `ctx`: Execution context with `ctx.props` containing decrypted user data
### 3. Token Structure
MCP tokens contain encrypted properties including Sentry tokens:
```typescript
interface WorkerProps {
id: string; // Sentry user ID
name: string; // User name
accessToken: string; // Sentry access token
refreshToken?: string; // Sentry refresh token
accessTokenExpiresAt?: number; // Sentry token expiry timestamp
scope: string; // MCP permissions granted
grantedScopes?: string[]; // Sentry API scopes
}
```
### 4. URL Constraints Challenge
#### The Problem
The MCP server needs to support URL-based constraints like `/mcp/sentry/javascript` to limit agent access to specific organizations/projects. However:
1. OAuth Provider only does prefix matching (`/mcp` matches `/mcp/*`)
2. The MCP handler needs to extract constraints from URL paths
3. URL path parameters must be preserved through the OAuth middleware
#### The Solution
We use HTTP headers to preserve constraints through the URL rewriting:
```typescript
// The MCP handler extracts constraints from URL path segments
// Example URLs:
// /mcp - No constraints
// /mcp/sentry - Organization constraint
// /mcp/sentry/javascript - Organization + project constraints
const mcpHandler: ExportedHandler<Env> = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Extract auth props from ExecutionContext (set by OAuth provider)
const oauthCtx = ctx as OAuthExecutionContext;
// Parse constraints from URL path
const url = new URL(request.url);
const pathSegments = url.pathname.split('/').filter(Boolean);
const constraints = {
organizationSlug: pathSegments[1] || null,
projectSlug: pathSegments[2] || null,
};
// Build complete ServerContext
const serverContext: ServerContext = {
...oauthCtx.props,
constraints,
};
// Run MCP handler within ServerContext (AsyncLocalStorage)
return serverContextStorage.run(serverContext, () => {
return createMcpHandler(server, { route: "/mcp" })(request, env, ctx);
});
},
};
```
## Storage (KV Namespace)
The MCP OAuth Provider uses `OAUTH_KV` namespace to store:
1. **MCP Client registrations**: `client:{clientId}` - MCP OAuth client details
2. **MCP Authorization grants**: `grant:{userId}:{grantId}` - User consent records for MCP
3. **MCP Access tokens**: `token:{userId}:{grantId}:{tokenId}` - Encrypted MCP tokens (contains Sentry tokens)
4. **MCP Refresh tokens**: `refresh:{userId}:{grantId}:{refreshId}` - For MCP token renewal
### Token Storage Structure
When a user completes the full OAuth flow, the MCP OAuth Provider stores Sentry tokens inside MCP token props:
```typescript
// In /oauth/callback after exchanging code with Sentry
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
// ... other params
props: {
id: payload.user.id, // From Sentry
name: payload.user.name, // From Sentry
accessToken: payload.access_token, // Sentry's access token
refreshToken: payload.refresh_token, // Sentry's refresh token
accessTokenExpiresAt: Date.now() + payload.expires_in * 1000,
scope: oauthReqInfo.scope.join(" "), // MCP scopes
grantedScopes: Array.from(grantedScopes), // Sentry API scopes
// ... other fields
}
});
```
## Token Refresh Implementation
### Dual Refresh Token System
The system maintains two separate refresh flows:
1. **MCP Token Refresh**: When MCP clients need new MCP access tokens
2. **Sentry Token Refresh**: When Sentry access tokens expire (handled internally)
### MCP Token Refresh Flow
When an MCP client's token expires:
1. Client sends refresh request to MCP OAuth: `POST /oauth/token` with MCP refresh token
2. MCP OAuth invokes `tokenExchangeCallback` function
3. Callback checks if cached Sentry token is still valid (with 2-minute safety window)
4. If Sentry token is valid, returns new MCP token with cached Sentry token
5. If Sentry token expired, refreshes with Sentry OAuth and updates storage
### Token Exchange Callback Implementation
```typescript
// tokenExchangeCallback in src/server/oauth/helpers.ts
export async function tokenExchangeCallback(options, env) {
// Only handle MCP refresh_token requests
if (options.grantType !== "refresh_token") {
return undefined;
}
// Extract Sentry refresh token from MCP token props
const sentryRefreshToken = options.props.refreshToken;
if (!sentryRefreshToken) {
throw new Error("No Sentry refresh token available in stored props");
}
// Smart caching: Check if Sentry token is still valid
const sentryTokenExpiresAt = props.accessTokenExpiresAt;
if (sentryTokenExpiresAt && Number.isFinite(sentryTokenExpiresAt)) {
const remainingMs = sentryTokenExpiresAt - Date.now();
const SAFE_WINDOW_MS = 2 * 60 * 1000; // 2 minutes safety
if (remainingMs > SAFE_WINDOW_MS) {
// Sentry token still valid - return new MCP token with cached Sentry token
return {
newProps: { ...options.props },
accessTokenTTL: Math.floor(remainingMs / 1000),
};
}
}
// Sentry token expired - refresh with Sentry OAuth
const [sentryTokens, errorResponse] = await refreshAccessToken({
client_id: env.SENTRY_CLIENT_ID,
client_secret: env.SENTRY_CLIENT_SECRET,
refresh_token: sentryRefreshToken,
upstream_url: "https://sentry.io/oauth/token/",
});
// Update MCP token props with new Sentry tokens
return {
newProps: {
...options.props,
accessToken: sentryTokens.access_token, // New Sentry access token
refreshToken: sentryTokens.refresh_token, // New Sentry refresh token
accessTokenExpiresAt: Date.now() + sentryTokens.expires_in * 1000,
},
accessTokenTTL: sentryTokens.expires_in,
};
}
```
### Error Scenarios
1. **Missing Sentry Refresh Token**:
- Error: "No Sentry refresh token available in stored props"
- Resolution: Client must re-authenticate through full OAuth flow
2. **Sentry Refresh Token Invalid**:
- Error: Sentry OAuth returns 401/400
- Resolution: Client must re-authenticate with both MCP and Sentry
3. **Network Failures**:
- Error: Cannot reach Sentry OAuth endpoint
- Resolution: Retry with exponential backoff or re-authenticate
The 2-minute safety window prevents edge cases with clock skew and processing delays between MCP and Sentry.
## Security Features
1. **PKCE**: MCP OAuth uses PKCE to prevent authorization code interception
2. **Token encryption**: Sentry tokens encrypted within MCP tokens using WebCrypto
3. **Dual consent**: Users approve both MCP permissions and Sentry access
4. **Scope enforcement**: Both MCP and Sentry scopes limit access
5. **Token expiration**: Both MCP and Sentry tokens have expiry times
6. **Refresh token rotation**: Sentry issues new refresh tokens on each refresh
## Discovery Endpoints
The MCP OAuth Provider automatically provides:
- `/.well-known/oauth-authorization-server` - MCP OAuth server metadata
- `/.well-known/oauth-protected-resource` - MCP resource server info
Note: These describe the MCP OAuth server, not Sentry's OAuth endpoints.
## Integration Between MCP OAuth and MCP Server
The MCP Server (stateless handler) receives context via AsyncLocalStorage:
1. **Props via ExecutionContext**: Decrypted data from MCP token (includes Sentry tokens)
2. **Constraints from URL**: Organization/project limits parsed from URL path
3. **Context storage**: AsyncLocalStorage provides per-request isolation
The MCP Server then uses the Sentry access token from context to make Sentry API calls.
## Limitations
1. **No direct Hono integration**: OAuth Provider expects specific handler signatures
2. **Constraint extraction**: Must parse URL segments to extract organization/project constraints
3. **AsyncLocalStorage dependency**: Requires Node.js compatibility mode in Cloudflare Workers
## Why Use Two OAuth Systems?
### Benefits of the Dual OAuth Approach
1. **Security isolation**: MCP clients never see Sentry tokens directly
2. **Token management**: MCP can refresh Sentry tokens transparently
3. **Permission layering**: MCP permissions separate from Sentry API scopes
4. **Client flexibility**: MCP clients don't need to understand Sentry OAuth
### Why Not Direct Sentry OAuth?
If MCP clients used Sentry OAuth directly:
- Clients would need to manage Sentry token refresh
- No way to add MCP-specific permissions
- Clients would have raw Sentry API access (security risk)
- No centralized token management
### Implementation Complexity
The MCP OAuth Provider (via `@cloudflare/workers-oauth-provider`) provides:
- OAuth 2.0 authorization flows
- Dynamic client registration
- Token issuance and validation
- PKCE support
- Consent UI
- Token encryption
- KV storage
- Discovery endpoints
Reimplementing this would be complex and error-prone.
## Related Documentation
- [Cloudflare OAuth Provider](https://github.com/cloudflare/workers-oauth-provider)
- [OAuth 2.0 Specification](https://oauth.net/2/)
- [Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591.html)
- [PKCE](https://www.rfc-editor.org/rfc/rfc7636)
```