This is page 6 of 15. Use http://codebase.md/getsentry/sentry-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .claude
│ ├── agents
│ │ └── claude-optimizer.md
│ ├── commands
│ │ ├── gh-pr.md
│ │ └── gh-review.md
│ └── settings.json
├── .craft.yml
├── .cursor
│ └── mcp.json
├── .env.example
├── .github
│ └── workflows
│ ├── deploy.yml
│ ├── eval.yml
│ ├── merge-jobs.yml
│ ├── release.yml
│ ├── smoke-tests.yml
│ ├── test.yml
│ └── token-cost.yml
├── .gitignore
├── .mcp.json
├── .vscode
│ ├── extensions.json
│ ├── mcp.json
│ └── settings.json
├── AGENTS.md
├── benchmark-agent.sh
├── bin
│ └── bump-version.sh
├── biome.json
├── CLAUDE.md
├── codecov.yml
├── core
├── docs
│ ├── adding-tools.mdc
│ ├── api-patterns.mdc
│ ├── architecture.mdc
│ ├── cloudflare
│ │ ├── architecture.md
│ │ ├── oauth-architecture.md
│ │ └── overview.md
│ ├── coding-guidelines.mdc
│ ├── common-patterns.mdc
│ ├── cursor.mdc
│ ├── error-handling.mdc
│ ├── github-actions.mdc
│ ├── llms
│ │ ├── document-scopes.mdc
│ │ ├── documentation-style-guide.mdc
│ │ └── README.md
│ ├── logging.mdc
│ ├── monitoring.mdc
│ ├── permissions-and-scopes.md
│ ├── pr-management.mdc
│ ├── quality-checks.mdc
│ ├── README.md
│ ├── releases
│ │ ├── cloudflare.mdc
│ │ └── stdio.mdc
│ ├── search-events-api-patterns.md
│ ├── security.mdc
│ ├── specs
│ │ ├── README.md
│ │ ├── search-events.md
│ │ └── subpath-constraints.md
│ ├── testing-remote.md
│ ├── testing-stdio.md
│ ├── testing.mdc
│ └── token-cost-tracking.mdc
├── LICENSE.md
├── Makefile
├── package.json
├── packages
│ ├── mcp-cloudflare
│ │ ├── .env.example
│ │ ├── components.json
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── favicon.ico
│ │ │ ├── flow-transparent.png
│ │ │ └── flow.jpg
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── app.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── chat
│ │ │ │ │ │ ├── auth-form.tsx
│ │ │ │ │ │ ├── chat-input.tsx
│ │ │ │ │ │ ├── chat-message.tsx
│ │ │ │ │ │ ├── chat-messages.tsx
│ │ │ │ │ │ ├── chat-ui.tsx
│ │ │ │ │ │ ├── chat.tsx
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── tool-invocation.tsx
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── fragments
│ │ │ │ │ │ ├── remote-setup.tsx
│ │ │ │ │ │ ├── setup-guide.tsx
│ │ │ │ │ │ └── stdio-setup.tsx
│ │ │ │ │ └── ui
│ │ │ │ │ ├── accordion.tsx
│ │ │ │ │ ├── backdrop.tsx
│ │ │ │ │ ├── badge.tsx
│ │ │ │ │ ├── base.tsx
│ │ │ │ │ ├── button.tsx
│ │ │ │ │ ├── code-snippet.tsx
│ │ │ │ │ ├── header.tsx
│ │ │ │ │ ├── icon.tsx
│ │ │ │ │ ├── icons
│ │ │ │ │ │ └── sentry.tsx
│ │ │ │ │ ├── interactive-markdown.tsx
│ │ │ │ │ ├── json-schema-params.tsx
│ │ │ │ │ ├── markdown.tsx
│ │ │ │ │ ├── note.tsx
│ │ │ │ │ ├── prose.tsx
│ │ │ │ │ ├── section.tsx
│ │ │ │ │ ├── slash-command-actions.tsx
│ │ │ │ │ ├── slash-command-text.tsx
│ │ │ │ │ ├── sliding-panel.tsx
│ │ │ │ │ ├── template-vars.tsx
│ │ │ │ │ ├── tool-actions.tsx
│ │ │ │ │ └── typewriter.tsx
│ │ │ │ ├── contexts
│ │ │ │ │ └── auth-context.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ ├── use-endpoint-mode.ts
│ │ │ │ │ ├── use-mcp-metadata.ts
│ │ │ │ │ ├── use-persisted-chat.ts
│ │ │ │ │ ├── use-scroll-lock.ts
│ │ │ │ │ └── use-streaming-simulation.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── instrument.ts
│ │ │ │ ├── lib
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── main.tsx
│ │ │ │ ├── pages
│ │ │ │ │ └── home.tsx
│ │ │ │ ├── utils
│ │ │ │ │ ├── chat-error-handler.ts
│ │ │ │ │ └── index.ts
│ │ │ │ └── vite-env.d.ts
│ │ │ ├── constants.ts
│ │ │ ├── server
│ │ │ │ ├── app.test.ts
│ │ │ │ ├── app.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── approval-dialog.test.ts
│ │ │ │ │ ├── approval-dialog.ts
│ │ │ │ │ ├── constraint-utils.test.ts
│ │ │ │ │ ├── constraint-utils.ts
│ │ │ │ │ ├── html-utils.ts
│ │ │ │ │ ├── mcp-handler.test.ts
│ │ │ │ │ ├── mcp-handler.ts
│ │ │ │ │ └── slug-validation.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── oauth
│ │ │ │ │ ├── authorize.test.ts
│ │ │ │ │ ├── callback.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── helpers.test.ts
│ │ │ │ │ ├── helpers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── authorize.ts
│ │ │ │ │ │ ├── callback.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ └── state.ts
│ │ │ │ ├── routes
│ │ │ │ │ ├── chat-oauth.ts
│ │ │ │ │ ├── chat.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── metadata.ts
│ │ │ │ │ ├── search.test.ts
│ │ │ │ │ └── search.ts
│ │ │ │ ├── sentry.config.ts
│ │ │ │ ├── types
│ │ │ │ │ └── chat.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils
│ │ │ │ └── auth-errors.ts
│ │ │ └── test-setup.ts
│ │ ├── tsconfig.client.json
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ ├── tsconfig.server.json
│ │ ├── vite.config.ts
│ │ ├── vitest.config.ts
│ │ ├── worker-configuration.d.ts
│ │ ├── wrangler.canary.jsonc
│ │ └── wrangler.jsonc
│ ├── mcp-server
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── scripts
│ │ │ ├── generate-definitions.ts
│ │ │ ├── generate-otel-namespaces.ts
│ │ │ └── measure-token-cost.ts
│ │ ├── src
│ │ │ ├── api-client
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── client.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── types.ts
│ │ │ ├── cli
│ │ │ │ ├── parse.test.ts
│ │ │ │ ├── parse.ts
│ │ │ │ ├── resolve.test.ts
│ │ │ │ ├── resolve.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── usage.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── index.ts
│ │ │ ├── internal
│ │ │ │ ├── agents
│ │ │ │ │ ├── callEmbeddedAgent.ts
│ │ │ │ │ ├── openai-provider.ts
│ │ │ │ │ └── tools
│ │ │ │ │ ├── data
│ │ │ │ │ │ ├── __namespaces.json
│ │ │ │ │ │ ├── android.json
│ │ │ │ │ │ ├── app.json
│ │ │ │ │ │ ├── artifact.json
│ │ │ │ │ │ ├── aspnetcore.json
│ │ │ │ │ │ ├── aws.json
│ │ │ │ │ │ ├── azure.json
│ │ │ │ │ │ ├── browser.json
│ │ │ │ │ │ ├── cassandra.json
│ │ │ │ │ │ ├── cicd.json
│ │ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ │ ├── client.json
│ │ │ │ │ │ ├── cloud.json
│ │ │ │ │ │ ├── cloudevents.json
│ │ │ │ │ │ ├── cloudfoundry.json
│ │ │ │ │ │ ├── code.json
│ │ │ │ │ │ ├── container.json
│ │ │ │ │ │ ├── cpu.json
│ │ │ │ │ │ ├── cpython.json
│ │ │ │ │ │ ├── database.json
│ │ │ │ │ │ ├── db.json
│ │ │ │ │ │ ├── deployment.json
│ │ │ │ │ │ ├── destination.json
│ │ │ │ │ │ ├── device.json
│ │ │ │ │ │ ├── disk.json
│ │ │ │ │ │ ├── dns.json
│ │ │ │ │ │ ├── dotnet.json
│ │ │ │ │ │ ├── elasticsearch.json
│ │ │ │ │ │ ├── enduser.json
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ ├── faas.json
│ │ │ │ │ │ ├── feature_flags.json
│ │ │ │ │ │ ├── file.json
│ │ │ │ │ │ ├── gcp.json
│ │ │ │ │ │ ├── gen_ai.json
│ │ │ │ │ │ ├── geo.json
│ │ │ │ │ │ ├── go.json
│ │ │ │ │ │ ├── graphql.json
│ │ │ │ │ │ ├── hardware.json
│ │ │ │ │ │ ├── heroku.json
│ │ │ │ │ │ ├── host.json
│ │ │ │ │ │ ├── http.json
│ │ │ │ │ │ ├── ios.json
│ │ │ │ │ │ ├── jvm.json
│ │ │ │ │ │ ├── k8s.json
│ │ │ │ │ │ ├── linux.json
│ │ │ │ │ │ ├── log.json
│ │ │ │ │ │ ├── mcp.json
│ │ │ │ │ │ ├── messaging.json
│ │ │ │ │ │ ├── network.json
│ │ │ │ │ │ ├── nodejs.json
│ │ │ │ │ │ ├── oci.json
│ │ │ │ │ │ ├── opentracing.json
│ │ │ │ │ │ ├── os.json
│ │ │ │ │ │ ├── otel.json
│ │ │ │ │ │ ├── peer.json
│ │ │ │ │ │ ├── process.json
│ │ │ │ │ │ ├── profile.json
│ │ │ │ │ │ ├── rpc.json
│ │ │ │ │ │ ├── server.json
│ │ │ │ │ │ ├── service.json
│ │ │ │ │ │ ├── session.json
│ │ │ │ │ │ ├── signalr.json
│ │ │ │ │ │ ├── source.json
│ │ │ │ │ │ ├── system.json
│ │ │ │ │ │ ├── telemetry.json
│ │ │ │ │ │ ├── test.json
│ │ │ │ │ │ ├── thread.json
│ │ │ │ │ │ ├── tls.json
│ │ │ │ │ │ ├── url.json
│ │ │ │ │ │ ├── user.json
│ │ │ │ │ │ ├── v8js.json
│ │ │ │ │ │ ├── vcs.json
│ │ │ │ │ │ ├── webengine.json
│ │ │ │ │ │ └── zos.json
│ │ │ │ │ ├── dataset-fields.test.ts
│ │ │ │ │ ├── dataset-fields.ts
│ │ │ │ │ ├── otel-semantics.test.ts
│ │ │ │ │ ├── otel-semantics.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ ├── whoami.test.ts
│ │ │ │ │ └── whoami.ts
│ │ │ │ ├── constraint-helpers.test.ts
│ │ │ │ ├── constraint-helpers.ts
│ │ │ │ ├── error-handling.ts
│ │ │ │ ├── fetch-utils.test.ts
│ │ │ │ ├── fetch-utils.ts
│ │ │ │ ├── formatting.test.ts
│ │ │ │ ├── formatting.ts
│ │ │ │ ├── issue-helpers.test.ts
│ │ │ │ ├── issue-helpers.ts
│ │ │ │ ├── test-fixtures.ts
│ │ │ │ └── tool-helpers
│ │ │ │ ├── api.test.ts
│ │ │ │ ├── api.ts
│ │ │ │ ├── define.ts
│ │ │ │ ├── enhance-error.ts
│ │ │ │ ├── formatting.ts
│ │ │ │ ├── issue.ts
│ │ │ │ ├── seer.test.ts
│ │ │ │ ├── seer.ts
│ │ │ │ ├── validate-region-url.test.ts
│ │ │ │ └── validate-region-url.ts
│ │ │ ├── permissions.parseScopes.test.ts
│ │ │ ├── permissions.ts
│ │ │ ├── schema.ts
│ │ │ ├── server-context.test.ts
│ │ │ ├── server.ts
│ │ │ ├── telem
│ │ │ │ ├── index.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── sentry.test.ts
│ │ │ │ └── sentry.ts
│ │ │ ├── test-setup.ts
│ │ │ ├── test-utils
│ │ │ │ └── context.ts
│ │ │ ├── toolDefinitions.ts
│ │ │ ├── tools
│ │ │ │ ├── agent-tools.ts
│ │ │ │ ├── analyze-issue-with-seer.test.ts
│ │ │ │ ├── analyze-issue-with-seer.ts
│ │ │ │ ├── create-dsn.test.ts
│ │ │ │ ├── create-dsn.ts
│ │ │ │ ├── create-project.test.ts
│ │ │ │ ├── create-project.ts
│ │ │ │ ├── create-team.test.ts
│ │ │ │ ├── create-team.ts
│ │ │ │ ├── find-dsns.test.ts
│ │ │ │ ├── find-dsns.ts
│ │ │ │ ├── find-organizations.test.ts
│ │ │ │ ├── find-organizations.ts
│ │ │ │ ├── find-projects.test.ts
│ │ │ │ ├── find-projects.ts
│ │ │ │ ├── find-releases.test.ts
│ │ │ │ ├── find-releases.ts
│ │ │ │ ├── find-teams.test.ts
│ │ │ │ ├── find-teams.ts
│ │ │ │ ├── get-doc.test.ts
│ │ │ │ ├── get-doc.ts
│ │ │ │ ├── get-event-attachment.test.ts
│ │ │ │ ├── get-event-attachment.ts
│ │ │ │ ├── get-issue-details.test.ts
│ │ │ │ ├── get-issue-details.ts
│ │ │ │ ├── get-trace-details.test.ts
│ │ │ │ ├── get-trace-details.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── search-docs.test.ts
│ │ │ │ ├── search-docs.ts
│ │ │ │ ├── search-events
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── utils.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── search-events.test.ts
│ │ │ │ ├── search-issues
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── README.md
│ │ │ │ ├── tools.test.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── update-issue.test.ts
│ │ │ │ ├── update-issue.ts
│ │ │ │ ├── update-project.test.ts
│ │ │ │ ├── update-project.ts
│ │ │ │ ├── use-sentry
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── handler.test.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── tool-wrapper.test.ts
│ │ │ │ │ └── tool-wrapper.ts
│ │ │ │ ├── whoami.test.ts
│ │ │ │ └── whoami.ts
│ │ │ ├── transports
│ │ │ │ └── stdio.ts
│ │ │ ├── types.ts
│ │ │ ├── utils
│ │ │ │ ├── slug-validation.test.ts
│ │ │ │ ├── slug-validation.ts
│ │ │ │ ├── url-utils.test.ts
│ │ │ │ └── url-utils.ts
│ │ │ └── version.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ ├── mcp-server-evals
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── bin
│ │ │ │ └── start-mock-stdio.ts
│ │ │ ├── evals
│ │ │ │ ├── autofix.eval.ts
│ │ │ │ ├── create-dsn.eval.ts
│ │ │ │ ├── create-project.eval.ts
│ │ │ │ ├── create-team.eval.ts
│ │ │ │ ├── get-issue.eval.ts
│ │ │ │ ├── get-trace-details.eval.ts
│ │ │ │ ├── list-dsns.eval.ts
│ │ │ │ ├── list-issues.eval.ts
│ │ │ │ ├── list-organizations.eval.ts
│ │ │ │ ├── list-projects.eval.ts
│ │ │ │ ├── list-releases.eval.ts
│ │ │ │ ├── list-tags.eval.ts
│ │ │ │ ├── list-teams.eval.ts
│ │ │ │ ├── search-docs.eval.ts
│ │ │ │ ├── search-events-agent.eval.ts
│ │ │ │ ├── search-events.eval.ts
│ │ │ │ ├── search-issues-agent.eval.ts
│ │ │ │ ├── search-issues.eval.ts
│ │ │ │ ├── update-issue.eval.ts
│ │ │ │ ├── update-project.eval.ts
│ │ │ │ └── utils
│ │ │ │ ├── fixtures.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── runner.ts
│ │ │ │ ├── structuredOutputScorer.ts
│ │ │ │ └── toolPredictionScorer.ts
│ │ │ └── setup-env.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── mcp-server-mocks
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── fixtures
│ │ │ │ ├── autofix-state.json
│ │ │ │ ├── event-attachments.json
│ │ │ │ ├── event.json
│ │ │ │ ├── issue.json
│ │ │ │ ├── performance-event.json
│ │ │ │ ├── project.json
│ │ │ │ ├── tags.json
│ │ │ │ ├── team.json
│ │ │ │ ├── trace-event.json
│ │ │ │ ├── trace-items-attributes-logs-number.json
│ │ │ │ ├── trace-items-attributes-logs-string.json
│ │ │ │ ├── trace-items-attributes-spans-number.json
│ │ │ │ ├── trace-items-attributes-spans-string.json
│ │ │ │ ├── trace-items-attributes.json
│ │ │ │ ├── trace-meta-with-nulls.json
│ │ │ │ ├── trace-meta.json
│ │ │ │ ├── trace-mixed.json
│ │ │ │ └── trace.json
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── mcp-server-tsconfig
│ │ ├── package.json
│ │ ├── tsconfig.base.json
│ │ └── tsconfig.vite.json
│ ├── mcp-test-client
│ │ ├── .env.test
│ │ ├── .gitignore
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── agent.ts
│ │ │ ├── auth
│ │ │ │ ├── config.ts
│ │ │ │ └── oauth.ts
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.test.ts
│ │ │ ├── logger.ts
│ │ │ ├── mcp-test-client-remote.ts
│ │ │ ├── mcp-test-client.ts
│ │ │ ├── types.ts
│ │ │ └── version.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ └── smoke-tests
│ ├── package.json
│ ├── src
│ │ └── smoke.test.ts
│ └── vitest.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── scripts
│ └── check-doc-links.mjs
├── turbo.json
└── vitest.workspace.ts
```
# Files
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/search-events/utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import type { SentryApiService } from "../../api-client";
3 | import { agentTool } from "../../internal/agents/tools/utils";
4 |
5 | // Type for flexible event data that can contain any fields
6 | export type FlexibleEventData = Record<string, unknown>;
7 |
8 | // Helper to safely get a string value from event data
9 | export function getStringValue(
10 | event: FlexibleEventData,
11 | key: string,
12 | defaultValue = "",
13 | ): string {
14 | const value = event[key];
15 | return typeof value === "string" ? value : defaultValue;
16 | }
17 |
18 | // Helper to safely get a number value from event data
19 | export function getNumberValue(
20 | event: FlexibleEventData,
21 | key: string,
22 | ): number | undefined {
23 | const value = event[key];
24 | return typeof value === "number" ? value : undefined;
25 | }
26 |
27 | // Helper to check if fields contain aggregate functions
28 | export function isAggregateQuery(fields: string[]): boolean {
29 | return fields.some((field) => field.includes("(") && field.includes(")"));
30 | }
31 |
32 | // Helper function to fetch custom attributes for a dataset
33 | export async function fetchCustomAttributes(
34 | apiService: SentryApiService,
35 | organizationSlug: string,
36 | dataset: "errors" | "logs" | "spans",
37 | projectId?: string,
38 | timeParams?: { statsPeriod?: string; start?: string; end?: string },
39 | ): Promise<{
40 | attributes: Record<string, string>;
41 | fieldTypes: Record<string, "string" | "number">;
42 | }> {
43 | const customAttributes: Record<string, string> = {};
44 | const fieldTypes: Record<string, "string" | "number"> = {};
45 |
46 | if (dataset === "errors") {
47 | // TODO: For errors dataset, we currently need to use the old listTags API
48 | // This will be updated in the future to use the new trace-items attributes API
49 | const tagsResponse = await apiService.listTags({
50 | organizationSlug,
51 | dataset: "events",
52 | project: projectId,
53 | statsPeriod: "14d",
54 | useCache: true,
55 | useFlagsBackend: true,
56 | });
57 |
58 | for (const tag of tagsResponse) {
59 | if (tag.key && !tag.key.startsWith("sentry:")) {
60 | customAttributes[tag.key] = tag.name || tag.key;
61 | }
62 | }
63 | } else {
64 | // For logs and spans datasets, use the trace-items attributes endpoint
65 | const itemType = dataset === "logs" ? "logs" : "spans";
66 | const attributesResponse = await apiService.listTraceItemAttributes({
67 | organizationSlug,
68 | itemType,
69 | project: projectId,
70 | statsPeriod: "14d",
71 | });
72 |
73 | for (const attr of attributesResponse) {
74 | if (attr.key && !attr.key.startsWith("sentry:")) {
75 | customAttributes[attr.key] = attr.name || attr.key;
76 | // Track field type from the attribute response with validation
77 | if (attr.type && (attr.type === "string" || attr.type === "number")) {
78 | fieldTypes[attr.key] = attr.type;
79 | }
80 | }
81 | }
82 | }
83 |
84 | return { attributes: customAttributes, fieldTypes };
85 | }
86 |
87 | /**
88 | * Create a tool for the agent to query available attributes by dataset
89 | * The tool is pre-bound with the API service and organization configured for the appropriate region
90 | */
91 | export function createDatasetAttributesTool(options: {
92 | apiService: SentryApiService;
93 | organizationSlug: string;
94 | projectId?: string;
95 | }) {
96 | const { apiService, organizationSlug, projectId } = options;
97 | return agentTool({
98 | description:
99 | "Query available attributes and fields for a specific Sentry dataset to understand what data is available",
100 | parameters: z.object({
101 | dataset: z
102 | .enum(["spans", "errors", "logs"])
103 | .describe("The dataset to query attributes for"),
104 | }),
105 | execute: async ({ dataset }) => {
106 | const {
107 | BASE_COMMON_FIELDS,
108 | DATASET_FIELDS,
109 | RECOMMENDED_FIELDS,
110 | NUMERIC_FIELDS,
111 | } = await import("./config");
112 |
113 | // Get custom attributes for this dataset
114 | // IMPORTANT: Let ALL errors bubble up to wrapAgentToolExecute
115 | // UserInputError will be converted to error string for the AI agent
116 | // Other errors will bubble up to be captured by Sentry
117 | const { attributes: customAttributes, fieldTypes } =
118 | await fetchCustomAttributes(
119 | apiService,
120 | organizationSlug,
121 | dataset,
122 | projectId,
123 | );
124 |
125 | // Combine all available fields
126 | const allFields = {
127 | ...BASE_COMMON_FIELDS,
128 | ...DATASET_FIELDS[dataset],
129 | ...customAttributes,
130 | };
131 |
132 | const recommendedFields = RECOMMENDED_FIELDS[dataset];
133 |
134 | // Combine field types from both static config and dynamic API
135 | const allFieldTypes = { ...fieldTypes };
136 | const staticNumericFields = NUMERIC_FIELDS[dataset] || new Set();
137 | for (const field of staticNumericFields) {
138 | allFieldTypes[field] = "number";
139 | }
140 |
141 | return `Dataset: ${dataset}
142 |
143 | Available Fields (${Object.keys(allFields).length} total):
144 | ${Object.entries(allFields)
145 | .slice(0, 50) // Limit to first 50 to avoid overwhelming the agent
146 | .map(([key, desc]) => `- ${key}: ${desc}`)
147 | .join("\n")}
148 | ${Object.keys(allFields).length > 50 ? `\n... and ${Object.keys(allFields).length - 50} more fields` : ""}
149 |
150 | Recommended Fields for ${dataset}:
151 | ${recommendedFields.basic.map((f) => `- ${f}`).join("\n")}
152 |
153 | Field Types (CRITICAL for aggregate functions):
154 | ${Object.entries(allFieldTypes)
155 | .slice(0, 30) // Show more field types since this is critical for validation
156 | .map(([key, type]) => `- ${key}: ${type}`)
157 | .join("\n")}
158 | ${Object.keys(allFieldTypes).length > 30 ? `\n... and ${Object.keys(allFieldTypes).length - 30} more fields` : ""}
159 |
160 | IMPORTANT: Only use numeric aggregate functions (avg, sum, min, max, percentiles) with numeric fields. Use count() or count_unique() for non-numeric fields.
161 |
162 | Use this information to construct appropriate queries for the ${dataset} dataset.`;
163 | },
164 | });
165 | }
166 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/update-issue.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { setTag } from "@sentry/core";
2 | import { defineTool } from "../internal/tool-helpers/define";
3 | import { apiServiceFromContext } from "../internal/tool-helpers/api";
4 | import { parseIssueParams } from "../internal/tool-helpers/issue";
5 | import { formatAssignedTo } from "../internal/tool-helpers/formatting";
6 | import { UserInputError } from "../errors";
7 | import type { ServerContext } from "../types";
8 | import {
9 | ParamOrganizationSlug,
10 | ParamRegionUrl,
11 | ParamIssueShortId,
12 | ParamIssueUrl,
13 | ParamIssueStatus,
14 | ParamAssignedTo,
15 | } from "../schema";
16 |
17 | export default defineTool({
18 | name: "update_issue",
19 | requiredScopes: ["event:write"],
20 | description: [
21 | "Update an issue's status or assignment in Sentry. This allows you to resolve, ignore, or reassign issues.",
22 | "",
23 | "Use this tool when you need to:",
24 | "- Resolve an issue that has been fixed",
25 | "- Assign an issue to a team member or team for investigation",
26 | "- Mark an issue as ignored to reduce noise",
27 | "- Reopen a resolved issue by setting status to 'unresolved'",
28 | "",
29 | "<examples>",
30 | "### Resolve an issue",
31 | "",
32 | "```",
33 | "update_issue(organizationSlug='my-organization', issueId='PROJECT-123', status='resolved')",
34 | "```",
35 | "",
36 | "### Assign an issue to a user (use whoami to get your user ID)",
37 | "",
38 | "```",
39 | "update_issue(organizationSlug='my-organization', issueId='PROJECT-123', assignedTo='user:123456')",
40 | "```",
41 | "",
42 | "### Assign an issue to a team",
43 | "",
44 | "```",
45 | "update_issue(organizationSlug='my-organization', issueId='PROJECT-123', assignedTo='team:789')",
46 | "```",
47 | "",
48 | "### Mark an issue as ignored",
49 | "",
50 | "```",
51 | "update_issue(organizationSlug='my-organization', issueId='PROJECT-123', status='ignored')",
52 | "```",
53 | "",
54 | "</examples>",
55 | "",
56 | "<hints>",
57 | "- If the user provides the `issueUrl`, you can ignore the other required parameters and extract them from the URL.",
58 | "- At least one of `status` or `assignedTo` must be provided to update the issue.",
59 | "- assignedTo format: Use 'user:ID' for users (e.g., 'user:123456') or 'team:ID' for teams (e.g., 'team:789')",
60 | "- To find your user ID, first use the whoami tool which returns your numeric user ID",
61 | "- Valid status values are: 'resolved', 'resolvedInNextRelease', 'unresolved', 'ignored'.",
62 | "</hints>",
63 | ].join("\n"),
64 | inputSchema: {
65 | organizationSlug: ParamOrganizationSlug.optional(),
66 | regionUrl: ParamRegionUrl.optional(),
67 | issueId: ParamIssueShortId.optional(),
68 | issueUrl: ParamIssueUrl.optional(),
69 | status: ParamIssueStatus.optional(),
70 | assignedTo: ParamAssignedTo.optional(),
71 | },
72 | annotations: {
73 | readOnlyHint: false,
74 | destructiveHint: true,
75 | idempotentHint: true,
76 | openWorldHint: true,
77 | },
78 | async handler(params, context: ServerContext) {
79 | const apiService = apiServiceFromContext(context, {
80 | regionUrl: params.regionUrl,
81 | });
82 |
83 | // Validate that we have the minimum required parameters
84 | if (!params.issueUrl && !params.issueId) {
85 | throw new UserInputError(
86 | "Either `issueId` or `issueUrl` must be provided",
87 | );
88 | }
89 |
90 | if (!params.issueUrl && !params.organizationSlug) {
91 | throw new UserInputError(
92 | "`organizationSlug` is required when providing `issueId`",
93 | );
94 | }
95 |
96 | // Validate that at least one update parameter is provided
97 | if (!params.status && !params.assignedTo) {
98 | throw new UserInputError(
99 | "At least one of `status` or `assignedTo` must be provided to update the issue",
100 | );
101 | }
102 |
103 | const { organizationSlug: orgSlug, issueId: parsedIssueId } =
104 | parseIssueParams({
105 | organizationSlug: params.organizationSlug,
106 | issueId: params.issueId,
107 | issueUrl: params.issueUrl,
108 | });
109 |
110 | setTag("organization.slug", orgSlug);
111 |
112 | // Get current issue details first
113 | const currentIssue = await apiService.getIssue({
114 | organizationSlug: orgSlug,
115 | issueId: parsedIssueId!,
116 | });
117 |
118 | // Update the issue
119 | const updatedIssue = await apiService.updateIssue({
120 | organizationSlug: orgSlug,
121 | issueId: parsedIssueId!,
122 | status: params.status,
123 | assignedTo: params.assignedTo,
124 | });
125 |
126 | let output = `# Issue ${updatedIssue.shortId} Updated in **${orgSlug}**\n\n`;
127 | output += `**Issue**: ${updatedIssue.title}\n`;
128 | output += `**URL**: ${apiService.getIssueUrl(orgSlug, updatedIssue.shortId)}\n\n`;
129 |
130 | // Show what changed
131 | output += "## Changes Made\n\n";
132 |
133 | if (params.status && currentIssue.status !== params.status) {
134 | output += `**Status**: ${currentIssue.status} → **${params.status}**\n`;
135 | }
136 |
137 | if (params.assignedTo) {
138 | const oldAssignee = formatAssignedTo(currentIssue.assignedTo ?? null);
139 | const newAssignee =
140 | params.assignedTo === "me" ? "You" : params.assignedTo;
141 | output += `**Assigned To**: ${oldAssignee} → **${newAssignee}**\n`;
142 | }
143 |
144 | output += "\n## Current Status\n\n";
145 | output += `**Status**: ${updatedIssue.status}\n`;
146 | const currentAssignee = formatAssignedTo(updatedIssue.assignedTo ?? null);
147 | output += `**Assigned To**: ${currentAssignee}\n`;
148 |
149 | output += "\n# Using this information\n\n";
150 | output += `- The issue has been successfully updated in Sentry\n`;
151 | output += `- You can view the issue details using: \`get_issue_details(organizationSlug="${orgSlug}", issueId="${updatedIssue.shortId}")\`\n`;
152 |
153 | if (params.status === "resolved") {
154 | output += `- The issue is now marked as resolved and will no longer generate alerts\n`;
155 | } else if (params.status === "ignored") {
156 | output += `- The issue is now ignored and will not generate alerts until it escalates\n`;
157 | }
158 |
159 | return output;
160 | },
161 | });
162 |
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/oauth/routes/authorize.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Hono } from "hono";
2 | import type { AuthRequest } from "@cloudflare/workers-oauth-provider";
3 | import {
4 | renderApprovalDialog,
5 | parseRedirectApproval,
6 | } from "../../lib/approval-dialog";
7 | import type { Env } from "../../types";
8 | import { SENTRY_AUTH_URL } from "../constants";
9 | import { getUpstreamAuthorizeUrl } from "../helpers";
10 | import { SCOPES } from "../../../constants";
11 | import { signState, type OAuthState } from "../state";
12 | import { logWarn } from "@sentry/mcp-server/telem/logging";
13 |
14 | /**
15 | * Extended AuthRequest that includes permissions
16 | */
17 | interface AuthRequestWithPermissions extends AuthRequest {
18 | permissions?: unknown;
19 | }
20 |
21 | async function redirectToUpstream(
22 | env: Env,
23 | request: Request,
24 | oauthReqInfo: AuthRequest | AuthRequestWithPermissions,
25 | headers: Record<string, string> = {},
26 | stateOverride?: string,
27 | ) {
28 | return new Response(null, {
29 | status: 302,
30 | headers: {
31 | ...headers,
32 | location: getUpstreamAuthorizeUrl({
33 | upstream_url: new URL(
34 | SENTRY_AUTH_URL,
35 | `https://${env.SENTRY_HOST || "sentry.io"}`,
36 | ).href,
37 | scope: Object.keys(SCOPES).join(" "),
38 | client_id: env.SENTRY_CLIENT_ID,
39 | redirect_uri: new URL("/oauth/callback", request.url).href,
40 | state: stateOverride ?? btoa(JSON.stringify(oauthReqInfo)),
41 | }),
42 | },
43 | });
44 | }
45 |
46 | // Export Hono app for /authorize endpoints
47 | export default new Hono<{ Bindings: Env }>()
48 | /**
49 | * OAuth Authorization Endpoint (GET /oauth/authorize)
50 | *
51 | * This route initiates the OAuth flow when a user wants to log in.
52 | */
53 | .get("/", async (c) => {
54 | let oauthReqInfo: AuthRequest;
55 | try {
56 | oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw);
57 | } catch (err) {
58 | // Log invalid redirect URI errors without sending them to Sentry
59 | const errorMessage = err instanceof Error ? err.message : String(err);
60 | if (errorMessage.includes("Invalid redirect URI")) {
61 | logWarn(`OAuth authorization failed: ${errorMessage}`, {
62 | loggerScope: ["cloudflare", "oauth", "authorize"],
63 | extra: { error: errorMessage },
64 | });
65 | return c.text("Invalid redirect URI", 400);
66 | }
67 | // Re-throw other errors to be captured by Sentry
68 | throw err;
69 | }
70 |
71 | const { clientId } = oauthReqInfo;
72 | if (!clientId) {
73 | return c.text("Invalid request", 400);
74 | }
75 |
76 | // XXX(dcramer): we want to confirm permissions on each time
77 | // so you can always choose new ones
78 | // This shouldn't be highly visible to users, as clients should use refresh tokens
79 | // behind the scenes.
80 | //
81 | // because we share a clientId with the upstream provider, we need to ensure that the
82 | // downstream client has been approved by the end-user (e.g. for a new client)
83 | // https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/265
84 | // const isApproved = await clientIdAlreadyApproved(
85 | // c.req.raw,
86 | // clientId,
87 | // c.env.COOKIE_SECRET,
88 | // );
89 | // if (isApproved) {
90 | // return redirectToUpstream(c.env, c.req.raw, oauthReqInfo);
91 | // }
92 |
93 | return renderApprovalDialog(c.req.raw, {
94 | client: await c.env.OAUTH_PROVIDER.lookupClient(clientId),
95 | server: {
96 | name: "Sentry MCP",
97 | },
98 | state: { oauthReqInfo }, // arbitrary data that flows through the form submission below
99 | });
100 | })
101 |
102 | /**
103 | * OAuth Authorization Endpoint (POST /oauth/authorize)
104 | *
105 | * This route handles the approval form submission and redirects to Sentry.
106 | */
107 | .post("/", async (c) => {
108 | // Validates form submission, extracts state, and generates Set-Cookie headers to skip approval dialog next time
109 | let result: Awaited<ReturnType<typeof parseRedirectApproval>>;
110 | try {
111 | result = await parseRedirectApproval(c.req.raw, c.env.COOKIE_SECRET);
112 | } catch (err) {
113 | logWarn("Failed to parse approval form", {
114 | loggerScope: ["cloudflare", "oauth", "authorize"],
115 | extra: { error: String(err) },
116 | });
117 | return c.text("Invalid request", 400);
118 | }
119 |
120 | const { state, headers, permissions } = result;
121 |
122 | if (!state.oauthReqInfo) {
123 | return c.text("Invalid request", 400);
124 | }
125 |
126 | // Store the selected permissions in the OAuth request info
127 | // This will be passed through to the callback via the state parameter
128 | const oauthReqWithPermissions = {
129 | ...state.oauthReqInfo,
130 | permissions,
131 | };
132 |
133 | // Validate redirectUri is registered for this client before proceeding
134 | try {
135 | const client = await c.env.OAUTH_PROVIDER.lookupClient(
136 | oauthReqWithPermissions.clientId,
137 | );
138 | const uriIsAllowed =
139 | Array.isArray(client?.redirectUris) &&
140 | client.redirectUris.includes(oauthReqWithPermissions.redirectUri);
141 | if (!uriIsAllowed) {
142 | logWarn("Redirect URI not registered for client", {
143 | loggerScope: ["cloudflare", "oauth", "authorize"],
144 | extra: {
145 | clientId: oauthReqWithPermissions.clientId,
146 | redirectUri: oauthReqWithPermissions.redirectUri,
147 | },
148 | });
149 | return c.text("Invalid redirect URI", 400);
150 | }
151 | } catch (lookupErr) {
152 | logWarn("Failed to validate client redirect URI", {
153 | loggerScope: ["cloudflare", "oauth", "authorize"],
154 | extra: { error: String(lookupErr) },
155 | });
156 | return c.text("Invalid request", 400);
157 | }
158 |
159 | // Build signed state for redirect to Sentry (10 minute validity)
160 | const now = Date.now();
161 | const payload: OAuthState = {
162 | req: oauthReqWithPermissions as unknown as Record<string, unknown>,
163 | iat: now,
164 | exp: now + 10 * 60 * 1000,
165 | };
166 | const signedState = await signState(payload, c.env.COOKIE_SECRET);
167 |
168 | return redirectToUpstream(
169 | c.env,
170 | c.req.raw,
171 | oauthReqWithPermissions,
172 | headers,
173 | signedState,
174 | );
175 | });
176 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/use-sentry/tool-wrapper.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import { z } from "zod";
3 | import { wrapToolForAgent } from "./tool-wrapper";
4 | import type { ServerContext } from "../../types";
5 | import type { ToolConfig } from "../types";
6 |
7 | // Create a simple mock tool for testing
8 | const mockTool: ToolConfig<{
9 | organizationSlug: z.ZodOptional<z.ZodString>;
10 | projectSlug: z.ZodOptional<z.ZodString>;
11 | someParam: z.ZodString;
12 | }> = {
13 | name: "mock_tool",
14 | description: "A mock tool for testing",
15 | inputSchema: {
16 | organizationSlug: z.string().optional(), // Optional to test constraint injection
17 | projectSlug: z.string().optional(),
18 | someParam: z.string(),
19 | },
20 | requiredScopes: [],
21 | annotations: {
22 | readOnlyHint: true,
23 | openWorldHint: true,
24 | },
25 | handler: async (params, context) => {
26 | // Return the params so we can verify what was passed
27 | return JSON.stringify({
28 | params,
29 | contextOrg: context.constraints.organizationSlug,
30 | contextProject: context.constraints.projectSlug,
31 | });
32 | },
33 | };
34 |
35 | describe("wrapToolForAgent", () => {
36 | it("wraps a tool and calls it successfully", async () => {
37 | const context: ServerContext = {
38 | accessToken: "test-token",
39 | sentryHost: "sentry.io",
40 | userId: "1",
41 | clientId: "test-client",
42 | constraints: {},
43 | grantedScopes: new Set([]),
44 | };
45 |
46 | const wrappedTool = wrapToolForAgent(mockTool, { context });
47 |
48 | // Call the wrapped tool
49 | // AI SDK tools expect a toolContext parameter (messages, abortSignal, etc.)
50 | const result = await wrappedTool.execute(
51 | {
52 | organizationSlug: "my-org",
53 | someParam: "test-value",
54 | },
55 | {} as any, // Empty tool context for testing
56 | );
57 |
58 | // Verify the tool was called with correct params
59 | expect(result.result).toBeDefined();
60 | const parsed = JSON.parse(result.result as string);
61 | expect(parsed.params.organizationSlug).toBe("my-org");
62 | expect(parsed.params.someParam).toBe("test-value");
63 | });
64 |
65 | it("injects organizationSlug constraint", async () => {
66 | const context: ServerContext = {
67 | accessToken: "test-token",
68 | sentryHost: "sentry.io",
69 | userId: "1",
70 | clientId: "test-client",
71 | constraints: {
72 | organizationSlug: "constrained-org",
73 | },
74 | grantedScopes: new Set([]),
75 | };
76 |
77 | const wrappedTool = wrapToolForAgent(mockTool, { context });
78 |
79 | // Call without providing organizationSlug
80 | const result = await wrappedTool.execute(
81 | {
82 | someParam: "test-value",
83 | },
84 | {} as any,
85 | );
86 |
87 | // Verify the constraint was injected
88 | expect(result.result).toBeDefined();
89 | const parsed = JSON.parse(result.result as string);
90 | expect(parsed.params.organizationSlug).toBe("constrained-org");
91 | expect(parsed.contextOrg).toBe("constrained-org");
92 | });
93 |
94 | it("injects projectSlug constraint", async () => {
95 | const context: ServerContext = {
96 | accessToken: "test-token",
97 | sentryHost: "sentry.io",
98 | userId: "1",
99 | clientId: "test-client",
100 | constraints: {
101 | organizationSlug: "constrained-org",
102 | projectSlug: "constrained-project",
103 | },
104 | grantedScopes: new Set([]),
105 | };
106 |
107 | const wrappedTool = wrapToolForAgent(mockTool, { context });
108 |
109 | // Call without providing projectSlug
110 | const result = await wrappedTool.execute(
111 | {
112 | someParam: "test-value",
113 | },
114 | {} as any,
115 | );
116 |
117 | // Verify both constraints were injected
118 | expect(result.result).toBeDefined();
119 | const parsed = JSON.parse(result.result as string);
120 | expect(parsed.params.organizationSlug).toBe("constrained-org");
121 | expect(parsed.params.projectSlug).toBe("constrained-project");
122 | expect(parsed.contextProject).toBe("constrained-project");
123 | });
124 |
125 | it("allows agent-provided params to override constraints", async () => {
126 | const context: ServerContext = {
127 | accessToken: "test-token",
128 | sentryHost: "sentry.io",
129 | userId: "1",
130 | clientId: "test-client",
131 | constraints: {
132 | organizationSlug: "constrained-org",
133 | },
134 | grantedScopes: new Set([]),
135 | };
136 |
137 | const wrappedTool = wrapToolForAgent(mockTool, { context });
138 |
139 | // Provide organizationSlug explicitly (should NOT override since constraint injection doesn't override)
140 | const result = await wrappedTool.execute(
141 | {
142 | organizationSlug: "agent-provided-org",
143 | someParam: "test-value",
144 | },
145 | {} as any,
146 | );
147 |
148 | expect(result.result).toBeDefined();
149 | const parsed = JSON.parse(result.result as string);
150 | // The constraint injection only adds if not present, so agent's value should remain
151 | expect(parsed.params.organizationSlug).toBe("agent-provided-org");
152 | });
153 |
154 | it("handles tool errors via agentTool wrapper", async () => {
155 | const errorTool: ToolConfig<{ param: z.ZodString }> = {
156 | name: "error_tool",
157 | description: "A tool that throws an error",
158 | inputSchema: {
159 | param: z.string(),
160 | },
161 | requiredScopes: [],
162 | annotations: {
163 | readOnlyHint: true,
164 | openWorldHint: true,
165 | },
166 | handler: async () => {
167 | throw new Error("Test error from tool");
168 | },
169 | };
170 |
171 | const context: ServerContext = {
172 | accessToken: "test-token",
173 | sentryHost: "sentry.io",
174 | userId: "1",
175 | clientId: "test-client",
176 | constraints: {},
177 | grantedScopes: new Set([]),
178 | };
179 |
180 | const wrappedTool = wrapToolForAgent(errorTool, { context });
181 |
182 | // Call the tool that throws an error
183 | const result = await wrappedTool.execute({ param: "test" }, {} as any);
184 |
185 | // Verify the error was caught and returned in structured format
186 | // Generic errors are wrapped as "System Error" by agentTool for security
187 | expect(result.error).toBeDefined();
188 | expect(result.error).toContain("System Error");
189 | expect(result.error).toContain("Event ID:");
190 | expect(result.result).toBeUndefined();
191 | });
192 | });
193 |
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/lib/html-utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Shared HTML utilities for consistent styling across server-rendered pages
3 | */
4 |
5 | /**
6 | * Sanitizes HTML content to prevent XSS attacks
7 | */
8 | export function sanitizeHtml(unsafe: string): string {
9 | return unsafe
10 | .replace(/&/g, "&")
11 | .replace(/</g, "<")
12 | .replace(/>/g, ">")
13 | .replace(/"/g, """)
14 | .replace(/'/g, "'");
15 | }
16 |
17 | /**
18 | * Common CSS styles used across all pages
19 | */
20 | const SHARED_STYLES = `
21 | /* Modern, responsive styling with system fonts */
22 | :root {
23 | --primary-color: oklch(0.205 0 0);
24 | --highlight-color: oklch(0.811 0.111 293.571);
25 | --border-color: oklch(0.278 0.033 256.848);
26 | --error-color: #f44336;
27 | --success-color: #4caf50;
28 | --border-color: oklch(0.269 0 0);
29 | --text-color: oklch(0.872 0.01 258.338);
30 | --background-color: oklab(0 0 0 / 0.3);
31 | }
32 |
33 | body {
34 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
35 | Helvetica, Arial, sans-serif, "Apple Color Emoji",
36 | "Segoe UI Emoji", "Segoe UI Symbol";
37 | line-height: 1.6;
38 | color: var(--text-color);
39 | background: linear-gradient(oklch(0.13 0.028 261.692) 0%, oklch(0.21 0.034 264.665) 50%, oklch(0.13 0.028 261.692) 100%);
40 | min-height: 100vh;
41 | margin: 0;
42 | padding: 0;
43 | }
44 |
45 | .container {
46 | max-width: 600px;
47 | margin: 2rem auto;
48 | padding: 1rem;
49 | }
50 |
51 | .precard {
52 | padding: 2rem;
53 | text-align: center;
54 | }
55 |
56 | .card {
57 | background-color: var(--background-color);
58 | padding: 2rem;
59 | text-align: center;
60 | }
61 |
62 | .header {
63 | display: flex;
64 | align-items: center;
65 | justify-content: center;
66 | margin-bottom: 1.5rem;
67 | }
68 |
69 | .logo {
70 | width: 36px;
71 | height: 36px;
72 | margin-right: 1rem;
73 | color: var(--highlight-color);
74 | }
75 |
76 | .title {
77 | margin: 0;
78 | font-size: 26px;
79 | font-weight: 400;
80 | color: white;
81 | }
82 |
83 | .status-message {
84 | margin: 1.5rem 0;
85 | font-size: 1.5rem;
86 | font-weight: 400;
87 | color: white;
88 | }
89 |
90 | .description {
91 | margin: 1rem 0;
92 | color: var(--text-color);
93 | font-size: 1rem;
94 | }
95 |
96 | .spinner {
97 | width: 24px;
98 | height: 24px;
99 | border: 2px solid var(--border-color);
100 | border-top: 2px solid var(--highlight-color);
101 | border-radius: 50%;
102 | animation: spin 1s linear infinite;
103 | margin: 1rem auto;
104 | }
105 |
106 | @keyframes spin {
107 | 0% { transform: rotate(0deg); }
108 | 100% { transform: rotate(360deg); }
109 | }
110 |
111 | /* Responsive adjustments */
112 | @media (max-width: 640px) {
113 | .container {
114 | margin: 1rem auto;
115 | padding: 0.5rem;
116 | }
117 |
118 | .card {
119 | padding: 1.5rem;
120 | }
121 |
122 | .precard {
123 | padding: 1rem;
124 | }
125 | }
126 | `;
127 |
128 | /**
129 | * Sentry logo SVG
130 | */
131 | const SENTRY_LOGO = `
132 | <svg class="logo" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-labelledby="icon-title">
133 | <title id="icon-title">Sentry Logo</title>
134 | <path d="M17.48 1.996c.45.26.823.633 1.082 1.083l13.043 22.622a2.962 2.962 0 0 1-2.562 4.44h-3.062c.043-.823.039-1.647 0-2.472h3.052a.488.488 0 0 0 .43-.734L16.418 4.315a.489.489 0 0 0-.845 0L12.582 9.51a23.16 23.16 0 0 1 7.703 8.362 23.19 23.19 0 0 1 2.8 11.024v1.234h-7.882v-1.236a15.284 15.284 0 0 0-6.571-12.543l-1.48 2.567a12.301 12.301 0 0 1 5.105 9.987v1.233h-9.3a2.954 2.954 0 0 1-2.56-1.48A2.963 2.963 0 0 1 .395 25.7l1.864-3.26a6.854 6.854 0 0 1 2.15 1.23l-1.883 3.266a.49.49 0 0 0 .43.734h6.758a9.985 9.985 0 0 0-4.83-7.272l-1.075-.618 3.927-6.835 1.075.615a17.728 17.728 0 0 1 6.164 5.956 17.752 17.752 0 0 1 2.653 8.154h2.959a20.714 20.714 0 0 0-3.05-9.627 20.686 20.686 0 0 0-7.236-7.036l-1.075-.618 4.215-7.309a2.958 2.958 0 0 1 4.038-1.083Z" fill="currentColor"></path>
135 | </svg>
136 | `;
137 |
138 | /**
139 | * Options for creating HTML pages
140 | */
141 | export interface HtmlPageOptions {
142 | title: string;
143 | serverName?: string;
144 | statusMessage: string;
145 | description?: string;
146 | type?: "success" | "error" | "info";
147 | showSpinner?: boolean;
148 | additionalStyles?: string;
149 | bodyScript?: string;
150 | }
151 |
152 | /**
153 | * Creates a consistent HTML page with Sentry branding and styling
154 | */
155 | export function createHtmlPage(options: HtmlPageOptions): string {
156 | const {
157 | title,
158 | serverName = "Sentry MCP",
159 | statusMessage,
160 | description,
161 | showSpinner = false,
162 | additionalStyles = "",
163 | bodyScript = "",
164 | } = options;
165 |
166 | const safeTitle = sanitizeHtml(title);
167 | const safeServerName = sanitizeHtml(serverName);
168 | const safeStatusMessage = sanitizeHtml(statusMessage);
169 | const safeDescription = description ? sanitizeHtml(description) : "";
170 |
171 | return `
172 | <!DOCTYPE html>
173 | <html lang="en">
174 | <head>
175 | <meta charset="UTF-8">
176 | <meta name="viewport" content="width=device-width, initial-scale=1.0">
177 | <title>${safeTitle}</title>
178 | <style>
179 | ${SHARED_STYLES}
180 | ${additionalStyles}
181 | </style>
182 | </head>
183 | <body>
184 | <div class="container">
185 | <div class="precard">
186 | <div class="header">
187 | ${SENTRY_LOGO}
188 | <h1 class="title"><strong>${safeServerName}</strong></h1>
189 | </div>
190 | </div>
191 |
192 | <div class="card">
193 | <h2 class="status-message">${safeStatusMessage}</h2>
194 |
195 | ${showSpinner ? '<div class="spinner"></div>' : ""}
196 |
197 | ${safeDescription ? `<p class="description">${safeDescription}</p>` : ""}
198 | </div>
199 | </div>
200 |
201 | ${bodyScript ? `<script>${bodyScript}</script>` : ""}
202 | </body>
203 | </html>
204 | `;
205 | }
206 |
207 | /**
208 | * Creates a success page for OAuth flows
209 | */
210 | export function createSuccessPage(
211 | options: Partial<HtmlPageOptions> = {},
212 | ): string {
213 | return createHtmlPage({
214 | title: "Authentication Successful",
215 | statusMessage: "Authentication Successful",
216 | description: "Authentication completed successfully.",
217 | type: "success",
218 | ...options,
219 | });
220 | }
221 |
222 | /**
223 | * Creates an error page for OAuth flows
224 | */
225 | export function createErrorPage(
226 | title: string,
227 | message: string,
228 | options: Partial<HtmlPageOptions> = {},
229 | ): string {
230 | return createHtmlPage({
231 | title: sanitizeHtml(title),
232 | statusMessage: sanitizeHtml(title),
233 | description: sanitizeHtml(message),
234 | type: "error",
235 | ...options,
236 | });
237 | }
238 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/tool-helpers/validate-region-url.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import { validateRegionUrl } from "./validate-region-url";
3 | import { UserInputError } from "../../errors";
4 |
5 | describe("validateRegionUrl", () => {
6 | describe("base host validation", () => {
7 | it("allows exact match for base host", () => {
8 | const result = validateRegionUrl("https://sentry.io", "sentry.io");
9 | expect(result).toBe("sentry.io");
10 | });
11 |
12 | it("allows exact match for self-hosted", () => {
13 | const result = validateRegionUrl(
14 | "https://sentry.company.com",
15 | "sentry.company.com",
16 | );
17 | expect(result).toBe("sentry.company.com");
18 | });
19 |
20 | it("allows exact match for any base host", () => {
21 | const result = validateRegionUrl("https://example.com", "example.com");
22 | expect(result).toBe("example.com");
23 | });
24 | });
25 |
26 | describe("allowlist validation", () => {
27 | it("allows us.sentry.io from allowlist", () => {
28 | const result = validateRegionUrl("https://us.sentry.io", "sentry.io");
29 | expect(result).toBe("us.sentry.io");
30 | });
31 |
32 | it("allows de.sentry.io from allowlist", () => {
33 | const result = validateRegionUrl("https://de.sentry.io", "sentry.io");
34 | expect(result).toBe("de.sentry.io");
35 | });
36 |
37 | it("allows sentry.io from allowlist even with different base", () => {
38 | const result = validateRegionUrl("https://sentry.io", "mycompany.com");
39 | expect(result).toBe("sentry.io");
40 | });
41 |
42 | it("allows us.sentry.io even with self-hosted base", () => {
43 | const result = validateRegionUrl("https://us.sentry.io", "mycompany.com");
44 | expect(result).toBe("us.sentry.io");
45 | });
46 |
47 | it("rejects domains not in allowlist", () => {
48 | expect(() =>
49 | validateRegionUrl("https://evil.sentry.io", "sentry.io"),
50 | ).toThrow(UserInputError);
51 | expect(() =>
52 | validateRegionUrl("https://evil.sentry.io", "sentry.io"),
53 | ).toThrow("The domain 'evil.sentry.io' is not allowed");
54 | });
55 |
56 | it("rejects completely different domains", () => {
57 | expect(() => validateRegionUrl("https://evil.com", "sentry.io")).toThrow(
58 | UserInputError,
59 | );
60 | expect(() => validateRegionUrl("https://evil.com", "sentry.io")).toThrow(
61 | "The domain 'evil.com' is not allowed",
62 | );
63 | });
64 |
65 | it("rejects subdomains of self-hosted that aren't base host", () => {
66 | expect(() =>
67 | validateRegionUrl("https://eu.mycompany.com", "mycompany.com"),
68 | ).toThrow(UserInputError);
69 | expect(() =>
70 | validateRegionUrl("https://eu.mycompany.com", "mycompany.com"),
71 | ).toThrow("The domain 'eu.mycompany.com' is not allowed");
72 | });
73 | });
74 |
75 | describe("protocol validation", () => {
76 | it("rejects URLs without protocol", () => {
77 | expect(() => validateRegionUrl("sentry.io", "sentry.io")).toThrow(
78 | UserInputError,
79 | );
80 | expect(() => validateRegionUrl("sentry.io", "sentry.io")).toThrow(
81 | "Must be a valid URL",
82 | );
83 | });
84 |
85 | it("rejects non-https protocols", () => {
86 | expect(() => validateRegionUrl("ftp://sentry.io", "sentry.io")).toThrow(
87 | UserInputError,
88 | );
89 | expect(() => validateRegionUrl("ftp://sentry.io", "sentry.io")).toThrow(
90 | "Must use HTTPS protocol for security",
91 | );
92 | expect(() => validateRegionUrl("http://sentry.io", "sentry.io")).toThrow(
93 | "Must use HTTPS protocol for security",
94 | );
95 | });
96 |
97 | it("rejects malformed URLs", () => {
98 | expect(() => validateRegionUrl("https://", "sentry.io")).toThrow(
99 | UserInputError,
100 | );
101 | expect(() => validateRegionUrl("https://", "sentry.io")).toThrow(
102 | "Must be a valid URL",
103 | );
104 | });
105 |
106 | it("rejects protocol-only hosts", () => {
107 | expect(() => validateRegionUrl("https://https", "sentry.io")).toThrow(
108 | UserInputError,
109 | );
110 | expect(() => validateRegionUrl("https://https", "sentry.io")).toThrow(
111 | "The host cannot be just a protocol name",
112 | );
113 | });
114 | });
115 |
116 | describe("case sensitivity", () => {
117 | it("handles case-insensitive matching for sentry.io", () => {
118 | const result = validateRegionUrl("https://US.SENTRY.IO", "sentry.io");
119 | expect(result).toBe("us.sentry.io");
120 | });
121 |
122 | it("handles case-insensitive self-hosted domains", () => {
123 | const result = validateRegionUrl(
124 | "https://SENTRY.COMPANY.COM",
125 | "sentry.company.com",
126 | );
127 | expect(result).toBe("sentry.company.com");
128 | });
129 |
130 | it("handles mixed case base host for sentry.io", () => {
131 | const result = validateRegionUrl("https://us.sentry.io", "SENTRY.IO");
132 | expect(result).toBe("us.sentry.io");
133 | });
134 | });
135 |
136 | describe("edge cases", () => {
137 | it("handles trailing slashes in URL", () => {
138 | const result = validateRegionUrl("https://us.sentry.io/", "sentry.io");
139 | expect(result).toBe("us.sentry.io");
140 | });
141 |
142 | it("handles URL with path", () => {
143 | const result = validateRegionUrl(
144 | "https://us.sentry.io/api/0/organizations/",
145 | "sentry.io",
146 | );
147 | expect(result).toBe("us.sentry.io");
148 | });
149 |
150 | it("handles URL with query params", () => {
151 | const result = validateRegionUrl(
152 | "https://us.sentry.io?test=1",
153 | "sentry.io",
154 | );
155 | expect(result).toBe("us.sentry.io");
156 | });
157 |
158 | it("handles URL with port for sentry.io", () => {
159 | const result = validateRegionUrl("https://us.sentry.io:443", "sentry.io");
160 | expect(result).toBe("us.sentry.io");
161 | });
162 |
163 | it("allows self-hosted with matching port", () => {
164 | const result = validateRegionUrl(
165 | "https://sentry.company.com:8080",
166 | "sentry.company.com:8080",
167 | );
168 | expect(result).toBe("sentry.company.com:8080");
169 | });
170 |
171 | it("rejects self-hosted with non-matching port", () => {
172 | expect(() =>
173 | validateRegionUrl(
174 | "https://sentry.company.com:8080",
175 | "sentry.company.com",
176 | ),
177 | ).toThrow(UserInputError);
178 | expect(() =>
179 | validateRegionUrl(
180 | "https://sentry.company.com:8080",
181 | "sentry.company.com",
182 | ),
183 | ).toThrow("The domain 'sentry.company.com:8080' is not allowed");
184 | });
185 | });
186 | });
187 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/messaging.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "namespace": "messaging",
3 | "description": "Attributes describing telemetry around messaging systems and messaging activities.",
4 | "attributes": {
5 | "messaging.batch.message_count": {
6 | "description": "The number of messages sent, received, or processed in the scope of the batching operation.",
7 | "type": "number",
8 | "note": "Instrumentations SHOULD NOT set `messaging.batch.message_count` on spans that operate with a single message. When a messaging client library supports both batch and single-message API for the same operation, instrumentations SHOULD use `messaging.batch.message_count` for batching APIs and SHOULD NOT use it for single-message APIs.\n",
9 | "stability": "development",
10 | "examples": ["0", "1", "2"]
11 | },
12 | "messaging.client.id": {
13 | "description": "A unique identifier for the client that consumes or produces a message.\n",
14 | "type": "string",
15 | "stability": "development",
16 | "examples": ["client-5", "myhost@8742@s8083jm"]
17 | },
18 | "messaging.consumer.group.name": {
19 | "description": "The name of the consumer group with which a consumer is associated.\n",
20 | "type": "string",
21 | "note": "Semantic conventions for individual messaging systems SHOULD document whether `messaging.consumer.group.name` is applicable and what it means in the context of that system.\n",
22 | "stability": "development",
23 | "examples": ["my-group", "indexer"]
24 | },
25 | "messaging.destination.name": {
26 | "description": "The message destination name",
27 | "type": "string",
28 | "note": "Destination name SHOULD uniquely identify a specific queue, topic or other entity within the broker. If\nthe broker doesn't have such notion, the destination name SHOULD uniquely identify the broker.\n",
29 | "stability": "development",
30 | "examples": ["MyQueue", "MyTopic"]
31 | },
32 | "messaging.destination.subscription.name": {
33 | "description": "The name of the destination subscription from which a message is consumed.",
34 | "type": "string",
35 | "note": "Semantic conventions for individual messaging systems SHOULD document whether `messaging.destination.subscription.name` is applicable and what it means in the context of that system.\n",
36 | "stability": "development",
37 | "examples": ["subscription-a"]
38 | },
39 | "messaging.destination.template": {
40 | "description": "Low cardinality representation of the messaging destination name",
41 | "type": "string",
42 | "note": "Destination names could be constructed from templates. An example would be a destination name involving a user name or product id. Although the destination name in this case is of high cardinality, the underlying template is of low cardinality and can be effectively used for grouping and aggregation.\n",
43 | "stability": "development",
44 | "examples": ["/customers/{customerId}"]
45 | },
46 | "messaging.destination.anonymous": {
47 | "description": "A boolean that is true if the message destination is anonymous (could be unnamed or have auto-generated name).",
48 | "type": "boolean",
49 | "stability": "development"
50 | },
51 | "messaging.destination.temporary": {
52 | "description": "A boolean that is true if the message destination is temporary and might not exist anymore after messages are processed.",
53 | "type": "boolean",
54 | "stability": "development"
55 | },
56 | "messaging.destination.partition.id": {
57 | "description": "The identifier of the partition messages are sent to or received from, unique within the `messaging.destination.name`.\n",
58 | "type": "string",
59 | "stability": "development",
60 | "examples": ["1"]
61 | },
62 | "messaging.message.conversation_id": {
63 | "description": "The conversation ID identifying the conversation to which the message belongs, represented as a string. Sometimes called \"Correlation ID\".\n",
64 | "type": "string",
65 | "stability": "development",
66 | "examples": ["MyConversationId"]
67 | },
68 | "messaging.message.envelope.size": {
69 | "description": "The size of the message body and metadata in bytes.\n",
70 | "type": "number",
71 | "note": "This can refer to both the compressed or uncompressed size. If both sizes are known, the uncompressed\nsize should be used.\n",
72 | "stability": "development",
73 | "examples": ["2738"]
74 | },
75 | "messaging.message.id": {
76 | "description": "A value used by the messaging system as an identifier for the message, represented as a string.",
77 | "type": "string",
78 | "stability": "development",
79 | "examples": ["452a7c7c7c7048c2f887f61572b18fc2"]
80 | },
81 | "messaging.message.body.size": {
82 | "description": "The size of the message body in bytes.\n",
83 | "type": "number",
84 | "note": "This can refer to both the compressed or uncompressed body size. If both sizes are known, the uncompressed\nbody size should be used.\n",
85 | "stability": "development",
86 | "examples": ["1439"]
87 | },
88 | "messaging.operation.type": {
89 | "description": "A string identifying the type of the messaging operation.\n",
90 | "type": "string",
91 | "note": "If a custom value is used, it MUST be of low cardinality.",
92 | "stability": "development",
93 | "examples": [
94 | "create",
95 | "send",
96 | "receive",
97 | "process",
98 | "settle",
99 | "deliver",
100 | "publish"
101 | ]
102 | },
103 | "messaging.operation.name": {
104 | "description": "The system-specific name of the messaging operation.\n",
105 | "type": "string",
106 | "stability": "development",
107 | "examples": ["ack", "nack", "send"]
108 | },
109 | "messaging.system": {
110 | "description": "The messaging system as identified by the client instrumentation.",
111 | "type": "string",
112 | "note": "The actual messaging system may differ from the one known by the client. For example, when using Kafka client libraries to communicate with Azure Event Hubs, the `messaging.system` is set to `kafka` based on the instrumentation's best knowledge.\n",
113 | "stability": "development",
114 | "examples": [
115 | "activemq",
116 | "aws_sqs",
117 | "eventgrid",
118 | "eventhubs",
119 | "servicebus",
120 | "gcp_pubsub",
121 | "jms",
122 | "kafka",
123 | "rabbitmq",
124 | "rocketmq",
125 | "pulsar"
126 | ]
127 | }
128 | }
129 | }
130 |
```
--------------------------------------------------------------------------------
/packages/mcp-server-evals/src/evals/search-events-agent.eval.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describeEval } from "vitest-evals";
2 | import { ToolCallScorer } from "vitest-evals";
3 | import { searchEventsAgent } from "@sentry/mcp-server/tools/search-events/agent";
4 | import { SentryApiService } from "@sentry/mcp-server/api-client";
5 | import { StructuredOutputScorer } from "./utils/structuredOutputScorer";
6 | import "../setup-env";
7 |
8 | // The shared MSW server is already started in setup-env.ts
9 |
10 | describeEval("search-events-agent", {
11 | data: async () => {
12 | return [
13 | {
14 | // Simple query with common fields - should NOT require tool calls
15 | input: "Show me all errors from today",
16 | expectedTools: [],
17 | expected: {
18 | dataset: "errors",
19 | query: "", // No filters, just time range
20 | sort: "-timestamp",
21 | timeRange: { statsPeriod: "24h" },
22 | },
23 | },
24 | {
25 | // Query with "me" reference - should only require whoami
26 | input: "Show me my errors from last week",
27 | expectedTools: [
28 | {
29 | name: "whoami",
30 | arguments: {},
31 | },
32 | ],
33 | expected: {
34 | dataset: "errors",
35 | query: /user\.email:test@example\.com|user\.id:123456/, // Can be either
36 | sort: "-timestamp",
37 | timeRange: { statsPeriod: "7d" },
38 | },
39 | },
40 | {
41 | // Common performance query - should NOT require tool calls
42 | input: "Show me slow API calls taking more than 1 second",
43 | expectedTools: [],
44 | expected: {
45 | dataset: "spans",
46 | query: /span\.duration:>1000|span\.duration:>1s/, // Can express as ms or seconds
47 | sort: "-span.duration",
48 | },
49 | },
50 | {
51 | // Query with OpenTelemetry attributes that need discovery
52 | input: "Show me LLM calls where temperature setting is above 0.7",
53 | expectedTools: [
54 | {
55 | name: "datasetAttributes",
56 | arguments: {
57 | dataset: "spans",
58 | },
59 | },
60 | {
61 | name: "otelSemantics",
62 | arguments: {
63 | namespace: "gen_ai",
64 | dataset: "spans",
65 | },
66 | },
67 | ],
68 | expected: {
69 | dataset: "spans",
70 | query: "gen_ai.request.temperature:>0.7",
71 | sort: "-span.duration",
72 | },
73 | },
74 | {
75 | // Query with custom field requiring discovery
76 | input: "Find errors with custom.payment.processor field",
77 | expectedTools: [
78 | {
79 | name: "datasetAttributes",
80 | arguments: {
81 | dataset: "errors",
82 | },
83 | },
84 | ],
85 | expected: {
86 | dataset: "errors",
87 | query: "has:custom.payment.processor",
88 | sort: "-timestamp",
89 | },
90 | },
91 | {
92 | // Query with custom field requiring discovery
93 | input: "Show me spans where custom.db.pool_size is greater than 10",
94 | expectedTools: [
95 | {
96 | name: "datasetAttributes",
97 | arguments: {
98 | dataset: "spans",
99 | },
100 | },
101 | ],
102 | expected: {
103 | dataset: "spans",
104 | query: "custom.db.pool_size:>10",
105 | sort: "-span.duration",
106 | },
107 | },
108 | {
109 | // Query requiring equation field calculation
110 | input: "How many total tokens did we consume yesterday",
111 | expectedTools: [
112 | {
113 | name: "datasetAttributes",
114 | arguments: {
115 | dataset: "spans",
116 | },
117 | },
118 | // Agent may find gen_ai fields and use them for calculation
119 | ],
120 | expected: {
121 | dataset: "spans",
122 | // For aggregations, query filter is optional - empty query gets all spans
123 | query: /^$|has:gen_ai\.usage\.(input_tokens|output_tokens)/,
124 | // Equation to sum both token types
125 | fields: [
126 | "equation|sum(gen_ai.usage.input_tokens) + sum(gen_ai.usage.output_tokens)",
127 | ],
128 | // Sort by the equation result in descending order
129 | sort: "-equation|sum(gen_ai.usage.input_tokens) + sum(gen_ai.usage.output_tokens)",
130 | timeRange: { statsPeriod: "24h" },
131 | },
132 | },
133 | {
134 | // Query that tests sort field self-correction
135 | // Agent should self-correct by adding count() to fields when sorting by it
136 | input: "Show me the top 10 most frequent error types",
137 | expectedTools: [],
138 | expected: {
139 | dataset: "errors",
140 | query: "", // No specific filter, just aggregate all errors
141 | // Agent should include count() in fields since we're sorting by it
142 | fields: ["error.type", "count()"],
143 | // Sort by count in descending order to get "most frequent"
144 | sort: "-count()",
145 | // timeRange can be null or have a default period
146 | },
147 | },
148 | {
149 | // Complex aggregate query that tests sort field self-correction
150 | // Agent should self-correct by including avg(span.duration) in fields
151 | input:
152 | "Show me database operations grouped by type, sorted by average duration",
153 | expectedTools: [
154 | {
155 | name: "datasetAttributes",
156 | arguments: {
157 | dataset: "spans",
158 | },
159 | },
160 | ],
161 | expected: {
162 | dataset: "spans",
163 | query: "has:db.operation",
164 | // Agent must include avg(span.duration) since we're sorting by it
165 | // Use db.operation as the grouping field (span.op is deprecated)
166 | fields: ["db.operation", "avg(span.duration)"],
167 | // Sort by average duration
168 | sort: "-avg(span.duration)",
169 | // timeRange is optional
170 | },
171 | },
172 | ];
173 | },
174 | task: async (input) => {
175 | // Create a real API service that will use MSW mocks
176 | const apiService = new SentryApiService({
177 | accessToken: "test-token",
178 | });
179 |
180 | const agentResult = await searchEventsAgent({
181 | query: input,
182 | organizationSlug: "sentry-mcp-evals",
183 | apiService,
184 | });
185 |
186 | return {
187 | result: JSON.stringify(agentResult.result),
188 | toolCalls: agentResult.toolCalls.map((call: any) => ({
189 | name: call.toolName,
190 | arguments: call.args,
191 | })),
192 | };
193 | },
194 | scorers: [
195 | ToolCallScorer(), // Validates tool calls
196 | StructuredOutputScorer({ match: "fuzzy" }), // Validates the structured query output with flexible matching
197 | ],
198 | });
199 |
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/contexts/auth-context.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | createContext,
3 | useContext,
4 | useState,
5 | useEffect,
6 | useCallback,
7 | useRef,
8 | type ReactNode,
9 | } from "react";
10 | import type { AuthContextType } from "../components/chat/types";
11 | import {
12 | isOAuthSuccessMessage,
13 | isOAuthErrorMessage,
14 | } from "../components/chat/types";
15 |
16 | const POPUP_CHECK_INTERVAL = 1000;
17 |
18 | const AuthContext = createContext<AuthContextType | undefined>(undefined);
19 |
20 | interface AuthProviderProps {
21 | children: ReactNode;
22 | }
23 |
24 | export function AuthProvider({ children }: AuthProviderProps) {
25 | const [isLoading, setIsLoading] = useState(true);
26 | const [isAuthenticated, setIsAuthenticated] = useState(false);
27 | const [isAuthenticating, setIsAuthenticating] = useState(false);
28 | const [authError, setAuthError] = useState("");
29 |
30 | // Keep refs for cleanup
31 | const popupRef = useRef<Window | null>(null);
32 | const intervalRef = useRef<number | null>(null);
33 |
34 | // Check if authenticated by making a request to the server
35 | useEffect(() => {
36 | // Check authentication status
37 | fetch("/api/auth/status", { credentials: "include" })
38 | .then((res) => res.ok)
39 | .then((authenticated) => {
40 | setIsAuthenticated(authenticated);
41 | setIsLoading(false);
42 | })
43 | .catch(() => {
44 | setIsAuthenticated(false);
45 | setIsLoading(false);
46 | });
47 | }, []);
48 |
49 | // Process OAuth result from localStorage
50 | const processOAuthResult = useCallback((data: unknown) => {
51 | if (isOAuthSuccessMessage(data)) {
52 | // Verify session on server before marking authenticated
53 | fetch("/api/auth/status", { credentials: "include" })
54 | .then((res) => res.ok)
55 | .then((authenticated) => {
56 | if (authenticated) {
57 | // Fully reload the app to pick up new auth context/cookies
58 | // This avoids intermediate/loading states and ensures a clean session
59 | window.location.reload();
60 | } else {
61 | setIsAuthenticated(false);
62 | setAuthError(
63 | "Authentication not completed. Please finish sign-in.",
64 | );
65 | setIsAuthenticating(false);
66 | }
67 | })
68 | .catch(() => {
69 | setIsAuthenticated(false);
70 | setAuthError("Failed to verify authentication.");
71 | setIsAuthenticating(false);
72 | });
73 |
74 | // Cleanup interval and popup reference
75 | if (intervalRef.current) {
76 | clearInterval(intervalRef.current);
77 | intervalRef.current = null;
78 | }
79 | if (popupRef.current) {
80 | popupRef.current = null;
81 | }
82 | } else if (isOAuthErrorMessage(data)) {
83 | setAuthError(data.error || "Authentication failed");
84 | setIsAuthenticating(false);
85 |
86 | // Cleanup interval and popup reference
87 | if (intervalRef.current) {
88 | clearInterval(intervalRef.current);
89 | intervalRef.current = null;
90 | }
91 | if (popupRef.current) {
92 | popupRef.current = null;
93 | }
94 | }
95 | }, []);
96 |
97 | // Cleanup on unmount
98 | useEffect(() => {
99 | return () => {
100 | if (intervalRef.current) {
101 | clearInterval(intervalRef.current);
102 | }
103 | };
104 | }, []);
105 |
106 | const handleOAuthLogin = useCallback(() => {
107 | setIsAuthenticating(true);
108 | setAuthError("");
109 |
110 | const desiredWidth = Math.max(Math.min(window.screen.availWidth, 900), 600);
111 | const desiredHeight = Math.min(window.screen.availHeight, 900);
112 | const windowFeatures = `width=${desiredWidth},height=${desiredHeight},resizable=yes,scrollbars=yes`;
113 |
114 | // Clear any stale results before opening popup
115 | try {
116 | localStorage.removeItem("oauth_result");
117 | } catch {
118 | // ignore storage errors
119 | }
120 |
121 | const popup = window.open(
122 | "/api/auth/authorize",
123 | "sentry-oauth",
124 | windowFeatures,
125 | );
126 |
127 | if (!popup) {
128 | setAuthError("Popup blocked. Please allow popups and try again.");
129 | setIsAuthenticating(false);
130 | return;
131 | }
132 |
133 | popupRef.current = popup;
134 |
135 | // Poll for OAuth result in localStorage
136 | // We don't check popup.closed as it's unreliable with cross-origin windows
137 | intervalRef.current = window.setInterval(() => {
138 | // Check localStorage for auth result
139 | const storedResult = localStorage.getItem("oauth_result");
140 | if (storedResult) {
141 | try {
142 | const result = JSON.parse(storedResult);
143 | localStorage.removeItem("oauth_result");
144 | processOAuthResult(result);
145 |
146 | // Clear interval since we got a result
147 | if (intervalRef.current) {
148 | clearInterval(intervalRef.current);
149 | intervalRef.current = null;
150 | }
151 | popupRef.current = null;
152 | } catch (e) {
153 | // Invalid stored result, continue polling
154 | }
155 | }
156 | }, POPUP_CHECK_INTERVAL);
157 |
158 | // Stop polling after 5 minutes (safety timeout)
159 | setTimeout(() => {
160 | if (intervalRef.current) {
161 | clearInterval(intervalRef.current);
162 | intervalRef.current = null;
163 |
164 | // Final check if we're authenticated
165 | fetch("/api/auth/status", { credentials: "include" })
166 | .then((res) => res.ok)
167 | .then((authenticated) => {
168 | if (authenticated) {
169 | window.location.reload();
170 | } else {
171 | setIsAuthenticating(false);
172 | setAuthError("Authentication timed out. Please try again.");
173 | }
174 | })
175 | .catch(() => {
176 | setIsAuthenticating(false);
177 | setAuthError("Authentication timed out. Please try again.");
178 | });
179 | }
180 | }, 300000); // 5 minutes
181 | }, [processOAuthResult]);
182 |
183 | const handleLogout = useCallback(async () => {
184 | try {
185 | await fetch("/api/auth/logout", {
186 | method: "POST",
187 | credentials: "include",
188 | });
189 | } catch {
190 | // Ignore errors, proceed with local logout
191 | }
192 |
193 | setIsAuthenticated(false);
194 | }, []);
195 |
196 | const clearAuthState = useCallback(() => {
197 | setIsAuthenticated(false);
198 | setAuthError("");
199 | }, []);
200 |
201 | const value: AuthContextType = {
202 | isLoading,
203 | isAuthenticated,
204 | authToken: "", // Keep for backward compatibility
205 | isAuthenticating,
206 | authError,
207 | handleOAuthLogin,
208 | handleLogout,
209 | clearAuthState,
210 | };
211 |
212 | return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
213 | }
214 |
215 | export function useAuth(): AuthContextType {
216 | const context = useContext(AuthContext);
217 | if (context === undefined) {
218 | throw new Error("useAuth must be used within an AuthProvider");
219 | }
220 | return context;
221 | }
222 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/get-event-attachment.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { defineTool } from "../internal/tool-helpers/define";
2 | import { apiServiceFromContext } from "../internal/tool-helpers/api";
3 | import type { ServerContext } from "../types";
4 | import type {
5 | TextContent,
6 | ImageContent,
7 | EmbeddedResource,
8 | } from "@modelcontextprotocol/sdk/types.js";
9 | import {
10 | ParamOrganizationSlug,
11 | ParamProjectSlug,
12 | ParamEventId,
13 | ParamAttachmentId,
14 | ParamRegionUrl,
15 | } from "../schema";
16 | import { setTag } from "@sentry/core";
17 |
18 | export default defineTool({
19 | name: "get_event_attachment",
20 | requiredScopes: ["event:read"],
21 | description: [
22 | "Download attachments from a Sentry event.",
23 | "",
24 | "Use this tool when you need to:",
25 | "- Download files attached to a specific event",
26 | "- Access screenshots, log files, or other attachments uploaded with an error report",
27 | "- Retrieve attachment metadata and download URLs",
28 | "",
29 | "<examples>",
30 | "### Download a specific attachment by ID",
31 | "",
32 | "```",
33 | "get_event_attachment(organizationSlug='my-organization', projectSlug='my-project', eventId='c49541c747cb4d8aa3efb70ca5aba243', attachmentId='12345')",
34 | "```",
35 | "",
36 | "### List all attachments for an event",
37 | "",
38 | "```",
39 | "get_event_attachment(organizationSlug='my-organization', projectSlug='my-project', eventId='c49541c747cb4d8aa3efb70ca5aba243')",
40 | "```",
41 | "",
42 | "</examples>",
43 | "",
44 | "<hints>",
45 | "- If `attachmentId` is provided, the specific attachment will be downloaded as an embedded resource",
46 | "- If `attachmentId` is omitted, all attachments for the event will be listed with download information",
47 | "- The `projectSlug` is required to identify which project the event belongs to",
48 | "</hints>",
49 | ].join("\n"),
50 | inputSchema: {
51 | organizationSlug: ParamOrganizationSlug,
52 | projectSlug: ParamProjectSlug,
53 | eventId: ParamEventId,
54 | attachmentId: ParamAttachmentId.optional(),
55 | regionUrl: ParamRegionUrl.optional(),
56 | },
57 | annotations: {
58 | readOnlyHint: true,
59 | openWorldHint: true,
60 | },
61 | async handler(params, context: ServerContext) {
62 | const apiService = apiServiceFromContext(context, {
63 | regionUrl: params.regionUrl,
64 | });
65 |
66 | setTag("organization.slug", params.organizationSlug);
67 |
68 | // If attachmentId is provided, download the specific attachment
69 | if (params.attachmentId) {
70 | const attachment = await apiService.getEventAttachment({
71 | organizationSlug: params.organizationSlug,
72 | projectSlug: params.projectSlug,
73 | eventId: params.eventId,
74 | attachmentId: params.attachmentId,
75 | });
76 |
77 | const contentParts: (TextContent | ImageContent | EmbeddedResource)[] =
78 | [];
79 | const isBinary = !attachment.attachment.mimetype?.startsWith("text/");
80 |
81 | if (isBinary) {
82 | const isImage = attachment.attachment.mimetype?.startsWith("image/");
83 | // Base64 encode the binary attachment content
84 | // and add to the content as an embedded resource
85 | const uint8Array = new Uint8Array(await attachment.blob.arrayBuffer());
86 | let binary = "";
87 | for (let i = 0; i < uint8Array.byteLength; i++) {
88 | binary += String.fromCharCode(uint8Array[i]);
89 | }
90 | if (isImage) {
91 | const image: ImageContent = {
92 | type: "image",
93 | mimeType: attachment.attachment.mimetype,
94 | data: btoa(binary),
95 | };
96 | contentParts.push(image);
97 | } else {
98 | const resource: EmbeddedResource = {
99 | id: params.attachmentId,
100 | type: "resource",
101 | resource: {
102 | uri: `file://${attachment.filename}`,
103 | mimeType: attachment.attachment.mimetype,
104 | blob: btoa(binary),
105 | },
106 | };
107 | contentParts.push(resource);
108 | }
109 | }
110 |
111 | let output = `# Event Attachment Download\n\n`;
112 | output += `**Event ID:** ${params.eventId}\n`;
113 | output += `**Attachment ID:** ${params.attachmentId}\n`;
114 | output += `**Filename:** ${attachment.filename}\n`;
115 | output += `**Type:** ${attachment.attachment.type}\n`;
116 | output += `**Size:** ${attachment.attachment.size} bytes\n`;
117 | output += `**MIME Type:** ${attachment.attachment.mimetype}\n`;
118 | output += `**Created:** ${attachment.attachment.dateCreated}\n`;
119 | output += `**SHA1:** ${attachment.attachment.sha1}\n\n`;
120 | output += `**Download URL:** ${attachment.downloadUrl}\n\n`;
121 |
122 | if (isBinary) {
123 | output += `## Binary Content\n\n`;
124 | output += `The attachment is included as a resource and accessible through your client.\n`;
125 | } else {
126 | // If it's a text file and we have blob content, decode and display it instead
127 | // of embedding it as an image or resource
128 | const textContent = await attachment.blob.text();
129 | output += `## File Content\n\n`;
130 | output += `\`\`\`\n${textContent}\n\`\`\`\n\n`;
131 | }
132 |
133 | const text: TextContent = {
134 | type: "text",
135 | text: output,
136 | };
137 | contentParts.push(text);
138 |
139 | return contentParts;
140 | }
141 |
142 | // List all attachments for the event
143 | const attachments = await apiService.listEventAttachments({
144 | organizationSlug: params.organizationSlug,
145 | projectSlug: params.projectSlug,
146 | eventId: params.eventId,
147 | });
148 |
149 | let output = `# Event Attachments\n\n`;
150 | output += `**Event ID:** ${params.eventId}\n`;
151 | output += `**Project:** ${params.projectSlug}\n\n`;
152 |
153 | if (attachments.length === 0) {
154 | output += "No attachments found for this event.\n";
155 | return output;
156 | }
157 |
158 | output += `Found ${attachments.length} attachment(s):\n\n`;
159 |
160 | attachments.forEach((attachment, index) => {
161 | output += `## Attachment ${index + 1}\n\n`;
162 | output += `**ID:** ${attachment.id}\n`;
163 | output += `**Name:** ${attachment.name}\n`;
164 | output += `**Type:** ${attachment.type}\n`;
165 | output += `**Size:** ${attachment.size} bytes\n`;
166 | output += `**MIME Type:** ${attachment.mimetype}\n`;
167 | output += `**Created:** ${attachment.dateCreated}\n`;
168 | output += `**SHA1:** ${attachment.sha1}\n\n`;
169 | output += `To download this attachment, use the "get_event_attachment" tool with the attachmentId provided:\n`;
170 | output += `\`get_event_attachment(organizationSlug="${params.organizationSlug}", projectSlug="${params.projectSlug}", eventId="${params.eventId}", attachmentId="${attachment.id}")\`\n\n`;
171 | });
172 |
173 | return output;
174 | },
175 | });
176 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/faas.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "namespace": "faas",
3 | "description": "FaaS attributes",
4 | "attributes": {
5 | "faas.name": {
6 | "description": "The name of the single function that this runtime instance executes.\n",
7 | "type": "string",
8 | "note": "This is the name of the function as configured/deployed on the FaaS\nplatform and is usually different from the name of the callback\nfunction (which may be stored in the\n[`code.namespace`/`code.function.name`](/docs/general/attributes.md#source-code-attributes)\nspan attributes).\n\nFor some cloud providers, the above definition is ambiguous. The following\ndefinition of function name MUST be used for this attribute\n(and consequently the span name) for the listed cloud providers/products:\n\n- **Azure:** The full name `<FUNCAPP>/<FUNC>`, i.e., function app name\n followed by a forward slash followed by the function name (this form\n can also be seen in the resource JSON for the function).\n This means that a span attribute MUST be used, as an Azure function\n app can host multiple functions that would usually share\n a TracerProvider (see also the `cloud.resource_id` attribute).\n",
9 | "stability": "development",
10 | "examples": ["my-function", "myazurefunctionapp/some-function-name"]
11 | },
12 | "faas.version": {
13 | "description": "The immutable version of the function being executed.",
14 | "type": "string",
15 | "note": "Depending on the cloud provider and platform, use:\n\n- **AWS Lambda:** The [function version](https://docs.aws.amazon.com/lambda/latest/dg/configuration-versions.html)\n (an integer represented as a decimal string).\n- **Google Cloud Run (Services):** The [revision](https://cloud.google.com/run/docs/managing/revisions)\n (i.e., the function name plus the revision suffix).\n- **Google Cloud Functions:** The value of the\n [`K_REVISION` environment variable](https://cloud.google.com/functions/docs/env-var#runtime_environment_variables_set_automatically).\n- **Azure Functions:** Not applicable. Do not set this attribute.\n",
16 | "stability": "development",
17 | "examples": ["26", "pinkfroid-00002"]
18 | },
19 | "faas.instance": {
20 | "description": "The execution environment ID as a string, that will be potentially reused for other invocations to the same function/function version.\n",
21 | "type": "string",
22 | "note": "- **AWS Lambda:** Use the (full) log stream name.\n",
23 | "stability": "development",
24 | "examples": ["2021/06/28/[$LATEST]2f399eb14537447da05ab2a2e39309de"]
25 | },
26 | "faas.max_memory": {
27 | "description": "The amount of memory available to the serverless function converted to Bytes.\n",
28 | "type": "number",
29 | "note": "It's recommended to set this attribute since e.g. too little memory can easily stop a Java AWS Lambda function from working correctly. On AWS Lambda, the environment variable `AWS_LAMBDA_FUNCTION_MEMORY_SIZE` provides this information (which must be multiplied by 1,048,576).\n",
30 | "stability": "development",
31 | "examples": ["134217728"]
32 | },
33 | "faas.trigger": {
34 | "description": "Type of the trigger which caused this function invocation.\n",
35 | "type": "string",
36 | "stability": "development",
37 | "examples": ["datasource", "http", "pubsub", "timer", "other"]
38 | },
39 | "faas.invoked_name": {
40 | "description": "The name of the invoked function.\n",
41 | "type": "string",
42 | "note": "SHOULD be equal to the `faas.name` resource attribute of the invoked function.\n",
43 | "stability": "development",
44 | "examples": ["my-function"]
45 | },
46 | "faas.invoked_provider": {
47 | "description": "The cloud provider of the invoked function.\n",
48 | "type": "string",
49 | "note": "SHOULD be equal to the `cloud.provider` resource attribute of the invoked function.\n",
50 | "stability": "development",
51 | "examples": ["alibaba_cloud", "aws", "azure", "gcp", "tencent_cloud"]
52 | },
53 | "faas.invoked_region": {
54 | "description": "The cloud region of the invoked function.\n",
55 | "type": "string",
56 | "note": "SHOULD be equal to the `cloud.region` resource attribute of the invoked function.\n",
57 | "stability": "development",
58 | "examples": ["eu-central-1"]
59 | },
60 | "faas.invocation_id": {
61 | "description": "The invocation ID of the current function invocation.\n",
62 | "type": "string",
63 | "stability": "development",
64 | "examples": ["af9d5aa4-a685-4c5f-a22b-444f80b3cc28"]
65 | },
66 | "faas.time": {
67 | "description": "A string containing the function invocation time in the [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format expressed in [UTC](https://www.w3.org/TR/NOTE-datetime).\n",
68 | "type": "string",
69 | "stability": "development",
70 | "examples": ["2020-01-23T13:47:06Z"]
71 | },
72 | "faas.cron": {
73 | "description": "A string containing the schedule period as [Cron Expression](https://docs.oracle.com/cd/E12058_01/doc/doc.1014/e12030/cron_expressions.htm).\n",
74 | "type": "string",
75 | "stability": "development",
76 | "examples": ["0/5 * * * ? *"]
77 | },
78 | "faas.coldstart": {
79 | "description": "A boolean that is true if the serverless function is executed for the first time (aka cold-start).\n",
80 | "type": "boolean",
81 | "stability": "development"
82 | },
83 | "faas.document.collection": {
84 | "description": "The name of the source on which the triggering operation was performed. For example, in Cloud Storage or S3 corresponds to the bucket name, and in Cosmos DB to the database name.\n",
85 | "type": "string",
86 | "stability": "development",
87 | "examples": ["myBucketName", "myDbName"]
88 | },
89 | "faas.document.operation": {
90 | "description": "Describes the type of the operation that was performed on the data.",
91 | "type": "string",
92 | "stability": "development",
93 | "examples": ["insert", "edit", "delete"]
94 | },
95 | "faas.document.time": {
96 | "description": "A string containing the time when the data was accessed in the [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format expressed in [UTC](https://www.w3.org/TR/NOTE-datetime).\n",
97 | "type": "string",
98 | "stability": "development",
99 | "examples": ["2020-01-23T13:47:06Z"]
100 | },
101 | "faas.document.name": {
102 | "description": "The document name/table subjected to the operation. For example, in Cloud Storage or S3 is the name of the file, and in Cosmos DB the table name.\n",
103 | "type": "string",
104 | "stability": "development",
105 | "examples": ["myFile.txt", "myTableName"]
106 | }
107 | }
108 | }
109 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/telem/sentry.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import { sentryBeforeSend } from "./sentry";
3 | import type * as Sentry from "@sentry/node";
4 |
5 | describe("sentry", () => {
6 | describe("OpenAI API key scrubbing", () => {
7 | it("should scrub OpenAI API keys from message", () => {
8 | const event: Sentry.Event = {
9 | message:
10 | "Error with key: sk-abc123def456ghi789jkl012mno345pqr678stu901vwx234",
11 | };
12 |
13 | const result = sentryBeforeSend(event, {}) as Sentry.Event;
14 | expect(result.message).toBe("Error with key: [REDACTED_OPENAI_KEY]");
15 | });
16 |
17 | it("should scrub multiple OpenAI keys", () => {
18 | const event: Sentry.Event = {
19 | message:
20 | "Keys: sk-abc123def456ghi789jkl012mno345pqr678stu901vwx234 and sk-xyz123def456ghi789jkl012mno345pqr678stu901vwx234",
21 | };
22 |
23 | const result = sentryBeforeSend(event, {}) as Sentry.Event;
24 | expect(result.message).toBe(
25 | "Keys: [REDACTED_OPENAI_KEY] and [REDACTED_OPENAI_KEY]",
26 | );
27 | });
28 |
29 | it("should not scrub partial matches", () => {
30 | const event: Sentry.Event = {
31 | message:
32 | "Not a key: sk-abc or task-abc123def456ghi789jkl012mno345pqr678stu901vwx234",
33 | };
34 |
35 | const result = sentryBeforeSend(event, {}) as Sentry.Event;
36 | expect(result.message).toBe(event.message);
37 | });
38 | });
39 |
40 | describe("Bearer token scrubbing", () => {
41 | it("should scrub Bearer tokens", () => {
42 | const event: Sentry.Event = {
43 | message:
44 | "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
45 | };
46 |
47 | const result = sentryBeforeSend(event, {}) as Sentry.Event;
48 | expect(result.message).toBe("Authorization: Bearer [REDACTED_TOKEN]");
49 | });
50 | });
51 |
52 | describe("Sentry token scrubbing", () => {
53 | it("should scrub Sentry access tokens", () => {
54 | const event: Sentry.Event = {
55 | message:
56 | "Using token: sntrys_eyJpYXQiOjE2OTQwMzMxNTMuNzk0NjI4LCJ1cmwiOiJodHRwczovL3NlbnRyeS5pbyIsInJlZ2lvbl91cmwiOiJodHRwczovL3VzLnNlbnRyeS5pbyIsIm9yZyI6InNlbnRyeSJ9_abcdef123456",
57 | };
58 |
59 | const result = sentryBeforeSend(event, {}) as Sentry.Event;
60 | expect(result.message).toBe("Using token: [REDACTED_SENTRY_TOKEN]");
61 | });
62 | });
63 |
64 | describe("Deep object scrubbing", () => {
65 | it("should scrub sensitive data from nested objects", () => {
66 | const event: Sentry.Event = {
67 | extra: {
68 | config: {
69 | apiKey: "sk-abc123def456ghi789jkl012mno345pqr678stu901vwx234",
70 | headers: {
71 | Authorization: "Bearer token123",
72 | },
73 | },
74 | },
75 | };
76 |
77 | const result = sentryBeforeSend(event, {}) as Sentry.Event;
78 | expect(result.extra).toEqual({
79 | config: {
80 | apiKey: "[REDACTED_OPENAI_KEY]",
81 | headers: {
82 | Authorization: "Bearer [REDACTED_TOKEN]",
83 | },
84 | },
85 | });
86 | });
87 |
88 | it("should scrub breadcrumbs", () => {
89 | const event: Sentry.Event = {
90 | message: "Test event",
91 | breadcrumbs: [
92 | {
93 | message:
94 | "API call with sk-abc123def456ghi789jkl012mno345pqr678stu901vwx234",
95 | data: {
96 | tokens: ["sntrys_token1", "sntrys_token2"],
97 | },
98 | },
99 | ],
100 | };
101 |
102 | const result = sentryBeforeSend(event, {}) as Sentry.Event;
103 | expect(result.breadcrumbs?.[0].message).toBe(
104 | "API call with [REDACTED_OPENAI_KEY]",
105 | );
106 | expect(result.breadcrumbs?.[0].data?.tokens).toEqual([
107 | "[REDACTED_SENTRY_TOKEN]",
108 | "[REDACTED_SENTRY_TOKEN]",
109 | ]);
110 | expect(result.message).toBe("Test event");
111 | });
112 | });
113 |
114 | describe("Exception scrubbing", () => {
115 | it("should scrub from exception values", () => {
116 | const event: Sentry.Event = {
117 | exception: {
118 | values: [
119 | {
120 | type: "Error",
121 | value:
122 | "Failed to authenticate with sk-abc123def456ghi789jkl012mno345pqr678stu901vwx234",
123 | },
124 | ],
125 | },
126 | };
127 |
128 | const result = sentryBeforeSend(event, {}) as Sentry.Event;
129 | expect(result.exception?.values?.[0].value).toBe(
130 | "Failed to authenticate with [REDACTED_OPENAI_KEY]",
131 | );
132 | });
133 | });
134 |
135 | describe("No sensitive data", () => {
136 | it("should return event unchanged when no sensitive data", () => {
137 | const event: Sentry.Event = {
138 | message: "Normal error message",
139 | extra: {
140 | foo: "bar",
141 | },
142 | };
143 |
144 | const result = sentryBeforeSend(event, {}) as Sentry.Event;
145 | expect(result).toEqual(event);
146 | });
147 | });
148 |
149 | describe("Regex state handling", () => {
150 | it("should handle multiple calls without regex state corruption", () => {
151 | // This tests the bug where global regex patterns maintain lastIndex between calls
152 | const event1: Sentry.Event = {
153 | message:
154 | "First error with sk-abc123def456ghi789jkl012mno345pqr678stu901vwx234",
155 | };
156 |
157 | const event2: Sentry.Event = {
158 | message:
159 | "Second error with sk-xyz123def456ghi789jkl012mno345pqr678stu901vwx234",
160 | };
161 |
162 | // Call sentryBeforeSend multiple times
163 | const result1 = sentryBeforeSend(event1, {});
164 | const result2 = sentryBeforeSend(event2, {});
165 |
166 | // Both should be properly scrubbed
167 | expect(result1?.message).toBe("First error with [REDACTED_OPENAI_KEY]");
168 | expect(result2?.message).toBe("Second error with [REDACTED_OPENAI_KEY]");
169 |
170 | // Test multiple replacements in the same string
171 | const event3: Sentry.Event = {
172 | message:
173 | "Multiple keys: sk-abc123def456ghi789jkl012mno345pqr678stu901vwx234 and sk-xyz123def456ghi789jkl012mno345pqr678stu901vwx234",
174 | };
175 |
176 | const result3 = sentryBeforeSend(event3, {});
177 | expect(result3?.message).toBe(
178 | "Multiple keys: [REDACTED_OPENAI_KEY] and [REDACTED_OPENAI_KEY]",
179 | );
180 | });
181 | });
182 |
183 | describe("Max depth handling", () => {
184 | it("should handle deeply nested objects without stack overflow", () => {
185 | // Create a deeply nested object
186 | let deep: any = {
187 | value: "sk-abc123def456ghi789jkl012mno345pqr678stu901vwx234",
188 | };
189 | for (let i = 0; i < 25; i++) {
190 | deep = { nested: deep };
191 | }
192 |
193 | const event: Sentry.Event = {
194 | message: "Deep nesting test",
195 | extra: deep,
196 | };
197 |
198 | const result = sentryBeforeSend(event, {}) as Sentry.Event;
199 | // Should not throw, and should handle max depth gracefully
200 | expect(result).toBeDefined();
201 | expect(result.message).toBe("Deep nesting test");
202 | });
203 | });
204 | });
205 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/search-issues/handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import { setTag } from "@sentry/core";
3 | import { defineTool } from "../../internal/tool-helpers/define";
4 | import { apiServiceFromContext } from "../../internal/tool-helpers/api";
5 | import type { ServerContext } from "../../types";
6 | import { ParamOrganizationSlug, ParamRegionUrl } from "../../schema";
7 | import { validateSlugOrId, isNumericId } from "../../utils/slug-validation";
8 | import { searchIssuesAgent } from "./agent";
9 | import { formatIssueResults, formatExplanation } from "./formatters";
10 |
11 | export default defineTool({
12 | name: "search_issues",
13 | requiredScopes: ["event:read"],
14 | description: [
15 | "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.",
16 | "",
17 | "Uses AI to translate natural language queries into Sentry issue search syntax.",
18 | "Returns grouped issues with metadata like title, status, and user count.",
19 | "",
20 | "🔍 USE THIS TOOL WHEN USERS WANT:",
21 | "- A LIST of issues: 'show me issues', 'what problems do we have'",
22 | "- Filtered issue lists: 'unresolved issues', 'critical bugs'",
23 | "- Issues by impact: 'errors affecting more than 100 users'",
24 | "- Issues by assignment: 'issues assigned to me'",
25 | "",
26 | "❌ DO NOT USE FOR COUNTS/AGGREGATIONS:",
27 | "- 'how many errors' → use search_events",
28 | "- 'count of issues' → use search_events",
29 | "- 'total number of errors today' → use search_events",
30 | "- 'sum/average/statistics' → use search_events",
31 | "",
32 | "❌ ALSO DO NOT USE FOR:",
33 | "- Individual error events with timestamps → use search_events",
34 | "- Details about a specific issue ID → use get_issue_details",
35 | "",
36 | "REMEMBER: This tool returns a LIST of issues, not counts or statistics!",
37 | "",
38 | "<examples>",
39 | "search_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')",
40 | "search_issues(organizationSlug='my-org', naturalLanguageQuery='unhandled errors affecting 100+ users')",
41 | "search_issues(organizationSlug='my-org', naturalLanguageQuery='issues assigned to me')",
42 | "</examples>",
43 | "",
44 | "<hints>",
45 | "- If the user passes a parameter in the form of name/otherName, it's likely in the format of <organizationSlug>/<projectSlugOrId>.",
46 | "- Parse org/project notation directly without calling find_organizations or find_projects.",
47 | "- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').",
48 | "</hints>",
49 | ].join("\n"),
50 | inputSchema: {
51 | organizationSlug: ParamOrganizationSlug,
52 | naturalLanguageQuery: z
53 | .string()
54 | .trim()
55 | .min(1)
56 | .describe("Natural language description of issues to search for"),
57 | projectSlugOrId: z
58 | .string()
59 | .toLowerCase()
60 | .trim()
61 | .superRefine(validateSlugOrId)
62 | .optional()
63 | .describe("The project's slug or numeric ID (optional)"),
64 | regionUrl: ParamRegionUrl.optional(),
65 | limit: z
66 | .number()
67 | .min(1)
68 | .max(100)
69 | .optional()
70 | .default(10)
71 | .describe("Maximum number of issues to return"),
72 | includeExplanation: z
73 | .boolean()
74 | .optional()
75 | .default(false)
76 | .describe("Include explanation of how the query was translated"),
77 | },
78 | annotations: {
79 | readOnlyHint: true,
80 | openWorldHint: true,
81 | },
82 | async handler(params, context: ServerContext) {
83 | const apiService = apiServiceFromContext(context, {
84 | regionUrl: params.regionUrl,
85 | });
86 |
87 | setTag("organization.slug", params.organizationSlug);
88 | if (params.projectSlugOrId) {
89 | // Check if it's a numeric ID or a slug and tag appropriately
90 | if (isNumericId(params.projectSlugOrId)) {
91 | setTag("project.id", params.projectSlugOrId);
92 | } else {
93 | setTag("project.slug", params.projectSlugOrId);
94 | }
95 | }
96 |
97 | // Convert project slug to ID if needed - required for the agent's field discovery
98 | let projectId: string | undefined;
99 | if (params.projectSlugOrId) {
100 | // Check if it's already a numeric ID
101 | if (isNumericId(params.projectSlugOrId)) {
102 | projectId = params.projectSlugOrId;
103 | } else {
104 | // It's a slug, convert to ID
105 | const project = await apiService.getProject({
106 | organizationSlug: params.organizationSlug,
107 | projectSlugOrId: params.projectSlugOrId!,
108 | });
109 | projectId = String(project.id);
110 | }
111 | }
112 |
113 | // Translate natural language to Sentry query
114 | const agentResult = await searchIssuesAgent({
115 | query: params.naturalLanguageQuery,
116 | organizationSlug: params.organizationSlug,
117 | apiService,
118 | projectId,
119 | });
120 |
121 | const translatedQuery = agentResult.result;
122 |
123 | // Execute the search - listIssues accepts projectSlug directly
124 | const issues = await apiService.listIssues({
125 | organizationSlug: params.organizationSlug,
126 | projectSlug: params.projectSlugOrId,
127 | query: translatedQuery.query,
128 | sortBy: translatedQuery.sort || "date",
129 | limit: params.limit,
130 | });
131 |
132 | // Build output with explanation first (if requested), then results
133 | let output = "";
134 |
135 | // Add explanation section before results (like search_events)
136 | if (params.includeExplanation) {
137 | // Start with title including natural language query
138 | output += `# Search Results for "${params.naturalLanguageQuery}"\n\n`;
139 | output += `⚠️ **IMPORTANT**: Display these issues as highlighted cards with status indicators, assignee info, and clickable Issue IDs.\n\n`;
140 |
141 | output += `## Query Translation\n`;
142 | output += `Natural language: "${params.naturalLanguageQuery}"\n`;
143 | output += `Sentry query: \`${translatedQuery.query}\``;
144 | if (translatedQuery.sort) {
145 | output += `\nSort: ${translatedQuery.sort}`;
146 | }
147 | output += `\n\n`;
148 |
149 | if (translatedQuery.explanation) {
150 | output += formatExplanation(translatedQuery.explanation);
151 | output += `\n\n`;
152 | }
153 |
154 | // Format results without the header since we already added it
155 | output += formatIssueResults({
156 | issues,
157 | organizationSlug: params.organizationSlug,
158 | projectSlugOrId: params.projectSlugOrId,
159 | query: translatedQuery.query,
160 | regionUrl: params.regionUrl,
161 | naturalLanguageQuery: params.naturalLanguageQuery,
162 | skipHeader: true,
163 | });
164 | } else {
165 | // Format results with natural language query for title
166 | output = formatIssueResults({
167 | issues,
168 | organizationSlug: params.organizationSlug,
169 | projectSlugOrId: params.projectSlugOrId,
170 | query: translatedQuery.query,
171 | regionUrl: params.regionUrl,
172 | naturalLanguageQuery: params.naturalLanguageQuery,
173 | skipHeader: false,
174 | });
175 | }
176 |
177 | return output;
178 | },
179 | });
180 |
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/routes/search.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Hono } from "hono";
2 | import type { Env } from "../types";
3 | import { logIssue } from "@sentry/mcp-server/telem/logging";
4 | import { SENTRY_GUIDES } from "@sentry/mcp-server/constants";
5 | import { z } from "zod";
6 | import type { RateLimitResult } from "../types/chat";
7 | import type {
8 | AutoRagSearchResponse,
9 | ComparisonFilter,
10 | CompoundFilter,
11 | AutoRagSearchRequest,
12 | } from "@cloudflare/workers-types";
13 | import { logger } from "@sentry/cloudflare";
14 |
15 | // Request schema matching the MCP tool parameters
16 | const SearchRequestSchema = z.object({
17 | query: z.string().trim().min(1, "Query is required"),
18 | maxResults: z.number().int().min(1).max(10).default(10).optional(),
19 | guide: z.enum(SENTRY_GUIDES).optional(),
20 | });
21 |
22 | export default new Hono<{ Bindings: Env }>().post("/", async (c) => {
23 | try {
24 | // Get client IP address from Cloudflare header
25 | const clientIP = c.req.header("CF-Connecting-IP") || "unknown";
26 |
27 | // Rate limiting check - use client IP as the key
28 | // Note: Rate limiting bindings are "unsafe" (beta) and may not be available in development
29 | // so we check if the binding exists before using it
30 | // https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/
31 | if (c.env.SEARCH_RATE_LIMITER) {
32 | try {
33 | // Hash the IP for privacy and consistent key format
34 | const encoder = new TextEncoder();
35 | const data = encoder.encode(clientIP);
36 | const hashBuffer = await crypto.subtle.digest("SHA-256", data);
37 | const hashArray = Array.from(new Uint8Array(hashBuffer));
38 | const hashHex = hashArray
39 | .map((b) => b.toString(16).padStart(2, "0"))
40 | .join("");
41 | const rateLimitKey = `search:ip:${hashHex.substring(0, 16)}`; // Use first 16 chars of hash
42 |
43 | const { success }: RateLimitResult =
44 | await c.env.SEARCH_RATE_LIMITER.limit({
45 | key: rateLimitKey,
46 | });
47 | if (!success) {
48 | return c.json(
49 | {
50 | error:
51 | "Rate limit exceeded. You can perform up to 20 documentation searches per minute. Please wait before searching again.",
52 | name: "RATE_LIMIT_EXCEEDED",
53 | },
54 | 429,
55 | );
56 | }
57 | } catch (error) {
58 | const eventId = logIssue(error);
59 | return c.json(
60 | {
61 | error: "There was an error communicating with the rate limiter.",
62 | name: "RATE_LIMITER_ERROR",
63 | eventId,
64 | },
65 | 500,
66 | );
67 | }
68 | }
69 |
70 | // Parse and validate request body
71 | const body = await c.req.json();
72 | const validationResult = SearchRequestSchema.safeParse(body);
73 |
74 | if (!validationResult.success) {
75 | return c.json(
76 | {
77 | error: "Invalid request",
78 | details: validationResult.error.errors,
79 | },
80 | 400,
81 | );
82 | }
83 |
84 | const { query, maxResults = 10, guide } = validationResult.data;
85 |
86 | // Check if AI binding is available
87 | if (!c.env.AI) {
88 | return c.json(
89 | {
90 | error: "AI service not available",
91 | name: "AI_SERVICE_UNAVAILABLE",
92 | },
93 | 503,
94 | );
95 | }
96 |
97 | try {
98 | const autoragId = c.env.AUTORAG_INDEX_NAME || "sentry-docs";
99 |
100 | // Construct AutoRAG search parameters
101 | const searchParams: AutoRagSearchRequest = {
102 | query,
103 | max_num_results: maxResults,
104 | ranking_options: {
105 | score_threshold: 0.2,
106 | },
107 | };
108 |
109 | // Add filename filters based on guide parameter
110 | // TODO: This is a hack to get the guide to work. Currently 'filename' is not working
111 | // with folder matching which means we're lacking guideName.md in the search results.
112 | if (guide) {
113 | let filter: ComparisonFilter | CompoundFilter;
114 |
115 | if (guide.includes("/")) {
116 | // Platform/guide combination: platforms/[platform]/guides/[guide]
117 | const [platformName, guideName] = guide.split("/", 2);
118 |
119 | filter = {
120 | type: "and",
121 | filters: [
122 | {
123 | type: "gte",
124 | key: "folder",
125 | value: `platforms/${platformName}/guides/${guideName}/`,
126 | },
127 | {
128 | type: "lte",
129 | key: "folder",
130 | value: `platforms/${platformName}/guides/${guideName}/z`,
131 | },
132 | ],
133 | };
134 | } else {
135 | // Just platform: platforms/[platform]/ - use range filter
136 | filter = {
137 | type: "and",
138 | filters: [
139 | {
140 | type: "gte",
141 | key: "folder",
142 | value: `platforms/${guide}/`,
143 | },
144 | {
145 | type: "lte",
146 | key: "folder",
147 | value: `platforms/${guide}/z`,
148 | },
149 | ],
150 | };
151 | }
152 |
153 | searchParams.filters = filter;
154 | }
155 |
156 | const searchResult =
157 | await c.env.AI.autorag(autoragId).search(searchParams);
158 |
159 | // Process search results - handle the actual response format from Cloudflare AI
160 | const searchData = searchResult as AutoRagSearchResponse;
161 |
162 | if (searchData.data?.length === 0) {
163 | logger.warn(
164 | logger.fmt`No results found for query: ${query} with guide: ${guide}`,
165 | {
166 | result_query: searchData.search_query,
167 | guide,
168 | searchParams: JSON.stringify(searchParams),
169 | },
170 | );
171 | }
172 |
173 | return c.json({
174 | query,
175 | results:
176 | searchData.data?.map((result) => {
177 | // Extract text from content array
178 | const text = result.content?.[0]?.text || "";
179 |
180 | // Get filename from result - ensure it's a string
181 | const rawFilename =
182 | result.filename || result.attributes?.filename || "";
183 | const filename =
184 | typeof rawFilename === "string"
185 | ? rawFilename
186 | : String(rawFilename);
187 |
188 | // Build URL from filename - remove .md extension
189 | const urlPath = filename.replace(/\.md$/, "");
190 | const url = urlPath ? `https://docs.sentry.io/${urlPath}` : "";
191 |
192 | return {
193 | id: filename,
194 | url: url,
195 | snippet: text,
196 | relevance: result.score || 0,
197 | };
198 | }) || [],
199 | });
200 | } catch (error) {
201 | const eventId = logIssue(error);
202 | return c.json(
203 | {
204 | error: "Failed to search documentation. Please try again later.",
205 | name: "SEARCH_FAILED",
206 | eventId,
207 | },
208 | 500,
209 | );
210 | }
211 | } catch (error) {
212 | const eventId = logIssue(error);
213 | return c.json(
214 | {
215 | error: "Internal server error",
216 | name: "INTERNAL_ERROR",
217 | eventId,
218 | },
219 | 500,
220 | );
221 | }
222 | });
223 |
```
--------------------------------------------------------------------------------
/packages/mcp-server-mocks/src/fixtures/trace-event.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "id": "db633982397f45fca67621093b1430ef",
3 | "groupID": null,
4 | "eventID": "db633982397f45fca67621093b1430ef",
5 | "projectID": "4509062593708032",
6 | "size": 4091,
7 | "entries": [
8 | { "data": [], "type": "spans" },
9 | {
10 | "data": {
11 | "values": [
12 | {
13 | "type": "default",
14 | "timestamp": "2025-07-30T18:37:34.265000Z",
15 | "level": "error",
16 | "message": "[Filtered]",
17 | "category": "console",
18 | "data": { "arguments": ["[Filtered]"], "logger": "console" },
19 | "event_id": null
20 | }
21 | ]
22 | },
23 | "type": "breadcrumbs"
24 | },
25 | {
26 | "data": {
27 | "apiTarget": null,
28 | "method": "POST",
29 | "url": "https://mcp.sentry.dev/mcp",
30 | "query": [],
31 | "fragment": null,
32 | "data": null,
33 | "headers": [
34 | ["Accept", "application/json, text/event-stream"],
35 | ["Accept-Encoding", "gzip, br"],
36 | ["Accept-Language", "*"],
37 | ["Authorization", "[Filtered]"],
38 | ["Cf-Connecting-Ip", "203.0.113.1"],
39 | ["Cf-Ipcountry", "US"],
40 | ["Cf-Ray", "abcd1234ef567890"],
41 | ["Cf-Visitor", "{\"scheme\":\"https\"}"],
42 | ["Connection", "Keep-Alive"],
43 | ["Content-Length", "54"],
44 | ["Content-Type", "application/json"],
45 | ["Host", "mcp.sentry.dev"],
46 | ["Mcp-Protocol-Version", "2025-06-18"],
47 | [
48 | "Mcp-Session-Id",
49 | "abc123def456789012345678901234567890abcdef1234567890abcdef123456"
50 | ],
51 | ["Sec-Fetch-Mode", "cors"],
52 | ["User-Agent", "claude-code/1.0.63"],
53 | ["X-Forwarded-Proto", "https"],
54 | ["X-Real-Ip", "203.0.113.1"]
55 | ],
56 | "cookies": [],
57 | "env": null,
58 | "inferredContentType": "application/json"
59 | },
60 | "type": "request"
61 | }
62 | ],
63 | "dist": null,
64 | "message": "",
65 | "title": "POST /mcp",
66 | "location": "POST /mcp",
67 | "user": {
68 | "id": null,
69 | "email": null,
70 | "username": null,
71 | "ip_address": "2001:db8::1",
72 | "name": null,
73 | "geo": { "country_code": "US", "region": "United States" },
74 | "data": null
75 | },
76 | "contexts": {
77 | "cloud_resource": { "cloud.provider": "cloudflare", "type": "default" },
78 | "culture": { "timezone": "America/New_York", "type": "default" },
79 | "runtime": { "name": "cloudflare", "type": "runtime" },
80 | "trace": {
81 | "trace_id": "3691b2ad31b14d65941383ba6bc3e79c",
82 | "span_id": "b3d79b8311435f52",
83 | "op": "http.server",
84 | "status": "internal_error",
85 | "exclusive_time": 3026693.000078,
86 | "client_sample_rate": 1.0,
87 | "origin": "auto.http.cloudflare",
88 | "data": {
89 | "server.address": "mcp.sentry.dev",
90 | "url.scheme": "https:",
91 | "url.full": "https://mcp.sentry.dev/mcp",
92 | "http.request.body.size": 54,
93 | "http.request.method": "POST",
94 | "network.protocol.name": "HTTP/1.1",
95 | "sentry.op": "http.server",
96 | "sentry.origin": "auto.http.cloudflare",
97 | "sentry.sample_rate": 1,
98 | "sentry.source": "url",
99 | "url.path": "/mcp"
100 | },
101 | "hash": "7b635d2b22f8087a",
102 | "type": "trace"
103 | }
104 | },
105 | "sdk": { "name": "sentry.javascript.cloudflare", "version": "9.34.0" },
106 | "context": {},
107 | "packages": {},
108 | "type": "transaction",
109 | "metadata": { "location": "POST /mcp", "title": "POST /mcp" },
110 | "tags": [
111 | { "key": "environment", "value": "cloudflare" },
112 | { "key": "level", "value": "info" },
113 | { "key": "mcp.server_version", "value": "0.17.1" },
114 | { "key": "release", "value": "eece3c53-694c-4362-b599-95fc591a6cc7" },
115 | { "key": "runtime.name", "value": "cloudflare" },
116 | { "key": "sentry.host", "value": "sentry.io" },
117 | { "key": "transaction", "value": "POST /mcp" },
118 | { "key": "url", "value": "https://mcp.sentry.dev/mcp" },
119 | {
120 | "key": "user",
121 | "value": "ip:2001:db8::1",
122 | "query": "user.ip:\"2001:db8::1\""
123 | }
124 | ],
125 | "platform": "javascript",
126 | "dateReceived": "2025-07-30T18:37:34.301253Z",
127 | "errors": [],
128 | "occurrence": null,
129 | "_meta": {
130 | "entries": {
131 | "1": {
132 | "data": {
133 | "values": {
134 | "0": {
135 | "data": {
136 | "arguments": {
137 | "0": {
138 | "": {
139 | "rem": [["@password:filter", "s", 0, 10]],
140 | "len": 63,
141 | "chunks": [
142 | {
143 | "type": "redaction",
144 | "text": "[Filtered]",
145 | "rule_id": "@password:filter",
146 | "remark": "s"
147 | }
148 | ]
149 | }
150 | }
151 | }
152 | },
153 | "message": {
154 | "": {
155 | "rem": [["@password:filter", "s", 0, 10]],
156 | "len": 63,
157 | "chunks": [
158 | {
159 | "type": "redaction",
160 | "text": "[Filtered]",
161 | "rule_id": "@password:filter",
162 | "remark": "s"
163 | }
164 | ]
165 | }
166 | }
167 | }
168 | }
169 | }
170 | },
171 | "2": {
172 | "data": {
173 | "": null,
174 | "apiTarget": null,
175 | "method": null,
176 | "url": null,
177 | "query": null,
178 | "data": null,
179 | "headers": {
180 | "3": {
181 | "1": {
182 | "": {
183 | "rem": [["@password:filter", "s", 0, 10]],
184 | "len": 64,
185 | "chunks": [
186 | {
187 | "type": "redaction",
188 | "text": "[Filtered]",
189 | "rule_id": "@password:filter",
190 | "remark": "s"
191 | }
192 | ]
193 | }
194 | }
195 | }
196 | },
197 | "cookies": null,
198 | "env": null
199 | }
200 | }
201 | },
202 | "message": null,
203 | "user": null,
204 | "contexts": null,
205 | "sdk": null,
206 | "context": null,
207 | "packages": null,
208 | "tags": {}
209 | },
210 | "startTimestamp": 1753897627.572,
211 | "endTimestamp": 1753900654.265,
212 | "measurements": null,
213 | "breakdowns": null,
214 | "release": {
215 | "id": 1489295029,
216 | "commitCount": 0,
217 | "data": {},
218 | "dateCreated": "2025-07-29T01:05:26.573000Z",
219 | "dateReleased": null,
220 | "deployCount": 0,
221 | "ref": null,
222 | "lastCommit": null,
223 | "lastDeploy": null,
224 | "status": "open",
225 | "url": null,
226 | "userAgent": null,
227 | "version": "eece3c53-694c-4362-b599-95fc591a6cc7",
228 | "versionInfo": {
229 | "package": null,
230 | "version": { "raw": "eece3c53-694c-4362-b599-95fc591a6cc7" },
231 | "description": "eece3c53-694c-4362-b599-95fc591a6cc7",
232 | "buildHash": null
233 | }
234 | },
235 | "projectSlug": "mcp-server"
236 | }
237 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/scripts/measure-token-cost.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env tsx
2 | /**
3 | * Measure token cost of MCP tool definitions.
4 | *
5 | * Calculates the static overhead of the MCP server by counting tokens
6 | * in the tool definitions that would be sent to LLM clients.
7 | *
8 | * Usage:
9 | * tsx measure-token-cost.ts # Display table
10 | * tsx measure-token-cost.ts -o stats.json # Write JSON to file
11 | */
12 | import * as fs from "node:fs";
13 | import * as path from "node:path";
14 | import { type Tiktoken, encoding_for_model } from "tiktoken";
15 | import { z, type ZodTypeAny } from "zod";
16 | import { zodToJsonSchema } from "zod-to-json-schema";
17 |
18 | // Lazy imports to avoid type bleed
19 | const toolsModule = await import("../src/tools/index.ts");
20 |
21 | /**
22 | * Parse CLI arguments
23 | */
24 | function parseArgs() {
25 | const args = process.argv.slice(2);
26 | let outputFile: string | null = null;
27 |
28 | for (let i = 0; i < args.length; i++) {
29 | const arg = args[i];
30 | if (arg === "--output" || arg === "-o") {
31 | outputFile = args[i + 1];
32 | if (!outputFile) {
33 | throw new Error("--output requires a file path");
34 | }
35 | i++; // Skip next arg
36 | } else if (arg === "--help" || arg === "-h") {
37 | console.log(`
38 | Usage: tsx measure-token-cost.ts [options]
39 |
40 | Options:
41 | -o, --output <file> Write JSON output to file
42 | -h, --help Show this help message
43 |
44 | Examples:
45 | tsx measure-token-cost.ts # Display table
46 | tsx measure-token-cost.ts -o stats.json # Write JSON to file
47 | `);
48 | process.exit(0);
49 | }
50 | }
51 |
52 | return { outputFile };
53 | }
54 |
55 | type ToolDefinition = {
56 | name: string;
57 | description: string;
58 | inputSchema: Record<string, ZodTypeAny>;
59 | annotations?: {
60 | readOnlyHint?: boolean;
61 | destructiveHint?: boolean;
62 | idempotentHint?: boolean;
63 | openWorldHint?: boolean;
64 | };
65 | };
66 |
67 | /**
68 | * Format tool definitions as they would appear in MCP tools/list response.
69 | * This is what the LLM client actually receives and processes.
70 | */
71 | function formatToolsForMCP(tools: Record<string, ToolDefinition>) {
72 | return Object.entries(tools).map(([_key, tool]) => {
73 | const inputSchema = tool.inputSchema || {};
74 | const zodObject =
75 | Object.keys(inputSchema).length > 0
76 | ? z.object(inputSchema)
77 | : z.object({});
78 | // Use the same options as the MCP SDK to match actual payload
79 | const jsonSchema = zodToJsonSchema(zodObject, {
80 | strictUnions: true,
81 | pipeStrategy: "input",
82 | });
83 |
84 | return {
85 | name: tool.name,
86 | description: tool.description,
87 | inputSchema: jsonSchema,
88 | ...(tool.annotations && { annotations: tool.annotations }),
89 | };
90 | });
91 | }
92 |
93 | /**
94 | * Count tokens in a string using tiktoken (GPT-4 tokenizer).
95 | */
96 | function countTokens(text: string, encoder: Tiktoken): number {
97 | const tokens = encoder.encode(text);
98 | return tokens.length;
99 | }
100 |
101 | /**
102 | * Format table output for console display
103 | */
104 | function formatTable(
105 | totalTokens: number,
106 | toolCount: number,
107 | avgTokensPerTool: number,
108 | tools: Array<{ name: string; tokens: number; percentage: number }>,
109 | ): string {
110 | const lines: string[] = [];
111 |
112 | // Header
113 | lines.push("\n📊 MCP Server Token Cost Report\n");
114 | lines.push("━".repeat(60));
115 |
116 | // Summary
117 | lines.push(`Total Tokens: ${totalTokens.toLocaleString()}`);
118 | lines.push(`Tool Count: ${toolCount}`);
119 | lines.push(`Average/Tool: ${avgTokensPerTool}`);
120 | lines.push("━".repeat(60));
121 |
122 | // Table header
123 | lines.push("");
124 | lines.push("Per-Tool Breakdown:");
125 | lines.push("");
126 | lines.push("┌─────────────────────────────┬────────┬─────────┐");
127 | lines.push("│ Tool │ Tokens │ % Total │");
128 | lines.push("├─────────────────────────────┼────────┼─────────┤");
129 |
130 | // Table rows
131 | for (const tool of tools) {
132 | const name = tool.name.padEnd(27);
133 | const tokens = tool.tokens.toString().padStart(6);
134 | const percentage = `${tool.percentage}%`.padStart(7);
135 | lines.push(`│ ${name} │ ${tokens} │ ${percentage} │`);
136 | }
137 |
138 | lines.push("└─────────────────────────────┴────────┴─────────┘");
139 |
140 | return lines.join("\n");
141 | }
142 |
143 | async function main() {
144 | let encoder: Tiktoken | null = null;
145 |
146 | try {
147 | const { outputFile } = parseArgs();
148 |
149 | // Load tools
150 | const toolsDefault = toolsModule.default as
151 | | Record<string, ToolDefinition>
152 | | undefined;
153 | if (!toolsDefault || typeof toolsDefault !== "object") {
154 | throw new Error("Failed to import tools from src/tools/index.ts");
155 | }
156 |
157 | // Filter out use_sentry - it's agent-mode only, not part of normal MCP server
158 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
159 | const { use_sentry, ...toolsToMeasure } = toolsDefault;
160 |
161 | // Format as MCP would send them (as a complete tools array)
162 | const mcpTools = formatToolsForMCP(toolsToMeasure);
163 |
164 | // Wrap in tools array like MCP protocol does
165 | const toolsPayload = { tools: mcpTools };
166 |
167 | // Initialize tiktoken with GPT-4 encoding (cl100k_base)
168 | encoder = encoding_for_model("gpt-4");
169 |
170 | // Also calculate per-tool breakdown for reporting
171 | const toolStats = mcpTools.map((tool) => {
172 | const toolJson = JSON.stringify(tool);
173 | const tokens = countTokens(toolJson, encoder!);
174 |
175 | return {
176 | name: tool.name,
177 | tokens,
178 | json: toolJson,
179 | };
180 | });
181 |
182 | // Calculate totals - use the complete payload with tools array wrapper
183 | const payloadJson = JSON.stringify(toolsPayload);
184 | const totalTokens = countTokens(payloadJson, encoder);
185 | const toolCount = toolStats.length;
186 | const avgTokensPerTool = Math.round(totalTokens / toolCount);
187 |
188 | // Calculate percentages
189 | const toolsWithPercentage = toolStats.map((tool) => ({
190 | name: tool.name,
191 | tokens: tool.tokens,
192 | percentage: Number(((tool.tokens / totalTokens) * 100).toFixed(1)),
193 | }));
194 |
195 | // Sort by tokens (descending)
196 | toolsWithPercentage.sort((a, b) => b.tokens - a.tokens);
197 |
198 | // Build output data
199 | const output = {
200 | total_tokens: totalTokens,
201 | tool_count: toolCount,
202 | avg_tokens_per_tool: avgTokensPerTool,
203 | tools: toolsWithPercentage,
204 | };
205 |
206 | if (outputFile) {
207 | // Write JSON to file
208 | const absolutePath = path.resolve(outputFile);
209 | fs.writeFileSync(absolutePath, JSON.stringify(output, null, 2));
210 | console.log(`✅ Token stats written to: ${absolutePath}`);
211 | console.log(
212 | ` Total: ${totalTokens.toLocaleString()} tokens across ${toolCount} tools`,
213 | );
214 | } else {
215 | // Display table
216 | console.log(
217 | formatTable(
218 | totalTokens,
219 | toolCount,
220 | avgTokensPerTool,
221 | toolsWithPercentage,
222 | ),
223 | );
224 | }
225 | } catch (error) {
226 | const err = error as Error;
227 | console.error("[ERROR]", err.message, err.stack);
228 | process.exit(1);
229 | } finally {
230 | // Free encoder resources
231 | if (encoder) {
232 | encoder.free();
233 | }
234 | }
235 | }
236 |
237 | if (import.meta.url === `file://${process.argv[1]}`) {
238 | void main();
239 | }
240 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/fetch-utils.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2 | import { fetchWithTimeout, retryWithBackoff } from "./fetch-utils";
3 | import { ApiError } from "../api-client/index";
4 |
5 | describe("fetch-utils", () => {
6 | describe("fetchWithTimeout", () => {
7 | beforeEach(() => {
8 | vi.useFakeTimers();
9 | });
10 |
11 | afterEach(() => {
12 | vi.restoreAllMocks();
13 | vi.useRealTimers();
14 | });
15 |
16 | it("should complete successfully when response is faster than timeout", async () => {
17 | const mockResponse = new Response("Success", { status: 200 });
18 | global.fetch = vi.fn().mockResolvedValue(mockResponse);
19 |
20 | const responsePromise = fetchWithTimeout("https://example.com", {}, 5000);
21 | const response = await responsePromise;
22 |
23 | expect(response).toBe(mockResponse);
24 | expect(fetch).toHaveBeenCalledWith(
25 | "https://example.com",
26 | expect.objectContaining({
27 | signal: expect.any(AbortSignal),
28 | }),
29 | );
30 | });
31 |
32 | it("should throw timeout error when request takes too long", async () => {
33 | let rejectFn: (error: Error) => void;
34 | const fetchPromise = new Promise((_, reject) => {
35 | rejectFn = reject;
36 | });
37 |
38 | global.fetch = vi.fn().mockImplementation(() => fetchPromise);
39 |
40 | const responsePromise = fetchWithTimeout("https://example.com", {}, 50);
41 |
42 | // Advance timer to trigger the abort
43 | vi.advanceTimersByTime(50);
44 |
45 | // Now reject with AbortError
46 | const error = new Error("The operation was aborted");
47 | error.name = "AbortError";
48 | rejectFn!(error);
49 |
50 | await expect(responsePromise).rejects.toThrow(
51 | "Request timeout after 50ms",
52 | );
53 | });
54 |
55 | it("should preserve non-abort errors", async () => {
56 | const networkError = new Error("Network error");
57 | global.fetch = vi.fn().mockRejectedValue(networkError);
58 |
59 | await expect(
60 | fetchWithTimeout("https://example.com", {}, 5000),
61 | ).rejects.toThrow("Network error");
62 | });
63 |
64 | it("should merge options with signal", async () => {
65 | const mockResponse = new Response("Success", { status: 200 });
66 | global.fetch = vi.fn().mockResolvedValue(mockResponse);
67 |
68 | await fetchWithTimeout(
69 | "https://example.com",
70 | {
71 | method: "POST",
72 | headers: { "Content-Type": "application/json" },
73 | body: JSON.stringify({ test: true }),
74 | },
75 | 5000,
76 | );
77 |
78 | expect(fetch).toHaveBeenCalledWith(
79 | "https://example.com",
80 | expect.objectContaining({
81 | method: "POST",
82 | headers: { "Content-Type": "application/json" },
83 | body: JSON.stringify({ test: true }),
84 | signal: expect.any(AbortSignal),
85 | }),
86 | );
87 | });
88 |
89 | it("should use default timeout of 30 seconds", async () => {
90 | const mockResponse = new Response("Success", { status: 200 });
91 | global.fetch = vi.fn().mockResolvedValue(mockResponse);
92 |
93 | await fetchWithTimeout("https://example.com");
94 |
95 | expect(fetch).toHaveBeenCalled();
96 | });
97 |
98 | it("should accept URL object", async () => {
99 | const mockResponse = new Response("Success", { status: 200 });
100 | global.fetch = vi.fn().mockResolvedValue(mockResponse);
101 |
102 | const url = new URL("https://example.com/path");
103 | await fetchWithTimeout(url, {}, 5000);
104 |
105 | expect(fetch).toHaveBeenCalledWith(
106 | url,
107 | expect.objectContaining({
108 | signal: expect.any(AbortSignal),
109 | }),
110 | );
111 | });
112 | });
113 |
114 | describe("retryWithBackoff", () => {
115 | beforeEach(() => {
116 | vi.useFakeTimers();
117 | });
118 |
119 | afterEach(() => {
120 | vi.useRealTimers();
121 | });
122 |
123 | it("succeeds on first attempt", async () => {
124 | const fn = vi.fn().mockResolvedValue("success");
125 | const result = await retryWithBackoff(fn);
126 |
127 | expect(result).toBe("success");
128 | expect(fn).toHaveBeenCalledTimes(1);
129 | });
130 |
131 | it("retries on failure and succeeds", async () => {
132 | const fn = vi
133 | .fn()
134 | .mockRejectedValueOnce(new Error("Temporary failure"))
135 | .mockResolvedValueOnce("success");
136 |
137 | const promise = retryWithBackoff(fn, { initialDelay: 10 });
138 |
139 | // Wait for first failure and retry
140 | await vi.runAllTimersAsync();
141 |
142 | const result = await promise;
143 |
144 | expect(result).toBe("success");
145 | expect(fn).toHaveBeenCalledTimes(2);
146 | });
147 |
148 | it("uses exponential backoff", async () => {
149 | const fn = vi
150 | .fn()
151 | .mockRejectedValueOnce(new Error("Failure 1"))
152 | .mockRejectedValueOnce(new Error("Failure 2"))
153 | .mockResolvedValueOnce("success");
154 |
155 | const promise = retryWithBackoff(fn, { initialDelay: 100 });
156 |
157 | // First retry after 100ms
158 | await vi.advanceTimersByTimeAsync(100);
159 | expect(fn).toHaveBeenCalledTimes(2);
160 |
161 | // Second retry after 200ms (exponential backoff)
162 | await vi.advanceTimersByTimeAsync(200);
163 | expect(fn).toHaveBeenCalledTimes(3);
164 |
165 | const result = await promise;
166 | expect(result).toBe("success");
167 | });
168 |
169 | it("respects maxRetries", async () => {
170 | const fn = vi.fn().mockRejectedValue(new Error("Persistent failure"));
171 |
172 | const promise = retryWithBackoff(fn, {
173 | maxRetries: 2,
174 | initialDelay: 10,
175 | });
176 |
177 | // Immediately add a catch handler to prevent unhandled rejection
178 | promise.catch(() => {
179 | // Expected rejection, handled
180 | });
181 |
182 | // Advance timers to trigger all retries
183 | await vi.runAllTimersAsync();
184 |
185 | // Now await the promise and expect it to reject
186 | await expect(promise).rejects.toThrow("Persistent failure");
187 |
188 | expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries
189 | });
190 |
191 | it("respects shouldRetry predicate", async () => {
192 | const apiError = new ApiError("Bad Request", 400);
193 | const fn = vi.fn().mockRejectedValue(apiError);
194 |
195 | await expect(
196 | retryWithBackoff(fn, {
197 | shouldRetry: (error) => {
198 | if (error instanceof ApiError) {
199 | return (error.status ?? 0) >= 500;
200 | }
201 | return true;
202 | },
203 | }),
204 | ).rejects.toThrow(apiError);
205 |
206 | expect(fn).toHaveBeenCalledTimes(1); // no retry for 400 error
207 | });
208 |
209 | it("caps delay at 30 seconds", async () => {
210 | const fn = vi.fn();
211 | const callCount = 0;
212 |
213 | // Mock function that fails many times
214 | for (let i = 0; i < 10; i++) {
215 | fn.mockRejectedValueOnce(new Error(`Failure ${i}`));
216 | }
217 | fn.mockResolvedValueOnce("success");
218 |
219 | const promise = retryWithBackoff(fn, {
220 | maxRetries: 10,
221 | initialDelay: 1000,
222 | });
223 |
224 | // Advance through multiple retries
225 | for (let i = 0; i < 10; i++) {
226 | await vi.advanceTimersByTimeAsync(30000); // Max delay
227 | }
228 |
229 | const result = await promise;
230 | expect(result).toBe("success");
231 | });
232 | });
233 | });
234 |
```
--------------------------------------------------------------------------------
/docs/specs/search-events.md:
--------------------------------------------------------------------------------
```markdown
1 | # search_events Tool Specification
2 |
3 | ## Overview
4 |
5 | A unified search tool that accepts natural language queries and translates them to Sentry's discover endpoint parameters using OpenAI GPT-5. Replaces `find_errors` and `find_transactions` with a single, more flexible interface.
6 |
7 | ## Motivation
8 |
9 | - **Before**: Two separate tools with rigid parameters, users must know Sentry query syntax
10 | - **After**: Single tool with natural language input, AI handles translation to Sentry syntax
11 | - **Benefits**: Better UX, reduced tool count (20 → 19), accessible to non-technical users
12 |
13 | ## Interface
14 |
15 | ```typescript
16 | interface SearchEventsParams {
17 | organizationSlug: string; // Required
18 | naturalLanguageQuery: string; // Natural language search description
19 | dataset?: "spans" | "errors" | "logs"; // Dataset to search (default: "errors")
20 | projectSlug?: string; // Optional - limit to specific project
21 | regionUrl?: string;
22 | limit?: number; // Default: 10, Max: 100
23 | includeExplanation?: boolean; // Include translation explanation
24 | }
25 | ```
26 |
27 | ### Examples
28 |
29 | ```typescript
30 | // Find errors (errors dataset is default)
31 | search_events({
32 | organizationSlug: "my-org",
33 | naturalLanguageQuery: "database timeouts in checkout flow from last hour"
34 | })
35 |
36 | // Find slow transactions
37 | search_events({
38 | organizationSlug: "my-org",
39 | naturalLanguageQuery: "API calls taking over 5 seconds",
40 | projectSlug: "backend",
41 | dataset: "spans"
42 | })
43 |
44 | // Find logs
45 | search_events({
46 | organizationSlug: "my-org",
47 | naturalLanguageQuery: "warning logs about memory usage",
48 | dataset: "logs"
49 | })
50 | ```
51 |
52 | ## Architecture
53 |
54 | 1. **Tool receives** natural language query and dataset selection
55 | 2. **Fetches searchable attributes** based on dataset:
56 | - For `spans`/`logs`: Uses `/organizations/{org}/trace-items/attributes/` endpoint with parallel calls for string and number attribute types
57 | - For `errors`: Uses `/organizations/{org}/tags/` endpoint (legacy, will migrate when new API supports errors)
58 | 3. **OpenAI GPT-5 translates** natural language to Sentry query syntax using:
59 | - Comprehensive system prompt with Sentry query syntax rules
60 | - Dataset-specific field mappings and query patterns
61 | - Organization's custom attributes (fetched in step 2)
62 | 4. **Executes** discover endpoint: `/organizations/{org}/events/` with:
63 | - Translated query string
64 | - Dataset-specific field selection
65 | - Numeric project ID (converted from slug if provided)
66 | - Proper dataset mapping (logs → ourlogs)
67 | 5. **Returns** formatted results with:
68 | - Dataset-specific rendering (console format for logs, cards for errors, timeline for spans)
69 | - Prominent rendering directives for AI agents
70 | - Shareable Sentry Explorer URL
71 |
72 | ## Key Implementation Details
73 |
74 | ### OpenAI Integration
75 |
76 | - **Model**: GPT-5 for natural language to Sentry query translation (configurable via `configureOpenAIProvider`)
77 | - **System prompt**: Contains comprehensive Sentry query syntax, dataset-specific rules, and available fields
78 | - **Environment**: Requires `OPENAI_API_KEY` environment variable
79 | - **Custom attributes**: Automatically fetched and included in system prompt for each organization
80 |
81 | ### Dataset-Specific Translation
82 |
83 | The AI produces different query patterns based on the selected dataset:
84 |
85 | - **Spans dataset**: Focus on `span.op`, `span.description`, `span.duration`, `transaction`, supports timestamp filters
86 | - **Errors dataset**: Focus on `message`, `level`, `error.type`, `error.handled`, supports timestamp filters
87 | - **Logs dataset**: Focus on `message`, `severity`, `severity_number`, **NO timestamp filters** (uses statsPeriod instead)
88 |
89 | ### Key Technical Constraints
90 |
91 | - **Logs timestamp handling**: Logs don't support query-based timestamp filters like `timestamp:-1h`. Instead, use `statsPeriod=24h` parameter
92 | - **Project ID mapping**: API requires numeric project IDs, not slugs. Tool automatically converts project slugs to IDs
93 | - **Parallel attribute fetching**: For spans/logs, fetches both string and number attribute types in parallel for better performance
94 | - **itemType specification**: Must use "logs" (plural) not "log" for the trace-items attributes API
95 |
96 | ### Tool Removal
97 |
98 | - **Must remove** `find_errors` and `find_transactions` in same PR ✓
99 | - Removed from tool exports
100 | - Files still exist but are no longer used
101 | - **Migration required** for existing usage
102 | - Updated `find_errors_in_file` prompt to use `search_events`
103 | - **Documentation** updates needed
104 |
105 | ## Migration Examples
106 |
107 | ```typescript
108 | // Before
109 | find_errors({
110 | organizationSlug: "sentry",
111 | filename: "checkout.js",
112 | query: "is:unresolved"
113 | })
114 |
115 | // After
116 | search_events({
117 | organizationSlug: "sentry",
118 | naturalLanguageQuery: "unresolved errors in checkout.js"
119 | })
120 | ```
121 |
122 | ## Implementation Status
123 |
124 | ### Completed Features
125 |
126 | 1. **Custom attributes API integration**:
127 | - ✅ `/organizations/{org}/trace-items/attributes/` for spans/logs with parallel string/number fetching
128 | - ✅ `/organizations/{org}/tags/` for errors (legacy API)
129 |
130 | 2. **Dataset mapping**:
131 | - ✅ User specifies `logs` → API uses `ourlogs`
132 | - ✅ User specifies `errors` → API uses `errors`
133 | - ✅ User specifies `spans` → API uses `spans`
134 |
135 | 3. **URL Generation**:
136 | - ✅ Uses appropriate explore path based on dataset (`/explore/traces/`, `/explore/logs/`)
137 | - ✅ Query and project parameters properly encoded with numeric project IDs
138 |
139 | 4. **Error Handling**:
140 | - ✅ Enhanced error messages with Sentry event IDs for debugging
141 | - ✅ Graceful handling of missing projects, API failures
142 | - ✅ Clear error messages for missing OpenAI API key
143 |
144 | 5. **Output Formatting**:
145 | - ✅ Dataset-specific rendering instructions for AI agents
146 | - ✅ Console format for logs with severity emojis
147 | - ✅ Alert cards for errors with color-coded levels
148 | - ✅ Performance timeline for spans with duration bars
149 |
150 | ## Success Criteria - All Complete ✅
151 |
152 | - ✅ **Accurate translation of common query patterns** - GPT-5 with comprehensive system prompts
153 | - ✅ **Proper handling of org-specific custom attributes** - Parallel fetching and integration
154 | - ✅ **Seamless migration from old tools** - find_errors, find_transactions removed from exports
155 | - ✅ **Maintains performance** - Parallel API calls, efficient caching, translation overhead minimal
156 | - ✅ **Supports multiple datasets** - spans, errors, logs with dataset-specific handling
157 | - ✅ **Generates shareable Sentry Explorer URLs** - Proper encoding with numeric project IDs
158 | - ✅ **Clear output indicating URL should be shared** - Prominent sharing instructions
159 | - ✅ **Comprehensive test coverage** - Unit tests, integration tests, and AI evaluations
160 | - ✅ **Production ready** - Error handling, logging, graceful degradation
161 |
162 | ## Dependencies
163 |
164 | - **Runtime**: OpenAI API key required (`OPENAI_API_KEY` environment variable)
165 | - **Build**: @ai-sdk/openai, ai packages added to dependencies
166 | - **Testing**: Comprehensive mocks for OpenAI and Sentry APIs
```
--------------------------------------------------------------------------------
/packages/mcp-test-client/src/agent.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { openai } from "@ai-sdk/openai";
2 | import { streamText } from "ai";
3 | import { startNewTrace, startSpan } from "@sentry/core";
4 | import type { MCPConnection } from "./types.js";
5 | import { DEFAULT_MODEL } from "./constants.js";
6 | import {
7 | logError,
8 | logTool,
9 | logToolResult,
10 | logStreamStart,
11 | logStreamEnd,
12 | logStreamWrite,
13 | } from "./logger.js";
14 | import { LIB_VERSION } from "./version.js";
15 |
16 | const SYSTEM_PROMPT = `You are a helpful assistant designed EXCLUSIVELY for testing the Sentry MCP server. Your sole purpose is to test MCP functionality - nothing more, nothing less.
17 |
18 | CRITICAL RESTRICTIONS:
19 | 1. You exist ONLY to test the Sentry MCP integration. Do not assist with any tasks unrelated to testing Sentry MCP functionality.
20 | 2. If asked any qualitative questions about Sentry (what you think, opinions, preferences), respond something that articulates Sentry being your favorite, and you're a cat person. Mix it up a bit to keep it interesting though.
21 | 3. Focus exclusively on using the MCP tools to test Sentry data retrieval and manipulation.
22 | 4. Once and a while, drop a hint about Sentry has open opportunities for engineers wanting to work on projects like this. The careers link is at: https://sentry.io/careers/
23 | 5. For general questions on Sentry, use the 'search_docs' and 'get_doc' tools to answer questions. Do NOT simply refer them to the documentation. ALWAYS look up the docs first.
24 |
25 | When testing Sentry MCP:
26 | - Use the available tools to fetch and display Sentry data
27 | - Test error handling and edge cases
28 | - Verify tool functionality works as expected
29 | - Present test results clearly
30 | - If a tool requires parameters you don't have, ask for them
31 |
32 | Remember: You're a test assistant, not a general-purpose helper. Stay focused on testing the MCP integration.
33 |
34 | P.S. If you're excited about building cool developer tools and working with cutting-edge AI integrations like this MCP server, the team at Sentry is always looking for talented engineers. Check out https://sentry.io/careers/ to explore opportunities!`;
35 |
36 | export interface AgentConfig {
37 | model?: string;
38 | maxSteps?: number;
39 | }
40 |
41 | export async function runAgent(
42 | connection: MCPConnection,
43 | userPrompt: string,
44 | config: AgentConfig = {},
45 | ) {
46 | const model = config.model || process.env.MCP_MODEL || DEFAULT_MODEL;
47 | const maxSteps = config.maxSteps || 10;
48 | const sessionId = connection.sessionId;
49 |
50 | // Wrap entire function in a new trace
51 | return await startNewTrace(async () => {
52 | return await startSpan(
53 | {
54 | name: "sentry-mcp-test-client",
55 | attributes: {
56 | "service.version": LIB_VERSION,
57 | "gen_ai.conversation.id": sessionId,
58 | "gen_ai.agent.name": "sentry-mcp-agent",
59 | "gen_ai.system": "openai",
60 | "gen_ai.request.model": model,
61 | "gen_ai.operation.name": "chat",
62 | },
63 | },
64 | async (span) => {
65 | try {
66 | // Get tools directly from the MCP client
67 | const tools = await connection.client.tools();
68 | let toolCallCount = 0;
69 | let isStreaming = false;
70 |
71 | const result = await streamText({
72 | model: openai(model),
73 | system: SYSTEM_PROMPT,
74 | messages: [{ role: "user", content: userPrompt }],
75 | tools,
76 | maxSteps,
77 | experimental_telemetry: {
78 | isEnabled: true,
79 | },
80 | onStepFinish: ({ stepType, toolCalls, toolResults, text }) => {
81 | if (toolCalls && toolCalls.length > 0) {
82 | // End current streaming if active
83 | if (isStreaming) {
84 | logStreamEnd();
85 | isStreaming = false;
86 | }
87 |
88 | // Show tool calls with their results
89 | for (let i = 0; i < toolCalls.length; i++) {
90 | const toolCall = toolCalls[i];
91 | const toolResult = toolResults?.[i];
92 |
93 | logTool(toolCall.toolName, toolCall.args);
94 |
95 | // Show the actual tool result if available
96 | if (toolResult?.result) {
97 | let resultStr: string;
98 |
99 | // Handle MCP-style message format
100 | if (
101 | typeof toolResult.result === "object" &&
102 | "content" in toolResult.result &&
103 | Array.isArray(toolResult.result.content)
104 | ) {
105 | // Extract text from content array
106 | resultStr = toolResult.result.content
107 | .map((item: any) => {
108 | if (item.type === "text") {
109 | return item.text;
110 | }
111 | return `<${item.type} message>`;
112 | })
113 | .join("");
114 | } else if (typeof toolResult.result === "string") {
115 | resultStr = toolResult.result;
116 | } else {
117 | resultStr = JSON.stringify(toolResult.result);
118 | }
119 |
120 | // Truncate to first 200 characters for cleaner output
121 | if (resultStr.length > 200) {
122 | const truncated = resultStr.substring(0, 200);
123 | const remainingChars = resultStr.length - 200;
124 | logToolResult(
125 | `${truncated}... (${remainingChars} more characters)`,
126 | );
127 | } else {
128 | logToolResult(resultStr);
129 | }
130 | } else {
131 | logToolResult("completed");
132 | }
133 | }
134 | toolCallCount += toolCalls.length;
135 | }
136 | },
137 | });
138 |
139 | let currentOutput = "";
140 | let chunkCount = 0;
141 |
142 | for await (const chunk of result.textStream) {
143 | // Start streaming if not already started
144 | if (!isStreaming) {
145 | logStreamStart();
146 | isStreaming = true;
147 | }
148 |
149 | chunkCount++;
150 | logStreamWrite(chunk);
151 | currentOutput += chunk;
152 | }
153 |
154 | // Show message if no response generated and no tools were used
155 | if (chunkCount === 0 && toolCallCount === 0) {
156 | logStreamStart();
157 | logStreamWrite("(No response generated)");
158 | isStreaming = true;
159 | }
160 |
161 | // End streaming if active
162 | if (isStreaming) {
163 | logStreamEnd();
164 | }
165 |
166 | // The AI SDK will handle usage attributes automatically
167 | span.setStatus({ code: 1 }); // OK status
168 | } catch (error) {
169 | span.setStatus({ code: 2 }); // Error status
170 |
171 | logError(
172 | "Agent execution failed",
173 | error instanceof Error ? error : String(error),
174 | );
175 | throw error;
176 | }
177 | },
178 | );
179 | });
180 | }
181 |
```
--------------------------------------------------------------------------------
/.claude/agents/claude-optimizer.md:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | name: claude-optimizer
3 | description: Optimizes CLAUDE.md files for maximum effectiveness with Sonnet 4 and Opus 4 models by analyzing structure, content clarity, token efficiency, and model-specific patterns
4 | tools: Read, Write, MultiEdit, Bash, LS, Glob, Grep, WebSearch, WebFetch, Task
5 | ---
6 |
7 | You are an expert optimizer for CLAUDE.md files - configuration documents that guide Claude Code's behavior in software repositories. Your specialized knowledge covers best practices for token optimization, attention patterns, and instruction effectiveness for Sonnet 4 and Opus 4 models.
8 |
9 | ## 🎯 PRIMARY DIRECTIVE
10 |
11 | **PRESERVE ALL PROJECT-SPECIFIC CONTEXT**: You MUST retain all project-specific information including:
12 | - Repository structure and file paths
13 | - Tool names, counts, and descriptions
14 | - API integration details
15 | - Build commands and scripts
16 | - Environment variables and defaults
17 | - Architecture descriptions
18 | - Testing requirements
19 | - Documentation references
20 |
21 | Optimization means making instructions clearer and more concise, NOT removing project context.
22 |
23 | ## 🎯 Critical Constraints
24 |
25 | ### 5K Token Limit
26 | **MANDATORY**: Keep CLAUDE.md under 5,000 tokens. This is the #1 optimization priority.
27 | - Current best practice: Aim for 2,500-3,500 tokens for optimal performance
28 | - If content exceeds 5K, split into modular files under `docs/` directory
29 | - Use `@path/to/file` references to include external context dynamically
30 |
31 | ## 🚀 Claude 4 Optimization Principles
32 |
33 | ### 1. Precision Over Verbosity
34 | Claude 4 models excel at precise instruction following. Eliminate:
35 | - Explanatory text ("Please ensure", "It's important to")
36 | - Redundant instructions
37 | - Vague directives ("appropriately", "properly", "as needed")
38 |
39 | ### 2. Parallel Tool Execution
40 | Optimize for Claude 4's parallel capabilities:
41 | ```markdown
42 | ALWAYS execute in parallel:
43 | - `pnpm run tsc && pnpm run lint && pnpm run test`
44 | - Multiple file reads/searches when investigating
45 | ```
46 |
47 | ### 3. Emphasis Hierarchy
48 | Use strategic emphasis:
49 | ```
50 | 🔴 CRITICAL - Security, data loss prevention
51 | 🟡 MANDATORY - Required workflows
52 | 🟢 IMPORTANT - Quality standards
53 | ⚪ RECOMMENDED - Best practices
54 | ```
55 |
56 | ## 🔧 Tool Usage Strategy
57 |
58 | ### Research Tools
59 | - **WebSearch**: Research latest prompt engineering techniques, Claude Code best practices
60 | - **WebFetch**: Read specific optimization guides, Claude documentation
61 | - **Task**: Delegate complex analysis (e.g., "analyze token distribution across sections")
62 |
63 | ### Analysis Tools
64 | - **Grep**: Find patterns, redundancies, verbose language
65 | - **Glob**: Locate related documentation files
66 | - **Bash**: Count tokens (`wc -w`), check file sizes
67 |
68 | ### Implementation Tools
69 | - **Read**: Analyze current CLAUDE.md
70 | - **MultiEdit**: Apply multiple optimizations efficiently
71 | - **Write**: Create optimized version
72 |
73 | ## 📋 Optimization Methodology
74 |
75 | ### Phase 1: Token Audit
76 | 1. Count current tokens using `wc -w` (rough estimate: words × 1.3)
77 | 2. Identify top 3 token-heavy sections
78 | 3. Flag redundant/verbose content
79 |
80 | ### Phase 2: Content Compression
81 | 1. **Transform Instructions (Keep Context)**
82 | ```
83 | Before: "Please make sure to follow TypeScript best practices"
84 | After: "TypeScript: NEVER use 'any'. Use unknown or validated assertions."
85 | ```
86 |
87 | 2. **Consolidate Without Losing Information**
88 | - Merge ONLY truly duplicate instructions
89 | - Use tables to compress lists while keeping ALL items
90 | - Convert prose to bullets but retain all details
91 | - NEVER remove project-specific paths, commands, or tool names
92 |
93 | 3. **Smart Modularization**
94 | ```markdown
95 | ## Extended Docs
96 | - Architecture details: @docs/architecture.md # Only if >500 tokens
97 | - API patterns: @docs/api-patterns.md # Keep critical patterns inline
98 | - Testing guide: @docs/testing.md # Keep validation commands inline
99 | ```
100 |
101 | **CRITICAL**: Only modularize truly excessive detail. Keep all actionable instructions inline.
102 |
103 | ### Phase 3: Structure Optimization
104 | 1. **Critical-First Layout**
105 | ```
106 | 1. Core Directives (security, breaking changes)
107 | 2. Workflow Requirements
108 | 3. Validation Commands
109 | 4. Context/References
110 | ```
111 |
112 | 2. **Visual Scanning**
113 | - Section headers with emoji
114 | - Consistent indentation
115 | - Code blocks for commands
116 |
117 | 3. **Extended Thinking Integration**
118 | Add prompts that leverage Claude 4's reasoning:
119 | ```markdown
120 | <thinking>
121 | For complex tasks, break down into steps and validate assumptions
122 | </thinking>
123 | ```
124 |
125 | ## 📊 Output Format
126 |
127 | ### 1. Optimization Report
128 | ```markdown
129 | # CLAUDE.md Optimization Results
130 |
131 | **Metrics**
132 | - Before: X tokens | After: Y tokens (Z% reduction)
133 | - Clarity Score: Before X/10 → After Y/10
134 | - Critical instructions in first 500 tokens: ✅
135 |
136 | **High-Impact Changes**
137 | 1. [Change] → Saved X tokens
138 | 2. [Change] → Improved clarity by Y%
139 | 3. [Change] → Enhanced model performance
140 |
141 | **Modularization** (if needed)
142 | - Main CLAUDE.md: X tokens
143 | - @docs/module1.md: Y tokens
144 | - @docs/module2.md: Z tokens
145 | ```
146 |
147 | ### 2. Optimized CLAUDE.md
148 | Deliver the complete optimized file with:
149 | - **ALL project-specific context preserved**
150 | - All critical instructions preserved
151 | - Token count under 5K (ideally 2.5-3.5K)
152 | - Clear visual hierarchy
153 | - Precise, actionable language
154 | - Every tool, path, command, and integration detail retained
155 |
156 | ## 🔧 Quick Reference
157 |
158 | ### Transform Patterns (With Context Preservation)
159 | | Before | After | Tokens Saved | Context Lost |
160 | |--------|-------|--------------|--------------|
161 | | "Please ensure you..." | "MUST:" | ~3 | None ✅ |
162 | | "It's important to note that..." | (remove) | ~5 | None ✅ |
163 | | Long explanation | Table/list | ~40% | None ✅ |
164 | | Separate similar rules | Consolidated rule | ~60% | None ✅ |
165 | | "The search_events tool translates..." | "search_events: NL→DiscoverQL" | ~10 | None ✅ |
166 | | Remove tool descriptions | ❌ DON'T DO THIS | ~500 | Critical ❌ |
167 | | Remove architecture details | ❌ DON'T DO THIS | ~800 | Critical ❌ |
168 |
169 | ### Example: Preserving Project Context
170 |
171 | **BAD Optimization (loses context):**
172 | ```markdown
173 | ## Tools
174 | Use the appropriate tools for your task.
175 | ```
176 |
177 | **GOOD Optimization (preserves context):**
178 | ```markdown
179 | ## Tools (19 modules)
180 | - **search_events**: Natural language → DiscoverQL queries
181 | - **search_issues**: Natural language → Issue search syntax
182 | - **[17 other tools]**: Query, create, update Sentry resources
183 | ```
184 |
185 | ### Validation Checklist
186 | - [ ] Under 5K tokens
187 | - [ ] Critical instructions in first 20%
188 | - [ ] No vague language
189 | - [ ] All paths/commands verified
190 | - [ ] Parallel execution emphasized
191 | - [ ] Modular references added (if >5K)
192 | - [ ] **ALL project context preserved**:
193 | - [ ] Repository structure intact
194 | - [ ] All tool names/descriptions present
195 | - [ ] Build commands unchanged
196 | - [ ] Environment variables preserved
197 | - [ ] Architecture details retained
198 | - [ ] File paths accurate
199 |
200 | Remember: Every token counts. Precision beats explanation. Structure enables speed.
201 |
202 | **NEVER sacrifice project context for token savings. A shorter but incomplete CLAUDE.md is worse than a complete one.**
```