This is page 3 of 11. 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
│ └── rules
├── .env.example
├── .github
│ └── workflows
│ ├── deploy.yml
│ ├── eval.yml
│ ├── merge-jobs.yml
│ ├── release.yml
│ ├── smoke-tests.yml
│ └── test.yml
├── .gitignore
├── .mcp.json
├── .vscode
│ ├── extensions.json
│ ├── mcp.json
│ └── settings.json
├── AGENTS.md
├── bin
│ └── bump-version.sh
├── biome.json
├── CLAUDE.md
├── codecov.yml
├── core
├── docs
│ ├── adding-tools.mdc
│ ├── api-patterns.mdc
│ ├── architecture.mdc
│ ├── cloudflare
│ │ ├── architecture.md
│ │ ├── constraint-do-analysis.md
│ │ ├── deployment.md
│ │ ├── mcpagent-architecture.md
│ │ ├── oauth-architecture.md
│ │ └── overview.md
│ ├── coding-guidelines.mdc
│ ├── common-patterns.mdc
│ ├── cursor.mdc
│ ├── deployment.mdc
│ ├── error-handling.mdc
│ ├── github-actions.mdc
│ ├── llms
│ │ ├── document-scopes.mdc
│ │ ├── documentation-style-guide.mdc
│ │ └── README.md
│ ├── logging.mdc
│ ├── monitoring.mdc
│ ├── permissions-and-scopes.md
│ ├── pr-management.mdc
│ ├── quality-checks.mdc
│ ├── README.md
│ ├── search-events-api-patterns.md
│ ├── security.mdc
│ ├── specs
│ │ ├── README.md
│ │ ├── search-events.md
│ │ └── subpath-constraints.md
│ └── testing.mdc
├── LICENSE.md
├── Makefile
├── package.json
├── packages
│ ├── mcp-cloudflare
│ │ ├── .env.example
│ │ ├── components.json
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── favicon.ico
│ │ │ ├── flow-transparent.png
│ │ │ └── flow.jpg
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── app.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── chat
│ │ │ │ │ │ ├── auth-form.tsx
│ │ │ │ │ │ ├── chat-input.tsx
│ │ │ │ │ │ ├── chat-message.tsx
│ │ │ │ │ │ ├── chat-messages.tsx
│ │ │ │ │ │ ├── chat-ui.tsx
│ │ │ │ │ │ ├── chat.tsx
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── tool-invocation.tsx
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── fragments
│ │ │ │ │ │ ├── remote-setup.tsx
│ │ │ │ │ │ ├── setup-guide.tsx
│ │ │ │ │ │ └── stdio-setup.tsx
│ │ │ │ │ └── ui
│ │ │ │ │ ├── accordion.tsx
│ │ │ │ │ ├── backdrop.tsx
│ │ │ │ │ ├── base.tsx
│ │ │ │ │ ├── button.tsx
│ │ │ │ │ ├── code-snippet.tsx
│ │ │ │ │ ├── header.tsx
│ │ │ │ │ ├── icon.tsx
│ │ │ │ │ ├── icons
│ │ │ │ │ │ └── sentry.tsx
│ │ │ │ │ ├── interactive-markdown.tsx
│ │ │ │ │ ├── json-schema-params.tsx
│ │ │ │ │ ├── markdown.tsx
│ │ │ │ │ ├── note.tsx
│ │ │ │ │ ├── prose.tsx
│ │ │ │ │ ├── section.tsx
│ │ │ │ │ ├── slash-command-actions.tsx
│ │ │ │ │ ├── slash-command-text.tsx
│ │ │ │ │ ├── sliding-panel.tsx
│ │ │ │ │ ├── template-vars.tsx
│ │ │ │ │ ├── tool-actions.tsx
│ │ │ │ │ └── typewriter.tsx
│ │ │ │ ├── contexts
│ │ │ │ │ └── auth-context.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ ├── use-mcp-metadata.ts
│ │ │ │ │ ├── use-persisted-chat.ts
│ │ │ │ │ ├── use-scroll-lock.ts
│ │ │ │ │ └── use-streaming-simulation.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── instrument.ts
│ │ │ │ ├── lib
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── main.tsx
│ │ │ │ ├── pages
│ │ │ │ │ └── home.tsx
│ │ │ │ ├── utils
│ │ │ │ │ ├── chat-error-handler.ts
│ │ │ │ │ └── index.ts
│ │ │ │ └── vite-env.d.ts
│ │ │ ├── constants.ts
│ │ │ ├── server
│ │ │ │ ├── app.test.ts
│ │ │ │ ├── app.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── approval-dialog.test.ts
│ │ │ │ │ ├── approval-dialog.ts
│ │ │ │ │ ├── constraint-utils.test.ts
│ │ │ │ │ ├── constraint-utils.ts
│ │ │ │ │ ├── html-utils.ts
│ │ │ │ │ ├── mcp-agent.ts
│ │ │ │ │ ├── slug-validation.test.ts
│ │ │ │ │ └── slug-validation.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── oauth
│ │ │ │ │ ├── authorize.test.ts
│ │ │ │ │ ├── callback.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── helpers.test.ts
│ │ │ │ │ ├── helpers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── authorize.ts
│ │ │ │ │ │ ├── callback.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ └── state.ts
│ │ │ │ ├── routes
│ │ │ │ │ ├── chat-oauth.ts
│ │ │ │ │ ├── chat.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── metadata.ts
│ │ │ │ │ ├── search.test.ts
│ │ │ │ │ └── search.ts
│ │ │ │ ├── sentry.config.ts
│ │ │ │ ├── types
│ │ │ │ │ └── chat.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils
│ │ │ │ └── auth-errors.ts
│ │ │ └── test-setup.ts
│ │ ├── tsconfig.client.json
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ ├── tsconfig.server.json
│ │ ├── vite.config.ts
│ │ ├── vitest.config.ts
│ │ ├── worker-configuration.d.ts
│ │ ├── wrangler.canary.jsonc
│ │ └── wrangler.jsonc
│ ├── mcp-server
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── scripts
│ │ │ ├── generate-definitions.ts
│ │ │ └── generate-otel-namespaces.ts
│ │ ├── src
│ │ │ ├── api-client
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── client.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── types.ts
│ │ │ ├── cli
│ │ │ │ ├── parse.test.ts
│ │ │ │ ├── parse.ts
│ │ │ │ ├── resolve.test.ts
│ │ │ │ ├── resolve.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── usage.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── index.ts
│ │ │ ├── internal
│ │ │ │ ├── agents
│ │ │ │ │ ├── callEmbeddedAgent.ts
│ │ │ │ │ ├── openai-provider.ts
│ │ │ │ │ └── tools
│ │ │ │ │ ├── data
│ │ │ │ │ │ ├── __namespaces.json
│ │ │ │ │ │ ├── android.json
│ │ │ │ │ │ ├── app.json
│ │ │ │ │ │ ├── artifact.json
│ │ │ │ │ │ ├── aspnetcore.json
│ │ │ │ │ │ ├── aws.json
│ │ │ │ │ │ ├── azure.json
│ │ │ │ │ │ ├── browser.json
│ │ │ │ │ │ ├── cassandra.json
│ │ │ │ │ │ ├── cicd.json
│ │ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ │ ├── client.json
│ │ │ │ │ │ ├── cloud.json
│ │ │ │ │ │ ├── cloudevents.json
│ │ │ │ │ │ ├── cloudfoundry.json
│ │ │ │ │ │ ├── code.json
│ │ │ │ │ │ ├── container.json
│ │ │ │ │ │ ├── cpu.json
│ │ │ │ │ │ ├── cpython.json
│ │ │ │ │ │ ├── database.json
│ │ │ │ │ │ ├── db.json
│ │ │ │ │ │ ├── deployment.json
│ │ │ │ │ │ ├── destination.json
│ │ │ │ │ │ ├── device.json
│ │ │ │ │ │ ├── disk.json
│ │ │ │ │ │ ├── dns.json
│ │ │ │ │ │ ├── dotnet.json
│ │ │ │ │ │ ├── elasticsearch.json
│ │ │ │ │ │ ├── enduser.json
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ ├── faas.json
│ │ │ │ │ │ ├── feature_flags.json
│ │ │ │ │ │ ├── file.json
│ │ │ │ │ │ ├── gcp.json
│ │ │ │ │ │ ├── gen_ai.json
│ │ │ │ │ │ ├── geo.json
│ │ │ │ │ │ ├── go.json
│ │ │ │ │ │ ├── graphql.json
│ │ │ │ │ │ ├── hardware.json
│ │ │ │ │ │ ├── heroku.json
│ │ │ │ │ │ ├── host.json
│ │ │ │ │ │ ├── http.json
│ │ │ │ │ │ ├── ios.json
│ │ │ │ │ │ ├── jvm.json
│ │ │ │ │ │ ├── k8s.json
│ │ │ │ │ │ ├── linux.json
│ │ │ │ │ │ ├── log.json
│ │ │ │ │ │ ├── mcp.json
│ │ │ │ │ │ ├── messaging.json
│ │ │ │ │ │ ├── network.json
│ │ │ │ │ │ ├── nodejs.json
│ │ │ │ │ │ ├── oci.json
│ │ │ │ │ │ ├── opentracing.json
│ │ │ │ │ │ ├── os.json
│ │ │ │ │ │ ├── otel.json
│ │ │ │ │ │ ├── peer.json
│ │ │ │ │ │ ├── process.json
│ │ │ │ │ │ ├── profile.json
│ │ │ │ │ │ ├── rpc.json
│ │ │ │ │ │ ├── server.json
│ │ │ │ │ │ ├── service.json
│ │ │ │ │ │ ├── session.json
│ │ │ │ │ │ ├── signalr.json
│ │ │ │ │ │ ├── source.json
│ │ │ │ │ │ ├── system.json
│ │ │ │ │ │ ├── telemetry.json
│ │ │ │ │ │ ├── test.json
│ │ │ │ │ │ ├── thread.json
│ │ │ │ │ │ ├── tls.json
│ │ │ │ │ │ ├── url.json
│ │ │ │ │ │ ├── user.json
│ │ │ │ │ │ ├── v8js.json
│ │ │ │ │ │ ├── vcs.json
│ │ │ │ │ │ ├── webengine.json
│ │ │ │ │ │ └── zos.json
│ │ │ │ │ ├── dataset-fields.test.ts
│ │ │ │ │ ├── dataset-fields.ts
│ │ │ │ │ ├── otel-semantics.test.ts
│ │ │ │ │ ├── otel-semantics.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ ├── whoami.test.ts
│ │ │ │ │ └── whoami.ts
│ │ │ │ ├── constraint-helpers.test.ts
│ │ │ │ ├── constraint-helpers.ts
│ │ │ │ ├── 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
│ │ │ ├── telem
│ │ │ │ ├── index.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── sentry.test.ts
│ │ │ │ └── sentry.ts
│ │ │ ├── test-setup.ts
│ │ │ ├── test-utils
│ │ │ │ └── context.ts
│ │ │ ├── toolDefinitions.ts
│ │ │ ├── tools
│ │ │ │ ├── analyze-issue-with-seer.test.ts
│ │ │ │ ├── analyze-issue-with-seer.ts
│ │ │ │ ├── create-dsn.test.ts
│ │ │ │ ├── create-dsn.ts
│ │ │ │ ├── create-project.test.ts
│ │ │ │ ├── create-project.ts
│ │ │ │ ├── create-team.test.ts
│ │ │ │ ├── create-team.ts
│ │ │ │ ├── find-dsns.test.ts
│ │ │ │ ├── find-dsns.ts
│ │ │ │ ├── find-organizations.test.ts
│ │ │ │ ├── find-organizations.ts
│ │ │ │ ├── find-projects.test.ts
│ │ │ │ ├── find-projects.ts
│ │ │ │ ├── find-releases.test.ts
│ │ │ │ ├── find-releases.ts
│ │ │ │ ├── find-teams.test.ts
│ │ │ │ ├── find-teams.ts
│ │ │ │ ├── get-doc.test.ts
│ │ │ │ ├── get-doc.ts
│ │ │ │ ├── get-event-attachment.test.ts
│ │ │ │ ├── get-event-attachment.ts
│ │ │ │ ├── get-issue-details.test.ts
│ │ │ │ ├── get-issue-details.ts
│ │ │ │ ├── get-trace-details.test.ts
│ │ │ │ ├── get-trace-details.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── search-docs.test.ts
│ │ │ │ ├── search-docs.ts
│ │ │ │ ├── search-events
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── utils.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── search-events.test.ts
│ │ │ │ ├── search-issues
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── README.md
│ │ │ │ ├── tools.test.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── update-issue.test.ts
│ │ │ │ ├── update-issue.ts
│ │ │ │ ├── update-project.test.ts
│ │ │ │ ├── update-project.ts
│ │ │ │ ├── whoami.test.ts
│ │ │ │ └── whoami.ts
│ │ │ ├── transports
│ │ │ │ └── stdio.ts
│ │ │ ├── types.ts
│ │ │ ├── utils
│ │ │ │ ├── slug-validation.test.ts
│ │ │ │ ├── slug-validation.ts
│ │ │ │ ├── url-utils.test.ts
│ │ │ │ └── url-utils.ts
│ │ │ └── version.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ ├── mcp-server-evals
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── bin
│ │ │ │ └── start-mock-stdio.ts
│ │ │ ├── evals
│ │ │ │ ├── autofix.eval.ts
│ │ │ │ ├── create-dsn.eval.ts
│ │ │ │ ├── create-project.eval.ts
│ │ │ │ ├── create-team.eval.ts
│ │ │ │ ├── get-issue.eval.ts
│ │ │ │ ├── get-trace-details.eval.ts
│ │ │ │ ├── list-dsns.eval.ts
│ │ │ │ ├── list-issues.eval.ts
│ │ │ │ ├── list-organizations.eval.ts
│ │ │ │ ├── list-projects.eval.ts
│ │ │ │ ├── list-releases.eval.ts
│ │ │ │ ├── list-tags.eval.ts
│ │ │ │ ├── list-teams.eval.ts
│ │ │ │ ├── search-docs.eval.ts
│ │ │ │ ├── search-events-agent.eval.ts
│ │ │ │ ├── search-events.eval.ts
│ │ │ │ ├── search-issues-agent.eval.ts
│ │ │ │ ├── search-issues.eval.ts
│ │ │ │ ├── update-issue.eval.ts
│ │ │ │ ├── update-project.eval.ts
│ │ │ │ └── utils
│ │ │ │ ├── fixtures.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── runner.ts
│ │ │ │ ├── structuredOutputScorer.ts
│ │ │ │ └── toolPredictionScorer.ts
│ │ │ └── setup-env.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── mcp-server-mocks
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── fixtures
│ │ │ │ ├── autofix-state.json
│ │ │ │ ├── event-attachments.json
│ │ │ │ ├── event.json
│ │ │ │ ├── issue.json
│ │ │ │ ├── performance-event.json
│ │ │ │ ├── project.json
│ │ │ │ ├── tags.json
│ │ │ │ ├── team.json
│ │ │ │ ├── trace-event.json
│ │ │ │ ├── trace-items-attributes-logs-number.json
│ │ │ │ ├── trace-items-attributes-logs-string.json
│ │ │ │ ├── trace-items-attributes-spans-number.json
│ │ │ │ ├── trace-items-attributes-spans-string.json
│ │ │ │ ├── trace-items-attributes.json
│ │ │ │ ├── trace-meta-with-nulls.json
│ │ │ │ ├── trace-meta.json
│ │ │ │ ├── trace-mixed.json
│ │ │ │ └── trace.json
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── mcp-server-tsconfig
│ │ ├── package.json
│ │ ├── tsconfig.base.json
│ │ └── tsconfig.vite.json
│ ├── mcp-test-client
│ │ ├── .env.test
│ │ ├── .gitignore
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── agent.ts
│ │ │ ├── auth
│ │ │ │ ├── config.ts
│ │ │ │ └── oauth.ts
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.test.ts
│ │ │ ├── logger.ts
│ │ │ ├── mcp-test-client-remote.ts
│ │ │ ├── mcp-test-client.ts
│ │ │ ├── types.ts
│ │ │ └── version.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ └── smoke-tests
│ ├── package.json
│ ├── src
│ │ └── smoke.test.ts
│ └── vitest.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── scripts
│ └── check-doc-links.mjs
├── turbo.json
└── vitest.workspace.ts
```
# Files
--------------------------------------------------------------------------------
/packages/mcp-server/src/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/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-server/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/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";
const RESULT_LIMIT = 25;
export default defineTool({
name: "find_organizations",
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.optional(),
},
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,
});
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-server/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'",
);
});
});
```
--------------------------------------------------------------------------------
/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/index.css:
--------------------------------------------------------------------------------
```css
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@plugin "@tailwindcss/typography";
:root {
--background: oklch(0.13 0.028 261.692);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950;
min-height: 100vh;
}
pre {
white-space: pre-wrap;
}
}
```
--------------------------------------------------------------------------------
/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-server/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-server/scripts/generate-definitions.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env tsx
/**
* Generate tool definitions JSON for external consumption.
*
* Outputs to src/ so they can be bundled and imported by clients and the Cloudflare app.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { z, type ZodTypeAny } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Lazy imports of server modules to avoid type bleed
const toolsModule = await import("../src/tools/index.ts");
function writeJson(file: string, data: unknown) {
fs.writeFileSync(file, JSON.stringify(data, null, 2));
}
function ensureDirExists(dir: string) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
// Shared helpers for Zod parameter maps
function zodFieldMapToDescriptions(
fieldMap: Record<string, ZodTypeAny>,
): Record<string, { description: string }> {
const out: Record<string, { description: string }> = {};
for (const [key, schema] of Object.entries(fieldMap)) {
const js = zodToJsonSchema(schema, { $refStrategy: "none" }) as {
description?: string;
};
out[key] = { description: js.description || "" };
}
return out;
}
function zodFieldMapToJsonSchema(
fieldMap: Record<string, ZodTypeAny>,
): unknown {
if (!fieldMap || Object.keys(fieldMap).length === 0) return {};
const obj = z.object(fieldMap);
return zodToJsonSchema(obj, { $refStrategy: "none" });
}
function byName<T extends { name: string }>(a: T, b: T) {
return a.name.localeCompare(b.name);
}
// Tools
function generateToolDefinitions() {
const toolsDefault = toolsModule.default as
| Record<string, unknown>
| undefined;
if (!toolsDefault || typeof toolsDefault !== "object") {
throw new Error("Failed to import tools from src/tools/index.ts");
}
const defs = Object.entries(toolsDefault).map(([key, tool]) => {
if (!tool || typeof tool !== "object")
throw new Error(`Invalid tool: ${key}`);
const t = tool as {
name: string;
description: string;
inputSchema: Record<string, ZodTypeAny>;
requiredScopes: string[]; // must exist on all tools (can be empty)
};
if (!Array.isArray(t.requiredScopes)) {
throw new Error(`Tool '${t.name}' is missing requiredScopes array`);
}
const jsonSchema = zodFieldMapToJsonSchema(t.inputSchema || {});
return {
name: t.name,
description: t.description,
// Export full JSON Schema under inputSchema for external docs
inputSchema: jsonSchema,
// Preserve tool access requirements for UIs/docs
requiredScopes: t.requiredScopes,
};
});
return defs.sort(byName);
}
async function main() {
try {
console.log("Generating tool definitions...");
const outDir = path.join(__dirname, "../src");
ensureDirExists(outDir);
const tools = generateToolDefinitions();
writeJson(path.join(outDir, "toolDefinitions.json"), tools);
console.log(`✅ Generated: tools(${tools.length})`);
} catch (error) {
const err = error as Error;
console.error("[ERROR]", err.message, err.stack);
process.exit(1);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
void main();
}
```
--------------------------------------------------------------------------------
/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-server/tools/search-issues/agent";
import { SentryApiService } from "@sentry/mcp-server/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-server/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-server/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",
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.optional(),
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.optional(),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: true,
},
async handler(params, context: ServerContext) {
const apiService = apiServiceFromContext(context, {
regionUrl: params.regionUrl,
});
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-server/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;
// 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
const httpTransport = new StreamableHTTPClientTransport(
new URL(`${mcpHost}/mcp`),
{
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-server/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: undefined,
regionUrl: undefined,
},
{
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: undefined,
},
{
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: undefined,
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-server/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"
]
}
}
}
```
--------------------------------------------------------------------------------
/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 { LogOut, X } from "lucide-react";
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;
showControls?: boolean;
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
onStop?: () => void;
onRetry?: () => void;
onClose?: () => void;
onLogout?: () => void;
onSlashCommand?: (command: string) => void;
onSendPrompt?: (prompt: string) => void;
}
export const ChatUI = ({
messages,
input,
error,
isChatLoading,
isLocalStreaming,
isMessageStreaming,
isOpen = true,
showControls = false,
onInputChange,
onSubmit,
onStop,
onRetry,
onClose,
onLogout,
onSlashCommand,
onSendPrompt,
}: ChatUIProps) => {
return (
<div className="h-full flex flex-col">
{/* Mobile header with close and logout buttons */}
<div className="md:hidden flex items-center justify-between p-4 border-b border-slate-800 flex-shrink-0">
{showControls && (
<>
<Button type="button" onClick={onClose} size="icon" title="Close">
<X className="h-4 w-4" />
</Button>
<Button type="button" onClick={onLogout} size="icon" title="Logout">
<LogOut className="h-4 w-4" />
</Button>
</>
)}
</div>
{/* Chat Messages - Scrollable area */}
<ScrollToBottom
className="flex-1 mb-34 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 bg-slate-950/95 h-34 overflow-hidden z-10">
{/* Sample Prompt Buttons - Always visible above input */}
{onSendPrompt && (
<div className="mb-4 flex flex-wrap gap-2 justify-center">
{SAMPLE_PROMPTS.map((samplePrompt) => (
<Button
key={samplePrompt.label}
type="button"
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-server/src/tools/search-events/agent.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from "zod";
import { ConfigurationError } from "../../errors";
import { callEmbeddedAgent } from "../../internal/agents/callEmbeddedAgent";
import type { SentryApiService } from "../../api-client";
import { createOtelLookupTool } from "../../internal/agents/tools/otel-semantics";
import { createWhoamiTool } from "../../internal/agents/tools/whoami";
import { createDatasetAttributesTool } from "./utils";
import { systemPrompt } from "./config";
const outputSchema = z
.object({
dataset: z
.enum(["spans", "errors", "logs"])
.describe("Which dataset to use for the query"),
query: z
.string()
.default("")
.nullish()
.describe("The Sentry query string for filtering results"),
fields: z
.array(z.string())
.describe("Array of field names to return in results."),
sort: z.string().describe("Sort parameter for results."),
timeRange: z
.union([
z.object({
statsPeriod: z
.string()
.describe("Relative time period like '1h', '24h', '7d'"),
}),
z.object({
start: z.string().describe("ISO 8601 start time"),
end: z.string().describe("ISO 8601 end time"),
}),
])
.nullish()
.describe(
"Time range for filtering events. Use either statsPeriod for relative time or start/end for absolute time.",
),
explanation: z
.string()
.describe("Brief explanation of how you translated this query."),
})
.refine(
(data) => {
// Only validate if both sort and fields are present
if (!data.sort || !data.fields || data.fields.length === 0) {
return true;
}
// Extract the field name from sort parameter (e.g., "-timestamp" -> "timestamp", "-count()" -> "count()")
const sortField = data.sort.startsWith("-")
? data.sort.substring(1)
: data.sort;
// Check if sort field is in fields array
return data.fields.includes(sortField);
},
{
message:
"Sort field must be included in the fields array. Sentry requires that any field used for sorting must also be explicitly selected. Add the sort field to the fields array or choose a different sort field that's already included.",
},
);
export interface SearchEventsAgentOptions {
query: string;
organizationSlug: string;
apiService: SentryApiService;
projectId?: string;
}
/**
* Search events agent - single entry point for translating natural language queries to Sentry search syntax
* This returns both the translated query result AND the tool calls made by the agent
*/
export async function searchEventsAgent(
options: SearchEventsAgentOptions,
): Promise<{
result: z.infer<typeof outputSchema>;
toolCalls: any[];
}> {
if (!process.env.OPENAI_API_KEY) {
throw new ConfigurationError(
"OPENAI_API_KEY environment variable is required for semantic search",
);
}
// Create tools pre-bound with the provided API service and organization
const datasetAttributesTool = createDatasetAttributesTool({
apiService: options.apiService,
organizationSlug: options.organizationSlug,
projectId: options.projectId,
});
const otelLookupTool = createOtelLookupTool({
apiService: options.apiService,
organizationSlug: options.organizationSlug,
projectId: options.projectId,
});
const whoamiTool = createWhoamiTool({ apiService: options.apiService });
// Use callEmbeddedAgent to translate the query with tool call capture
return await callEmbeddedAgent({
system: systemPrompt,
prompt: options.query,
tools: {
datasetAttributes: datasetAttributesTool,
otelSemantics: otelLookupTool,
whoami: whoamiTool,
},
schema: outputSchema,
});
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/api-client/types.ts:
--------------------------------------------------------------------------------
```typescript
/**
* TypeScript type definitions derived from Zod schemas.
*
* This module provides strongly-typed interfaces for all Sentry API data
* structures. Types are automatically derived from their corresponding
* Zod schemas using `z.infer<>`, ensuring perfect synchronization between
* runtime validation and compile-time type checking.
*
* Type Categories:
* - **Core Resources**: User, Organization, Team, Project
* - **Issue Management**: Issue, Event, AssignedTo
* - **Release Management**: Release
* - **Search & Discovery**: Tag
* - **Integrations**: ClientKey, AutofixRun, AutofixRunState
*
* Array Types:
* All list types follow the pattern `ResourceList = Resource[]` for consistency.
*
* @example Type Usage
* ```typescript
* import type { Issue, IssueList } from "./types";
*
* function processIssues(issues: IssueList): void {
* issues.forEach((issue: Issue) => {
* console.log(`${issue.shortId}: ${issue.title}`);
* });
* }
* ```
*
* @example API Response Typing
* ```typescript
* async function getIssue(id: string): Promise<Issue> {
* const response = await apiService.getIssue({
* organizationSlug: "my-org",
* issueId: id
* });
* return response; // Already typed as Issue from schema validation
* }
* ```
*/
import type { z } from "zod";
import type {
AssignedToSchema,
AutofixRunSchema,
AutofixRunStateSchema,
ClientKeyListSchema,
ClientKeySchema,
ErrorEventSchema,
DefaultEventSchema,
TransactionEventSchema,
UnknownEventSchema,
EventSchema,
EventAttachmentSchema,
EventAttachmentListSchema,
IssueListSchema,
IssueSchema,
OrganizationListSchema,
OrganizationSchema,
ProjectListSchema,
ProjectSchema,
ReleaseListSchema,
ReleaseSchema,
TagListSchema,
TagSchema,
TeamListSchema,
TeamSchema,
TraceMetaSchema,
TraceSchema,
TraceSpanSchema,
TraceIssueSchema,
UserSchema,
} from "./schema";
export type User = z.infer<typeof UserSchema>;
export type Organization = z.infer<typeof OrganizationSchema>;
export type Team = z.infer<typeof TeamSchema>;
export type Project = z.infer<typeof ProjectSchema>;
export type ClientKey = z.infer<typeof ClientKeySchema>;
export type Release = z.infer<typeof ReleaseSchema>;
export type Issue = z.infer<typeof IssueSchema>;
// Individual event types
export type ErrorEvent = z.infer<typeof ErrorEventSchema>;
export type DefaultEvent = z.infer<typeof DefaultEventSchema>;
export type TransactionEvent = z.infer<typeof TransactionEventSchema>;
export type UnknownEvent = z.infer<typeof UnknownEventSchema>;
// Event union - use RawEvent for parsing, Event for known types only
export type RawEvent = z.infer<typeof EventSchema>;
export type Event = ErrorEvent | DefaultEvent | TransactionEvent;
export type EventAttachment = z.infer<typeof EventAttachmentSchema>;
export type Tag = z.infer<typeof TagSchema>;
export type AutofixRun = z.infer<typeof AutofixRunSchema>;
export type AutofixRunState = z.infer<typeof AutofixRunStateSchema>;
export type AssignedTo = z.infer<typeof AssignedToSchema>;
export type OrganizationList = z.infer<typeof OrganizationListSchema>;
export type TeamList = z.infer<typeof TeamListSchema>;
export type ProjectList = z.infer<typeof ProjectListSchema>;
export type ReleaseList = z.infer<typeof ReleaseListSchema>;
export type IssueList = z.infer<typeof IssueListSchema>;
export type EventAttachmentList = z.infer<typeof EventAttachmentListSchema>;
export type TagList = z.infer<typeof TagListSchema>;
export type ClientKeyList = z.infer<typeof ClientKeyListSchema>;
// Trace types
export type TraceMeta = z.infer<typeof TraceMetaSchema>;
export type TraceSpan = z.infer<typeof TraceSpanSchema>;
export type TraceIssue = z.infer<typeof TraceIssueSchema>;
export type Trace = z.infer<typeof TraceSchema>;
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/service.json:
--------------------------------------------------------------------------------
```json
{
"namespace": "service",
"description": "A service instance.\n",
"attributes": {
"service.name": {
"description": "Logical name of the service.\n",
"type": "string",
"note": "MUST be the same for all instances of horizontally scaled services. If the value was not specified, SDKs MUST fallback to `unknown_service:` concatenated with [`process.executable.name`](process.md), e.g. `unknown_service:bash`. If `process.executable.name` is not available, the value MUST be set to `unknown_service`.\n",
"stability": "stable",
"examples": ["shoppingcart"]
},
"service.version": {
"description": "The version string of the service API or implementation. The format is not defined by these conventions.\n",
"type": "string",
"stability": "stable",
"examples": ["2.0.0", "a01dbef8a"]
},
"service.namespace": {
"description": "A namespace for `service.name`.\n",
"type": "string",
"note": "A string value having a meaning that helps to distinguish a group of services, for example the team name that owns a group of services. `service.name` is expected to be unique within the same namespace. If `service.namespace` is not specified in the Resource then `service.name` is expected to be unique for all services that have no explicit namespace defined (so the empty/unspecified namespace is simply one more valid namespace). Zero-length namespace string is assumed equal to unspecified namespace.\n",
"stability": "development",
"examples": ["Shop"]
},
"service.instance.id": {
"description": "The string ID of the service instance.\n",
"type": "string",
"note": "MUST be unique for each instance of the same `service.namespace,service.name` pair (in other words\n`service.namespace,service.name,service.instance.id` triplet MUST be globally unique). The ID helps to\ndistinguish instances of the same service that exist at the same time (e.g. instances of a horizontally scaled\nservice).\n\nImplementations, such as SDKs, are recommended to generate a random Version 1 or Version 4 [RFC\n4122](https://www.ietf.org/rfc/rfc4122.txt) UUID, but are free to use an inherent unique ID as the source of\nthis value if stability is desirable. In that case, the ID SHOULD be used as source of a UUID Version 5 and\nSHOULD use the following UUID as the namespace: `4d63009a-8d0f-11ee-aad7-4c796ed8e320`.\n\nUUIDs are typically recommended, as only an opaque value for the purposes of identifying a service instance is\nneeded. Similar to what can be seen in the man page for the\n[`/etc/machine-id`](https://www.freedesktop.org/software/systemd/man/latest/machine-id.html) file, the underlying\ndata, such as pod name and namespace should be treated as confidential, being the user's choice to expose it\nor not via another resource attribute.\n\nFor applications running behind an application server (like unicorn), we do not recommend using one identifier\nfor all processes participating in the application. Instead, it's recommended each division (e.g. a worker\nthread in unicorn) to have its own instance.id.\n\nIt's not recommended for a Collector to set `service.instance.id` if it can't unambiguously determine the\nservice instance that is generating that telemetry. For instance, creating an UUID based on `pod.name` will\nlikely be wrong, as the Collector might not know from which container within that pod the telemetry originated.\nHowever, Collectors can set the `service.instance.id` if they can unambiguously determine the service instance\nfor that telemetry. This is typically the case for scraping receivers, as they know the target address and\nport.\n",
"stability": "development",
"examples": ["627cc493-f310-47de-96bd-71410b7dec09"]
}
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/tool-helpers/seer.ts:
--------------------------------------------------------------------------------
```typescript
import type { z } from "zod";
import type {
AutofixRunStepSchema,
AutofixRunStepRootCauseAnalysisSchema,
AutofixRunStepSolutionSchema,
AutofixRunStepDefaultSchema,
} from "../../api-client/index";
export const SEER_POLLING_INTERVAL = 5000; // 5 seconds
export const SEER_TIMEOUT = 5 * 60 * 1000; // 5 minutes
export const SEER_MAX_RETRIES = 3; // Maximum retries for transient failures
export const SEER_INITIAL_RETRY_DELAY = 1000; // 1 second initial retry delay
export function getStatusDisplayName(status: string): string {
switch (status) {
case "COMPLETED":
return "Complete";
case "FAILED":
case "ERROR":
return "Failed";
case "CANCELLED":
return "Cancelled";
case "NEED_MORE_INFORMATION":
return "Needs More Information";
case "WAITING_FOR_USER_RESPONSE":
return "Waiting for Response";
case "PROCESSING":
return "Processing";
case "IN_PROGRESS":
return "In Progress";
default:
return status;
}
}
/**
* Check if an autofix status is terminal (no more updates expected)
*/
export function isTerminalStatus(status: string): boolean {
return [
"COMPLETED",
"FAILED",
"ERROR",
"CANCELLED",
"NEED_MORE_INFORMATION",
"WAITING_FOR_USER_RESPONSE",
].includes(status);
}
/**
* Check if an autofix status requires human intervention
*/
export function isHumanInterventionStatus(status: string): boolean {
return (
status === "NEED_MORE_INFORMATION" || status === "WAITING_FOR_USER_RESPONSE"
);
}
/**
* Get guidance message for human intervention states
*/
export function getHumanInterventionGuidance(status: string): string {
if (status === "NEED_MORE_INFORMATION") {
return "\nSeer needs additional information to continue the analysis. Please review the insights above and consider providing more context.\n";
}
if (status === "WAITING_FOR_USER_RESPONSE") {
return "\nSeer is waiting for your response to proceed. Please review the analysis and provide feedback.\n";
}
return "";
}
export function getOutputForAutofixStep(
step: z.infer<typeof AutofixRunStepSchema>,
) {
let output = `## ${step.title}\n\n`;
if (step.status === "FAILED") {
output += `**Sentry hit an error completing this step.\n\n`;
return output;
}
if (step.status !== "COMPLETED") {
output += `**Sentry is still working on this step. Please check back in a minute.**\n\n`;
return output;
}
if (step.type === "root_cause_analysis") {
const typedStep = step as z.infer<
typeof AutofixRunStepRootCauseAnalysisSchema
>;
for (const cause of typedStep.causes) {
if (cause.description) {
output += `${cause.description}\n\n`;
}
for (const entry of cause.root_cause_reproduction) {
output += `**${entry.title}**\n\n`;
output += `${entry.code_snippet_and_analysis}\n\n`;
}
}
return output;
}
if (step.type === "solution") {
const typedStep = step as z.infer<typeof AutofixRunStepSolutionSchema>;
output += `${typedStep.description}\n\n`;
for (const entry of typedStep.solution) {
output += `**${entry.title}**\n`;
output += `${entry.code_snippet_and_analysis}\n\n`;
}
if (typedStep.status === "FAILED") {
output += `**Sentry hit an error completing this step.\n\n`;
} else if (typedStep.status !== "COMPLETED") {
output += `**Sentry is still working on this step.**\n\n`;
}
return output;
}
const typedStep = step as z.infer<typeof AutofixRunStepDefaultSchema>;
if (typedStep.insights && typedStep.insights.length > 0) {
for (const entry of typedStep.insights) {
output += `**${entry.insight}**\n`;
output += `${entry.justification}\n\n`;
}
} else if (step.output_stream) {
output += `${step.output_stream}\n`;
}
return output;
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/search-docs.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, it, expect, vi } from "vitest";
import searchDocs from "./search-docs.js";
describe("search_docs", () => {
// Note: Query validation (empty, too short, too long) is now handled by Zod schema
// These validation tests are no longer needed as they test framework behavior, not our tool logic
it("returns results from the API", async () => {
const result = await searchDocs.handler(
{
query: "How do I configure rate limiting?",
maxResults: 5,
guide: undefined,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
mcpUrl: "https://mcp.sentry.dev",
},
);
expect(result).toMatchInlineSnapshot(`
"# Documentation Search Results
**Query**: "How do I configure rate limiting?"
Found 2 matches
These are just snippets. Use \`get_doc(path='...')\` to fetch the full content.
## 1. https://docs.sentry.io/product/rate-limiting
**Path**: product/rate-limiting.md
**Relevance**: 95.0%
**Matching Context**
> Learn how to configure rate limiting in Sentry to prevent quota exhaustion and control event ingestion.
## 2. https://docs.sentry.io/product/accounts/quotas/spike-protection
**Path**: product/accounts/quotas/spike-protection.md
**Relevance**: 87.0%
**Matching Context**
> Spike protection helps prevent unexpected spikes in event volume from consuming your quota.
"
`);
});
it("handles API errors", async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
json: async () => ({ error: "Internal server error" }),
} as Response);
await expect(
searchDocs.handler(
{
query: "test query",
maxResults: undefined,
guide: undefined,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
},
),
).rejects.toThrow();
});
it("handles timeout errors", async () => {
// Mock fetch to simulate a timeout by throwing an AbortError
vi.spyOn(global, "fetch").mockImplementationOnce(() => {
const error = new Error("The operation was aborted");
error.name = "AbortError";
return Promise.reject(error);
});
await expect(
searchDocs.handler(
{
query: "test query",
maxResults: undefined,
guide: undefined,
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
},
),
).rejects.toThrow("Request timeout after 15000ms");
});
it("includes platform in output and request", async () => {
const mockFetch = vi.spyOn(global, "fetch");
const result = await searchDocs.handler(
{
query: "test query",
maxResults: 5,
guide: "javascript/nextjs",
},
{
constraints: {
organizationSlug: null,
},
accessToken: "access-token",
userId: "1",
mcpUrl: "https://mcp.sentry.dev",
},
);
// Check that platform is included in the output
expect(result).toContain("**Guide**: javascript/nextjs");
// Check that platform is included in the request
expect(mockFetch).toHaveBeenCalledWith(
"https://mcp.sentry.dev/api/search",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: "test query",
maxResults: 5,
guide: "javascript/nextjs",
}),
}),
);
});
});
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/routes/metadata.ts:
--------------------------------------------------------------------------------
```typescript
/**
* MCP Metadata API endpoint
*
* Provides immediate access to MCP server metadata including tools
* without requiring a chat stream to be initialized.
*/
import { Hono } from "hono";
import { experimental_createMCPClient } from "ai";
import type { Env } from "../types";
import { logIssue, logWarn } from "@sentry/mcp-server/telem/logging";
import type { ErrorResponse } from "../types/chat";
import { analyzeAuthError, getAuthErrorResponse } from "../utils/auth-errors";
import { z } from "zod";
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>;
function createErrorResponse(errorResponse: ErrorResponse): ErrorResponse {
return errorResponse;
}
export default new Hono<{ Bindings: Env }>().get("/", async (c) => {
// Support cookie-based auth (preferred) with fallback to Authorization header
let accessToken: string | null = null;
// Try to read from signed cookie set during OAuth
try {
const { getCookie } = await import("hono/cookie");
const authDataCookie = getCookie(c, "sentry_auth_data");
if (authDataCookie) {
const AuthDataSchema = z.object({ access_token: z.string() });
const authData = AuthDataSchema.parse(JSON.parse(authDataCookie));
accessToken = authData.access_token;
}
} catch {
// Ignore cookie parse errors; we'll check header below
}
// Fallback to Authorization header if cookie is not present
if (!accessToken) {
const authHeader = c.req.header("Authorization");
if (authHeader?.startsWith("Bearer ")) {
accessToken = authHeader.substring(7);
}
}
if (!accessToken) {
return c.json(
createErrorResponse({
error: "Authorization required",
name: "MISSING_AUTH_TOKEN",
}),
401,
);
}
try {
// Get tools by connecting to MCP server
let tools: string[] = [];
let mcpClient: MCPClient | undefined;
try {
const requestUrl = new URL(c.req.url);
const sseUrl = `${requestUrl.protocol}//${requestUrl.host}/sse`;
mcpClient = await experimental_createMCPClient({
name: "sentry",
transport: {
type: "sse" as const,
url: sseUrl,
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
});
const mcpTools = await mcpClient.tools();
tools = Object.keys(mcpTools);
} catch (error) {
// If we can't get tools, return empty array
logWarn(error, {
loggerScope: ["cloudflare", "metadata"],
extra: {
message: "Failed to fetch tools from MCP server",
},
});
} finally {
// Ensure the MCP client connection is properly closed to prevent hanging connections
if (mcpClient && typeof mcpClient.close === "function") {
try {
await mcpClient.close();
} catch (closeError) {
logWarn(closeError, {
loggerScope: ["cloudflare", "metadata"],
extra: {
message: "Failed to close MCP client connection",
},
});
}
}
}
// Return the metadata
return c.json({
type: "mcp-metadata",
tools,
timestamp: new Date().toISOString(),
});
} catch (error) {
logIssue(error, {
loggerScope: ["cloudflare", "metadata"],
extra: {
message: "Metadata API error",
},
});
// Check if this is an authentication error
const authInfo = analyzeAuthError(error);
if (authInfo.isAuthError) {
return c.json(
createErrorResponse(getAuthErrorResponse(authInfo)),
authInfo.statusCode || (401 as any),
);
}
const eventId = logIssue(error);
return c.json(
createErrorResponse({
error: "Failed to fetch MCP metadata",
name: "METADATA_FETCH_FAILED",
eventId,
}),
500,
);
}
});
```
--------------------------------------------------------------------------------
/packages/mcp-server/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@sentry/mcp-server",
"version": "0.18.0",
"type": "module",
"packageManager": "[email protected]",
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"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"
},
"bin": {
"sentry-mcp": "./dist/index.js"
},
"files": ["./dist/*"],
"exports": {
".": {
"types": "./dist/index.ts",
"default": "./dist/index.js"
},
"./api-client": {
"types": "./dist/api-client/index.ts",
"default": "./dist/api-client/index.js"
},
"./constants": {
"types": "./dist/constants.ts",
"default": "./dist/constants.js"
},
"./telem": {
"types": "./dist/telem/index.ts",
"default": "./dist/telem/index.js"
},
"./telem/logging": {
"types": "./dist/telem/logging.ts",
"default": "./dist/telem/logging.js"
},
"./telem/sentry": {
"types": "./dist/telem/sentry.ts",
"default": "./dist/telem/sentry.js"
},
"./permissions": {
"types": "./dist/permissions.ts",
"default": "./dist/permissions.js"
},
"./transports/stdio": {
"types": "./dist/transports/stdio.ts",
"default": "./dist/transports/stdio.js"
},
"./server": {
"types": "./dist/server.ts",
"default": "./dist/server.js"
},
"./toolDefinitions": {
"types": "./dist/toolDefinitions.ts",
"default": "./dist/toolDefinitions.js"
},
"./types": {
"types": "./dist/types.ts",
"default": "./dist/types.js"
},
"./version": {
"types": "./dist/version.ts",
"default": "./dist/version.js"
},
"./tools/search-events": {
"types": "./dist/tools/search-events/index.ts",
"default": "./dist/tools/search-events/index.js"
},
"./tools/search-issues": {
"types": "./dist/tools/search-issues/index.ts",
"default": "./dist/tools/search-issues/index.js"
},
"./tools/search-events/agent": {
"types": "./dist/tools/search-events/agent.ts",
"default": "./dist/tools/search-events/agent.js"
},
"./tools/search-issues/agent": {
"types": "./dist/tools/search-issues/agent.ts",
"default": "./dist/tools/search-issues/agent.js"
},
"./internal/agents/callEmbeddedAgent": {
"types": "./dist/internal/agents/callEmbeddedAgent.ts",
"default": "./dist/internal/agents/callEmbeddedAgent.js"
}
},
"scripts": {
"prebuild": "pnpm run generate-definitions",
"build": "tsdown",
"dev": "pnpm run generate-definitions && tsdown -w",
"start": "tsx src/index.ts",
"prepare": "pnpm run build",
"pretest": "pnpm run generate-definitions",
"test": "vitest run",
"test:ci": "pnpm run generate-definitions && vitest run --coverage --reporter=default --reporter=junit --outputFile=tests.junit.xml",
"test:watch": "pnpm run generate-definitions && vitest",
"tsc": "tsc --noEmit",
"generate-definitions": "tsx scripts/generate-definitions.ts",
"generate-otel-namespaces": "tsx scripts/generate-otel-namespaces.ts"
},
"devDependencies": {
"@sentry/mcp-server-mocks": "workspace:*",
"@sentry/mcp-server-tsconfig": "workspace:*",
"msw": "catalog:",
"yaml": "^2.6.1",
"zod-to-json-schema": "catalog:"
},
"dependencies": {
"@ai-sdk/openai": "catalog:",
"@logtape/logtape": "^1.1.1",
"@logtape/sentry": "^1.1.1",
"@modelcontextprotocol/sdk": "catalog:",
"@sentry/core": "catalog:",
"@sentry/node": "catalog:",
"ai": "catalog:",
"dotenv": "catalog:",
"zod": "catalog:"
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/error-handling.ts:
--------------------------------------------------------------------------------
```typescript
import { UserInputError, ConfigurationError } from "../errors";
import { ApiError, ApiClientError, ApiServerError } from "../api-client";
import { logIssue } from "../telem/logging";
/**
* Type guard to identify user input validation errors.
*/
export function isUserInputError(error: unknown): error is UserInputError {
return error instanceof UserInputError;
}
/**
* Type guard to identify configuration errors.
*/
export function isConfigurationError(
error: unknown,
): error is ConfigurationError {
return error instanceof ConfigurationError;
}
/**
* Type guard to identify API errors.
*/
export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError;
}
/**
* Type guard to identify API client errors (4xx).
*/
export function isApiClientError(error: unknown): error is ApiClientError {
return error instanceof ApiClientError;
}
/**
* Type guard to identify API server errors (5xx).
*/
export function isApiServerError(error: unknown): error is ApiServerError {
return error instanceof ApiServerError;
}
/**
* Format an error for user display with markdown formatting.
* This is used by tool handlers to format errors for MCP responses.
*
* SECURITY: Only return trusted error messages to prevent prompt injection vulnerabilities.
* We trust: Sentry API errors, our own UserInputError/ConfigurationError messages, and system templates.
*/
export async function formatErrorForUser(error: unknown): Promise<string> {
if (isUserInputError(error)) {
return [
"**Input Error**",
"It looks like there was a problem with the input you provided.",
error.message,
`You may be able to resolve the issue by addressing the concern and trying again.`,
].join("\n\n");
}
if (isConfigurationError(error)) {
return [
"**Configuration Error**",
"There appears to be a configuration issue with your setup.",
error.message,
`Please check your environment configuration and try again.`,
].join("\n\n");
}
// Handle ApiClientError (4xx) - user input errors, should NOT be logged to Sentry
if (isApiClientError(error)) {
const statusText = error.status
? `There was an HTTP ${error.status} error with your request to the Sentry API.`
: "There was an error with your request.";
return [
"**Input Error**",
statusText,
error.toUserMessage(),
`You may be able to resolve the issue by addressing the concern and trying again.`,
].join("\n\n");
}
// Handle ApiServerError (5xx) - system errors, SHOULD be logged to Sentry
if (isApiServerError(error)) {
const eventId = logIssue(error);
const statusText = error.status
? `There was an HTTP ${error.status} server error with the Sentry API.`
: "There was a server error.";
return [
"**Error**",
statusText,
`${error.message}`,
`**Event ID**: ${eventId}`,
`Please contact support with this Event ID if the problem persists.`,
].join("\n\n");
}
// Handle generic ApiError (shouldn't happen with new hierarchy, but just in case)
if (isApiError(error)) {
const statusText = error.status
? `There was an HTTP ${error.status} error with your request to the Sentry API.`
: "There was an error with your request.";
return [
"**Error**",
statusText,
`${error.message}`,
`You may be able to resolve the issue by addressing the concern and trying again.`,
].join("\n\n");
}
const eventId = logIssue(error);
return [
"**Error**",
"It looks like there was a problem communicating with the Sentry API.",
"Please report the following to the user for the Sentry team:",
`**Event ID**: ${eventId}`,
process.env.NODE_ENV !== "production"
? error instanceof Error
? error.message
: String(error)
: "",
]
.filter(Boolean)
.join("\n\n");
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
/**
* Main CLI entry point for the Sentry MCP server.
*
* Handles command-line argument parsing, environment configuration, Sentry
* initialization, and starts the MCP server with stdio transport. Requires
* a Sentry access token and optionally accepts host and DSN configuration.
*
* @example CLI Usage
* ```bash
* npx @sentry/mcp-server --access-token=TOKEN --host=sentry.io
* npx @sentry/mcp-server --access-token=TOKEN --url=https://sentry.example.com
* ```
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { startStdio } from "./transports/stdio";
import * as Sentry from "@sentry/node";
import { LIB_VERSION } from "./version";
import { buildUsage } from "./cli/usage";
import { parseArgv, parseEnv, merge } from "./cli/parse";
import { finalize } from "./cli/resolve";
import { sentryBeforeSend } from "./telem/sentry";
import { ALL_SCOPES } from "./permissions";
import { DEFAULT_SCOPES } from "./constants";
import { configureOpenAIProvider } from "./internal/agents/openai-provider";
const packageName = "@sentry/mcp-server";
const usageText = buildUsage(packageName, DEFAULT_SCOPES, ALL_SCOPES);
function die(message: string): never {
console.error(message);
console.error(usageText);
process.exit(1);
}
const cli = parseArgv(process.argv.slice(2));
if (cli.help) {
console.log(usageText);
process.exit(0);
}
if (cli.version) {
console.log(`${packageName} ${LIB_VERSION}`);
process.exit(0);
}
if (cli.unknownArgs.length > 0) {
console.error("Error: Invalid argument(s):", cli.unknownArgs.join(", "));
console.error(usageText);
process.exit(1);
}
const env = parseEnv(process.env);
const cfg = (() => {
try {
return finalize(merge(cli, env));
} catch (err) {
die(err instanceof Error ? err.message : String(err));
}
})();
// Check for OpenAI API key and warn if missing
if (!process.env.OPENAI_API_KEY) {
console.warn("Warning: OPENAI_API_KEY environment variable is not set.");
console.warn("The following AI-powered search tools will be unavailable:");
console.warn(" - search_events (natural language event search)");
console.warn(" - search_issues (natural language issue search)");
console.warn(
"All other tools will function normally. To enable AI-powered search, set OPENAI_API_KEY.",
);
console.warn("");
}
configureOpenAIProvider({ baseUrl: cfg.openaiBaseUrl });
Sentry.init({
dsn: cfg.sentryDsn,
sendDefaultPii: true,
tracesSampleRate: 1,
beforeSend: sentryBeforeSend,
initialScope: {
tags: {
"mcp.server_version": LIB_VERSION,
"mcp.transport": "stdio",
"sentry.host": cfg.sentryHost,
"mcp.mcp-url": cfg.mcpUrl,
},
},
release: process.env.SENTRY_RELEASE,
integrations: [
Sentry.consoleLoggingIntegration(),
Sentry.zodErrorsIntegration(),
Sentry.vercelAIIntegration({
recordInputs: true,
recordOutputs: true,
}),
],
environment:
process.env.SENTRY_ENVIRONMENT ??
(process.env.NODE_ENV !== "production" ? "development" : "production"),
});
const server = new McpServer({
name: "Sentry MCP",
version: LIB_VERSION,
});
const instrumentedServer = Sentry.wrapMcpServerWithSentry(server);
const SENTRY_TIMEOUT = 5000; // 5 seconds
// Process scope configuration using shared resolver
// XXX: we could do what we're doing in routes/auth.ts and pass the context
// identically, but we don't really need userId and userName yet
startStdio(instrumentedServer, {
accessToken: cfg.accessToken,
grantedScopes: cfg.finalScopes,
constraints: {
organizationSlug: cfg.organizationSlug ?? null,
projectSlug: cfg.projectSlug ?? null,
},
sentryHost: cfg.sentryHost,
mcpUrl: cfg.mcpUrl,
openaiBaseUrl: cfg.openaiBaseUrl,
}).catch((err) => {
console.error("Server error:", err);
// ensure we've flushed all events
Sentry.flush(SENTRY_TIMEOUT);
process.exit(1);
});
// ensure we've flushed all events
Sentry.flush(SENTRY_TIMEOUT);
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/artifact.json:
--------------------------------------------------------------------------------
```json
{
"namespace": "artifact",
"description": "This group describes attributes specific to artifacts. Artifacts are files or other immutable objects that are intended for distribution. This definition aligns directly with the [SLSA](https://slsa.dev/spec/v1.0/terminology#package-model) package model.\n",
"attributes": {
"artifact.filename": {
"description": "The human readable file name of the artifact, typically generated during build and release processes. Often includes the package name and version in the file name.\n",
"type": "string",
"note": "This file name can also act as the [Package Name](https://slsa.dev/spec/v1.0/terminology#package-model)\nin cases where the package ecosystem maps accordingly.\nAdditionally, the artifact [can be published](https://slsa.dev/spec/v1.0/terminology#software-supply-chain)\nfor others, but that is not a guarantee.\n",
"stability": "development",
"examples": [
"golang-binary-amd64-v0.1.0",
"docker-image-amd64-v0.1.0",
"release-1.tar.gz",
"file-name-package.tar.gz"
]
},
"artifact.version": {
"description": "The version of the artifact.\n",
"type": "string",
"stability": "development",
"examples": ["v0.1.0", "1.2.1", "122691-build"]
},
"artifact.purl": {
"description": "The [Package URL](https://github.com/package-url/purl-spec) of the [package artifact](https://slsa.dev/spec/v1.0/terminology#package-model) provides a standard way to identify and locate the packaged artifact.\n",
"type": "string",
"stability": "development",
"examples": [
"pkg:github/package-url/purl-spec@1209109710924",
"pkg:npm/[email protected]"
]
},
"artifact.hash": {
"description": "The full [hash value (see glossary)](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-5.pdf), often found in checksum.txt on a release of the artifact and used to verify package integrity.\n",
"type": "string",
"note": "The specific algorithm used to create the cryptographic hash value is\nnot defined. In situations where an artifact has multiple\ncryptographic hashes, it is up to the implementer to choose which\nhash value to set here; this should be the most secure hash algorithm\nthat is suitable for the situation and consistent with the\ncorresponding attestation. The implementer can then provide the other\nhash values through an additional set of attribute extensions as they\ndeem necessary.\n",
"stability": "development",
"examples": [
"9ff4c52759e2c4ac70b7d517bc7fcdc1cda631ca0045271ddd1b192544f8a3e9"
]
},
"artifact.attestation.id": {
"description": "The id of the build [software attestation](https://slsa.dev/attestation-model).\n",
"type": "string",
"stability": "development",
"examples": ["123"]
},
"artifact.attestation.filename": {
"description": "The provenance filename of the built attestation which directly relates to the build artifact filename. This filename SHOULD accompany the artifact at publish time. See the [SLSA Relationship](https://slsa.dev/spec/v1.0/distributing-provenance#relationship-between-artifacts-and-attestations) specification for more information.\n",
"type": "string",
"stability": "development",
"examples": [
"golang-binary-amd64-v0.1.0.attestation",
"docker-image-amd64-v0.1.0.intoto.json1",
"release-1.tar.gz.attestation",
"file-name-package.tar.gz.intoto.json1"
]
},
"artifact.attestation.hash": {
"description": "The full [hash value (see glossary)](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-5.pdf), of the built attestation. Some envelopes in the [software attestation space](https://github.com/in-toto/attestation/tree/main/spec) also refer to this as the **digest**.\n",
"type": "string",
"stability": "development",
"examples": [
"1b31dfcd5b7f9267bf2ff47651df1cfb9147b9e4df1f335accf65b4cda498408"
]
}
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/constraint-helpers.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Constraint application helpers for MCP server configuration.
*
* These functions handle the logic for filtering tool schemas and injecting
* constraint parameters, including support for parameter aliases (e.g., projectSlug → projectSlugOrId).
*/
import type { Constraints } from "../types";
import type { z } from "zod";
/**
* Determines which tool parameter keys should be filtered out of the schema
* because they will be injected from constraints.
*
* Handles parameter aliases: when a projectSlug constraint exists and the tool
* has a projectSlugOrId parameter, the alias will be applied UNLESS projectSlugOrId
* is explicitly constrained with a truthy value.
*
* @param constraints - The active constraints (org, project, region)
* @param toolInputSchema - The tool's input schema definition
* @returns Array of parameter keys that should be filtered from the schema
*
* @example
* ```typescript
* const constraints = { projectSlug: "my-project", organizationSlug: "my-org" };
* const schema = { organizationSlug: z.string(), projectSlugOrId: z.string() };
* const keys = getConstraintKeysToFilter(constraints, schema);
* // Returns: ["organizationSlug", "projectSlugOrId"]
* // projectSlugOrId is included because projectSlug constraint will map to it
* ```
*/
export function getConstraintKeysToFilter(
constraints: Constraints & Record<string, string | null | undefined>,
toolInputSchema: Record<string, z.ZodType>,
): string[] {
return Object.entries(constraints).flatMap(([key, value]) => {
if (!value) return [];
const keys: string[] = [];
// If this constraint key exists in the schema, include it
if (key in toolInputSchema) {
keys.push(key);
}
// Special handling: projectSlug constraint can also apply to projectSlugOrId parameter
// Only add the alias to filter if projectSlugOrId isn't being explicitly constrained
if (
key === "projectSlug" &&
"projectSlugOrId" in toolInputSchema &&
!("projectSlugOrId" in constraints && constraints.projectSlugOrId)
) {
keys.push("projectSlugOrId");
}
return keys;
});
}
/**
* Builds the constraint parameters that should be injected into tool calls.
*
* Handles parameter aliases: when a projectSlug constraint exists and the tool
* has a projectSlugOrId parameter, the constraint value will be injected as
* projectSlugOrId UNLESS projectSlugOrId is explicitly constrained with a truthy value.
*
* @param constraints - The active constraints (org, project, region)
* @param toolInputSchema - The tool's input schema definition
* @returns Object mapping parameter names to constraint values
*
* @example
* ```typescript
* const constraints = { projectSlug: "my-project", organizationSlug: "my-org" };
* const schema = { organizationSlug: z.string(), projectSlugOrId: z.string() };
* const params = getConstraintParametersToInject(constraints, schema);
* // Returns: { organizationSlug: "my-org", projectSlugOrId: "my-project" }
* // projectSlug constraint is injected as projectSlugOrId parameter
* ```
*/
export function getConstraintParametersToInject(
constraints: Constraints & Record<string, string | null | undefined>,
toolInputSchema: Record<string, z.ZodType>,
): Record<string, string> {
return Object.fromEntries(
Object.entries(constraints).flatMap(([key, value]) => {
if (!value) return [];
const entries: [string, string][] = [];
// If this constraint key exists in the schema, add it
if (key in toolInputSchema) {
entries.push([key, value]);
}
// Special handling: projectSlug constraint can also apply to projectSlugOrId parameter
// Only apply alias if the target parameter isn't already being constrained with a truthy value
if (
key === "projectSlug" &&
"projectSlugOrId" in toolInputSchema &&
!("projectSlugOrId" in constraints && constraints.projectSlugOrId)
) {
entries.push(["projectSlugOrId", value]);
}
return entries;
}),
);
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/dataset-fields.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from "zod";
import type { SentryApiService } from "../../../api-client";
import { agentTool } from "./utils";
export type DatasetType = "events" | "errors" | "search_issues";
export interface DatasetField {
key: string;
name: string;
totalValues: number;
examples?: string[];
}
export interface DatasetFieldsResult {
dataset: string;
fields: DatasetField[];
commonPatterns: Array<{ pattern: string; description: string }>;
}
/**
* Discover available fields for a dataset by querying Sentry's tags API
*/
export async function discoverDatasetFields(
apiService: SentryApiService,
organizationSlug: string,
dataset: DatasetType,
options: {
projectId?: string;
} = {},
): Promise<DatasetFieldsResult> {
const { projectId } = options;
// Get available tags for the dataset
const tags = await apiService.listTags({
organizationSlug,
dataset,
project: projectId,
statsPeriod: "14d",
});
// Filter out internal Sentry tags and format
const fields = tags
.filter((tag) => !tag.key.startsWith("sentry:"))
.map((tag) => ({
key: tag.key,
name: tag.name,
totalValues: tag.totalValues,
examples: getFieldExamples(tag.key, dataset),
}));
return {
dataset,
fields,
commonPatterns: getCommonPatterns(dataset),
};
}
/**
* Create a tool for discovering available fields in a dataset
* The tool is pre-bound with the API service and organization configured for the appropriate region
*/
export function createDatasetFieldsTool(options: {
apiService: SentryApiService;
organizationSlug: string;
dataset: DatasetType;
projectId?: string;
}) {
const { apiService, organizationSlug, dataset, projectId } = options;
return agentTool({
description: `Discover available fields for ${dataset} searches in Sentry (includes example values)`,
parameters: z.object({}),
execute: async () => {
return discoverDatasetFields(apiService, organizationSlug, dataset, {
projectId,
});
},
});
}
/**
* Get example values for common fields
*/
export function getFieldExamples(
key: string,
dataset: string,
): string[] | undefined {
const commonExamples: Record<string, string[]> = {
level: ["error", "warning", "info", "debug", "fatal"],
environment: ["production", "staging", "development"],
release: ["v1.0.0", "latest", "[email protected]"],
user: ["user123", "[email protected]"],
};
const issueExamples: Record<string, string[]> = {
...commonExamples,
assignedOrSuggested: ["[email protected]", "team-slug", "me"],
is: ["unresolved", "resolved", "ignored"],
};
const eventExamples: Record<string, string[]> = {
...commonExamples,
"http.method": ["GET", "POST", "PUT", "DELETE"],
"http.status_code": ["200", "404", "500"],
"db.system": ["postgresql", "mysql", "redis"],
};
if (dataset === "search_issues") {
return issueExamples[key];
}
if (dataset === "events" || dataset === "errors") {
return eventExamples[key];
}
return commonExamples[key];
}
/**
* Get common search patterns for a dataset
*/
export function getCommonPatterns(dataset: string) {
if (dataset === "search_issues") {
return [
{ pattern: "is:unresolved", description: "Open issues" },
{ pattern: "is:resolved", description: "Closed issues" },
{ pattern: "level:error", description: "Error level issues" },
{
pattern: "firstSeen:-24h",
description: "New issues from last 24 hours",
},
{
pattern: "userCount:>100",
description: "Affecting more than 100 users",
},
];
}
if (dataset === "events" || dataset === "errors") {
return [
{ pattern: "level:error", description: "Error events" },
{ pattern: "environment:production", description: "Production events" },
{ pattern: "timestamp:-1h", description: "Events from last hour" },
{ pattern: "has:http.method", description: "HTTP requests" },
{ pattern: "has:db.statement", description: "Database queries" },
];
}
return [];
}
```
--------------------------------------------------------------------------------
/docs/permissions-and-scopes.md:
--------------------------------------------------------------------------------
```markdown
# Permissions and Scopes
OAuth-style scope system for controlling access to Sentry MCP tools.
## Default Permissions
**By default, all users receive read-only access.** This includes:
- `org:read`, `project:read`, `team:read`, `event:read`
Additional permissions must be explicitly granted through the OAuth flow or CLI arguments.
## Permission Levels
When authenticating via OAuth, users can select additional permissions:
| Level | Scopes | Tools Enabled |
|-------|--------|--------------|
| **Read-Only** (default) | `org:read`, `project:read`, `team:read`, `event:read` | Search, view issues/traces, documentation |
| **+ Issue Triage** | Adds `event:write` | All above + resolve/assign issues, AI analysis |
| **+ Project Management** | Adds `project:write`, `team:write` | All above + create/modify projects/teams/DSNs |
### CLI Usage
```bash
# Default: read-only access
npx @sentry/mcp-server --access-token=TOKEN
# Override defaults with specific scopes only
npx @sentry/mcp-server --access-token=TOKEN --scopes=org:read,event:read
# Add write permissions to default read-only scopes
npx @sentry/mcp-server --access-token=TOKEN --add-scopes=event:write,project:write
# Via environment variables
export MCP_SCOPES=org:read,project:write # Overrides defaults
export MCP_ADD_SCOPES=event:write # Adds to defaults
npx @sentry/mcp-server --access-token=TOKEN
```
Precedence and validation:
- Flags override environment variables. If `--scopes` is provided, `MCP_SCOPES` is ignored. If `--add-scopes` is provided, `MCP_ADD_SCOPES` is ignored.
- Flags and env vars are strict: any invalid scope token causes an error listing allowed scopes.
**Note:** `--scopes` completely replaces the default scopes, while `--add-scopes` adds to them.
## Scope Hierarchy
Higher scopes include lower ones:
```
admin → write → read
```
Examples:
- `team:write` includes `team:read`
- `event:admin` includes `event:write` and `event:read`
## Available Scopes
| Resource | Read | Write | Admin |
|----------|------|-------|-------|
| **Organization** | `org:read` | `org:write` | `org:admin` |
| **Project** | `project:read` | `project:write` | `project:admin` |
| **Team** | `team:read` | `team:write` | `team:admin` |
| **Member** | `member:read` | `member:write` | `member:admin` |
| **Event/Issue** | `event:read` | `event:write` | `event:admin` |
| **Special** | `project:releases` | - | - |
## Tool Requirements
### Always Available (No Scopes)
- `whoami` - User identification
- `search_docs` - Documentation search
- `get_doc` - Documentation retrieval
### Read Operations
- `find_organizations` - `org:read`
- `find_projects` - `project:read`
- `find_teams` - `team:read`
- `find_releases` - `project:read`
- `find_dsns` - `project:read`
- `get_issue_details` - `event:read`
- `get_event_attachment` - `event:read`
- `get_trace_details` - `event:read`
- `search_events` - `event:read`
- `search_issues` - `event:read`
- `analyze_issue_with_seer` - `event:read`
### Write Operations
- `update_issue` - `event:write`
- `create_project` - `project:write`, `team:read`
- `update_project` - `project:write`
- `create_team` - `team:write`
- `create_dsn` - `project:write`
## How It Works
1. **Sentry Authentication**: MCP requests all necessary scopes from Sentry
2. **Permission Selection**: User chooses permission level in approval dialog
3. **Tool Filtering**: MCP filters available tools based on granted scopes
4. **Runtime Validation**: Scopes checked when tools are invoked
## Notes
- Default behavior grants read-only access if no scopes specified
- Embedded agent tools don't require scope binding
- Documentation tools always available regardless of scopes
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Tool not in list | Check required scopes are granted |
| "Tool not allowed" error | Re-authenticate with higher permission level |
| Invalid scope | Use lowercase with colon separator (e.g., `event:write`) |
## References
- Adding Tools: @docs/adding-tools.mdc — Add tools with scope requirements
- Testing: @docs/testing.mdc — Test with different scope configurations
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/cli/parse.ts:
--------------------------------------------------------------------------------
```typescript
import { parseArgs } from "node:util";
import type { CliArgs, EnvArgs, MergedArgs } from "./types";
export function parseArgv(argv: string[]): CliArgs {
const options = {
"access-token": { type: "string" as const },
host: { type: "string" as const },
url: { type: "string" as const },
"mcp-url": { type: "string" as const },
"sentry-dsn": { type: "string" as const },
"openai-base-url": { type: "string" as const },
"organization-slug": { type: "string" as const },
"project-slug": { type: "string" as const },
scopes: { type: "string" as const },
"add-scopes": { type: "string" as const },
"all-scopes": { type: "boolean" as const },
help: { type: "boolean" as const, short: "h" as const },
version: { type: "boolean" as const, short: "v" as const },
};
const { values, positionals, tokens } = parseArgs({
args: argv,
options,
allowPositionals: false,
strict: false,
tokens: true,
});
const knownLong = new Set(Object.keys(options));
const knownShort = new Set([
...(Object.values(options)
.map((o) => ("short" in o ? (o.short as string | undefined) : undefined))
.filter(Boolean) as string[]),
]);
const unknownArgs: string[] = [];
for (const t of (tokens as any[]) || []) {
if (t.kind === "option") {
const name = t.name as string | undefined;
if (name && !(knownLong.has(name) || knownShort.has(name))) {
unknownArgs.push((t.raw as string) ?? `--${name}`);
}
} else if (t.kind === "positional") {
unknownArgs.push((t.raw as string) ?? String(t.value ?? ""));
}
}
return {
accessToken: values["access-token"] as string | undefined,
host: values.host as string | undefined,
url: values.url as string | undefined,
mcpUrl: values["mcp-url"] as string | undefined,
sentryDsn: values["sentry-dsn"] as string | undefined,
openaiBaseUrl: values["openai-base-url"] as string | undefined,
organizationSlug: values["organization-slug"] as string | undefined,
projectSlug: values["project-slug"] as string | undefined,
scopes: values.scopes as string | undefined,
addScopes: values["add-scopes"] as string | undefined,
allScopes: (values["all-scopes"] as boolean | undefined) === true,
help: (values.help as boolean | undefined) === true,
version: (values.version as boolean | undefined) === true,
unknownArgs:
unknownArgs.length > 0 ? unknownArgs : (positionals as string[]) || [],
};
}
export function parseEnv(env: NodeJS.ProcessEnv): EnvArgs {
const fromEnv: EnvArgs = {};
if (env.SENTRY_ACCESS_TOKEN) fromEnv.accessToken = env.SENTRY_ACCESS_TOKEN;
if (env.SENTRY_URL) fromEnv.url = env.SENTRY_URL;
if (env.SENTRY_HOST) fromEnv.host = env.SENTRY_HOST;
if (env.MCP_URL) fromEnv.mcpUrl = env.MCP_URL;
if (env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN)
fromEnv.sentryDsn = env.SENTRY_DSN || env.DEFAULT_SENTRY_DSN;
if (env.MCP_SCOPES) fromEnv.scopes = env.MCP_SCOPES;
if (env.MCP_ADD_SCOPES) fromEnv.addScopes = env.MCP_ADD_SCOPES;
return fromEnv;
}
export function merge(cli: CliArgs, env: EnvArgs): MergedArgs {
// CLI wins over env
const merged: MergedArgs = {
accessToken: cli.accessToken ?? env.accessToken,
// If CLI provided url/host, prefer those; else fall back to env
url: cli.url ?? env.url,
host: cli.host ?? env.host,
mcpUrl: cli.mcpUrl ?? env.mcpUrl,
sentryDsn: cli.sentryDsn ?? env.sentryDsn,
openaiBaseUrl: cli.openaiBaseUrl,
// Scopes precedence: CLI scopes/add-scopes override their env counterparts
scopes: cli.scopes ?? env.scopes,
addScopes: cli.addScopes ?? env.addScopes,
allScopes: cli.allScopes === true,
organizationSlug: cli.organizationSlug,
projectSlug: cli.projectSlug,
help: cli.help === true,
version: cli.version === true,
unknownArgs: cli.unknownArgs,
};
// If CLI provided scopes, ignore additive env var
if (cli.scopes) merged.addScopes = cli.addScopes;
// If CLI provided add-scopes, ensure scopes override isn't pulled from env
if (cli.addScopes) merged.scopes = cli.scopes;
return merged;
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/utils.ts:
--------------------------------------------------------------------------------
```typescript
import { tool } from "ai";
import { z } from "zod";
import { UserInputError } from "../../../errors";
import { ApiClientError, ApiServerError } from "../../../api-client";
import { logIssue, logWarn } from "../../../telem/logging";
/**
* Standard response schema for all embedded agent tools.
* Tools return either an error message or the result data, never both.
*/
const AgentToolResponseSchema = z.object({
error: z
.string()
.optional()
.describe("Error message if the operation failed"),
result: z.unknown().optional().describe("The successful result data"),
});
export type AgentToolResponse<T = unknown> = {
error?: string;
result?: T;
};
/**
* Handles errors from agent tool execution and returns appropriate error messages.
*
* SECURITY: Only returns trusted error messages to prevent prompt injection.
* We trust: Sentry API errors, our own UserInputError messages, and system templates.
*/
function handleAgentToolError<T>(error: unknown): AgentToolResponse<T> {
if (error instanceof UserInputError) {
// Log UserInputError for Sentry logging (as log, not exception)
logWarn(error, {
loggerScope: ["agent-tools", "user-input"],
contexts: {
agentTool: {
errorType: "UserInputError",
},
},
});
return {
error: `Input Error: ${error.message}. You may be able to resolve this by addressing the concern and trying again.`,
};
}
if (error instanceof ApiClientError) {
// Log ApiClientError for Sentry logging (as log, not exception)
const message = error.toUserMessage();
logWarn(message, {
loggerScope: ["agent-tools", "api-client"],
contexts: {
agentTool: {
errorType: error.name,
status: error.status ?? null,
},
},
});
return {
error: `Input Error: ${message}. You may be able to resolve this by addressing the concern and trying again.`,
};
}
if (error instanceof ApiServerError) {
// Log server errors to Sentry and get Event ID
const eventId = logIssue(error);
const statusText = error.status ? ` (${error.status})` : "";
return {
error: `Server Error${statusText}: ${error.message}. Event ID: ${eventId}. This is a system error that cannot be resolved by retrying.`,
};
}
// Log unexpected errors to Sentry and return safe generic message
// SECURITY: Don't return untrusted error messages that could enable prompt injection
const eventId = logIssue(error);
return {
error: `System Error: An unexpected error occurred. Event ID: ${eventId}. This is a system error that cannot be resolved by retrying.`,
};
}
/**
* Creates an embedded agent tool with automatic error handling and schema wrapping.
*
* This wrapper:
* - Maintains the same API as the AI SDK's tool() function
* - Automatically wraps the result schema with error/result structure
* - Handles all error types and returns them as structured responses
* - Preserves type inference from the original tool implementation
*
* @example
* ```typescript
* export function createMyTool(apiService: SentryApiService) {
* return agentTool({
* description: "My tool description",
* parameters: z.object({ param: z.string() }),
* execute: async (params) => {
* // Tool implementation that might throw errors
* const result = await apiService.someMethod(params);
* return result; // Original return type preserved
* }
* });
* }
* ```
*/
export function agentTool<TParameters, TResult>(config: {
description: string;
parameters: z.ZodSchema<TParameters>;
execute: (params: TParameters) => Promise<TResult>;
}) {
// Infer the result type from the execute function's return type
type InferredResult = Awaited<ReturnType<typeof config.execute>>;
return tool({
description: config.description,
parameters: config.parameters,
execute: async (
params: TParameters,
): Promise<AgentToolResponse<InferredResult>> => {
try {
const result = await config.execute(params);
return { result };
} catch (error) {
return handleAgentToolError<InferredResult>(error);
}
},
});
}
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/app.tsx:
--------------------------------------------------------------------------------
```typescript
import { Header } from "./components/ui/header";
import { useState, useEffect } from "react";
import { Chat } from "./components/chat";
import { useAuth } from "./contexts/auth-context";
import Home from "./pages/home";
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 based on screen size to avoid flash on mobile
// Note: This is safe for SSR since we handle the correction in useEffect
if (typeof window !== "undefined") {
return window.innerWidth >= 768; // Desktop: open, Mobile: closed
}
// SSR fallback - default to true for desktop-first approach
return true;
});
// 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 open on desktop, closed on mobile
setIsChatOpen(window.innerWidth >= 768);
}
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, []);
// Handle window resize to adjust chat state appropriately
useEffect(() => {
const handleResize = () => {
// If no explicit URL state, adjust based on screen size
const urlParams = new URLSearchParams(window.location.search);
if (!urlParams.has("chat")) {
const isDesktop = window.innerWidth >= 768;
setIsChatOpen(isDesktop); // Open on desktop, closed on mobile
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<div className="min-h-screen text-white">
{/* Mobile layout: Single column with overlay chat */}
<div className="md:hidden h-screen flex flex-col">
<div className="flex-1 overflow-y-auto sm:p-8 p-4">
<div className="max-w-3xl mx-auto">
<Header isAuthenticated={isAuthenticated} onLogout={handleLogout} />
<Home onChatClick={() => toggleChat(true)} />
</div>
</div>
</div>
{/* Desktop layout: Main content adjusts width based on chat state */}
<div className="hidden md:flex h-screen">
<div
className={`flex flex-col ${isChatOpen ? "w-1/2" : "flex-1"} md:transition-all md:duration-300`}
>
<div className="flex-1 overflow-y-auto sm:p-8 p-4">
<div className="max-w-3xl mx-auto">
<Header
isAuthenticated={isAuthenticated}
onLogout={handleLogout}
/>
<Home onChatClick={() => toggleChat(true)} />
</div>
</div>
</div>
</div>
{/* Single Chat component - handles both mobile and desktop layouts */}
<Chat
isOpen={isChatOpen}
onClose={() => toggleChat(false)}
onLogout={handleLogout}
/>
</div>
);
}
```
--------------------------------------------------------------------------------
/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 missing access token", async () => {
const result = await verifyConstraintsAccess(
{ organizationSlug: "org", projectSlug: null },
{ accessToken: "", sentryHost: host },
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(401);
}
});
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'",
);
}
});
it("handles null access token", async () => {
const result = await verifyConstraintsAccess(
{ organizationSlug: "org", projectSlug: null },
{ accessToken: null, 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("handles undefined access token", async () => {
const result = await verifyConstraintsAccess(
{ organizationSlug: "org", projectSlug: null },
{ accessToken: undefined, 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("handles org with missing regionUrl (regionUrl defaults to null)", async () => {
// This tests the case where org.links?.regionUrl is not available
// The mock always returns regionUrl, so this tests the fallback logic
const result = await verifyConstraintsAccess(
{ organizationSlug: "sentry-mcp-evals", projectSlug: null },
{ accessToken: token, sentryHost: host },
);
expect(result.ok).toBe(true);
if (result.ok) {
// Should still get regionUrl from the mock org data
expect(result.constraints.regionUrl).toBe("https://us.sentry.io");
}
});
});
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/get-doc.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from "zod";
import { setTag } from "@sentry/core";
import { defineTool } from "../internal/tool-helpers/define";
import { fetchWithTimeout } from "../internal/fetch-utils";
import { UserInputError } from "../errors";
import { ApiError } from "../api-client/index";
import type { ServerContext } from "../types";
export default defineTool({
name: "get_doc",
requiredScopes: [], // Documentation reading doesn't require specific scopes
description: [
"Fetch the full markdown content of a Sentry documentation page.",
"",
"Use this tool when you need to:",
"- Read the complete documentation for a specific topic",
"- Get detailed implementation examples or code snippets",
"- Access the full context of a documentation page",
"- Extract specific sections from documentation",
"",
"<examples>",
"### Get the Next.js integration guide",
"",
"```",
"get_doc(path='/platforms/javascript/guides/nextjs.md')",
"```",
"</examples>",
"",
"<hints>",
"- Use the path from search_docs results for accurate fetching",
"- Paths should end with .md extension",
"</hints>",
].join("\n"),
inputSchema: {
path: z
.string()
.trim()
.describe(
"The documentation path (e.g., '/platforms/javascript/guides/nextjs.md'). Get this from search_docs results.",
),
},
annotations: {
readOnlyHint: true,
openWorldHint: true,
},
async handler(params, context: ServerContext) {
setTag("doc.path", params.path);
let output = `# Documentation Content\n\n`;
output += `**Path**: ${params.path}\n\n`;
// Validate path format
if (!params.path.endsWith(".md")) {
throw new UserInputError(
"Invalid documentation path. Path must end with .md extension.",
);
}
// Use docs.sentry.io for now - will be configurable via flag in the future
const baseUrl = "https://docs.sentry.io";
// Construct the full URL for the markdown file
const docUrl = new URL(params.path, baseUrl);
// Validate domain whitelist for security
const allowedDomains = ["docs.sentry.io", "develop.sentry.io"];
if (!allowedDomains.includes(docUrl.hostname)) {
throw new UserInputError(
`Invalid domain. Documentation can only be fetched from allowed domains: ${allowedDomains.join(", ")}`,
);
}
const response = await fetchWithTimeout(
docUrl.toString(),
{
headers: {
Accept: "text/plain, text/markdown",
"User-Agent": "Sentry-MCP/1.0",
},
},
15000, // 15 second timeout
);
if (!response.ok) {
if (response.status === 404) {
output += `**Error**: Documentation not found at this path.\n\n`;
output += `Please verify the path is correct. Common issues:\n`;
output += `- Path should start with / (e.g., /platforms/javascript/guides/nextjs.md)\n`;
output += `- Path should match exactly what's shown in search_docs results\n`;
output += `- Some pages may have been moved or renamed\n\n`;
output += `Try searching again with \`search_docs()\` to find the correct path.\n`;
return output;
}
throw new ApiError(
`Failed to fetch documentation: ${response.statusText}`,
response.status,
);
}
const content = await response.text();
// Check if we got HTML instead of markdown (wrong path format)
if (
content.trim().startsWith("<!DOCTYPE") ||
content.trim().startsWith("<html")
) {
output += `> **Error**: Received HTML instead of markdown. The path may be incorrect.\n\n`;
output += `Make sure to use the .md extension in the path.\n`;
output += `Example: /platforms/javascript/guides/nextjs.md\n`;
return output;
}
// Add the markdown content
output += "---\n\n";
output += content;
output += "\n\n---\n\n";
output += "## Using this documentation\n\n";
output +=
"- This is the raw markdown content from Sentry's documentation\n";
output +=
"- Code examples and configuration snippets can be copied directly\n";
output +=
"- Links in the documentation are relative to https://docs.sentry.io\n";
output +=
"- For more related topics, use `search_docs()` to find additional pages\n";
return output;
},
});
```
--------------------------------------------------------------------------------
/.github/workflows/smoke-tests.yml:
--------------------------------------------------------------------------------
```yaml
name: Smoke Tests (Local)
permissions:
contents: read
checks: write
on:
push:
branches: [main]
pull_request:
jobs:
smoke-tests:
name: Run Smoke Tests Against Local Server
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
# pnpm/action-setup@v4
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Build
run: pnpm build
- name: Start local dev server
working-directory: packages/mcp-cloudflare
run: |
# Start wrangler in background and capture output
pnpm exec wrangler dev --port 8788 --local > wrangler.log 2>&1 &
WRANGLER_PID=$!
echo "WRANGLER_PID=$WRANGLER_PID" >> $GITHUB_ENV
echo "Waiting for server to start (PID: $WRANGLER_PID)..."
# Wait for server to be ready (up to 2 minutes)
MAX_ATTEMPTS=24
ATTEMPT=0
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
# Check if wrangler process is still running
if ! kill -0 $WRANGLER_PID 2>/dev/null; then
echo "❌ Wrangler process died unexpectedly!"
echo "📋 Last 50 lines of wrangler.log:"
tail -50 wrangler.log
exit 1
fi
if curl -s -f -o /dev/null http://localhost:8788/; then
echo "✅ Server is ready!"
echo "📋 Wrangler startup log:"
cat wrangler.log
break
else
echo "⏳ Waiting for server to start (attempt $((ATTEMPT+1))/$MAX_ATTEMPTS)..."
# Show partial log every 5 attempts
if [ $((ATTEMPT % 5)) -eq 0 ] && [ $ATTEMPT -gt 0 ]; then
echo "📋 Current wrangler.log output:"
tail -20 wrangler.log
fi
fi
ATTEMPT=$((ATTEMPT+1))
sleep 5
done
if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
echo "❌ Server failed to start after $MAX_ATTEMPTS attempts"
echo "📋 Full wrangler.log:"
cat wrangler.log
exit 1
fi
- name: Run smoke tests against local server
env:
PREVIEW_URL: http://localhost:8788
working-directory: packages/smoke-tests
run: |
echo "🧪 Running smoke tests against local server at $PREVIEW_URL"
# Give server a bit more time to stabilize after startup
echo "⏳ Waiting 5 seconds for server to stabilize..."
sleep 5
# Verify server is still responding before running tests
if ! curl -s -f -o /dev/null http://localhost:8788/; then
echo "❌ Server is not responding before tests!"
echo "📋 Wrangler log:"
cat ../mcp-cloudflare/wrangler.log
exit 1
fi
echo "✅ Server is responding, running tests..."
pnpm test:ci || TEST_EXIT_CODE=$?
# If tests failed, show server logs for debugging
if [ "${TEST_EXIT_CODE:-0}" -ne 0 ]; then
echo "❌ Tests failed with exit code ${TEST_EXIT_CODE}"
echo "📋 Wrangler log at time of failure:"
cat ../mcp-cloudflare/wrangler.log
exit ${TEST_EXIT_CODE}
fi
- name: Publish Smoke Test Report
uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857
if: always()
with:
report_paths: "packages/smoke-tests/tests.junit.xml"
check_name: "Local Smoke Test Results"
fail_on_failure: true
- name: Stop local server
if: always()
run: |
if [ ! -z "$WRANGLER_PID" ]; then
kill $WRANGLER_PID || true
fi
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/schema.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Reusable Zod parameter schemas for MCP tools.
*
* Shared validation schemas used across tool definitions to ensure consistent
* parameter handling and validation. Each schema includes transformation
* (e.g., toLowerCase, trim) and LLM-friendly descriptions.
*/
import { z } from "zod";
import { SENTRY_GUIDES } from "./constants";
import { validateSlug } from "./utils/slug-validation";
export const ParamOrganizationSlug = z
.string()
.toLowerCase()
.trim()
.superRefine(validateSlug)
.describe(
"The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool.",
);
export const ParamTeamSlug = z
.string()
.toLowerCase()
.trim()
.superRefine(validateSlug)
.describe(
"The team's slug. You can find a list of existing teams in an organization using the `find_teams()` tool.",
);
export const ParamProjectSlug = z
.string()
.toLowerCase()
.trim()
.superRefine(validateSlug)
.describe(
"The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool.",
);
export const ParamProjectSlugOrAll = z
.string()
.toLowerCase()
.trim()
.superRefine(validateSlug)
.describe(
"The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible.",
);
export const ParamSearchQuery = z
.string()
.trim()
.describe(
"Search query to filter results by name or slug. Use this to narrow down results when there are many items.",
);
export const ParamIssueShortId = z
.string()
.toUpperCase()
.trim()
.describe("The Issue ID. e.g. `PROJECT-1Z43`");
export const ParamIssueUrl = z
.string()
.url()
.trim()
.describe(
"The URL of the issue. e.g. https://my-organization.sentry.io/issues/PROJECT-1Z43",
);
export const ParamTraceId = z
.string()
.trim()
.regex(
/^[0-9a-fA-F]{32}$/,
"Trace ID must be a 32-character hexadecimal string",
)
.describe("The trace ID. e.g. `a4d1aae7216b47ff8117cf4e09ce9d0a`");
export const ParamPlatform = z
.string()
.toLowerCase()
.trim()
.describe(
"The platform for the project. e.g., python, javascript, react, etc.",
);
export const ParamTransaction = z
.string()
.trim()
.describe("The transaction name. Also known as the endpoint, or route name.");
export const ParamQuery = z
.string()
.trim()
.describe(
`The search query to apply. Use the \`help(subject="query_syntax")\` tool to get more information about the query syntax rather than guessing.`,
);
/**
* Region URL parameter for Sentry API requests.
*
* Handles region-specific URLs for Sentry's Cloud Service while gracefully
* supporting self-hosted Sentry installations that may return empty regionUrl values.
* This schema accepts both valid URLs and empty strings to ensure compatibility
* across different Sentry deployment types.
*/
export const ParamRegionUrl = z
.string()
.trim()
.refine((value) => !value || z.string().url().safeParse(value).success, {
message: "Must be a valid URL or empty string (for self-hosted Sentry)",
})
.describe(
"The region URL for the organization you're querying, if known. " +
"For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. " +
"For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. " +
"You can find the correct regionUrl from the organization details using the `find_organizations()` tool.",
);
export const ParamIssueStatus = z
.enum(["resolved", "resolvedInNextRelease", "unresolved", "ignored"])
.describe(
"The new status for the issue. Valid values are 'resolved', 'resolvedInNextRelease', 'unresolved', and 'ignored'.",
);
export const ParamAssignedTo = z
.string()
.trim()
.describe(
"The assignee in format 'user:ID' or 'team:ID' where ID is numeric. Example: 'user:123456' or 'team:789'. Use the whoami tool to find your user ID.",
);
export const ParamSentryGuide = z
.enum(SENTRY_GUIDES)
.describe(
"Optional guide filter to limit search results to specific documentation sections. " +
"Use either a platform (e.g., 'javascript', 'python') or platform/guide combination (e.g., 'javascript/nextjs', 'python/django').",
);
export const ParamEventId = z.string().trim().describe("The ID of the event.");
export const ParamAttachmentId = z
.string()
.trim()
.describe("The ID of the attachment to download.");
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/issue-helpers.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Issue parameter parsing and validation utilities.
*
* Handles flexible input formats for Sentry issues (URLs vs explicit parameters),
* extracts organization and issue identifiers, and validates issue ID formats.
* Provides robust parsing for LLM-generated parameters that may contain formatting
* inconsistencies.
*/
import { UserInputError } from "../errors";
/**
* Extracts the Sentry issue ID and organization slug from a full URL
*
* @param url - A full Sentry issue URL
* @returns Object containing the numeric issue ID and organization slug (if found)
* @throws Error if the input is invalid
*/
export function extractIssueId(url: string): {
issueId: string;
organizationSlug: string;
} {
if (!url || typeof url !== "string") {
throw new UserInputError(
"Invalid Sentry issue URL. URL must be a non-empty string.",
);
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
throw new UserInputError(
"Invalid Sentry issue URL. Must start with http:// or https://",
);
}
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch (error) {
throw new UserInputError(
`Invalid Sentry issue URL. Unable to parse URL: ${url}`,
);
}
const pathParts = parsedUrl.pathname.split("/").filter(Boolean);
if (pathParts.length < 2 || !pathParts.includes("issues")) {
throw new UserInputError(
"Invalid Sentry issue URL. Path must contain '/issues/{issue_id}'",
);
}
const issueId = pathParts[pathParts.indexOf("issues") + 1];
if (!issueId) {
throw new UserInputError("Unable to determine issue ID from URL.");
}
// Extract organization slug from either the path or subdomain
let organizationSlug: string | undefined;
if (pathParts.includes("organizations")) {
organizationSlug = pathParts[pathParts.indexOf("organizations") + 1];
} else if (pathParts.length > 1 && pathParts[0] !== "issues") {
// If URL is like sentry.io/sentry/issues/123
organizationSlug = pathParts[0];
} else {
// Check for subdomain
const hostParts = parsedUrl.hostname.split(".");
if (hostParts.length > 2 && hostParts[0] !== "www") {
organizationSlug = hostParts[0];
}
}
if (!organizationSlug) {
throw new UserInputError(
"Invalid Sentry issue URL. Could not determine organization.",
);
}
return { issueId, organizationSlug };
}
/**
* Sometimes the LLM will pass in a funky issue shortId. For example it might pass
* in "CLOUDFLARE-MCP-41." instead of "CLOUDFLARE-MCP-41". This function attempts to
* fix common issues.
*
* @param issueId - The issue ID to parse
* @returns The parsed issue ID
*/
export function parseIssueId(issueId: string) {
if (!issueId.trim()) {
throw new UserInputError("Issue ID cannot be empty");
}
let finalIssueId = issueId;
// remove trailing punctuation
finalIssueId = finalIssueId.replace(/[^\w-]/g, "");
if (!finalIssueId) {
throw new UserInputError(
"Issue ID cannot be empty after removing special characters",
);
}
// Validate against common Sentry issue ID patterns
// Either numeric IDs or PROJECT-ABC123 format
// Allow project codes to start with alphanumeric characters (including numbers)
const validFormatRegex = /^(\d+|[A-Za-z0-9][\w-]*-[A-Za-z0-9]+)$/;
if (!validFormatRegex.test(finalIssueId)) {
throw new UserInputError(
`Invalid issue ID format: "${finalIssueId}". Expected either a numeric ID or a project code followed by an alphanumeric identifier (e.g., "PROJECT-ABC123").`,
);
}
return finalIssueId;
}
/**
* Parses issue parameters from a variety of formats.
*
* @param params - Object containing issue URL, issue ID, and organization slug
* @returns Object containing the parsed organization slug and issue ID
* @throws Error if the input is invalid
*/
export function parseIssueParams({
issueUrl,
issueId,
organizationSlug,
}: {
issueUrl?: string | null;
issueId?: string | null;
organizationSlug?: string | null;
}): {
organizationSlug: string;
issueId: string;
} {
if (issueUrl) {
const resolved = extractIssueId(issueUrl);
if (!resolved) {
throw new Error(
"Invalid Sentry issue URL. Path should contain '/issues/{issue_id}'",
);
}
return {
...resolved,
issueId: parseIssueId(resolved.issueId),
};
}
if (!organizationSlug) {
throw new UserInputError("Organization slug is required");
}
if (issueId) {
return {
organizationSlug,
issueId: parseIssueId(issueId),
};
}
throw new UserInputError("Either issueId or issueUrl must be provided");
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/host.json:
--------------------------------------------------------------------------------
```json
{
"namespace": "host",
"description": "A host is defined as a computing instance. For example, physical servers, virtual machines, switches or disk array.\n",
"attributes": {
"host.id": {
"description": "Unique host ID. For Cloud, this must be the instance_id assigned by the cloud provider. For non-containerized systems, this should be the `machine-id`. See the table below for the sources to use to determine the `machine-id` based on operating system.\n",
"type": "string",
"stability": "development",
"examples": ["fdbf79e8af94cb7f9e8df36789187052"]
},
"host.name": {
"description": "Name of the host. On Unix systems, it may contain what the hostname command returns, or the fully qualified hostname, or another name specified by the user.\n",
"type": "string",
"stability": "development",
"examples": ["opentelemetry-test"]
},
"host.type": {
"description": "Type of host. For Cloud, this must be the machine type.\n",
"type": "string",
"stability": "development",
"examples": ["n1-standard-1"]
},
"host.arch": {
"description": "The CPU architecture the host system is running on.\n",
"type": "string",
"stability": "development",
"examples": [
"amd64",
"arm32",
"arm64",
"ia64",
"ppc32",
"ppc64",
"s390x",
"x86"
]
},
"host.image.name": {
"description": "Name of the VM image or OS install the host was instantiated from.\n",
"type": "string",
"stability": "development",
"examples": [
"infra-ami-eks-worker-node-7d4ec78312",
"CentOS-8-x86_64-1905"
]
},
"host.image.id": {
"description": "VM image ID or host OS image ID. For Cloud, this value is from the provider.\n",
"type": "string",
"stability": "development",
"examples": ["ami-07b06b442921831e5"]
},
"host.image.version": {
"description": "The version string of the VM image or host OS as defined in [Version Attributes](/docs/resource/README.md#version-attributes).\n",
"type": "string",
"stability": "development",
"examples": ["0.1"]
},
"host.ip": {
"description": "Available IP addresses of the host, excluding loopback interfaces.\n",
"type": "string",
"note": "IPv4 Addresses MUST be specified in dotted-quad notation. IPv6 addresses MUST be specified in the [RFC 5952](https://www.rfc-editor.org/rfc/rfc5952.html) format.\n",
"stability": "development",
"examples": ["[\"192.168.1.140\",\"fe80::abc2:4a28:737a:609e\"]"]
},
"host.mac": {
"description": "Available MAC addresses of the host, excluding loopback interfaces.\n",
"type": "string",
"note": "MAC Addresses MUST be represented in [IEEE RA hexadecimal form](https://standards.ieee.org/wp-content/uploads/import/documents/tutorials/eui.pdf): as hyphen-separated octets in uppercase hexadecimal form from most to least significant.\n",
"stability": "development",
"examples": ["[\"AC-DE-48-23-45-67\",\"AC-DE-48-23-45-67-01-9F\"]"]
},
"host.cpu.vendor.id": {
"description": "Processor manufacturer identifier. A maximum 12-character string.\n",
"type": "string",
"note": "[CPUID](https://wiki.osdev.org/CPUID) command returns the vendor ID string in EBX, EDX and ECX registers. Writing these to memory in this order results in a 12-character string.\n",
"stability": "development",
"examples": ["GenuineIntel"]
},
"host.cpu.family": {
"description": "Family or generation of the CPU.\n",
"type": "string",
"stability": "development",
"examples": ["6", "PA-RISC 1.1e"]
},
"host.cpu.model.id": {
"description": "Model identifier. It provides more granular information about the CPU, distinguishing it from other CPUs within the same family.\n",
"type": "string",
"stability": "development",
"examples": ["6", "9000/778/B180L"]
},
"host.cpu.model.name": {
"description": "Model designation of the processor.\n",
"type": "string",
"stability": "development",
"examples": ["11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz"]
},
"host.cpu.stepping": {
"description": "Stepping or core revisions.\n",
"type": "string",
"stability": "development",
"examples": ["1", "r1p1"]
},
"host.cpu.cache.l2.size": {
"description": "The amount of level 2 memory cache available to the processor (in Bytes).\n",
"type": "number",
"stability": "development",
"examples": ["12288000"]
}
}
}
```
--------------------------------------------------------------------------------
/packages/mcp-server-mocks/src/fixtures/issue.json:
--------------------------------------------------------------------------------
```json
{
"id": "6507376925",
"shareId": null,
"shortId": "CLOUDFLARE-MCP-41",
"title": "Error: Tool list_organizations is already registered",
"culprit": "Object.fetch(index)",
"permalink": "https://sentry-mcp-evals.sentry.io/issues/6507376925/",
"logger": null,
"level": "error",
"status": "unresolved",
"statusDetails": {},
"substatus": "ongoing",
"isPublic": false,
"platform": "javascript",
"project": {
"id": "4509062593708032",
"name": "CLOUDFLARE-MCP",
"slug": "CLOUDFLARE-MCP",
"platform": "bun"
},
"type": "error",
"metadata": {
"value": "Tool list_organizations is already registered",
"type": "Error",
"filename": "index.js",
"function": "Object.fetch",
"in_app_frame_mix": "in-app-only",
"sdk": {
"name": "sentry.javascript.cloudflare",
"name_normalized": "sentry.javascript.cloudflare"
},
"severity": 0,
"severity_reason": "ml",
"initial_priority": 50,
"title": "Error: Tool list_organizations is already registered"
},
"numComments": 0,
"assignedTo": null,
"isBookmarked": false,
"isSubscribed": false,
"subscriptionDetails": null,
"hasSeen": true,
"annotations": [],
"issueType": "error",
"issueCategory": "error",
"priority": "medium",
"priorityLockedAt": null,
"isUnhandled": true,
"count": "25",
"userCount": 1,
"firstSeen": "2025-04-03T22:51:19.403000Z",
"lastSeen": "2025-04-12T11:34:11Z",
"firstRelease": null,
"lastRelease": null,
"activity": [
{
"id": "4633815464",
"user": null,
"type": "auto_set_ongoing",
"data": {
"after_days": 7
},
"dateCreated": "2025-04-10T22:55:22.411699Z"
},
{
"id": "0",
"user": null,
"type": "first_seen",
"data": {
"priority": "medium"
},
"dateCreated": "2025-04-03T22:51:19.403000Z"
}
],
"openPeriods": [
{
"start": "2025-04-03T22:51:19.403000Z",
"end": null,
"duration": null,
"isOpen": true,
"lastChecked": "2025-04-12T11:34:11.310000Z"
}
],
"seenBy": [
{
"id": "1",
"name": "David Cramer",
"username": "[email protected]",
"email": "[email protected]",
"avatarUrl": null,
"isActive": true,
"hasPasswordAuth": true,
"isManaged": false,
"dateJoined": "2012-01-14T22:08:29.270831Z",
"lastLogin": "2025-04-13T14:00:11.516852Z",
"has2fa": true,
"lastActive": "2025-04-13T18:10:49.177605Z",
"isSuperuser": true,
"isStaff": true,
"experiments": {},
"emails": [
{
"id": "87429",
"email": "[email protected]",
"is_verified": true
}
],
"options": {
"theme": "light",
"language": "en",
"stacktraceOrder": 2,
"defaultIssueEvent": "recommended",
"timezone": "US/Pacific",
"clock24Hours": false
},
"flags": {
"newsletter_consent_prompt": false
},
"avatar": {
"avatarType": "upload",
"avatarUuid": "51e63edabf31412aa2a955e9cf2c1ca0",
"avatarUrl": "https://sentry.io/avatar/51e63edabf31412aa2a955e9cf2c1ca0/"
},
"identities": [],
"lastSeen": "2025-04-08T23:15:26.569455Z"
}
],
"pluginActions": [],
"pluginIssues": [],
"pluginContexts": [],
"userReportCount": 0,
"stats": {
"24h": [
[1744480800, 0],
[1744484400, 0],
[1744488000, 0],
[1744491600, 0],
[1744495200, 0],
[1744498800, 0],
[1744502400, 0],
[1744506000, 0],
[1744509600, 0],
[1744513200, 0],
[1744516800, 0],
[1744520400, 0],
[1744524000, 0],
[1744527600, 0],
[1744531200, 0],
[1744534800, 0],
[1744538400, 0],
[1744542000, 0],
[1744545600, 0],
[1744549200, 0],
[1744552800, 0],
[1744556400, 0],
[1744560000, 0],
[1744563600, 0],
[1744567200, 0]
],
"30d": [
[1741910400, 0],
[1741996800, 0],
[1742083200, 0],
[1742169600, 0],
[1742256000, 0],
[1742342400, 0],
[1742428800, 0],
[1742515200, 0],
[1742601600, 0],
[1742688000, 0],
[1742774400, 0],
[1742860800, 0],
[1742947200, 0],
[1743033600, 0],
[1743120000, 0],
[1743206400, 0],
[1743292800, 0],
[1743379200, 0],
[1743465600, 0],
[1743552000, 0],
[1743638400, 1],
[1743724800, 0],
[1743811200, 0],
[1743897600, 0],
[1743984000, 0],
[1744070400, 20],
[1744156800, 1],
[1744243200, 1],
[1744329600, 0],
[1744416000, 2],
[1744502400, 0]
]
},
"participants": []
}
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/search-issues/formatters.ts:
--------------------------------------------------------------------------------
```typescript
import type { Issue } from "../../api-client";
import { getIssueUrl, getIssuesSearchUrl } from "../../utils/url-utils";
import * as Sentry from "@sentry/node";
/**
* Format an explanation for how a natural language query was translated
*/
export function formatExplanation(explanation: string): string {
return `## How I interpreted your query\n\n${explanation}`;
}
export interface FormatIssueResultsParams {
issues: Issue[];
organizationSlug: string;
projectSlugOrId?: string;
query?: string | null;
regionUrl?: string;
naturalLanguageQuery?: string;
skipHeader?: boolean;
}
/**
* Format issue search results for display
*/
export function formatIssueResults(params: FormatIssueResultsParams): string {
const {
issues,
organizationSlug,
projectSlugOrId,
query,
regionUrl,
naturalLanguageQuery,
skipHeader = false,
} = params;
const host = regionUrl ? new URL(regionUrl).host : "sentry.io";
let output = "";
// Skip header section if requested (when called from handler with includeExplanation)
if (!skipHeader) {
// Use natural language query in title if provided, otherwise fall back to org/project
if (naturalLanguageQuery) {
output = `# Search Results for "${naturalLanguageQuery}"\n\n`;
} else {
output = `# Issues in **${organizationSlug}`;
if (projectSlugOrId) {
output += `/${projectSlugOrId}`;
}
output += "**\n\n";
}
// Add display instructions for UI
output += `⚠️ **IMPORTANT**: Display these issues as highlighted cards with status indicators, assignee info, and clickable Issue IDs.\n\n`;
}
if (issues.length === 0) {
Sentry.logger.info(
Sentry.logger
.fmt`No issues found for query: ${naturalLanguageQuery || query}`,
{
query,
organizationSlug,
projectSlug: projectSlugOrId,
naturalLanguageQuery,
},
);
output += "No issues found matching your search criteria.\n\n";
output += "Try adjusting your search criteria or time range.";
return output;
}
// Generate search URL for viewing results
const searchUrl = getIssuesSearchUrl(
host,
organizationSlug,
query,
projectSlugOrId,
);
// Add view link with emoji and guidance text (like search_events)
output += `**View these results in Sentry**:\n${searchUrl}\n`;
output += `_Please share this link with the user to view the search results in their Sentry dashboard._\n\n`;
output += `Found **${issues.length}** issue${issues.length === 1 ? "" : "s"}:\n\n`;
// Format each issue
issues.forEach((issue, index) => {
// Generate issue URL using the utility function
const issueUrl = getIssueUrl(host, organizationSlug, issue.shortId);
output += `## ${index + 1}. [${issue.shortId}](${issueUrl})\n\n`;
output += `**${issue.title}**\n\n`;
// Issue metadata
// Issues don't have a level field in the API response
output += `- **Status**: ${issue.status}\n`;
output += `- **Users**: ${issue.userCount || 0}\n`;
output += `- **Events**: ${issue.count || 0}\n`;
if (issue.assignedTo) {
const assignee = issue.assignedTo;
if (typeof assignee === "string") {
output += `- **Assigned to**: ${assignee}\n`;
} else if (
assignee &&
typeof assignee === "object" &&
"name" in assignee
) {
output += `- **Assigned to**: ${assignee.name}\n`;
}
}
output += `- **First seen**: ${formatDate(issue.firstSeen)}\n`;
output += `- **Last seen**: ${formatDate(issue.lastSeen)}\n`;
if (issue.culprit) {
output += `- **Culprit**: \`${issue.culprit}\`\n`;
}
output += "\n";
});
// Add next steps section (like search_events)
output += "## Next Steps\n\n";
output +=
"- Get more details about a specific issue: Use the Issue ID with get_issue_details\n";
output +=
"- Update issue status: Use update_issue to resolve or assign issues\n";
output +=
"- View event counts: Use search_events for aggregated statistics\n";
return output;
}
/**
* Format date for display
*/
function formatDate(dateString?: string | null): string {
if (!dateString) return "N/A";
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
if (diffHours < 1) {
const diffMinutes = Math.floor(diffMs / (1000 * 60));
return `${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} ago`;
}
if (diffHours < 24) {
return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
}
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) {
return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
}
return date.toLocaleDateString();
}
```
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
```yaml
name: Deploy to Cloudflare
permissions:
contents: read
deployments: write
checks: write
on:
workflow_run:
workflows: ["Test"]
types:
- completed
branches: [main]
workflow_dispatch:
jobs:
deploy:
name: Deploy to Cloudflare
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
# pnpm/action-setup@v4
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
# === BUILD AND DEPLOY CANARY WORKER ===
- name: Build
working-directory: packages/mcp-cloudflare
run: pnpm build
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Deploy to Canary Worker
id: deploy_canary
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: packages/mcp-cloudflare
command: deploy --config wrangler.canary.jsonc
packageManager: pnpm
- name: Wait for Canary to Propagate
if: success()
run: |
echo "Waiting 30 seconds for canary deployment to propagate..."
sleep 30
# === SMOKE TEST CANARY ===
- name: Run Smoke Tests on Canary
id: canary_smoke_tests
if: success()
env:
PREVIEW_URL: https://sentry-mcp-canary.getsentry.workers.dev
run: |
echo "Running smoke tests against canary worker..."
cd packages/smoke-tests
pnpm test:ci
- name: Publish Canary Smoke Test Report
uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857
if: always() && steps.canary_smoke_tests.outcome != 'skipped'
with:
report_paths: "packages/smoke-tests/tests.junit.xml"
check_name: "Canary Smoke Test Results"
fail_on_failure: false
# === DEPLOY PRODUCTION WORKER (only if canary tests pass) ===
- name: Deploy to Production Worker
id: deploy_production
if: steps.canary_smoke_tests.outcome == 'success'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: packages/mcp-cloudflare
command: deploy
packageManager: pnpm
- name: Wait for Production to Propagate
if: steps.deploy_production.outcome == 'success'
run: |
echo "Waiting 30 seconds for production deployment to propagate..."
sleep 30
# === SMOKE TEST PRODUCTION ===
- name: Run Smoke Tests on Production
id: production_smoke_tests
if: steps.deploy_production.outcome == 'success'
env:
PREVIEW_URL: https://mcp.sentry.dev
run: |
echo "Running smoke tests on production..."
cd packages/smoke-tests
pnpm test:ci
- name: Publish Production Smoke Test Report
uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857
if: always() && steps.production_smoke_tests.outcome != 'skipped'
with:
report_paths: "packages/smoke-tests/tests.junit.xml"
check_name: "Production Smoke Test Results"
fail_on_failure: false
# === ROLLBACK IF PRODUCTION SMOKE TESTS FAIL ===
- name: Rollback Production on Smoke Test Failure
if: steps.production_smoke_tests.outcome == 'failure'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: packages/mcp-cloudflare
command: rollback
packageManager: pnpm
continue-on-error: true
- name: Fail Job if Production Smoke Tests Failed
if: steps.production_smoke_tests.outcome == 'failure'
run: |
echo "Production smoke tests failed - job failed after rollback"
exit 1
```
--------------------------------------------------------------------------------
/docs/cloudflare/architecture.md:
--------------------------------------------------------------------------------
```markdown
# Cloudflare Chat Agent Architecture
Technical architecture for the web-based chat interface hosted on Cloudflare Workers.
## Overview
The Cloudflare chat agent provides a web interface for interacting with Sentry through an AI assistant. It's built as a full-stack application using:
- **Frontend**: React with Tailwind CSS
- **Backend**: Cloudflare Workers with Hono framework
- **AI**: OpenAI GPT-4 via Vercel AI SDK
- **MCP Integration**: HTTP transport to core MCP server
## Package Structure
```
packages/mcp-cloudflare/
├── src/
│ ├── client/ # React frontend
│ │ ├── components/ # UI components
│ │ ├── contexts/ # React contexts
│ │ ├── hooks/ # Custom React hooks
│ │ └── utils/ # Client utilities
│ └── server/ # Cloudflare Workers backend
│ ├── lib/ # Server libraries
│ ├── routes/ # API routes
│ ├── types/ # TypeScript types
│ └── utils/ # Server utilities
├── public/ # Static assets
└── wrangler.toml # Cloudflare configuration
```
## Key Components
### 1. OAuth Authentication
Handles Sentry OAuth flow for user authentication:
```typescript
// server/routes/auth.ts
export default new Hono()
.get("/login", handleOAuthLogin)
.get("/callback", handleOAuthCallback)
.post("/logout", handleLogout);
```
**Features:**
- OAuth 2.0 flow with Sentry
- Token storage in Cloudflare KV
- Automatic token refresh
- Per-organization access control
### 2. Chat Interface
React-based chat UI with real-time streaming:
```typescript
// client/components/chat/chat.tsx
export function Chat() {
const { messages, handleSubmit } = useChat({
api: "/api/chat",
headers: { Authorization: `Bearer ${authToken}` }
});
}
```
**Features:**
- Message streaming with Vercel AI SDK
- Tool call visualization
- Slash commands (/help, /prompts, /clear)
- Prompt parameter dialogs
- Markdown rendering with syntax highlighting
### 3. MCP Integration
Connects to the core MCP server via HTTP transport:
```typescript
// server/routes/chat.ts
const mcpClient = await experimental_createMCPClient({
name: "sentry",
transport: {
type: "sse",
url: sseUrl,
headers: { Authorization: `Bearer ${accessToken}` }
}
});
```
**Features:**
- Server-sent events (SSE) for MCP communication
- Automatic tool discovery
- Prompt metadata endpoint
- Error handling with fallbacks
### 4. AI Assistant
GPT-4 integration with Sentry-specific system prompt:
```typescript
const result = streamText({
model: openai("gpt-4o"),
messages: processedMessages,
tools: mcpTools,
system: "You are an AI assistant for testing Sentry MCP..."
});
```
**Features:**
- Streaming responses
- Tool execution
- Prompt template processing
- Context-aware assistance
## Data Flow
1. **User Authentication**:
```
User → OAuth Login → Sentry → OAuth Callback → KV Storage
```
2. **Chat Message Flow**:
```
User Input → Chat API → Process Prompts → AI Model → Stream Response
↓
MCP Server ← Tool Calls
```
3. **MCP Communication**:
```
Chat Server → SSE Transport → MCP Server → Sentry API
```
## Deployment Architecture
### Cloudflare Resources
- **Workers**: Serverless compute for API routes
- **Pages**: Static asset hosting for React app
- **KV Namespace**: Token storage
- **Durable Objects**: State management (future)
- **R2**: File storage (future)
### Environment Variables
Required for deployment:
```toml
[vars]
COOKIE_SECRET = "..." # For session encryption
OPENAI_API_KEY = "..." # For GPT-4 access
SENTRY_CLIENT_ID = "..." # OAuth app ID
SENTRY_CLIENT_SECRET = "..." # OAuth app secret
```
### API Routes
- `/api/auth/*` - Authentication endpoints
- `/api/chat` - Main chat endpoint
- `/api/metadata` - MCP metadata endpoint
- `/sse` - Server-sent events for MCP
## Security Considerations
1. **Authentication**: OAuth tokens stored encrypted in KV
2. **Authorization**: Per-organization access control
3. **Rate Limiting**: Cloudflare rate limiter integration
4. **CORS**: Restricted to same-origin requests
5. **CSP**: Content Security Policy headers
## Performance Optimizations
1. **Edge Computing**: Runs at Cloudflare edge locations
2. **Caching**: Metadata endpoint with cache headers
3. **Streaming**: Server-sent events for real-time updates
4. **Bundle Splitting**: Optimized React build
## Monitoring
- Sentry integration for error tracking
- Cloudflare Analytics for usage metrics
- Custom telemetry for MCP operations
## Related Documentation
- See "OAuth Architecture" in @docs/cloudflare/oauth-architecture.md
- See "Chat Interface" in @docs/cloudflare/architecture.md
- See "Deployment" in @docs/cloudflare/deployment.md
- See "Architecture" in @docs/architecture.mdc
```