This is page 4 of 15. Use http://codebase.md/getsentry/sentry-mcp?lines=true&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/telem/sentry.ts:
--------------------------------------------------------------------------------
```typescript
1 | interface ScrubPattern {
2 | pattern: RegExp;
3 | replacement: string;
4 | description: string;
5 | }
6 |
7 | // Patterns for sensitive data that should be scrubbed
8 | // Pre-compile patterns with global flag for replacement
9 | const SCRUB_PATTERNS: ScrubPattern[] = [
10 | {
11 | pattern: /\bsk-[a-zA-Z0-9]{48}\b/g,
12 | replacement: "[REDACTED_OPENAI_KEY]",
13 | description: "OpenAI API key",
14 | },
15 | {
16 | pattern: /\bBearer\s+[a-zA-Z0-9\-._~+/]+={0,}/g,
17 | replacement: "Bearer [REDACTED_TOKEN]",
18 | description: "Bearer token",
19 | },
20 | {
21 | pattern: /\bsntrys_[a-zA-Z0-9_]+\b/g,
22 | replacement: "[REDACTED_SENTRY_TOKEN]",
23 | description: "Sentry access token",
24 | },
25 | ];
26 |
27 | // Maximum depth for recursive scrubbing to prevent stack overflow
28 | const MAX_SCRUB_DEPTH = 20;
29 |
30 | /**
31 | * Recursively scrub sensitive data from any value.
32 | * Returns tuple of [scrubbedValue, didScrub, descriptionsOfMatchedPatterns]
33 | */
34 | function scrubValue(value: unknown, depth = 0): [unknown, boolean, string[]] {
35 | // Prevent stack overflow by limiting recursion depth
36 | if (depth >= MAX_SCRUB_DEPTH) {
37 | return ["[MAX_DEPTH_EXCEEDED]", false, []];
38 | }
39 |
40 | if (typeof value === "string") {
41 | let scrubbed = value;
42 | let didScrub = false;
43 | const matchedDescriptions: string[] = [];
44 |
45 | for (const { pattern, replacement, description } of SCRUB_PATTERNS) {
46 | // Reset lastIndex to avoid stateful regex issues
47 | pattern.lastIndex = 0;
48 | if (pattern.test(scrubbed)) {
49 | didScrub = true;
50 | matchedDescriptions.push(description);
51 | // Reset again before replace
52 | pattern.lastIndex = 0;
53 | scrubbed = scrubbed.replace(pattern, replacement);
54 | }
55 | }
56 | return [scrubbed, didScrub, matchedDescriptions];
57 | }
58 |
59 | if (Array.isArray(value)) {
60 | let arrayDidScrub = false;
61 | const arrayDescriptions: string[] = [];
62 | const scrubbedArray = value.map((item) => {
63 | const [scrubbed, didScrub, descriptions] = scrubValue(item, depth + 1);
64 | if (didScrub) {
65 | arrayDidScrub = true;
66 | arrayDescriptions.push(...descriptions);
67 | }
68 | return scrubbed;
69 | });
70 | return [scrubbedArray, arrayDidScrub, arrayDescriptions];
71 | }
72 |
73 | if (value && typeof value === "object") {
74 | let objectDidScrub = false;
75 | const objectDescriptions: string[] = [];
76 | const scrubbed: Record<string, unknown> = {};
77 | for (const [key, val] of Object.entries(value)) {
78 | const [scrubbedVal, didScrub, descriptions] = scrubValue(val, depth + 1);
79 | if (didScrub) {
80 | objectDidScrub = true;
81 | objectDescriptions.push(...descriptions);
82 | }
83 | scrubbed[key] = scrubbedVal;
84 | }
85 | return [scrubbed, objectDidScrub, objectDescriptions];
86 | }
87 |
88 | return [value, false, []];
89 | }
90 |
91 | /**
92 | * Sentry beforeSend hook that scrubs sensitive data from events
93 | */
94 | export function sentryBeforeSend(event: any, hint: any): any {
95 | // Always scrub the entire event
96 | const [scrubbedEvent, didScrub, descriptions] = scrubValue(event);
97 |
98 | // Log to console if we found and scrubbed sensitive data
99 | // (avoiding LogTape dependency for edge/browser compatibility)
100 | if (didScrub) {
101 | const uniqueDescriptions = [...new Set(descriptions)];
102 | console.warn(
103 | `[Sentry] Event contained sensitive data: ${uniqueDescriptions.join(", ")}`,
104 | );
105 | }
106 |
107 | return scrubbedEvent as any;
108 | }
109 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/scripts/generate-definitions.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env tsx
2 | /**
3 | * Generate tool definitions JSON for external consumption.
4 | *
5 | * Outputs to src/ so they can be bundled and imported by clients and the Cloudflare app.
6 | */
7 | import * as fs from "node:fs";
8 | import * as path from "node:path";
9 | import { fileURLToPath } from "node:url";
10 | import { z, type ZodTypeAny } from "zod";
11 | import { zodToJsonSchema } from "zod-to-json-schema";
12 |
13 | const __filename = fileURLToPath(import.meta.url);
14 | const __dirname = path.dirname(__filename);
15 |
16 | // Lazy imports of server modules to avoid type bleed
17 | const toolsModule = await import("../src/tools/index.ts");
18 |
19 | function writeJson(file: string, data: unknown) {
20 | fs.writeFileSync(file, JSON.stringify(data, null, 2));
21 | }
22 |
23 | function ensureDirExists(dir: string) {
24 | if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
25 | }
26 |
27 | // Shared helpers for Zod parameter maps
28 | function zodFieldMapToDescriptions(
29 | fieldMap: Record<string, ZodTypeAny>,
30 | ): Record<string, { description: string }> {
31 | const out: Record<string, { description: string }> = {};
32 | for (const [key, schema] of Object.entries(fieldMap)) {
33 | const js = zodToJsonSchema(schema, { $refStrategy: "none" }) as {
34 | description?: string;
35 | };
36 | out[key] = { description: js.description || "" };
37 | }
38 | return out;
39 | }
40 |
41 | function zodFieldMapToJsonSchema(
42 | fieldMap: Record<string, ZodTypeAny>,
43 | ): unknown {
44 | if (!fieldMap || Object.keys(fieldMap).length === 0) return {};
45 | const obj = z.object(fieldMap);
46 | return zodToJsonSchema(obj, { $refStrategy: "none" });
47 | }
48 |
49 | function byName<T extends { name: string }>(a: T, b: T) {
50 | return a.name.localeCompare(b.name);
51 | }
52 |
53 | // Tools
54 | function generateToolDefinitions() {
55 | const toolsDefault = toolsModule.default as
56 | | Record<string, unknown>
57 | | undefined;
58 | if (!toolsDefault || typeof toolsDefault !== "object") {
59 | throw new Error("Failed to import tools from src/tools/index.ts");
60 | }
61 |
62 | const defs = Object.entries(toolsDefault).map(([key, tool]) => {
63 | if (!tool || typeof tool !== "object")
64 | throw new Error(`Invalid tool: ${key}`);
65 | const t = tool as {
66 | name: string;
67 | description: string;
68 | inputSchema: Record<string, ZodTypeAny>;
69 | requiredScopes: string[]; // must exist on all tools (can be empty)
70 | };
71 | if (!Array.isArray(t.requiredScopes)) {
72 | throw new Error(`Tool '${t.name}' is missing requiredScopes array`);
73 | }
74 | const jsonSchema = zodFieldMapToJsonSchema(t.inputSchema || {});
75 | return {
76 | name: t.name,
77 | description: t.description,
78 | // Export full JSON Schema under inputSchema for external docs
79 | inputSchema: jsonSchema,
80 | // Preserve tool access requirements for UIs/docs
81 | requiredScopes: t.requiredScopes,
82 | };
83 | });
84 | return defs.sort(byName);
85 | }
86 |
87 | async function main() {
88 | try {
89 | console.log("Generating tool definitions...");
90 | const outDir = path.join(__dirname, "../src");
91 | ensureDirExists(outDir);
92 |
93 | const tools = generateToolDefinitions();
94 |
95 | writeJson(path.join(outDir, "toolDefinitions.json"), tools);
96 |
97 | console.log(`✅ Generated: tools(${tools.length})`);
98 | } catch (error) {
99 | const err = error as Error;
100 | console.error("[ERROR]", err.message, err.stack);
101 | process.exit(1);
102 | }
103 | }
104 |
105 | if (import.meta.url === `file://${process.argv[1]}`) {
106 | void main();
107 | }
108 |
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/lib/constraint-utils.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import "urlpattern-polyfill";
3 | import { verifyConstraintsAccess } from "./constraint-utils";
4 |
5 | describe("verifyConstraintsAccess", () => {
6 | const token = "test-token";
7 | const host = "sentry.io";
8 |
9 | it("returns ok with empty constraints when no org constraint provided", async () => {
10 | const result = await verifyConstraintsAccess(
11 | { organizationSlug: null, projectSlug: null },
12 | { accessToken: token, sentryHost: host },
13 | );
14 | expect(result).toEqual({
15 | ok: true,
16 | constraints: {
17 | organizationSlug: null,
18 | projectSlug: null,
19 | regionUrl: null,
20 | },
21 | });
22 | });
23 |
24 | it("fails when access token is missing, null, undefined, or empty", async () => {
25 | const testCases = [
26 | { accessToken: "", label: "empty" },
27 | { accessToken: null, label: "null" },
28 | { accessToken: undefined, label: "undefined" },
29 | ];
30 |
31 | for (const { accessToken, label } of testCases) {
32 | const result = await verifyConstraintsAccess(
33 | { organizationSlug: "org", projectSlug: null },
34 | { accessToken, sentryHost: host },
35 | );
36 | expect(result.ok).toBe(false);
37 | if (!result.ok) {
38 | expect(result.status).toBe(401);
39 | expect(result.message).toBe(
40 | "Missing access token for constraint verification",
41 | );
42 | }
43 | }
44 | });
45 |
46 | it("successfully verifies org access and returns constraints with regionUrl", async () => {
47 | const result = await verifyConstraintsAccess(
48 | { organizationSlug: "sentry-mcp-evals", projectSlug: null },
49 | { accessToken: token, sentryHost: host },
50 | );
51 | expect(result.ok).toBe(true);
52 | if (result.ok) {
53 | expect(result.constraints).toEqual({
54 | organizationSlug: "sentry-mcp-evals",
55 | projectSlug: null,
56 | regionUrl: "https://us.sentry.io",
57 | });
58 | }
59 | });
60 |
61 | it("successfully verifies org and project access", async () => {
62 | const result = await verifyConstraintsAccess(
63 | { organizationSlug: "sentry-mcp-evals", projectSlug: "cloudflare-mcp" },
64 | { accessToken: token, sentryHost: host },
65 | );
66 | expect(result.ok).toBe(true);
67 | if (result.ok) {
68 | expect(result.constraints).toEqual({
69 | organizationSlug: "sentry-mcp-evals",
70 | projectSlug: "cloudflare-mcp",
71 | regionUrl: "https://us.sentry.io",
72 | });
73 | }
74 | });
75 |
76 | it("fails when org does not exist", async () => {
77 | const result = await verifyConstraintsAccess(
78 | { organizationSlug: "nonexistent-org", projectSlug: null },
79 | { accessToken: token, sentryHost: host },
80 | );
81 | expect(result.ok).toBe(false);
82 | if (!result.ok) {
83 | expect(result.status).toBe(404);
84 | expect(result.message).toBe("Organization 'nonexistent-org' not found");
85 | }
86 | });
87 |
88 | it("fails when project does not exist", async () => {
89 | const result = await verifyConstraintsAccess(
90 | {
91 | organizationSlug: "sentry-mcp-evals",
92 | projectSlug: "nonexistent-project",
93 | },
94 | { accessToken: token, sentryHost: host },
95 | );
96 | expect(result.ok).toBe(false);
97 | if (!result.ok) {
98 | expect(result.status).toBe(404);
99 | expect(result.message).toBe(
100 | "Project 'nonexistent-project' not found in organization 'sentry-mcp-evals'",
101 | );
102 | }
103 | });
104 | });
105 |
```
--------------------------------------------------------------------------------
/packages/mcp-server-evals/src/evals/search-issues-agent.eval.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describeEval } from "vitest-evals";
2 | import { ToolCallScorer } from "vitest-evals";
3 | import { searchIssuesAgent } from "@sentry/mcp-server/tools/search-issues/agent";
4 | import { SentryApiService } from "@sentry/mcp-server/api-client";
5 | import { StructuredOutputScorer } from "./utils/structuredOutputScorer";
6 | import "../setup-env";
7 |
8 | // The shared MSW server is already started in setup-env.ts
9 |
10 | describeEval("search-issues-agent", {
11 | data: async () => {
12 | return [
13 | {
14 | // Simple query with common fields - should NOT require tool calls
15 | input: "Show me unresolved issues",
16 | expectedTools: [],
17 | expected: {
18 | query: "is:unresolved",
19 | sort: "date", // Agent uses "date" as default
20 | },
21 | },
22 | {
23 | // Query with "me" reference - should only require whoami
24 | input: "Show me issues assigned to me",
25 | expectedTools: [
26 | {
27 | name: "whoami",
28 | arguments: {},
29 | },
30 | ],
31 | expected: {
32 | query:
33 | /assignedOrSuggested:test@example\.com|assigned:test@example\.com|assigned:me/, // Various valid forms
34 | sort: "date",
35 | },
36 | },
37 | {
38 | // Complex query but with common fields - should NOT require tool calls
39 | // NOTE: AI often incorrectly uses firstSeen instead of lastSeen - known limitation
40 | input: "Show me critical unhandled errors from the last 24 hours",
41 | expectedTools: [],
42 | expected: {
43 | query: /level:error.*is:unresolved.*lastSeen:-24h/,
44 | sort: "date",
45 | },
46 | },
47 | {
48 | // Query with custom/uncommon field that would require discovery
49 | input: "Show me issues with custom.payment.failed tag",
50 | expectedTools: [
51 | {
52 | name: "issueFields",
53 | arguments: {}, // No arguments needed anymore
54 | },
55 | ],
56 | expected: {
57 | query: /custom\.payment\.failed|tags\[custom\.payment\.failed\]/, // Both syntaxes are valid for tags
58 | sort: "date", // Agent should always return a sort value
59 | },
60 | },
61 | {
62 | // Another query requiring field discovery
63 | input: "Find issues where the kafka.consumer.group is orders-processor",
64 | expectedTools: [
65 | {
66 | name: "issueFields",
67 | arguments: {}, // No arguments needed anymore
68 | },
69 | ],
70 | expected: {
71 | query: "kafka.consumer.group:orders-processor",
72 | sort: "date", // Agent should always return a sort value
73 | },
74 | },
75 | ];
76 | },
77 | task: async (input) => {
78 | // Create a real API service that will use MSW mocks
79 | const apiService = new SentryApiService({
80 | accessToken: "test-token",
81 | });
82 |
83 | const agentResult = await searchIssuesAgent({
84 | query: input,
85 | organizationSlug: "sentry-mcp-evals",
86 | apiService,
87 | });
88 |
89 | // Return in the format expected by ToolCallScorer
90 | return {
91 | result: JSON.stringify(agentResult.result),
92 | toolCalls: agentResult.toolCalls.map((call: any) => ({
93 | name: call.toolName,
94 | arguments: call.args,
95 | })),
96 | };
97 | },
98 | scorers: [
99 | ToolCallScorer(), // Validates tool calls
100 | StructuredOutputScorer({ match: "fuzzy" }), // Validates the structured query output with flexible matching
101 | ],
102 | });
103 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/search-issues/config.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Configuration for the search-issues agent
3 | */
4 |
5 | export const systemPrompt = `You are a Sentry issue search query translator. Convert natural language queries to Sentry issue search syntax.
6 |
7 | IMPORTANT RULES:
8 | 1. Use Sentry issue search syntax, NOT SQL
9 | 2. Time ranges use relative notation: -24h, -7d, -30d
10 | 3. Comparisons: >, <, >=, <=
11 | 4. Boolean operators: AND, OR, NOT (or !)
12 | 5. Field values with spaces need quotes: environment:"dev server"
13 |
14 | BUILT-IN FIELDS:
15 | - is: Issue status (unresolved, resolved, ignored, archived)
16 | - level: Severity level (error, warning, info, debug, fatal)
17 | IMPORTANT: Almost NEVER use this field. Terms like "critical", "important", "severe" refer to IMPACT not level.
18 | Only use if user explicitly says "error level", "warning level", etc.
19 | - environment: Deployment environment (production, staging, development)
20 | - release: Version/release identifier
21 | - firstSeen: When the issue was FIRST encountered (use for "new issues", "started", "began")
22 | WARNING: Excludes ongoing issues that started before the time window
23 | - lastSeen: When the issue was LAST encountered (use for "from the last", "recent", "active")
24 | This includes ALL issues seen during the time window, regardless of when they started
25 | - assigned: Issues explicitly assigned to a user (email or "me")
26 | - assignedOrSuggested: Issues assigned to OR suggested for a user (broader match)
27 | - userCount: Number of unique users affected
28 | - eventCount: Total number of events
29 |
30 | COMMON QUERY PATTERNS:
31 | - Unresolved issues: is:unresolved (NO level filter unless explicitly requested)
32 | - Critical/important issues: is:unresolved with sort:freq or sort:user (NOT level:error)
33 | - Recent activity: lastSeen:-24h
34 | - New issues: firstSeen:-7d
35 | - High impact: userCount:>100
36 | - My work: assignedOrSuggested:me
37 |
38 | SORTING RULES:
39 | 1. CRITICAL: Sort MUST go in the separate "sort" field, NEVER in the "query" field
40 | - WRONG: query: "is:unresolved sort:user" ← Sort syntax in query field is FORBIDDEN
41 | - CORRECT: query: "is:unresolved", sort: "user" ← Sort in separate field
42 |
43 | 2. AVAILABLE SORT OPTIONS:
44 | - date: Last seen (default)
45 | - freq: Event frequency
46 | - new: First seen
47 | - user: User count
48 |
49 | 3. IMPORTANT: Query field is for filtering only (is:, level:, environment:, etc.)
50 |
51 | 'ME' REFERENCES:
52 | - When the user says "assigned to me" or similar, you MUST use the whoami tool to get the current user's email
53 | - Replace "me" with the actual email address in the query
54 | - Example: "assigned to me" → use whoami tool → assignedOrSuggested:[email protected]
55 |
56 | EXAMPLES:
57 | "critical bugs" → query: "level:error is:unresolved", sort: "date"
58 | "worst issues affecting the most users" → query: "is:unresolved", sort: "user"
59 | "assigned to [email protected]" → query: "assignedOrSuggested:[email protected]", sort: "date"
60 |
61 | NEVER: query: "is:unresolved sort:user" ← Sort goes in separate field!
62 |
63 | CRITICAL - TOOL RESPONSE HANDLING:
64 | All tools return responses in this format: {error?: string, result?: data}
65 | - If 'error' is present: The tool failed - analyze the error message and potentially retry with corrections
66 | - If 'result' is present: The tool succeeded - use the result data for your query construction
67 | - Always check for errors before using results
68 |
69 | Always use the issueFields tool to discover available fields when needed.
70 | Use the whoami tool when you need to resolve 'me' references.`;
71 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/create-project.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import { setTag } from "@sentry/core";
3 | import { defineTool } from "../internal/tool-helpers/define";
4 | import { apiServiceFromContext } from "../internal/tool-helpers/api";
5 | import { logIssue } from "../telem/logging";
6 | import type { ServerContext } from "../types";
7 | import type { ClientKey } from "../api-client/index";
8 | import {
9 | ParamOrganizationSlug,
10 | ParamRegionUrl,
11 | ParamTeamSlug,
12 | ParamPlatform,
13 | } from "../schema";
14 |
15 | export default defineTool({
16 | name: "create_project",
17 | requiredScopes: ["project:write", "team:read"],
18 | description: [
19 | "Create a new project in Sentry (includes DSN automatically).",
20 | "",
21 | "🔍 USE THIS TOOL WHEN USERS WANT TO:",
22 | "- 'Create a new project'",
23 | "- 'Set up a project for [app/service] with team [X]'",
24 | "- 'I need a new Sentry project'",
25 | "- Create project AND need DSN in one step",
26 | "",
27 | "❌ DO NOT USE create_dsn after this - DSN is included in output.",
28 | "",
29 | "Be careful when using this tool!",
30 | "",
31 | "<examples>",
32 | "### Create new project with team",
33 | "```",
34 | "create_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')",
35 | "```",
36 | "</examples>",
37 | "",
38 | "<hints>",
39 | "- If the user passes a parameter in the form of name/otherName, its likely in the format of <organizationSlug>/<teamSlug>.",
40 | "- If any parameter is ambiguous, you should clarify with the user what they meant.",
41 | "</hints>",
42 | ].join("\n"),
43 | inputSchema: {
44 | organizationSlug: ParamOrganizationSlug,
45 | regionUrl: ParamRegionUrl.optional(),
46 | teamSlug: ParamTeamSlug,
47 | name: z
48 | .string()
49 | .trim()
50 | .describe(
51 | "The name of the project to create. Typically this is commonly the name of the repository or service. It is only used as a visual label in Sentry.",
52 | ),
53 | platform: ParamPlatform.optional(),
54 | },
55 | annotations: {
56 | readOnlyHint: false,
57 | destructiveHint: false,
58 | openWorldHint: true,
59 | },
60 | async handler(params, context: ServerContext) {
61 | const apiService = apiServiceFromContext(context, {
62 | regionUrl: params.regionUrl,
63 | });
64 | const organizationSlug = params.organizationSlug;
65 |
66 | setTag("organization.slug", organizationSlug);
67 | setTag("team.slug", params.teamSlug);
68 |
69 | const project = await apiService.createProject({
70 | organizationSlug,
71 | teamSlug: params.teamSlug,
72 | name: params.name,
73 | platform: params.platform,
74 | });
75 | let clientKey: ClientKey | null = null;
76 | try {
77 | clientKey = await apiService.createClientKey({
78 | organizationSlug,
79 | projectSlug: project.slug,
80 | name: "Default",
81 | });
82 | } catch (err) {
83 | logIssue(err);
84 | }
85 | let output = `# New Project in **${organizationSlug}**\n\n`;
86 | output += `**ID**: ${project.id}\n`;
87 | output += `**Slug**: ${project.slug}\n`;
88 | output += `**Name**: ${project.name}\n`;
89 | if (clientKey) {
90 | output += `**SENTRY_DSN**: ${clientKey?.dsn.public}\n\n`;
91 | } else {
92 | output += "**SENTRY_DSN**: There was an error fetching this value.\n\n";
93 | }
94 | output += "# Using this information\n\n";
95 | output += `- You can reference the **SENTRY_DSN** value to initialize Sentry's SDKs.\n`;
96 | output += `- You should always inform the user of the **SENTRY_DSN** and Project Slug values.\n`;
97 | return output;
98 | },
99 | });
100 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/constants.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Constants for Sentry MCP server.
3 | *
4 | * Defines platform and framework combinations available in Sentry documentation.
5 | */
6 |
7 | /**
8 | * MCP Server identification
9 | */
10 | export const MCP_SERVER_NAME = "Sentry MCP" as const;
11 |
12 | /**
13 | * Allowed region domains for sentry.io
14 | * Only these specific domains are permitted when using Sentry's cloud service
15 | * This is used to prevent SSRF attacks by restricting regionUrl to known domains
16 | */
17 | export const SENTRY_ALLOWED_REGION_DOMAINS = new Set([
18 | "sentry.io",
19 | "us.sentry.io",
20 | "de.sentry.io",
21 | ]);
22 |
23 | /**
24 | * Common Sentry platforms that have documentation available
25 | */
26 | export const SENTRY_PLATFORMS_BASE = [
27 | "javascript",
28 | "python",
29 | "java",
30 | "dotnet",
31 | "go",
32 | "php",
33 | "ruby",
34 | "android",
35 | "apple",
36 | "unity",
37 | "unreal",
38 | "rust",
39 | "elixir",
40 | "kotlin",
41 | "native",
42 | "dart",
43 | "godot",
44 | "nintendo-switch",
45 | "playstation",
46 | "powershell",
47 | "react-native",
48 | "xbox",
49 | ] as const;
50 |
51 | /**
52 | * Platform-specific frameworks that have Sentry guides
53 | */
54 | export const SENTRY_FRAMEWORKS: Record<string, string[]> = {
55 | javascript: [
56 | "nextjs",
57 | "react",
58 | "gatsby",
59 | "remix",
60 | "vue",
61 | "angular",
62 | "hono",
63 | "svelte",
64 | "express",
65 | "fastify",
66 | "astro",
67 | "bun",
68 | "capacitor",
69 | "cloudflare",
70 | "connect",
71 | "cordova",
72 | "deno",
73 | "electron",
74 | "ember",
75 | "nuxt",
76 | "solid",
77 | "solidstart",
78 | "sveltekit",
79 | "tanstack-react",
80 | "wasm",
81 | "node",
82 | "koa",
83 | "nestjs",
84 | "hapi",
85 | ],
86 | python: [
87 | "django",
88 | "flask",
89 | "fastapi",
90 | "celery",
91 | "tornado",
92 | "pyramid",
93 | "aiohttp",
94 | "anthropic",
95 | "airflow",
96 | "aws-lambda",
97 | "boto3",
98 | "bottle",
99 | "chalice",
100 | "dramatiq",
101 | "falcon",
102 | "langchain",
103 | "litestar",
104 | "logging",
105 | "loguru",
106 | "openai",
107 | "quart",
108 | "ray",
109 | "redis",
110 | "rq",
111 | "sanic",
112 | "sqlalchemy",
113 | "starlette",
114 | ],
115 | dart: ["flutter"],
116 | dotnet: [
117 | "aspnetcore",
118 | "maui",
119 | "wpf",
120 | "winforms",
121 | "aspnet",
122 | "aws-lambda",
123 | "azure-functions",
124 | "blazor-webassembly",
125 | "entityframework",
126 | "google-cloud-functions",
127 | "extensions-logging",
128 | "log4net",
129 | "nlog",
130 | "serilog",
131 | "uwp",
132 | "xamarin",
133 | ],
134 | java: [
135 | "spring",
136 | "spring-boot",
137 | "android",
138 | "jul",
139 | "log4j2",
140 | "logback",
141 | "servlet",
142 | ],
143 | go: [
144 | "echo",
145 | "fasthttp",
146 | "fiber",
147 | "gin",
148 | "http",
149 | "iris",
150 | "logrus",
151 | "negroni",
152 | "slog",
153 | "zerolog",
154 | ],
155 | php: ["laravel", "symfony"],
156 | ruby: ["delayed_job", "rack", "rails", "resque", "sidekiq"],
157 | android: ["kotlin"],
158 | apple: ["ios", "macos", "watchos", "tvos", "visionos"],
159 | kotlin: ["multiplatform"],
160 | } as const;
161 |
162 | /**
163 | * All valid guides for Sentry docs search filtering.
164 | * A guide can be either a platform (e.g., 'javascript') or a platform/framework combination (e.g., 'javascript/nextjs').
165 | */
166 | export const SENTRY_GUIDES = [
167 | // Base platforms
168 | ...SENTRY_PLATFORMS_BASE,
169 | // Platform/guide combinations
170 | ...Object.entries(SENTRY_FRAMEWORKS).flatMap(([platform, guides]) =>
171 | guides.map((guide) => `${platform}/${guide}`),
172 | ),
173 | ] as const;
174 |
175 | export const DEFAULT_SCOPES = [
176 | "org:read",
177 | "project:read",
178 | "team:read",
179 | "event:read",
180 | ] as const;
181 |
182 | // Note: All scopes are now exported from permissions.ts to avoid pulling this
183 | // heavy constants module into scope-only consumers.
184 |
```
--------------------------------------------------------------------------------
/packages/mcp-test-client/src/mcp-test-client-remote.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { experimental_createMCPClient } from "ai";
2 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3 | import { startNewTrace, startSpan } from "@sentry/core";
4 | import { OAuthClient } from "./auth/oauth.js";
5 | import { DEFAULT_MCP_URL } from "./constants.js";
6 | import { logError, logSuccess } from "./logger.js";
7 | import type { MCPConnection, RemoteMCPConfig } from "./types.js";
8 | import { randomUUID } from "node:crypto";
9 | import { LIB_VERSION } from "./version.js";
10 |
11 | export async function connectToRemoteMCPServer(
12 | config: RemoteMCPConfig,
13 | ): Promise<MCPConnection> {
14 | const sessionId = randomUUID();
15 |
16 | return await startNewTrace(async () => {
17 | return await startSpan(
18 | {
19 | name: "mcp.connect/http",
20 | attributes: {
21 | "mcp.transport": "http",
22 | "gen_ai.conversation.id": sessionId,
23 | "service.version": LIB_VERSION,
24 | },
25 | },
26 | async (span) => {
27 | try {
28 | const mcpHost = config.mcpHost || DEFAULT_MCP_URL;
29 |
30 | // Remove custom attributes - let SDK handle standard attributes
31 | let accessToken = config.accessToken;
32 |
33 | // If no access token provided, we need to authenticate
34 | if (!accessToken) {
35 | await startSpan(
36 | {
37 | name: "mcp.auth/oauth",
38 | },
39 | async (authSpan) => {
40 | try {
41 | const oauthClient = new OAuthClient({
42 | mcpHost: mcpHost,
43 | });
44 | accessToken = await oauthClient.getAccessToken();
45 | authSpan.setStatus({ code: 1 });
46 | } catch (error) {
47 | authSpan.setStatus({ code: 2 });
48 | logError(
49 | "OAuth authentication failed",
50 | error instanceof Error ? error : String(error),
51 | );
52 | throw error;
53 | }
54 | },
55 | );
56 | }
57 |
58 | // Create HTTP streaming client with authentication
59 | // Use ?agent=1 query param for agent mode, otherwise standard /mcp
60 | const mcpUrl = new URL(`${mcpHost}/mcp`);
61 | if (config.useAgentEndpoint) {
62 | mcpUrl.searchParams.set("agent", "1");
63 | }
64 | const httpTransport = new StreamableHTTPClientTransport(mcpUrl, {
65 | requestInit: {
66 | headers: {
67 | Authorization: `Bearer ${accessToken}`,
68 | },
69 | },
70 | });
71 |
72 | const client = await experimental_createMCPClient({
73 | name: "mcp.sentry.dev (test-client)",
74 | transport: httpTransport,
75 | });
76 |
77 | // Discover available tools
78 | const toolsMap = await client.tools();
79 | const tools = new Map<string, any>();
80 |
81 | for (const [name, tool] of Object.entries(toolsMap)) {
82 | tools.set(name, tool);
83 | }
84 |
85 | // Remove custom attributes - let SDK handle standard attributes
86 | span.setStatus({ code: 1 });
87 |
88 | logSuccess(
89 | `Connected to MCP server (${mcpHost})`,
90 | `${tools.size} tools available`,
91 | );
92 |
93 | const disconnect = async () => {
94 | await client.close();
95 | };
96 |
97 | return {
98 | client,
99 | tools,
100 | disconnect,
101 | sessionId,
102 | transport: "http" as const,
103 | };
104 | } catch (error) {
105 | span.setStatus({ code: 2 });
106 | throw error;
107 | }
108 | },
109 | );
110 | });
111 | }
112 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/get-event-attachment.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import getEventAttachment from "./get-event-attachment.js";
3 |
4 | describe("get_event_attachment", () => {
5 | it("lists attachments for an event", async () => {
6 | const result = await getEventAttachment.handler(
7 | {
8 | organizationSlug: "sentry-mcp-evals",
9 | projectSlug: "cloudflare-mcp",
10 | eventId: "7ca573c0f4814912aaa9bdc77d1a7d51",
11 | attachmentId: undefined,
12 | regionUrl: undefined,
13 | },
14 | {
15 | constraints: {
16 | organizationSlug: null,
17 | projectSlug: null,
18 | },
19 | accessToken: "access-token",
20 | userId: "1",
21 | },
22 | );
23 | expect(result).toMatchInlineSnapshot(`
24 | "# Event Attachments
25 |
26 | **Event ID:** 7ca573c0f4814912aaa9bdc77d1a7d51
27 | **Project:** cloudflare-mcp
28 |
29 | Found 1 attachment(s):
30 |
31 | ## Attachment 1
32 |
33 | **ID:** 123
34 | **Name:** screenshot.png
35 | **Type:** event.attachment
36 | **Size:** 1024 bytes
37 | **MIME Type:** image/png
38 | **Created:** 2025-04-08T21:15:04.000Z
39 | **SHA1:** abc123def456
40 |
41 | To download this attachment, use the "get_event_attachment" tool with the attachmentId provided:
42 | \`get_event_attachment(organizationSlug="sentry-mcp-evals", projectSlug="cloudflare-mcp", eventId="7ca573c0f4814912aaa9bdc77d1a7d51", attachmentId="123")\`
43 |
44 | "
45 | `);
46 | });
47 |
48 | it("downloads a specific attachment by ID", async () => {
49 | const result = await getEventAttachment.handler(
50 | {
51 | organizationSlug: "sentry-mcp-evals",
52 | projectSlug: "cloudflare-mcp",
53 | eventId: "7ca573c0f4814912aaa9bdc77d1a7d51",
54 | attachmentId: "123",
55 | regionUrl: undefined,
56 | },
57 | {
58 | constraints: {
59 | organizationSlug: null,
60 | projectSlug: null,
61 | },
62 | accessToken: "access-token",
63 | userId: "1",
64 | },
65 | );
66 |
67 | // Should return an array with both text description and image content
68 | expect(Array.isArray(result)).toBe(true);
69 | expect(result).toHaveLength(2);
70 |
71 | // First item should be the image content
72 | expect(result[0]).toMatchObject({
73 | type: "image",
74 | mimeType: "image/png",
75 | data: expect.any(String), // base64 encoded data
76 | });
77 |
78 | // Second item should be the text description
79 | expect(result[1]).toMatchInlineSnapshot(`
80 | {
81 | "text": "# Event Attachment Download
82 |
83 | **Event ID:** 7ca573c0f4814912aaa9bdc77d1a7d51
84 | **Attachment ID:** 123
85 | **Filename:** screenshot.png
86 | **Type:** event.attachment
87 | **Size:** 1024 bytes
88 | **MIME Type:** image/png
89 | **Created:** 2025-04-08T21:15:04.000Z
90 | **SHA1:** abc123def456
91 |
92 | **Download URL:** https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/events/7ca573c0f4814912aaa9bdc77d1a7d51/attachments/123/?download=1
93 |
94 | ## Binary Content
95 |
96 | The attachment is included as a resource and accessible through your client.
97 | ",
98 | "type": "text",
99 | }
100 | `);
101 | });
102 |
103 | it("throws error for malformed regionUrl", async () => {
104 | await expect(
105 | getEventAttachment.handler(
106 | {
107 | organizationSlug: "sentry-mcp-evals",
108 | projectSlug: "cloudflare-mcp",
109 | eventId: "7ca573c0f4814912aaa9bdc77d1a7d51",
110 | attachmentId: undefined,
111 | regionUrl: "https",
112 | },
113 | {
114 | constraints: {
115 | organizationSlug: null,
116 | projectSlug: null,
117 | },
118 | accessToken: "access-token",
119 | userId: "1",
120 | },
121 | ),
122 | ).rejects.toThrow(
123 | "Invalid regionUrl provided: https. Must be a valid URL.",
124 | );
125 | });
126 | });
127 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/code.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "namespace": "code",
3 | "description": "These attributes provide context about source code\n",
4 | "attributes": {
5 | "code.function.name": {
6 | "description": "The method or function fully-qualified name without arguments. The value should fit the natural representation of the language runtime, which is also likely the same used within `code.stacktrace` attribute value. This attribute MUST NOT be used on the Profile signal since the data is already captured in 'message Function'. This constraint is imposed to prevent redundancy and maintain data integrity.\n",
7 | "type": "string",
8 | "note": "Values and format depends on each language runtime, thus it is impossible to provide an exhaustive list of examples.\nThe values are usually the same (or prefixes of) the ones found in native stack trace representation stored in\n`code.stacktrace` without information on arguments.\n\nExamples:\n\n* Java method: `com.example.MyHttpService.serveRequest`\n* Java anonymous class method: `com.mycompany.Main$1.myMethod`\n* Java lambda method: `com.mycompany.Main$$Lambda/0x0000748ae4149c00.myMethod`\n* PHP function: `GuzzleHttp\\Client::transfer`\n* Go function: `github.com/my/repo/pkg.foo.func5`\n* Elixir: `OpenTelemetry.Ctx.new`\n* Erlang: `opentelemetry_ctx:new`\n* Rust: `playground::my_module::my_cool_func`\n* C function: `fopen`\n",
9 | "stability": "stable",
10 | "examples": [
11 | "com.example.MyHttpService.serveRequest",
12 | "GuzzleHttp\\Client::transfer",
13 | "fopen"
14 | ]
15 | },
16 | "code.file.path": {
17 | "description": "The source code file name that identifies the code unit as uniquely as possible (preferably an absolute file path). This attribute MUST NOT be used on the Profile signal since the data is already captured in 'message Function'. This constraint is imposed to prevent redundancy and maintain data integrity.\n",
18 | "type": "string",
19 | "stability": "stable",
20 | "examples": ["/usr/local/MyApplication/content_root/app/index.php"]
21 | },
22 | "code.line.number": {
23 | "description": "The line number in `code.file.path` best representing the operation. It SHOULD point within the code unit named in `code.function.name`. This attribute MUST NOT be used on the Profile signal since the data is already captured in 'message Line'. This constraint is imposed to prevent redundancy and maintain data integrity.\n",
24 | "type": "number",
25 | "stability": "stable",
26 | "examples": ["42"]
27 | },
28 | "code.column.number": {
29 | "description": "The column number in `code.file.path` best representing the operation. It SHOULD point within the code unit named in `code.function.name`. This attribute MUST NOT be used on the Profile signal since the data is already captured in 'message Line'. This constraint is imposed to prevent redundancy and maintain data integrity.\n",
30 | "type": "number",
31 | "stability": "stable",
32 | "examples": ["16"]
33 | },
34 | "code.stacktrace": {
35 | "description": "A stacktrace as a string in the natural representation for the language runtime. The representation is identical to [`exception.stacktrace`](/docs/exceptions/exceptions-spans.md#stacktrace-representation). This attribute MUST NOT be used on the Profile signal since the data is already captured in 'message Location'. This constraint is imposed to prevent redundancy and maintain data integrity.\n",
36 | "type": "string",
37 | "stability": "stable",
38 | "examples": [
39 | "at com.example.GenerateTrace.methodB(GenerateTrace.java:13)\\n at com.example.GenerateTrace.methodA(GenerateTrace.java:9)\\n at com.example.GenerateTrace.main(GenerateTrace.java:5)\n"
40 | ]
41 | }
42 | }
43 | }
44 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/use-sentry/handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3 | import { experimental_createMCPClient } from "ai";
4 | import { defineTool } from "../../internal/tool-helpers/define";
5 | import type { ServerContext } from "../../types";
6 | import { useSentryAgent } from "./agent";
7 | import { buildServer } from "../../server";
8 | import tools from "../index";
9 | import type { ToolCall } from "../../internal/agents/callEmbeddedAgent";
10 |
11 | /**
12 | * Format tool calls into a readable trace
13 | */
14 | function formatToolCallTrace(toolCalls: ToolCall[]): string {
15 | let trace = "";
16 |
17 | for (let i = 0; i < toolCalls.length; i++) {
18 | const call = toolCalls[i];
19 | trace += `### ${i + 1}. ${call.toolName}\n\n`;
20 |
21 | // Type assertion is safe: AI SDK guarantees args is always a JSON-serializable object
22 | const args = call.args as Record<string, unknown>;
23 |
24 | // Format arguments
25 | if (Object.keys(args).length === 0) {
26 | trace += "_No arguments_\n\n";
27 | } else {
28 | trace += "**Arguments:**\n```json\n";
29 | trace += JSON.stringify(args, null, 2);
30 | trace += "\n```\n\n";
31 | }
32 | }
33 |
34 | return trace;
35 | }
36 |
37 | export default defineTool({
38 | name: "use_sentry",
39 | requiredScopes: [], // No specific scopes - uses authentication token
40 | description: [
41 | "Use Sentry's MCP Agent to answer questions related to Sentry (sentry.io).",
42 | "",
43 | "You should pass the entirety of the user's prompt to the agent. Do not interpret the prompt in any way. Just pass it directly to the agent.",
44 | "",
45 | ].join("\n"),
46 | inputSchema: {
47 | request: z
48 | .string()
49 | .trim()
50 | .min(1)
51 | .describe(
52 | "The user's raw input. Do not interpret the prompt in any way. Do not add any additional information to the prompt.",
53 | ),
54 | trace: z
55 | .boolean()
56 | .optional()
57 | .describe(
58 | "Enable tracing to see all tool calls made by the agent. Useful for debugging.",
59 | ),
60 | },
61 | annotations: {
62 | readOnlyHint: true, // Will be adjusted based on actual implementation
63 | openWorldHint: true,
64 | },
65 | async handler(params, context: ServerContext) {
66 | // Create linked pair of in-memory transports for client-server communication
67 | const [clientTransport, serverTransport] =
68 | InMemoryTransport.createLinkedPair();
69 |
70 | // Filter out use_sentry from tools to prevent recursion and circular dependency
71 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
72 | const { use_sentry, ...toolsForAgent } = tools;
73 |
74 | // Build internal MCP server with the provided context
75 | // Context is captured in tool handler closures during buildServer()
76 | const server = buildServer({
77 | context,
78 | tools: toolsForAgent,
79 | });
80 |
81 | // Connect server to its transport
82 | await server.server.connect(serverTransport);
83 |
84 | // Create MCP client with the other end of the transport
85 | const mcpClient = await experimental_createMCPClient({
86 | name: "mcp.sentry.dev (use-sentry)",
87 | transport: clientTransport,
88 | });
89 |
90 | try {
91 | // Get tools from MCP server (returns Vercel AI SDK compatible tools)
92 | const mcpTools = await mcpClient.tools();
93 |
94 | // Call the embedded agent with MCP tools and the user's request
95 | const agentResult = await useSentryAgent({
96 | request: params.request,
97 | tools: mcpTools,
98 | });
99 |
100 | let output = agentResult.result.result;
101 |
102 | // If tracing is enabled, append the tool call trace
103 | if (params.trace && agentResult.toolCalls.length > 0) {
104 | output += "\n\n---\n\n## Tool Call Trace\n\n";
105 | output += formatToolCallTrace(agentResult.toolCalls);
106 | }
107 |
108 | return output;
109 | } finally {
110 | // Clean up connections
111 | await mcpClient.close();
112 | await server.server.close();
113 | }
114 | },
115 | });
116 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/search-events/agent.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import { ConfigurationError } from "../../errors";
3 | import { callEmbeddedAgent } from "../../internal/agents/callEmbeddedAgent";
4 | import type { SentryApiService } from "../../api-client";
5 | import { createOtelLookupTool } from "../../internal/agents/tools/otel-semantics";
6 | import { createWhoamiTool } from "../../internal/agents/tools/whoami";
7 | import { createDatasetAttributesTool } from "./utils";
8 | import { systemPrompt } from "./config";
9 |
10 | const outputSchema = z
11 | .object({
12 | dataset: z
13 | .enum(["spans", "errors", "logs"])
14 | .describe("Which dataset to use for the query"),
15 | query: z
16 | .string()
17 | .default("")
18 | .nullish()
19 | .describe("The Sentry query string for filtering results"),
20 | fields: z
21 | .array(z.string())
22 | .describe("Array of field names to return in results."),
23 | sort: z.string().describe("Sort parameter for results."),
24 | timeRange: z
25 | .union([
26 | z.object({
27 | statsPeriod: z
28 | .string()
29 | .describe("Relative time period like '1h', '24h', '7d'"),
30 | }),
31 | z.object({
32 | start: z.string().describe("ISO 8601 start time"),
33 | end: z.string().describe("ISO 8601 end time"),
34 | }),
35 | ])
36 | .nullish()
37 | .describe(
38 | "Time range for filtering events. Use either statsPeriod for relative time or start/end for absolute time.",
39 | ),
40 | explanation: z
41 | .string()
42 | .describe("Brief explanation of how you translated this query."),
43 | })
44 | .refine(
45 | (data) => {
46 | // Only validate if both sort and fields are present
47 | if (!data.sort || !data.fields || data.fields.length === 0) {
48 | return true;
49 | }
50 |
51 | // Extract the field name from sort parameter (e.g., "-timestamp" -> "timestamp", "-count()" -> "count()")
52 | const sortField = data.sort.startsWith("-")
53 | ? data.sort.substring(1)
54 | : data.sort;
55 |
56 | // Check if sort field is in fields array
57 | return data.fields.includes(sortField);
58 | },
59 | {
60 | message:
61 | "Sort field must be included in the fields array. Sentry requires that any field used for sorting must also be explicitly selected. Add the sort field to the fields array or choose a different sort field that's already included.",
62 | },
63 | );
64 |
65 | export interface SearchEventsAgentOptions {
66 | query: string;
67 | organizationSlug: string;
68 | apiService: SentryApiService;
69 | projectId?: string;
70 | }
71 |
72 | /**
73 | * Search events agent - single entry point for translating natural language queries to Sentry search syntax
74 | * This returns both the translated query result AND the tool calls made by the agent
75 | */
76 | export async function searchEventsAgent(
77 | options: SearchEventsAgentOptions,
78 | ): Promise<{
79 | result: z.infer<typeof outputSchema>;
80 | toolCalls: any[];
81 | }> {
82 | if (!process.env.OPENAI_API_KEY) {
83 | throw new ConfigurationError(
84 | "OPENAI_API_KEY environment variable is required for semantic search",
85 | );
86 | }
87 |
88 | // Create tools pre-bound with the provided API service and organization
89 | const datasetAttributesTool = createDatasetAttributesTool({
90 | apiService: options.apiService,
91 | organizationSlug: options.organizationSlug,
92 | projectId: options.projectId,
93 | });
94 | const otelLookupTool = createOtelLookupTool({
95 | apiService: options.apiService,
96 | organizationSlug: options.organizationSlug,
97 | projectId: options.projectId,
98 | });
99 | const whoamiTool = createWhoamiTool({ apiService: options.apiService });
100 |
101 | // Use callEmbeddedAgent to translate the query with tool call capture
102 | return await callEmbeddedAgent({
103 | system: systemPrompt,
104 | prompt: options.query,
105 | tools: {
106 | datasetAttributes: datasetAttributesTool,
107 | otelSemantics: otelLookupTool,
108 | whoami: whoamiTool,
109 | },
110 | schema: outputSchema,
111 | });
112 | }
113 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/api-client/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * TypeScript type definitions derived from Zod schemas.
3 | *
4 | * This module provides strongly-typed interfaces for all Sentry API data
5 | * structures. Types are automatically derived from their corresponding
6 | * Zod schemas using `z.infer<>`, ensuring perfect synchronization between
7 | * runtime validation and compile-time type checking.
8 | *
9 | * Type Categories:
10 | * - **Core Resources**: User, Organization, Team, Project
11 | * - **Issue Management**: Issue, Event, AssignedTo
12 | * - **Release Management**: Release
13 | * - **Search & Discovery**: Tag
14 | * - **Integrations**: ClientKey, AutofixRun, AutofixRunState
15 | *
16 | * Array Types:
17 | * All list types follow the pattern `ResourceList = Resource[]` for consistency.
18 | *
19 | * @example Type Usage
20 | * ```typescript
21 | * import type { Issue, IssueList } from "./types";
22 | *
23 | * function processIssues(issues: IssueList): void {
24 | * issues.forEach((issue: Issue) => {
25 | * console.log(`${issue.shortId}: ${issue.title}`);
26 | * });
27 | * }
28 | * ```
29 | *
30 | * @example API Response Typing
31 | * ```typescript
32 | * async function getIssue(id: string): Promise<Issue> {
33 | * const response = await apiService.getIssue({
34 | * organizationSlug: "my-org",
35 | * issueId: id
36 | * });
37 | * return response; // Already typed as Issue from schema validation
38 | * }
39 | * ```
40 | */
41 | import type { z } from "zod";
42 | import type {
43 | AssignedToSchema,
44 | AutofixRunSchema,
45 | AutofixRunStateSchema,
46 | ClientKeyListSchema,
47 | ClientKeySchema,
48 | ErrorEventSchema,
49 | DefaultEventSchema,
50 | TransactionEventSchema,
51 | UnknownEventSchema,
52 | EventSchema,
53 | EventAttachmentSchema,
54 | EventAttachmentListSchema,
55 | IssueListSchema,
56 | IssueSchema,
57 | OrganizationListSchema,
58 | OrganizationSchema,
59 | ProjectListSchema,
60 | ProjectSchema,
61 | ReleaseListSchema,
62 | ReleaseSchema,
63 | TagListSchema,
64 | TagSchema,
65 | TeamListSchema,
66 | TeamSchema,
67 | TraceMetaSchema,
68 | TraceSchema,
69 | TraceSpanSchema,
70 | TraceIssueSchema,
71 | UserSchema,
72 | } from "./schema";
73 |
74 | export type User = z.infer<typeof UserSchema>;
75 | export type Organization = z.infer<typeof OrganizationSchema>;
76 | export type Team = z.infer<typeof TeamSchema>;
77 | export type Project = z.infer<typeof ProjectSchema>;
78 | export type ClientKey = z.infer<typeof ClientKeySchema>;
79 | export type Release = z.infer<typeof ReleaseSchema>;
80 | export type Issue = z.infer<typeof IssueSchema>;
81 |
82 | // Individual event types
83 | export type ErrorEvent = z.infer<typeof ErrorEventSchema>;
84 | export type DefaultEvent = z.infer<typeof DefaultEventSchema>;
85 | export type TransactionEvent = z.infer<typeof TransactionEventSchema>;
86 | export type UnknownEvent = z.infer<typeof UnknownEventSchema>;
87 |
88 | // Event union - use RawEvent for parsing, Event for known types only
89 | export type RawEvent = z.infer<typeof EventSchema>;
90 | export type Event = ErrorEvent | DefaultEvent | TransactionEvent;
91 |
92 | export type EventAttachment = z.infer<typeof EventAttachmentSchema>;
93 | export type Tag = z.infer<typeof TagSchema>;
94 | export type AutofixRun = z.infer<typeof AutofixRunSchema>;
95 | export type AutofixRunState = z.infer<typeof AutofixRunStateSchema>;
96 | export type AssignedTo = z.infer<typeof AssignedToSchema>;
97 |
98 | export type OrganizationList = z.infer<typeof OrganizationListSchema>;
99 | export type TeamList = z.infer<typeof TeamListSchema>;
100 | export type ProjectList = z.infer<typeof ProjectListSchema>;
101 | export type ReleaseList = z.infer<typeof ReleaseListSchema>;
102 | export type IssueList = z.infer<typeof IssueListSchema>;
103 | export type EventAttachmentList = z.infer<typeof EventAttachmentListSchema>;
104 | export type TagList = z.infer<typeof TagListSchema>;
105 | export type ClientKeyList = z.infer<typeof ClientKeyListSchema>;
106 |
107 | // Trace types
108 | export type TraceMeta = z.infer<typeof TraceMetaSchema>;
109 | export type TraceSpan = z.infer<typeof TraceSpanSchema>;
110 | export type TraceIssue = z.infer<typeof TraceIssueSchema>;
111 | export type Trace = z.infer<typeof TraceSchema>;
112 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/service.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "namespace": "service",
3 | "description": "A service instance.\n",
4 | "attributes": {
5 | "service.name": {
6 | "description": "Logical name of the service.\n",
7 | "type": "string",
8 | "note": "MUST be the same for all instances of horizontally scaled services. If the value was not specified, SDKs MUST fallback to `unknown_service:` concatenated with [`process.executable.name`](process.md), e.g. `unknown_service:bash`. If `process.executable.name` is not available, the value MUST be set to `unknown_service`.\n",
9 | "stability": "stable",
10 | "examples": ["shoppingcart"]
11 | },
12 | "service.version": {
13 | "description": "The version string of the service API or implementation. The format is not defined by these conventions.\n",
14 | "type": "string",
15 | "stability": "stable",
16 | "examples": ["2.0.0", "a01dbef8a"]
17 | },
18 | "service.namespace": {
19 | "description": "A namespace for `service.name`.\n",
20 | "type": "string",
21 | "note": "A string value having a meaning that helps to distinguish a group of services, for example the team name that owns a group of services. `service.name` is expected to be unique within the same namespace. If `service.namespace` is not specified in the Resource then `service.name` is expected to be unique for all services that have no explicit namespace defined (so the empty/unspecified namespace is simply one more valid namespace). Zero-length namespace string is assumed equal to unspecified namespace.\n",
22 | "stability": "development",
23 | "examples": ["Shop"]
24 | },
25 | "service.instance.id": {
26 | "description": "The string ID of the service instance.\n",
27 | "type": "string",
28 | "note": "MUST be unique for each instance of the same `service.namespace,service.name` pair (in other words\n`service.namespace,service.name,service.instance.id` triplet MUST be globally unique). The ID helps to\ndistinguish instances of the same service that exist at the same time (e.g. instances of a horizontally scaled\nservice).\n\nImplementations, such as SDKs, are recommended to generate a random Version 1 or Version 4 [RFC\n4122](https://www.ietf.org/rfc/rfc4122.txt) UUID, but are free to use an inherent unique ID as the source of\nthis value if stability is desirable. In that case, the ID SHOULD be used as source of a UUID Version 5 and\nSHOULD use the following UUID as the namespace: `4d63009a-8d0f-11ee-aad7-4c796ed8e320`.\n\nUUIDs are typically recommended, as only an opaque value for the purposes of identifying a service instance is\nneeded. Similar to what can be seen in the man page for the\n[`/etc/machine-id`](https://www.freedesktop.org/software/systemd/man/latest/machine-id.html) file, the underlying\ndata, such as pod name and namespace should be treated as confidential, being the user's choice to expose it\nor not via another resource attribute.\n\nFor applications running behind an application server (like unicorn), we do not recommend using one identifier\nfor all processes participating in the application. Instead, it's recommended each division (e.g. a worker\nthread in unicorn) to have its own instance.id.\n\nIt's not recommended for a Collector to set `service.instance.id` if it can't unambiguously determine the\nservice instance that is generating that telemetry. For instance, creating an UUID based on `pod.name` will\nlikely be wrong, as the Collector might not know from which container within that pod the telemetry originated.\nHowever, Collectors can set the `service.instance.id` if they can unambiguously determine the service instance\nfor that telemetry. This is typically the case for scraping receivers, as they know the target address and\nport.\n",
29 | "stability": "development",
30 | "examples": ["627cc493-f310-47de-96bd-71410b7dec09"]
31 | }
32 | }
33 | }
34 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/tool-helpers/seer.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { z } from "zod";
2 | import type {
3 | AutofixRunStepSchema,
4 | AutofixRunStepRootCauseAnalysisSchema,
5 | AutofixRunStepSolutionSchema,
6 | AutofixRunStepDefaultSchema,
7 | } from "../../api-client/index";
8 |
9 | export const SEER_POLLING_INTERVAL = 5000; // 5 seconds
10 | export const SEER_TIMEOUT = 5 * 60 * 1000; // 5 minutes
11 | export const SEER_MAX_RETRIES = 3; // Maximum retries for transient failures
12 | export const SEER_INITIAL_RETRY_DELAY = 1000; // 1 second initial retry delay
13 |
14 | export function getStatusDisplayName(status: string): string {
15 | switch (status) {
16 | case "COMPLETED":
17 | return "Complete";
18 | case "FAILED":
19 | case "ERROR":
20 | return "Failed";
21 | case "CANCELLED":
22 | return "Cancelled";
23 | case "NEED_MORE_INFORMATION":
24 | return "Needs More Information";
25 | case "WAITING_FOR_USER_RESPONSE":
26 | return "Waiting for Response";
27 | case "PROCESSING":
28 | return "Processing";
29 | case "IN_PROGRESS":
30 | return "In Progress";
31 | default:
32 | return status;
33 | }
34 | }
35 |
36 | /**
37 | * Check if an autofix status is terminal (no more updates expected)
38 | */
39 | export function isTerminalStatus(status: string): boolean {
40 | return [
41 | "COMPLETED",
42 | "FAILED",
43 | "ERROR",
44 | "CANCELLED",
45 | "NEED_MORE_INFORMATION",
46 | "WAITING_FOR_USER_RESPONSE",
47 | ].includes(status);
48 | }
49 |
50 | /**
51 | * Check if an autofix status requires human intervention
52 | */
53 | export function isHumanInterventionStatus(status: string): boolean {
54 | return (
55 | status === "NEED_MORE_INFORMATION" || status === "WAITING_FOR_USER_RESPONSE"
56 | );
57 | }
58 |
59 | /**
60 | * Get guidance message for human intervention states
61 | */
62 | export function getHumanInterventionGuidance(status: string): string {
63 | if (status === "NEED_MORE_INFORMATION") {
64 | return "\nSeer needs additional information to continue the analysis. Please review the insights above and consider providing more context.\n";
65 | }
66 | if (status === "WAITING_FOR_USER_RESPONSE") {
67 | return "\nSeer is waiting for your response to proceed. Please review the analysis and provide feedback.\n";
68 | }
69 | return "";
70 | }
71 |
72 | export function getOutputForAutofixStep(
73 | step: z.infer<typeof AutofixRunStepSchema>,
74 | ) {
75 | let output = `## ${step.title}\n\n`;
76 |
77 | if (step.status === "FAILED") {
78 | output += `**Sentry hit an error completing this step.\n\n`;
79 | return output;
80 | }
81 |
82 | if (step.status !== "COMPLETED") {
83 | output += `**Sentry is still working on this step. Please check back in a minute.**\n\n`;
84 | return output;
85 | }
86 |
87 | if (step.type === "root_cause_analysis") {
88 | const typedStep = step as z.infer<
89 | typeof AutofixRunStepRootCauseAnalysisSchema
90 | >;
91 |
92 | for (const cause of typedStep.causes) {
93 | if (cause.description) {
94 | output += `${cause.description}\n\n`;
95 | }
96 | for (const entry of cause.root_cause_reproduction) {
97 | output += `**${entry.title}**\n\n`;
98 | output += `${entry.code_snippet_and_analysis}\n\n`;
99 | }
100 | }
101 | return output;
102 | }
103 |
104 | if (step.type === "solution") {
105 | const typedStep = step as z.infer<typeof AutofixRunStepSolutionSchema>;
106 | output += `${typedStep.description}\n\n`;
107 | for (const entry of typedStep.solution) {
108 | output += `**${entry.title}**\n`;
109 | output += `${entry.code_snippet_and_analysis}\n\n`;
110 | }
111 |
112 | if (typedStep.status === "FAILED") {
113 | output += `**Sentry hit an error completing this step.\n\n`;
114 | } else if (typedStep.status !== "COMPLETED") {
115 | output += `**Sentry is still working on this step.**\n\n`;
116 | }
117 |
118 | return output;
119 | }
120 |
121 | const typedStep = step as z.infer<typeof AutofixRunStepDefaultSchema>;
122 | if (typedStep.insights && typedStep.insights.length > 0) {
123 | for (const entry of typedStep.insights) {
124 | output += `**${entry.insight}**\n`;
125 | output += `${entry.justification}\n\n`;
126 | }
127 | } else if (step.output_stream) {
128 | output += `${step.output_stream}\n`;
129 | }
130 |
131 | return output;
132 | }
133 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/search-docs.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi } from "vitest";
2 | import searchDocs from "./search-docs.js";
3 |
4 | describe("search_docs", () => {
5 | // Note: Query validation (empty, too short, too long) is now handled by Zod schema
6 | // These validation tests are no longer needed as they test framework behavior, not our tool logic
7 |
8 | it("returns results from the API", async () => {
9 | const result = await searchDocs.handler(
10 | {
11 | query: "How do I configure rate limiting?",
12 | maxResults: 5,
13 | guide: undefined,
14 | },
15 | {
16 | constraints: {
17 | organizationSlug: null,
18 | },
19 | accessToken: "access-token",
20 | userId: "1",
21 | mcpUrl: "https://mcp.sentry.dev",
22 | },
23 | );
24 | expect(result).toMatchInlineSnapshot(`
25 | "# Documentation Search Results
26 |
27 | **Query**: "How do I configure rate limiting?"
28 |
29 | Found 2 matches
30 |
31 | These are just snippets. Use \`get_doc(path='...')\` to fetch the full content.
32 |
33 | ## 1. https://docs.sentry.io/product/rate-limiting
34 |
35 | **Path**: product/rate-limiting.md
36 | **Relevance**: 95.0%
37 |
38 | **Matching Context**
39 | > Learn how to configure rate limiting in Sentry to prevent quota exhaustion and control event ingestion.
40 |
41 | ## 2. https://docs.sentry.io/product/accounts/quotas/spike-protection
42 |
43 | **Path**: product/accounts/quotas/spike-protection.md
44 | **Relevance**: 87.0%
45 |
46 | **Matching Context**
47 | > Spike protection helps prevent unexpected spikes in event volume from consuming your quota.
48 |
49 | "
50 | `);
51 | });
52 |
53 | it("handles API errors", async () => {
54 | vi.spyOn(global, "fetch").mockResolvedValueOnce({
55 | ok: false,
56 | status: 500,
57 | statusText: "Internal Server Error",
58 | json: async () => ({ error: "Internal server error" }),
59 | } as Response);
60 |
61 | await expect(
62 | searchDocs.handler(
63 | {
64 | query: "test query",
65 | maxResults: undefined,
66 | guide: undefined,
67 | },
68 | {
69 | constraints: {
70 | organizationSlug: null,
71 | },
72 | accessToken: "access-token",
73 | userId: "1",
74 | },
75 | ),
76 | ).rejects.toThrow();
77 | });
78 |
79 | it("handles timeout errors", async () => {
80 | // Mock fetch to simulate a timeout by throwing an AbortError
81 | vi.spyOn(global, "fetch").mockImplementationOnce(() => {
82 | const error = new Error("The operation was aborted");
83 | error.name = "AbortError";
84 | return Promise.reject(error);
85 | });
86 |
87 | await expect(
88 | searchDocs.handler(
89 | {
90 | query: "test query",
91 | maxResults: undefined,
92 | guide: undefined,
93 | },
94 | {
95 | constraints: {
96 | organizationSlug: null,
97 | },
98 | accessToken: "access-token",
99 | userId: "1",
100 | },
101 | ),
102 | ).rejects.toThrow("Request timeout after 15000ms");
103 | });
104 |
105 | it("includes platform in output and request", async () => {
106 | const mockFetch = vi.spyOn(global, "fetch");
107 |
108 | const result = await searchDocs.handler(
109 | {
110 | query: "test query",
111 | maxResults: 5,
112 | guide: "javascript/nextjs",
113 | },
114 | {
115 | constraints: {
116 | organizationSlug: null,
117 | },
118 | accessToken: "access-token",
119 | userId: "1",
120 | mcpUrl: "https://mcp.sentry.dev",
121 | },
122 | );
123 |
124 | // Check that platform is included in the output
125 | expect(result).toContain("**Guide**: javascript/nextjs");
126 |
127 | // Check that platform is included in the request
128 | expect(mockFetch).toHaveBeenCalledWith(
129 | "https://mcp.sentry.dev/api/search",
130 | expect.objectContaining({
131 | method: "POST",
132 | headers: {
133 | "Content-Type": "application/json",
134 | },
135 | body: JSON.stringify({
136 | query: "test query",
137 | maxResults: 5,
138 | guide: "javascript/nextjs",
139 | }),
140 | }),
141 | );
142 | });
143 | });
144 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/error-handling.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { UserInputError, ConfigurationError } from "../errors";
2 | import { ApiError, ApiClientError, ApiServerError } from "../api-client";
3 | import { logIssue } from "../telem/logging";
4 |
5 | /**
6 | * Type guard to identify user input validation errors.
7 | */
8 | export function isUserInputError(error: unknown): error is UserInputError {
9 | return error instanceof UserInputError;
10 | }
11 |
12 | /**
13 | * Type guard to identify configuration errors.
14 | */
15 | export function isConfigurationError(
16 | error: unknown,
17 | ): error is ConfigurationError {
18 | return error instanceof ConfigurationError;
19 | }
20 |
21 | /**
22 | * Type guard to identify API errors.
23 | */
24 | export function isApiError(error: unknown): error is ApiError {
25 | return error instanceof ApiError;
26 | }
27 |
28 | /**
29 | * Type guard to identify API client errors (4xx).
30 | */
31 | export function isApiClientError(error: unknown): error is ApiClientError {
32 | return error instanceof ApiClientError;
33 | }
34 |
35 | /**
36 | * Type guard to identify API server errors (5xx).
37 | */
38 | export function isApiServerError(error: unknown): error is ApiServerError {
39 | return error instanceof ApiServerError;
40 | }
41 |
42 | /**
43 | * Format an error for user display with markdown formatting.
44 | * This is used by tool handlers to format errors for MCP responses.
45 | *
46 | * SECURITY: Only return trusted error messages to prevent prompt injection vulnerabilities.
47 | * We trust: Sentry API errors, our own UserInputError/ConfigurationError messages, and system templates.
48 | */
49 | export async function formatErrorForUser(error: unknown): Promise<string> {
50 | if (isUserInputError(error)) {
51 | return [
52 | "**Input Error**",
53 | "It looks like there was a problem with the input you provided.",
54 | error.message,
55 | `You may be able to resolve the issue by addressing the concern and trying again.`,
56 | ].join("\n\n");
57 | }
58 |
59 | if (isConfigurationError(error)) {
60 | return [
61 | "**Configuration Error**",
62 | "There appears to be a configuration issue with your setup.",
63 | error.message,
64 | `Please check your environment configuration and try again.`,
65 | ].join("\n\n");
66 | }
67 |
68 | // Handle ApiClientError (4xx) - user input errors, should NOT be logged to Sentry
69 | if (isApiClientError(error)) {
70 | const statusText = error.status
71 | ? `There was an HTTP ${error.status} error with your request to the Sentry API.`
72 | : "There was an error with your request.";
73 |
74 | return [
75 | "**Input Error**",
76 | statusText,
77 | error.toUserMessage(),
78 | `You may be able to resolve the issue by addressing the concern and trying again.`,
79 | ].join("\n\n");
80 | }
81 |
82 | // Handle ApiServerError (5xx) - system errors, SHOULD be logged to Sentry
83 | if (isApiServerError(error)) {
84 | const eventId = logIssue(error);
85 | const statusText = error.status
86 | ? `There was an HTTP ${error.status} server error with the Sentry API.`
87 | : "There was a server error.";
88 |
89 | return [
90 | "**Error**",
91 | statusText,
92 | `${error.message}`,
93 | `**Event ID**: ${eventId}`,
94 | `Please contact support with this Event ID if the problem persists.`,
95 | ].join("\n\n");
96 | }
97 |
98 | // Handle generic ApiError (shouldn't happen with new hierarchy, but just in case)
99 | if (isApiError(error)) {
100 | const statusText = error.status
101 | ? `There was an HTTP ${error.status} error with your request to the Sentry API.`
102 | : "There was an error with your request.";
103 |
104 | return [
105 | "**Error**",
106 | statusText,
107 | `${error.message}`,
108 | `You may be able to resolve the issue by addressing the concern and trying again.`,
109 | ].join("\n\n");
110 | }
111 |
112 | const eventId = logIssue(error);
113 |
114 | return [
115 | "**Error**",
116 | "It looks like there was a problem communicating with the Sentry API.",
117 | "Please report the following to the user for the Sentry team:",
118 | `**Event ID**: ${eventId}`,
119 | process.env.NODE_ENV !== "production"
120 | ? error instanceof Error
121 | ? error.message
122 | : String(error)
123 | : "",
124 | ]
125 | .filter(Boolean)
126 | .join("\n\n");
127 | }
128 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/use-sentry/handler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
2 | import useSentry from "./handler";
3 | import type { ServerContext } from "../../types";
4 | import type { Scope } from "../../permissions";
5 |
6 | // Mock the embedded agent
7 | vi.mock("./agent", () => ({
8 | useSentryAgent: vi.fn(),
9 | }));
10 |
11 | // Import the mocked module to get access to the mock function
12 | import { useSentryAgent } from "./agent";
13 | const mockUseSentryAgent = useSentryAgent as Mock;
14 |
15 | // Use all scopes for testing to ensure all tools are available
16 | const ALL_SCOPES: Scope[] = [
17 | "org:read",
18 | "org:write",
19 | "project:read",
20 | "project:write",
21 | "team:read",
22 | "team:write",
23 | "event:read",
24 | "event:write",
25 | "project:releases",
26 | "seer",
27 | "docs",
28 | ];
29 |
30 | const mockContext: ServerContext = {
31 | accessToken: "test-token",
32 | sentryHost: "sentry.io",
33 | userId: "1",
34 | clientId: "test-client",
35 | constraints: {},
36 | grantedScopes: new Set(ALL_SCOPES),
37 | };
38 |
39 | describe("use_sentry handler", () => {
40 | beforeEach(() => {
41 | mockUseSentryAgent.mockClear();
42 | });
43 |
44 | it("calls embedded agent with request and wrapped tools", async () => {
45 | mockUseSentryAgent.mockResolvedValue({
46 | result: {
47 | result: "Agent executed tools successfully",
48 | },
49 | toolCalls: [{ toolName: "whoami", args: {} }],
50 | });
51 |
52 | const result = await useSentry.handler(
53 | { request: "Show me unresolved issues" },
54 | mockContext,
55 | );
56 |
57 | // Verify agent was called
58 | expect(mockUseSentryAgent).toHaveBeenCalledWith({
59 | request: "Show me unresolved issues",
60 | tools: expect.objectContaining({
61 | whoami: expect.any(Object),
62 | find_organizations: expect.any(Object),
63 | search_issues: expect.any(Object),
64 | }),
65 | });
66 |
67 | // Verify all 19 tools were provided (20 total - use_sentry itself)
68 | const toolsArg = mockUseSentryAgent.mock.calls[0][0].tools;
69 | expect(Object.keys(toolsArg)).toHaveLength(19);
70 |
71 | // Verify result is returned
72 | expect(result).toBe("Agent executed tools successfully");
73 | });
74 |
75 | it("provides wrapped tools with ServerContext", async () => {
76 | mockUseSentryAgent.mockResolvedValue({
77 | result: {
78 | result: "Success",
79 | },
80 | toolCalls: [],
81 | });
82 |
83 | await useSentry.handler({ request: "test request" }, mockContext);
84 |
85 | // Verify tools were provided to agent
86 | const toolsArg = mockUseSentryAgent.mock.calls[0][0].tools;
87 | expect(toolsArg).toBeDefined();
88 |
89 | // Verify key tools are present
90 | expect(toolsArg.whoami).toBeDefined();
91 | expect(toolsArg.find_organizations).toBeDefined();
92 | expect(toolsArg.search_events).toBeDefined();
93 | expect(toolsArg.search_issues).toBeDefined();
94 | expect(toolsArg.get_issue_details).toBeDefined();
95 | });
96 |
97 | it("excludes use_sentry from available tools to prevent recursion", async () => {
98 | mockUseSentryAgent.mockResolvedValue({
99 | result: {
100 | result: "Success",
101 | },
102 | toolCalls: [],
103 | });
104 |
105 | await useSentry.handler({ request: "test" }, mockContext);
106 |
107 | const toolsArg = mockUseSentryAgent.mock.calls[0][0].tools;
108 | const toolNames = Object.keys(toolsArg);
109 |
110 | // Verify use_sentry is NOT in the list
111 | expect(toolNames).not.toContain("use_sentry");
112 |
113 | // Verify we have exactly 19 tools (20 total - 1 use_sentry)
114 | expect(toolNames).toHaveLength(19);
115 | });
116 |
117 | it("wraps tools with session constraints", async () => {
118 | const constrainedContext: ServerContext = {
119 | ...mockContext,
120 | constraints: {
121 | organizationSlug: "constrained-org",
122 | projectSlug: "constrained-project",
123 | },
124 | };
125 |
126 | mockUseSentryAgent.mockResolvedValue({
127 | result: {
128 | result: "Success with constraints",
129 | },
130 | toolCalls: [],
131 | });
132 |
133 | await useSentry.handler(
134 | { request: "test with constraints" },
135 | constrainedContext,
136 | );
137 |
138 | // Verify agent was called with tools
139 | expect(mockUseSentryAgent).toHaveBeenCalled();
140 | const toolsArg = mockUseSentryAgent.mock.calls[0][0].tools;
141 | expect(toolsArg).toBeDefined();
142 | expect(Object.keys(toolsArg)).toHaveLength(19);
143 | });
144 | });
145 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/artifact.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "namespace": "artifact",
3 | "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",
4 | "attributes": {
5 | "artifact.filename": {
6 | "description": "The human readable file name of the artifact, typically generated during build and release processes. Often includes the package name and version in the file name.\n",
7 | "type": "string",
8 | "note": "This file name can also act as the [Package Name](https://slsa.dev/spec/v1.0/terminology#package-model)\nin cases where the package ecosystem maps accordingly.\nAdditionally, the artifact [can be published](https://slsa.dev/spec/v1.0/terminology#software-supply-chain)\nfor others, but that is not a guarantee.\n",
9 | "stability": "development",
10 | "examples": [
11 | "golang-binary-amd64-v0.1.0",
12 | "docker-image-amd64-v0.1.0",
13 | "release-1.tar.gz",
14 | "file-name-package.tar.gz"
15 | ]
16 | },
17 | "artifact.version": {
18 | "description": "The version of the artifact.\n",
19 | "type": "string",
20 | "stability": "development",
21 | "examples": ["v0.1.0", "1.2.1", "122691-build"]
22 | },
23 | "artifact.purl": {
24 | "description": "The [Package URL](https://github.com/package-url/purl-spec) of the [package artifact](https://slsa.dev/spec/v1.0/terminology#package-model) provides a standard way to identify and locate the packaged artifact.\n",
25 | "type": "string",
26 | "stability": "development",
27 | "examples": [
28 | "pkg:github/package-url/purl-spec@1209109710924",
29 | "pkg:npm/[email protected]"
30 | ]
31 | },
32 | "artifact.hash": {
33 | "description": "The full [hash value (see glossary)](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-5.pdf), often found in checksum.txt on a release of the artifact and used to verify package integrity.\n",
34 | "type": "string",
35 | "note": "The specific algorithm used to create the cryptographic hash value is\nnot defined. In situations where an artifact has multiple\ncryptographic hashes, it is up to the implementer to choose which\nhash value to set here; this should be the most secure hash algorithm\nthat is suitable for the situation and consistent with the\ncorresponding attestation. The implementer can then provide the other\nhash values through an additional set of attribute extensions as they\ndeem necessary.\n",
36 | "stability": "development",
37 | "examples": [
38 | "9ff4c52759e2c4ac70b7d517bc7fcdc1cda631ca0045271ddd1b192544f8a3e9"
39 | ]
40 | },
41 | "artifact.attestation.id": {
42 | "description": "The id of the build [software attestation](https://slsa.dev/attestation-model).\n",
43 | "type": "string",
44 | "stability": "development",
45 | "examples": ["123"]
46 | },
47 | "artifact.attestation.filename": {
48 | "description": "The provenance filename of the built attestation which directly relates to the build artifact filename. This filename SHOULD accompany the artifact at publish time. See the [SLSA Relationship](https://slsa.dev/spec/v1.0/distributing-provenance#relationship-between-artifacts-and-attestations) specification for more information.\n",
49 | "type": "string",
50 | "stability": "development",
51 | "examples": [
52 | "golang-binary-amd64-v0.1.0.attestation",
53 | "docker-image-amd64-v0.1.0.intoto.json1",
54 | "release-1.tar.gz.attestation",
55 | "file-name-package.tar.gz.intoto.json1"
56 | ]
57 | },
58 | "artifact.attestation.hash": {
59 | "description": "The full [hash value (see glossary)](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-5.pdf), of the built attestation. Some envelopes in the [software attestation space](https://github.com/in-toto/attestation/tree/main/spec) also refer to this as the **digest**.\n",
60 | "type": "string",
61 | "stability": "development",
62 | "examples": [
63 | "1b31dfcd5b7f9267bf2ff47651df1cfb9147b9e4df1f335accf65b4cda498408"
64 | ]
65 | }
66 | }
67 | }
68 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/constraint-helpers.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Constraint application helpers for MCP server configuration.
3 | *
4 | * These functions handle the logic for filtering tool schemas and injecting
5 | * constraint parameters, including support for parameter aliases (e.g., projectSlug → projectSlugOrId).
6 | */
7 | import type { Constraints } from "../types";
8 | import type { z } from "zod";
9 |
10 | /**
11 | * Determines which tool parameter keys should be filtered out of the schema
12 | * because they will be injected from constraints.
13 | *
14 | * Handles parameter aliases: when a projectSlug constraint exists and the tool
15 | * has a projectSlugOrId parameter, the alias will be applied UNLESS projectSlugOrId
16 | * is explicitly constrained with a truthy value.
17 | *
18 | * @param constraints - The active constraints (org, project, region)
19 | * @param toolInputSchema - The tool's input schema definition
20 | * @returns Array of parameter keys that should be filtered from the schema
21 | *
22 | * @example
23 | * ```typescript
24 | * const constraints = { projectSlug: "my-project", organizationSlug: "my-org" };
25 | * const schema = { organizationSlug: z.string(), projectSlugOrId: z.string() };
26 | * const keys = getConstraintKeysToFilter(constraints, schema);
27 | * // Returns: ["organizationSlug", "projectSlugOrId"]
28 | * // projectSlugOrId is included because projectSlug constraint will map to it
29 | * ```
30 | */
31 | export function getConstraintKeysToFilter(
32 | constraints: Constraints & Record<string, string | null | undefined>,
33 | toolInputSchema: Record<string, z.ZodType>,
34 | ): string[] {
35 | return Object.entries(constraints).flatMap(([key, value]) => {
36 | if (!value) return [];
37 |
38 | const keys: string[] = [];
39 |
40 | // If this constraint key exists in the schema, include it
41 | if (key in toolInputSchema) {
42 | keys.push(key);
43 | }
44 |
45 | // Special handling: projectSlug constraint can also apply to projectSlugOrId parameter
46 | // Only add the alias to filter if projectSlugOrId isn't being explicitly constrained
47 | if (
48 | key === "projectSlug" &&
49 | "projectSlugOrId" in toolInputSchema &&
50 | !("projectSlugOrId" in constraints && constraints.projectSlugOrId)
51 | ) {
52 | keys.push("projectSlugOrId");
53 | }
54 |
55 | return keys;
56 | });
57 | }
58 |
59 | /**
60 | * Builds the constraint parameters that should be injected into tool calls.
61 | *
62 | * Handles parameter aliases: when a projectSlug constraint exists and the tool
63 | * has a projectSlugOrId parameter, the constraint value will be injected as
64 | * projectSlugOrId UNLESS projectSlugOrId is explicitly constrained with a truthy value.
65 | *
66 | * @param constraints - The active constraints (org, project, region)
67 | * @param toolInputSchema - The tool's input schema definition
68 | * @returns Object mapping parameter names to constraint values
69 | *
70 | * @example
71 | * ```typescript
72 | * const constraints = { projectSlug: "my-project", organizationSlug: "my-org" };
73 | * const schema = { organizationSlug: z.string(), projectSlugOrId: z.string() };
74 | * const params = getConstraintParametersToInject(constraints, schema);
75 | * // Returns: { organizationSlug: "my-org", projectSlugOrId: "my-project" }
76 | * // projectSlug constraint is injected as projectSlugOrId parameter
77 | * ```
78 | */
79 | export function getConstraintParametersToInject(
80 | constraints: Constraints & Record<string, string | null | undefined>,
81 | toolInputSchema: Record<string, z.ZodType>,
82 | ): Record<string, string> {
83 | return Object.fromEntries(
84 | Object.entries(constraints).flatMap(([key, value]) => {
85 | if (!value) return [];
86 |
87 | const entries: [string, string][] = [];
88 |
89 | // If this constraint key exists in the schema, add it
90 | if (key in toolInputSchema) {
91 | entries.push([key, value]);
92 | }
93 |
94 | // Special handling: projectSlug constraint can also apply to projectSlugOrId parameter
95 | // Only apply alias if the target parameter isn't already being constrained with a truthy value
96 | if (
97 | key === "projectSlug" &&
98 | "projectSlugOrId" in toolInputSchema &&
99 | !("projectSlugOrId" in constraints && constraints.projectSlugOrId)
100 | ) {
101 | entries.push(["projectSlugOrId", value]);
102 | }
103 |
104 | return entries;
105 | }),
106 | );
107 | }
108 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/dataset-fields.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import type { SentryApiService } from "../../../api-client";
3 | import { agentTool } from "./utils";
4 |
5 | export type DatasetType = "events" | "errors" | "search_issues";
6 |
7 | export interface DatasetField {
8 | key: string;
9 | name: string;
10 | totalValues: number;
11 | examples?: string[];
12 | }
13 |
14 | export interface DatasetFieldsResult {
15 | dataset: string;
16 | fields: DatasetField[];
17 | commonPatterns: Array<{ pattern: string; description: string }>;
18 | }
19 |
20 | /**
21 | * Discover available fields for a dataset by querying Sentry's tags API
22 | */
23 | export async function discoverDatasetFields(
24 | apiService: SentryApiService,
25 | organizationSlug: string,
26 | dataset: DatasetType,
27 | options: {
28 | projectId?: string;
29 | } = {},
30 | ): Promise<DatasetFieldsResult> {
31 | const { projectId } = options;
32 |
33 | // Get available tags for the dataset
34 | const tags = await apiService.listTags({
35 | organizationSlug,
36 | dataset,
37 | project: projectId,
38 | statsPeriod: "14d",
39 | });
40 |
41 | // Filter out internal Sentry tags and format
42 | const fields = tags
43 | .filter((tag) => !tag.key.startsWith("sentry:"))
44 | .map((tag) => ({
45 | key: tag.key,
46 | name: tag.name,
47 | totalValues: tag.totalValues,
48 | examples: getFieldExamples(tag.key, dataset),
49 | }));
50 |
51 | return {
52 | dataset,
53 | fields,
54 | commonPatterns: getCommonPatterns(dataset),
55 | };
56 | }
57 |
58 | /**
59 | * Create a tool for discovering available fields in a dataset
60 | * The tool is pre-bound with the API service and organization configured for the appropriate region
61 | */
62 | export function createDatasetFieldsTool(options: {
63 | apiService: SentryApiService;
64 | organizationSlug: string;
65 | dataset: DatasetType;
66 | projectId?: string;
67 | }) {
68 | const { apiService, organizationSlug, dataset, projectId } = options;
69 | return agentTool({
70 | description: `Discover available fields for ${dataset} searches in Sentry (includes example values)`,
71 | parameters: z.object({}),
72 | execute: async () => {
73 | return discoverDatasetFields(apiService, organizationSlug, dataset, {
74 | projectId,
75 | });
76 | },
77 | });
78 | }
79 |
80 | /**
81 | * Get example values for common fields
82 | */
83 | export function getFieldExamples(
84 | key: string,
85 | dataset: string,
86 | ): string[] | undefined {
87 | const commonExamples: Record<string, string[]> = {
88 | level: ["error", "warning", "info", "debug", "fatal"],
89 | environment: ["production", "staging", "development"],
90 | release: ["v1.0.0", "latest", "[email protected]"],
91 | user: ["user123", "[email protected]"],
92 | };
93 |
94 | const issueExamples: Record<string, string[]> = {
95 | ...commonExamples,
96 | assignedOrSuggested: ["[email protected]", "team-slug", "me"],
97 | is: ["unresolved", "resolved", "ignored"],
98 | };
99 |
100 | const eventExamples: Record<string, string[]> = {
101 | ...commonExamples,
102 | "http.method": ["GET", "POST", "PUT", "DELETE"],
103 | "http.status_code": ["200", "404", "500"],
104 | "db.system": ["postgresql", "mysql", "redis"],
105 | };
106 |
107 | if (dataset === "search_issues") {
108 | return issueExamples[key];
109 | }
110 | if (dataset === "events" || dataset === "errors") {
111 | return eventExamples[key];
112 | }
113 |
114 | return commonExamples[key];
115 | }
116 |
117 | /**
118 | * Get common search patterns for a dataset
119 | */
120 | export function getCommonPatterns(dataset: string) {
121 | if (dataset === "search_issues") {
122 | return [
123 | { pattern: "is:unresolved", description: "Open issues" },
124 | { pattern: "is:resolved", description: "Closed issues" },
125 | { pattern: "level:error", description: "Error level issues" },
126 | {
127 | pattern: "firstSeen:-24h",
128 | description: "New issues from last 24 hours",
129 | },
130 | {
131 | pattern: "userCount:>100",
132 | description: "Affecting more than 100 users",
133 | },
134 | ];
135 | }
136 | if (dataset === "events" || dataset === "errors") {
137 | return [
138 | { pattern: "level:error", description: "Error events" },
139 | { pattern: "environment:production", description: "Production events" },
140 | { pattern: "timestamp:-1h", description: "Events from last hour" },
141 | { pattern: "has:http.method", description: "HTTP requests" },
142 | { pattern: "has:db.statement", description: "Database queries" },
143 | ];
144 | }
145 |
146 | return [];
147 | }
148 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@sentry/mcp-server",
3 | "version": "0.20.0",
4 | "type": "module",
5 | "packageManager": "[email protected]",
6 | "engines": {
7 | "node": ">=20"
8 | },
9 | "publishConfig": {
10 | "access": "public"
11 | },
12 | "license": "FSL-1.1-ALv2",
13 | "author": "Sentry",
14 | "description": "Sentry MCP Server",
15 | "homepage": "https://github.com/getsentry/sentry-mcp",
16 | "keywords": [
17 | "sentry"
18 | ],
19 | "bugs": {
20 | "url": "https://github.com/getsentry/sentry-mcp/issues"
21 | },
22 | "repository": {
23 | "type": "git",
24 | "url": "[email protected]:getsentry/sentry-mcp.git"
25 | },
26 | "bin": {
27 | "sentry-mcp": "./dist/index.js"
28 | },
29 | "files": [
30 | "./dist/*"
31 | ],
32 | "exports": {
33 | ".": {
34 | "types": "./dist/index.ts",
35 | "default": "./dist/index.js"
36 | },
37 | "./api-client": {
38 | "types": "./dist/api-client/index.ts",
39 | "default": "./dist/api-client/index.js"
40 | },
41 | "./constants": {
42 | "types": "./dist/constants.ts",
43 | "default": "./dist/constants.js"
44 | },
45 | "./telem": {
46 | "types": "./dist/telem/index.ts",
47 | "default": "./dist/telem/index.js"
48 | },
49 | "./telem/logging": {
50 | "types": "./dist/telem/logging.ts",
51 | "default": "./dist/telem/logging.js"
52 | },
53 | "./telem/sentry": {
54 | "types": "./dist/telem/sentry.ts",
55 | "default": "./dist/telem/sentry.js"
56 | },
57 | "./permissions": {
58 | "types": "./dist/permissions.ts",
59 | "default": "./dist/permissions.js"
60 | },
61 | "./transports/stdio": {
62 | "types": "./dist/transports/stdio.ts",
63 | "default": "./dist/transports/stdio.js"
64 | },
65 | "./server": {
66 | "types": "./dist/server.ts",
67 | "default": "./dist/server.js"
68 | },
69 | "./toolDefinitions": {
70 | "types": "./dist/toolDefinitions.ts",
71 | "default": "./dist/toolDefinitions.js"
72 | },
73 | "./types": {
74 | "types": "./dist/types.ts",
75 | "default": "./dist/types.js"
76 | },
77 | "./version": {
78 | "types": "./dist/version.ts",
79 | "default": "./dist/version.js"
80 | },
81 | "./tools/search-events": {
82 | "types": "./dist/tools/search-events/index.ts",
83 | "default": "./dist/tools/search-events/index.js"
84 | },
85 | "./tools/search-issues": {
86 | "types": "./dist/tools/search-issues/index.ts",
87 | "default": "./dist/tools/search-issues/index.js"
88 | },
89 | "./tools/search-events/agent": {
90 | "types": "./dist/tools/search-events/agent.ts",
91 | "default": "./dist/tools/search-events/agent.js"
92 | },
93 | "./tools/search-issues/agent": {
94 | "types": "./dist/tools/search-issues/agent.ts",
95 | "default": "./dist/tools/search-issues/agent.js"
96 | },
97 | "./tools/agent-tools": {
98 | "types": "./dist/tools/agent-tools.ts",
99 | "default": "./dist/tools/agent-tools.js"
100 | },
101 | "./internal/agents/callEmbeddedAgent": {
102 | "types": "./dist/internal/agents/callEmbeddedAgent.ts",
103 | "default": "./dist/internal/agents/callEmbeddedAgent.js"
104 | }
105 | },
106 | "scripts": {
107 | "prebuild": "pnpm run generate-definitions",
108 | "build": "tsdown",
109 | "dev": "pnpm run generate-definitions && tsdown -w",
110 | "start": "tsx src/index.ts",
111 | "prepare": "pnpm run build",
112 | "pretest": "pnpm run generate-definitions",
113 | "test": "vitest run",
114 | "test:ci": "pnpm run generate-definitions && vitest run --coverage --reporter=default --reporter=junit --outputFile=tests.junit.xml",
115 | "test:watch": "pnpm run generate-definitions && vitest",
116 | "tsc": "tsc --noEmit",
117 | "generate-definitions": "tsx scripts/generate-definitions.ts",
118 | "generate-otel-namespaces": "tsx scripts/generate-otel-namespaces.ts",
119 | "measure-tokens": "tsx scripts/measure-token-cost.ts"
120 | },
121 | "devDependencies": {
122 | "@sentry/mcp-server-mocks": "workspace:*",
123 | "@sentry/mcp-server-tsconfig": "workspace:*",
124 | "msw": "catalog:",
125 | "tiktoken": "^1.0.18",
126 | "yaml": "^2.6.1",
127 | "zod-to-json-schema": "catalog:"
128 | },
129 | "dependencies": {
130 | "@ai-sdk/openai": "catalog:",
131 | "@logtape/logtape": "^1.1.1",
132 | "@logtape/sentry": "^1.1.1",
133 | "@modelcontextprotocol/sdk": "catalog:",
134 | "@sentry/core": "catalog:",
135 | "@sentry/node": "catalog:",
136 | "ai": "catalog:",
137 | "dotenv": "catalog:",
138 | "zod": "catalog:"
139 | }
140 | }
141 |
```
--------------------------------------------------------------------------------
/docs/permissions-and-scopes.md:
--------------------------------------------------------------------------------
```markdown
1 | # Permissions and Scopes
2 |
3 | OAuth-style scope system for controlling access to Sentry MCP tools.
4 |
5 | ## Default Permissions
6 |
7 | **By default, all users receive read-only access.** This includes:
8 | - `org:read`, `project:read`, `team:read`, `event:read`
9 |
10 | Additional permissions must be explicitly granted through the OAuth flow or CLI arguments.
11 |
12 | ## Permission Levels
13 |
14 | When authenticating via OAuth, users can select additional permissions:
15 |
16 | | Level | Scopes | Tools Enabled |
17 | |-------|--------|--------------|
18 | | **Read-Only** (default) | `org:read`, `project:read`, `team:read`, `event:read` | Search, view issues/traces, documentation |
19 | | **+ Issue Triage** | Adds `event:write` | All above + resolve/assign issues, AI analysis |
20 | | **+ Project Management** | Adds `project:write`, `team:write` | All above + create/modify projects/teams/DSNs |
21 |
22 | ### CLI Usage
23 |
24 | ```bash
25 | # Default: read-only access
26 | npx @sentry/mcp-server --access-token=TOKEN
27 |
28 | # Override defaults with specific scopes only
29 | npx @sentry/mcp-server --access-token=TOKEN --scopes=org:read,event:read
30 |
31 | # Add write permissions to default read-only scopes
32 | npx @sentry/mcp-server --access-token=TOKEN --add-scopes=event:write,project:write
33 |
34 | # Via environment variables
35 | export MCP_SCOPES=org:read,project:write # Overrides defaults
36 | export MCP_ADD_SCOPES=event:write # Adds to defaults
37 | npx @sentry/mcp-server --access-token=TOKEN
38 | ```
39 |
40 | Precedence and validation:
41 | - Flags override environment variables. If `--scopes` is provided, `MCP_SCOPES` is ignored. If `--add-scopes` is provided, `MCP_ADD_SCOPES` is ignored.
42 | - Flags and env vars are strict: any invalid scope token causes an error listing allowed scopes.
43 |
44 | **Note:** `--scopes` completely replaces the default scopes, while `--add-scopes` adds to them.
45 |
46 | ## Scope Hierarchy
47 |
48 | Higher scopes include lower ones:
49 |
50 | ```
51 | admin → write → read
52 | ```
53 |
54 | Examples:
55 | - `team:write` includes `team:read`
56 | - `event:admin` includes `event:write` and `event:read`
57 |
58 | ## Available Scopes
59 |
60 | | Resource | Read | Write | Admin |
61 | |----------|------|-------|-------|
62 | | **Organization** | `org:read` | `org:write` | `org:admin` |
63 | | **Project** | `project:read` | `project:write` | `project:admin` |
64 | | **Team** | `team:read` | `team:write` | `team:admin` |
65 | | **Member** | `member:read` | `member:write` | `member:admin` |
66 | | **Event/Issue** | `event:read` | `event:write` | `event:admin` |
67 | | **Special** | `project:releases` | - | - |
68 |
69 | ## Tool Requirements
70 |
71 | ### Always Available (No Scopes)
72 | - `whoami` - User identification
73 | - `search_docs` - Documentation search
74 | - `get_doc` - Documentation retrieval
75 |
76 | ### Read Operations
77 | - `find_organizations` - `org:read`
78 | - `find_projects` - `project:read`
79 | - `find_teams` - `team:read`
80 | - `find_releases` - `project:read`
81 | - `find_dsns` - `project:read`
82 | - `get_issue_details` - `event:read`
83 | - `get_event_attachment` - `event:read`
84 | - `get_trace_details` - `event:read`
85 | - `search_events` - `event:read`
86 | - `search_issues` - `event:read`
87 | - `analyze_issue_with_seer` - `event:read`
88 |
89 | ### Write Operations
90 | - `update_issue` - `event:write`
91 | - `create_project` - `project:write`, `team:read`
92 | - `update_project` - `project:write`
93 | - `create_team` - `team:write`
94 | - `create_dsn` - `project:write`
95 |
96 | ## How It Works
97 |
98 | 1. **Sentry Authentication**: MCP requests all necessary scopes from Sentry
99 | 2. **Permission Selection**: User chooses permission level in approval dialog
100 | 3. **Tool Filtering**: MCP filters available tools based on granted scopes
101 | 4. **Runtime Validation**: Scopes checked when tools are invoked
102 |
103 | ## Notes
104 |
105 | - Default behavior grants read-only access if no scopes specified
106 | - Embedded agent tools don't require scope binding
107 | - Documentation tools always available regardless of scopes
108 |
109 | ## Troubleshooting
110 |
111 | | Issue | Solution |
112 | |-------|----------|
113 | | Tool not in list | Check required scopes are granted |
114 | | "Tool not allowed" error | Re-authenticate with higher permission level |
115 | | Invalid scope | Use lowercase with colon separator (e.g., `event:write`) |
116 |
117 | ## References
118 |
119 | - Adding Tools: @docs/adding-tools.mdc — Add tools with scope requirements
120 | - Testing: @docs/testing.mdc — Test with different scope configurations
121 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { tool } from "ai";
2 | import { z } from "zod";
3 | import { UserInputError } from "../../../errors";
4 | import { ApiClientError, ApiServerError } from "../../../api-client";
5 | import { logIssue, logWarn } from "../../../telem/logging";
6 |
7 | /**
8 | * Standard response schema for all embedded agent tools.
9 | * Tools return either an error message or the result data, never both.
10 | */
11 | const AgentToolResponseSchema = z.object({
12 | error: z
13 | .string()
14 | .optional()
15 | .describe("Error message if the operation failed"),
16 | result: z.unknown().optional().describe("The successful result data"),
17 | });
18 |
19 | export type AgentToolResponse<T = unknown> = {
20 | error?: string;
21 | result?: T;
22 | };
23 |
24 | /**
25 | * Handles errors from agent tool execution and returns appropriate error messages.
26 | *
27 | * SECURITY: Only returns trusted error messages to prevent prompt injection.
28 | * We trust: Sentry API errors, our own UserInputError messages, and system templates.
29 | */
30 | function handleAgentToolError<T>(error: unknown): AgentToolResponse<T> {
31 | if (error instanceof UserInputError) {
32 | // Log UserInputError for Sentry logging (as log, not exception)
33 | logWarn(error, {
34 | loggerScope: ["agent-tools", "user-input"],
35 | contexts: {
36 | agentTool: {
37 | errorType: "UserInputError",
38 | },
39 | },
40 | });
41 | return {
42 | error: `Input Error: ${error.message}. You may be able to resolve this by addressing the concern and trying again.`,
43 | };
44 | }
45 |
46 | if (error instanceof ApiClientError) {
47 | // Log ApiClientError for Sentry logging (as log, not exception)
48 | const message = error.toUserMessage();
49 | logWarn(message, {
50 | loggerScope: ["agent-tools", "api-client"],
51 | contexts: {
52 | agentTool: {
53 | errorType: error.name,
54 | status: error.status ?? null,
55 | },
56 | },
57 | });
58 | return {
59 | error: `Input Error: ${message}. You may be able to resolve this by addressing the concern and trying again.`,
60 | };
61 | }
62 |
63 | if (error instanceof ApiServerError) {
64 | // Log server errors to Sentry and get Event ID
65 | const eventId = logIssue(error);
66 | const statusText = error.status ? ` (${error.status})` : "";
67 | return {
68 | error: `Server Error${statusText}: ${error.message}. Event ID: ${eventId}. This is a system error that cannot be resolved by retrying.`,
69 | };
70 | }
71 |
72 | // Log unexpected errors to Sentry and return safe generic message
73 | // SECURITY: Don't return untrusted error messages that could enable prompt injection
74 | const eventId = logIssue(error);
75 | return {
76 | error: `System Error: An unexpected error occurred. Event ID: ${eventId}. This is a system error that cannot be resolved by retrying.`,
77 | };
78 | }
79 |
80 | /**
81 | * Creates an embedded agent tool with automatic error handling and schema wrapping.
82 | *
83 | * This wrapper:
84 | * - Maintains the same API as the AI SDK's tool() function
85 | * - Automatically wraps the result schema with error/result structure
86 | * - Handles all error types and returns them as structured responses
87 | * - Preserves type inference from the original tool implementation
88 | *
89 | * @example
90 | * ```typescript
91 | * export function createMyTool(apiService: SentryApiService) {
92 | * return agentTool({
93 | * description: "My tool description",
94 | * parameters: z.object({ param: z.string() }),
95 | * execute: async (params) => {
96 | * // Tool implementation that might throw errors
97 | * const result = await apiService.someMethod(params);
98 | * return result; // Original return type preserved
99 | * }
100 | * });
101 | * }
102 | * ```
103 | */
104 | export function agentTool<TParameters, TResult>(config: {
105 | description: string;
106 | parameters: z.ZodSchema<TParameters>;
107 | execute: (params: TParameters) => Promise<TResult>;
108 | }) {
109 | // Infer the result type from the execute function's return type
110 | type InferredResult = Awaited<ReturnType<typeof config.execute>>;
111 |
112 | return tool({
113 | description: config.description,
114 | parameters: config.parameters,
115 | execute: async (
116 | params: TParameters,
117 | ): Promise<AgentToolResponse<InferredResult>> => {
118 | try {
119 | const result = await config.execute(params);
120 | return { result };
121 | } catch (error) {
122 | return handleAgentToolError<InferredResult>(error);
123 | }
124 | },
125 | });
126 | }
127 |
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/app.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import { Header } from "./components/ui/header";
2 | import { useState, useEffect } from "react";
3 | import { Chat } from "./components/chat";
4 | import { useAuth } from "./contexts/auth-context";
5 | import Home from "./pages/home";
6 |
7 | export default function App() {
8 | const { isAuthenticated, handleLogout } = useAuth();
9 |
10 | const [isChatOpen, setIsChatOpen] = useState(() => {
11 | // Initialize based on URL query string only to avoid hydration issues
12 | const urlParams = new URLSearchParams(window.location.search);
13 | const hasQueryParam = urlParams.has("chat");
14 |
15 | if (hasQueryParam) {
16 | return urlParams.get("chat") !== "0";
17 | }
18 |
19 | // Default based on screen size to avoid flash on mobile
20 | // Note: This is safe for SSR since we handle the correction in useEffect
21 | if (typeof window !== "undefined") {
22 | return window.innerWidth >= 768; // Desktop: open, Mobile: closed
23 | }
24 |
25 | // SSR fallback - default to true for desktop-first approach
26 | return true;
27 | });
28 |
29 | // Adjust initial state for mobile after component mounts
30 | useEffect(() => {
31 | const urlParams = new URLSearchParams(window.location.search);
32 |
33 | // Only adjust state if no URL parameter exists and we're on mobile
34 | if (!urlParams.has("chat") && window.innerWidth < 768) {
35 | setIsChatOpen(false);
36 | }
37 | }, []);
38 |
39 | // Update URL when chat state changes
40 | const toggleChat = (open: boolean) => {
41 | setIsChatOpen(open);
42 |
43 | if (open) {
44 | // Add ?chat to URL
45 | const newUrl = new URL(window.location.href);
46 | newUrl.searchParams.set("chat", "1");
47 | window.history.pushState({}, "", newUrl.toString());
48 | } else {
49 | // Remove query string for home page
50 | const newUrl = new URL(window.location.href);
51 | newUrl.search = "";
52 | window.history.pushState({}, "", newUrl.toString());
53 | }
54 | };
55 |
56 | // Handle browser back/forward navigation
57 | useEffect(() => {
58 | const handlePopState = () => {
59 | const urlParams = new URLSearchParams(window.location.search);
60 | const hasQueryParam = urlParams.has("chat");
61 |
62 | if (hasQueryParam) {
63 | setIsChatOpen(urlParams.get("chat") !== "0");
64 | } else {
65 | // Default to open on desktop, closed on mobile
66 | setIsChatOpen(window.innerWidth >= 768);
67 | }
68 | };
69 |
70 | window.addEventListener("popstate", handlePopState);
71 | return () => window.removeEventListener("popstate", handlePopState);
72 | }, []);
73 |
74 | // Handle window resize to adjust chat state appropriately
75 | useEffect(() => {
76 | const handleResize = () => {
77 | // If no explicit URL state, adjust based on screen size
78 | const urlParams = new URLSearchParams(window.location.search);
79 | if (!urlParams.has("chat")) {
80 | const isDesktop = window.innerWidth >= 768;
81 | setIsChatOpen(isDesktop); // Open on desktop, closed on mobile
82 | }
83 | };
84 |
85 | window.addEventListener("resize", handleResize);
86 | return () => window.removeEventListener("resize", handleResize);
87 | }, []);
88 |
89 | return (
90 | <div className="min-h-screen text-white">
91 | {/* Mobile layout: Single column with overlay chat */}
92 | <div className="md:hidden h-screen flex flex-col">
93 | <div className="flex-1 overflow-y-auto sm:p-8 p-4">
94 | <div className="max-w-3xl mx-auto">
95 | <Header isAuthenticated={isAuthenticated} onLogout={handleLogout} />
96 | <Home onChatClick={() => toggleChat(true)} />
97 | </div>
98 | </div>
99 | </div>
100 |
101 | {/* Desktop layout: Main content adjusts width based on chat state */}
102 | <div className="hidden md:flex h-screen">
103 | <div
104 | className={`flex flex-col ${isChatOpen ? "w-1/2" : "flex-1"} md:transition-all md:duration-300`}
105 | >
106 | <div className="flex-1 overflow-y-auto sm:p-8 p-4">
107 | <div className="max-w-3xl mx-auto">
108 | <Header
109 | isAuthenticated={isAuthenticated}
110 | onLogout={handleLogout}
111 | />
112 | <Home onChatClick={() => toggleChat(true)} />
113 | </div>
114 | </div>
115 | </div>
116 | </div>
117 |
118 | {/* Single Chat component - handles both mobile and desktop layouts */}
119 | <Chat
120 | isOpen={isChatOpen}
121 | onClose={() => toggleChat(false)}
122 | onLogout={handleLogout}
123 | />
124 | </div>
125 | );
126 | }
127 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Main CLI entry point for the Sentry MCP server.
5 | *
6 | * Handles command-line argument parsing, environment configuration, Sentry
7 | * initialization, and starts the MCP server with stdio transport. Requires
8 | * a Sentry access token and optionally accepts host and DSN configuration.
9 | *
10 | * @example CLI Usage
11 | * ```bash
12 | * npx @sentry/mcp-server --access-token=TOKEN --host=sentry.io
13 | * npx @sentry/mcp-server --access-token=TOKEN --url=https://sentry.example.com
14 | * ```
15 | */
16 |
17 | import { buildServer } from "./server";
18 | import { startStdio } from "./transports/stdio";
19 | import * as Sentry from "@sentry/node";
20 | import { LIB_VERSION } from "./version";
21 | import { buildUsage } from "./cli/usage";
22 | import { parseArgv, parseEnv, merge } from "./cli/parse";
23 | import { finalize } from "./cli/resolve";
24 | import { sentryBeforeSend } from "./telem/sentry";
25 | import { ALL_SCOPES } from "./permissions";
26 | import { DEFAULT_SCOPES } from "./constants";
27 | import { configureOpenAIProvider } from "./internal/agents/openai-provider";
28 | import agentTools from "./tools/agent-tools";
29 |
30 | const packageName = "@sentry/mcp-server";
31 | const usageText = buildUsage(packageName, DEFAULT_SCOPES, ALL_SCOPES);
32 |
33 | function die(message: string): never {
34 | console.error(message);
35 | console.error(usageText);
36 | process.exit(1);
37 | }
38 | const cli = parseArgv(process.argv.slice(2));
39 | if (cli.help) {
40 | console.log(usageText);
41 | process.exit(0);
42 | }
43 | if (cli.version) {
44 | console.log(`${packageName} ${LIB_VERSION}`);
45 | process.exit(0);
46 | }
47 | if (cli.unknownArgs.length > 0) {
48 | console.error("Error: Invalid argument(s):", cli.unknownArgs.join(", "));
49 | console.error(usageText);
50 | process.exit(1);
51 | }
52 |
53 | const env = parseEnv(process.env);
54 | const cfg = (() => {
55 | try {
56 | return finalize(merge(cli, env));
57 | } catch (err) {
58 | die(err instanceof Error ? err.message : String(err));
59 | }
60 | })();
61 |
62 | // Check for OpenAI API key and warn if missing
63 | if (!process.env.OPENAI_API_KEY) {
64 | console.warn("Warning: OPENAI_API_KEY environment variable is not set.");
65 | console.warn("The following AI-powered search tools will be unavailable:");
66 | console.warn(" - search_events (natural language event search)");
67 | console.warn(" - search_issues (natural language issue search)");
68 | console.warn(
69 | "All other tools will function normally. To enable AI-powered search, set OPENAI_API_KEY.",
70 | );
71 | console.warn("");
72 | }
73 |
74 | configureOpenAIProvider({ baseUrl: cfg.openaiBaseUrl });
75 |
76 | Sentry.init({
77 | dsn: cfg.sentryDsn,
78 | sendDefaultPii: true,
79 | tracesSampleRate: 1,
80 | beforeSend: sentryBeforeSend,
81 | initialScope: {
82 | tags: {
83 | "mcp.server_version": LIB_VERSION,
84 | "mcp.transport": "stdio",
85 | "mcp.agent_mode": cli.agent ? "true" : "false",
86 | "sentry.host": cfg.sentryHost,
87 | "mcp.mcp-url": cfg.mcpUrl,
88 | },
89 | },
90 | release: process.env.SENTRY_RELEASE,
91 | integrations: [
92 | Sentry.consoleLoggingIntegration(),
93 | Sentry.zodErrorsIntegration(),
94 | Sentry.vercelAIIntegration({
95 | recordInputs: true,
96 | recordOutputs: true,
97 | }),
98 | ],
99 | environment:
100 | process.env.SENTRY_ENVIRONMENT ??
101 | (process.env.NODE_ENV !== "production" ? "development" : "production"),
102 | });
103 |
104 | // Log agent mode status
105 | if (cli.agent) {
106 | console.warn("Agent mode enabled: Only use_sentry tool is available.");
107 | console.warn(
108 | "The use_sentry tool provides access to all Sentry operations through natural language.",
109 | );
110 | console.warn("");
111 | }
112 |
113 | const SENTRY_TIMEOUT = 5000; // 5 seconds
114 |
115 | // Build context once for server configuration and runtime
116 | const context = {
117 | accessToken: cfg.accessToken,
118 | grantedScopes: cfg.finalScopes,
119 | constraints: {
120 | organizationSlug: cfg.organizationSlug ?? null,
121 | projectSlug: cfg.projectSlug ?? null,
122 | },
123 | sentryHost: cfg.sentryHost,
124 | mcpUrl: cfg.mcpUrl,
125 | openaiBaseUrl: cfg.openaiBaseUrl,
126 | };
127 |
128 | // Build server with context to filter tools based on granted scopes
129 | // Use agentTools when --agent flag is set (only exposes use_sentry tool)
130 | const server = buildServer({
131 | context,
132 | tools: cli.agent ? agentTools : undefined,
133 | });
134 |
135 | startStdio(server, context).catch((err) => {
136 | console.error("Server error:", err);
137 | // ensure we've flushed all events
138 | Sentry.flush(SENTRY_TIMEOUT);
139 | process.exit(1);
140 | });
141 |
142 | // ensure we've flushed all events
143 | Sentry.flush(SENTRY_TIMEOUT);
144 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/cli/parse.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { parseArgs } from "node:util";
2 | import type { CliArgs, EnvArgs, MergedArgs } from "./types";
3 |
4 | export function parseArgv(argv: string[]): CliArgs {
5 | const options = {
6 | "access-token": { type: "string" as const },
7 | host: { type: "string" as const },
8 | url: { type: "string" as const },
9 | "mcp-url": { type: "string" as const },
10 | "sentry-dsn": { type: "string" as const },
11 | "openai-base-url": { type: "string" as const },
12 | "organization-slug": { type: "string" as const },
13 | "project-slug": { type: "string" as const },
14 | scopes: { type: "string" as const },
15 | "add-scopes": { type: "string" as const },
16 | "all-scopes": { type: "boolean" as const },
17 | agent: { type: "boolean" as const },
18 | help: { type: "boolean" as const, short: "h" as const },
19 | version: { type: "boolean" as const, short: "v" as const },
20 | };
21 |
22 | const { values, positionals, tokens } = parseArgs({
23 | args: argv,
24 | options,
25 | allowPositionals: false,
26 | strict: false,
27 | tokens: true,
28 | });
29 |
30 | const knownLong = new Set(Object.keys(options));
31 | const knownShort = new Set([
32 | ...(Object.values(options)
33 | .map((o) => ("short" in o ? (o.short as string | undefined) : undefined))
34 | .filter(Boolean) as string[]),
35 | ]);
36 |
37 | const unknownArgs: string[] = [];
38 | for (const t of (tokens as any[]) || []) {
39 | if (t.kind === "option") {
40 | const name = t.name as string | undefined;
41 | if (name && !(knownLong.has(name) || knownShort.has(name))) {
42 | unknownArgs.push((t.raw as string) ?? `--${name}`);
43 | }
44 | } else if (t.kind === "positional") {
45 | unknownArgs.push((t.raw as string) ?? String(t.value ?? ""));
46 | }
47 | }
48 |
49 | return {
50 | accessToken: values["access-token"] as string | undefined,
51 | host: values.host as string | undefined,
52 | url: values.url as string | undefined,
53 | mcpUrl: values["mcp-url"] as string | undefined,
54 | sentryDsn: values["sentry-dsn"] as string | undefined,
55 | openaiBaseUrl: values["openai-base-url"] as string | undefined,
56 | organizationSlug: values["organization-slug"] as string | undefined,
57 | projectSlug: values["project-slug"] as string | undefined,
58 | scopes: values.scopes as string | undefined,
59 | addScopes: values["add-scopes"] as string | undefined,
60 | allScopes: (values["all-scopes"] as boolean | undefined) === true,
61 | agent: (values.agent as boolean | undefined) === true,
62 | help: (values.help as boolean | undefined) === true,
63 | version: (values.version as boolean | undefined) === true,
64 | unknownArgs:
65 | unknownArgs.length > 0 ? unknownArgs : (positionals as string[]) || [],
66 | };
67 | }
68 |
69 | export function parseEnv(env: NodeJS.ProcessEnv): EnvArgs {
70 | const fromEnv: EnvArgs = {};
71 | if (env.SENTRY_ACCESS_TOKEN) fromEnv.accessToken = env.SENTRY_ACCESS_TOKEN;
72 | if (env.SENTRY_URL) fromEnv.url = env.SENTRY_URL;
73 | if (env.SENTRY_HOST) fromEnv.host = env.SENTRY_HOST;
74 | if (env.MCP_URL) fromEnv.mcpUrl = env.MCP_URL;
75 | if (env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN)
76 | fromEnv.sentryDsn = env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN;
77 | if (env.MCP_SCOPES) fromEnv.scopes = env.MCP_SCOPES;
78 | if (env.MCP_ADD_SCOPES) fromEnv.addScopes = env.MCP_ADD_SCOPES;
79 | return fromEnv;
80 | }
81 |
82 | export function merge(cli: CliArgs, env: EnvArgs): MergedArgs {
83 | // CLI wins over env
84 | const merged: MergedArgs = {
85 | accessToken: cli.accessToken ?? env.accessToken,
86 | // If CLI provided url/host, prefer those; else fall back to env
87 | url: cli.url ?? env.url,
88 | host: cli.host ?? env.host,
89 | mcpUrl: cli.mcpUrl ?? env.mcpUrl,
90 | sentryDsn: cli.sentryDsn ?? env.sentryDsn,
91 | openaiBaseUrl: cli.openaiBaseUrl,
92 | // Scopes precedence: CLI scopes/add-scopes override their env counterparts
93 | scopes: cli.scopes ?? env.scopes,
94 | addScopes: cli.addScopes ?? env.addScopes,
95 | allScopes: cli.allScopes === true,
96 | agent: cli.agent === true,
97 | organizationSlug: cli.organizationSlug,
98 | projectSlug: cli.projectSlug,
99 | help: cli.help === true,
100 | version: cli.version === true,
101 | unknownArgs: cli.unknownArgs,
102 | };
103 |
104 | // If CLI provided scopes, ignore additive env var
105 | if (cli.scopes) merged.addScopes = cli.addScopes;
106 | // If CLI provided add-scopes, ensure scopes override isn't pulled from env
107 | if (cli.addScopes) merged.scopes = cli.scopes;
108 | return merged;
109 | }
110 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/get-doc.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import { setTag } from "@sentry/core";
3 | import { defineTool } from "../internal/tool-helpers/define";
4 | import { fetchWithTimeout } from "../internal/fetch-utils";
5 | import { UserInputError } from "../errors";
6 | import { ApiError } from "../api-client/index";
7 | import type { ServerContext } from "../types";
8 |
9 | export default defineTool({
10 | name: "get_doc",
11 | requiredScopes: ["docs"], // Documentation reading requires docs permission
12 | description: [
13 | "Fetch the full markdown content of a Sentry documentation page.",
14 | "",
15 | "Use this tool when you need to:",
16 | "- Read the complete documentation for a specific topic",
17 | "- Get detailed implementation examples or code snippets",
18 | "- Access the full context of a documentation page",
19 | "- Extract specific sections from documentation",
20 | "",
21 | "<examples>",
22 | "### Get the Next.js integration guide",
23 | "",
24 | "```",
25 | "get_doc(path='/platforms/javascript/guides/nextjs.md')",
26 | "```",
27 | "</examples>",
28 | "",
29 | "<hints>",
30 | "- Use the path from search_docs results for accurate fetching",
31 | "- Paths should end with .md extension",
32 | "</hints>",
33 | ].join("\n"),
34 | inputSchema: {
35 | path: z
36 | .string()
37 | .trim()
38 | .describe(
39 | "The documentation path (e.g., '/platforms/javascript/guides/nextjs.md'). Get this from search_docs results.",
40 | ),
41 | },
42 | annotations: {
43 | readOnlyHint: true,
44 | openWorldHint: true,
45 | },
46 | async handler(params, context: ServerContext) {
47 | setTag("doc.path", params.path);
48 |
49 | let output = `# Documentation Content\n\n`;
50 | output += `**Path**: ${params.path}\n\n`;
51 |
52 | // Validate path format
53 | if (!params.path.endsWith(".md")) {
54 | throw new UserInputError(
55 | "Invalid documentation path. Path must end with .md extension.",
56 | );
57 | }
58 |
59 | // Use docs.sentry.io for now - will be configurable via flag in the future
60 | const baseUrl = "https://docs.sentry.io";
61 |
62 | // Construct the full URL for the markdown file
63 | const docUrl = new URL(params.path, baseUrl);
64 |
65 | // Validate domain whitelist for security
66 | const allowedDomains = ["docs.sentry.io", "develop.sentry.io"];
67 | if (!allowedDomains.includes(docUrl.hostname)) {
68 | throw new UserInputError(
69 | `Invalid domain. Documentation can only be fetched from allowed domains: ${allowedDomains.join(", ")}`,
70 | );
71 | }
72 |
73 | const response = await fetchWithTimeout(
74 | docUrl.toString(),
75 | {
76 | headers: {
77 | Accept: "text/plain, text/markdown",
78 | "User-Agent": "Sentry-MCP/1.0",
79 | },
80 | },
81 | 15000, // 15 second timeout
82 | );
83 |
84 | if (!response.ok) {
85 | if (response.status === 404) {
86 | output += `**Error**: Documentation not found at this path.\n\n`;
87 | output += `Please verify the path is correct. Common issues:\n`;
88 | output += `- Path should start with / (e.g., /platforms/javascript/guides/nextjs.md)\n`;
89 | output += `- Path should match exactly what's shown in search_docs results\n`;
90 | output += `- Some pages may have been moved or renamed\n\n`;
91 | output += `Try searching again with \`search_docs()\` to find the correct path.\n`;
92 | return output;
93 | }
94 |
95 | throw new ApiError(
96 | `Failed to fetch documentation: ${response.statusText}`,
97 | response.status,
98 | );
99 | }
100 |
101 | const content = await response.text();
102 |
103 | // Check if we got HTML instead of markdown (wrong path format)
104 | if (
105 | content.trim().startsWith("<!DOCTYPE") ||
106 | content.trim().startsWith("<html")
107 | ) {
108 | output += `> **Error**: Received HTML instead of markdown. The path may be incorrect.\n\n`;
109 | output += `Make sure to use the .md extension in the path.\n`;
110 | output += `Example: /platforms/javascript/guides/nextjs.md\n`;
111 | return output;
112 | }
113 |
114 | // Add the markdown content
115 | output += "---\n\n";
116 | output += content;
117 | output += "\n\n---\n\n";
118 |
119 | output += "## Using this documentation\n\n";
120 | output +=
121 | "- This is the raw markdown content from Sentry's documentation\n";
122 | output +=
123 | "- Code examples and configuration snippets can be copied directly\n";
124 | output +=
125 | "- Links in the documentation are relative to https://docs.sentry.io\n";
126 | output +=
127 | "- For more related topics, use `search_docs()` to find additional pages\n";
128 |
129 | return output;
130 | },
131 | });
132 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/search-docs.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import { defineTool } from "../internal/tool-helpers/define";
3 | import { fetchWithTimeout } from "../internal/fetch-utils";
4 | import { ApiError } from "../api-client/index";
5 | import type { ServerContext } from "../types";
6 | import type { SearchResponse } from "./types";
7 | import { ParamSentryGuide } from "../schema";
8 |
9 | export default defineTool({
10 | name: "search_docs",
11 | requiredScopes: ["docs"], // Documentation search requires docs permission
12 | description: [
13 | "Search Sentry documentation for SDK setup, instrumentation, and configuration guidance.",
14 | "",
15 | "Use this tool when you need to:",
16 | "- Set up Sentry SDK or framework integrations (Django, Flask, Express, Next.js, etc.)",
17 | "- Configure features like performance monitoring, error sampling, or release tracking",
18 | "- Implement custom instrumentation (spans, transactions, breadcrumbs)",
19 | "- Configure data scrubbing, filtering, or sampling rules",
20 | "",
21 | "Returns snippets only. Use `get_doc(path='...')` to fetch full documentation content.",
22 | "",
23 | "<examples>",
24 | "```",
25 | "search_docs(query='Django setup configuration SENTRY_DSN', guide='python/django')",
26 | "search_docs(query='source maps webpack upload', guide='javascript/nextjs')",
27 | "```",
28 | "</examples>",
29 | "",
30 | "<hints>",
31 | "- Use guide parameter to filter to specific technologies (e.g., 'javascript/nextjs')",
32 | "- Include specific feature names like 'beforeSend', 'tracesSampleRate', 'SENTRY_DSN'",
33 | "</hints>",
34 | ].join("\n"),
35 | inputSchema: {
36 | query: z
37 | .string()
38 | .trim()
39 | .min(
40 | 2,
41 | "Search query is too short. Please provide at least 2 characters.",
42 | )
43 | .max(
44 | 200,
45 | "Search query is too long. Please keep your query under 200 characters.",
46 | )
47 | .describe(
48 | "The search query in natural language. Be specific about what you're looking for.",
49 | ),
50 | maxResults: z
51 | .number()
52 | .int()
53 | .min(1)
54 | .max(10)
55 | .default(3)
56 | .describe("Maximum number of results to return (1-10)")
57 | .optional(),
58 | guide: ParamSentryGuide.optional(),
59 | },
60 | annotations: {
61 | readOnlyHint: true,
62 | openWorldHint: true,
63 | },
64 | async handler(params, context: ServerContext) {
65 | let output = `# Documentation Search Results\n\n`;
66 | output += `**Query**: "${params.query}"\n`;
67 | if (params.guide) {
68 | output += `**Guide**: ${params.guide}\n`;
69 | }
70 | output += `\n`;
71 |
72 | // Determine the URL - use context.mcpUrl if available, otherwise default to production
73 | const host = context.mcpUrl || "https://mcp.sentry.dev";
74 | const searchUrl = new URL("/api/search", host);
75 |
76 | const response = await fetchWithTimeout(
77 | searchUrl.toString(),
78 | {
79 | method: "POST",
80 | headers: {
81 | "Content-Type": "application/json",
82 | },
83 | body: JSON.stringify({
84 | query: params.query,
85 | maxResults: params.maxResults,
86 | guide: params.guide,
87 | }),
88 | },
89 | 15000, // 15 second timeout
90 | );
91 |
92 | if (!response.ok) {
93 | // TODO: improve error responses with types
94 | const errorData = (await response.json().catch(() => null)) as {
95 | error?: string;
96 | } | null;
97 |
98 | const errorMessage =
99 | errorData?.error || `Search failed with status ${response.status}`;
100 | throw new ApiError(errorMessage, response.status);
101 | }
102 |
103 | const data = (await response.json()) as SearchResponse;
104 |
105 | // Handle error in response
106 | if ("error" in data && data.error) {
107 | output += `**Error**: ${data.error}\n\n`;
108 | return output;
109 | }
110 |
111 | // Display results
112 | if (data.results.length === 0) {
113 | output += "No documentation found matching your query.\n\n";
114 | return output;
115 | }
116 |
117 | output += `Found ${data.results.length} match${data.results.length === 1 ? "" : "es"}\n\n`;
118 |
119 | output += `These are just snippets. Use \`get_doc(path='...')\` to fetch the full content.\n\n`;
120 |
121 | for (const [index, result] of data.results.entries()) {
122 | output += `## ${index + 1}. ${result.url}\n\n`;
123 | output += `**Path**: ${result.id}\n`;
124 | output += `**Relevance**: ${(result.relevance * 100).toFixed(1)}%\n\n`;
125 | if (index < 3) {
126 | output += "**Matching Context**\n";
127 | output += `> ${result.snippet.replace(/\n/g, "\n> ")}\n\n`;
128 | }
129 | }
130 |
131 | return output;
132 | },
133 | });
134 |
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * MCP Handler using experimental_createMcpHandler from Cloudflare agents library.
3 | *
4 | * Stateless request handling approach:
5 | * - Uses experimental_createMcpHandler to wrap the MCP server
6 | * - Extracts auth props directly from ExecutionContext (set by OAuth provider)
7 | * - Context captured in tool handler closures during buildServer()
8 | * - No session state required - each request is independent
9 | */
10 |
11 | import * as Sentry from "@sentry/cloudflare";
12 | import { experimental_createMcpHandler as createMcpHandler } from "agents/mcp";
13 | import { buildServer } from "@sentry/mcp-server/server";
14 | import {
15 | expandScopes,
16 | parseScopes,
17 | type Scope,
18 | } from "@sentry/mcp-server/permissions";
19 | import { logWarn } from "@sentry/mcp-server/telem/logging";
20 | import type { ServerContext } from "@sentry/mcp-server/types";
21 | import type { Env } from "../types";
22 | import { verifyConstraintsAccess } from "./constraint-utils";
23 | import type { ExportedHandler } from "@cloudflare/workers-types";
24 | import agentTools from "@sentry/mcp-server/tools/agent-tools";
25 |
26 | /**
27 | * ExecutionContext with OAuth props injected by the OAuth provider.
28 | */
29 | type OAuthExecutionContext = ExecutionContext & {
30 | props?: Record<string, unknown>;
31 | };
32 |
33 | /**
34 | * Main request handler that:
35 | * 1. Extracts auth props from ExecutionContext
36 | * 2. Parses org/project constraints from URL
37 | * 3. Verifies user has access to the constraints
38 | * 4. Builds complete ServerContext
39 | * 5. Creates and configures MCP server per-request (context captured in closures)
40 | * 6. Runs MCP handler
41 | */
42 | const mcpHandler: ExportedHandler<Env> = {
43 | async fetch(
44 | request: Request,
45 | env: Env,
46 | ctx: ExecutionContext,
47 | ): Promise<Response> {
48 | const url = new URL(request.url);
49 |
50 | // Parse constraints from URL pattern /mcp/:org?/:project?
51 | const pattern = new URLPattern({ pathname: "/mcp/:org?/:project?" });
52 | const result = pattern.exec(url);
53 |
54 | if (!result) {
55 | return new Response("Not found", { status: 404 });
56 | }
57 |
58 | const { groups } = result.pathname;
59 | const organizationSlug = groups?.org || null;
60 | const projectSlug = groups?.project || null;
61 |
62 | // Check for agent mode query parameter
63 | const isAgentMode = url.searchParams.get("agent") === "1";
64 |
65 | // Extract OAuth props from ExecutionContext (set by OAuth provider)
66 | const oauthCtx = ctx as OAuthExecutionContext;
67 | if (!oauthCtx.props) {
68 | throw new Error("No authentication context available");
69 | }
70 |
71 | const sentryHost = env.SENTRY_HOST || "sentry.io";
72 |
73 | // Verify user has access to the requested org/project
74 | const verification = await verifyConstraintsAccess(
75 | { organizationSlug, projectSlug },
76 | {
77 | accessToken: oauthCtx.props.accessToken as string,
78 | sentryHost,
79 | },
80 | );
81 |
82 | if (!verification.ok) {
83 | return new Response(verification.message, {
84 | status: verification.status ?? 500,
85 | });
86 | }
87 |
88 | // Parse and expand granted scopes
89 | let expandedScopes: Set<Scope> | undefined;
90 | if (oauthCtx.props.grantedScopes) {
91 | const { valid, invalid } = parseScopes(
92 | oauthCtx.props.grantedScopes as string[],
93 | );
94 | if (invalid.length > 0) {
95 | logWarn("Ignoring invalid scopes from OAuth provider", {
96 | loggerScope: ["cloudflare", "mcp-handler"],
97 | extra: {
98 | invalidScopes: invalid,
99 | },
100 | });
101 | }
102 | expandedScopes = expandScopes(new Set(valid));
103 | }
104 |
105 | // Build complete ServerContext from OAuth props + verified constraints
106 | const serverContext: ServerContext = {
107 | userId: oauthCtx.props.id as string | undefined,
108 | clientId: oauthCtx.props.clientId as string,
109 | accessToken: oauthCtx.props.accessToken as string,
110 | grantedScopes: expandedScopes,
111 | constraints: verification.constraints,
112 | sentryHost,
113 | mcpUrl: env.MCP_URL,
114 | };
115 |
116 | // Create and configure MCP server with tools filtered by context
117 | // Context is captured in tool handler closures during buildServer()
118 | const server = buildServer({
119 | context: serverContext,
120 | tools: isAgentMode ? agentTools : undefined,
121 | onToolComplete: () => {
122 | // Flush Sentry events after tool execution
123 | Sentry.flush(2000);
124 | },
125 | });
126 |
127 | // Run MCP handler - context already captured in closures
128 | return createMcpHandler(server, {
129 | route: url.pathname,
130 | })(request, env, ctx);
131 | },
132 | };
133 |
134 | export default mcpHandler;
135 |
```
--------------------------------------------------------------------------------
/.github/workflows/smoke-tests.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Smoke Tests (Local)
2 |
3 | permissions:
4 | contents: read
5 | checks: write
6 |
7 | on:
8 | push:
9 | branches: [main]
10 | pull_request:
11 |
12 | jobs:
13 | smoke-tests:
14 | name: Run Smoke Tests Against Local Server
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 |
20 | - name: Setup Node.js
21 | uses: actions/setup-node@v4
22 | with:
23 | node-version: "20"
24 |
25 | # pnpm/action-setup@v4
26 | - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda
27 | name: Install pnpm
28 | with:
29 | run_install: false
30 |
31 | - name: Get pnpm store directory
32 | shell: bash
33 | run: |
34 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
35 |
36 | - uses: actions/cache@v4
37 | name: Setup pnpm cache
38 | with:
39 | path: ${{ env.STORE_PATH }}
40 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
41 | restore-keys: |
42 | ${{ runner.os }}-pnpm-store-
43 |
44 | - name: Install dependencies
45 | run: pnpm install --no-frozen-lockfile
46 |
47 | - name: Build
48 | run: pnpm build
49 |
50 | - name: Start local dev server
51 | working-directory: packages/mcp-cloudflare
52 | run: |
53 | # Start wrangler in background and capture output
54 | pnpm exec wrangler dev --port 8788 --local > wrangler.log 2>&1 &
55 | WRANGLER_PID=$!
56 | echo "WRANGLER_PID=$WRANGLER_PID" >> $GITHUB_ENV
57 | echo "Waiting for server to start (PID: $WRANGLER_PID)..."
58 |
59 | # Wait for server to be ready (up to 2 minutes)
60 | MAX_ATTEMPTS=24
61 | ATTEMPT=0
62 | while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
63 | # Check if wrangler process is still running
64 | if ! kill -0 $WRANGLER_PID 2>/dev/null; then
65 | echo "❌ Wrangler process died unexpectedly!"
66 | echo "📋 Last 50 lines of wrangler.log:"
67 | tail -50 wrangler.log
68 | exit 1
69 | fi
70 |
71 | if curl -s -f -o /dev/null http://localhost:8788/; then
72 | echo "✅ Server is ready!"
73 | echo "📋 Wrangler startup log:"
74 | cat wrangler.log
75 | break
76 | else
77 | echo "⏳ Waiting for server to start (attempt $((ATTEMPT+1))/$MAX_ATTEMPTS)..."
78 | # Show partial log every 5 attempts
79 | if [ $((ATTEMPT % 5)) -eq 0 ] && [ $ATTEMPT -gt 0 ]; then
80 | echo "📋 Current wrangler.log output:"
81 | tail -20 wrangler.log
82 | fi
83 | fi
84 |
85 | ATTEMPT=$((ATTEMPT+1))
86 | sleep 5
87 | done
88 |
89 | if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
90 | echo "❌ Server failed to start after $MAX_ATTEMPTS attempts"
91 | echo "📋 Full wrangler.log:"
92 | cat wrangler.log
93 | exit 1
94 | fi
95 |
96 | - name: Run smoke tests against local server
97 | env:
98 | PREVIEW_URL: http://localhost:8788
99 | working-directory: packages/smoke-tests
100 | run: |
101 | echo "🧪 Running smoke tests against local server at $PREVIEW_URL"
102 |
103 | # Give server a bit more time to stabilize after startup
104 | echo "⏳ Waiting 5 seconds for server to stabilize..."
105 | sleep 5
106 |
107 | # Verify server is still responding before running tests
108 | if ! curl -s -f -o /dev/null http://localhost:8788/; then
109 | echo "❌ Server is not responding before tests!"
110 | echo "📋 Wrangler log:"
111 | cat ../mcp-cloudflare/wrangler.log
112 | exit 1
113 | fi
114 |
115 | echo "✅ Server is responding, running tests..."
116 | pnpm test:ci || TEST_EXIT_CODE=$?
117 |
118 | # If tests failed, show server logs for debugging
119 | if [ "${TEST_EXIT_CODE:-0}" -ne 0 ]; then
120 | echo "❌ Tests failed with exit code ${TEST_EXIT_CODE}"
121 | echo "📋 Wrangler log at time of failure:"
122 | cat ../mcp-cloudflare/wrangler.log
123 | exit ${TEST_EXIT_CODE}
124 | fi
125 |
126 | - name: Publish Smoke Test Report
127 | uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857
128 | if: always()
129 | with:
130 | report_paths: "packages/smoke-tests/tests.junit.xml"
131 | check_name: "Local Smoke Test Results"
132 | fail_on_failure: true
133 |
134 | - name: Stop local server
135 | if: always()
136 | run: |
137 | if [ ! -z "$WRANGLER_PID" ]; then
138 | kill $WRANGLER_PID || true
139 | fi
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/routes/metadata.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * MCP Metadata API endpoint
3 | *
4 | * Provides immediate access to MCP server metadata including tools
5 | * without requiring a chat stream to be initialized.
6 | */
7 | import { Hono } from "hono";
8 | import { experimental_createMCPClient } from "ai";
9 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
10 | import type { Env } from "../types";
11 | import { logIssue, logWarn } from "@sentry/mcp-server/telem/logging";
12 | import type { ErrorResponse } from "../types/chat";
13 | import { analyzeAuthError, getAuthErrorResponse } from "../utils/auth-errors";
14 | import { z } from "zod";
15 |
16 | type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>;
17 |
18 | function createErrorResponse(errorResponse: ErrorResponse): ErrorResponse {
19 | return errorResponse;
20 | }
21 |
22 | export default new Hono<{ Bindings: Env }>().get("/", async (c) => {
23 | // Support cookie-based auth (preferred) with fallback to Authorization header
24 | let accessToken: string | null = null;
25 |
26 | // Try to read from signed cookie set during OAuth
27 | try {
28 | const { getCookie } = await import("hono/cookie");
29 | const authDataCookie = getCookie(c, "sentry_auth_data");
30 | if (authDataCookie) {
31 | const AuthDataSchema = z.object({ access_token: z.string() });
32 | const authData = AuthDataSchema.parse(JSON.parse(authDataCookie));
33 | accessToken = authData.access_token;
34 | }
35 | } catch {
36 | // Ignore cookie parse errors; we'll check header below
37 | }
38 |
39 | // Fallback to Authorization header if cookie is not present
40 | if (!accessToken) {
41 | const authHeader = c.req.header("Authorization");
42 | if (authHeader?.startsWith("Bearer ")) {
43 | accessToken = authHeader.substring(7);
44 | }
45 | }
46 |
47 | if (!accessToken) {
48 | return c.json(
49 | createErrorResponse({
50 | error: "Authorization required",
51 | name: "MISSING_AUTH_TOKEN",
52 | }),
53 | 401,
54 | );
55 | }
56 |
57 | // Declare mcpClient in outer scope for cleanup in catch block
58 | let mcpClient: MCPClient | undefined;
59 |
60 | try {
61 | // Get tools by connecting to MCP server
62 | let tools: string[] = [];
63 | try {
64 | const requestUrl = new URL(c.req.url);
65 | const mcpUrl = `${requestUrl.protocol}//${requestUrl.host}/mcp`;
66 |
67 | const httpTransport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
68 | requestInit: {
69 | headers: {
70 | Authorization: `Bearer ${accessToken}`,
71 | },
72 | },
73 | });
74 |
75 | mcpClient = await experimental_createMCPClient({
76 | name: "sentry",
77 | transport: httpTransport,
78 | });
79 |
80 | const mcpTools = await mcpClient.tools();
81 | tools = Object.keys(mcpTools);
82 | } catch (error) {
83 | // If we can't get tools, return empty array
84 | logWarn(error, {
85 | loggerScope: ["cloudflare", "metadata"],
86 | extra: {
87 | message: "Failed to fetch tools from MCP server",
88 | },
89 | });
90 | } finally {
91 | // Ensure the MCP client connection is properly closed to prevent hanging connections
92 | if (mcpClient && typeof mcpClient.close === "function") {
93 | try {
94 | await mcpClient.close();
95 | } catch (closeError) {
96 | logWarn(closeError, {
97 | loggerScope: ["cloudflare", "metadata"],
98 | extra: {
99 | message: "Failed to close MCP client connection",
100 | },
101 | });
102 | }
103 | }
104 | }
105 |
106 | // Return the metadata
107 | return c.json({
108 | type: "mcp-metadata",
109 | tools,
110 | timestamp: new Date().toISOString(),
111 | });
112 | } catch (error) {
113 | // Cleanup mcpClient if it was created
114 | if (mcpClient && typeof mcpClient.close === "function") {
115 | try {
116 | await mcpClient.close();
117 | } catch (closeError) {
118 | logWarn(closeError, {
119 | loggerScope: ["cloudflare", "metadata"],
120 | extra: {
121 | message: "Failed to close MCP client connection in error handler",
122 | },
123 | });
124 | }
125 | }
126 |
127 | logIssue(error, {
128 | loggerScope: ["cloudflare", "metadata"],
129 | extra: {
130 | message: "Metadata API error",
131 | },
132 | });
133 |
134 | // Check if this is an authentication error
135 | const authInfo = analyzeAuthError(error);
136 | if (authInfo.isAuthError) {
137 | return c.json(
138 | createErrorResponse(getAuthErrorResponse(authInfo)),
139 | authInfo.statusCode || (401 as any),
140 | );
141 | }
142 |
143 | const eventId = logIssue(error);
144 | return c.json(
145 | createErrorResponse({
146 | error: "Failed to fetch MCP metadata",
147 | name: "METADATA_FETCH_FAILED",
148 | eventId,
149 | }),
150 | 500,
151 | );
152 | }
153 | });
154 |
```