This is page 3 of 16. Use http://codebase.md/getsentry/sentry-mcp?page={x} to view the full context.
# Directory Structure
```
├── .claude
│ ├── agents
│ │ └── claude-optimizer.md
│ ├── commands
│ │ ├── gh-pr.md
│ │ └── gh-review.md
│ └── settings.json
├── .craft.yml
├── .cursor
│ └── mcp.json
├── .env.example
├── .github
│ └── workflows
│ ├── deploy.yml
│ ├── eval.yml
│ ├── merge-jobs.yml
│ ├── release.yml
│ ├── smoke-tests.yml
│ ├── test.yml
│ └── token-cost.yml
├── .gitignore
├── .mcp.json
├── .vscode
│ ├── extensions.json
│ ├── mcp.json
│ └── settings.json
├── AGENTS.md
├── benchmark-agent.sh
├── bin
│ └── bump-version.sh
├── biome.json
├── CLAUDE.md
├── codecov.yml
├── core
├── docs
│ ├── adding-tools.md
│ ├── api-patterns.md
│ ├── architecture.md
│ ├── cloudflare
│ │ ├── architecture.md
│ │ ├── oauth-architecture.md
│ │ └── overview.md
│ ├── coding-guidelines.md
│ ├── common-patterns.md
│ ├── error-handling.md
│ ├── github-actions.md
│ ├── llms
│ │ ├── document-scopes.md
│ │ ├── documentation-style-guide.md
│ │ └── README.md
│ ├── logging.md
│ ├── monitoring.md
│ ├── pr-management.md
│ ├── quality-checks.md
│ ├── README.md
│ ├── releases
│ │ ├── cloudflare.md
│ │ └── stdio.md
│ ├── search-events-api-patterns.md
│ ├── security.md
│ ├── specs
│ │ ├── README.md
│ │ ├── search-events.md
│ │ └── subpath-constraints.md
│ ├── testing-remote.md
│ ├── testing-stdio.md
│ ├── testing.md
│ └── token-cost-tracking.md
├── LICENSE.md
├── Makefile
├── package.json
├── packages
│ ├── mcp-cloudflare
│ │ ├── .env.example
│ │ ├── components.json
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── demo.cast
│ │ │ ├── favicon.ico
│ │ │ ├── flow-transparent.png
│ │ │ ├── flow.jpg
│ │ │ ├── keycap-⌘.png
│ │ │ ├── keycap-c.png
│ │ │ └── keycap-v.png
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── app.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── animation
│ │ │ │ │ │ ├── browser-ui
│ │ │ │ │ │ │ ├── BrowserWindow.tsx
│ │ │ │ │ │ │ ├── BrowserWindowIconSidebar.tsx
│ │ │ │ │ │ │ ├── DiffBlock.tsx
│ │ │ │ │ │ │ ├── IDEWindow.tsx
│ │ │ │ │ │ │ ├── IssueDetails.tsx
│ │ │ │ │ │ │ ├── keys-copy.tsx
│ │ │ │ │ │ │ ├── LoadingSquares.tsx
│ │ │ │ │ │ │ ├── RootCause.tsx
│ │ │ │ │ │ │ ├── seer-clipmask.tsx
│ │ │ │ │ │ │ ├── seer-noisefilter.tsx
│ │ │ │ │ │ │ ├── seer.tsx
│ │ │ │ │ │ │ └── WindowHeader.tsx
│ │ │ │ │ │ ├── BrowserAnimation.tsx
│ │ │ │ │ │ ├── DataWire.tsx
│ │ │ │ │ │ ├── dracula.css
│ │ │ │ │ │ ├── terminal-ui
│ │ │ │ │ │ │ ├── keys-paste.tsx
│ │ │ │ │ │ │ ├── SpeedDisplay.tsx
│ │ │ │ │ │ │ └── StepsList.tsx
│ │ │ │ │ │ ├── TerminalAnimation.tsx
│ │ │ │ │ │ └── tests.tsx
│ │ │ │ │ ├── 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
│ │ │ │ │ ├── docs
│ │ │ │ │ │ └── toc.tsx
│ │ │ │ │ ├── fragments
│ │ │ │ │ │ ├── install-tabs.tsx
│ │ │ │ │ │ ├── remote-setup.tsx
│ │ │ │ │ │ ├── setup-guide.tsx
│ │ │ │ │ │ └── stdio-setup.tsx
│ │ │ │ │ ├── getting-started.tsx
│ │ │ │ │ ├── hero
│ │ │ │ │ │ ├── header-divider.tsx
│ │ │ │ │ │ └── hero-block.tsx
│ │ │ │ │ ├── home-layout
│ │ │ │ │ │ ├── footer.tsx
│ │ │ │ │ │ └── sidebars.tsx
│ │ │ │ │ ├── ui
│ │ │ │ │ │ ├── accordion.tsx
│ │ │ │ │ │ ├── backdrop.tsx
│ │ │ │ │ │ ├── badge.tsx
│ │ │ │ │ │ ├── base.tsx
│ │ │ │ │ │ ├── button.tsx
│ │ │ │ │ │ ├── code-snippet.tsx
│ │ │ │ │ │ ├── header.tsx
│ │ │ │ │ │ ├── icon.tsx
│ │ │ │ │ │ ├── icons
│ │ │ │ │ │ │ ├── gemini.tsx
│ │ │ │ │ │ │ └── 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
│ │ │ │ │ └── usecases
│ │ │ │ │ ├── fix-bugs.tsx
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── instrument.tsx
│ │ │ │ │ ├── search-things.tsx
│ │ │ │ │ └── search-visual.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
│ │ │ │ ├── utils
│ │ │ │ │ ├── chat-error-handler.ts
│ │ │ │ │ ├── cursor-deeplink.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
│ │ │ │ ├── client-ip.test.ts
│ │ │ │ ├── client-ip.ts
│ │ │ │ ├── rate-limiter.test.ts
│ │ │ │ └── rate-limiter.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-core
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── scripts
│ │ │ ├── generate-definitions.ts
│ │ │ ├── generate-otel-namespaces.ts
│ │ │ ├── measure-token-cost.ts
│ │ │ └── validate-skills-mapping.ts
│ │ ├── src
│ │ │ ├── api-client
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── client.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.test.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── types.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── internal
│ │ │ │ ├── agents
│ │ │ │ │ ├── callEmbeddedAgent.ts
│ │ │ │ │ ├── openai-provider.test.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.ts
│ │ │ ├── skillDefinitions.json
│ │ │ ├── skillDefinitions.ts
│ │ │ ├── skills.test.ts
│ │ │ ├── skills.ts
│ │ │ ├── telem
│ │ │ │ ├── index.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── sentry.test.ts
│ │ │ │ └── sentry.ts
│ │ │ ├── test-setup.ts
│ │ │ ├── test-utils
│ │ │ │ └── context.ts
│ │ │ ├── toolDefinitions.json
│ │ │ ├── toolDefinitions.ts
│ │ │ ├── tools
│ │ │ │ ├── analyze-issue-with-seer.test.ts
│ │ │ │ ├── analyze-issue-with-seer.ts
│ │ │ │ ├── create-dsn.test.ts
│ │ │ │ ├── create-dsn.ts
│ │ │ │ ├── create-project.test.ts
│ │ │ │ ├── create-project.ts
│ │ │ │ ├── create-team.test.ts
│ │ │ │ ├── create-team.ts
│ │ │ │ ├── find-dsns.test.ts
│ │ │ │ ├── find-dsns.ts
│ │ │ │ ├── find-organizations.test.ts
│ │ │ │ ├── find-organizations.ts
│ │ │ │ ├── find-projects.test.ts
│ │ │ │ ├── find-projects.ts
│ │ │ │ ├── find-releases.test.ts
│ │ │ │ ├── find-releases.ts
│ │ │ │ ├── find-teams.test.ts
│ │ │ │ ├── find-teams.ts
│ │ │ │ ├── get-doc.test.ts
│ │ │ │ ├── get-doc.ts
│ │ │ │ ├── get-event-attachment.test.ts
│ │ │ │ ├── get-event-attachment.ts
│ │ │ │ ├── get-issue-details.test.ts
│ │ │ │ ├── get-issue-details.ts
│ │ │ │ ├── get-trace-details.test.ts
│ │ │ │ ├── get-trace-details.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── search-docs.test.ts
│ │ │ │ ├── search-docs.ts
│ │ │ │ ├── search-events
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── utils.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── search-events.test.ts
│ │ │ │ ├── search-issues
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── README.md
│ │ │ │ ├── tools.test.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── update-issue.test.ts
│ │ │ │ ├── update-issue.ts
│ │ │ │ ├── update-project.test.ts
│ │ │ │ ├── update-project.ts
│ │ │ │ ├── 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
│ │ │ ├── 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
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── cli
│ │ │ │ ├── parse.test.ts
│ │ │ │ ├── parse.ts
│ │ │ │ ├── resolve.test.ts
│ │ │ │ ├── resolve.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── usage.ts
│ │ │ ├── index.ts
│ │ │ └── transports
│ │ │ └── stdio.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.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
│ │ │ │ ├── csp-event.json
│ │ │ │ ├── csp-issue.json
│ │ │ │ ├── default-event.json
│ │ │ │ ├── event-attachments.json
│ │ │ │ ├── event.json
│ │ │ │ ├── generic-event.json
│ │ │ │ ├── issue.json
│ │ │ │ ├── performance-event.json
│ │ │ │ ├── performance-issue.json
│ │ │ │ ├── project.json
│ │ │ │ ├── regressed-issue.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
│ │ │ │ ├── unknown-event.json
│ │ │ │ └── unsupported-issue.json
│ │ │ ├── fixtures.ts
│ │ │ ├── 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/transports/stdio.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Standard I/O Transport for MCP Server.
*
* Provides stdio-based communication for the Sentry MCP server, typically used
* when the server runs as a subprocess communicating via stdin/stdout pipes.
*
* @example Basic Usage
* ```typescript
* import { Server } from "@modelcontextprotocol/sdk/server/index.js";
* import { startStdio } from "./transports/stdio.js";
*
* const server = new Server();
* const context = {
* accessToken: process.env.SENTRY_TOKEN,
* host: "sentry.io"
* };
*
* await startStdio(server, context);
* ```
*/
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import * as Sentry from "@sentry/node";
import { LIB_VERSION } from "@sentry/mcp-core/version";
import type { ServerContext } from "@sentry/mcp-core/types";
/**
* Starts the MCP server with stdio transport and telemetry.
*
* Connects the server using stdio transport for process-based communication.
* Context is already captured in tool handler closures during buildServer().
* All operations are wrapped in Sentry tracing for observability.
*
* @param server - Configured and instrumented MCP server instance (with context in closures)
* @param context - Server context with authentication and configuration (for telemetry attributes)
*
* @example CLI Integration
* ```typescript
* import { buildServer } from "./server.js";
* import { startStdio } from "./transports/stdio.js";
*
* const context = {
* accessToken: userToken,
* sentryHost: "sentry.io",
* userId: "user-123",
* clientId: "cursor-ide",
* constraints: {}
* };
*
* const server = buildServer({ context }); // Context captured in closures
* await startStdio(server, context);
* ```
*/
export async function startStdio(server: McpServer, context: ServerContext) {
await Sentry.startNewTrace(async () => {
return await Sentry.startSpan(
{
name: "mcp.server/stdio",
attributes: {
"mcp.transport": "stdio",
"network.transport": "pipe",
"service.version": LIB_VERSION,
},
},
async () => {
// Context already captured in tool handler closures during buildServer()
const transport = new StdioServerTransport();
await server.connect(transport);
},
);
});
}
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/tools/find-teams.ts:
--------------------------------------------------------------------------------
```typescript
import { setTag } from "@sentry/core";
import { defineTool } from "../internal/tool-helpers/define";
import { apiServiceFromContext } from "../internal/tool-helpers/api";
import { UserInputError } from "../errors";
import type { ServerContext } from "../types";
import {
ParamOrganizationSlug,
ParamRegionUrl,
ParamSearchQuery,
} from "../schema";
const RESULT_LIMIT = 25;
export default defineTool({
name: "find_teams",
skills: ["inspect", "triage", "project-management"], // Team viewing and management
requiredScopes: ["team:read"],
description: [
"Find teams in an organization in Sentry.",
"",
"Use this tool when you need to:",
"- View teams in a Sentry organization",
"- Find a team's slug to aid other tool requests",
"- Search for specific teams by name or slug",
"",
`Returns up to ${RESULT_LIMIT} results. If you hit this limit, use the query parameter to narrow down results.`,
].join("\n"),
inputSchema: {
organizationSlug: ParamOrganizationSlug,
regionUrl: ParamRegionUrl.nullable().default(null),
query: ParamSearchQuery.nullable().default(null),
},
annotations: {
readOnlyHint: true,
openWorldHint: true,
},
async handler(params, context: ServerContext) {
const apiService = apiServiceFromContext(context, {
regionUrl: params.regionUrl ?? undefined,
});
const organizationSlug = params.organizationSlug;
if (!organizationSlug) {
throw new UserInputError(
"Organization slug is required. Please provide an organizationSlug parameter.",
);
}
setTag("organization.slug", organizationSlug);
const teams = await apiService.listTeams(organizationSlug, {
query: params.query ?? undefined,
});
let output = `# Teams in **${organizationSlug}**\n\n`;
if (params.query) {
output += `**Search query:** "${params.query}"\n\n`;
}
if (teams.length === 0) {
output += params.query
? `No teams found matching "${params.query}".\n`
: "No teams found.\n";
return output;
}
output += teams.map((team) => `- ${team.slug}\n`).join("");
if (teams.length === RESULT_LIMIT) {
output += `\n---\n\n**Note:** Showing ${RESULT_LIMIT} results (maximum). There may be more teams available. Use the \`query\` parameter to search for specific teams.`;
}
return output;
},
});
```
--------------------------------------------------------------------------------
/docs/github-actions.md:
--------------------------------------------------------------------------------
```markdown
# GitHub Actions
CI/CD workflows for the Sentry MCP project.
## Workflows
### test.yml
Runs on all pushes to main and pull requests:
- Build, lint, unit tests
- Code coverage reporting
### deploy.yml
Runs after tests pass on main branch:
- **Canary deployment**: Deploy to `sentry-mcp-canary` worker with isolated resources
- **Smoke tests**: Test canary deployment
- **Production deployment**: Deploy to `sentry-mcp` worker (only if canary tests pass)
- **Production smoke tests**: Test production deployment
- **Automatic rollback**: Rollback production if smoke tests fail
### eval.yml
Runs evaluation tests against the MCP server.
## Required Secrets
Repository secrets (no environment needed):
- **`CLOUDFLARE_API_TOKEN`** - Cloudflare API token with Workers deployment permissions
- **`CLOUDFLARE_ACCOUNT_ID`** - Your Cloudflare account ID
- **`SENTRY_AUTH_TOKEN`** - For Sentry release tracking
- **`SENTRY_CLIENT_SECRET`** - Sentry OAuth client secret
- **`COOKIE_SECRET`** - Session cookie encryption secret
- **`OPENAI_API_KEY`** - For AI-powered search features
## Deployment Architecture
### Workers
- **`sentry-mcp`** - Production worker at `https://mcp.sentry.dev`
- **`sentry-mcp-canary`** - Canary worker at `https://sentry-mcp-canary.getsentry.workers.dev`
### Resource Isolation
Canary and production use separate resources for complete isolation:
| Resource | Production | Canary |
|----------|------------|---------|
| KV Namespace | `8dd5e9bafe1945298e2d5ca3b408a553` | `a3fe0d23b2d34416930e284362a88a3b` |
| Rate Limiter IDs | `1001`, `1002` | `2001`, `2002` |
| Wrangler Config | `wrangler.jsonc` | `wrangler.canary.jsonc` |
### Deployment Flow
1. **Build once** - Single build for both deployments
2. **Deploy canary** - `wrangler deploy --config wrangler.canary.jsonc`
3. **Wait 30s** - Allow propagation
4. **Test canary** - Run smoke tests against canary worker
5. **Deploy production** - `wrangler deploy` (only if canary tests pass)
6. **Wait 30s** - Allow propagation
7. **Test production** - Run smoke tests against production worker
8. **Rollback** - `wrangler rollback` if production tests fail
## Manual Deployment
Trigger via GitHub Actions → Deploy to Cloudflare → "Run workflow"
## Troubleshooting
1. **Authentication failed** - Check `CLOUDFLARE_API_TOKEN` permissions
2. **Build failures** - Review TypeScript/build logs
3. **Smoke test failures** - Check worker logs in Cloudflare dashboard
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/tools/find-projects.ts:
--------------------------------------------------------------------------------
```typescript
import { setTag } from "@sentry/core";
import { defineTool } from "../internal/tool-helpers/define";
import { apiServiceFromContext } from "../internal/tool-helpers/api";
import { UserInputError } from "../errors";
import type { ServerContext } from "../types";
import {
ParamOrganizationSlug,
ParamRegionUrl,
ParamSearchQuery,
} from "../schema";
import { ALL_SKILLS } from "../skills";
const RESULT_LIMIT = 25;
export default defineTool({
name: "find_projects",
skills: ALL_SKILLS, // Foundational tool - available to all skills
requiredScopes: ["project:read"],
description: [
"Find projects in Sentry.",
"",
"Use this tool when you need to:",
"- View projects in a Sentry organization",
"- Find a project's slug to aid other tool requests",
"- Search for specific projects by name or slug",
"",
`Returns up to ${RESULT_LIMIT} results. If you hit this limit, use the query parameter to narrow down results.`,
].join("\n"),
inputSchema: {
organizationSlug: ParamOrganizationSlug,
regionUrl: ParamRegionUrl.nullable().default(null),
query: ParamSearchQuery.nullable().default(null),
},
annotations: {
readOnlyHint: true,
openWorldHint: true,
},
async handler(params, context: ServerContext) {
const apiService = apiServiceFromContext(context, {
regionUrl: params.regionUrl ?? undefined,
});
const organizationSlug = params.organizationSlug;
if (!organizationSlug) {
throw new UserInputError(
"Organization slug is required. Please provide an organizationSlug parameter.",
);
}
setTag("organization.slug", organizationSlug);
const projects = await apiService.listProjects(organizationSlug, {
query: params.query ?? undefined,
});
let output = `# Projects in **${organizationSlug}**\n\n`;
if (params.query) {
output += `**Search query:** "${params.query}"\n\n`;
}
if (projects.length === 0) {
output += params.query
? `No projects found matching "${params.query}".\n`
: "No projects found.\n";
return output;
}
output += projects.map((project) => `- **${project.slug}**\n`).join("");
if (projects.length === RESULT_LIMIT) {
output += `\n---\n\n**Note:** Showing ${RESULT_LIMIT} results (maximum). There may be more projects available. Use the \`query\` parameter to search for specific projects.`;
}
return output;
},
});
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/animation/browser-ui/keys-copy.tsx:
--------------------------------------------------------------------------------
```typescript
export default function KeysCopy({ step }: { step?: number }) {
return (
<div className="-translate-1/2 absolute top-1/2 left-1/2 z-10 flex items-center gap-3 pointer-events-none">
<div
className={`${
step === 0 && "animate-keycap"
} relative size-fit opacity-0 translate-x-8 -translate-y-4`}
style={{ ["--delay" as any]: "1.00s" }}
>
{/* <div className="absolute bottom-0 left-1/2 h-[69%] w-[72.5%] -translate-x-[51%] rotate-x-50 rotate-z-27 rounded-3xl rounded-br-2xl bg-background" /> */}
<div className="[mask-image:url('/keycap-⌘.png')] [mask-size:100%] bg-clip-content size-fit relative [filter:drop-shadow(inset_0_-1rem_1rem_rgba(0,0,0,1))] translate-y-2">
<div className="absolute bottom-0 left-1/2 h-[69%] w-[75%] -translate-x-[51%] rotate-x-50 rotate-z-27 rounded-3xl rounded-br-2xl rounded-tr-2xl bg-background/50 -z-10" />
<img
className={`${
step === 0 && "animate-keycap-inner-meta"
} bg-clip-content select-none`}
src="/keycap-⌘.png"
draggable={false}
alt="keycap-⌘"
style={{ ["--delay" as any]: "1.20s" }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-30% via-transparent to-transparent z-10" />
</div>
</div>
<div
className={`${
step === 0 && "animate-keycap"
} relative size-fit opacity-0 -translate-x-8 translate-y-4`}
style={{ ["--delay" as any]: "1.15s" }}
>
{/* <div className="absolute bottom-0 left-1/2 h-[69%] w-[72.5%] -translate-x-[51%] rotate-x-50 rotate-z-27 rounded-3xl rounded-br-2xl bg-background" /> */}
<div className="[mask-image:url('/keycap-c.png')] [mask-size:100%] bg-clip-content size-fit relative [filter:drop-shadow(inset_0_-1rem_1rem_rgba(0,0,0,1))] translate-y-2">
<div className="absolute bottom-0 left-1/2 h-[69%] w-[72.5%] -translate-x-[51%] rotate-x-50 rotate-z-27 rounded-3xl rounded-br-2xl bg-background/50 -z-10" />
<img
className={`${
step === 0 && "animate-keycap-inner"
} bg-clip-content select-none`}
src="/keycap-c.png"
draggable={false}
alt="keycap-c"
style={{ ["--delay" as any]: "1.35s" }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-30% via-transparent to-transparent z-10" />
</div>
</div>
</div>
);
}
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/animation/terminal-ui/keys-paste.tsx:
--------------------------------------------------------------------------------
```typescript
export default function KeysPaste({ step }: { step?: number }) {
return (
<div className="-translate-1/2 absolute top-1/2 left-1/2 z-50 hidden items-center gap-3 md:flex pointer-events-none">
<div
className={`${
step === 0 && "animate-keycap"
} relative size-fit opacity-0 translate-x-8 -translate-y-4`}
style={{ ["--delay" as any]: "1.75s" }}
>
{/* <div className="absolute bottom-0 left-1/2 h-[69%] w-[72.5%] -translate-x-[51%] rotate-x-50 rotate-z-27 rounded-3xl rounded-br-2xl bg-background" /> */}
<div className="[mask-image:url('/keycap-⌘.png')] [mask-size:100%] bg-clip-content size-fit relative [filter:drop-shadow(inset_0_-1rem_1rem_rgba(0,0,0,1))] translate-y-2">
<div className="absolute bottom-0 left-1/2 h-[69%] w-[75%] -translate-x-[51%] rotate-x-50 rotate-z-27 rounded-3xl rounded-br-2xl rounded-tr-2xl bg-background/50 -z-10" />
<img
className={`${
step === 0 && "animate-keycap-inner-meta"
} bg-clip-content select-none`}
src="/keycap-⌘.png"
draggable={false}
alt="keycap-⌘"
style={{ ["--delay" as any]: "2.05s" }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-30% via-transparent to-transparent z-10" />
</div>
</div>
<div
className={`${
step === 0 && "animate-keycap"
} relative size-fit opacity-0 -translate-x-8 translate-y-4`}
style={{ ["--delay" as any]: "2.00s" }}
>
{/* <div className="absolute bottom-0 left-1/2 h-[69%] w-[72.5%] -translate-x-[51%] rotate-x-50 rotate-z-27 rounded-3xl rounded-br-2xl bg-background" /> */}
<div className="[mask-image:url('/keycap-v.png')] [mask-size:100%] bg-clip-content size-fit relative [filter:drop-shadow(inset_0_-1rem_1rem_rgba(0,0,0,1))] translate-y-2">
<div className="absolute bottom-0 left-1/2 h-[69%] w-[72.5%] -translate-x-[51%] rotate-x-50 rotate-z-27 rounded-3xl rounded-br-2xl bg-background/50 -z-10" />
<img
className={`${
step === 0 && "animate-keycap-inner"
} bg-clip-content select-none`}
src="/keycap-v.png"
draggable={false}
alt="keycap-v"
style={{ ["--delay" as any]: "2.30s" }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-30% via-transparent to-transparent z-10" />
</div>
</div>
</div>
);
}
```
--------------------------------------------------------------------------------
/docs/quality-checks.md:
--------------------------------------------------------------------------------
```markdown
# Quality Checks
Required quality checks that MUST pass before completing any code changes.
## Critical Quality Checks
**After ANY code changes, you MUST run:**
```bash
pnpm -w run lint:fix # Fix linting issues
pnpm tsc --noEmit # Check TypeScript types
pnpm test # Run all tests
```
**DO NOT proceed if any check fails.**
## Tool Testing Requirements
**ALL tools MUST have comprehensive tests that verify:**
- **Input validation** - Required/optional parameters, type checking, edge cases
- **Output formatting** - Markdown structure, content accuracy, error messages
- **API integration** - Mock server responses, error handling, parameter passing
- **Snapshot testing** - Use inline snapshots to verify formatted output
**Required test patterns:**
- Unit tests in individual `{tool-name}.test.ts` files using Vitest and MSW mocks
- Input/output validation with inline snapshots
- Error case testing (API failures, invalid params)
- Mock server setup in `packages/mcp-server-mocks`
See `docs/testing.md` for detailed testing patterns and `docs/adding-tools.md` for the testing workflow.
## Tool Count Limits
**IMPORTANT**: AI agents have a hard cap of 45 total tools. Sentry MCP must:
- Target ~20 tools (current best practice)
- Never exceed 25 tools (absolute maximum)
- This limit exists in Cursor and possibly other tools
**Current status**: 19 tools (within target range)
## Build Verification
Ensure the build process works correctly:
```bash
npm run build # Build all packages
npm run generate-tool-definitions # Generate tool definitions
```
Tool definitions must generate without errors for client consumption.
## Code Quality Standards
- **TypeScript strict mode** - All code must compile without errors
- **Linting compliance** - Follow established code style patterns
- **Test coverage** - All new tools must have comprehensive tests
- **Error handling** - Use patterns from `common-patterns.md#error-handling`
- **API patterns** - Follow patterns from `api-patterns.md`
## Pre-Commit Checklist
Before completing any task:
- [ ] All quality checks pass (`pnpm -w run lint:fix`, `pnpm tsc --noEmit`, `pnpm test`)
- [ ] Tool count within limits (≤20 target, ≤25 absolute max)
- [ ] New tools have comprehensive tests
- [ ] Build process generates tool definitions successfully
- [ ] Documentation updated if patterns changed
## References
- Testing patterns: `testing.md`
- Tool development: `adding-tools.md`
- Code patterns: `common-patterns.md`
- API usage: `api-patterns.md`
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/oauth/state.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from "zod";
// Minimal, stateless HMAC-signed OAuth state utilities
// Format: `${signatureHex}.${base64(payloadJson)}`
// Safe envelope: keep the full downstream AuthRequest (+permissions) under `req`
// and include only iat/exp metadata at top-level to avoid collisions.
export const OAuthStateSchema = z.object({
req: z.record(z.unknown()),
iat: z.number().int(),
exp: z.number().int(),
});
export type OAuthState = z.infer<typeof OAuthStateSchema> & {
req: Record<string, unknown>;
};
async function importKey(secret: string): Promise<CryptoKey> {
if (!secret) {
throw new Error(
"COOKIE_SECRET is not defined. A secret key is required for signing state.",
);
}
const enc = new TextEncoder();
return crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
}
async function signHex(key: CryptoKey, data: string): Promise<string> {
const enc = new TextEncoder();
const signatureBuffer = await crypto.subtle.sign(
"HMAC",
key,
enc.encode(data),
);
return Array.from(new Uint8Array(signatureBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function verifyHex(
key: CryptoKey,
signatureHex: string,
data: string,
): Promise<boolean> {
try {
const enc = new TextEncoder();
const signatureBytes = new Uint8Array(
signatureHex.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16)),
);
return await crypto.subtle.verify(
"HMAC",
key,
signatureBytes.buffer,
enc.encode(data),
);
} catch {
return false;
}
}
export async function signState(
payload: OAuthState,
secret: string,
): Promise<string> {
const key = await importKey(secret);
const json = JSON.stringify(payload);
const sig = await signHex(key, json);
// Using standard base64 to match other usage in the codebase
const b64 = btoa(json);
return `${sig}.${b64}`;
}
export async function verifyAndParseState(
compact: string,
secret: string,
): Promise<OAuthState> {
const [sig, b64] = compact.split(".");
if (!sig || !b64) {
throw new Error("Invalid state format");
}
const json = atob(b64);
const key = await importKey(secret);
const ok = await verifyHex(key, sig, json);
if (!ok) {
throw new Error("Invalid state signature");
}
const parsed = OAuthStateSchema.parse(JSON.parse(json));
const now = Date.now();
if (parsed.exp <= now) {
throw new Error("State expired");
}
return parsed;
}
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/animation/browser-ui/WindowHeader.tsx:
--------------------------------------------------------------------------------
```typescript
import {
ArrowLeft,
ArrowRight,
ChevronLeft,
ChevronRight,
Copy,
Lock,
PanelBottom,
PanelLeft,
PanelRight,
Plus,
RotateCcw,
Search,
Settings,
Share,
} from "lucide-react";
export default function WindowHeader({
step,
ide = false,
}: {
step?: number;
ide?: boolean;
}) {
return (
<div
className={`flex items-center gap-2 max-w-full p-2 pr-2 pl-4 ${
ide && "pr-1"
}`}
>
<div className="size-3 flex-shrink-0 rounded-full border border-white/20 bg-pink-300/50" />
<div className="size-3 flex-shrink-0 rounded-full border border-white/20 bg-amber-300/50" />
<div className="mr-4 size-3 flex-shrink-0 rounded-full border border-white/20 bg-emerald-300/50" />
{/*<PanelLeft />*/}
{ide ? (
<>
<ArrowLeft className="size-5" />
<ArrowRight className="size-5" />
</>
) : (
<>
<ChevronLeft className="size-5" />
<ChevronRight className="size-5" />
</>
)}
{/*<ShieldCheck className="ml-auto"/>*/}
<div
className={`mx-auto flex items-center ${
ide && "justify-center"
} h-8 w-full max-w-1/2 cursor-pointer gap-2 rounded-xl border border-white/20 bg-white/5 p-3 duration-200 hover:bg-white/10 active:bg-white/50 active:duration-75`}
style={{ ["--delay" as any]: "0.45s" }}
>
{!ide && <Lock className="size-4 flex-shrink-0" />}
{ide && <Search className="size-4 flex-shrink-0" />}
<div className="truncate relative">
<div
className={`absolute w-full h-[1lh] bg-pink-500 -z-10 ${
step === 0
? "animate-select opacity-100"
: "opacity-0 duration-300"
}`}
/>
{ide
? "Search"
: "https://sentry.sentry.io/issues/6811213890/?environment=cloudflare&project=4509062593708032&query=is%3Aunresolved&referrer=issue-stream&seerDrawer=true"}
</div>
{!ide && <RotateCcw className="size-4 flex-shrink-0" />}
</div>
{ide ? (
<>
<PanelLeft className="ml-1 size-4" />
<PanelBottom className="ml-1 hidden size-4 2xl:block" />
<PanelRight className="ml-1 hidden size-4 2xl:block" />
<Settings className="mr-2 ml-1 size-4" />
</>
) : (
<>
<Share className="ml-2 size-4" />
<Plus className="ml-2 hidden size-4 2xl:block" />
<Copy className="mr-3 ml-2 size-4" />
</>
)}
</div>
);
}
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "sentry-mcp",
"version": "0.0.1",
"private": true,
"type": "module",
"packageManager": "[email protected]",
"engines": {
"node": ">=20"
},
"license": "FSL-1.1-ALv2",
"author": "Sentry",
"description": "Sentry MCP Server",
"homepage": "https://github.com/getsentry/sentry-mcp",
"keywords": ["sentry"],
"bugs": {
"url": "https://github.com/getsentry/sentry-mcp/issues"
},
"repository": {
"type": "git",
"url": "[email protected]:getsentry/sentry-mcp.git"
},
"scripts": {
"docs:check": "node scripts/check-doc-links.mjs",
"dev": "dotenv -e .env -e .env.local -- turbo dev --filter=!@sentry/mcp-server",
"dev:stdio": "dotenv -e .env -e .env.local -- turbo dev --filter=!@sentry/mcp-cloudflare",
"build": "turbo build after-build",
"deploy": "turbo deploy",
"eval": "dotenv -e .env -e .env.local -- turbo eval",
"eval:ci": "CI=true dotenv -e .env -e .env.local -- pnpm --stream -r run eval:ci",
"format": "biome format --write",
"lint": "biome lint",
"lint:fix": "biome lint --fix",
"inspector": "pnpx @modelcontextprotocol/inspector@latest",
"measure-tokens": "pnpm run --filter ./packages/mcp-core measure-tokens",
"prepare": "simple-git-hooks",
"cli": "pnpm run --filter ./packages/mcp-test-client start",
"start:stdio": "pnpm --stream run --filter ./packages/mcp-server start",
"test": "dotenv -e .env -e .env.local -- turbo test",
"test:ci": "CI=true dotenv -e .env -e .env.local -- pnpm --stream -r run test:ci",
"test:watch": "dotenv -e .env -e .env.local -- turbo test:watch",
"tsc": "turbo tsc"
},
"dependencies": {
"@biomejs/biome": "catalog:",
"@types/node": "catalog:",
"@vitest/coverage-v8": "catalog:",
"dotenv": "catalog:",
"dotenv-cli": "catalog:",
"lint-staged": "catalog:",
"simple-git-hooks": "catalog:",
"tsdown": "catalog:",
"tsx": "catalog:",
"turbo": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"vitest-evals": "catalog:"
},
"simple-git-hooks": {
"pre-commit": "pnpm exec lint-staged --concurrent false"
},
"lint-staged": {
"*": [
"biome format --write --no-errors-on-unmatched --files-ignore-unknown=true",
"biome lint --fix --no-errors-on-unmatched --files-ignore-unknown=true"
]
},
"pnpm": {
"onlyBuiltDependencies": [
"@biomejs/biome",
"better-sqlite3",
"esbuild",
"sharp",
"simple-git-hooks",
"workerd"
]
},
"devDependencies": {
"@types/json-schema": "^7.0.15"
}
}
```
--------------------------------------------------------------------------------
/scripts/check-doc-links.mjs:
--------------------------------------------------------------------------------
```
#!/usr/bin/env node
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
const root = resolve(process.cwd());
const docsDir = join(root, "docs");
/** Recursively collect docs files */
function walk(dir) {
const entries = readdirSync(dir);
const files = [];
for (const e of entries) {
const p = join(dir, e);
const s = statSync(p);
if (s.isDirectory()) files.push(...walk(p));
else if (e.endsWith(".md") || e.endsWith(".mdc")) files.push(p);
}
return files;
}
const files = walk(docsDir);
const problems = [];
for (const file of files) {
const rel = file.slice(root.length + 1);
const content = readFileSync(file, "utf8");
// Strip fenced code blocks to avoid false positives in examples
const contentNoFences = content.replace(/```[\s\S]*?```/g, "");
// 1) Flag local Markdown links like [text](./file.md) or [text](../file.mdc)
const localLinkRe = /\[[^\]]+\]\((\.\.?\/[^)]+)\)/g;
for (const m of contentNoFences.matchAll(localLinkRe)) {
// Skip illustrative placeholders
if (m[1].includes("...")) continue;
problems.push({
file: rel,
type: "local-markdown-link",
message: `Use @path for local docs instead of Markdown links: ${m[0]}`,
});
}
// 1b) Flag Markdown links that point to @path
const atMarkdownLinkRe = /\[[^\]]+\]\(@[^)]+\)/g;
for (const m of contentNoFences.matchAll(atMarkdownLinkRe)) {
problems.push({
file: rel,
type: "atpath-markdown-link",
message: `Do not wrap @paths in Markdown links: ${m[0]}`,
});
}
// 2) Validate @path references point to real files (only for clear file tokens)
// Matches @path segments with known extensions or obvious repo files
const atPathRe = /@([A-Za-z0-9_.\-\/]+\.(?:mdc|md|ts|tsx|js|json))/g;
for (const m of contentNoFences.matchAll(atPathRe)) {
const relPath = m[1];
const abs = join(root, relPath);
try {
const st = statSync(abs);
if (!st.isFile()) {
problems.push({
file: rel,
type: "missing-file",
message: `@${relPath} is not a file`,
});
}
} catch {
problems.push({
file: rel,
type: "missing-file",
message: `@${relPath} does not exist`,
});
}
}
}
if (problems.length) {
console.error("[docs:check] Problems found:\n");
for (const p of problems) {
console.error(`- ${p.type}: ${p.file} -> ${p.message}`);
}
process.exit(1);
} else {
console.log("[docs:check] OK: no local Markdown links and all @paths exist.");
}
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/tools/create-dsn.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from "zod";
import { setTag } from "@sentry/core";
import { defineTool } from "../internal/tool-helpers/define";
import { apiServiceFromContext } from "../internal/tool-helpers/api";
import type { ServerContext } from "../types";
import {
ParamOrganizationSlug,
ParamRegionUrl,
ParamProjectSlug,
} from "../schema";
export default defineTool({
name: "create_dsn",
skills: ["project-management"], // Only available in project-management skill
requiredScopes: ["project:write"],
description: [
"Create an additional DSN for an EXISTING project.",
"",
"USE THIS TOOL WHEN:",
"- Project already exists and needs additional DSN",
"- 'Create another DSN for project X'",
"- 'I need a production DSN for existing project'",
"",
"DO NOT USE for new projects (use create_project instead)",
"",
"Be careful when using this tool!",
"",
"<examples>",
"### Create additional DSN for existing project",
"```",
"create_dsn(organizationSlug='my-organization', projectSlug='my-project', name='Production')",
"```",
"</examples>",
"",
"<hints>",
"- If the user passes a parameter in the form of name/otherName, its likely in the format of <organizationSlug>/<projectSlug>.",
"- If any parameter is ambiguous, you should clarify with the user what they meant.",
"</hints>",
].join("\n"),
inputSchema: {
organizationSlug: ParamOrganizationSlug,
regionUrl: ParamRegionUrl.nullable().default(null),
projectSlug: ParamProjectSlug,
name: z
.string()
.trim()
.describe("The name of the DSN to create, for example 'Production'."),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: true,
},
async handler(params, context: ServerContext) {
const apiService = apiServiceFromContext(context, {
regionUrl: params.regionUrl ?? undefined,
});
const organizationSlug = params.organizationSlug;
setTag("organization.slug", organizationSlug);
setTag("project.slug", params.projectSlug);
const clientKey = await apiService.createClientKey({
organizationSlug,
projectSlug: params.projectSlug,
name: params.name,
});
let output = `# New DSN in **${organizationSlug}/${params.projectSlug}**\n\n`;
output += `**DSN**: ${clientKey.dsn.public}\n`;
output += `**Name**: ${clientKey.name}\n\n`;
output += "# Using this information\n\n";
output +=
"- The `SENTRY_DSN` value is a URL that you can use to initialize Sentry's SDKs.\n";
return output;
},
});
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/agents/openai-provider.ts:
--------------------------------------------------------------------------------
```typescript
import { createOpenAI } from "@ai-sdk/openai";
import type { LanguageModelV1 } from "ai";
import { USER_AGENT } from "../../version";
// Default configuration constants
const DEFAULT_OPENAI_MODEL = "gpt-5";
const VALID_REASONING_EFFORTS = ["low", "medium", "high"] as const;
const DEFAULT_REASONING_EFFORT: (typeof VALID_REASONING_EFFORTS)[number] =
"low";
type ReasoningEffort = (typeof VALID_REASONING_EFFORTS)[number];
// Module-level state for baseURL (set only via explicit configuration, not env vars)
let configuredBaseUrl: string | undefined;
/**
* Configure the OpenAI base URL (CLI flag only, not environment variable).
* This must be called explicitly - it cannot be set via environment variables for security.
*/
export function setOpenAIBaseUrl(baseUrl: string | undefined): void {
configuredBaseUrl = baseUrl;
}
/**
* Retrieve an OpenAI language model configured from environment variables and explicit config.
*
* Configuration:
* - OPENAI_MODEL: Model to use (default: "gpt-5") - env var OK
* - OPENAI_REASONING_EFFORT: Reasoning effort for o1 models: "low", "medium", "high", or "" to disable (default: "low") - env var OK
* - Base URL: Must be set via setOpenAIBaseUrl() - NOT from env vars (security risk)
*/
export function getOpenAIModel(model?: string): LanguageModelV1 {
const defaultModel = process.env.OPENAI_MODEL || DEFAULT_OPENAI_MODEL;
// Handle reasoning effort: empty string explicitly disables it, undefined uses default
const envReasoningEffort = process.env.OPENAI_REASONING_EFFORT;
let reasoningEffort: ReasoningEffort | undefined;
if (envReasoningEffort === "") {
// Empty string explicitly disables reasoning effort
reasoningEffort = undefined;
} else if (envReasoningEffort === undefined) {
// Not set - use default
reasoningEffort = DEFAULT_REASONING_EFFORT;
} else if (
VALID_REASONING_EFFORTS.includes(envReasoningEffort as ReasoningEffort)
) {
// Valid value
reasoningEffort = envReasoningEffort as ReasoningEffort;
} else {
// Invalid value - provide helpful error with all valid options
const validValues = VALID_REASONING_EFFORTS.map((v) => `"${v}"`).join(", ");
throw new Error(
`Invalid OPENAI_REASONING_EFFORT value: "${envReasoningEffort}". Must be one of: ${validValues}, or "" (empty string to disable). Default is "${DEFAULT_REASONING_EFFORT}".`,
);
}
const factory = createOpenAI({
...(configuredBaseUrl && { baseURL: configuredBaseUrl }),
headers: {
"User-Agent": USER_AGENT,
},
});
return factory(model ?? defaultModel, {
...(reasoningEffort && { reasoningEffort }),
});
}
```
--------------------------------------------------------------------------------
/packages/mcp-server-evals/src/evals/search-issues.eval.ts:
--------------------------------------------------------------------------------
```typescript
import { describeEval } from "vitest-evals";
import { FIXTURES, NoOpTaskRunner, ToolPredictionScorer } from "./utils";
// Note: This eval requires OPENAI_API_KEY to be set in the environment
// The search_issues tool uses the AI SDK to translate natural language queries
describeEval("search-issues", {
data: async () => {
return [
// Core test: Basic issue search
{
input: `Show me unresolved issues in ${FIXTURES.organizationSlug}`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "search_issues",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
naturalLanguageQuery: "unresolved issues",
},
},
],
},
// Core test: Search with 'me' reference (tests whoami integration)
{
input: `Find issues assigned to me in ${FIXTURES.organizationSlug}`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "whoami",
arguments: {},
},
{
name: "search_issues",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
naturalLanguageQuery: "issues assigned to me",
},
},
],
},
// Core test: Project-specific search
{
input: `Search for database errors in ${FIXTURES.organizationSlug}/${FIXTURES.projectSlug}`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "search_issues",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
projectSlugOrId: FIXTURES.projectSlug,
naturalLanguageQuery: "database errors",
},
},
],
},
// Core test: Complex natural language query
{
input: `Find critical production errors affecting more than 100 users in ${FIXTURES.organizationSlug}`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "search_issues",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
naturalLanguageQuery:
"critical production errors affecting more than 100 users",
},
},
],
},
];
},
task: NoOpTaskRunner(),
scorers: [ToolPredictionScorer()],
threshold: 0.6,
timeout: 30000,
});
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/agents/tools/data/device.json:
--------------------------------------------------------------------------------
```json
{
"namespace": "device",
"description": "Describes device attributes.\n",
"attributes": {
"device.id": {
"description": "A unique identifier representing the device\n",
"type": "string",
"note": "Its value SHOULD be identical for all apps on a device and it SHOULD NOT change if an app is uninstalled and re-installed.\nHowever, it might be resettable by the user for all apps on a device.\nHardware IDs (e.g. vendor-specific serial number, IMEI or MAC address) MAY be used as values.\n\nMore information about Android identifier best practices can be found [here](https://developer.android.com/training/articles/user-data-ids).\n\n> [!WARNING]\n>\n> This attribute may contain sensitive (PII) information. Caution should be taken when storing personal data or anything which can identify a user. GDPR and data protection laws may apply,\n> ensure you do your own due diligence.\n>\n> Due to these reasons, this identifier is not recommended for consumer applications and will likely result in rejection from both Google Play and App Store.\n> However, it may be appropriate for specific enterprise scenarios, such as kiosk devices or enterprise-managed devices, with appropriate compliance clearance.\n> Any instrumentation providing this identifier MUST implement it as an opt-in feature.\n>\n> See [`app.installation.id`](/docs/registry/attributes/app.md#app-installation-id) for a more privacy-preserving alternative.\n",
"stability": "development",
"examples": ["123456789012345", "01:23:45:67:89:AB"]
},
"device.manufacturer": {
"description": "The name of the device manufacturer\n",
"type": "string",
"note": "The Android OS provides this field via [Build](https://developer.android.com/reference/android/os/Build#MANUFACTURER). iOS apps SHOULD hardcode the value `Apple`.\n",
"stability": "development",
"examples": ["Apple", "Samsung"]
},
"device.model.identifier": {
"description": "The model identifier for the device\n",
"type": "string",
"note": "It's recommended this value represents a machine-readable version of the model identifier rather than the market or consumer-friendly name of the device.\n",
"stability": "development",
"examples": ["iPhone3,4", "SM-G920F"]
},
"device.model.name": {
"description": "The marketing name for the device model\n",
"type": "string",
"note": "It's recommended this value represents a human-readable version of the device model rather than a machine-readable alternative.\n",
"stability": "development",
"examples": ["iPhone 6s Plus", "Samsung Galaxy S6"]
}
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/app.ts:
--------------------------------------------------------------------------------
```typescript
import { Hono } from "hono";
import { csrf } from "hono/csrf";
import { secureHeaders } from "hono/secure-headers";
import * as Sentry from "@sentry/cloudflare";
import type { Env } from "./types";
import sentryOauth from "./oauth";
import chatOauth from "./routes/chat-oauth";
import chat from "./routes/chat";
import search from "./routes/search";
import metadata from "./routes/metadata";
import { logIssue } from "@sentry/mcp-core/telem/logging";
import { createRequestLogger } from "./logging";
import mcpRoutes from "./routes/mcp";
import { getClientIp } from "./utils/client-ip";
const app = new Hono<{
Bindings: Env;
}>()
.use("*", createRequestLogger())
// Set user IP address for Sentry (optional in local dev)
.use("*", async (c, next) => {
const clientIP = getClientIp(c.req.raw);
if (clientIP) {
Sentry.setUser({ ip_address: clientIP });
}
// In local development, IP extraction may fail - this is expected and safe to ignore
// as it's only used for Sentry telemetry context
await next();
})
// Apply security middleware globally
.use(
"*",
secureHeaders({
xFrameOptions: "DENY",
xContentTypeOptions: "nosniff",
referrerPolicy: "strict-origin-when-cross-origin",
strictTransportSecurity: "max-age=31536000; includeSubDomains",
}),
)
.use(
"*",
csrf({
origin: (origin, c) => {
if (!origin) {
return true;
}
const requestUrl = new URL(c.req.url);
return origin === requestUrl.origin;
},
}),
)
.get("/robots.txt", (c) => {
return c.text(["User-agent: *", "Allow: /$", "Disallow: /"].join("\n"));
})
.get("/llms.txt", (c) => {
return c.text(
[
"# sentry-mcp",
"",
"This service implements the Model Context Protocol for interacting with Sentry (https://sentry.io/welcome/).",
"",
`The MCP's server address is: ${new URL("/mcp", c.req.url).href}`,
"",
].join("\n"),
);
})
.route("/oauth", sentryOauth)
.route("/api/auth", chatOauth)
.route("/api/chat", chat)
.route("/api/search", search)
.route("/api/metadata", metadata)
.route("/.mcp", mcpRoutes)
.get("/sse", (c) => {
return c.json(
{
error: "SSE transport has been removed",
message:
"The SSE transport endpoint is no longer supported. Please use the HTTP transport at /mcp instead.",
migrationGuide: "https://mcp.sentry.dev",
},
410,
);
});
// TODO: propagate the error as sentry isnt injecting into hono
app.onError((err, c) => {
logIssue(err);
return c.text("Internal Server Error", 500);
});
export default app;
```
--------------------------------------------------------------------------------
/packages/mcp-server-evals/src/evals/list-issues.eval.ts:
--------------------------------------------------------------------------------
```typescript
import { describeEval } from "vitest-evals";
import { FIXTURES, NoOpTaskRunner, ToolPredictionScorer } from "./utils";
describeEval("list-issues", {
data: async () => {
return [
{
input: `What are the most common production errors in ${FIXTURES.organizationSlug}?`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "find_issues",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
query: "is:unresolved",
sortBy: "count",
regionUrl: "https://us.sentry.io",
},
},
],
},
{
input: `Show me the top issues in ${FIXTURES.organizationSlug} organization`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "find_issues",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
sortBy: "count",
regionUrl: "https://us.sentry.io",
},
},
],
},
{
input: `What are the most recent issues in ${FIXTURES.organizationSlug}?`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "find_issues",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
sortBy: "last_seen",
regionUrl: "https://us.sentry.io",
},
},
],
},
{
input: `Find the newest production issues in ${FIXTURES.organizationSlug}`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "find_issues",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
sortBy: "first_seen",
regionUrl: "https://us.sentry.io",
},
},
],
},
{
input: `What issues is [email protected] experiencing in ${FIXTURES.organizationSlug}?`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "find_issues",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
query: "user.email:[email protected]",
regionUrl: "https://us.sentry.io",
},
},
],
},
];
},
task: NoOpTaskRunner(),
scorers: [ToolPredictionScorer()],
threshold: 0.6,
timeout: 30000,
});
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/ui/header.tsx:
--------------------------------------------------------------------------------
```typescript
import type React from "react";
import { SentryIcon } from "./icons/sentry";
import { Github, PanelLeftClose } from "lucide-react";
import { Button } from "./button";
import { Badge } from "./badge";
interface HeaderProps {
toggleChat?: (open: boolean) => void;
isChatOpen?: boolean;
}
export const Header: React.FC<HeaderProps> = ({ toggleChat, isChatOpen }) => {
return (
<header className="pl-4 container mx-auto w-full px-4 sm:px-8 sticky py-4 top-0 z-30 backdrop-blur-xl bg-gradient-to-b from-background to-1% to-background/80">
{/* <div className="absolute inset-0 h-full w-full -z-10 pointer-events-none bg-gradient-to-r from-transparent to-background-2 hidden md:block" /> */}
<div className="absolute inset-0 h-full w-screen left-1/2 -translate-x-1/2 [mask-image:linear-gradient(to_right,transparent,red_4rem,red_calc(100%-4rem),transparent)] border-b-[1px] border-white/15 pointer-events-none -z-10" />
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 flex-shrink-0">
<SentryIcon className="h-8 w-8" />
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold whitespace-nowrap">
Sentry MCP
</h1>
<Badge
variant="outline"
className="text-xs bg-background-3 font-normal"
>
Beta
</Badge>
</div>
</div>
<div
className={`flex items-center gap-4 motion-safe:duration-300 [--x:0] xl:[--x:30rem] 2xl:[--x:38rem] ${
isChatOpen ? "-translate-x-[var(--x)]" : ""
}`}
>
<Button
className="rounded-xl max-md:!py-3 max-md:!px-2.25"
variant="secondary"
asChild
>
<a
href="https://github.com/getsentry/sentry-mcp"
target="_blank"
rel="noopener noreferrer"
>
<Github className="h-5 w-5" />
<span className="max-sm:sr-only">GitHub</span>
</a>
</Button>
{toggleChat && isChatOpen !== undefined && (
<Button
type="button"
onClick={() => toggleChat(!isChatOpen)}
className="cursor-pointer pl-3 pr-3.5 py-2 rounded-xl max-md:!py-3 max-md:!px-2.25 flex items-center bg-violet-300 text-background hover:bg-white/90 transition font-bold font-sans border border-background"
>
<PanelLeftClose className="size-4" />
<span className="max-sm:sr-only">Live Demo</span>
</Button>
)}
</div>
</div>
</header>
);
};
```
--------------------------------------------------------------------------------
/docs/coding-guidelines.md:
--------------------------------------------------------------------------------
```markdown
# Coding Guidelines
Essential patterns and standards for Sentry MCP development.
## TypeScript Configuration
```typescript
// tsconfig.json essentials
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"sourceMap": true,
"noImplicitAny": true
}
}
```
## Code Style
### Biome Configuration
- 2 spaces, double quotes, semicolons
- Max line: 100 chars
- Trailing commas in multiline
### Naming Conventions
- Files: `kebab-case.ts`
- Functions: `camelCase`
- Types/Classes: `PascalCase`
- Constants: `UPPER_SNAKE_CASE`
### Import Order
```typescript
// 1. Node built-ins
import { readFile } from "node:fs/promises";
// 2. External deps
import { z } from "zod";
// 3. Internal packages
import { mockData } from "@sentry-mcp/mocks";
// 4. Relative imports
import { UserInputError } from "./errors.js";
```
## Tool Implementation
```typescript
export const toolName = {
description: "Clear, concise description",
parameters: z.object({
required: z.string().describe("Description"),
optional: z.string().optional()
}),
execute: async (params, context) => {
// 1. Validate inputs
// 2. Call API
// 3. Format output
return formatResponse(data);
}
};
```
## Testing Standards
```typescript
describe("Component", () => {
it("handles normal case", async () => {
// Arrange
const input = createTestInput();
// Act
const result = await method(input);
// Assert
expect(result).toMatchInlineSnapshot();
});
});
```
Key practices:
- Use inline snapshots for formatting
- Mock with MSW
- Test success and error paths
- Keep tests isolated
## Quality Checklist
Before committing:
```bash
pnpm -w run lint # Biome check
pnpm -w run lint:fix # Fix issues
pnpm tsc --noEmit # Type check
pnpm test # Run tests
pnpm -w run build # Build all
```
## JSDoc Pattern
```typescript
/**
* Brief description.
*
* @param param - Description
* @returns What it returns
*
* @example
* ```typescript
* const result = func(param);
* ```
*/
```
## Security Essentials
- Never commit secrets
- Validate all inputs
- Use environment variables
- Sanitize displayed data
## Common Patterns
For shared patterns see:
- Error handling: `common-patterns.md#error-handling`
- Zod schemas: `common-patterns.md#zod-schema-patterns`
- API usage: `api-patterns.md`
- Testing: `testing.md`
## Monorepo Commands
```bash
# Workspace-wide (from root)
pnpm -w run lint
# Package-specific (from package dir)
pnpm test
```
## References
- Architecture: `architecture.md`
- Testing guide: `testing.md`
- API patterns: `api-patterns.md`
- Common patterns: `common-patterns.md`
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/utils/rate-limiter.ts:
--------------------------------------------------------------------------------
```typescript
import type { RateLimit } from "@cloudflare/workers-types";
/**
* Result from rate limit check
*/
export interface RateLimitCheckResult {
/**
* Whether the request is allowed
*/
allowed: boolean;
/**
* Error message if rate limited (only present when allowed=false)
*/
errorMessage?: string;
}
/**
* Hash a string using SHA-256 for privacy-preserving rate limit keys
*
* @param value - The value to hash (e.g., IP address, access token)
* @returns Hex-encoded SHA-256 hash (first 16 characters)
*/
async function hashValue(value: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(value);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return hashHex.substring(0, 16); // Use first 16 chars of hash
}
/**
* Check rate limit for a given identifier
*
* @param identifier - The identifier to rate limit (e.g., IP address, user ID)
* @param rateLimiter - The Cloudflare rate limiter binding
* @param options - Configuration options
* @returns Result indicating whether the request is allowed
*
* @example
* ```typescript
* const result = await checkRateLimit(
* clientIP,
* env.MCP_RATE_LIMITER,
* {
* keyPrefix: "mcp:ip",
* errorMessage: "Rate limit exceeded. Please wait before trying again."
* }
* );
*
* if (!result.allowed) {
* return new Response(result.errorMessage, { status: 429 });
* }
* ```
*/
export async function checkRateLimit(
identifier: string,
rateLimiter: RateLimit | undefined,
options: {
/**
* Prefix for the rate limit key (e.g., "mcp:ip", "chat:user")
*/
keyPrefix: string;
/**
* Error message to return when rate limited
*/
errorMessage: string;
},
): Promise<RateLimitCheckResult> {
// If rate limiter binding is not available (e.g., in development),
// allow the request to proceed
if (!rateLimiter) {
return { allowed: true };
}
try {
// Hash the identifier for privacy
const hashedIdentifier = await hashValue(identifier);
const rateLimitKey = `${options.keyPrefix}:${hashedIdentifier}`;
// Check rate limit
const { success } = await rateLimiter.limit({ key: rateLimitKey });
if (!success) {
return {
allowed: false,
errorMessage: options.errorMessage,
};
}
return { allowed: true };
} catch (error) {
// If rate limiter fails, log error but allow request to proceed
// This prevents rate limiter issues from breaking the service
console.error("Rate limiter error:", error);
return { allowed: true };
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/lib/constraint-utils.ts:
--------------------------------------------------------------------------------
```typescript
import type { Constraints } from "@sentry/mcp-core/types";
import { SentryApiService, ApiError } from "@sentry/mcp-core/api-client";
import { logIssue } from "@sentry/mcp-core/telem/logging";
/**
* Verify that provided org/project constraints exist and the user has access
* by querying Sentry's API using the provided OAuth access token.
*/
export async function verifyConstraintsAccess(
{ organizationSlug, projectSlug }: Constraints,
{
accessToken,
sentryHost = "sentry.io",
}: {
accessToken: string | undefined | null;
sentryHost?: string;
},
): Promise<
| {
ok: true;
constraints: Constraints;
}
| { ok: false; status?: number; message: string; eventId?: string }
> {
if (!organizationSlug) {
// No constraints specified, nothing to verify
return {
ok: true,
constraints: {
organizationSlug: null,
projectSlug: null,
regionUrl: null,
},
};
}
if (!accessToken) {
return {
ok: false,
status: 401,
message: "Missing access token for constraint verification",
};
}
// Use shared API client for consistent behavior and error handling
const api = new SentryApiService({ accessToken, host: sentryHost });
// Verify organization using API client
let regionUrl: string | null | undefined = null;
try {
const org = await api.getOrganization(organizationSlug);
regionUrl = org.links?.regionUrl || null;
} catch (error) {
if (error instanceof ApiError) {
const message =
error.status === 404
? `Organization '${organizationSlug}' not found`
: error.message;
return { ok: false, status: error.status, message };
}
const eventId = logIssue(error);
return {
ok: false,
status: 502,
message: "Failed to verify organization",
eventId,
};
}
// Verify project access if specified
if (projectSlug) {
try {
await api.getProject(
{
organizationSlug,
projectSlugOrId: projectSlug,
},
regionUrl ? { host: new URL(regionUrl).host } : undefined,
);
} catch (error) {
if (error instanceof ApiError) {
const message =
error.status === 404
? `Project '${projectSlug}' not found in organization '${organizationSlug}'`
: error.message;
return { ok: false, status: error.status, message };
}
const eventId = logIssue(error);
return {
ok: false,
status: 502,
message: "Failed to verify project",
eventId,
};
}
}
return {
ok: true,
constraints: {
organizationSlug,
projectSlug: projectSlug || null,
regionUrl: regionUrl || null,
},
};
}
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/agents/tools/otel-semantics.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SentryApiService } from "../../../api-client";
vi.mock("../logging", () => ({
logIssue: vi.fn(),
}));
// Import the actual function - no mocking needed since build runs first
import { lookupOtelSemantics } from "./otel-semantics";
describe("otel-semantics-lookup", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockApiService = {} as SentryApiService;
describe("lookupOtelSemantics", () => {
it("should return namespace information for valid namespace", async () => {
const result = await lookupOtelSemantics(
"gen_ai",
"spans",
mockApiService,
"test-org",
);
expect(result).toContain("# OpenTelemetry Semantic Conventions: gen_ai");
expect(result).toContain("## Attributes");
expect(result).toContain("`gen_ai.usage.input_tokens`");
expect(result).toContain("`gen_ai.usage.output_tokens`");
expect(result).toContain("- **Type:**");
expect(result).toContain("- **Description:**");
});
it("should handle namespace with underscore and dash interchangeably", async () => {
const result1 = await lookupOtelSemantics(
"gen_ai",
"spans",
mockApiService,
"test-org",
);
const result2 = await lookupOtelSemantics(
"gen-ai",
"spans",
mockApiService,
"test-org",
);
expect(result1).toBe(result2);
});
it("should return all attributes for a namespace", async () => {
const result = await lookupOtelSemantics(
"http",
"spans",
mockApiService,
"test-org",
);
expect(result).toContain("total)");
expect(result).toContain("`http.request.method`");
expect(result).toContain("`http.response.status_code`");
});
it("should show custom namespace note for mcp", async () => {
const result = await lookupOtelSemantics(
"mcp",
"spans",
mockApiService,
"test-org",
);
expect(result).toContain("**Note:** This is a custom namespace");
});
it("should handle invalid namespace", async () => {
const result = await lookupOtelSemantics(
"totally_invalid_namespace_that_does_not_exist",
"spans",
mockApiService,
"test-org",
);
expect(result).toContain(
"Namespace 'totally_invalid_namespace_that_does_not_exist' not found",
);
});
it("should suggest similar namespaces", async () => {
const result = await lookupOtelSemantics(
"gen",
"spans",
mockApiService,
"test-org",
);
expect(result).toContain("Did you mean:");
expect(result).toContain("gen_ai");
});
});
});
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/utils/slug-validation.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Slug validation utilities to prevent path traversal and injection attacks.
*
* Provides reusable validation functions for use with Zod's superRefine()
* to add security validation for URL parameters.
*/
import { z } from "zod";
/**
* Maximum reasonable length for a slug.
*/
const MAX_SLUG_LENGTH = 100;
/**
* Maximum reasonable length for a numeric ID.
*/
const MAX_ID_LENGTH = 20;
/**
* Helper to check if a string is a numeric ID.
*/
export function isNumericId(value: string): boolean {
return /^\d+$/.test(value);
}
/**
* Valid slug pattern: alphanumeric, hyphens, underscores, and dots.
* Must start with alphanumeric character.
*/
const VALID_SLUG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
/**
* Validates a slug to prevent path traversal and injection attacks.
* Designed to be used with Zod's superRefine() method.
*
* @example
* ```typescript
* const OrganizationSlug = z.string()
* .toLowerCase()
* .trim()
* .superRefine(validateSlug)
* .describe("Organization slug");
*
* const TeamSlug = z.string()
* .toLowerCase()
* .trim()
* .superRefine(validateSlug)
* .describe("Team slug");
* ```
*/
export function validateSlug(val: string, ctx: z.RefinementCtx): void {
// Check for empty string
if (val.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Slug cannot be empty",
});
return;
}
// Check length
if (val.length > MAX_SLUG_LENGTH) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Slug exceeds maximum length of ${MAX_SLUG_LENGTH} characters`,
});
return;
}
// Validate pattern - this implicitly blocks all dangerous characters and patterns
if (!VALID_SLUG_PATTERN.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Slug must contain only alphanumeric characters, hyphens, underscores, and dots, and must start with an alphanumeric character",
});
}
}
/**
* Validates a parameter that can be either a slug or numeric ID.
* Designed to be used with Zod's superRefine() method.
*
* @example
* ```typescript
* const ProjectSlugOrId = z.string()
* .toLowerCase()
* .trim()
* .superRefine(validateSlugOrId)
* .describe("Project slug or numeric ID");
*
* const IssueSlugOrId = z.string()
* .trim()
* .superRefine(validateSlugOrId)
* .describe("Issue slug or numeric ID");
* ```
*/
export function validateSlugOrId(val: string, ctx: z.RefinementCtx): void {
// Check if it's a numeric ID
if (isNumericId(val)) {
if (val.length > MAX_ID_LENGTH) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Numeric ID exceeds maximum length of ${MAX_ID_LENGTH} characters`,
});
}
// Numeric IDs don't need slug validation
return;
}
// Otherwise validate as a slug
validateSlug(val, ctx);
}
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/agents/tools/data/jvm.json:
--------------------------------------------------------------------------------
```json
{
"namespace": "jvm",
"description": "This document defines Java Virtual machine related attributes.\n",
"attributes": {
"jvm.gc.action": {
"description": "Name of the garbage collector action.",
"type": "string",
"note": "Garbage collector action is generally obtained via [GarbageCollectionNotificationInfo#getGcAction()](https://docs.oracle.com/en/java/javase/11/docs/api/jdk.management/com/sun/management/GarbageCollectionNotificationInfo.html#getGcAction()).\n",
"stability": "stable",
"examples": ["end of minor GC", "end of major GC"]
},
"jvm.gc.cause": {
"description": "Name of the garbage collector cause.",
"type": "string",
"note": "Garbage collector cause is generally obtained via [GarbageCollectionNotificationInfo#getGcCause()](https://docs.oracle.com/en/java/javase/11/docs/api/jdk.management/com/sun/management/GarbageCollectionNotificationInfo.html#getGcCause()).\n",
"stability": "development",
"examples": ["System.gc()", "Allocation Failure"]
},
"jvm.gc.name": {
"description": "Name of the garbage collector.",
"type": "string",
"note": "Garbage collector name is generally obtained via [GarbageCollectionNotificationInfo#getGcName()](https://docs.oracle.com/en/java/javase/11/docs/api/jdk.management/com/sun/management/GarbageCollectionNotificationInfo.html#getGcName()).\n",
"stability": "stable",
"examples": ["G1 Young Generation", "G1 Old Generation"]
},
"jvm.memory.type": {
"description": "The type of memory.",
"type": "string",
"stability": "stable",
"examples": ["heap", "non_heap"]
},
"jvm.memory.pool.name": {
"description": "Name of the memory pool.",
"type": "string",
"note": "Pool names are generally obtained via [MemoryPoolMXBean#getName()](https://docs.oracle.com/en/java/javase/11/docs/api/java.management/java/lang/management/MemoryPoolMXBean.html#getName()).\n",
"stability": "stable",
"examples": ["G1 Old Gen", "G1 Eden space", "G1 Survivor Space"]
},
"jvm.thread.daemon": {
"description": "Whether the thread is daemon or not.",
"type": "boolean",
"stability": "stable"
},
"jvm.thread.state": {
"description": "State of the thread.",
"type": "string",
"stability": "stable",
"examples": [
"new",
"runnable",
"blocked",
"waiting",
"timed_waiting",
"terminated"
]
},
"jvm.buffer.pool.name": {
"description": "Name of the buffer pool.",
"type": "string",
"note": "Pool names are generally obtained via [BufferPoolMXBean#getName()](https://docs.oracle.com/en/java/javase/11/docs/api/java.management/java/lang/management/BufferPoolMXBean.html#getName()).\n",
"stability": "development",
"examples": ["mapped", "direct"]
}
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-test-client/src/mcp-test-client.ts:
--------------------------------------------------------------------------------
```typescript
import { experimental_createMCPClient } from "ai";
import { Experimental_StdioMCPTransport } from "ai/mcp-stdio";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { startNewTrace, startSpan } from "@sentry/core";
import { logSuccess } from "./logger.js";
import type { MCPConnection, MCPConfig } from "./types.js";
import { randomUUID } from "node:crypto";
import { LIB_VERSION } from "./version.js";
export async function connectToMCPServer(
config: MCPConfig,
): Promise<MCPConnection> {
const sessionId = randomUUID();
return await startNewTrace(async () => {
return await startSpan(
{
name: "mcp.connect/stdio",
attributes: {
"mcp.transport": "stdio",
"gen_ai.conversation.id": sessionId,
"service.version": LIB_VERSION,
},
},
async (span) => {
try {
const args = [`--access-token=${config.accessToken}`];
if (config.host) {
args.push(`--host=${config.host}`);
}
if (config.sentryDsn) {
args.push(`--sentry-dsn=${config.sentryDsn}`);
}
if (config.useAgentEndpoint) {
args.push("--agent");
}
// Resolve the path to the mcp-server binary
const __dirname = dirname(fileURLToPath(import.meta.url));
const mcpServerPath = join(
__dirname,
"../../mcp-server/dist/index.js",
);
const transport = new Experimental_StdioMCPTransport({
command: "node",
args: [mcpServerPath, ...args],
env: {
...process.env,
SENTRY_ACCESS_TOKEN: config.accessToken,
SENTRY_HOST: config.host || "sentry.io",
...(config.sentryDsn && { SENTRY_DSN: config.sentryDsn }),
},
});
const client = await experimental_createMCPClient({
name: "mcp.sentry.dev (test-client)",
transport,
});
// Discover available tools
const toolsMap = await client.tools();
const tools = new Map<string, any>();
for (const [name, tool] of Object.entries(toolsMap)) {
tools.set(name, tool);
}
// Remove custom attributes - let SDK handle standard attributes
span.setStatus({ code: 1 }); // OK status
logSuccess(
"Connected to MCP server (stdio)",
`${tools.size} tools available`,
);
const disconnect = async () => {
await client.close();
};
return {
client,
tools,
disconnect,
sessionId,
transport: "stdio" as const,
};
} catch (error) {
span.setStatus({ code: 2 }); // Error status
throw error;
}
},
);
});
}
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/tool-helpers/api.ts:
--------------------------------------------------------------------------------
```typescript
import {
SentryApiService,
ApiClientError,
ApiNotFoundError,
} from "../../api-client/index";
import { UserInputError } from "../../errors";
import type { ServerContext } from "../../types";
import { validateRegionUrl } from "./validate-region-url";
/**
* Create a Sentry API service from server context with optional region override
* @param context - Server context containing host and access token
* @param opts - Options object containing optional regionUrl override
* @returns Configured SentryApiService instance (always uses HTTPS)
* @throws {UserInputError} When regionUrl is provided but invalid
*/
export function apiServiceFromContext(
context: ServerContext,
opts: { regionUrl?: string } = {},
) {
let host = context.sentryHost;
if (opts.regionUrl?.trim()) {
// Validate the regionUrl against the base host to prevent SSRF
// Use default host if context.sentryHost is not set
const baseHost = context.sentryHost || "sentry.io";
host = validateRegionUrl(opts.regionUrl.trim(), baseHost);
}
return new SentryApiService({
host,
accessToken: context.accessToken,
});
}
/**
* Maps API errors to user-friendly errors based on context
* @param error - The error to handle
* @param params - The parameters that were used in the API call
* @returns Never - always throws an error
* @throws {UserInputError} For 4xx errors that are likely user input issues
* @throws {Error} For other errors
*/
export function handleApiError(
error: unknown,
params?: Record<string, unknown>,
): never {
// Use the new error hierarchy - all 4xx errors extend ApiClientError
if (error instanceof ApiClientError) {
let message = `API error (${error.status}): ${error.message}`;
// Special handling for 404s with parameter context
if (error instanceof ApiNotFoundError && params) {
const paramsList: string[] = [];
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") {
paramsList.push(`${key}: '${value}'`);
}
}
if (paramsList.length > 0) {
message = `Resource not found (404): ${error.message}\nPlease verify these parameters are correct:\n${paramsList.map((p) => ` - ${p}`).join("\n")}`;
}
}
throw new UserInputError(message, { cause: error });
}
// All other errors bubble up (including ApiServerError for 5xx)
throw error;
}
/**
* Wraps an async API call with automatic error handling
* @param fn - The async function to execute
* @param params - The parameters that were used in the API call
* @returns The result of the function
* @throws {UserInputError} For user input errors
* @throws {Error} For other errors
*/
export async function withApiErrorHandling<T>(
fn: () => Promise<T>,
params?: Record<string, unknown>,
): Promise<T> {
try {
return await fn();
} catch (error) {
handleApiError(error, params);
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/tools/use-sentry/tool-wrapper.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Generic tool wrapper for the use_sentry embedded agent.
*
* Provides a single function that can wrap ANY MCP tool handler
* to work with the embedded agent pattern.
*/
import { z } from "zod";
import { agentTool } from "../../internal/agents/tools/utils";
import type { ServerContext } from "../../types";
import type { ToolConfig } from "../types";
/**
* Options for wrapping a tool
*/
export interface WrapToolOptions {
context: ServerContext;
}
/**
* Helper to inject constrained parameters into tool calls.
* This applies session-level constraints (org, project, region) to tool parameters.
*/
function injectConstrainedParams(
params: Record<string, any>,
constraints: ServerContext["constraints"],
): Record<string, any> {
const result = { ...params };
// Apply organization constraint if set
if (constraints.organizationSlug && !result.organizationSlug) {
result.organizationSlug = constraints.organizationSlug;
}
// Apply project constraint (handle both projectSlug and projectSlugOrId)
if (constraints.projectSlug) {
if (!result.projectSlug) {
result.projectSlug = constraints.projectSlug;
}
if (!result.projectSlugOrId) {
result.projectSlugOrId = constraints.projectSlug;
}
}
// Apply region constraint if set
if (constraints.regionUrl && !result.regionUrl) {
result.regionUrl = constraints.regionUrl;
}
return result;
}
/**
* Wraps any MCP tool to work with the embedded agent pattern.
*
* This function:
* 1. Takes a tool definition with its handler
* 2. Creates an agentTool-wrapped version
* 3. Pre-binds ServerContext so the agent doesn't need it
* 4. Applies session constraints automatically
* 5. Handles errors via agentTool's error handling
*
* @param tool - The MCP tool to wrap (from defineTool)
* @param options - Context and configuration for the tool
* @returns An agentTool-wrapped version ready for use by the embedded agent
*
* @example
* ```typescript
* const whoami = wrapToolForAgent(whoamiTool, { context });
* const findOrgs = wrapToolForAgent(findOrganizationsTool, { context });
* ```
*/
export function wrapToolForAgent<TSchema extends Record<string, z.ZodType>>(
tool: ToolConfig<TSchema>,
options: WrapToolOptions,
) {
return agentTool({
description: tool.description,
parameters: z.object(tool.inputSchema),
execute: async (params: unknown) => {
// Type safety: params is validated by agentTool's Zod schema before reaching here
const fullParams = injectConstrainedParams(
params as Record<string, unknown>,
options.context.constraints,
);
// Call the actual tool handler with full context
// Type assertion is safe: fullParams matches the tool's input schema (enforced by Zod)
const result = await tool.handler(fullParams as never, options.context);
// Return the result - agentTool handles error wrapping
return result;
},
});
}
```
--------------------------------------------------------------------------------
/docs/specs/subpath-constraints.md:
--------------------------------------------------------------------------------
```markdown
# Subpath-Based Constraints (End-User Guide)
## What constraints do
Constraints let you scope your Sentry MCP session to a specific organization and optionally a project. When scoped, all tools automatically use that org/project by default and only access data you are permitted to see.
## How to connect
- No scope: connect to `/mcp` (or `/sse` for SSE transport)
- Organization scope: `/mcp/{organizationSlug}`
- Organization + project scope: `/mcp/{organizationSlug}/{projectSlug}`
The same pattern applies to the SSE endpoint: `/sse`, `/sse/{org}`, `/sse/{org}/{project}`.
Examples:
```
/mcp/sentry
/mcp/sentry/my-project
/sse/sentry
/sse/sentry/my-project
```
## What you'll experience
- Tools automatically use the constrained organization/project as defaults
- You can still pass explicit `organizationSlug`/`projectSlug` to override defaults per call
- If you don't provide a scope, tools work across your accessible organizations when supported
- Some tools are filtered when not useful: `find_organizations` is hidden when scoped to an org, and `find_projects` is hidden when scoped to a project
## Access verification
When you connect with a scoped path, we validate that:
- The slugs are well-formed
- The organization exists and you have access
- If a project is included, the project exists and you have access
If there’s a problem, you’ll receive a clear HTTP error when connecting:
- 400: Invalid slug format
- 401: Missing authentication
- 403: You don’t have access to the specified org/project
- 404: Organization or project not found
## Region awareness
For Sentry Cloud, your organization may be hosted in a regional cluster. When you scope by organization, we automatically determine the region (if available) and use it for API calls. You don’t need to take any action—this happens behind the scenes. For self-hosted Sentry, the region concept doesn’t apply.
## Best practices
- Prefer scoping by organization (and project when known) to reduce ambiguity and improve safety
- Use scoped sessions when collaborating across multiple orgs to avoid cross-org access by mistake
- If a tool reports access errors, reconnect with a different scope or verify your permissions in Sentry
## Frequently asked questions
- Can I switch scope mid-session?
- Yes. Open a new connection using a different subpath (e.g., `/mcp/{org}/{project}`) and use that session.
- Do I need to specify scope for documentation or metadata endpoints?
- No. Public metadata endpoints don’t require scope and support CORS.
- How do tools know my scope?
- The MCP session embeds the constraints, and tools read them as defaults for `organizationSlug` and `projectSlug`.
## Reference
Supported URL patterns:
```
/mcp/{organizationSlug}/{projectSlug}
/mcp/{organizationSlug}
/mcp
/sse/{organizationSlug}/{projectSlug}
/sse/{organizationSlug}
/sse
```
For implementation details and security notes, see:
- `docs/cloudflare/constraint-flow-verification.md`
- `docs/architecture.md`
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/agents/tools/data/app.json:
--------------------------------------------------------------------------------
```json
{
"namespace": "app",
"description": "Describes attributes related to client-side applications (e.g. web apps or mobile apps).\n",
"attributes": {
"app.installation.id": {
"description": "A unique identifier representing the installation of an application on a specific device\n",
"type": "string",
"note": "Its value SHOULD persist across launches of the same application installation, including through application upgrades.\nIt SHOULD change if the application is uninstalled or if all applications of the vendor are uninstalled.\nAdditionally, users might be able to reset this value (e.g. by clearing application data).\nIf an app is installed multiple times on the same device (e.g. in different accounts on Android), each `app.installation.id` SHOULD have a different value.\nIf multiple OpenTelemetry SDKs are used within the same application, they SHOULD use the same value for `app.installation.id`.\nHardware IDs (e.g. serial number, IMEI, MAC address) MUST NOT be used as the `app.installation.id`.\n\nFor iOS, this value SHOULD be equal to the [vendor identifier](https://developer.apple.com/documentation/uikit/uidevice/identifierforvendor).\n\nFor Android, examples of `app.installation.id` implementations include:\n\n- [Firebase Installation ID](https://firebase.google.com/docs/projects/manage-installations).\n- A globally unique UUID which is persisted across sessions in your application.\n- [App set ID](https://developer.android.com/identity/app-set-id).\n- [`Settings.getString(Settings.Secure.ANDROID_ID)`](https://developer.android.com/reference/android/provider/Settings.Secure#ANDROID_ID).\n\nMore information about Android identifier best practices can be found [here](https://developer.android.com/training/articles/user-data-ids).\n",
"stability": "development",
"examples": ["2ab2916d-a51f-4ac8-80ee-45ac31a28092"]
},
"app.screen.coordinate.x": {
"description": "The x (horizontal) coordinate of a screen coordinate, in screen pixels.",
"type": "number",
"stability": "development",
"examples": ["0", "131"]
},
"app.screen.coordinate.y": {
"description": "The y (vertical) component of a screen coordinate, in screen pixels.\n",
"type": "number",
"stability": "development",
"examples": ["12", "99"]
},
"app.widget.id": {
"description": "An identifier that uniquely differentiates this widget from other widgets in the same application.\n",
"type": "string",
"note": "A widget is an application component, typically an on-screen visual GUI element.\n",
"stability": "development",
"examples": ["f9bc787d-ff05-48ad-90e1-fca1d46130b3", "submit_order_1829"]
},
"app.widget.name": {
"description": "The name of an application widget.",
"type": "string",
"note": "A widget is an application component, typically an on-screen visual GUI element.\n",
"stability": "development",
"examples": ["submit", "attack", "Clear Cart"]
}
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/cli/resolve.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from "vitest";
import { finalize } from "./resolve";
describe("cli/finalize", () => {
it("throws on missing access token", () => {
expect(() => finalize({ unknownArgs: [] } as any)).toThrow(
/No access token was provided/,
);
});
it("normalizes host from URL", () => {
const cfg = finalize({
accessToken: "tok",
url: "https://sentry.example.com",
unknownArgs: [],
});
expect(cfg.sentryHost).toBe("sentry.example.com");
});
it("accepts valid OpenAI base URL", () => {
const cfg = finalize({
accessToken: "tok",
openaiBaseUrl: "https://api.proxy.example/v1",
unknownArgs: [],
});
expect(cfg.openaiBaseUrl).toBe(
new URL("https://api.proxy.example/v1").toString(),
);
});
it("rejects invalid OpenAI base URL", () => {
expect(() =>
finalize({
accessToken: "tok",
openaiBaseUrl: "ftp://example.com",
unknownArgs: [],
}),
).toThrow(/OPENAI base URL must use http or https scheme/);
});
it("throws on non-https URL", () => {
expect(() =>
finalize({ accessToken: "tok", url: "http://bad", unknownArgs: [] }),
).toThrow(/must be a full HTTPS URL/);
});
// Skills tests
it("throws on invalid skills", () => {
expect(() =>
finalize({
accessToken: "tok",
skills: "invalid-skill",
unknownArgs: [],
}),
).toThrow(/Invalid skills provided: invalid-skill/);
});
it("validates multiple skills and reports all invalid ones", () => {
expect(() =>
finalize({
accessToken: "tok",
skills: "inspect,invalid1,triage,invalid2",
unknownArgs: [],
}),
).toThrow(/Invalid skills provided: invalid1, invalid2/);
});
it("resolves valid skills in override mode (--skills)", () => {
const cfg = finalize({
accessToken: "tok",
skills: "inspect,triage",
unknownArgs: [],
});
expect(cfg.finalSkills?.has("inspect")).toBe(true);
expect(cfg.finalSkills?.has("triage")).toBe(true);
expect(cfg.finalSkills?.size).toBe(2);
// Should not include defaults
expect(cfg.finalSkills?.has("docs")).toBe(false);
});
it("throws on empty skills after validation", () => {
expect(() =>
finalize({
accessToken: "tok",
skills: "invalid1,invalid2",
unknownArgs: [],
}),
).toThrow(/Invalid skills provided/);
});
it("grants all skills when no skills specified", () => {
const cfg = finalize({
accessToken: "tok",
unknownArgs: [],
});
expect(cfg.finalSkills).toBeDefined();
expect(cfg.finalSkills?.size).toBe(5); // All skills: inspect, triage, project-management, seer, docs
expect(cfg.finalSkills?.has("inspect")).toBe(true);
expect(cfg.finalSkills?.has("triage")).toBe(true);
expect(cfg.finalSkills?.has("project-management")).toBe(true);
expect(cfg.finalSkills?.has("seer")).toBe(true);
expect(cfg.finalSkills?.has("docs")).toBe(true);
});
});
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/agents/tools/data/feature_flags.json:
--------------------------------------------------------------------------------
```json
{
"namespace": "feature_flags",
"description": "This document defines attributes for Feature Flags.\n",
"attributes": {
"feature_flag.key": {
"description": "The lookup key of the feature flag.",
"type": "string",
"stability": "release_candidate",
"examples": ["logo-color"]
},
"feature_flag.provider.name": {
"description": "Identifies the feature flag provider.",
"type": "string",
"stability": "release_candidate",
"examples": ["Flag Manager"]
},
"feature_flag.result.variant": {
"description": "A semantic identifier for an evaluated flag value.\n",
"type": "string",
"note": "A semantic identifier, commonly referred to as a variant, provides a means\nfor referring to a value without including the value itself. This can\nprovide additional context for understanding the meaning behind a value.\nFor example, the variant `red` maybe be used for the value `#c05543`.",
"stability": "release_candidate",
"examples": ["red", "true", "on"]
},
"feature_flag.context.id": {
"description": "The unique identifier for the flag evaluation context. For example, the targeting key.\n",
"type": "string",
"stability": "release_candidate",
"examples": ["5157782b-2203-4c80-a857-dbbd5e7761db"]
},
"feature_flag.version": {
"description": "The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset.\n",
"type": "string",
"stability": "release_candidate",
"examples": ["1", "01ABCDEF"]
},
"feature_flag.set.id": {
"description": "The identifier of the [flag set](https://openfeature.dev/specification/glossary/#flag-set) to which the feature flag belongs.\n",
"type": "string",
"stability": "release_candidate",
"examples": ["proj-1", "ab98sgs", "service1/dev"]
},
"feature_flag.result.reason": {
"description": "The reason code which shows how a feature flag value was determined.\n",
"type": "string",
"stability": "release_candidate",
"examples": [
"static",
"default",
"targeting_match",
"split",
"cached",
"disabled",
"unknown",
"stale",
"error"
]
},
"feature_flag.result.value": {
"description": "The evaluated value of the feature flag.",
"type": "string",
"note": "With some feature flag providers, feature flag results can be quite large or contain private or sensitive details.\nBecause of this, `feature_flag.result.variant` is often the preferred attribute if it is available.\n\nIt may be desirable to redact or otherwise limit the size and scope of `feature_flag.result.value` if possible.\nBecause the evaluated flag value is unstructured and may be any type, it is left to the instrumentation author to determine how best to achieve this.\n",
"stability": "release_candidate",
"examples": ["#ff0000", "true", "3"]
}
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/tool-helpers/seer.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from "vitest";
import {
isTerminalStatus,
isHumanInterventionStatus,
getStatusDisplayName,
getHumanInterventionGuidance,
} from "./seer";
describe("seer-utils", () => {
describe("isTerminalStatus", () => {
it("returns true for terminal statuses", () => {
expect(isTerminalStatus("COMPLETED")).toBe(true);
expect(isTerminalStatus("FAILED")).toBe(true);
expect(isTerminalStatus("ERROR")).toBe(true);
expect(isTerminalStatus("CANCELLED")).toBe(true);
expect(isTerminalStatus("NEED_MORE_INFORMATION")).toBe(true);
expect(isTerminalStatus("WAITING_FOR_USER_RESPONSE")).toBe(true);
});
it("returns false for non-terminal statuses", () => {
expect(isTerminalStatus("PROCESSING")).toBe(false);
expect(isTerminalStatus("IN_PROGRESS")).toBe(false);
expect(isTerminalStatus("PENDING")).toBe(false);
});
});
describe("isHumanInterventionStatus", () => {
it("returns true for human intervention statuses", () => {
expect(isHumanInterventionStatus("NEED_MORE_INFORMATION")).toBe(true);
expect(isHumanInterventionStatus("WAITING_FOR_USER_RESPONSE")).toBe(true);
});
it("returns false for other statuses", () => {
expect(isHumanInterventionStatus("COMPLETED")).toBe(false);
expect(isHumanInterventionStatus("PROCESSING")).toBe(false);
expect(isHumanInterventionStatus("FAILED")).toBe(false);
});
});
describe("getStatusDisplayName", () => {
it("returns friendly names for known statuses", () => {
expect(getStatusDisplayName("COMPLETED")).toBe("Complete");
expect(getStatusDisplayName("FAILED")).toBe("Failed");
expect(getStatusDisplayName("ERROR")).toBe("Failed");
expect(getStatusDisplayName("CANCELLED")).toBe("Cancelled");
expect(getStatusDisplayName("NEED_MORE_INFORMATION")).toBe(
"Needs More Information",
);
expect(getStatusDisplayName("WAITING_FOR_USER_RESPONSE")).toBe(
"Waiting for Response",
);
expect(getStatusDisplayName("PROCESSING")).toBe("Processing");
expect(getStatusDisplayName("IN_PROGRESS")).toBe("In Progress");
});
it("returns status as-is for unknown statuses", () => {
expect(getStatusDisplayName("UNKNOWN_STATUS")).toBe("UNKNOWN_STATUS");
});
});
describe("getHumanInterventionGuidance", () => {
it("returns guidance for NEED_MORE_INFORMATION", () => {
const guidance = getHumanInterventionGuidance("NEED_MORE_INFORMATION");
expect(guidance).toContain("Seer needs additional information");
});
it("returns guidance for WAITING_FOR_USER_RESPONSE", () => {
const guidance = getHumanInterventionGuidance(
"WAITING_FOR_USER_RESPONSE",
);
expect(guidance).toContain("Seer is waiting for your response");
});
it("returns empty string for other statuses", () => {
expect(getHumanInterventionGuidance("COMPLETED")).toBe("");
expect(getHumanInterventionGuidance("PROCESSING")).toBe("");
});
});
});
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/cli/resolve.ts:
--------------------------------------------------------------------------------
```typescript
import { parseSkills, SKILLS, type Skill } from "@sentry/mcp-core/skills";
import {
validateAndParseSentryUrlThrows,
validateOpenAiBaseUrlThrows,
validateSentryHostThrows,
} from "@sentry/mcp-core/utils/url-utils";
import type { MergedArgs, ResolvedConfig } from "./types";
export function formatInvalidSkills(
invalid: string[],
envName?: string,
): string {
const where = envName ? `${envName} provided` : "Invalid skills provided";
const allSkills = Object.keys(SKILLS).join(", ");
return `Error: ${where}: ${invalid.join(", ")}\nAvailable skills: ${allSkills}`;
}
export function finalize(input: MergedArgs): ResolvedConfig {
// Access token required
if (!input.accessToken) {
throw new Error(
"Error: No access token was provided. Pass one with `--access-token` or via `SENTRY_ACCESS_TOKEN`.",
);
}
// Determine host from url/host with validation
let sentryHost = "sentry.io";
if (input.url) {
sentryHost = validateAndParseSentryUrlThrows(input.url);
} else if (input.host) {
validateSentryHostThrows(input.host);
sentryHost = input.host;
}
// Skills resolution
//
// IMPORTANT: stdio (CLI) intentionally defaults to ALL skills when no --skills flag is provided
//
// This differs from the OAuth flow, which requires explicit user selection:
// - stdio/CLI: Non-interactive, defaults to ALL skills (inspect, docs, seer, triage, project-management)
// - OAuth: Interactive, requires user to explicitly select skills (with sensible defaults pre-checked)
//
// Rationale:
// We don't want the MCP to break if users don't specify skills. stdio is typically used in
// local development and CI/CD environments where maximum access by default is expected.
// OAuth is used in multi-tenant hosted environments where users should consciously grant
// permissions on a per-app basis.
//
// For OAuth validation that enforces minimum 1 skill selection, see:
// packages/mcp-cloudflare/src/server/oauth/routes/callback.ts (lines 234-248)
//
let finalSkills: Set<Skill> | undefined = undefined;
if (input.skills) {
// Override: use only the specified skills
const { valid, invalid } = parseSkills(input.skills);
if (invalid.length > 0) {
throw new Error(formatInvalidSkills(invalid));
}
if (valid.size === 0) {
throw new Error("Error: Invalid skills provided. No valid skills found.");
}
finalSkills = valid;
} else {
// Default: grant ALL skills when no flag is provided (see comment block above for rationale)
const allSkills = Object.keys(SKILLS) as Skill[];
finalSkills = new Set<Skill>(allSkills);
}
const resolvedOpenAiBaseUrl = input.openaiBaseUrl
? validateOpenAiBaseUrlThrows(input.openaiBaseUrl)
: undefined;
return {
accessToken: input.accessToken,
sentryHost,
mcpUrl: input.mcpUrl,
sentryDsn: input.sentryDsn,
openaiBaseUrl: resolvedOpenAiBaseUrl,
openaiModel: input.openaiModel,
finalSkills,
organizationSlug: input.organizationSlug,
projectSlug: input.projectSlug,
};
}
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/tool-helpers/api.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from "vitest";
import { ApiNotFoundError, createApiError } from "../../api-client";
import { UserInputError } from "../../errors";
import { handleApiError, withApiErrorHandling } from "./api";
describe("handleApiError", () => {
it("converts 404 errors with params to list all parameters", () => {
const error = new ApiNotFoundError("Not Found");
expect(() =>
handleApiError(error, {
organizationSlug: "my-org",
issueId: "PROJ-123",
}),
).toThrow(UserInputError);
expect(() =>
handleApiError(error, {
organizationSlug: "my-org",
issueId: "PROJ-123",
}),
).toThrow(
"Resource not found (404): Not Found\nPlease verify these parameters are correct:\n - organizationSlug: 'my-org'\n - issueId: 'PROJ-123'",
);
});
it("converts 404 errors with multiple params including nullish values", () => {
const error = new ApiNotFoundError("Not Found");
expect(() =>
handleApiError(error, {
organizationSlug: "my-org",
projectSlug: "my-project",
query: undefined,
sortBy: null,
limit: 0,
emptyString: "",
}),
).toThrow(
"Resource not found (404): Not Found\nPlease verify these parameters are correct:\n - organizationSlug: 'my-org'\n - projectSlug: 'my-project'\n - limit: '0'",
);
});
it("converts 404 errors with no params to generic message", () => {
const error = new ApiNotFoundError("Not Found");
expect(() => handleApiError(error, {})).toThrow(
"API error (404): Not Found",
);
});
it("converts 400 errors to UserInputError", () => {
const error = createApiError("Invalid parameters", 400);
expect(() => handleApiError(error)).toThrow(UserInputError);
expect(() => handleApiError(error)).toThrow(
"API error (400): Invalid parameters",
);
});
it("converts 403 errors to UserInputError with access message", () => {
const error = createApiError("Forbidden", 403);
expect(() => handleApiError(error)).toThrow("API error (403): Forbidden");
});
it("re-throws non-API errors unchanged", () => {
const error = new Error("Network error");
expect(() => handleApiError(error)).toThrow(error);
});
});
describe("withApiErrorHandling", () => {
it("returns successful results unchanged", async () => {
const result = await withApiErrorHandling(
async () => ({ id: "123", title: "Test Issue" }),
{ issueId: "PROJ-123" },
);
expect(result).toEqual({ id: "123", title: "Test Issue" });
});
it("handles errors through handleApiError", async () => {
const error = new ApiNotFoundError("Not Found");
await expect(
withApiErrorHandling(
async () => {
throw error;
},
{
organizationSlug: "my-org",
issueId: "PROJ-123",
},
),
).rejects.toThrow(
"Resource not found (404): Not Found\nPlease verify these parameters are correct:\n - organizationSlug: 'my-org'\n - issueId: 'PROJ-123'",
);
});
});
```
--------------------------------------------------------------------------------
/benchmark-agent.sh:
--------------------------------------------------------------------------------
```bash
#!/bin/bash
# Benchmark script for comparing direct vs agent mode performance
# Usage: ./benchmark-agent.sh [iterations]
ITERATIONS=${1:-10}
QUERY="what organizations do I have access to?"
echo "=========================================="
echo "MCP Agent Performance Benchmark"
echo "=========================================="
echo "Query: $QUERY"
echo "Iterations: $ITERATIONS"
echo ""
# Arrays to store timings
declare -a direct_times
declare -a agent_times
echo "Running direct mode tests..."
for i in $(seq 1 $ITERATIONS); do
echo -n " Run $i/$ITERATIONS... "
# Run and capture timing (real time in seconds)
START=$(date +%s.%N)
pnpm -w run cli "$QUERY" > /dev/null 2>&1
END=$(date +%s.%N)
# Calculate duration
DURATION=$(echo "$END - $START" | bc)
direct_times+=($DURATION)
echo "${DURATION}s"
done
echo ""
echo "Running agent mode tests..."
for i in $(seq 1 $ITERATIONS); do
echo -n " Run $i/$ITERATIONS... "
# Run and capture timing
START=$(date +%s.%N)
pnpm -w run cli --agent "$QUERY" > /dev/null 2>&1
END=$(date +%s.%N)
# Calculate duration
DURATION=$(echo "$END - $START" | bc)
agent_times+=($DURATION)
echo "${DURATION}s"
done
echo ""
echo "=========================================="
echo "Results"
echo "=========================================="
# Calculate statistics for direct mode
direct_sum=0
direct_min=${direct_times[0]}
direct_max=${direct_times[0]}
for time in "${direct_times[@]}"; do
direct_sum=$(echo "$direct_sum + $time" | bc)
if (( $(echo "$time < $direct_min" | bc -l) )); then
direct_min=$time
fi
if (( $(echo "$time > $direct_max" | bc -l) )); then
direct_max=$time
fi
done
direct_avg=$(echo "scale=2; $direct_sum / $ITERATIONS" | bc)
# Calculate statistics for agent mode
agent_sum=0
agent_min=${agent_times[0]}
agent_max=${agent_times[0]}
for time in "${agent_times[@]}"; do
agent_sum=$(echo "$agent_sum + $time" | bc)
if (( $(echo "$time < $agent_min" | bc -l) )); then
agent_min=$time
fi
if (( $(echo "$time > $agent_max" | bc -l) )); then
agent_max=$time
fi
done
agent_avg=$(echo "scale=2; $agent_sum / $ITERATIONS" | bc)
# Calculate difference
diff=$(echo "scale=2; $agent_avg - $direct_avg" | bc)
percent=$(echo "scale=1; ($agent_avg - $direct_avg) / $direct_avg * 100" | bc)
echo ""
echo "Direct Mode:"
echo " Min: ${direct_min}s"
echo " Max: ${direct_max}s"
echo " Average: ${direct_avg}s"
echo ""
echo "Agent Mode:"
echo " Min: ${agent_min}s"
echo " Max: ${agent_max}s"
echo " Average: ${agent_avg}s"
echo ""
echo "Difference:"
if (( $(echo "$diff > 0" | bc -l) )); then
echo " +${diff}s (${percent}% slower)"
elif (( $(echo "$diff < 0" | bc -l) )); then
abs_diff=$(echo "scale=2; -1 * $diff" | bc)
abs_percent=$(echo "scale=1; -1 * $percent" | bc)
echo " -${abs_diff}s (${abs_percent}% faster)"
else
echo " No difference (0%)"
fi
echo ""
# Show all individual results
echo "All timings:"
echo " Direct: ${direct_times[*]}"
echo " Agent: ${agent_times[*]}"
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/ui/interactive-markdown.tsx:
--------------------------------------------------------------------------------
```typescript
/**
* Markdown component that makes slash commands clickable
*/
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/client/lib/utils";
import { Markdown } from "./markdown";
interface InteractiveMarkdownProps {
children: string;
className?: string;
hasSlashCommands?: boolean;
onSlashCommand?: (command: string) => void;
}
export function InteractiveMarkdown({
children,
className,
hasSlashCommands,
onSlashCommand,
}: InteractiveMarkdownProps) {
// If this content has slash commands and we have a handler, create custom renderer
if (hasSlashCommands && onSlashCommand) {
return (
<ReactMarkdown
className={cn(
"prose prose-invert prose-slate max-w-none",
"prose-p:my-2 prose-p:leading-relaxed",
"prose-pre:bg-slate-900 prose-pre:border prose-pre:border-slate-700",
"prose-code:bg-slate-800 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-sm",
"prose-code:before:content-none prose-code:after:content-none",
"prose-strong:text-slate-100",
"prose-em:text-slate-200",
"prose-a:text-violet-300",
"prose-blockquote:border-l-violet-500 prose-blockquote:bg-slate-800/50 prose-blockquote:py-2 prose-blockquote:px-4",
"prose-h1:text-slate-100 prose-h2:text-slate-100 prose-h3:text-slate-100",
"prose-h4:text-slate-100 prose-h5:text-slate-100 prose-h6:text-slate-100",
"prose-ul:my-2 prose-ol:my-2",
"prose-li:my-1",
"prose-hr:border-slate-700",
"prose-table:border-slate-700",
"prose-th:border-slate-700 prose-td:border-slate-700",
className,
)}
remarkPlugins={[remarkGfm]}
disallowedElements={["script", "style", "iframe", "object", "embed"]}
unwrapDisallowed={true}
components={{
// Custom renderer for code that might contain slash commands
code: ({ children, ref, ...props }) => {
const text = String(children);
if (text.startsWith("/") && text.match(/^\/[a-zA-Z]+$/)) {
// This is a slash command, make it clickable
const command = text.slice(1);
return (
<button
onClick={() => onSlashCommand(command)}
className="inline-flex items-center gap-1 px-1 py-0.5 text-xs bg-blue-900/50 border border-blue-700/50 rounded text-blue-300 hover:bg-blue-800/50 hover:border-blue-600/50 transition-colors font-mono cursor-pointer"
type="button"
{...props}
>
{text}
</button>
);
}
// Regular code rendering
return (
<code ref={ref as any} {...props}>
{children}
</code>
);
},
}}
>
{children}
</ReactMarkdown>
);
}
// Otherwise, render as normal markdown
return <Markdown className={className}>{children}</Markdown>;
}
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/animation/browser-ui/IssueDetails.tsx:
--------------------------------------------------------------------------------
```typescript
import { ChevronDown } from "lucide-react";
export default function IssueDetails({ step }: { step: number }) {
return (
<>
<div
className={`${
step === 1 ? "opacity-100" : step > 1 ? "opacity-0" : "opacity-40"
} rounded-xl border border-white/0 bg-white/0 duration-300`}
id="stack-trace-container"
>
<div className="w-full border-white/5 flex justify-between items-center border-b bg-white/0 p-3">
Highlights
<ChevronDown className="h-5 w-5 text-white/50" />
</div>
<div className="w-full p-3 flex items-center justify-between">
Stack Trace
<ChevronDown className="h-5 w-5 text-white/50 -scale-y-100" />
</div>
<div className="relative w-[calc(100%-1rem)] m-2 border border-white/10 bg-white/5 rounded-xl">
<div
className={`${
step === 1
? "motion-reduce:opacity-0 motion-reduce:duration-1000 motion-reduce:delay-800 motion-reduce:!animate-none animate-issue-context opacity-30"
: "opacity-0"
} pb-4 rounded-xl absolute inset-0 border border-white/20 bg-pink-900 text-pink100`}
style={{ ["--delay" as any]: "0.8s" }}
>
<div className="h-full w-full rounded-xl border border-white/20 bg-white/10 pb-4" />
</div>
<div
className={`${
step === 1
? "motion-reduce:opacity-0 motion-reduce:duration-1000 motion-reduce:delay-1000 motion-reduce:!animate-none animate-issue-context opacity-30"
: "opacity-0"
} pb-4 rounded-xl absolute inset-0 border border-white/20 bg-pink-900 text-pink100`}
style={{ ["--delay" as any]: "1s" }}
>
<div className="h-full w-full rounded-xl border border-white/20 bg-white/10 pb-4" />
</div>
<div
className={`${
step === 1
? "motion-reduce:opacity-0 motion-reduce:duration-1000 motion-reduce:delay-1200 motion-reduce:!animate-none animate-issue-context opacity-30"
: "opacity-0"
} pb-4 rounded-xl absolute inset-0 border border-white/20 bg-pink-900 text-pink100`}
style={{ ["--delay" as any]: "1.2s" }}
>
<div className="h-full w-full rounded-xl border border-white/20 bg-white/10 pb-4" />
</div>
<div
className={`${
step === 1
? "motion-reduce:opacity-0 motion-reduce:duration-1000 motion-reduce:delay-675 motion-reduce:!animate-none animate-issue-context"
: step > 1
? "opacity-0"
: "opacity-100"
} pb-4 rounded-xl border border-white/20 bg-pink-900 text-pink100`}
style={{ ["--delay" as any]: "0.675s" }}
>
<div className="h-full w-full rounded-xl border border-white/20 bg-white/10 pb-4">
<pre>
{`
Error: Something went wrong
at main.js:123
at index.js:456`}
</pre>
</div>
</div>
</div>
</div>
</>
);
}
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/agents/openai-provider.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { getOpenAIModel, setOpenAIBaseUrl } from "./openai-provider.js";
describe("openai-provider", () => {
const originalEnv = process.env.OPENAI_REASONING_EFFORT;
beforeEach(() => {
setOpenAIBaseUrl(undefined);
});
afterEach(() => {
if (originalEnv === undefined) {
// biome-ignore lint/performance/noDelete: Required to properly unset environment variable
delete process.env.OPENAI_REASONING_EFFORT;
} else {
process.env.OPENAI_REASONING_EFFORT = originalEnv;
}
});
describe("reasoning effort configuration", () => {
it("uses default reasoning effort when env var is not set", () => {
// biome-ignore lint/performance/noDelete: Required to properly unset environment variable
delete process.env.OPENAI_REASONING_EFFORT;
const model = getOpenAIModel();
// The model object should be created with default reasoning effort
expect(model).toBeDefined();
expect(model.modelId).toBe("gpt-5");
});
it("disables reasoning effort when env var is empty string", () => {
process.env.OPENAI_REASONING_EFFORT = "";
const model = getOpenAIModel();
// The model object should be created without reasoning effort
expect(model).toBeDefined();
expect(model.modelId).toBe("gpt-5");
});
it("uses specified reasoning effort when env var is set", () => {
process.env.OPENAI_REASONING_EFFORT = "high";
const model = getOpenAIModel();
// The model object should be created with high reasoning effort
expect(model).toBeDefined();
expect(model.modelId).toBe("gpt-5");
});
it("throws error for invalid reasoning effort value", () => {
process.env.OPENAI_REASONING_EFFORT = "invalid";
expect(() => getOpenAIModel()).toThrow(
'Invalid OPENAI_REASONING_EFFORT value: "invalid". Must be one of: "low", "medium", "high", or "" (empty string to disable). Default is "low".',
);
});
});
describe("base URL configuration", () => {
it("uses default base URL when not configured", () => {
const model = getOpenAIModel();
expect(model).toBeDefined();
expect(model.modelId).toBe("gpt-5");
});
it("uses configured base URL", () => {
setOpenAIBaseUrl("https://custom-openai.example.com");
const model = getOpenAIModel();
expect(model).toBeDefined();
expect(model.modelId).toBe("gpt-5");
});
});
describe("model override", () => {
it("uses default model when not specified", () => {
const model = getOpenAIModel();
expect(model.modelId).toBe("gpt-5");
});
it("uses specified model when provided", () => {
const model = getOpenAIModel("gpt-4");
expect(model.modelId).toBe("gpt-4");
});
it("uses OPENAI_MODEL env var when set", () => {
process.env.OPENAI_MODEL = "gpt-4o";
const model = getOpenAIModel();
expect(model.modelId).toBe("gpt-4o");
// biome-ignore lint/performance/noDelete: Required to properly unset environment variable
delete process.env.OPENAI_MODEL;
});
});
});
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/tools/find-organizations.ts:
--------------------------------------------------------------------------------
```typescript
import { defineTool } from "../internal/tool-helpers/define";
import { apiServiceFromContext } from "../internal/tool-helpers/api";
import type { ServerContext } from "../types";
import { ParamSearchQuery } from "../schema";
import { ALL_SKILLS } from "../skills";
const RESULT_LIMIT = 25;
export default defineTool({
name: "find_organizations",
skills: ALL_SKILLS, // Foundational tool - available to all skills
requiredScopes: ["org:read"],
description: [
"Find organizations that the user has access to in Sentry.",
"",
"Use this tool when you need to:",
"- View organizations in Sentry",
"- Find an organization's slug to aid other tool requests",
"- Search for specific organizations by name or slug",
"",
`Returns up to ${RESULT_LIMIT} results. If you hit this limit, use the query parameter to narrow down results.`,
].join("\n"),
inputSchema: {
query: ParamSearchQuery.nullable().default(null),
},
annotations: {
readOnlyHint: true,
openWorldHint: true,
},
async handler(params, context: ServerContext) {
// User data endpoints (like /users/me/regions/) should never use regionUrl
// as they must always query the main API server, not region-specific servers
const apiService = apiServiceFromContext(context);
const organizations = await apiService.listOrganizations({
query: params.query ?? undefined,
});
let output = "# Organizations\n\n";
if (params.query) {
output += `**Search query:** "${params.query}"\n\n`;
}
if (organizations.length === 0) {
output += params.query
? `No organizations found matching "${params.query}".\n`
: "You don't appear to be a member of any organizations.\n";
return output;
}
output += organizations
.map((org) =>
[
`## **${org.slug}**`,
"",
`**Web URL:** ${org.links?.organizationUrl || "Not available"}`,
`**Region URL:** ${org.links?.regionUrl || ""}`,
].join("\n"),
)
.join("\n\n");
if (organizations.length === RESULT_LIMIT) {
output += `\n\n---\n\n**Note:** Showing ${RESULT_LIMIT} results (maximum). There may be more organizations available. Use the \`query\` parameter to search for specific organizations.`;
}
output += "\n\n# Using this information\n\n";
output += `- The organization's name is the identifier for the organization, and is used in many tools for \`organizationSlug\`.\n`;
const hasValidRegionUrls = organizations.some((org) =>
org.links?.regionUrl?.trim(),
);
if (hasValidRegionUrls) {
output += `- If a tool supports passing in the \`regionUrl\`, you MUST pass in the correct value shown above for each organization.\n`;
output += `- For Sentry's Cloud Service (sentry.io), always use the regionUrl to ensure requests go to the correct region.\n`;
} else {
output += `- This appears to be a self-hosted Sentry installation. You can omit the \`regionUrl\` parameter when using other tools.\n`;
output += `- For self-hosted Sentry, the regionUrl is typically empty and not needed for API calls.\n`;
}
return output;
},
});
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/app.tsx:
--------------------------------------------------------------------------------
```typescript
import { Chat } from "./components/chat";
import { useAuth } from "./contexts/auth-context";
import { useState, useEffect } from "react";
import { Header } from "./components/ui/header";
import { HeaderDivider } from "./components/hero/header-divider";
import { Sidebars } from "./components/home-layout/sidebars";
import HeroBlock from "./components/hero/hero-block";
import UseCases from "./components/usecases";
import GettingStarted from "./components/getting-started";
import TableOfContents from "./components/docs/toc";
import Footer from "./components/home-layout/footer";
export default function App() {
const { isAuthenticated, handleLogout } = useAuth();
const [isChatOpen, setIsChatOpen] = useState(() => {
// Initialize based on URL query string only to avoid hydration issues
const urlParams = new URLSearchParams(window.location.search);
const hasQueryParam = urlParams.has("chat");
if (hasQueryParam) {
return urlParams.get("chat") !== "0";
}
// default to false for mobile and to avoid scroll lock on desktop
return false;
});
// Adjust initial state for mobile after component mounts
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
// Only adjust state if no URL parameter exists and we're on mobile
if (!urlParams.has("chat") && window.innerWidth < 768) {
setIsChatOpen(false);
}
}, []);
// Update URL when chat state changes
const toggleChat = (open: boolean) => {
setIsChatOpen(open);
if (open) {
// Add ?chat to URL
const newUrl = new URL(window.location.href);
newUrl.searchParams.set("chat", "1");
window.history.pushState({}, "", newUrl.toString());
} else {
// Remove query string for home page
const newUrl = new URL(window.location.href);
newUrl.search = "";
window.history.pushState({}, "", newUrl.toString());
}
};
// Handle browser back/forward navigation
useEffect(() => {
const handlePopState = () => {
const urlParams = new URLSearchParams(window.location.search);
const hasQueryParam = urlParams.has("chat");
if (hasQueryParam) {
setIsChatOpen(urlParams.get("chat") !== "0");
} else {
// default to closed on both desktop and mobile
setIsChatOpen(false);
}
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, []);
return (
<div className="overflow-x-clip max-w-screen relative">
{/* //!NOTE: order matters for z- */}
<Sidebars isChatOpen={isChatOpen} toggleChat={toggleChat} />
<Header toggleChat={toggleChat} isChatOpen={isChatOpen} />
<HeaderDivider />
<HeroBlock />
<UseCases />
<GettingStarted />
{/* main content */}
<div className="relative container mx-auto">
<aside className="max-xl:hidden absolute h-full right-15 inset-y-0">
<TableOfContents />
</aside>
</div>
<Chat
isOpen={isChatOpen}
onClose={() => toggleChat(false)}
onLogout={handleLogout}
/>
<Footer isChatOpen={isChatOpen} />
</div>
);
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/cli/parse.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from "vitest";
import { parseArgv, parseEnv, merge } from "./parse";
describe("cli/parseArgv", () => {
it("parses known flags and short aliases", () => {
const parsed = parseArgv([
"--access-token=tok",
"--host=sentry.io",
"--url=https://example.com",
"--mcp-url=https://mcp.example.com",
"--sentry-dsn=dsn",
"--openai-base-url=https://api.example.com/v1",
"--skills=inspect,triage",
"-h",
"-v",
]);
expect(parsed.accessToken).toBe("tok");
expect(parsed.host).toBe("sentry.io");
expect(parsed.url).toBe("https://example.com");
expect(parsed.mcpUrl).toBe("https://mcp.example.com");
expect(parsed.sentryDsn).toBe("dsn");
expect(parsed.openaiBaseUrl).toBe("https://api.example.com/v1");
expect(parsed.skills).toBe("inspect,triage");
expect(parsed.help).toBe(true);
expect(parsed.version).toBe(true);
expect(parsed.unknownArgs).toEqual([]);
});
it("parses skills flags", () => {
const parsed = parseArgv(["--access-token=tok", "--skills=inspect,triage"]);
expect(parsed.accessToken).toBe("tok");
expect(parsed.skills).toBe("inspect,triage");
});
it("collects unknown args", () => {
const parsed = parseArgv(["--unknown", "--another=1"]);
expect(parsed.unknownArgs.length).toBeGreaterThan(0);
});
});
describe("cli/parseEnv", () => {
it("parses environment variables including skills", () => {
const env = parseEnv({
SENTRY_ACCESS_TOKEN: "envtok",
SENTRY_HOST: "envhost",
MCP_URL: "envmcp",
SENTRY_DSN: "envdsn",
MCP_SKILLS: "inspect,triage",
} as any);
expect(env.accessToken).toBe("envtok");
expect(env.host).toBe("envhost");
expect(env.mcpUrl).toBe("envmcp");
expect(env.sentryDsn).toBe("envdsn");
expect(env.skills).toBe("inspect,triage");
});
});
describe("cli/merge", () => {
it("applies precedence: CLI over env", () => {
const env = parseEnv({
SENTRY_ACCESS_TOKEN: "envtok",
SENTRY_HOST: "envhost",
MCP_URL: "envmcp",
SENTRY_DSN: "envdsn",
} as any);
const cli = parseArgv([
"--access-token=clitok",
"--host=clihost",
"--mcp-url=climcp",
"--sentry-dsn=clidsn",
"--openai-base-url=https://api.cli/v1",
]);
const merged = merge(cli, env);
expect(merged.accessToken).toBe("clitok");
expect(merged.host).toBe("clihost");
expect(merged.mcpUrl).toBe("climcp");
expect(merged.sentryDsn).toBe("clidsn");
expect(merged.openaiBaseUrl).toBe("https://api.cli/v1");
});
it("applies precedence for skills: CLI over env", () => {
const env = parseEnv({
SENTRY_ACCESS_TOKEN: "envtok",
MCP_SKILLS: "inspect",
} as any);
const cli = parseArgv(["--access-token=clitok", "--skills=inspect,triage"]);
const merged = merge(cli, env);
expect(merged.skills).toBe("inspect,triage");
});
it("falls back to env when CLI skills not provided", () => {
const env = parseEnv({
SENTRY_ACCESS_TOKEN: "envtok",
MCP_SKILLS: "inspect,triage",
} as any);
const cli = parseArgv(["--access-token=clitok"]);
const merged = merge(cli, env);
expect(merged.skills).toBe("inspect,triage");
});
});
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/agents/callEmbeddedAgent.ts:
--------------------------------------------------------------------------------
```typescript
import { generateText, Output, type Tool } from "ai";
import { getOpenAIModel } from "./openai-provider";
import { UserInputError } from "../../errors";
import type { z } from "zod";
export type ToolCall = {
toolName: string;
args: unknown;
};
interface EmbeddedAgentResult<T> {
result: T;
toolCalls: ToolCall[];
}
/**
* Call an embedded agent with tool call capture
* This is the standard way to call embedded AI agents within MCP tools
*
* Error handling:
* - Errors are re-thrown for the calling agent to handle
* - Each agent can implement its own error handling strategy
*/
export async function callEmbeddedAgent<
TOutput,
TSchema extends z.ZodType<TOutput, z.ZodTypeDef, unknown>,
>({
system,
prompt,
tools,
schema,
}: {
system: string;
prompt: string;
tools: Record<string, Tool>;
schema: TSchema;
}): Promise<EmbeddedAgentResult<TOutput>> {
const capturedToolCalls: ToolCall[] = [];
const result = await generateText({
model: getOpenAIModel(), // Uses configured default model (gpt-5)
system,
prompt,
tools,
maxSteps: 5,
temperature: 1, // GPT-5 only supports temperature of 1
experimental_output: Output.object({ schema }),
experimental_telemetry: {
isEnabled: true,
functionId: "callEmbeddedAgent",
},
// Disable strict schema validation for both output and tool parameter schemas.
//
// OpenAI's structured outputs have limitations:
// - structuredOutputs: true (default) enforces strict mode for BOTH output and tool schemas
// - This requires ALL properties to be in the "required" array, breaking .optional()/.nullable()
//
// By setting both to false:
// - structuredOutputs: false - Disables strict mode for tool parameter schemas
// - strictJsonSchema: false - Disables strict mode for output schema
// - We still get valid JSON, just without the strict "all fields required" constraint
//
// See:
// - Issue: https://github.com/getsentry/sentry-mcp/issues/623
// - AI SDK docs: https://ai-sdk.dev/providers/ai-sdk-providers/openai#structuredoutputs
// - OpenAI docs: https://platform.openai.com/docs/guides/structured-outputs
providerOptions: {
openai: {
structuredOutputs: false,
strictJsonSchema: false,
},
},
onStepFinish: (event) => {
if (event.toolCalls && event.toolCalls.length > 0) {
for (const toolCall of event.toolCalls) {
capturedToolCalls.push({
toolName: toolCall.toolName,
args: toolCall.args,
});
}
}
},
});
if (!result.experimental_output) {
throw new Error("Failed to generate output");
}
const rawOutput = result.experimental_output;
if (
typeof rawOutput === "object" &&
rawOutput !== null &&
"error" in rawOutput &&
typeof (rawOutput as { error?: unknown }).error === "string"
) {
throw new UserInputError((rawOutput as { error: string }).error);
}
const parsedResult = schema.safeParse(rawOutput);
if (!parsedResult.success) {
throw new UserInputError(
`Invalid agent response: ${parsedResult.error.message}`,
);
}
return {
result: parsedResult.data,
toolCalls: capturedToolCalls,
};
}
```
--------------------------------------------------------------------------------
/packages/mcp-server-evals/src/evals/search-events.eval.ts:
--------------------------------------------------------------------------------
```typescript
import { describeEval } from "vitest-evals";
import { FIXTURES, NoOpTaskRunner, ToolPredictionScorer } from "./utils";
// Note: This eval requires OPENAI_API_KEY to be set in the environment
// The search_events tool uses the AI SDK to translate natural language queries
describeEval("search-events", {
data: async () => {
return [
// Core test: Basic error event search
{
input: `Find database timeouts in ${FIXTURES.organizationSlug} from the last week`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "search_events",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
naturalLanguageQuery: "database timeouts from the last week",
dataset: "errors",
},
},
],
},
// Core test: Performance spans search
{
input: `Find slow API calls taking over 5 seconds in ${FIXTURES.organizationSlug}`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "search_events",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
naturalLanguageQuery: "slow API calls taking over 5 seconds",
dataset: "spans",
},
},
],
},
// Core test: Logs search
{
input: `Show me error logs from the last hour in ${FIXTURES.organizationSlug}`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "search_events",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
naturalLanguageQuery: "error logs from the last hour",
dataset: "logs",
},
},
],
},
// Core test: Project-specific search
{
input: `Show me authentication errors in ${FIXTURES.organizationSlug}/${FIXTURES.projectSlug}`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "search_events",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
projectSlug: FIXTURES.projectSlug,
naturalLanguageQuery: "authentication errors",
dataset: "errors",
},
},
],
},
// Core test: Search with 'me' reference
{
input: `Show me errors affecting me in ${FIXTURES.organizationSlug}`,
expectedTools: [
{
name: "find_organizations",
arguments: {},
},
{
name: "whoami",
arguments: {},
},
{
name: "search_events",
arguments: {
organizationSlug: FIXTURES.organizationSlug,
naturalLanguageQuery: "errors affecting user.id:12345",
dataset: "errors",
},
},
],
},
];
},
task: NoOpTaskRunner(),
scorers: [ToolPredictionScorer()],
threshold: 0.6,
timeout: 30000,
});
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/telem/sentry.ts:
--------------------------------------------------------------------------------
```typescript
interface ScrubPattern {
pattern: RegExp;
replacement: string;
description: string;
}
// Patterns for sensitive data that should be scrubbed
// Pre-compile patterns with global flag for replacement
const SCRUB_PATTERNS: ScrubPattern[] = [
{
pattern: /\bsk-[a-zA-Z0-9]{48}\b/g,
replacement: "[REDACTED_OPENAI_KEY]",
description: "OpenAI API key",
},
{
pattern: /\bBearer\s+[a-zA-Z0-9\-._~+/]+={0,}/g,
replacement: "Bearer [REDACTED_TOKEN]",
description: "Bearer token",
},
{
pattern: /\bsntrys_[a-zA-Z0-9_]+\b/g,
replacement: "[REDACTED_SENTRY_TOKEN]",
description: "Sentry access token",
},
];
// Maximum depth for recursive scrubbing to prevent stack overflow
const MAX_SCRUB_DEPTH = 20;
/**
* Recursively scrub sensitive data from any value.
* Returns tuple of [scrubbedValue, didScrub, descriptionsOfMatchedPatterns]
*/
function scrubValue(value: unknown, depth = 0): [unknown, boolean, string[]] {
// Prevent stack overflow by limiting recursion depth
if (depth >= MAX_SCRUB_DEPTH) {
return ["[MAX_DEPTH_EXCEEDED]", false, []];
}
if (typeof value === "string") {
let scrubbed = value;
let didScrub = false;
const matchedDescriptions: string[] = [];
for (const { pattern, replacement, description } of SCRUB_PATTERNS) {
// Reset lastIndex to avoid stateful regex issues
pattern.lastIndex = 0;
if (pattern.test(scrubbed)) {
didScrub = true;
matchedDescriptions.push(description);
// Reset again before replace
pattern.lastIndex = 0;
scrubbed = scrubbed.replace(pattern, replacement);
}
}
return [scrubbed, didScrub, matchedDescriptions];
}
if (Array.isArray(value)) {
let arrayDidScrub = false;
const arrayDescriptions: string[] = [];
const scrubbedArray = value.map((item) => {
const [scrubbed, didScrub, descriptions] = scrubValue(item, depth + 1);
if (didScrub) {
arrayDidScrub = true;
arrayDescriptions.push(...descriptions);
}
return scrubbed;
});
return [scrubbedArray, arrayDidScrub, arrayDescriptions];
}
if (value && typeof value === "object") {
let objectDidScrub = false;
const objectDescriptions: string[] = [];
const scrubbed: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
const [scrubbedVal, didScrub, descriptions] = scrubValue(val, depth + 1);
if (didScrub) {
objectDidScrub = true;
objectDescriptions.push(...descriptions);
}
scrubbed[key] = scrubbedVal;
}
return [scrubbed, objectDidScrub, objectDescriptions];
}
return [value, false, []];
}
/**
* Sentry beforeSend hook that scrubs sensitive data from events
*/
export function sentryBeforeSend(event: any, hint: any): any {
// Always scrub the entire event
const [scrubbedEvent, didScrub, descriptions] = scrubValue(event);
// Log to console if we found and scrubbed sensitive data
// (avoiding LogTape dependency for edge/browser compatibility)
if (didScrub) {
const uniqueDescriptions = [...new Set(descriptions)];
console.warn(
`[Sentry] Event contained sensitive data: ${uniqueDescriptions.join(", ")}`,
);
}
return scrubbedEvent as any;
}
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/lib/constraint-utils.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from "vitest";
import "urlpattern-polyfill";
import { verifyConstraintsAccess } from "./constraint-utils";
describe("verifyConstraintsAccess", () => {
const token = "test-token";
const host = "sentry.io";
it("returns ok with empty constraints when no org constraint provided", async () => {
const result = await verifyConstraintsAccess(
{ organizationSlug: null, projectSlug: null },
{ accessToken: token, sentryHost: host },
);
expect(result).toEqual({
ok: true,
constraints: {
organizationSlug: null,
projectSlug: null,
regionUrl: null,
},
});
});
it("fails when access token is missing, null, undefined, or empty", async () => {
const testCases = [
{ accessToken: "", label: "empty" },
{ accessToken: null, label: "null" },
{ accessToken: undefined, label: "undefined" },
];
for (const { accessToken, label } of testCases) {
const result = await verifyConstraintsAccess(
{ organizationSlug: "org", projectSlug: null },
{ accessToken, sentryHost: host },
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(401);
expect(result.message).toBe(
"Missing access token for constraint verification",
);
}
}
});
it("successfully verifies org access and returns constraints with regionUrl", async () => {
const result = await verifyConstraintsAccess(
{ organizationSlug: "sentry-mcp-evals", projectSlug: null },
{ accessToken: token, sentryHost: host },
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.constraints).toEqual({
organizationSlug: "sentry-mcp-evals",
projectSlug: null,
regionUrl: "https://us.sentry.io",
});
}
});
it("successfully verifies org and project access", async () => {
const result = await verifyConstraintsAccess(
{ organizationSlug: "sentry-mcp-evals", projectSlug: "cloudflare-mcp" },
{ accessToken: token, sentryHost: host },
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.constraints).toEqual({
organizationSlug: "sentry-mcp-evals",
projectSlug: "cloudflare-mcp",
regionUrl: "https://us.sentry.io",
});
}
});
it("fails when org does not exist", async () => {
const result = await verifyConstraintsAccess(
{ organizationSlug: "nonexistent-org", projectSlug: null },
{ accessToken: token, sentryHost: host },
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(404);
expect(result.message).toBe("Organization 'nonexistent-org' not found");
}
});
it("fails when project does not exist", async () => {
const result = await verifyConstraintsAccess(
{
organizationSlug: "sentry-mcp-evals",
projectSlug: "nonexistent-project",
},
{ accessToken: token, sentryHost: host },
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(404);
expect(result.message).toBe(
"Project 'nonexistent-project' not found in organization 'sentry-mcp-evals'",
);
}
});
});
```
--------------------------------------------------------------------------------
/packages/mcp-server-evals/src/evals/search-issues-agent.eval.ts:
--------------------------------------------------------------------------------
```typescript
import { describeEval } from "vitest-evals";
import { ToolCallScorer } from "vitest-evals";
import { searchIssuesAgent } from "@sentry/mcp-core/tools/search-issues/agent";
import { SentryApiService } from "@sentry/mcp-core/api-client";
import { StructuredOutputScorer } from "./utils/structuredOutputScorer";
import "../setup-env";
// The shared MSW server is already started in setup-env.ts
describeEval("search-issues-agent", {
data: async () => {
return [
{
// Simple query with common fields - should NOT require tool calls
input: "Show me unresolved issues",
expectedTools: [],
expected: {
query: "is:unresolved",
sort: "date", // Agent uses "date" as default
},
},
{
// Query with "me" reference - should only require whoami
input: "Show me issues assigned to me",
expectedTools: [
{
name: "whoami",
arguments: {},
},
],
expected: {
query:
/assignedOrSuggested:test@example\.com|assigned:test@example\.com|assigned:me/, // Various valid forms
sort: "date",
},
},
{
// Complex query but with common fields - should NOT require tool calls
// NOTE: AI often incorrectly uses firstSeen instead of lastSeen - known limitation
input: "Show me critical unhandled errors from the last 24 hours",
expectedTools: [],
expected: {
query: /level:error.*is:unresolved.*lastSeen:-24h/,
sort: "date",
},
},
{
// Query with custom/uncommon field that would require discovery
input: "Show me issues with custom.payment.failed tag",
expectedTools: [
{
name: "issueFields",
arguments: {}, // No arguments needed anymore
},
],
expected: {
query: /custom\.payment\.failed|tags\[custom\.payment\.failed\]/, // Both syntaxes are valid for tags
sort: "date", // Agent should always return a sort value
},
},
{
// Another query requiring field discovery
input: "Find issues where the kafka.consumer.group is orders-processor",
expectedTools: [
{
name: "issueFields",
arguments: {}, // No arguments needed anymore
},
],
expected: {
query: "kafka.consumer.group:orders-processor",
sort: "date", // Agent should always return a sort value
},
},
];
},
task: async (input) => {
// Create a real API service that will use MSW mocks
const apiService = new SentryApiService({
accessToken: "test-token",
});
const agentResult = await searchIssuesAgent({
query: input,
organizationSlug: "sentry-mcp-evals",
apiService,
});
// Return in the format expected by ToolCallScorer
return {
result: JSON.stringify(agentResult.result),
toolCalls: agentResult.toolCalls.map((call: any) => ({
name: call.toolName,
arguments: call.args,
})),
};
},
scorers: [
ToolCallScorer(), // Validates tool calls
StructuredOutputScorer({ match: "fuzzy" }), // Validates the structured query output with flexible matching
],
});
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/tools/search-issues/config.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Configuration for the search-issues agent
*/
export const systemPrompt = `You are a Sentry issue search query translator. Convert natural language queries to Sentry issue search syntax.
IMPORTANT RULES:
1. Use Sentry issue search syntax, NOT SQL
2. Time ranges use relative notation: -24h, -7d, -30d
3. Comparisons: >, <, >=, <=
4. Boolean operators: AND, OR, NOT (or !)
5. Field values with spaces need quotes: environment:"dev server"
BUILT-IN FIELDS:
- is: Issue status (unresolved, resolved, ignored, archived)
- level: Severity level (error, warning, info, debug, fatal)
IMPORTANT: Almost NEVER use this field. Terms like "critical", "important", "severe" refer to IMPACT not level.
Only use if user explicitly says "error level", "warning level", etc.
- environment: Deployment environment (production, staging, development)
- release: Version/release identifier
- firstSeen: When the issue was FIRST encountered (use for "new issues", "started", "began")
WARNING: Excludes ongoing issues that started before the time window
- lastSeen: When the issue was LAST encountered (use for "from the last", "recent", "active")
This includes ALL issues seen during the time window, regardless of when they started
- assigned: Issues explicitly assigned to a user (email or "me")
- assignedOrSuggested: Issues assigned to OR suggested for a user (broader match)
- userCount: Number of unique users affected
- eventCount: Total number of events
COMMON QUERY PATTERNS:
- Unresolved issues: is:unresolved (NO level filter unless explicitly requested)
- Critical/important issues: is:unresolved with sort:freq or sort:user (NOT level:error)
- Recent activity: lastSeen:-24h
- New issues: firstSeen:-7d
- High impact: userCount:>100
- My work: assignedOrSuggested:me
SORTING RULES:
1. CRITICAL: Sort MUST go in the separate "sort" field, NEVER in the "query" field
- WRONG: query: "is:unresolved sort:user" ← Sort syntax in query field is FORBIDDEN
- CORRECT: query: "is:unresolved", sort: "user" ← Sort in separate field
2. AVAILABLE SORT OPTIONS:
- date: Last seen (default)
- freq: Event frequency
- new: First seen
- user: User count
3. IMPORTANT: Query field is for filtering only (is:, level:, environment:, etc.)
'ME' REFERENCES:
- When the user says "assigned to me" or similar, you MUST use the whoami tool to get the current user's email
- Replace "me" with the actual email address in the query
- Example: "assigned to me" → use whoami tool → assignedOrSuggested:[email protected]
EXAMPLES:
"critical bugs" → query: "level:error is:unresolved", sort: "date"
"worst issues affecting the most users" → query: "is:unresolved", sort: "user"
"assigned to [email protected]" → query: "assignedOrSuggested:[email protected]", sort: "date"
NEVER: query: "is:unresolved sort:user" ← Sort goes in separate field!
CRITICAL - TOOL RESPONSE HANDLING:
All tools return responses in this format: {error?: string, result?: data}
- If 'error' is present: The tool failed - analyze the error message and potentially retry with corrections
- If 'result' is present: The tool succeeded - use the result data for your query construction
- Always check for errors before using results
Always use the issueFields tool to discover available fields when needed.
Use the whoami tool when you need to resolve 'me' references.`;
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/chat/chat-ui.tsx:
--------------------------------------------------------------------------------
```typescript
/**
* Reusable chat UI component
* Extracts the common chat interface used in both mobile and desktop views
*/
import ScrollToBottom from "react-scroll-to-bottom";
import { Button } from "../ui/button";
import { ChatInput, ChatMessages } from ".";
import type { Message } from "ai/react";
// Constant empty function to avoid creating new instances on every render
const EMPTY_FUNCTION = () => {};
// Sample prompts for quick access
const SAMPLE_PROMPTS = [
{
label: "Help",
prompt: "/help",
},
{
label: "React SDK Usage",
prompt: "Show me how to set up the React SDK for error monitoring",
},
{
label: "Recent Issues",
prompt: "What are my most recent issues?",
},
] as const;
interface ChatUIProps {
messages: Message[];
input: string;
error?: Error | null;
isChatLoading: boolean;
isLocalStreaming?: boolean;
isMessageStreaming?: (messageId: string) => boolean;
isOpen?: boolean;
onInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
onStop?: () => void;
onRetry?: () => void;
onSlashCommand?: (command: string) => void;
onSendPrompt?: (prompt: string) => void;
}
export const ChatUI = ({
messages,
input,
error,
isChatLoading,
isLocalStreaming,
isMessageStreaming,
isOpen = true,
onInputChange,
onSubmit,
onStop,
onRetry,
onSlashCommand,
onSendPrompt,
}: ChatUIProps) => {
return (
<div className="h-full flex flex-col relative">
{/* Chat Messages - Scrollable area */}
<ScrollToBottom
className="flex-1 mb-18 flex overflow-y-auto"
scrollViewClassName="px-0"
followButtonClassName="hidden"
initialScrollBehavior="smooth"
>
<ChatMessages
messages={messages}
isChatLoading={isChatLoading}
isLocalStreaming={isLocalStreaming}
isMessageStreaming={isMessageStreaming}
error={error}
onRetry={onRetry}
onSlashCommand={onSlashCommand}
/>
</ScrollToBottom>
{/* Chat Input - Always pinned at bottom */}
<div className="py-4 px-6 bottom-0 left-0 right-0 absolute min-h-34 z-10">
<div className="w-full [mask-image:linear-gradient(to_bottom,transparent,red_4.5rem)] pointer-events-none absolute bottom-0 left-0 h-full -z-10 backdrop-blur-md bg-gradient-to-t from-background/80 xl:from-background to-background/20 xl:to-[#160f2433]" />
{/* Sample Prompt Buttons - Always visible above input */}
{onSendPrompt && (
<div className="mb-4 flex flex-wrap gap-2 justify-center xl:justify-end">
{SAMPLE_PROMPTS.map((samplePrompt) => (
<Button
key={samplePrompt.label}
type="button"
className="backdrop-blur"
onClick={() => onSendPrompt(samplePrompt.prompt)}
size="sm"
variant="outline"
>
{samplePrompt.label}
</Button>
))}
</div>
)}
<ChatInput
input={input}
isLoading={isChatLoading}
isOpen={isOpen}
onInputChange={onInputChange}
onSubmit={onSubmit}
onStop={onStop || EMPTY_FUNCTION}
onSlashCommand={onSlashCommand}
/>
</div>
</div>
);
};
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/tools/create-project.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from "zod";
import { setTag } from "@sentry/core";
import { defineTool } from "../internal/tool-helpers/define";
import { apiServiceFromContext } from "../internal/tool-helpers/api";
import { logIssue } from "../telem/logging";
import type { ServerContext } from "../types";
import type { ClientKey } from "../api-client/index";
import {
ParamOrganizationSlug,
ParamRegionUrl,
ParamTeamSlug,
ParamPlatform,
} from "../schema";
export default defineTool({
name: "create_project",
skills: ["project-management"], // Only available in project-management skill
requiredScopes: ["project:write", "team:read"],
description: [
"Create a new project in Sentry (includes DSN automatically).",
"",
"USE THIS TOOL WHEN USERS WANT TO:",
"- 'Create a new project'",
"- 'Set up a project for [app/service] with team [X]'",
"- 'I need a new Sentry project'",
"- Create project AND need DSN in one step",
"",
"DO NOT USE create_dsn after this - DSN is included in output.",
"",
"Be careful when using this tool!",
"",
"<examples>",
"### Create new project with team",
"```",
"create_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')",
"```",
"</examples>",
"",
"<hints>",
"- If the user passes a parameter in the form of name/otherName, its likely in the format of <organizationSlug>/<teamSlug>.",
"- If any parameter is ambiguous, you should clarify with the user what they meant.",
"</hints>",
].join("\n"),
inputSchema: {
organizationSlug: ParamOrganizationSlug,
regionUrl: ParamRegionUrl.nullable().default(null),
teamSlug: ParamTeamSlug,
name: z
.string()
.trim()
.describe(
"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.",
),
platform: ParamPlatform.nullable().default(null),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: true,
},
async handler(params, context: ServerContext) {
const apiService = apiServiceFromContext(context, {
regionUrl: params.regionUrl ?? undefined,
});
const organizationSlug = params.organizationSlug;
setTag("organization.slug", organizationSlug);
setTag("team.slug", params.teamSlug);
const project = await apiService.createProject({
organizationSlug,
teamSlug: params.teamSlug,
name: params.name,
platform: params.platform,
});
let clientKey: ClientKey | null = null;
try {
clientKey = await apiService.createClientKey({
organizationSlug,
projectSlug: project.slug,
name: "Default",
});
} catch (err) {
logIssue(err);
}
let output = `# New Project in **${organizationSlug}**\n\n`;
output += `**ID**: ${project.id}\n`;
output += `**Slug**: ${project.slug}\n`;
output += `**Name**: ${project.name}\n`;
if (clientKey) {
output += `**SENTRY_DSN**: ${clientKey?.dsn.public}\n\n`;
} else {
output += "**SENTRY_DSN**: There was an error fetching this value.\n\n";
}
output += "# Using this information\n\n";
output += `- You can reference the **SENTRY_DSN** value to initialize Sentry's SDKs.\n`;
output += `- You should always inform the user of the **SENTRY_DSN** and Project Slug values.\n`;
return output;
},
});
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/getting-started.tsx:
--------------------------------------------------------------------------------
```typescript
import { Button } from "./ui/button";
import { useState } from "react";
import RemoteSetup, { RemoteSetupTabs } from "./fragments/remote-setup";
import StdioSetup, { StdioSetupTabs } from "./fragments/stdio-setup";
import { Cable, Cloud } from "lucide-react";
export default function Integration() {
const [stdio, setStdio] = useState(false);
return (
<section
id="getting-started"
className="flex flex-col md:container mx-auto relative mb-12 -scroll-mt-8 border-b border-dashed border-white/20 max-w-full duration-300 will-change-contents"
>
<div className="absolute top-0 left-0 right-0 flex justify-start flex-col px-8 pt-4 pointer-events-none">
<div className="flex items-center text-xs bg-background-3 rounded-full p-1 sticky top-4 size-fit -translate-x-[1.5px] mx-auto z-20 border-[0.5px] border-violet-300/50 pointer-events-auto">
<Button
variant={!stdio ? "default" : "secondary"}
size="xs"
onClick={() => {
setStdio(false);
document
.getElementById("getting-started")
?.scrollIntoView({ behavior: "smooth", block: "start" });
// preserve current query string, only change the hash
const url = new URL(window.location.href);
url.hash = "#getting-started";
window.history.pushState(
window.history.state,
"",
url.toString(),
);
}}
className={`${!stdio && "shadow-sm"} rounded-full !pr-3 !pl-2`}
>
<Cloud className="size-4 fill-current" />
Cloud
</Button>
<Button
variant={stdio ? "default" : "secondary"}
size="xs"
onClick={() => {
setStdio(true);
document
.getElementById("getting-started")
?.scrollIntoView({ behavior: "smooth", block: "start" });
// preserve current query string, only change the hash
const url = new URL(window.location.href);
url.hash = "#getting-started";
window.history.pushState(
window.history.state,
"",
url.toString(),
);
}}
className={`${stdio && "shadow-sm"} rounded-full !pr-3 !pl-2`}
>
<Cable className="size-4" />
Stdio
</Button>
</div>
</div>
<div className="px-4 sm:px-8 pt-4 sm:pt-8 pb-4 border-b border-dashed border-white/20">
{/* Client installation tabs first */}
<div className="bg-dots bg-fixed p-4 sm:p-12 flex items-start justify-center mb-4 border border-dashed border-white/10 rounded-lg">
{!stdio ? <RemoteSetupTabs /> : <StdioSetupTabs />}
</div>
</div>
<div className="px-4 sm:px-8 pt-4 sm:pt-8 pb-4">
{/* Advanced options after */}
<div className="relative min-h-0">
{!stdio ? (
<div
key="cloud"
className="animate-in fade-in motion-safe:slide-in-from-left-4 duration-300"
>
<RemoteSetup />
</div>
) : (
<div
key="stdio-self-hosted"
className="animate-in fade-in motion-safe:slide-in-from-right-4 duration-300"
>
<StdioSetup />
</div>
)}
</div>
</div>
</section>
);
}
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/constants.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Constants for Sentry MCP server.
*
* Defines platform and framework combinations available in Sentry documentation.
*/
/**
* MCP Server identification
*/
export const MCP_SERVER_NAME = "Sentry MCP" as const;
/**
* Allowed region domains for sentry.io
* Only these specific domains are permitted when using Sentry's cloud service
* This is used to prevent SSRF attacks by restricting regionUrl to known domains
*/
export const SENTRY_ALLOWED_REGION_DOMAINS = new Set([
"sentry.io",
"us.sentry.io",
"de.sentry.io",
]);
/**
* Common Sentry platforms that have documentation available
*/
export const SENTRY_PLATFORMS_BASE = [
"javascript",
"python",
"java",
"dotnet",
"go",
"php",
"ruby",
"android",
"apple",
"unity",
"unreal",
"rust",
"elixir",
"kotlin",
"native",
"dart",
"godot",
"nintendo-switch",
"playstation",
"powershell",
"react-native",
"xbox",
] as const;
/**
* Platform-specific frameworks that have Sentry guides
*/
export const SENTRY_FRAMEWORKS: Record<string, string[]> = {
javascript: [
"nextjs",
"react",
"gatsby",
"remix",
"vue",
"angular",
"hono",
"svelte",
"express",
"fastify",
"astro",
"bun",
"capacitor",
"cloudflare",
"connect",
"cordova",
"deno",
"electron",
"ember",
"nuxt",
"solid",
"solidstart",
"sveltekit",
"tanstack-react",
"wasm",
"node",
"koa",
"nestjs",
"hapi",
],
python: [
"django",
"flask",
"fastapi",
"celery",
"tornado",
"pyramid",
"aiohttp",
"anthropic",
"airflow",
"aws-lambda",
"boto3",
"bottle",
"chalice",
"dramatiq",
"falcon",
"langchain",
"litestar",
"logging",
"loguru",
"openai",
"quart",
"ray",
"redis",
"rq",
"sanic",
"sqlalchemy",
"starlette",
],
dart: ["flutter"],
dotnet: [
"aspnetcore",
"maui",
"wpf",
"winforms",
"aspnet",
"aws-lambda",
"azure-functions",
"blazor-webassembly",
"entityframework",
"google-cloud-functions",
"extensions-logging",
"log4net",
"nlog",
"serilog",
"uwp",
"xamarin",
],
java: [
"spring",
"spring-boot",
"android",
"jul",
"log4j2",
"logback",
"servlet",
],
go: [
"echo",
"fasthttp",
"fiber",
"gin",
"http",
"iris",
"logrus",
"negroni",
"slog",
"zerolog",
],
php: ["laravel", "symfony"],
ruby: ["delayed_job", "rack", "rails", "resque", "sidekiq"],
android: ["kotlin"],
apple: ["ios", "macos", "watchos", "tvos", "visionos"],
kotlin: ["multiplatform"],
} as const;
/**
* All valid guides for Sentry docs search filtering.
* A guide can be either a platform (e.g., 'javascript') or a platform/framework combination (e.g., 'javascript/nextjs').
*/
export const SENTRY_GUIDES = [
// Base platforms
...SENTRY_PLATFORMS_BASE,
// Platform/guide combinations
...Object.entries(SENTRY_FRAMEWORKS).flatMap(([platform, guides]) =>
guides.map((guide) => `${platform}/${guide}`),
),
] as const;
export const DEFAULT_SCOPES = [
"org:read",
"project:read",
"team:read",
"event:read",
] as const;
// Re-export DEFAULT_SKILLS from skills.ts for convenience
// (Skills are the new user-facing authorization system)
export { DEFAULT_SKILLS } from "./skills";
// Note: All scopes are now exported from permissions.ts to avoid pulling this
// heavy constants module into scope-only consumers.
```
--------------------------------------------------------------------------------
/packages/mcp-test-client/src/mcp-test-client-remote.ts:
--------------------------------------------------------------------------------
```typescript
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { startNewTrace, startSpan } from "@sentry/core";
import { OAuthClient } from "./auth/oauth.js";
import { DEFAULT_MCP_URL } from "./constants.js";
import { logError, logSuccess } from "./logger.js";
import type { MCPConnection, RemoteMCPConfig } from "./types.js";
import { randomUUID } from "node:crypto";
import { LIB_VERSION } from "./version.js";
export async function connectToRemoteMCPServer(
config: RemoteMCPConfig,
): Promise<MCPConnection> {
const sessionId = randomUUID();
return await startNewTrace(async () => {
return await startSpan(
{
name: "mcp.connect/http",
attributes: {
"mcp.transport": "http",
"gen_ai.conversation.id": sessionId,
"service.version": LIB_VERSION,
},
},
async (span) => {
try {
const mcpHost = config.mcpHost || DEFAULT_MCP_URL;
// Remove custom attributes - let SDK handle standard attributes
let accessToken = config.accessToken;
// If no access token provided, we need to authenticate
if (!accessToken) {
await startSpan(
{
name: "mcp.auth/oauth",
},
async (authSpan) => {
try {
const oauthClient = new OAuthClient({
mcpHost: mcpHost,
});
accessToken = await oauthClient.getAccessToken();
authSpan.setStatus({ code: 1 });
} catch (error) {
authSpan.setStatus({ code: 2 });
logError(
"OAuth authentication failed",
error instanceof Error ? error : String(error),
);
throw error;
}
},
);
}
// Create HTTP streaming client with authentication
// Use ?agent=1 query param for agent mode, otherwise standard /mcp
const mcpUrl = new URL(`${mcpHost}/mcp`);
if (config.useAgentEndpoint) {
mcpUrl.searchParams.set("agent", "1");
}
const httpTransport = new StreamableHTTPClientTransport(mcpUrl, {
requestInit: {
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
});
const client = await experimental_createMCPClient({
name: "mcp.sentry.dev (test-client)",
transport: httpTransport,
});
// Discover available tools
const toolsMap = await client.tools();
const tools = new Map<string, any>();
for (const [name, tool] of Object.entries(toolsMap)) {
tools.set(name, tool);
}
// Remove custom attributes - let SDK handle standard attributes
span.setStatus({ code: 1 });
logSuccess(
`Connected to MCP server (${mcpHost})`,
`${tools.size} tools available`,
);
const disconnect = async () => {
await client.close();
};
return {
client,
tools,
disconnect,
sessionId,
transport: "http" as const,
};
} catch (error) {
span.setStatus({ code: 2 });
throw error;
}
},
);
});
}
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/tools/get-event-attachment.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect } from "vitest";
import getEventAttachment from "./get-event-attachment.js";
describe("get_event_attachment", () => {
it("lists attachments for an event", async () => {
const result = await getEventAttachment.handler(
{
organizationSlug: "sentry-mcp-evals",
projectSlug: "cloudflare-mcp",
eventId: "7ca573c0f4814912aaa9bdc77d1a7d51",
attachmentId: null,
regionUrl: null,
},
{
constraints: {
organizationSlug: null,
projectSlug: null,
},
accessToken: "access-token",
userId: "1",
},
);
expect(result).toMatchInlineSnapshot(`
"# Event Attachments
**Event ID:** 7ca573c0f4814912aaa9bdc77d1a7d51
**Project:** cloudflare-mcp
Found 1 attachment(s):
## Attachment 1
**ID:** 123
**Name:** screenshot.png
**Type:** event.attachment
**Size:** 1024 bytes
**MIME Type:** image/png
**Created:** 2025-04-08T21:15:04.000Z
**SHA1:** abc123def456
To download this attachment, use the "get_event_attachment" tool with the attachmentId provided:
\`get_event_attachment(organizationSlug="sentry-mcp-evals", projectSlug="cloudflare-mcp", eventId="7ca573c0f4814912aaa9bdc77d1a7d51", attachmentId="123")\`
"
`);
});
it("downloads a specific attachment by ID", async () => {
const result = await getEventAttachment.handler(
{
organizationSlug: "sentry-mcp-evals",
projectSlug: "cloudflare-mcp",
eventId: "7ca573c0f4814912aaa9bdc77d1a7d51",
attachmentId: "123",
regionUrl: null,
},
{
constraints: {
organizationSlug: null,
projectSlug: null,
},
accessToken: "access-token",
userId: "1",
},
);
// Should return an array with both text description and image content
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
// First item should be the image content
expect(result[0]).toMatchObject({
type: "image",
mimeType: "image/png",
data: expect.any(String), // base64 encoded data
});
// Second item should be the text description
expect(result[1]).toMatchInlineSnapshot(`
{
"text": "# Event Attachment Download
**Event ID:** 7ca573c0f4814912aaa9bdc77d1a7d51
**Attachment ID:** 123
**Filename:** screenshot.png
**Type:** event.attachment
**Size:** 1024 bytes
**MIME Type:** image/png
**Created:** 2025-04-08T21:15:04.000Z
**SHA1:** abc123def456
**Download URL:** https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/events/7ca573c0f4814912aaa9bdc77d1a7d51/attachments/123/?download=1
## Binary Content
The attachment is included as a resource and accessible through your client.
",
"type": "text",
}
`);
});
it("throws error for malformed regionUrl", async () => {
await expect(
getEventAttachment.handler(
{
organizationSlug: "sentry-mcp-evals",
projectSlug: "cloudflare-mcp",
eventId: "7ca573c0f4814912aaa9bdc77d1a7d51",
attachmentId: null,
regionUrl: "https",
},
{
constraints: {
organizationSlug: null,
projectSlug: null,
},
accessToken: "access-token",
userId: "1",
},
),
).rejects.toThrow(
"Invalid regionUrl provided: https. Must be a valid URL.",
);
});
});
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/internal/agents/tools/data/code.json:
--------------------------------------------------------------------------------
```json
{
"namespace": "code",
"description": "These attributes provide context about source code\n",
"attributes": {
"code.function.name": {
"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",
"type": "string",
"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",
"stability": "stable",
"examples": [
"com.example.MyHttpService.serveRequest",
"GuzzleHttp\\Client::transfer",
"fopen"
]
},
"code.file.path": {
"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",
"type": "string",
"stability": "stable",
"examples": ["/usr/local/MyApplication/content_root/app/index.php"]
},
"code.line.number": {
"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",
"type": "number",
"stability": "stable",
"examples": ["42"]
},
"code.column.number": {
"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",
"type": "number",
"stability": "stable",
"examples": ["16"]
},
"code.stacktrace": {
"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",
"type": "string",
"stability": "stable",
"examples": [
"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"
]
}
}
}
```
--------------------------------------------------------------------------------
/docs/releases/stdio.md:
--------------------------------------------------------------------------------
```markdown
# stdio Release
npm package release process for the MCP server stdio transport.
## Overview
The MCP server is published to npm as `@sentry/mcp-server` for use with:
- Claude Desktop
- Cursor IDE
- VS Code with MCP extension
- Other MCP clients supporting stdio transport
## Package Structure
Published package includes:
- Compiled TypeScript (`dist/`)
- stdio transport implementation
- Type definitions
- Tool definitions
## Release Process
### 1. Version Bump
Update version in `packages/mcp-server/package.json`:
```json
{
"name": "@sentry/mcp-server",
"version": "1.2.3"
}
```
Follow semantic versioning:
- **Major**: Breaking changes to tool interfaces
- **Minor**: New tools or non-breaking features
- **Patch**: Bug fixes
### 2. Update Changelog
Document changes in `CHANGELOG.md`:
```markdown
## [1.2.3] - 2025-01-16
### Added
- New `search_docs` tool for documentation search
### Fixed
- Fix context propagation in tool handlers
```
### 3. Quality Checks
**MANDATORY before publishing:**
```bash
pnpm -w run lint:fix # Fix linting issues
pnpm tsc --noEmit # TypeScript type checking
pnpm test # Run all tests
pnpm run build # Ensure clean build
```
All checks must pass.
### 4. Publish to npm
```bash
cd packages/mcp-server
# Dry run to verify package contents
npm publish --dry-run
# Publish to npm
npm publish
```
### 5. Tag Release
```bash
git tag v1.2.3
git push origin v1.2.3
```
## User Installation
Users install via npx in their MCP client configuration:
### Claude Desktop
```json
{
"mcpServers": {
"sentry": {
"command": "npx",
"args": ["-y", "@sentry/mcp-server"],
"env": {
"SENTRY_ACCESS_TOKEN": "sntrys_...",
"SENTRY_HOST": "sentry.io"
}
}
}
}
```
Config location:
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
### Cursor IDE
Add to `.cursor/mcp.json`:
```json
{
"mcpServers": {
"sentry": {
"command": "npx",
"args": ["-y", "@sentry/mcp-server"],
"env": {
"SENTRY_ACCESS_TOKEN": "sntrys_...",
"SENTRY_HOST": "sentry.io"
}
}
}
}
```
## Environment Variables
Required:
- `SENTRY_ACCESS_TOKEN` - Sentry API access token
- `SENTRY_HOST` - Sentry instance hostname (default: `sentry.io`)
Optional:
- `SENTRY_ORG` - Default organization slug
- `SENTRY_PROJECT` - Default project slug
## Version Pinning
Users can pin to specific versions:
```json
{
"args": ["-y", "@sentry/[email protected]"]
}
```
## Testing Releases
### Local Testing Before Publishing
Test the built package locally:
```bash
cd packages/mcp-server
npm pack
# Creates sentry-mcp-server-1.2.3.tgz
# Test installation
npm install -g ./sentry-mcp-server-1.2.3.tgz
# Run stdio server
SENTRY_ACCESS_TOKEN=... @sentry/mcp-server
```
### Beta Releases
For testing with users before stable release:
```bash
npm publish --tag beta
```
Users install with:
```json
{
"args": ["-y", "@sentry/mcp-server@beta"]
}
```
## Troubleshooting
### Package Not Found
- Verify package name: `@sentry/mcp-server` (with scope)
- Check npm registry: `npm view @sentry/mcp-server`
### Version Mismatch
- Users may have cached version: `npx clear-npx-cache`
- Recommend version pinning for stability
### Build Failures
- Ensure `pnpm run build` succeeds before publishing
- Check TypeScript compilation errors
- Verify all dependencies are listed in package.json
## References
- Package config: `packages/mcp-server/package.json`
- stdio transport: `packages/mcp-server/src/transports/stdio.ts`
- Build script: `packages/mcp-server/scripts/build.ts`
- npm publishing docs: https://docs.npmjs.com/cli/publish
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/index.ts:
--------------------------------------------------------------------------------
```typescript
import * as Sentry from "@sentry/cloudflare";
import OAuthProvider from "@cloudflare/workers-oauth-provider";
import app from "./app";
import { SCOPES } from "../constants";
import type { Env } from "./types";
import getSentryConfig from "./sentry.config";
import { tokenExchangeCallback } from "./oauth";
import sentryMcpHandler from "./lib/mcp-handler";
import { checkRateLimit } from "./utils/rate-limiter";
import { getClientIp } from "./utils/client-ip";
// Public metadata endpoints that should be accessible from any origin
const PUBLIC_METADATA_PATHS = [
"/.well-known/", // OAuth discovery endpoints
"/robots.txt", // Search engine directives
"/llms.txt", // LLM/AI agent directives
];
const isPublicMetadataEndpoint = (pathname: string): boolean => {
return PUBLIC_METADATA_PATHS.some((path) =>
path.endsWith("/") ? pathname.startsWith(path) : pathname === path,
);
};
const addCorsHeaders = (response: Response): Response => {
const newResponse = new Response(response.body, response);
newResponse.headers.set("Access-Control-Allow-Origin", "*");
newResponse.headers.set("Access-Control-Allow-Methods", "GET, OPTIONS");
newResponse.headers.set("Access-Control-Allow-Headers", "Content-Type");
return newResponse;
};
// Wrap OAuth Provider to restrict CORS headers on public metadata endpoints
// OAuth Provider v0.0.12 adds overly permissive CORS (allows all methods/headers).
// We override with secure headers for .well-known endpoints and add CORS to robots.txt/llms.txt.
const wrappedOAuthProvider = {
fetch: async (request: Request, env: Env, ctx: ExecutionContext) => {
const url = new URL(request.url);
// Handle CORS preflight for public metadata endpoints
if (request.method === "OPTIONS") {
if (isPublicMetadataEndpoint(url.pathname)) {
return addCorsHeaders(new Response(null, { status: 204 }));
}
}
// Apply rate limiting to MCP and OAuth routes
// This protects against abuse at the earliest possible point
if (url.pathname.startsWith("/mcp") || url.pathname.startsWith("/oauth")) {
const clientIP = getClientIp(request);
// In local development or when IP can't be extracted, skip rate limiting
// Rate limiter is optional and primarily for production abuse prevention
if (clientIP) {
const rateLimitResult = await checkRateLimit(
clientIP,
env.MCP_RATE_LIMITER,
{
keyPrefix: "mcp",
errorMessage:
"Rate limit exceeded. Please wait before trying again.",
},
);
if (!rateLimitResult.allowed) {
return new Response(rateLimitResult.errorMessage, { status: 429 });
}
}
// If no clientIP, allow the request (likely local dev)
}
const oAuthProvider = new OAuthProvider({
apiRoute: "/mcp",
// @ts-expect-error - OAuthProvider types don't support specific Env types
apiHandler: sentryMcpHandler,
// @ts-expect-error - OAuthProvider types don't support specific Env types
defaultHandler: app,
// must match the routes registered in `app.ts`
authorizeEndpoint: "/oauth/authorize",
tokenEndpoint: "/oauth/token",
clientRegistrationEndpoint: "/oauth/register",
tokenExchangeCallback: (options) => tokenExchangeCallback(options, env),
scopesSupported: Object.keys(SCOPES),
});
const response = await oAuthProvider.fetch(request, env, ctx);
// Add CORS headers to public metadata endpoints
if (isPublicMetadataEndpoint(url.pathname)) {
return addCorsHeaders(response);
}
return response;
},
};
export default Sentry.withSentry(
getSentryConfig,
wrappedOAuthProvider,
) satisfies ExportedHandler<Env>;
```