This is page 7 of 12. Use http://codebase.md/getsentry/sentry-mcp?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
│ └── token-cost.yml
├── .gitignore
├── .mcp.json
├── .vscode
│ ├── extensions.json
│ ├── mcp.json
│ └── settings.json
├── AGENTS.md
├── benchmark-agent.sh
├── bin
│ └── bump-version.sh
├── biome.json
├── CLAUDE.md
├── codecov.yml
├── core
├── docs
│ ├── adding-tools.mdc
│ ├── api-patterns.mdc
│ ├── architecture.mdc
│ ├── cloudflare
│ │ ├── architecture.md
│ │ ├── oauth-architecture.md
│ │ └── overview.md
│ ├── coding-guidelines.mdc
│ ├── common-patterns.mdc
│ ├── cursor.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
│ ├── releases
│ │ ├── cloudflare.mdc
│ │ └── stdio.mdc
│ ├── search-events-api-patterns.md
│ ├── security.mdc
│ ├── specs
│ │ ├── README.md
│ │ ├── search-events.md
│ │ └── subpath-constraints.md
│ ├── testing-remote.md
│ ├── testing-stdio.md
│ ├── testing.mdc
│ └── token-cost-tracking.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
│ │ │ │ │ ├── badge.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-endpoint-mode.ts
│ │ │ │ │ ├── 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
│ │ │ └── measure-token-cost.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
│ │ │ │ ├── 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
│ │ │ │ ├── agent-tools.ts
│ │ │ │ ├── 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
│ │ │ │ ├── use-sentry
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── handler.test.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── tool-wrapper.test.ts
│ │ │ │ │ └── tool-wrapper.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/internal/test-fixtures.ts:
--------------------------------------------------------------------------------
```typescript
import type { Event } from "../api-client/types";
import type { z } from "zod";
import type {
FrameInterface,
ExceptionInterface,
ThreadEntrySchema,
} from "../api-client/schema";
// Type aliases for cleaner code
type Frame = z.infer<typeof FrameInterface>;
type ExceptionValue = z.infer<typeof ExceptionInterface>;
type Thread = z.infer<typeof ThreadEntrySchema>;
type StackTrace = { frames: Frame[] };
/**
* Test fixture factories for creating Event objects with minimal boilerplate.
* These factories provide sensible defaults while allowing customization.
*/
// Frame factory with common defaults
export function createFrame(overrides: Partial<Frame> = {}): Frame {
return {
filename: "/app/main.js",
function: "main",
lineNo: 42,
...overrides,
};
}
// Platform-specific frame factories
export const frameFactories = {
python: (overrides: Partial<Frame> = {}) =>
createFrame({
filename: "/app/main.py",
function: "process_data",
...overrides,
}),
java: (overrides: Partial<Frame> = {}) =>
createFrame({
filename: "Example.java",
module: "com.example.Example",
function: "doSomething",
...overrides,
}),
javascript: (overrides: Partial<Frame> = {}) =>
createFrame({
filename: "/app/main.js",
function: "handleRequest",
colNo: 15,
...overrides,
}),
ruby: (overrides: Partial<Frame> = {}) =>
createFrame({
filename: "/app/main.rb",
function: "process",
...overrides,
}),
php: (overrides: Partial<Frame> = {}) =>
createFrame({
filename: "/app/main.php",
function: "handleRequest",
...overrides,
}),
};
// StackTrace factory
export function createStackTrace(frames: Frame[]): StackTrace {
return { frames };
}
// Exception value factory
export function createExceptionValue(
overrides: Partial<ExceptionValue> = {},
): ExceptionValue {
return {
type: "Error",
value: "Something went wrong",
stacktrace: createStackTrace([createFrame()]),
...overrides,
};
}
// Thread factory
export function createThread(overrides: Partial<Thread> = {}): Thread {
return {
id: 1,
name: "main",
crashed: true,
stacktrace: createStackTrace([createFrame()]),
...overrides,
};
}
// Base type that includes all possible fields from ErrorEvent and TransactionEvent
// This allows the builder to mutate fields without type casts
type MutableEvent = {
id: string;
title: string;
message: string | null;
platform: string | null;
type: "error" | "transaction";
entries: Array<{
type: string;
data: any;
}>;
contexts?: Record<string, any>;
tags?: Array<{ key: string; value: string }>;
_meta?: unknown;
dateReceived?: string;
// ErrorEvent specific fields
culprit?: string | null;
dateCreated?: string;
// TransactionEvent specific fields
occurrence?: {
id?: string;
projectId?: number;
eventId?: string;
fingerprint?: string[];
issueTitle: string;
subtitle?: string;
resourceId?: string | null;
evidenceData?: Record<string, any>;
evidenceDisplay?: Array<{
name: string;
value: string;
important?: boolean;
}>;
type?: number;
detectionTime?: number;
level?: string;
culprit?: string | null;
priority?: number;
assignee?: string | null;
// Allow extra fields for test flexibility (like issueType)
[key: string]: unknown;
};
};
// Event factory with builder pattern
export class EventBuilder {
private event: MutableEvent;
constructor(platform = "javascript") {
this.event = {
id: "test123",
title: "Test Event",
message: null,
platform,
type: "error",
entries: [],
contexts: {},
culprit: null,
dateCreated: new Date().toISOString(),
};
}
withId(id: string): this {
this.event.id = id;
return this;
}
withPlatform(platform: string): this {
this.event.platform = platform;
return this;
}
withException(exception: ExceptionValue): this {
this.event.entries.push({
type: "exception",
data: {
values: [exception],
},
});
return this;
}
withChainedExceptions(exceptions: ExceptionValue[]): this {
this.event.entries.push({
type: "exception",
data: {
values: exceptions,
},
});
return this;
}
withThread(thread: Thread): this {
const existingThread = this.event.entries.find((e) => e.type === "threads");
if (
existingThread?.data &&
typeof existingThread.data === "object" &&
"values" in existingThread.data &&
Array.isArray(existingThread.data.values)
) {
existingThread.data.values.push(thread);
} else {
this.event.entries.push({
type: "threads",
data: {
values: [thread],
},
});
}
return this;
}
withMessage(message: string): this {
this.event.entries.push({
type: "message",
data: {
formatted: message,
},
});
return this;
}
withTitle(title: string): this {
this.event.title = title;
return this;
}
withType(type: "error" | "transaction"): this {
this.event.type = type;
return this;
}
withContexts(contexts: Record<string, any>): this {
this.event.contexts = contexts;
return this;
}
withOccurrence(occurrence: {
id?: string;
projectId?: number;
eventId?: string;
fingerprint?: string[];
issueTitle: string;
subtitle?: string;
resourceId?: string | null;
evidenceData?: Record<string, any>;
evidenceDisplay?: Array<{
name: string;
value: string;
important?: boolean;
}>;
type?: number;
detectionTime?: number;
level?: string;
culprit?: string | null;
priority?: number;
assignee?: string | null;
// Allow extra fields for test flexibility (like issueType)
[key: string]: unknown;
}): this {
this.event.occurrence = occurrence;
return this;
}
withEntry(entry: { type: string; data: any }): this {
this.event.entries.push(entry);
return this;
}
build(): Event {
// Cast is safe here because we ensure MutableEvent has all fields needed
// for either ErrorEvent or TransactionEvent based on the type field
return { ...this.event } as Event;
}
}
// Convenience factories for common test scenarios
export const testEvents = {
// Simple Python exception
pythonException: (errorMessage = "Invalid value") =>
new EventBuilder("python")
.withException(
createExceptionValue({
type: "ValueError",
value: errorMessage,
stacktrace: createStackTrace([
frameFactories.python({ lineNo: 42 }),
frameFactories.python({
filename: "/app/utils.py",
function: "validate",
lineNo: 15,
}),
]),
}),
)
.build(),
// Java thread error
javaThreadError: (message = "Test error") =>
new EventBuilder("java")
.withTitle("Test Error")
.withType("error")
.withMessage(message)
.withThread(
createThread({
id: 187,
name: "CONTRACT_WORKER",
state: "RUNNABLE",
stacktrace: createStackTrace([
frameFactories.java({
filename: "Thread.java",
module: "java.lang.Thread",
function: "run",
lineNo: 833,
}),
frameFactories.java({
filename: "AeronServer.java",
module: "com.citics.eqd.mq.aeron.AeronServer",
function: "lambda$start$3",
lineNo: 110,
}),
]),
}),
)
.build(),
// Enhanced frame with context and variables
enhancedFrame: (platform = "python") => {
const frame = frameFactories[platform as keyof typeof frameFactories]({
inApp: true,
context: [
[40, ' raise ValueError("User not found")'],
[41, " "],
[42, " balance = user.account.balance"],
[43, " if balance < amount:"],
[44, " raise InsufficientFundsError()"],
],
vars: {
amount: 150.0,
user_id: "usr_123456",
user: null,
},
});
return new EventBuilder(platform)
.withException(
createExceptionValue({
type: "ValueError",
value: "Something went wrong",
stacktrace: createStackTrace([frame]),
}),
)
.build();
},
};
// Helper to create frames with context lines
export function createFrameWithContext(
frame: Partial<Frame>,
contextLines: Array<[number, string]>,
vars?: Record<string, any>,
): Frame {
return createFrame({
...frame,
inApp: true,
context: contextLines,
vars,
});
}
// Advanced test fixtures for specific scenarios
export const advancedFixtures = {
// Create a minimal event with just an error message
minimalError: (message: string, platform = "javascript") =>
new EventBuilder(platform)
.withException(createExceptionValue({ value: message }))
.build(),
// Create an event with multiple exceptions (chained errors)
chainedExceptions: (platform = "javascript") =>
new EventBuilder(platform)
.withException(
createExceptionValue({
type: "Error",
value: "High level error",
stacktrace: createStackTrace([createFrame({ lineNo: 100 })]),
}),
)
.withException(
createExceptionValue({
type: "CausedBy",
value: "Low level error",
stacktrace: createStackTrace([createFrame({ lineNo: 50 })]),
}),
)
.build(),
// Create event with specific context data
withContextData: (contexts: Record<string, any>) => {
return new EventBuilder().withContexts(contexts).build();
},
};
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/server.ts:
--------------------------------------------------------------------------------
```typescript
/**
* MCP Server Configuration and Request Handling Infrastructure.
*
* This module orchestrates tool execution and telemetry collection
* in a unified server interface for LLMs.
*
* **Configuration Example:**
* ```typescript
* const server = buildServer({
* context: {
* accessToken: "your-sentry-token",
* sentryHost: "sentry.io",
* userId: "user-123",
* clientId: "mcp-client",
* constraints: {}
* },
* wrapWithSentry: (s) => Sentry.wrapMcpServerWithSentry(s),
* });
* ```
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type {
ServerRequest,
ServerNotification,
} from "@modelcontextprotocol/sdk/types.js";
import tools from "./tools/index";
import type { ToolConfig } from "./tools/types";
import type { ServerContext } from "./types";
import {
setTag,
setUser,
startNewTrace,
startSpan,
wrapMcpServerWithSentry,
} from "@sentry/core";
import { logIssue, type LogIssueOptions } from "./telem/logging";
import { formatErrorForUser } from "./internal/error-handling";
import { LIB_VERSION } from "./version";
import { DEFAULT_SCOPES, MCP_SERVER_NAME } from "./constants";
import { isToolAllowed, type Scope } from "./permissions";
import {
getConstraintParametersToInject,
getConstraintKeysToFilter,
} from "./internal/constraint-helpers";
/**
* Extracts MCP request parameters for OpenTelemetry attributes.
*
* @example Parameter Transformation
* ```typescript
* const input = { organizationSlug: "my-org", query: "is:unresolved" };
* const output = extractMcpParameters(input);
* // { "mcp.request.argument.organizationSlug": "\"my-org\"", "mcp.request.argument.query": "\"is:unresolved\"" }
* ```
*/
function extractMcpParameters(args: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(args).map(([key, value]) => {
return [`mcp.request.argument.${key}`, JSON.stringify(value)];
}),
);
}
/**
* Creates and configures a complete MCP server with Sentry instrumentation.
*
* The server is built with tools filtered based on the granted scopes in the context.
* Context is captured in tool handler closures and passed directly to handlers.
*
* @example Usage with stdio transport
* ```typescript
* import { buildServer } from "@sentry/mcp-server/server";
* import { startStdio } from "@sentry/mcp-server/transports/stdio";
*
* const context = {
* accessToken: process.env.SENTRY_TOKEN,
* sentryHost: "sentry.io",
* userId: "user-123",
* clientId: "cursor-ide",
* constraints: {}
* };
*
* const server = buildServer({ context });
* await startStdio(server, context);
* ```
*
* @example Usage with Cloudflare Workers
* ```typescript
* import { buildServer } from "@sentry/mcp-server/server";
* import { experimental_createMcpHandler as createMcpHandler } from "agents/mcp";
*
* const serverContext = buildContextFromOAuth();
* // Context is captured in closures during buildServer()
* const server = buildServer({ context: serverContext });
*
* // Context already available to tool handlers via closures
* return createMcpHandler(server, { route: "/mcp" })(request, env, ctx);
* ```
*/
export function buildServer({
context,
onToolComplete,
tools: customTools,
}: {
context: ServerContext;
onToolComplete?: () => void;
tools?: Record<string, ToolConfig<any>>;
}): McpServer {
const server = new McpServer({
name: MCP_SERVER_NAME,
version: LIB_VERSION,
});
configureServer({ server, context, onToolComplete, tools: customTools });
return wrapMcpServerWithSentry(server);
}
/**
* Configures an MCP server with tools filtered by granted scopes.
*
* Internal function used by buildServer(). Use buildServer() instead for most cases.
* Tools are filtered at registration time based on grantedScopes, and context is
* captured in closures for tool handler execution.
*/
function configureServer({
server,
context,
onToolComplete,
tools: customTools,
}: {
server: McpServer;
context: ServerContext;
onToolComplete?: () => void;
tools?: Record<string, ToolConfig<any>>;
}) {
// Use custom tools if provided, otherwise use default tools
const toolsToRegister = customTools ?? tools;
// Get granted scopes from context for tool filtering
const grantedScopes: Set<Scope> = context.grantedScopes
? new Set<Scope>(context.grantedScopes)
: new Set<Scope>(DEFAULT_SCOPES);
server.server.onerror = (error) => {
const transportLogOptions: LogIssueOptions = {
loggerScope: ["server", "transport"] as const,
contexts: {
transport: {
phase: "server.onerror",
},
},
};
logIssue(error, transportLogOptions);
};
for (const [toolKey, tool] of Object.entries(toolsToRegister)) {
// Filter tools BEFORE registration based on granted scopes
if (!isToolAllowed(tool.requiredScopes, grantedScopes)) {
continue; // Skip this tool entirely
}
// Filter out constraint parameters from schema that will be auto-injected
// Only filter parameters that are ACTUALLY constrained in the current context
// to avoid breaking tools when constraints are not set
const constraintKeysToFilter = new Set(
getConstraintKeysToFilter(context.constraints, tool.inputSchema),
);
const filteredInputSchema = Object.fromEntries(
Object.entries(tool.inputSchema).filter(
([key]) => !constraintKeysToFilter.has(key),
),
) as typeof tool.inputSchema;
server.tool(
tool.name,
tool.description,
filteredInputSchema,
tool.annotations,
async (
params: any,
extra: RequestHandlerExtra<ServerRequest, ServerNotification>,
) => {
try {
return await startNewTrace(async () => {
return await startSpan(
{
name: `tools/call ${tool.name}`,
attributes: {
"mcp.tool.name": tool.name,
"mcp.server.name": MCP_SERVER_NAME,
"mcp.server.version": LIB_VERSION,
...extractMcpParameters(params || {}),
},
},
async (span) => {
// Add constraint attributes to span
if (context.constraints.organizationSlug) {
span.setAttribute(
"sentry-mcp.constraint-organization",
context.constraints.organizationSlug,
);
}
if (context.constraints.projectSlug) {
span.setAttribute(
"sentry-mcp.constraint-project",
context.constraints.projectSlug,
);
}
if (context.userId) {
setUser({
id: context.userId,
});
}
if (context.clientId) {
setTag("client.id", context.clientId);
}
try {
// Apply constraints as parameters, handling aliases (e.g., projectSlug → projectSlugOrId)
const applicableConstraints = getConstraintParametersToInject(
context.constraints,
tool.inputSchema,
);
const paramsWithConstraints = {
...params,
...applicableConstraints,
};
// Execute tool handler with context passed directly
// Context is available via the closure and as a parameter
const output = await tool.handler(
paramsWithConstraints,
context,
);
span.setStatus({
code: 1, // ok
});
// if the tool returns a string, assume it's a message
if (typeof output === "string") {
return {
content: [
{
type: "text" as const,
text: output,
},
],
};
}
// if the tool returns a list, assume it's a content list
if (Array.isArray(output)) {
return {
content: output,
};
}
throw new Error(`Invalid tool output: ${output}`);
} catch (error) {
span.setStatus({
code: 2, // error
});
// CRITICAL: Tool errors MUST be returned as formatted text responses,
// NOT thrown as exceptions. This ensures consistent error handling
// and prevents the MCP client from receiving raw error objects.
//
// The logAndFormatError function provides user-friendly error messages
// with appropriate formatting for different error types:
// - UserInputError: Clear guidance for fixing input problems
// - ConfigurationError: Clear guidance for fixing configuration issues
// - ApiError: HTTP status context with helpful messaging
// - System errors: Sentry event IDs for debugging
//
// DO NOT change this to throw error - it breaks error handling!
return {
content: [
{
type: "text" as const,
text: await formatErrorForUser(error),
},
],
isError: true,
};
}
},
);
});
} finally {
onToolComplete?.();
}
},
);
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/analyze-issue-with-seer.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from "zod";
import { setTag } from "@sentry/core";
import { defineTool } from "../internal/tool-helpers/define";
import { apiServiceFromContext } from "../internal/tool-helpers/api";
import { parseIssueParams } from "../internal/tool-helpers/issue";
import {
getStatusDisplayName,
isTerminalStatus,
getHumanInterventionGuidance,
getOutputForAutofixStep,
SEER_POLLING_INTERVAL,
SEER_TIMEOUT,
SEER_MAX_RETRIES,
SEER_INITIAL_RETRY_DELAY,
} from "../internal/tool-helpers/seer";
import { retryWithBackoff } from "../internal/fetch-utils";
import type { ServerContext } from "../types";
import { ApiError, ApiServerError } from "../api-client/index";
import {
ParamOrganizationSlug,
ParamRegionUrl,
ParamIssueShortId,
ParamIssueUrl,
} from "../schema";
export default defineTool({
name: "analyze_issue_with_seer",
requiredScopes: ["seer"],
description: [
"Use Seer to analyze production errors and get detailed root cause analysis with specific code fixes.",
"",
"Use this tool when you need:",
"- Detailed AI-powered root cause analysis",
"- Specific code fixes and implementation guidance",
"- Step-by-step troubleshooting for complex issues",
"- Understanding why an error is happening in production",
"",
"What this tool provides:",
"- Root cause analysis with code-level explanations",
"- Specific file locations and line numbers where errors occur",
"- Concrete code fixes you can apply",
"- Step-by-step implementation guidance",
"",
"This tool automatically:",
"1. Checks if analysis already exists (instant results)",
"2. Starts new AI analysis if needed (~2-5 minutes)",
"3. Returns complete fix recommendations",
"",
"<examples>",
'### User: "What\'s causing this error? https://my-org.sentry.io/issues/PROJECT-1Z43"',
"",
"```",
"analyze_issue_with_seer(issueUrl='https://my-org.sentry.io/issues/PROJECT-1Z43')",
"```",
"",
'### User: "Can you help me understand why this is failing in production?"',
"",
"```",
"analyze_issue_with_seer(organizationSlug='my-organization', issueId='ERROR-456')",
"```",
"</examples>",
"",
"<hints>",
"- Use this tool when you need deeper analysis beyond basic issue details",
"- If the user provides an issueUrl, extract it and use that parameter alone",
"- The analysis includes actual code snippets and fixes, not just error descriptions",
"- Results are cached - subsequent calls return instantly",
"</hints>",
].join("\n"),
inputSchema: {
organizationSlug: ParamOrganizationSlug.optional(),
regionUrl: ParamRegionUrl.optional(),
issueId: ParamIssueShortId.optional(),
issueUrl: ParamIssueUrl.optional(),
instruction: z
.string()
.describe("Optional custom instruction for the AI analysis")
.optional(),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: true,
},
async handler(params, context: ServerContext) {
const apiService = apiServiceFromContext(context, {
regionUrl: params.regionUrl,
});
const { organizationSlug: orgSlug, issueId: parsedIssueId } =
parseIssueParams({
organizationSlug: params.organizationSlug,
issueId: params.issueId,
issueUrl: params.issueUrl,
});
setTag("organization.slug", orgSlug);
let output = `# Seer Analysis for Issue ${parsedIssueId}\n\n`;
// Step 1: Check if analysis already exists
let autofixState = await retryWithBackoff(
() =>
apiService.getAutofixState({
organizationSlug: orgSlug,
issueId: parsedIssueId!,
}),
{
maxRetries: SEER_MAX_RETRIES,
initialDelay: SEER_INITIAL_RETRY_DELAY,
shouldRetry: (error) => {
// Retry on server errors (5xx) or non-API errors (network issues)
return (
error instanceof ApiServerError || !(error instanceof ApiError)
);
},
},
);
// Step 2: Start analysis if none exists
if (!autofixState.autofix) {
output += `Starting new analysis...\n\n`;
const startResult = await apiService.startAutofix({
organizationSlug: orgSlug,
issueId: parsedIssueId,
instruction: params.instruction,
});
output += `Analysis started with Run ID: ${startResult.run_id}\n\n`;
// Give it a moment to initialize
await new Promise((resolve) => setTimeout(resolve, 1000));
// Refresh state with retry logic
autofixState = await retryWithBackoff(
() =>
apiService.getAutofixState({
organizationSlug: orgSlug,
issueId: parsedIssueId!,
}),
{
maxRetries: SEER_MAX_RETRIES,
initialDelay: SEER_INITIAL_RETRY_DELAY,
shouldRetry: (error) => {
// Retry on server errors (5xx) or non-API errors (network issues)
return (
error instanceof ApiServerError || !(error instanceof ApiError)
);
},
},
);
} else {
output += `Found existing analysis (Run ID: ${autofixState.autofix.run_id})\n\n`;
// Check if existing analysis is already complete
const existingStatus = autofixState.autofix.status;
if (isTerminalStatus(existingStatus)) {
// Return results immediately, no polling needed
output += `## Analysis ${getStatusDisplayName(existingStatus)}\n\n`;
for (const step of autofixState.autofix.steps) {
output += getOutputForAutofixStep(step);
output += "\n";
}
if (existingStatus !== "COMPLETED") {
output += `\n**Status**: ${existingStatus}\n`;
output += getHumanInterventionGuidance(existingStatus);
output += "\n";
}
return output;
}
}
// Step 3: Poll until complete or timeout (only for non-terminal states)
const startTime = Date.now();
let lastStatus = "";
let consecutiveErrors = 0;
while (Date.now() - startTime < SEER_TIMEOUT) {
if (!autofixState.autofix) {
output += `Error: Analysis state lost. Please try again by running:\n`;
output += `\`\`\`\n`;
output += params.issueUrl
? `analyze_issue_with_seer(issueUrl="${params.issueUrl}")`
: `analyze_issue_with_seer(organizationSlug="${orgSlug}", issueId="${parsedIssueId}")`;
output += `\n\`\`\`\n`;
return output;
}
const status = autofixState.autofix.status;
// Check if completed (terminal state)
if (isTerminalStatus(status)) {
output += `## Analysis ${getStatusDisplayName(status)}\n\n`;
// Add all step outputs
for (const step of autofixState.autofix.steps) {
output += getOutputForAutofixStep(step);
output += "\n";
}
if (status !== "COMPLETED") {
output += `\n**Status**: ${status}\n`;
output += getHumanInterventionGuidance(status);
}
return output;
}
// Update status if changed
if (status !== lastStatus) {
const activeStep = autofixState.autofix.steps.find(
(step) =>
step.status === "PROCESSING" || step.status === "IN_PROGRESS",
);
if (activeStep) {
output += `Processing: ${activeStep.title}...\n`;
}
lastStatus = status;
}
// Wait before next poll
await new Promise((resolve) =>
setTimeout(resolve, SEER_POLLING_INTERVAL),
);
// Refresh state with error handling
try {
autofixState = await retryWithBackoff(
() =>
apiService.getAutofixState({
organizationSlug: orgSlug,
issueId: parsedIssueId!,
}),
{
maxRetries: SEER_MAX_RETRIES,
initialDelay: SEER_INITIAL_RETRY_DELAY,
shouldRetry: (error) => {
// Retry on server errors (5xx) or non-API errors (network issues)
return (
error instanceof ApiServerError || !(error instanceof ApiError)
);
},
},
);
consecutiveErrors = 0; // Reset error counter on success
} catch (error) {
consecutiveErrors++;
// If we've had too many consecutive errors, give up
if (consecutiveErrors >= 3) {
output += `\n## Error During Analysis\n\n`;
output += `Unable to retrieve analysis status after multiple attempts.\n`;
output += `Error: ${error instanceof Error ? error.message : String(error)}\n\n`;
output += `You can check the status later by running the same command again:\n`;
output += `\`\`\`\n`;
output += params.issueUrl
? `analyze_issue_with_seer(issueUrl="${params.issueUrl}")`
: `analyze_issue_with_seer(organizationSlug="${orgSlug}", issueId="${parsedIssueId}")`;
output += `\n\`\`\`\n`;
return output;
}
// Log the error but continue polling
output += `Temporary error retrieving status (attempt ${consecutiveErrors}/3), retrying...\n`;
}
}
// Show current progress
if (autofixState.autofix) {
output += `**Current Status**: ${getStatusDisplayName(autofixState.autofix.status)}\n\n`;
for (const step of autofixState.autofix.steps) {
output += getOutputForAutofixStep(step);
output += "\n";
}
}
// Timeout reached
output += `\n## Analysis Timed Out\n\n`;
output += `The analysis is taking longer than expected (>${SEER_TIMEOUT / 1000}s).\n\n`;
output += `\nYou can check the status later by running the same command again:\n`;
output += `\`\`\`\n`;
output += params.issueUrl
? `analyze_issue_with_seer(issueUrl="${params.issueUrl}")`
: `analyze_issue_with_seer(organizationSlug="${orgSlug}", issueId="${parsedIssueId}")`;
output += `\n\`\`\`\n`;
return output;
},
});
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/gen_ai.json:
--------------------------------------------------------------------------------
```json
{
"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",
"attributes": {
"gen_ai.provider.name": {
"description": "The Generative AI provider as identified by the client or server instrumentation.",
"type": "string",
"note": "The attribute SHOULD be set based on the instrumentation's best\nknowledge and may differ from the actual model provider.\n\nMultiple providers, including Azure OpenAI, Gemini, and AI hosting platforms\nare accessible using the OpenAI REST API and corresponding client libraries,\nbut may proxy or host models from different providers.\n\nThe `gen_ai.request.model`, `gen_ai.response.model`, and `server.address`\nattributes may help identify the actual system in use.\n\nThe `gen_ai.provider.name` attribute acts as a discriminator that\nidentifies the GenAI telemetry format flavor specific to that provider\nwithin GenAI semantic conventions.\nIt SHOULD be set consistently with provider-specific attributes and signals.\nFor example, GenAI spans, metrics, and events related to AWS Bedrock\nshould have the `gen_ai.provider.name` set to `aws.bedrock` and include\napplicable `aws.bedrock.*` attributes and are not expected to include\n`openai.*` attributes.\n",
"stability": "development",
"examples": [
"openai",
"gcp.gen_ai",
"gcp.vertex_ai",
"gcp.gemini",
"anthropic",
"cohere",
"azure.ai.inference",
"azure.ai.openai",
"ibm.watsonx.ai",
"aws.bedrock",
"perplexity",
"x_ai",
"deepseek",
"groq",
"mistral_ai"
]
},
"gen_ai.request.model": {
"description": "The name of the GenAI model a request is being made to.",
"type": "string",
"stability": "development",
"examples": ["gpt-4"]
},
"gen_ai.request.max_tokens": {
"description": "The maximum number of tokens the model generates for a request.",
"type": "number",
"stability": "development",
"examples": ["100"]
},
"gen_ai.request.choice.count": {
"description": "The target number of candidate completions to return.",
"type": "number",
"stability": "development",
"examples": ["3"]
},
"gen_ai.request.temperature": {
"description": "The temperature setting for the GenAI request.",
"type": "number",
"stability": "development",
"examples": ["0"]
},
"gen_ai.request.top_p": {
"description": "The top_p sampling setting for the GenAI request.",
"type": "number",
"stability": "development",
"examples": ["1"]
},
"gen_ai.request.top_k": {
"description": "The top_k sampling setting for the GenAI request.",
"type": "number",
"stability": "development",
"examples": ["1"]
},
"gen_ai.request.stop_sequences": {
"description": "List of sequences that the model will use to stop generating further tokens.",
"type": "string",
"stability": "development",
"examples": ["[\"forest\",\"lived\"]"]
},
"gen_ai.request.frequency_penalty": {
"description": "The frequency penalty setting for the GenAI request.",
"type": "number",
"stability": "development",
"examples": ["0.1"]
},
"gen_ai.request.presence_penalty": {
"description": "The presence penalty setting for the GenAI request.",
"type": "number",
"stability": "development",
"examples": ["0.1"]
},
"gen_ai.request.encoding_formats": {
"description": "The encoding formats requested in an embeddings operation, if specified.",
"type": "string",
"note": "In some GenAI systems the encoding formats are called embedding types. Also, some GenAI systems only accept a single format per request.\n",
"stability": "development",
"examples": ["[\"base64\"]", "[\"float\",\"binary\"]"]
},
"gen_ai.request.seed": {
"description": "Requests with same seed value more likely to return same result.",
"type": "number",
"stability": "development",
"examples": ["100"]
},
"gen_ai.response.id": {
"description": "The unique identifier for the completion.",
"type": "string",
"stability": "development",
"examples": ["chatcmpl-123"]
},
"gen_ai.response.model": {
"description": "The name of the model that generated the response.",
"type": "string",
"stability": "development",
"examples": ["gpt-4-0613"]
},
"gen_ai.response.finish_reasons": {
"description": "Array of reasons the model stopped generating tokens, corresponding to each generation received.",
"type": "string",
"stability": "development",
"examples": ["[\"stop\"]", "[\"stop\",\"length\"]"]
},
"gen_ai.usage.input_tokens": {
"description": "The number of tokens used in the GenAI input (prompt).",
"type": "number",
"stability": "development",
"examples": ["100"]
},
"gen_ai.usage.output_tokens": {
"description": "The number of tokens used in the GenAI response (completion).",
"type": "number",
"stability": "development",
"examples": ["180"]
},
"gen_ai.token.type": {
"description": "The type of token being counted.",
"type": "string",
"stability": "development",
"examples": ["input", "output", "output"]
},
"gen_ai.conversation.id": {
"description": "The unique identifier for a conversation (session, thread), used to store and correlate messages within this conversation.",
"type": "string",
"stability": "development",
"examples": ["conv_5j66UpCpwteGg4YSxUnt7lPY"]
},
"gen_ai.agent.id": {
"description": "The unique identifier of the GenAI agent.",
"type": "string",
"stability": "development",
"examples": ["asst_5j66UpCpwteGg4YSxUnt7lPY"]
},
"gen_ai.agent.name": {
"description": "Human-readable name of the GenAI agent provided by the application.",
"type": "string",
"stability": "development",
"examples": ["Math Tutor", "Fiction Writer"]
},
"gen_ai.agent.description": {
"description": "Free-form description of the GenAI agent provided by the application.",
"type": "string",
"stability": "development",
"examples": ["Helps with math problems", "Generates fiction stories"]
},
"gen_ai.tool.name": {
"description": "Name of the tool utilized by the agent.",
"type": "string",
"stability": "development",
"examples": ["Flights"]
},
"gen_ai.tool.call.id": {
"description": "The tool call identifier.",
"type": "string",
"stability": "development",
"examples": ["call_mszuSIzqtI65i1wAUOE8w5H4"]
},
"gen_ai.tool.description": {
"description": "The tool description.",
"type": "string",
"stability": "development",
"examples": ["Multiply two numbers"]
},
"gen_ai.tool.type": {
"description": "Type of the tool utilized by the agent",
"type": "string",
"note": "Extension: A tool executed on the agent-side to directly call external APIs, bridging the gap between the agent and real-world systems.\n Agent-side operations involve actions that are performed by the agent on the server or within the agent's controlled environment.\nFunction: A tool executed on the client-side, where the agent generates parameters for a predefined function, and the client executes the logic.\n Client-side operations are actions taken on the user's end or within the client application.\nDatastore: A tool used by the agent to access and query structured or unstructured external data for retrieval-augmented tasks or knowledge updates.\n",
"stability": "development",
"examples": ["function", "extension", "datastore"]
},
"gen_ai.data_source.id": {
"description": "The data source identifier.",
"type": "string",
"note": "Data sources are used by AI agents and RAG applications to store grounding data. A data source may be an external database, object store, document collection, website, or any other storage system used by the GenAI agent or application. The `gen_ai.data_source.id` SHOULD match the identifier used by the GenAI system rather than a name specific to the external storage, such as a database or object store. Semantic conventions referencing `gen_ai.data_source.id` MAY also leverage additional attributes, such as `db.*`, to further identify and describe the data source.\n",
"stability": "development",
"examples": ["H7STPQYOND"]
},
"gen_ai.operation.name": {
"description": "The name of the operation being performed.",
"type": "string",
"note": "If one of the predefined values applies, but specific system uses a different name it's RECOMMENDED to document it in the semantic conventions for specific GenAI system and use system-specific name in the instrumentation. If a different name is not documented, instrumentation libraries SHOULD use applicable predefined value.\n",
"stability": "development",
"examples": [
"chat",
"generate_content",
"text_completion",
"embeddings",
"create_agent",
"invoke_agent",
"execute_tool"
]
},
"gen_ai.output.type": {
"description": "Represents the content type requested by the client.",
"type": "string",
"note": "This attribute SHOULD be used when the client requests output of a specific type. The model may return zero or more outputs of this type.\nThis attribute specifies the output modality and not the actual output format. For example, if an image is requested, the actual output could be a URL pointing to an image file.\nAdditional output format details may be recorded in the future in the `gen_ai.output.{type}.*` attributes.\n",
"stability": "development",
"examples": ["text", "json", "image", "speech"]
}
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/fragments/remote-setup.tsx:
--------------------------------------------------------------------------------
```typescript
import { Accordion } from "../ui/accordion";
import CodeSnippet from "../ui/code-snippet";
import SetupGuide from "./setup-guide";
import { Prose } from "../ui/prose";
import { NPM_REMOTE_NAME } from "@/constants";
import { Button } from "../ui/button";
import { Heading } from "../ui/base";
const mcpServerName = import.meta.env.DEV ? "sentry-dev" : "sentry";
export default function RemoteSetup() {
const endpoint = new URL("/mcp", window.location.href).href;
const mcpRemoteSnippet = `npx ${NPM_REMOTE_NAME}@latest ${endpoint}`;
// the shared configuration for all clients
const coreConfig = {
command: "npx",
args: ["-y", `${NPM_REMOTE_NAME}@latest`, endpoint],
};
const codexRemoteConfigToml = [
"[mcp_servers.sentry]",
'command = "npx"',
`args = ["-y", "${NPM_REMOTE_NAME}@latest", "${endpoint}"]`,
].join("\n");
const sentryMCPConfig = {
url: endpoint,
};
// https://code.visualstudio.com/docs/copilot/chat/mcp-servers
const vsCodeHandler = `vscode:mcp/install?${encodeURIComponent(
JSON.stringify({
name: mcpServerName,
serverUrl: endpoint,
}),
)}`;
const zedInstructions = JSON.stringify(
{
context_servers: {
[mcpServerName]: coreConfig,
settings: {},
},
},
undefined,
2,
);
return (
<>
<Prose className="mb-6">
<p>Connect directly using the base endpoint:</p>
<CodeSnippet snippet={endpoint} />
<p>
<strong>Path Constraints:</strong> Restrict the session to a specific
organization or project by adding them to the URL path. This ensures
all tools operate within the specified scope.
</p>
<ul>
<li>
<code>/:organization</code> — Limit to one organization
</li>
<li>
<code>/:organization/:project</code> — Limit to a specific project
</li>
</ul>
<p>
<strong>Agent Mode:</strong> Reduce context by exposing a single{" "}
<code>use_sentry</code> tool instead of individual tools. The embedded
AI agent handles natural language requests and automatically chains
tool calls as needed. Note: Agent mode approximately doubles response
time due to the embedded AI layer.
</p>
<ul>
<li>
<code>?agent=1</code> — Enable agent mode (works with path
constraints)
</li>
</ul>
</Prose>
<Heading as="h3">Integration Guides</Heading>
<Accordion type="single" collapsible>
<SetupGuide id="cursor" title="Cursor">
<Button
variant="secondary"
size="sm"
onClick={() => {
const deepLink =
"cursor://anysphere.cursor-deeplink/mcp/install?name=Sentry&config=eyJ1cmwiOiJodHRwczovL21jcC5zZW50cnkuZGV2L21jcCJ9";
window.location.href = deepLink;
}}
className="mt-2 mb-2 bg-violet-300 text-black hover:bg-violet-400 hover:text-black"
>
Install in Cursor
</Button>
<ol>
<li>
Or manually: <strong>Cmd + Shift + J</strong> to open Cursor
Settings.
</li>
<li>
Select <strong>Tools and Integrations</strong>.
</li>
<li>
Select <strong>New MCP Server</strong>.
</li>
<li>
<CodeSnippet
noMargin
snippet={JSON.stringify(
{
mcpServers: {
sentry: sentryMCPConfig,
},
},
undefined,
2,
)}
/>
</li>
<li>
Optional: To use the service with <code>cursor-agent</code>:
<CodeSnippet noMargin snippet={`cursor-agent mcp login sentry`} />
</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 --transport http sentry ${endpoint}`}
/>
</li>
<li>
This will trigger an OAuth authentication flow to connect Claude
Code to your Sentry account.
</li>
<li>
You may need to manually authenticate if it doesnt happen
automatically, which can be done via <code>/mcp</code>.
</li>
</ol>
<p>
<small>
For more details, see the{" "}
<a href="https://docs.anthropic.com/en/docs/claude-code/mcp">
Claude Code MCP documentation
</a>
.
</small>
</p>
</SetupGuide>
<SetupGuide id="codex-cli" title="Codex">
<ol>
<li>Open your terminal to access the CLI.</li>
<li>
<CodeSnippet
noMargin
snippet={`codex mcp add sentry -- ${coreConfig.command} ${coreConfig.args.join(" ")}`}
/>
</li>
<li>
Next time you run <code>codex</code>, the Sentry MCP server will
be available. It will automatically open the OAuth flow to connect
to your Sentry account.
</li>
</ol>
Or
<ol>
<li>
Edit <code>~/.codex/config.toml</code> and add the remote MCP
configuration:
<CodeSnippet noMargin snippet={codexRemoteConfigToml} />
</li>
<li>
Save the file and restart any running <code>codex</code> session
</li>
<li>
Next time you run <code>codex</code>, the Sentry MCP server will
be available. It will automatically open the OAuth flow to connect
to your Sentry account.
</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,
},
},
undefined,
2,
)}
/>
</li>
</ol>
</SetupGuide>
<SetupGuide id="vscode" title="Visual Studio Code">
<Button
variant="secondary"
size="sm"
onClick={() => {
window.location.href = vsCodeHandler;
}}
className="mt-2 mb-2 bg-violet-300 text-black hover:bg-violet-400 hover:text-black"
>
Install in VSCode
</Button>
<p>
If this doesn't work, you can manually add the server using the
following steps:
</p>
<ol>
<li>
<strong>CMD + P</strong> and search for{" "}
<strong>MCP: Add Server</strong>.
</li>
<li>
Select <strong>HTTP (HTTP or Server-Sent Events)</strong>.
</li>
<li>
Enter the following configuration, and hit enter
<strong> {endpoint}</strong>
</li>
<li>
Enter the name <strong>Sentry</strong> and hit enter.
</li>
<li>Allow the authentication flow to complete.</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: {},
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={zedInstructions} />
</li>
</ol>
</SetupGuide>
</Accordion>
</>
);
}
```
--------------------------------------------------------------------------------
/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-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-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";
import { useEndpointMode } from "../../hooks/use-endpoint-mode";
// 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 endpoint mode hook to manage MCP endpoint preference
const { endpointMode, toggleEndpointMode } = useEndpointMode();
// 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,
// Pass endpoint mode to the API
body: {
endpointMode,
},
});
// 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
endpointMode={endpointMode}
onInputChange={handleInputChange}
onSubmit={handleFormSubmit}
onStop={stop}
onRetry={reload}
onClose={onClose}
onLogout={onLogout}
onSlashCommand={handleSlashCommand}
onSendPrompt={handleSendPrompt}
onToggleEndpointMode={toggleEndpointMode}
/>
</div>
</SlidingPanel>
);
}
```
--------------------------------------------------------------------------------
/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 });
});
```