#
tokens: 43260/50000 7/408 files (page 10/15)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 10 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
│   └── rules
├── .env.example
├── .github
│   └── workflows
│       ├── deploy.yml
│       ├── eval.yml
│       ├── merge-jobs.yml
│       ├── release.yml
│       ├── smoke-tests.yml
│       └── test.yml
├── .gitignore
├── .mcp.json
├── .vscode
│   ├── extensions.json
│   ├── mcp.json
│   └── settings.json
├── AGENTS.md
├── bin
│   └── bump-version.sh
├── biome.json
├── CLAUDE.md
├── codecov.yml
├── core
├── docs
│   ├── adding-tools.mdc
│   ├── api-patterns.mdc
│   ├── architecture.mdc
│   ├── cloudflare
│   │   ├── architecture.md
│   │   ├── constraint-do-analysis.md
│   │   ├── deployment.md
│   │   ├── mcpagent-architecture.md
│   │   ├── oauth-architecture.md
│   │   └── overview.md
│   ├── coding-guidelines.mdc
│   ├── common-patterns.mdc
│   ├── cursor.mdc
│   ├── deployment.mdc
│   ├── error-handling.mdc
│   ├── github-actions.mdc
│   ├── llms
│   │   ├── document-scopes.mdc
│   │   ├── documentation-style-guide.mdc
│   │   └── README.md
│   ├── logging.mdc
│   ├── monitoring.mdc
│   ├── permissions-and-scopes.md
│   ├── pr-management.mdc
│   ├── quality-checks.mdc
│   ├── README.md
│   ├── search-events-api-patterns.md
│   ├── security.mdc
│   ├── specs
│   │   ├── README.md
│   │   ├── search-events.md
│   │   └── subpath-constraints.md
│   └── testing.mdc
├── LICENSE.md
├── Makefile
├── package.json
├── packages
│   ├── mcp-cloudflare
│   │   ├── .env.example
│   │   ├── components.json
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── public
│   │   │   ├── favicon.ico
│   │   │   ├── flow-transparent.png
│   │   │   └── flow.jpg
│   │   ├── src
│   │   │   ├── client
│   │   │   │   ├── app.tsx
│   │   │   │   ├── components
│   │   │   │   │   ├── chat
│   │   │   │   │   │   ├── auth-form.tsx
│   │   │   │   │   │   ├── chat-input.tsx
│   │   │   │   │   │   ├── chat-message.tsx
│   │   │   │   │   │   ├── chat-messages.tsx
│   │   │   │   │   │   ├── chat-ui.tsx
│   │   │   │   │   │   ├── chat.tsx
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── tool-invocation.tsx
│   │   │   │   │   │   └── types.ts
│   │   │   │   │   ├── fragments
│   │   │   │   │   │   ├── remote-setup.tsx
│   │   │   │   │   │   ├── setup-guide.tsx
│   │   │   │   │   │   └── stdio-setup.tsx
│   │   │   │   │   └── ui
│   │   │   │   │       ├── accordion.tsx
│   │   │   │   │       ├── backdrop.tsx
│   │   │   │   │       ├── base.tsx
│   │   │   │   │       ├── button.tsx
│   │   │   │   │       ├── code-snippet.tsx
│   │   │   │   │       ├── header.tsx
│   │   │   │   │       ├── icon.tsx
│   │   │   │   │       ├── icons
│   │   │   │   │       │   └── sentry.tsx
│   │   │   │   │       ├── interactive-markdown.tsx
│   │   │   │   │       ├── json-schema-params.tsx
│   │   │   │   │       ├── markdown.tsx
│   │   │   │   │       ├── note.tsx
│   │   │   │   │       ├── prose.tsx
│   │   │   │   │       ├── section.tsx
│   │   │   │   │       ├── slash-command-actions.tsx
│   │   │   │   │       ├── slash-command-text.tsx
│   │   │   │   │       ├── sliding-panel.tsx
│   │   │   │   │       ├── template-vars.tsx
│   │   │   │   │       ├── tool-actions.tsx
│   │   │   │   │       └── typewriter.tsx
│   │   │   │   ├── contexts
│   │   │   │   │   └── auth-context.tsx
│   │   │   │   ├── hooks
│   │   │   │   │   ├── use-mcp-metadata.ts
│   │   │   │   │   ├── use-persisted-chat.ts
│   │   │   │   │   ├── use-scroll-lock.ts
│   │   │   │   │   └── use-streaming-simulation.ts
│   │   │   │   ├── index.css
│   │   │   │   ├── instrument.ts
│   │   │   │   ├── lib
│   │   │   │   │   └── utils.ts
│   │   │   │   ├── main.tsx
│   │   │   │   ├── pages
│   │   │   │   │   └── home.tsx
│   │   │   │   ├── utils
│   │   │   │   │   ├── chat-error-handler.ts
│   │   │   │   │   └── index.ts
│   │   │   │   └── vite-env.d.ts
│   │   │   ├── constants.ts
│   │   │   ├── server
│   │   │   │   ├── app.test.ts
│   │   │   │   ├── app.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── lib
│   │   │   │   │   ├── approval-dialog.test.ts
│   │   │   │   │   ├── approval-dialog.ts
│   │   │   │   │   ├── constraint-utils.test.ts
│   │   │   │   │   ├── constraint-utils.ts
│   │   │   │   │   ├── html-utils.ts
│   │   │   │   │   ├── mcp-agent.ts
│   │   │   │   │   ├── slug-validation.test.ts
│   │   │   │   │   └── slug-validation.ts
│   │   │   │   ├── logging.ts
│   │   │   │   ├── oauth
│   │   │   │   │   ├── authorize.test.ts
│   │   │   │   │   ├── callback.test.ts
│   │   │   │   │   ├── constants.ts
│   │   │   │   │   ├── helpers.test.ts
│   │   │   │   │   ├── helpers.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── routes
│   │   │   │   │   │   ├── authorize.ts
│   │   │   │   │   │   ├── callback.ts
│   │   │   │   │   │   └── index.ts
│   │   │   │   │   └── state.ts
│   │   │   │   ├── routes
│   │   │   │   │   ├── chat-oauth.ts
│   │   │   │   │   ├── chat.ts
│   │   │   │   │   ├── mcp.ts
│   │   │   │   │   ├── metadata.ts
│   │   │   │   │   ├── search.test.ts
│   │   │   │   │   └── search.ts
│   │   │   │   ├── sentry.config.ts
│   │   │   │   ├── types
│   │   │   │   │   └── chat.ts
│   │   │   │   ├── types.ts
│   │   │   │   └── utils
│   │   │   │       └── auth-errors.ts
│   │   │   └── test-setup.ts
│   │   ├── tsconfig.client.json
│   │   ├── tsconfig.json
│   │   ├── tsconfig.node.json
│   │   ├── tsconfig.server.json
│   │   ├── vite.config.ts
│   │   ├── vitest.config.ts
│   │   ├── worker-configuration.d.ts
│   │   ├── wrangler.canary.jsonc
│   │   └── wrangler.jsonc
│   ├── mcp-server
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── scripts
│   │   │   ├── generate-definitions.ts
│   │   │   └── generate-otel-namespaces.ts
│   │   ├── src
│   │   │   ├── api-client
│   │   │   │   ├── client.test.ts
│   │   │   │   ├── client.ts
│   │   │   │   ├── errors.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── schema.ts
│   │   │   │   └── types.ts
│   │   │   ├── cli
│   │   │   │   ├── parse.test.ts
│   │   │   │   ├── parse.ts
│   │   │   │   ├── resolve.test.ts
│   │   │   │   ├── resolve.ts
│   │   │   │   ├── types.ts
│   │   │   │   └── usage.ts
│   │   │   ├── constants.ts
│   │   │   ├── errors.test.ts
│   │   │   ├── errors.ts
│   │   │   ├── index.ts
│   │   │   ├── internal
│   │   │   │   ├── agents
│   │   │   │   │   ├── callEmbeddedAgent.ts
│   │   │   │   │   ├── openai-provider.ts
│   │   │   │   │   └── tools
│   │   │   │   │       ├── data
│   │   │   │   │       │   ├── __namespaces.json
│   │   │   │   │       │   ├── android.json
│   │   │   │   │       │   ├── app.json
│   │   │   │   │       │   ├── artifact.json
│   │   │   │   │       │   ├── aspnetcore.json
│   │   │   │   │       │   ├── aws.json
│   │   │   │   │       │   ├── azure.json
│   │   │   │   │       │   ├── browser.json
│   │   │   │   │       │   ├── cassandra.json
│   │   │   │   │       │   ├── cicd.json
│   │   │   │   │       │   ├── CLAUDE.md
│   │   │   │   │       │   ├── client.json
│   │   │   │   │       │   ├── cloud.json
│   │   │   │   │       │   ├── cloudevents.json
│   │   │   │   │       │   ├── cloudfoundry.json
│   │   │   │   │       │   ├── code.json
│   │   │   │   │       │   ├── container.json
│   │   │   │   │       │   ├── cpu.json
│   │   │   │   │       │   ├── cpython.json
│   │   │   │   │       │   ├── database.json
│   │   │   │   │       │   ├── db.json
│   │   │   │   │       │   ├── deployment.json
│   │   │   │   │       │   ├── destination.json
│   │   │   │   │       │   ├── device.json
│   │   │   │   │       │   ├── disk.json
│   │   │   │   │       │   ├── dns.json
│   │   │   │   │       │   ├── dotnet.json
│   │   │   │   │       │   ├── elasticsearch.json
│   │   │   │   │       │   ├── enduser.json
│   │   │   │   │       │   ├── error.json
│   │   │   │   │       │   ├── faas.json
│   │   │   │   │       │   ├── feature_flags.json
│   │   │   │   │       │   ├── file.json
│   │   │   │   │       │   ├── gcp.json
│   │   │   │   │       │   ├── gen_ai.json
│   │   │   │   │       │   ├── geo.json
│   │   │   │   │       │   ├── go.json
│   │   │   │   │       │   ├── graphql.json
│   │   │   │   │       │   ├── hardware.json
│   │   │   │   │       │   ├── heroku.json
│   │   │   │   │       │   ├── host.json
│   │   │   │   │       │   ├── http.json
│   │   │   │   │       │   ├── ios.json
│   │   │   │   │       │   ├── jvm.json
│   │   │   │   │       │   ├── k8s.json
│   │   │   │   │       │   ├── linux.json
│   │   │   │   │       │   ├── log.json
│   │   │   │   │       │   ├── mcp.json
│   │   │   │   │       │   ├── messaging.json
│   │   │   │   │       │   ├── network.json
│   │   │   │   │       │   ├── nodejs.json
│   │   │   │   │       │   ├── oci.json
│   │   │   │   │       │   ├── opentracing.json
│   │   │   │   │       │   ├── os.json
│   │   │   │   │       │   ├── otel.json
│   │   │   │   │       │   ├── peer.json
│   │   │   │   │       │   ├── process.json
│   │   │   │   │       │   ├── profile.json
│   │   │   │   │       │   ├── rpc.json
│   │   │   │   │       │   ├── server.json
│   │   │   │   │       │   ├── service.json
│   │   │   │   │       │   ├── session.json
│   │   │   │   │       │   ├── signalr.json
│   │   │   │   │       │   ├── source.json
│   │   │   │   │       │   ├── system.json
│   │   │   │   │       │   ├── telemetry.json
│   │   │   │   │       │   ├── test.json
│   │   │   │   │       │   ├── thread.json
│   │   │   │   │       │   ├── tls.json
│   │   │   │   │       │   ├── url.json
│   │   │   │   │       │   ├── user.json
│   │   │   │   │       │   ├── v8js.json
│   │   │   │   │       │   ├── vcs.json
│   │   │   │   │       │   ├── webengine.json
│   │   │   │   │       │   └── zos.json
│   │   │   │   │       ├── dataset-fields.test.ts
│   │   │   │   │       ├── dataset-fields.ts
│   │   │   │   │       ├── otel-semantics.test.ts
│   │   │   │   │       ├── otel-semantics.ts
│   │   │   │   │       ├── utils.ts
│   │   │   │   │       ├── whoami.test.ts
│   │   │   │   │       └── whoami.ts
│   │   │   │   ├── constraint-helpers.test.ts
│   │   │   │   ├── constraint-helpers.ts
│   │   │   │   ├── error-handling.ts
│   │   │   │   ├── fetch-utils.test.ts
│   │   │   │   ├── fetch-utils.ts
│   │   │   │   ├── formatting.test.ts
│   │   │   │   ├── formatting.ts
│   │   │   │   ├── issue-helpers.test.ts
│   │   │   │   ├── issue-helpers.ts
│   │   │   │   ├── test-fixtures.ts
│   │   │   │   └── tool-helpers
│   │   │   │       ├── api.test.ts
│   │   │   │       ├── api.ts
│   │   │   │       ├── define.ts
│   │   │   │       ├── enhance-error.ts
│   │   │   │       ├── formatting.ts
│   │   │   │       ├── issue.ts
│   │   │   │       ├── seer.test.ts
│   │   │   │       ├── seer.ts
│   │   │   │       ├── validate-region-url.test.ts
│   │   │   │       └── validate-region-url.ts
│   │   │   ├── permissions.parseScopes.test.ts
│   │   │   ├── permissions.ts
│   │   │   ├── schema.ts
│   │   │   ├── server.ts
│   │   │   ├── telem
│   │   │   │   ├── index.ts
│   │   │   │   ├── logging.ts
│   │   │   │   ├── sentry.test.ts
│   │   │   │   └── sentry.ts
│   │   │   ├── test-setup.ts
│   │   │   ├── test-utils
│   │   │   │   └── context.ts
│   │   │   ├── toolDefinitions.ts
│   │   │   ├── tools
│   │   │   │   ├── analyze-issue-with-seer.test.ts
│   │   │   │   ├── analyze-issue-with-seer.ts
│   │   │   │   ├── create-dsn.test.ts
│   │   │   │   ├── create-dsn.ts
│   │   │   │   ├── create-project.test.ts
│   │   │   │   ├── create-project.ts
│   │   │   │   ├── create-team.test.ts
│   │   │   │   ├── create-team.ts
│   │   │   │   ├── find-dsns.test.ts
│   │   │   │   ├── find-dsns.ts
│   │   │   │   ├── find-organizations.test.ts
│   │   │   │   ├── find-organizations.ts
│   │   │   │   ├── find-projects.test.ts
│   │   │   │   ├── find-projects.ts
│   │   │   │   ├── find-releases.test.ts
│   │   │   │   ├── find-releases.ts
│   │   │   │   ├── find-teams.test.ts
│   │   │   │   ├── find-teams.ts
│   │   │   │   ├── get-doc.test.ts
│   │   │   │   ├── get-doc.ts
│   │   │   │   ├── get-event-attachment.test.ts
│   │   │   │   ├── get-event-attachment.ts
│   │   │   │   ├── get-issue-details.test.ts
│   │   │   │   ├── get-issue-details.ts
│   │   │   │   ├── get-trace-details.test.ts
│   │   │   │   ├── get-trace-details.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── search-docs.test.ts
│   │   │   │   ├── search-docs.ts
│   │   │   │   ├── search-events
│   │   │   │   │   ├── agent.ts
│   │   │   │   │   ├── CLAUDE.md
│   │   │   │   │   ├── config.ts
│   │   │   │   │   ├── formatters.ts
│   │   │   │   │   ├── handler.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── utils.test.ts
│   │   │   │   │   └── utils.ts
│   │   │   │   ├── search-events.test.ts
│   │   │   │   ├── search-issues
│   │   │   │   │   ├── agent.ts
│   │   │   │   │   ├── CLAUDE.md
│   │   │   │   │   ├── config.ts
│   │   │   │   │   ├── formatters.ts
│   │   │   │   │   ├── handler.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── README.md
│   │   │   │   ├── tools.test.ts
│   │   │   │   ├── types.ts
│   │   │   │   ├── update-issue.test.ts
│   │   │   │   ├── update-issue.ts
│   │   │   │   ├── update-project.test.ts
│   │   │   │   ├── update-project.ts
│   │   │   │   ├── whoami.test.ts
│   │   │   │   └── whoami.ts
│   │   │   ├── transports
│   │   │   │   └── stdio.ts
│   │   │   ├── types.ts
│   │   │   ├── utils
│   │   │   │   ├── slug-validation.test.ts
│   │   │   │   ├── slug-validation.ts
│   │   │   │   ├── url-utils.test.ts
│   │   │   │   └── url-utils.ts
│   │   │   └── version.ts
│   │   ├── tsconfig.json
│   │   ├── tsdown.config.ts
│   │   └── vitest.config.ts
│   ├── mcp-server-evals
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── bin
│   │   │   │   └── start-mock-stdio.ts
│   │   │   ├── evals
│   │   │   │   ├── autofix.eval.ts
│   │   │   │   ├── create-dsn.eval.ts
│   │   │   │   ├── create-project.eval.ts
│   │   │   │   ├── create-team.eval.ts
│   │   │   │   ├── get-issue.eval.ts
│   │   │   │   ├── get-trace-details.eval.ts
│   │   │   │   ├── list-dsns.eval.ts
│   │   │   │   ├── list-issues.eval.ts
│   │   │   │   ├── list-organizations.eval.ts
│   │   │   │   ├── list-projects.eval.ts
│   │   │   │   ├── list-releases.eval.ts
│   │   │   │   ├── list-tags.eval.ts
│   │   │   │   ├── list-teams.eval.ts
│   │   │   │   ├── search-docs.eval.ts
│   │   │   │   ├── search-events-agent.eval.ts
│   │   │   │   ├── search-events.eval.ts
│   │   │   │   ├── search-issues-agent.eval.ts
│   │   │   │   ├── search-issues.eval.ts
│   │   │   │   ├── update-issue.eval.ts
│   │   │   │   ├── update-project.eval.ts
│   │   │   │   └── utils
│   │   │   │       ├── fixtures.ts
│   │   │   │       ├── index.ts
│   │   │   │       ├── runner.ts
│   │   │   │       ├── structuredOutputScorer.ts
│   │   │   │       └── toolPredictionScorer.ts
│   │   │   └── setup-env.ts
│   │   ├── tsconfig.json
│   │   └── vitest.config.ts
│   ├── mcp-server-mocks
│   │   ├── package.json
│   │   ├── src
│   │   │   ├── fixtures
│   │   │   │   ├── autofix-state.json
│   │   │   │   ├── event-attachments.json
│   │   │   │   ├── event.json
│   │   │   │   ├── issue.json
│   │   │   │   ├── performance-event.json
│   │   │   │   ├── project.json
│   │   │   │   ├── tags.json
│   │   │   │   ├── team.json
│   │   │   │   ├── trace-event.json
│   │   │   │   ├── trace-items-attributes-logs-number.json
│   │   │   │   ├── trace-items-attributes-logs-string.json
│   │   │   │   ├── trace-items-attributes-spans-number.json
│   │   │   │   ├── trace-items-attributes-spans-string.json
│   │   │   │   ├── trace-items-attributes.json
│   │   │   │   ├── trace-meta-with-nulls.json
│   │   │   │   ├── trace-meta.json
│   │   │   │   ├── trace-mixed.json
│   │   │   │   └── trace.json
│   │   │   ├── index.ts
│   │   │   └── utils.ts
│   │   ├── tsconfig.json
│   │   └── tsdown.config.ts
│   ├── mcp-server-tsconfig
│   │   ├── package.json
│   │   ├── tsconfig.base.json
│   │   └── tsconfig.vite.json
│   ├── mcp-test-client
│   │   ├── .env.test
│   │   ├── .gitignore
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── src
│   │   │   ├── agent.ts
│   │   │   ├── auth
│   │   │   │   ├── config.ts
│   │   │   │   └── oauth.ts
│   │   │   ├── constants.ts
│   │   │   ├── index.ts
│   │   │   ├── logger.test.ts
│   │   │   ├── logger.ts
│   │   │   ├── mcp-test-client-remote.ts
│   │   │   ├── mcp-test-client.ts
│   │   │   ├── types.ts
│   │   │   └── version.ts
│   │   ├── tsconfig.json
│   │   ├── tsdown.config.ts
│   │   └── vitest.config.ts
│   └── smoke-tests
│       ├── package.json
│       ├── src
│       │   └── smoke.test.ts
│       └── vitest.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── scripts
│   └── check-doc-links.mjs
├── turbo.json
└── vitest.workspace.ts
```

# Files

--------------------------------------------------------------------------------
/packages/smoke-tests/src/smoke.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, beforeAll } from "vitest";
  2 | 
  3 | const PREVIEW_URL = process.env.PREVIEW_URL;
  4 | // All endpoints should respond quickly - 1 second is plenty for 401/200 responses
  5 | const DEFAULT_TIMEOUT_MS = 1000;
  6 | const IS_LOCAL_DEV =
  7 |   PREVIEW_URL?.includes("localhost") || PREVIEW_URL?.includes("127.0.0.1");
  8 | 
  9 | // Skip all smoke tests if PREVIEW_URL is not set
 10 | const describeIfPreviewUrl = PREVIEW_URL ? describe : describe.skip;
 11 | 
 12 | /**
 13 |  * Unified fetch wrapper with proper cleanup for all response types.
 14 |  *
 15 |  * @param url - The URL to fetch
 16 |  * @param options - Fetch options with additional helpers
 17 |  * @param options.consumeBody - Whether to read the response body (default: true)
 18 |  *                               Set to false for SSE or when you only need status/headers
 19 |  * @param options.timeoutMs - Timeout in milliseconds (default: DEFAULT_TIMEOUT_MS)
 20 |  *
 21 |  * NOTE: Workerd connection errors (kj/compat/http.c++:1993) are caused by
 22 |  * the agents library's McpAgent server-side implementation, NOT our client code.
 23 |  * These errors are expected during development and don't affect test reliability.
 24 |  */
 25 | async function safeFetch(
 26 |   url: string,
 27 |   options: RequestInit & {
 28 |     timeoutMs?: number;
 29 |     consumeBody?: boolean;
 30 |   } = {},
 31 | ): Promise<{
 32 |   response: Response;
 33 |   data: any;
 34 | }> {
 35 |   const {
 36 |     timeoutMs = DEFAULT_TIMEOUT_MS,
 37 |     consumeBody = true,
 38 |     ...fetchOptions
 39 |   } = options;
 40 | 
 41 |   // Create an AbortController for cleanup
 42 |   const controller = new AbortController();
 43 |   const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
 44 | 
 45 |   // Merge any existing signal with our controller
 46 |   const signal = fetchOptions.signal || controller.signal;
 47 | 
 48 |   let response: Response;
 49 |   let data: any = null;
 50 | 
 51 |   try {
 52 |     response = await fetch(url, {
 53 |       ...fetchOptions,
 54 |       signal,
 55 |     });
 56 | 
 57 |     // Only consume body if requested (not for SSE streams)
 58 |     if (consumeBody) {
 59 |       const contentType = response.headers.get("content-type") || "";
 60 | 
 61 |       try {
 62 |         if (contentType.includes("application/json")) {
 63 |           data = await response.json();
 64 |         } else if (contentType.includes("text/event-stream")) {
 65 |           // Don't consume SSE streams
 66 |           data = null;
 67 |         } else {
 68 |           data = await response.text();
 69 |         }
 70 |       } catch (error) {
 71 |         // If we can't read the body, log but don't fail
 72 |         console.warn(`Failed to read response body from ${url}:`, error);
 73 |         data = null;
 74 |       }
 75 |     }
 76 |   } finally {
 77 |     clearTimeout(timeoutId);
 78 | 
 79 |     // Always clean up: if body wasn't consumed and exists, cancel it
 80 |     if (!consumeBody && response?.body && !response.bodyUsed) {
 81 |       try {
 82 |         await response.body.cancel();
 83 |       } catch {
 84 |         // Ignore cancel errors
 85 |       }
 86 |     }
 87 |   }
 88 | 
 89 |   return { response: response!, data };
 90 | }
 91 | 
 92 | describeIfPreviewUrl(
 93 |   `Smoke Tests for ${PREVIEW_URL || "(no PREVIEW_URL set)"}`,
 94 |   () => {
 95 |     beforeAll(async () => {
 96 |       console.log(`🔍 Running smoke tests against: ${PREVIEW_URL}`);
 97 |       // TODO: Add back workerd warmup if needed for SSE tests
 98 |       // Currently disabled to keep tests under 15s timeout
 99 |     });
100 | 
101 |     it("should respond on root endpoint", async () => {
102 |       const { response } = await safeFetch(PREVIEW_URL);
103 |       expect(response.status).toBe(200);
104 |     });
105 | 
106 |     it("should have MCP endpoint that returns server info (with auth error)", async () => {
107 |       const { response, data } = await safeFetch(`${PREVIEW_URL}/mcp`, {
108 |         method: "POST",
109 |         headers: { "Content-Type": "application/json" },
110 |         body: JSON.stringify({
111 |           jsonrpc: "2.0",
112 |           method: "initialize",
113 |           params: {
114 |             protocolVersion: "2024-11-05",
115 |             capabilities: {},
116 |             clientInfo: {
117 |               name: "smoke-test",
118 |               version: "1.0.0",
119 |             },
120 |           },
121 |           id: 1,
122 |         }),
123 |       });
124 | 
125 |       expect(response.status).toBe(401);
126 | 
127 |       // Should return auth error, not 404 - this proves the MCP endpoint exists
128 |       if (data) {
129 |         expect(data).toHaveProperty("error");
130 |         expect(data.error).toMatch(/invalid_token|unauthorized/i);
131 |       }
132 |     });
133 | 
134 |     it("should have SSE endpoint for MCP transport", async () => {
135 |       /*
136 |        * SSE endpoints are simple:
137 |        * - No auth = 401 Unauthorized
138 |        * - Valid auth = 200 OK + stream starts
139 |        *
140 |        * For smoke tests (no auth), we expect 401 with error details.
141 |        * This proves the endpoint exists and auth is working.
142 |        */
143 | 
144 |       // For SSE, we don't want to consume the stream body
145 |       const { response, data } = await safeFetch(`${PREVIEW_URL}/sse`, {
146 |         headers: {
147 |           Accept: "text/event-stream",
148 |         },
149 |         consumeBody: false, // Don't try to read SSE stream
150 |       });
151 | 
152 |       console.log(`📡 SSE test result: status=${response.status}`);
153 | 
154 |       // Should return 401 since we're not providing auth
155 |       expect(response.status).toBe(401);
156 | 
157 |       // For 401 responses, we need to manually read the error since we set consumeBody: false
158 |       if (response.status === 401) {
159 |         try {
160 |           const errorData = await response.json();
161 |           console.log(
162 |             `📡 SSE error: ${JSON.stringify(errorData).substring(0, 100)}...`,
163 |           );
164 |           expect(errorData).toHaveProperty("error");
165 |           expect(errorData.error).toMatch(/invalid_token|unauthorized/i);
166 |         } catch {
167 |           // Body might already be consumed or not JSON
168 |           console.log("📡 SSE returned non-JSON 401 response");
169 |         }
170 |       }
171 |     });
172 | 
173 |     it("should have metadata endpoint that requires auth", async () => {
174 |       try {
175 |         const { response, data } = await safeFetch(
176 |           `${PREVIEW_URL}/api/metadata`,
177 |         );
178 | 
179 |         expect(response.status).toBe(401);
180 | 
181 |         // Verify it returns proper error structure
182 |         if (data && typeof data === "object") {
183 |           expect(data).toHaveProperty("error");
184 |         }
185 |       } catch (error: any) {
186 |         // If we timeout, that's acceptable - the endpoint exists but is slow
187 |         if (error.name === "TimeoutError" || error.name === "AbortError") {
188 |           // The timeout fired, but the endpoint exists (would 404 if not)
189 |           console.warn("Metadata endpoint timed out (expected in dev)");
190 |           return;
191 |         }
192 |         throw error;
193 |       }
194 |     });
195 | 
196 |     it("should have MCP endpoint with org constraint (/mcp/sentry)", async () => {
197 |       // Retry logic for potential Durable Object initialization
198 |       let response: Response;
199 |       let retries = 5;
200 | 
201 |       while (retries > 0) {
202 |         const { response: fetchResponse, data } = await safeFetch(
203 |           `${PREVIEW_URL}/mcp/sentry`,
204 |           {
205 |             method: "POST",
206 |             headers: { "Content-Type": "application/json" },
207 |             body: JSON.stringify({
208 |               jsonrpc: "2.0",
209 |               method: "initialize",
210 |               params: {
211 |                 protocolVersion: "2024-11-05",
212 |                 capabilities: {},
213 |                 clientInfo: {
214 |                   name: "smoke-test",
215 |                   version: "1.0.0",
216 |                 },
217 |               },
218 |               id: 1,
219 |             }),
220 |           },
221 |         );
222 | 
223 |         response = fetchResponse;
224 | 
225 |         // If we get 503, retry after a delay
226 |         if (response.status === 503 && retries > 1) {
227 |           retries--;
228 |           await new Promise((resolve) => setTimeout(resolve, 2000));
229 |           continue;
230 |         }
231 | 
232 |         // Store data for later use
233 |         (response as any).testData = data;
234 |         break;
235 |       }
236 | 
237 |       expect(response.status).toBe(401);
238 | 
239 |       // Should return auth error, not 404 - this proves the constrained MCP endpoint exists
240 |       const data = (response as any).testData;
241 |       if (typeof data === "object") {
242 |         expect(data).toHaveProperty("error");
243 |         expect(data.error).toMatch(/invalid_token|unauthorized/i);
244 |       } else {
245 |         expect(data).toMatch(/invalid_token|unauthorized/i);
246 |       }
247 |     });
248 | 
249 |     it("should have MCP endpoint with org and project constraints (/mcp/sentry/mcp-server)", async () => {
250 |       // Retry logic for Durable Object initialization
251 |       let response: Response;
252 |       let retries = 5;
253 | 
254 |       while (retries > 0) {
255 |         const { response: fetchResponse, data } = await safeFetch(
256 |           `${PREVIEW_URL}/mcp/sentry/mcp-server`,
257 |           {
258 |             method: "POST",
259 |             headers: { "Content-Type": "application/json" },
260 |             body: JSON.stringify({
261 |               jsonrpc: "2.0",
262 |               method: "initialize",
263 |               params: {
264 |                 protocolVersion: "2024-11-05",
265 |                 capabilities: {},
266 |                 clientInfo: {
267 |                   name: "smoke-test",
268 |                   version: "1.0.0",
269 |                 },
270 |               },
271 |               id: 1,
272 |             }),
273 |           },
274 |         );
275 | 
276 |         response = fetchResponse;
277 | 
278 |         // If we get 503, it's Durable Object initialization - retry
279 |         if (response.status === 503 && retries > 1) {
280 |           retries--;
281 |           await new Promise((resolve) => setTimeout(resolve, 2000)); // Wait 2 seconds for DO to stabilize
282 |           continue;
283 |         }
284 | 
285 |         // Store data for later use
286 |         (response as any).testData = data;
287 |         break;
288 |       }
289 | 
290 |       expect(response.status).toBe(401);
291 | 
292 |       // Should return auth error, not 404 - this proves the fully constrained MCP endpoint exists
293 |       const data = (response as any).testData;
294 |       if (typeof data === "object") {
295 |         expect(data).toHaveProperty("error");
296 |         expect(data.error).toMatch(/invalid_token|unauthorized/i);
297 |       } else {
298 |         expect(data).toMatch(/invalid_token|unauthorized/i);
299 |       }
300 |     });
301 | 
302 |     it("should have chat endpoint that accepts POST", async () => {
303 |       // Chat endpoint might return 503 temporarily after DO operations
304 |       let response: Response;
305 |       let retries = 3;
306 | 
307 |       while (retries > 0) {
308 |         const { response: fetchResponse } = await safeFetch(
309 |           `${PREVIEW_URL}/api/chat`,
310 |           {
311 |             method: "POST",
312 |             headers: {
313 |               Origin: PREVIEW_URL, // Required for CSRF check
314 |             },
315 |           },
316 |         );
317 |         response = fetchResponse;
318 | 
319 |         // If we get 503, retry after a short delay
320 |         if (response.status === 503 && retries > 1) {
321 |           retries--;
322 |           await new Promise((resolve) => setTimeout(resolve, 1000));
323 |           continue;
324 |         }
325 |         break;
326 |       }
327 | 
328 |       // Should return 401 (unauthorized), 400 (bad request), or 500 (server error) for POST without auth
329 |       expect([400, 401, 500]).toContain(response.status);
330 |     });
331 | 
332 |     it("should have OAuth authorize endpoint", async () => {
333 |       const { response } = await safeFetch(`${PREVIEW_URL}/oauth/authorize`, {
334 |         redirect: "manual", // Don't follow redirects
335 |       });
336 |       // Should return 200, 302 (redirect), or 400 (bad request)
337 |       expect([200, 302, 400]).toContain(response.status);
338 |     });
339 | 
340 |     it("should serve robots.txt", async () => {
341 |       const { response, data } = await safeFetch(
342 |         `${PREVIEW_URL}/robots.txt`,
343 |         {},
344 |       );
345 |       expect(response.status).toBe(200);
346 | 
347 |       expect(data).toContain("User-agent");
348 |     });
349 | 
350 |     it("should serve llms.txt with MCP info", async () => {
351 |       const { response, data } = await safeFetch(`${PREVIEW_URL}/llms.txt`, {});
352 |       expect(response.status).toBe(200);
353 | 
354 |       expect(data).toContain("sentry-mcp");
355 |       expect(data).toContain("Model Context Protocol");
356 |       expect(data).toContain("/mcp");
357 |     });
358 | 
359 |     it("should serve /.well-known/oauth-authorization-server with CORS headers", async () => {
360 |       const { response, data } = await safeFetch(
361 |         `${PREVIEW_URL}/.well-known/oauth-authorization-server`,
362 |         {
363 |           headers: {
364 |             Origin: "http://localhost:6274", // MCP inspector origin
365 |           },
366 |         },
367 |       );
368 |       expect(response.status).toBe(200);
369 | 
370 |       // Should have CORS headers for cross-origin access
371 |       expect(response.headers.get("access-control-allow-origin")).toBe("*");
372 |       expect(response.headers.get("access-control-allow-methods")).toBe(
373 |         "GET, OPTIONS",
374 |       );
375 |       expect(response.headers.get("access-control-allow-headers")).toBe(
376 |         "Content-Type",
377 |       );
378 | 
379 |       // Should return valid OAuth server metadata
380 |       expect(data).toHaveProperty("issuer");
381 |       expect(data).toHaveProperty("authorization_endpoint");
382 |       expect(data).toHaveProperty("token_endpoint");
383 |     });
384 | 
385 |     it("should handle CORS preflight for /.well-known/oauth-authorization-server", async () => {
386 |       const { response } = await safeFetch(
387 |         `${PREVIEW_URL}/.well-known/oauth-authorization-server`,
388 |         {
389 |           method: "OPTIONS",
390 |           headers: {
391 |             Origin: "http://localhost:6274",
392 |             "Access-Control-Request-Method": "GET",
393 |           },
394 |         },
395 |       );
396 | 
397 |       // Should return 204 No Content for preflight
398 |       expect(response.status).toBe(204);
399 | 
400 |       // Should have CORS headers
401 |       const allowOrigin = response.headers.get("access-control-allow-origin");
402 |       // In dev, Vite echoes the origin; in production, we set "*"
403 |       expect(
404 |         allowOrigin === "*" || allowOrigin === "http://localhost:6274",
405 |       ).toBe(true);
406 | 
407 |       const allowMethods = response.headers.get("access-control-allow-methods");
408 |       // Should include at least GET
409 |       expect(allowMethods).toContain("GET");
410 |     });
411 | 
412 |     it("should respond quickly (under 2 seconds)", async () => {
413 |       const start = Date.now();
414 |       const { response } = await safeFetch(PREVIEW_URL);
415 |       const duration = Date.now() - start;
416 | 
417 |       expect(response.status).toBe(200);
418 |       expect(duration).toBeLessThan(2000);
419 |     });
420 | 
421 |     it("should have proper security headers", async () => {
422 |       const { response } = await safeFetch(PREVIEW_URL);
423 | 
424 |       // Check security headers - some might be set by Cloudflare instead of Hono
425 |       // So we check if they exist rather than exact values
426 |       const frameOptions = response.headers.get("x-frame-options");
427 |       const contentTypeOptions = response.headers.get("x-content-type-options");
428 | 
429 |       // Either the header is set by our app or by Cloudflare
430 |       expect(
431 |         frameOptions === "DENY" ||
432 |           frameOptions === "SAMEORIGIN" ||
433 |           frameOptions === null,
434 |       ).toBe(true);
435 |       expect(
436 |         contentTypeOptions === "nosniff" || contentTypeOptions === null,
437 |       ).toBe(true);
438 |     });
439 |   },
440 | );
441 | 
```

--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx:
--------------------------------------------------------------------------------

```typescript
  1 | import { Accordion } from "../ui/accordion";
  2 | import { Heading, Link } from "../ui/base";
  3 | import CodeSnippet from "../ui/code-snippet";
  4 | import SetupGuide from "./setup-guide";
  5 | import { NPM_PACKAGE_NAME, SCOPES } from "../../../constants";
  6 | import { Prose } from "../ui/prose";
  7 | 
  8 | const mcpServerName = import.meta.env.DEV ? "sentry-dev" : "sentry";
  9 | 
 10 | export default function StdioSetup() {
 11 |   const mcpStdioSnippet = `npx ${NPM_PACKAGE_NAME}@latest`;
 12 | 
 13 |   const defaultEnv = {
 14 |     SENTRY_ACCESS_TOKEN: "sentry-user-token",
 15 |     OPENAI_API_KEY: "your-openai-key", // Required for AI-powered search tools
 16 |   } as const;
 17 | 
 18 |   const coreConfig = {
 19 |     command: "npx",
 20 |     args: ["@sentry/mcp-server@latest"],
 21 |     env: defaultEnv,
 22 |   };
 23 | 
 24 |   const codexConfigToml = [
 25 |     "[mcp_servers.sentry]",
 26 |     'command = "npx"',
 27 |     'args = ["@sentry/mcp-server@latest"]',
 28 |     'env = { SENTRY_ACCESS_TOKEN = "sentry-user-token", OPENAI_API_KEY = "your-openai-key" }',
 29 |   ].join("\n");
 30 | 
 31 |   const selfHostedHostExample = [
 32 |     `${mcpStdioSnippet}`,
 33 |     "--access-token=sentry-user-token",
 34 |     "--host=sentry.example.com",
 35 |   ].join(" \\\n  ");
 36 | 
 37 |   const selfHostedEnvLine =
 38 |     'env = { SENTRY_ACCESS_TOKEN = "sentry-user-token", SENTRY_HOST = "sentry.example.com", OPENAI_API_KEY = "your-openai-key" }';
 39 | 
 40 |   return (
 41 |     <>
 42 |       <Prose className="mb-6">
 43 |         <p>
 44 |           The stdio client is made available on npm at{" "}
 45 |           <Link href={`https://www.npmjs.com/package/${NPM_PACKAGE_NAME}`}>
 46 |             {NPM_PACKAGE_NAME}
 47 |           </Link>
 48 |           .
 49 |         </p>
 50 |         <p>
 51 |           <strong>Note:</strong> The MCP is developed against the cloud service
 52 |           of Sentry. If you are self-hosting Sentry you may find some tool calls
 53 |           are either using outdated APIs, or otherwise using APIs not available
 54 |           in self-hosted.
 55 |         </p>
 56 | 
 57 |         <p>
 58 |           The CLI targets Sentry's hosted service by default. Add host overrides
 59 |           only when you run self-hosted Sentry.
 60 |         </p>
 61 | 
 62 |         <p>
 63 |           Create a User Auth Token in your account settings with the following
 64 |           scopes:
 65 |         </p>
 66 |         <ul>
 67 |           {Object.entries(SCOPES).map(([scope, description]) => (
 68 |             <li key={scope}>
 69 |               <strong>{scope}</strong> - {description}
 70 |             </li>
 71 |           ))}
 72 |         </ul>
 73 |         <p>Now wire up that token to the MCP configuration:</p>
 74 |         <CodeSnippet
 75 |           snippet={[
 76 |             `${mcpStdioSnippet}`,
 77 |             "--access-token=sentry-user-token",
 78 |           ].join(" \\\n  ")}
 79 |         />
 80 |         <div className="mt-6">
 81 |           <h4 className="text-base font-semibold text-slate-100">
 82 |             Using with Self-Hosted Sentry
 83 |           </h4>
 84 |           <p>
 85 |             You'll need to provide the hostname of your self-hosted Sentry
 86 |             instance:
 87 |           </p>
 88 |           <CodeSnippet snippet={selfHostedHostExample} />
 89 |         </div>
 90 | 
 91 |         <h4 className="mb-6 text-lg font-semibold text-slate-100">
 92 |           Configuration
 93 |         </h4>
 94 | 
 95 |         <div className="mt-6 space-y-6 text-sm text-slate-200">
 96 |           <section>
 97 |             <h5 className="font-semibold uppercase tracking-wide text-slate-300 text-xs">
 98 |               Core setup
 99 |             </h5>
100 |             <dl className="mt-3 space-y-2">
101 |               <dt className="font-medium text-slate-100">
102 |                 <code>--access-token</code> / <code>SENTRY_ACCESS_TOKEN</code>
103 |               </dt>
104 |               <dd className="text-slate-300">Required user auth token.</dd>
105 | 
106 |               <dt className="font-medium text-slate-100">
107 |                 <code>--host</code> / <code>SENTRY_HOST</code>
108 |               </dt>
109 |               <dd className="text-slate-300">
110 |                 Hostname override when you run self-hosted Sentry.
111 |               </dd>
112 | 
113 |               <dt className="font-medium text-slate-100">
114 |                 <code>--sentry-dsn</code> / <code>SENTRY_DSN</code>
115 |               </dt>
116 |               <dd className="text-slate-300">
117 |                 Send telemetry elsewhere or disable it by passing an empty
118 |                 value.
119 |               </dd>
120 |             </dl>
121 |           </section>
122 | 
123 |           <section>
124 |             <h5 className="font-semibold uppercase tracking-wide text-slate-300 text-xs">
125 |               Constraints
126 |             </h5>
127 |             <dl className="mt-3 space-y-2">
128 |               <dt className="font-medium text-slate-100">
129 |                 <code>--organization-slug</code>
130 |               </dt>
131 |               <dd className="text-slate-300">
132 |                 Scope all tools to a single organization (CLI only).
133 |               </dd>
134 | 
135 |               <dt className="font-medium text-slate-100">
136 |                 <code>--project-slug</code>
137 |               </dt>
138 |               <dd className="text-slate-300">
139 |                 Scope all tools to a specific project within that organization
140 |                 (CLI only).
141 |               </dd>
142 |             </dl>
143 |           </section>
144 | 
145 |           <section>
146 |             <h5 className="font-semibold uppercase tracking-wide text-slate-300 text-xs">
147 |               Permissions
148 |             </h5>
149 |             <dl className="mt-3 space-y-2">
150 |               <dt className="font-medium text-slate-100">
151 |                 <code>--all-scopes</code>
152 |               </dt>
153 |               <dd className="text-slate-300">
154 |                 Expand the token to the full permission set for every tool.
155 |               </dd>
156 | 
157 |               <dt className="font-medium text-slate-100">
158 |                 <code>--scopes</code> / <code>MCP_SCOPES</code>
159 |               </dt>
160 |               <dd className="text-slate-300">
161 |                 Replace the default read-only scopes with an explicit list.
162 |               </dd>
163 | 
164 |               <dt className="font-medium text-slate-100">
165 |                 <code>--add-scopes</code> / <code>MCP_ADD_SCOPES</code>
166 |               </dt>
167 |               <dd className="text-slate-300">
168 |                 Keep the read-only defaults and layer on additional scopes.
169 |               </dd>
170 |             </dl>
171 |           </section>
172 |         </div>
173 |         <p className="mt-4 text-sm text-slate-300">
174 |           Need something else? Run{" "}
175 |           <code>npx @sentry/mcp-server@latest --help</code> to view the full
176 |           flag list.
177 |         </p>
178 |       </Prose>
179 |       <Heading as="h3">Integration Guides</Heading>
180 |       <Accordion type="single" collapsible>
181 |         <SetupGuide id="cursor" title="Cursor">
182 |           <ol>
183 |             <li>
184 |               Or manually: <strong>Cmd + Shift + J</strong> to open Cursor
185 |               Settings.
186 |             </li>
187 |             <li>
188 |               Select <strong>MCP Tools</strong>.
189 |             </li>
190 |             <li>
191 |               Select <strong>New MCP Server</strong>.
192 |             </li>
193 |             <li>
194 |               <CodeSnippet
195 |                 noMargin
196 |                 snippet={JSON.stringify(
197 |                   {
198 |                     mcpServers: {
199 |                       sentry: {
200 |                         ...coreConfig,
201 |                         env: {
202 |                           ...coreConfig.env,
203 |                         },
204 |                       },
205 |                     },
206 |                   },
207 |                   undefined,
208 |                   2,
209 |                 )}
210 |               />
211 |             </li>
212 |           </ol>
213 |         </SetupGuide>
214 | 
215 |         <SetupGuide id="claude-code" title="Claude Code">
216 |           <ol>
217 |             <li>Open your terminal to access the CLI.</li>
218 |             <li>
219 |               <CodeSnippet
220 |                 noMargin
221 |                 snippet={`claude mcp add sentry -e SENTRY_ACCESS_TOKEN=sentry-user-token -e OPENAI_API_KEY=your-openai-key -- ${mcpStdioSnippet}`}
222 |               />
223 |             </li>
224 |             <li>
225 |               Replace <code>sentry-user-token</code> with your actual User Auth
226 |               Token.
227 |             </li>
228 |             <li>
229 |               Connecting to self-hosted Sentry? Append
230 |               <code>-e SENTRY_HOST=your-hostname</code>.
231 |             </li>
232 |           </ol>
233 |           <p>
234 |             <small>
235 |               For more details, see the{" "}
236 |               <Link href="https://docs.anthropic.com/en/docs/claude-code/mcp">
237 |                 Claude Code MCP documentation
238 |               </Link>
239 |               .
240 |             </small>
241 |           </p>
242 |         </SetupGuide>
243 | 
244 |         <SetupGuide id="codex-cli" title="Codex">
245 |           <ol>
246 |             <li>
247 |               Edit <code>~/.codex/config.toml</code> and add the MCP server
248 |               configuration:
249 |               <CodeSnippet noMargin snippet={codexConfigToml} />
250 |             </li>
251 |             <li>
252 |               Replace <code>sentry-user-token</code> with your Sentry User Auth
253 |               Token. Add <code>SENTRY_HOST</code> if you run self-hosted Sentry.
254 |               <CodeSnippet noMargin snippet={selfHostedEnvLine} />
255 |             </li>
256 |             <li>
257 |               Restart any running <code>codex</code> session to load the new MCP
258 |               configuration.
259 |             </li>
260 |           </ol>
261 |         </SetupGuide>
262 | 
263 |         <SetupGuide id="windsurf" title="Windsurf">
264 |           <ol>
265 |             <li>Open Windsurf Settings.</li>
266 |             <li>
267 |               Under <strong>Cascade</strong>, you'll find{" "}
268 |               <strong>Model Context Protocol Servers</strong>.
269 |             </li>
270 |             <li>
271 |               Select <strong>Add Server</strong>.
272 |             </li>
273 |             <li>
274 |               <CodeSnippet
275 |                 noMargin
276 |                 snippet={JSON.stringify(
277 |                   {
278 |                     mcpServers: {
279 |                       sentry: {
280 |                         ...coreConfig,
281 |                         env: {
282 |                           ...coreConfig.env,
283 |                         },
284 |                       },
285 |                     },
286 |                   },
287 |                   undefined,
288 |                   2,
289 |                 )}
290 |               />
291 |             </li>
292 |           </ol>
293 |         </SetupGuide>
294 | 
295 |         <SetupGuide id="vscode" title="Visual Studio Code">
296 |           <ol>
297 |             <li>
298 |               <strong>CMD + P</strong> and search for{" "}
299 |               <strong>MCP: Add Server</strong>.
300 |             </li>
301 |             <li>
302 |               Select <strong>Command (stdio)</strong>
303 |             </li>
304 |             <li>
305 |               Enter the following configuration, and hit enter.
306 |               <CodeSnippet noMargin snippet={mcpStdioSnippet} />
307 |             </li>
308 |             <li>
309 |               Enter the name <strong>Sentry</strong> and hit enter.
310 |             </li>
311 |             <li>
312 |               Update the server configuration to include your configuration:
313 |               <CodeSnippet
314 |                 noMargin
315 |                 snippet={JSON.stringify(
316 |                   {
317 |                     [mcpServerName]: {
318 |                       type: "stdio",
319 |                       ...coreConfig,
320 |                       env: {
321 |                         ...coreConfig.env,
322 |                       },
323 |                     },
324 |                   },
325 |                   undefined,
326 |                   2,
327 |                 )}
328 |               />
329 |             </li>
330 |             <li>
331 |               Activate the server using <strong>MCP: List Servers</strong> and
332 |               selecting <strong>Sentry</strong>, and selecting{" "}
333 |               <strong>Start Server</strong>.
334 |             </li>
335 |           </ol>
336 |           <p>
337 |             <small>Note: MCP is supported in VSCode 1.99 and above.</small>
338 |           </p>
339 |         </SetupGuide>
340 | 
341 |         <SetupGuide id="warp" title="Warp">
342 |           <ol>
343 |             <li>
344 |               Open{" "}
345 |               <a
346 |                 href="https://warp.dev"
347 |                 target="_blank"
348 |                 rel="noopener noreferrer"
349 |               >
350 |                 Warp
351 |               </a>{" "}
352 |               and navigate to MCP server settings using one of these methods:
353 |               <ul>
354 |                 <li>
355 |                   From Warp Drive: <strong>Personal → MCP Servers</strong>
356 |                 </li>
357 |                 <li>
358 |                   From Command Palette: search for{" "}
359 |                   <strong>Open MCP Servers</strong>
360 |                 </li>
361 |                 <li>
362 |                   From Settings:{" "}
363 |                   <strong>Settings → AI → Manage MCP servers</strong>
364 |                 </li>
365 |               </ul>
366 |             </li>
367 |             <li>
368 |               Click <strong>+ Add</strong> button.
369 |             </li>
370 |             <li>
371 |               Select <strong>CLI Server (Command)</strong> option.
372 |             </li>
373 |             <li>
374 |               <CodeSnippet
375 |                 noMargin
376 |                 snippet={JSON.stringify(
377 |                   {
378 |                     Sentry: {
379 |                       ...coreConfig,
380 |                       env: {
381 |                         ...coreConfig.env,
382 |                       },
383 |                       working_directory: null,
384 |                     },
385 |                   },
386 |                   undefined,
387 |                   2,
388 |                 )}
389 |               />
390 |             </li>
391 |           </ol>
392 |           <p>
393 |             <small>
394 |               For more details, see the{" "}
395 |               <a
396 |                 href="https://docs.warp.dev/knowledge-and-collaboration/mcp"
397 |                 target="_blank"
398 |                 rel="noopener noreferrer"
399 |               >
400 |                 Warp MCP documentation
401 |               </a>
402 |               .
403 |             </small>
404 |           </p>
405 |         </SetupGuide>
406 | 
407 |         <SetupGuide id="zed" title="Zed">
408 |           <ol>
409 |             <li>
410 |               <strong>CMD + ,</strong> to open Zed settings.
411 |             </li>
412 |             <li>
413 |               <CodeSnippet
414 |                 noMargin
415 |                 snippet={JSON.stringify(
416 |                   {
417 |                     context_servers: {
418 |                       [mcpServerName]: {
419 |                         ...coreConfig,
420 |                         env: {
421 |                           ...coreConfig.env,
422 |                         },
423 |                       },
424 |                       settings: {},
425 |                     },
426 |                   },
427 |                   undefined,
428 |                   2,
429 |                 )}
430 |               />
431 |             </li>
432 |           </ol>
433 |         </SetupGuide>
434 |       </Accordion>
435 | 
436 |       <Heading as="h3">Troubleshooting Connectivity</Heading>
437 |       <Prose>
438 |         <p>
439 |           <strong>Having trouble connecting via the stdio client?</strong>
440 |           Start with these checks:
441 |         </p>
442 |         <ul>
443 |           <li>
444 |             <strong>401/403 errors:</strong> Verify your User Auth Token still
445 |             exists and includes the required scopes. Reissue the token if it was
446 |             rotated or downgraded.
447 |           </li>
448 |           <li>
449 |             <strong>404s for organizations or issues:</strong> Confirm the
450 |             <code>--organization-slug</code> / <code>--project-slug</code>
451 |             values and make sure the host matches your self-hosted Sentry
452 |             endpoint (e.g. <code>--host=sentry.example.com</code>).
453 |           </li>
454 |           <li>
455 |             <strong>TLS or network failures:</strong> Ensure you are using HTTPS
456 |             endpoints and that firewalls allow outbound traffic to your Sentry
457 |             instance.
458 |           </li>
459 |         </ul>
460 |       </Prose>
461 |     </>
462 |   );
463 | }
464 | 
```

--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/get-trace-details.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 { UserInputError } from "../errors";
  5 | import type { ServerContext } from "../types";
  6 | import { ParamOrganizationSlug, ParamRegionUrl, ParamTraceId } from "../schema";
  7 | 
  8 | // Constants for span filtering and tree rendering
  9 | const MAX_DEPTH = 2;
 10 | const MINIMUM_DURATION_THRESHOLD_MS = 10;
 11 | const MIN_MEANINGFUL_CHILD_DURATION = 5;
 12 | const MIN_AVG_DURATION_MS = 5;
 13 | 
 14 | export default defineTool({
 15 |   name: "get_trace_details",
 16 |   requiredScopes: ["event:read"],
 17 |   description: [
 18 |     "Get detailed information about a specific Sentry trace by ID.",
 19 |     "",
 20 |     "🔍 USE THIS TOOL WHEN USERS:",
 21 |     "- Provide a specific trace ID (e.g., 'a4d1aae7216b47ff8117cf4e09ce9d0a')",
 22 |     "- Ask to 'show me trace [TRACE-ID]', 'explain trace [TRACE-ID]'",
 23 |     "- Want high-level overview and link to view trace details in Sentry",
 24 |     "- Need trace statistics and span breakdown",
 25 |     "",
 26 |     "❌ DO NOT USE for:",
 27 |     "- General searching for traces (use search_events with trace queries)",
 28 |     "- Individual span details (this shows trace overview)",
 29 |     "",
 30 |     "TRIGGER PATTERNS:",
 31 |     "- 'Show me trace abc123' → use get_trace_details",
 32 |     "- 'Explain trace a4d1aae7216b47ff8117cf4e09ce9d0a' → use get_trace_details",
 33 |     "- 'What is trace [trace-id]' → use get_trace_details",
 34 |     "",
 35 |     "<examples>",
 36 |     "### Get trace overview",
 37 |     "```",
 38 |     "get_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a')",
 39 |     "```",
 40 |     "</examples>",
 41 |     "",
 42 |     "<hints>",
 43 |     "- Trace IDs are 32-character hexadecimal strings",
 44 |     "</hints>",
 45 |   ].join("\n"),
 46 |   inputSchema: {
 47 |     organizationSlug: ParamOrganizationSlug,
 48 |     regionUrl: ParamRegionUrl.optional(),
 49 |     traceId: ParamTraceId,
 50 |   },
 51 |   annotations: {
 52 |     readOnlyHint: true,
 53 |     openWorldHint: true,
 54 |   },
 55 |   async handler(params, context: ServerContext) {
 56 |     // Validate trace ID format
 57 |     if (!/^[0-9a-fA-F]{32}$/.test(params.traceId)) {
 58 |       throw new UserInputError(
 59 |         "Trace ID must be a 32-character hexadecimal string",
 60 |       );
 61 |     }
 62 | 
 63 |     const apiService = apiServiceFromContext(context, {
 64 |       regionUrl: params.regionUrl,
 65 |     });
 66 | 
 67 |     setTag("organization.slug", params.organizationSlug);
 68 |     setTag("trace.id", params.traceId);
 69 | 
 70 |     // Get trace metadata for overview
 71 |     const traceMeta = await apiService.getTraceMeta({
 72 |       organizationSlug: params.organizationSlug,
 73 |       traceId: params.traceId,
 74 |       statsPeriod: "14d", // Fixed stats period
 75 |     });
 76 | 
 77 |     // Get minimal trace data to show key transactions
 78 |     const trace = await apiService.getTrace({
 79 |       organizationSlug: params.organizationSlug,
 80 |       traceId: params.traceId,
 81 |       limit: 10, // Only get top-level spans for overview
 82 |       statsPeriod: "14d", // Fixed stats period
 83 |     });
 84 | 
 85 |     return formatTraceOutput({
 86 |       organizationSlug: params.organizationSlug,
 87 |       traceId: params.traceId,
 88 |       traceMeta,
 89 |       trace,
 90 |       apiService,
 91 |     });
 92 |   },
 93 | });
 94 | 
 95 | interface SelectedSpan {
 96 |   event_id: string;
 97 |   op: string;
 98 |   name: string | null;
 99 |   description: string;
100 |   duration: number;
101 |   is_transaction: boolean;
102 |   children: SelectedSpan[];
103 |   level: number;
104 | }
105 | 
106 | /**
107 |  * Selects a subset of "interesting" spans from a trace for display in the overview.
108 |  *
109 |  * Creates a fake root span representing the entire trace, with selected interesting
110 |  * spans as children. This provides a unified tree view of the trace.
111 |  *
112 |  * The goal is to provide a meaningful sample of the trace that highlights the most
113 |  * important operations while staying within display limits. Selection prioritizes:
114 |  *
115 |  * 1. **Transactions** - Top-level operations that represent complete user requests
116 |  * 2. **Error spans** - Any spans that contain errors (critical for debugging)
117 |  * 3. **Long-running spans** - Operations >= 10ms duration (performance bottlenecks)
118 |  * 4. **Hierarchical context** - Maintains parent-child relationships for understanding
119 |  *
120 |  * Span inclusion rules:
121 |  * - All transactions are included (they're typically root-level operations)
122 |  * - Spans with errors are always included (debugging importance)
123 |  * - Spans with duration >= 10ms are included (performance relevance)
124 |  * - Children are recursively added up to 2 levels deep:
125 |  *   - Transactions can have up to 2 children each
126 |  *   - Regular spans can have up to 1 child each
127 |  * - Total output is capped at maxSpans to prevent overwhelming display
128 |  *
129 |  * @param spans - Complete array of trace spans with nested children
130 |  * @param traceId - Trace ID to display in the fake root span
131 |  * @param maxSpans - Maximum number of spans to include in output (default: 20)
132 |  * @returns Single-element array containing fake root span with selected spans as children
133 |  */
134 | function selectInterestingSpans(
135 |   spans: any[],
136 |   traceId: string,
137 |   maxSpans = 20,
138 | ): SelectedSpan[] {
139 |   const selected: SelectedSpan[] = [];
140 |   let spanCount = 0;
141 | 
142 |   // Filter out non-span items (issues) from the trace data
143 |   // Spans must have children array, duration, and other span-specific fields
144 |   const actualSpans = spans.filter(
145 |     (item) =>
146 |       item &&
147 |       typeof item === "object" &&
148 |       "children" in item &&
149 |       Array.isArray(item.children) &&
150 |       "duration" in item,
151 |   );
152 | 
153 |   function addSpan(span: any, level: number): boolean {
154 |     if (spanCount >= maxSpans || level > MAX_DEPTH) return false;
155 | 
156 |     const duration = span.duration || 0;
157 |     const isTransaction = span.is_transaction;
158 |     const hasErrors = span.errors?.length > 0;
159 | 
160 |     // Always include transactions and spans with errors
161 |     // For regular spans, include if they have reasonable duration or are at root level
162 |     const shouldInclude =
163 |       isTransaction ||
164 |       hasErrors ||
165 |       level === 0 ||
166 |       duration >= MINIMUM_DURATION_THRESHOLD_MS;
167 | 
168 |     if (!shouldInclude) return false;
169 | 
170 |     const selectedSpan: SelectedSpan = {
171 |       event_id: span.event_id,
172 |       op: span.op || "unknown",
173 |       name: span.name || null,
174 |       description: span.description || span.transaction || "unnamed",
175 |       duration,
176 |       is_transaction: isTransaction,
177 |       children: [],
178 |       level,
179 |     };
180 | 
181 |     spanCount++;
182 | 
183 |     // Add up to one interesting child per span, up to MAX_DEPTH levels deep
184 |     if (level < MAX_DEPTH && span.children?.length > 0) {
185 |       // Sort children by duration (descending) and take the most interesting ones
186 |       const sortedChildren = span.children
187 |         .filter((child: any) => child.duration > MIN_MEANINGFUL_CHILD_DURATION) // Only children with meaningful duration
188 |         .sort((a: any, b: any) => (b.duration || 0) - (a.duration || 0));
189 | 
190 |       // Add up to 2 children for transactions, 1 for regular spans
191 |       const maxChildren = isTransaction ? 2 : 1;
192 |       let addedChildren = 0;
193 | 
194 |       for (const child of sortedChildren) {
195 |         if (addedChildren >= maxChildren || spanCount >= maxSpans) break;
196 | 
197 |         if (addSpan(child, level + 1)) {
198 |           const childSpan = selected[selected.length - 1];
199 |           selectedSpan.children.push(childSpan);
200 |           addedChildren++;
201 |         }
202 |       }
203 |     }
204 | 
205 |     selected.push(selectedSpan);
206 |     return true;
207 |   }
208 | 
209 |   // Sort root spans by duration and select the most interesting ones
210 |   const sortedRoots = actualSpans
211 |     .sort((a, b) => (b.duration || 0) - (a.duration || 0))
212 |     .slice(0, 5); // Start with top 5 root spans
213 | 
214 |   for (const root of sortedRoots) {
215 |     if (spanCount >= maxSpans) break;
216 |     addSpan(root, 0);
217 |   }
218 | 
219 |   const rootSpans = selected.filter((span) => span.level === 0);
220 | 
221 |   // Create fake root span representing the entire trace (no duration - traces are unbounded)
222 |   const fakeRoot: SelectedSpan = {
223 |     event_id: traceId,
224 |     op: "trace",
225 |     name: null,
226 |     description: `Trace ${traceId.substring(0, 8)}`,
227 |     duration: 0, // Traces don't have duration
228 |     is_transaction: false,
229 |     children: rootSpans,
230 |     level: -1, // Mark as fake root
231 |   };
232 | 
233 |   return [fakeRoot];
234 | }
235 | 
236 | /**
237 |  * Formats a span display name for the tree view.
238 |  *
239 |  * Uses span.name if available (OTEL-native), otherwise falls back to span.description.
240 |  *
241 |  * @param span - The span to format
242 |  * @returns A formatted display name for the span
243 |  */
244 | function formatSpanDisplayName(span: SelectedSpan): string {
245 |   // For the fake trace root, just return "trace"
246 |   if (span.op === "trace") {
247 |     return "trace";
248 |   }
249 | 
250 |   // Use span.name if available (OTEL-native), otherwise use description
251 |   return span.name?.trim() || span.description || "unnamed";
252 | }
253 | 
254 | /**
255 |  * Renders a hierarchical tree structure of spans using Unicode box-drawing characters.
256 |  *
257 |  * Creates a visual tree representation showing parent-child relationships between spans,
258 |  * with proper indentation and connecting lines. Each span shows its operation, short ID,
259 |  * description, duration, and type (transaction vs span).
260 |  *
261 |  * Tree format:
262 |  * - Root spans have no prefix
263 |  * - Child spans use ├─ for intermediate children, └─ for last child
264 |  * - Continuation lines use │ for vertical connections
265 |  * - Proper spacing maintains visual alignment
266 |  *
267 |  * @param spans - Array of selected spans with their nested children structure
268 |  * @returns Array of formatted markdown strings representing the tree structure
269 |  */
270 | function renderSpanTree(spans: SelectedSpan[]): string[] {
271 |   const lines: string[] = [];
272 | 
273 |   function renderSpan(span: SelectedSpan, prefix = "", isLast = true): void {
274 |     const shortId = span.event_id.substring(0, 8);
275 |     const connector = prefix === "" ? "" : isLast ? "└─ " : "├─ ";
276 |     const displayName = formatSpanDisplayName(span);
277 | 
278 |     // Don't show duration for the fake trace root span
279 |     if (span.op === "trace") {
280 |       lines.push(`${prefix}${connector}${displayName} [${shortId}]`);
281 |     } else {
282 |       const duration = span.duration
283 |         ? `${Math.round(span.duration)}ms`
284 |         : "unknown";
285 | 
286 |       // Don't show 'default' operations as they're not meaningful
287 |       const opDisplay = span.op === "default" ? "" : ` · ${span.op}`;
288 |       lines.push(
289 |         `${prefix}${connector}${displayName} [${shortId}${opDisplay} · ${duration}]`,
290 |       );
291 |     }
292 | 
293 |     // Render children with proper tree indentation
294 |     for (let i = 0; i < span.children.length; i++) {
295 |       const child = span.children[i];
296 |       const isLastChild = i === span.children.length - 1;
297 |       const childPrefix = prefix + (isLast ? "   " : "│  ");
298 |       renderSpan(child, childPrefix, isLastChild);
299 |     }
300 |   }
301 | 
302 |   for (let i = 0; i < spans.length; i++) {
303 |     const span = spans[i];
304 |     const isLastRoot = i === spans.length - 1;
305 |     renderSpan(span, "", isLastRoot);
306 |   }
307 | 
308 |   return lines;
309 | }
310 | 
311 | function calculateOperationStats(spans: any[]): Record<
312 |   string,
313 |   {
314 |     count: number;
315 |     avgDuration: number;
316 |     p95Duration: number;
317 |   }
318 | > {
319 |   const allSpans = getAllSpansFlattened(spans);
320 |   const operationSpans: Record<string, any[]> = {};
321 | 
322 |   // Group leaf spans by operation type (only spans with no children)
323 |   for (const span of allSpans) {
324 |     // Only consider leaf nodes - spans that have no children
325 |     if (!span.children || span.children.length === 0) {
326 |       // Use span.op if available, otherwise extract from span.name, fallback to "unknown"
327 |       const op = span.op || (span.name ? span.name.split(" ")[0] : "unknown");
328 |       if (!operationSpans[op]) {
329 |         operationSpans[op] = [];
330 |       }
331 |       operationSpans[op].push(span);
332 |     }
333 |   }
334 | 
335 |   const stats: Record<
336 |     string,
337 |     { count: number; avgDuration: number; p95Duration: number }
338 |   > = {};
339 | 
340 |   // Calculate stats for each operation
341 |   for (const [op, opSpans] of Object.entries(operationSpans)) {
342 |     const durations = opSpans
343 |       .map((span) => span.duration || 0)
344 |       .filter((duration) => duration > 0)
345 |       .sort((a, b) => a - b);
346 | 
347 |     const count = opSpans.length;
348 |     const avgDuration =
349 |       durations.length > 0
350 |         ? durations.reduce((sum, duration) => sum + duration, 0) /
351 |           durations.length
352 |         : 0;
353 | 
354 |     // Calculate P95 (95th percentile)
355 |     const p95Index = Math.floor(durations.length * 0.95);
356 |     const p95Duration = durations.length > 0 ? durations[p95Index] || 0 : 0;
357 | 
358 |     stats[op] = {
359 |       count,
360 |       avgDuration,
361 |       p95Duration,
362 |     };
363 |   }
364 | 
365 |   return stats;
366 | }
367 | 
368 | function getAllSpansFlattened(spans: any[]): any[] {
369 |   const result: any[] = [];
370 | 
371 |   // Filter out non-span items (issues) from the trace data
372 |   // Spans must have children array and duration
373 |   const actualSpans = spans.filter(
374 |     (item) =>
375 |       item &&
376 |       typeof item === "object" &&
377 |       "children" in item &&
378 |       Array.isArray(item.children) &&
379 |       "duration" in item,
380 |   );
381 | 
382 |   function collectSpans(spanList: any[]) {
383 |     for (const span of spanList) {
384 |       result.push(span);
385 |       if (span.children && span.children.length > 0) {
386 |         collectSpans(span.children);
387 |       }
388 |     }
389 |   }
390 | 
391 |   collectSpans(actualSpans);
392 |   return result;
393 | }
394 | 
395 | function formatTraceOutput({
396 |   organizationSlug,
397 |   traceId,
398 |   traceMeta,
399 |   trace,
400 |   apiService,
401 | }: {
402 |   organizationSlug: string;
403 |   traceId: string;
404 |   traceMeta: any;
405 |   trace: any[];
406 |   apiService: any;
407 | }): string {
408 |   const sections: string[] = [];
409 | 
410 |   // Header
411 |   sections.push(`# Trace \`${traceId}\` in **${organizationSlug}**`);
412 |   sections.push("");
413 | 
414 |   // High-level statistics
415 |   sections.push("## Summary");
416 |   sections.push("");
417 |   sections.push(`**Total Spans**: ${traceMeta.span_count}`);
418 |   sections.push(`**Errors**: ${traceMeta.errors}`);
419 |   sections.push(`**Performance Issues**: ${traceMeta.performance_issues}`);
420 |   sections.push(`**Logs**: ${traceMeta.logs}`);
421 | 
422 |   // Show operation breakdown with detailed stats if we have trace data
423 |   if (trace.length > 0) {
424 |     const operationStats = calculateOperationStats(trace);
425 |     const sortedOps = Object.entries(operationStats)
426 |       .filter(([, stats]) => stats.avgDuration >= MIN_AVG_DURATION_MS) // Only show ops with avg duration >= 5ms
427 |       .sort(([, a], [, b]) => b.count - a.count)
428 |       .slice(0, 10); // Show top 10
429 | 
430 |     if (sortedOps.length > 0) {
431 |       sections.push("");
432 |       sections.push("## Operation Breakdown");
433 |       sections.push("");
434 | 
435 |       for (const [op, stats] of sortedOps) {
436 |         const avgDuration = Math.round(stats.avgDuration);
437 |         const p95Duration = Math.round(stats.p95Duration);
438 |         sections.push(
439 |           `- **${op}**: ${stats.count} spans (avg: ${avgDuration}ms, p95: ${p95Duration}ms)`,
440 |         );
441 |       }
442 |       sections.push("");
443 |     }
444 |   }
445 | 
446 |   // Show span tree structure
447 |   if (trace.length > 0) {
448 |     const selectedSpans = selectInterestingSpans(trace, traceId);
449 | 
450 |     if (selectedSpans.length > 0) {
451 |       sections.push("## Overview");
452 |       sections.push("");
453 |       const treeLines = renderSpanTree(selectedSpans);
454 |       sections.push(...treeLines);
455 |       sections.push("");
456 |       sections.push(
457 |         "*Note: This shows a subset of spans. View the full trace for complete details.*",
458 |       );
459 |       sections.push("");
460 |     }
461 |   }
462 | 
463 |   // Links and usage information
464 |   const traceUrl = apiService.getTraceUrl(organizationSlug, traceId);
465 |   sections.push("## View Full Trace");
466 |   sections.push("");
467 |   sections.push(`**Sentry URL**: ${traceUrl}`);
468 |   sections.push("");
469 |   sections.push("## Find Related Events");
470 |   sections.push("");
471 |   sections.push(`Use this search query to find all events in this trace:`);
472 |   sections.push("```");
473 |   sections.push(`trace:${traceId}`);
474 |   sections.push("```");
475 |   sections.push("");
476 |   sections.push(
477 |     "You can use this query with the `search_events` tool to get detailed event data from this trace.",
478 |   );
479 | 
480 |   return sections.join("\n");
481 | }
482 | 
```

--------------------------------------------------------------------------------
/docs/cloudflare/oauth-architecture.md:
--------------------------------------------------------------------------------

```markdown
  1 | # OAuth Architecture: MCP OAuth vs Sentry OAuth
  2 | 
  3 | ## Two Separate OAuth Systems
  4 | 
  5 | The Sentry MCP implementation involves **two completely separate OAuth providers**:
  6 | 
  7 | ### 1. MCP OAuth Provider (Our Server)
  8 | - **What it is**: Our own OAuth 2.0 server built with `@cloudflare/workers-oauth-provider`
  9 | - **Purpose**: Authenticates MCP clients (like Cursor, VS Code, etc.)
 10 | - **Tokens issued**: MCP access tokens and MCP refresh tokens
 11 | - **Storage**: Uses Cloudflare KV to store encrypted tokens
 12 | - **Endpoints**: `/oauth/register`, `/oauth/authorize`, `/oauth/token`
 13 | 
 14 | ### 2. Sentry OAuth Provider (Sentry's Server)
 15 | - **What it is**: Sentry's official OAuth 2.0 server at `sentry.io`
 16 | - **Purpose**: Authenticates users and grants API access to Sentry
 17 | - **Tokens issued**: Sentry access tokens and Sentry refresh tokens
 18 | - **Storage**: Tokens are stored encrypted within MCP's token props
 19 | - **Endpoints**: `https://sentry.io/oauth/authorize/`, `https://sentry.io/oauth/token/`
 20 | 
 21 | ## High-Level Flow
 22 | 
 23 | The system uses a dual-token approach:
 24 | 1. **MCP clients** authenticate with **MCP OAuth** to get MCP tokens
 25 | 2. **MCP OAuth** authenticates with **Sentry OAuth** to get Sentry tokens
 26 | 3. **MCP tokens** contain encrypted **Sentry tokens** in their payload
 27 | 4. When serving MCP requests, the server uses Sentry tokens to call Sentry's API
 28 | 
 29 | ### Complete Flow Diagram
 30 | 
 31 | ```mermaid
 32 | sequenceDiagram
 33 |     participant Client as MCP Client (Cursor)
 34 |     participant MCPOAuth as MCP OAuth Provider<br/>(Our Server)
 35 |     participant MCP as MCP Server<br/>(Durable Object)
 36 |     participant SentryOAuth as Sentry OAuth Provider<br/>(sentry.io)
 37 |     participant SentryAPI as Sentry API
 38 |     participant User as User
 39 | 
 40 |     Note over Client,SentryAPI: Initial Client Registration
 41 |     Client->>MCPOAuth: Register as OAuth client
 42 |     MCPOAuth-->>Client: MCP Client ID & Secret
 43 | 
 44 |     Note over Client,SentryAPI: User Authorization Flow
 45 |     Client->>MCPOAuth: Request authorization
 46 |     MCPOAuth->>User: Show MCP consent screen
 47 |     User->>MCPOAuth: Approve MCP permissions
 48 |     MCPOAuth->>SentryOAuth: Redirect to Sentry OAuth
 49 |     SentryOAuth->>User: Sentry login page
 50 |     User->>SentryOAuth: Authenticate with Sentry
 51 |     SentryOAuth-->>MCPOAuth: Sentry auth code
 52 |     MCPOAuth->>SentryOAuth: Exchange code for tokens
 53 |     SentryOAuth-->>MCPOAuth: Sentry access + refresh tokens
 54 |     MCPOAuth-->>Client: MCP access token<br/>(contains encrypted Sentry tokens)
 55 | 
 56 |     Note over Client,SentryAPI: Using MCP Protocol
 57 |     Client->>MCP: MCP request with MCP Bearer token
 58 |     MCP->>MCPOAuth: Validate MCP token
 59 |     MCPOAuth-->>MCP: Decrypted props<br/>(includes Sentry tokens)
 60 |     MCP->>SentryAPI: API call with Sentry Bearer token
 61 |     SentryAPI-->>MCP: API response
 62 |     MCP-->>Client: MCP response
 63 | 
 64 |     Note over Client,SentryAPI: Token Refresh
 65 |     Client->>MCPOAuth: POST /oauth/token<br/>(MCP refresh_token)
 66 |     MCPOAuth->>MCPOAuth: Check Sentry token expiry
 67 |     alt Sentry token still valid
 68 |         MCPOAuth-->>Client: New MCP token<br/>(reusing cached Sentry token)
 69 |     else Sentry token expired
 70 |         MCPOAuth->>SentryOAuth: Refresh Sentry token
 71 |         SentryOAuth-->>MCPOAuth: New Sentry tokens
 72 |         MCPOAuth-->>Client: New MCP token<br/>(with new Sentry tokens)
 73 |     end
 74 | ```
 75 | 
 76 | ## Key Concepts
 77 | 
 78 | ### Token Types
 79 | 
 80 | | Token Type | Issued By | Used By | Contains | Purpose |
 81 | |------------|-----------|---------|----------|----------|
 82 | | **MCP Access Token** | MCP OAuth Provider | MCP Clients | Encrypted Sentry tokens | Authenticate to MCP Server |
 83 | | **MCP Refresh Token** | MCP OAuth Provider | MCP Clients | Grant reference | Refresh MCP access tokens |
 84 | | **Sentry Access Token** | Sentry OAuth | MCP Server | User credentials | Call Sentry API |
 85 | | **Sentry Refresh Token** | Sentry OAuth | MCP OAuth Provider | Refresh credentials | Refresh Sentry tokens |
 86 | 
 87 | ### Not a Simple Proxy
 88 | 
 89 | **Important**: MCP is NOT an HTTP proxy that forwards requests. Instead:
 90 | - MCP implements the **Model Context Protocol** (tools, prompts, resources)
 91 | - Clients send MCP protocol messages, not HTTP requests
 92 | - MCP Server executes these commands using Sentry's API
 93 | - Responses are MCP protocol messages, not raw HTTP responses
 94 | 
 95 | ## Technical Implementation
 96 | 
 97 | ### MCP OAuth Provider Details
 98 | 
 99 | The MCP OAuth Provider is built with `@cloudflare/workers-oauth-provider` and provides:
100 | 
101 | 1. **Dynamic client registration** - MCP clients can register on-demand
102 | 2. **PKCE support** - Secure authorization code flow
103 | 3. **Token management** - Issues and validates MCP tokens
104 | 4. **Consent UI** - Custom approval screen for permissions
105 | 5. **Token encryption** - Stores Sentry tokens encrypted in MCP token props
106 | 
107 | ### Sentry OAuth Integration
108 | 
109 | The integration with Sentry OAuth happens through:
110 | 
111 | 1. **Authorization redirect** - After MCP consent, redirect to Sentry OAuth
112 | 2. **Code exchange** - Exchange Sentry auth code for tokens
113 | 3. **Token storage** - Store Sentry tokens in MCP token props
114 | 4. **Token refresh** - Use Sentry refresh tokens to get new access tokens
115 | 
116 | ## Key Concepts
117 | 
118 | ### How the MCP OAuth Provider Works
119 | 
120 | ```mermaid
121 | sequenceDiagram
122 |     participant Agent as AI Agent
123 |     participant MCPOAuth as MCP OAuth Provider
124 |     participant KV as Cloudflare KV
125 |     participant User as User
126 |     participant MCP as MCP Server
127 | 
128 |     Agent->>MCPOAuth: Register as client
129 |     MCPOAuth->>KV: Store client registration
130 |     MCPOAuth-->>Agent: MCP Client ID & Secret
131 | 
132 |     Agent->>MCPOAuth: Request authorization
133 |     MCPOAuth->>User: Show MCP consent screen
134 |     User->>MCPOAuth: Approve
135 |     MCPOAuth->>KV: Store grant
136 |     MCPOAuth-->>Agent: Authorization code
137 | 
138 |     Agent->>MCPOAuth: Exchange code for MCP token
139 |     MCPOAuth->>KV: Validate grant
140 |     MCPOAuth->>KV: Store encrypted MCP token
141 |     MCPOAuth-->>Agent: MCP access token
142 | 
143 |     Agent->>MCP: MCP protocol request with MCP token
144 |     MCP->>MCPOAuth: Validate MCP token
145 |     MCPOAuth->>KV: Lookup MCP token
146 |     MCPOAuth-->>MCP: Decrypted props (includes Sentry tokens)
147 |     MCP-->>Agent: MCP protocol response
148 | ```
149 | 
150 | ## Implementation Details
151 | 
152 | ### 1. MCP OAuth Provider Configuration
153 | 
154 | The MCP OAuth Provider is configured in `src/server/index.ts`:
155 | 
156 | ```typescript
157 | const oAuthProvider = new OAuthProvider({
158 |   apiHandlers: {
159 |     "/sse": createMcpHandler("/sse", true),
160 |     "/mcp": createMcpHandler("/mcp", false),
161 |   },
162 |   defaultHandler: app,  // Hono app for non-OAuth routes
163 |   authorizeEndpoint: "/oauth/authorize",
164 |   tokenEndpoint: "/oauth/token", 
165 |   clientRegistrationEndpoint: "/oauth/register",
166 |   scopesSupported: Object.keys(SCOPES),
167 | });
168 | ```
169 | 
170 | ### 2. API Handlers
171 | 
172 | The `apiHandlers` are protected endpoints that require valid OAuth tokens:
173 | 
174 | - `/mcp/*` - MCP protocol endpoints
175 | - `/sse/*` - Server-sent events for MCP
176 | 
177 | These handlers receive:
178 | - `request`: The incoming request
179 | - `env`: Cloudflare environment bindings
180 | - `ctx`: Execution context with `ctx.props` containing decrypted user data
181 | 
182 | ### 3. Token Structure
183 | 
184 | MCP tokens contain encrypted properties including Sentry tokens:
185 | 
186 | ```typescript
187 | interface WorkerProps {
188 |   id: string;                    // Sentry user ID
189 |   name: string;                   // User name
190 |   accessToken: string;            // Sentry access token
191 |   refreshToken?: string;          // Sentry refresh token
192 |   accessTokenExpiresAt?: number;  // Sentry token expiry timestamp
193 |   scope: string;                  // MCP permissions granted
194 |   grantedScopes?: string[];       // Sentry API scopes
195 | }
196 | ```
197 | 
198 | ### 4. URL Constraints Challenge
199 | 
200 | #### The Problem
201 | 
202 | The MCP server needs to support URL-based constraints like `/mcp/sentry/javascript` to limit agent access to specific organizations/projects. However:
203 | 
204 | 1. OAuth Provider only does prefix matching (`/mcp` matches `/mcp/*`)
205 | 2. The agents library rewrites URLs to `/streamable-http` before reaching the Durable Object
206 | 3. URL path parameters are lost in this rewrite
207 | 
208 | #### The Solution
209 | 
210 | We use HTTP headers to preserve constraints through the URL rewriting:
211 | 
212 | ```typescript
213 | const createMcpHandler = (basePath: string, isSSE = false) => {
214 |   const handler = isSSE ? SentryMCP.serveSSE("/*") : SentryMCP.serve("/*");
215 | 
216 |   return {
217 |     fetch: (request: Request, env: unknown, ctx: ExecutionContext) => {
218 |       const url = new URL(request.url);
219 |       
220 |       // Extract constraints from URL
221 |       const pathMatch = url.pathname.match(
222 |         /^\/(mcp|sse)(?:\/([a-z0-9._-]+))?(?:\/([a-z0-9._-]+))?/i
223 |       );
224 |       
225 |       // Pass constraints via headers (preserved through URL rewriting)
226 |       const headers = new Headers(request.headers);
227 |       if (pathMatch?.[2]) {
228 |         headers.set("X-Sentry-Org-Slug", pathMatch[2]);
229 |       }
230 |       if (pathMatch?.[3]) {
231 |         headers.set("X-Sentry-Project-Slug", pathMatch[3]);
232 |       }
233 |       
234 |       const modifiedRequest = new Request(request, { headers });
235 |       return handler.fetch(modifiedRequest, env, ctx);
236 |     },
237 |   };
238 | };
239 | ```
240 | 
241 | ## Storage (KV Namespace)
242 | 
243 | The MCP OAuth Provider uses `OAUTH_KV` namespace to store:
244 | 
245 | 1. **MCP Client registrations**: `client:{clientId}` - MCP OAuth client details
246 | 2. **MCP Authorization grants**: `grant:{userId}:{grantId}` - User consent records for MCP
247 | 3. **MCP Access tokens**: `token:{userId}:{grantId}:{tokenId}` - Encrypted MCP tokens (contains Sentry tokens)
248 | 4. **MCP Refresh tokens**: `refresh:{userId}:{grantId}:{refreshId}` - For MCP token renewal
249 | 
250 | ### Token Storage Structure
251 | 
252 | When a user completes the full OAuth flow, the MCP OAuth Provider stores Sentry tokens inside MCP token props:
253 | 
254 | ```typescript
255 | // In /oauth/callback after exchanging code with Sentry
256 | const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
257 |   // ... other params
258 |   props: {
259 |     id: payload.user.id,                    // From Sentry
260 |     name: payload.user.name,                 // From Sentry
261 |     accessToken: payload.access_token,       // Sentry's access token
262 |     refreshToken: payload.refresh_token,     // Sentry's refresh token
263 |     accessTokenExpiresAt: Date.now() + payload.expires_in * 1000,
264 |     scope: oauthReqInfo.scope.join(" "),     // MCP scopes
265 |     grantedScopes: Array.from(grantedScopes), // Sentry API scopes
266 |     // ... other fields
267 |   }
268 | });
269 | ```
270 | 
271 | ## Token Refresh Implementation
272 | 
273 | ### Dual Refresh Token System
274 | 
275 | The system maintains two separate refresh flows:
276 | 
277 | 1. **MCP Token Refresh**: When MCP clients need new MCP access tokens
278 | 2. **Sentry Token Refresh**: When Sentry access tokens expire (handled internally)
279 | 
280 | ### MCP Token Refresh Flow
281 | 
282 | When an MCP client's token expires:
283 | 
284 | 1. Client sends refresh request to MCP OAuth: `POST /oauth/token` with MCP refresh token
285 | 2. MCP OAuth invokes `tokenExchangeCallback` function
286 | 3. Callback checks if cached Sentry token is still valid (with 2-minute safety window)
287 | 4. If Sentry token is valid, returns new MCP token with cached Sentry token
288 | 5. If Sentry token expired, refreshes with Sentry OAuth and updates storage
289 | 
290 | ### Token Exchange Callback Implementation
291 | 
292 | ```typescript
293 | // tokenExchangeCallback in src/server/oauth/helpers.ts
294 | export async function tokenExchangeCallback(options, env) {
295 |   // Only handle MCP refresh_token requests
296 |   if (options.grantType !== "refresh_token") {
297 |     return undefined;
298 |   }
299 | 
300 |   // Extract Sentry refresh token from MCP token props
301 |   const sentryRefreshToken = options.props.refreshToken;
302 |   if (!sentryRefreshToken) {
303 |     throw new Error("No Sentry refresh token available in stored props");
304 |   }
305 | 
306 |   // Smart caching: Check if Sentry token is still valid
307 |   const sentryTokenExpiresAt = props.accessTokenExpiresAt;
308 |   if (sentryTokenExpiresAt && Number.isFinite(sentryTokenExpiresAt)) {
309 |     const remainingMs = sentryTokenExpiresAt - Date.now();
310 |     const SAFE_WINDOW_MS = 2 * 60 * 1000; // 2 minutes safety
311 |     
312 |     if (remainingMs > SAFE_WINDOW_MS) {
313 |       // Sentry token still valid - return new MCP token with cached Sentry token
314 |       return {
315 |         newProps: { ...options.props },
316 |         accessTokenTTL: Math.floor(remainingMs / 1000),
317 |       };
318 |     }
319 |   }
320 | 
321 |   // Sentry token expired - refresh with Sentry OAuth
322 |   const [sentryTokens, errorResponse] = await refreshAccessToken({
323 |     client_id: env.SENTRY_CLIENT_ID,
324 |     client_secret: env.SENTRY_CLIENT_SECRET,
325 |     refresh_token: sentryRefreshToken,
326 |     upstream_url: "https://sentry.io/oauth/token/",
327 |   });
328 | 
329 |   // Update MCP token props with new Sentry tokens
330 |   return {
331 |     newProps: {
332 |       ...options.props,
333 |       accessToken: sentryTokens.access_token,      // New Sentry access token
334 |       refreshToken: sentryTokens.refresh_token,    // New Sentry refresh token
335 |       accessTokenExpiresAt: Date.now() + sentryTokens.expires_in * 1000,
336 |     },
337 |     accessTokenTTL: sentryTokens.expires_in,
338 |   };
339 | }
340 | ```
341 | 
342 | ### Error Scenarios
343 | 
344 | 1. **Missing Sentry Refresh Token**: 
345 |    - Error: "No Sentry refresh token available in stored props"
346 |    - Resolution: Client must re-authenticate through full OAuth flow
347 | 
348 | 2. **Sentry Refresh Token Invalid**: 
349 |    - Error: Sentry OAuth returns 401/400
350 |    - Resolution: Client must re-authenticate with both MCP and Sentry
351 | 
352 | 3. **Network Failures**: 
353 |    - Error: Cannot reach Sentry OAuth endpoint
354 |    - Resolution: Retry with exponential backoff or re-authenticate
355 | 
356 | The 2-minute safety window prevents edge cases with clock skew and processing delays between MCP and Sentry.
357 | 
358 | ## Security Features
359 | 
360 | 1. **PKCE**: MCP OAuth uses PKCE to prevent authorization code interception
361 | 2. **Token encryption**: Sentry tokens encrypted within MCP tokens using WebCrypto
362 | 3. **Dual consent**: Users approve both MCP permissions and Sentry access
363 | 4. **Scope enforcement**: Both MCP and Sentry scopes limit access
364 | 5. **Token expiration**: Both MCP and Sentry tokens have expiry times
365 | 6. **Refresh token rotation**: Sentry issues new refresh tokens on each refresh
366 | 
367 | ## Discovery Endpoints
368 | 
369 | The MCP OAuth Provider automatically provides:
370 | 
371 | - `/.well-known/oauth-authorization-server` - MCP OAuth server metadata
372 | - `/.well-known/oauth-protected-resource` - MCP resource server info
373 | 
374 | Note: These describe the MCP OAuth server, not Sentry's OAuth endpoints.
375 | 
376 | ## Integration Between MCP OAuth and MCP Server
377 | 
378 | The MCP Server (Durable Object `SentryMCP`) receives:
379 | 
380 | 1. **Props via constructor**: Decrypted data from MCP token (includes Sentry tokens)
381 | 2. **Constraints via headers**: Organization/project limits from URL path
382 | 3. **Both stored**: In Durable Object storage for session persistence
383 | 
384 | The MCP Server then uses the Sentry access token from props to make Sentry API calls.
385 | 
386 | ## Limitations
387 | 
388 | 1. **No direct Hono integration**: OAuth Provider expects specific handler signatures
389 | 2. **URL rewriting**: Requires header-based constraint passing
390 | 3. **Props architecture mismatch**: OAuth passes props per-request, agents library expects them in constructor
391 | 
392 | ## Why Use Two OAuth Systems?
393 | 
394 | ### Benefits of the Dual OAuth Approach
395 | 
396 | 1. **Security isolation**: MCP clients never see Sentry tokens directly
397 | 2. **Token management**: MCP can refresh Sentry tokens transparently
398 | 3. **Permission layering**: MCP permissions separate from Sentry API scopes
399 | 4. **Client flexibility**: MCP clients don't need to understand Sentry OAuth
400 | 
401 | ### Why Not Direct Sentry OAuth?
402 | 
403 | If MCP clients used Sentry OAuth directly:
404 | - Clients would need to manage Sentry token refresh
405 | - No way to add MCP-specific permissions
406 | - Clients would have raw Sentry API access (security risk)
407 | - No centralized token management
408 | 
409 | ### Implementation Complexity
410 | 
411 | The MCP OAuth Provider (via `@cloudflare/workers-oauth-provider`) provides:
412 | - OAuth 2.0 authorization flows
413 | - Dynamic client registration
414 | - Token issuance and validation
415 | - PKCE support
416 | - Consent UI
417 | - Token encryption
418 | - KV storage
419 | - Discovery endpoints
420 | 
421 | Reimplementing this would be complex and error-prone.
422 | 
423 | ## Related Documentation
424 | 
425 | - [Cloudflare OAuth Provider](https://github.com/cloudflare/workers-oauth-provider)
426 | - [OAuth 2.0 Specification](https://oauth.net/2/)
427 | - [Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591.html)
428 | - [PKCE](https://www.rfc-editor.org/rfc/rfc7636)
```

--------------------------------------------------------------------------------
/docs/search-events-api-patterns.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Search Events API Patterns
  2 | 
  3 | ## Overview
  4 | 
  5 | The `search_events` tool provides a unified interface for searching Sentry events across different datasets (errors, logs, spans). This document covers the API patterns, query structures, and best practices for both individual event queries and aggregate queries.
  6 | 
  7 | ## API Architecture
  8 | 
  9 | ### Legacy Discover API vs Modern EAP API
 10 | 
 11 | Sentry uses two different API architectures depending on the dataset:
 12 | 
 13 | 1. **Legacy Discover API** (errors dataset)
 14 |    - Uses the original Discover query format
 15 |    - Simpler aggregate field handling
 16 |    - Returns data in a different format
 17 | 
 18 | 2. **Modern EAP (Event Analytics Platform) API** (spans, logs datasets)
 19 |    - Uses structured aggregate parameters
 20 |    - More sophisticated query capabilities
 21 |    - Different URL generation patterns
 22 | 
 23 | ### API Endpoint
 24 | 
 25 | All queries use the same base endpoint:
 26 | ```
 27 | /api/0/organizations/{organizationSlug}/events/
 28 | ```
 29 | 
 30 | ### Dataset Mapping
 31 | 
 32 | The tool handles dataset name mapping internally:
 33 | - User specifies `errors` → API uses `errors` (Legacy Discover)
 34 | - User specifies `spans` → API uses `spans` (EAP)
 35 | - User specifies `logs` → API uses `ourlogs` (EAP) ⚠️ Note the transformation!
 36 | 
 37 | ## Query Modes
 38 | 
 39 | ### 1. Individual Events (Samples)
 40 | 
 41 | Returns raw event data with full details. This is the default mode when no aggregate functions are used.
 42 | 
 43 | **Key Characteristics:**
 44 | - Returns actual event occurrences
 45 | - Includes default fields plus any user-requested fields
 46 | - Sorted by timestamp (newest first) by default
 47 | - Limited to a specific number of results (default: 10, max: 100)
 48 | 
 49 | **Example API URL:**
 50 | ```
 51 | https://us.sentry.io/api/0/organizations/sentry/events/?dataset=spans&field=id&field=span.op&field=span.description&field=span.duration&field=transaction&field=timestamp&field=ai.model.id&field=ai.model.provider&field=project&field=trace&per_page=50&query=&sort=-timestamp&statsPeriod=24h
 52 | ```
 53 | 
 54 | **Default Fields by Dataset:**
 55 | 
 56 | - **Spans**: `id`, `span.op`, `span.description`, `span.duration`, `transaction`, `timestamp`, `project`, `trace`
 57 | - **Errors**: `issue`, `title`, `project`, `timestamp`, `level`, `message`, `error.type`, `culprit`
 58 | - **Logs**: `timestamp`, `project`, `message`, `severity`, `trace`
 59 | 
 60 | ### 2. Aggregate Queries (Statistics)
 61 | 
 62 | Returns grouped and aggregated data, similar to SQL GROUP BY queries.
 63 | 
 64 | **Key Characteristics:**
 65 | - Activated when ANY field contains a function (e.g., `count()`, `avg()`)
 66 | - Fields should ONLY include aggregate functions and groupBy fields
 67 | - Do NOT include default fields (id, timestamp, etc.)
 68 | - Automatically groups by all non-function fields
 69 | 
 70 | **Example API URLs:**
 71 | 
 72 | Single groupBy field:
 73 | ```
 74 | https://us.sentry.io/api/0/organizations/sentry/events/?dataset=spans&field=ai.model.id&field=count()&per_page=50&query=&sort=-count&statsPeriod=24h
 75 | ```
 76 | 
 77 | Multiple groupBy fields:
 78 | ```
 79 | https://us.sentry.io/api/0/organizations/sentry/events/?dataset=spans&field=ai.model.id&field=ai.model.provider&field=sum(span.duration)&per_page=50&query=&sort=-sum_span_duration&statsPeriod=24h
 80 | ```
 81 | 
 82 | ## Query Parameters
 83 | 
 84 | ### Common Parameters
 85 | 
 86 | | Parameter | Description | Example |
 87 | |-----------|-------------|---------|
 88 | | `dataset` | Which dataset to query | `spans`, `errors`, `logs` (API uses `ourlogs`) |
 89 | | `field` | Fields to return (repeated for each field) | `field=span.op&field=count()` |
 90 | | `query` | Sentry query syntax filter | `has:db.statement AND span.duration:>1000` |
 91 | | `sort` | Sort order (prefix with `-` for descending) | `-timestamp`, `-count()` |
 92 | | `per_page` | Results per page | `50` |
 93 | | `statsPeriod` | Relative time window filter | `1h`, `24h`, `7d`, `14d`, `30d` |
 94 | | `start` | Absolute start time (ISO 8601) | `2025-06-19T07:00:00` |
 95 | | `end` | Absolute end time (ISO 8601) | `2025-06-20T06:59:59` |
 96 | | `project` | Project ID (numeric, not slug) | `4509062593708032` |
 97 | 
 98 | 
 99 | ### Dataset-Specific Considerations
100 | 
101 | #### Spans Dataset
102 | - Supports timestamp filters in query (e.g., `timestamp:-1h`)
103 | - Rich performance metrics available
104 | - Common aggregate functions: `count()`, `avg(span.duration)`, `p95(span.duration)`
105 | 
106 | #### Errors Dataset  
107 | - Supports timestamp filters in query
108 | - Issue grouping available via `issue` field
109 | - Common aggregate functions: `count()`, `count_unique(user.id)`, `last_seen()`
110 | 
111 | #### Logs Dataset
112 | - Does NOT support timestamp filters in query (use `statsPeriod` instead)
113 | - Severity levels: fatal, error, warning, info, debug, trace
114 | - Common aggregate functions: `count()`, `epm()`
115 | - Uses `ourlogs` as the actual API dataset value (not `logs`)
116 | 
117 | ## Query Syntax
118 | 
119 | ### Basic Filters
120 | - Exact match: `field:value`
121 | - Wildcards: `field:*pattern*`
122 | - Comparison: `field:>100`, `field:<500`
123 | - Boolean: `AND`, `OR`, `NOT`
124 | - Phrases: `message:"database connection failed"`
125 | - Attribute existence: `has:field` (recommended for spans)
126 | 
127 | ### Attribute-Based Queries (Recommended for Spans)
128 | Instead of using `span.op` patterns, use `has:` queries for more flexible attribute-based filtering:
129 | - HTTP requests: `has:request.url` instead of `span.op:http*`
130 | - Database queries: `has:db.statement` or `has:db.system` instead of `span.op:db*`
131 | - AI/LLM calls: `has:ai.model.id` or `has:mcp.tool.name`
132 | 
133 | ### Aggregate Functions
134 | 
135 | #### Universal Functions (all datasets)
136 | - `count()` - Count of events
137 | - `count_unique(field)` - Count of unique values
138 | - `epm()` - Events per minute rate
139 | 
140 | #### Numeric Field Functions (spans, logs)
141 | - `avg(field)` - Average value
142 | - `sum(field)` - Sum of values
143 | - `min(field)` - Minimum value
144 | - `max(field)` - Maximum value
145 | - `p50(field)`, `p75(field)`, `p90(field)`, `p95(field)`, `p99(field)` - Percentiles
146 | 
147 | #### Errors-Specific Functions
148 | - `count_if(field,equals,value)` - Conditional count
149 | - `last_seen()` - Most recent timestamp
150 | - `eps()` - Events per second rate
151 | 
152 | ## Examples
153 | 
154 | ### Find Database Queries (Individual Events)
155 | ```
156 | Query: has:db.statement
157 | Fields: ["id", "span.op", "span.description", "span.duration", "transaction", "timestamp", "project", "trace", "db.system", "db.statement"]
158 | Sort: -span.duration
159 | Dataset: spans
160 | ```
161 | 
162 | ### Top 10 Slowest API Endpoints (Aggregate)
163 | ```
164 | Query: is_transaction:true
165 | Fields: ["transaction", "count()", "avg(span.duration)", "p95(span.duration)"]
166 | Sort: -avg(span.duration)
167 | Dataset: spans
168 | ```
169 | 
170 | ### Error Count by Type (Aggregate)
171 | ```
172 | Query: level:error
173 | Fields: ["error.type", "count()"]
174 | Sort: -count()
175 | Dataset: errors
176 | ```
177 | 
178 | ### Logs by Severity (Aggregate)
179 | ```
180 | Query: (empty)
181 | Fields: ["severity", "count()", "epm()"]
182 | Sort: -count()
183 | Dataset: logs
184 | ```
185 | 
186 | ### Tool Calls by Model (Aggregate)
187 | ```
188 | Query: has:mcp.tool.name
189 | Fields: ["ai.model.id", "mcp.tool.name", "count()"]
190 | Sort: -count()
191 | Dataset: spans
192 | ```
193 | 
194 | ### HTTP Requests (Individual Events)
195 | ```
196 | Query: has:request.url
197 | Fields: ["id", "span.op", "span.description", "span.duration", "transaction", "timestamp", "project", "trace", "request.url", "request.method"]
198 | Sort: -timestamp
199 | Dataset: spans
200 | ```
201 | 
202 | ## Common Pitfalls
203 | 
204 | 1. **Mixing aggregate and non-aggregate fields**: Don't include fields like `timestamp` or `id` in aggregate queries
205 | 2. **Wrong sort field**: The field you sort by must be included in the fields array
206 | 3. **Timestamp filters on logs**: Use `statsPeriod` parameter instead of query filters
207 | 4. **Using project slugs**: API requires numeric project IDs, not slugs
208 | 5. **Dataset naming**: Use `logs` in the tool, but API expects `ourlogs`
209 | 
210 | ## Web UI URL Generation
211 | 
212 | The tool automatically generates shareable Sentry web UI URLs after making API calls. These URLs allow users to view results in the Sentry interface:
213 | 
214 | - **Errors dataset**: `/organizations/{org}/discover/results/`
215 | - **Spans dataset**: `/organizations/{org}/explore/traces/`
216 | - **Logs dataset**: `/organizations/{org}/explore/logs/`
217 | 
218 | Note: The web UI URLs use different parameter formats than the API:
219 | - Legacy Discover uses simple field parameters
220 | - Modern Explore uses `aggregateField` with JSON-encoded values
221 | - The tool handles this transformation automatically in `buildDiscoverUrl()` and `buildEapUrl()`
222 | 
223 | ### Web URL Generation Parameters
224 | 
225 | The `getEventsExplorerUrl()` method accepts these parameters to determine URL format:
226 | 
227 | 1. **organizationSlug**: Organization identifier
228 | 2. **query**: The Sentry query string
229 | 3. **projectSlug**: Numeric project ID (optional)
230 | 4. **dataset**: "spans", "errors", or "logs"
231 | 5. **fields**: Array of fields (used to detect if it's an aggregate query)
232 | 6. **sort**: Sort parameter
233 | 7. **aggregateFunctions**: Array of aggregate functions (e.g., `["count()", "avg(span.duration)"]`)
234 | 8. **groupByFields**: Array of fields to group by (e.g., `["span.op", "ai.model.id"]`)
235 | 
236 | Based on these parameters:
237 | - If `aggregateFunctions` has items → generates aggregate query URL
238 | - For errors dataset → routes to Legacy Discover URL format
239 | - For spans/logs datasets → routes to Modern Explore URL format with JSON-encoded `aggregateField` parameters
240 | 
241 | ## API vs Web UI URLs
242 | 
243 | ### Important Distinction
244 | 
245 | The API and Web UI use different parameter formats:
246 | 
247 | **API (Backend)**: Always uses the same format regardless of dataset
248 | - Endpoint: `/api/0/organizations/{org}/events/`
249 | - Parameters: `field`, `query`, `sort`, `dataset`, etc.
250 | - Example: `?dataset=spans&field=span.op&field=count()&sort=-count()`
251 | 
252 | **Web UI (Frontend)**: Different formats for different pages
253 | - Legacy Discover: `/organizations/{org}/discover/results/`
254 | - Modern Explore: `/organizations/{org}/explore/{dataset}/`
255 | - Uses different parameter encoding (e.g., `aggregateField` with JSON for explore pages)
256 | 
257 | ### API Parameter Format
258 | 
259 | The API **always** uses this format for all datasets:
260 | 
261 | **Individual Events:**
262 | ```
263 | ?dataset=spans
264 | &field=id
265 | &field=span.op
266 | &field=span.description
267 | &query=span.op:db
268 | &sort=-timestamp
269 | &statsPeriod=24h
270 | ```
271 | 
272 | **Aggregate Queries:**
273 | ```
274 | ?dataset=spans
275 | &field=span.op
276 | &field=count()
277 | &query=span.op:db*
278 | &sort=-count()
279 | &statsPeriod=24h
280 | ```
281 | 
282 | The only difference between datasets is the `dataset` parameter value and available fields.
283 | 
284 | ## Time Range Filtering
285 | 
286 | All API endpoints support time range filtering using either relative or absolute time parameters:
287 | 
288 | **Relative Time** (`statsPeriod`):
289 | - Format: number + unit (e.g., `1h`, `24h`, `7d`, `30d`)
290 | - Default: `14d` (last 14 days)
291 | - Example: `?statsPeriod=7d`
292 | 
293 | **Absolute Time** (`start` and `end`):
294 | - Format: ISO 8601 timestamps
295 | - Both parameters must be provided together
296 | - Example: `?start=2025-06-19T07:00:00&end=2025-06-20T06:59:59`
297 | 
298 | **Important**: Cannot use both `statsPeriod` and `start`/`end` parameters in the same request.
299 | 
300 | **Applies to**:
301 | - Events API: `/organizations/{org}/events/`
302 | - Tags API: `/organizations/{org}/tags/`
303 | - Trace Items Attributes API: `/organizations/{org}/trace-items/attributes/`
304 | 
305 | ## Attribute Lookup Endpoints
306 | 
307 | ### Overview
308 | 
309 | Before translating queries, the tool fetches available attributes/fields for the organization. This ensures the AI knows about custom attributes specific to the organization.
310 | 
311 | ### Tags Endpoint (Errors Dataset)
312 | 
313 | **Endpoint**: `/api/0/organizations/{org}/tags/`
314 | 
315 | **Parameters**:
316 | - `dataset`: Always `events` for error data
317 | - `project`: Numeric project ID (optional)
318 | - `statsPeriod`: Time range (e.g., `24h`)
319 | - `useCache`: Set to `1` for performance
320 | - `useFlagsBackend`: Set to `1` for latest features
321 | 
322 | **Example**:
323 | ```
324 | https://us.sentry.io/api/0/organizations/sentry/tags/?dataset=events&project=4509062593708032&statsPeriod=24h&useCache=1&useFlagsBackend=1
325 | ```
326 | 
327 | **Response Format**:
328 | ```json
329 | [
330 |   {
331 |     "key": "browser.name",
332 |     "name": "Browser Name"
333 |   },
334 |   {
335 |     "key": "custom.payment_method",
336 |     "name": "Payment Method"
337 |   }
338 | ]
339 | ```
340 | 
341 | **Processing**:
342 | - Filters out `sentry:` prefixed tags (internal tags)
343 | - Maps to key-value pairs for the AI prompt
344 | 
345 | ### Trace Items Attributes Endpoint (Spans/Logs Datasets)
346 | 
347 | **Endpoint**: `/api/0/organizations/{org}/trace-items/attributes/`
348 | 
349 | **Parameters**:
350 | - `itemType`: Either `spans` or `logs` (plural!)
351 | - `attributeType`: Either `string` or `number`
352 | - `project`: Numeric project ID (optional)
353 | - `statsPeriod`: Time range
354 | 
355 | **Examples**:
356 | 
357 | Spans string attributes:
358 | ```
359 | https://us.sentry.io/api/0/organizations/sentry/trace-items/attributes/?attributeType=string&itemType=spans&project=4509062593708032&statsPeriod=24h
360 | ```
361 | 
362 | Spans number attributes:
363 | ```
364 | https://us.sentry.io/api/0/organizations/sentry/trace-items/attributes/?attributeType=number&itemType=spans&project=4509062593708032&statsPeriod=24h
365 | ```
366 | 
367 | Logs string attributes:
368 | ```
369 | https://us.sentry.io/api/0/organizations/sentry/trace-items/attributes/?attributeType=string&itemType=logs&project=4509062593708032&statsPeriod=24h
370 | ```
371 | 
372 | **Response Format**:
373 | ```json
374 | [
375 |   {
376 |     "key": "span.duration",
377 |     "name": "Span Duration",
378 |     "type": "number"
379 |   },
380 |   {
381 |     "key": "ai.model.id",
382 |     "name": "AI Model ID",
383 |     "type": "string"
384 |   }
385 | ]
386 | ```
387 | 
388 | ### Implementation Strategy
389 | 
390 | The tool makes parallel requests to fetch attributes efficiently:
391 | 
392 | 1. **For errors**: Single request to tags endpoint with optimized parameters
393 | 2. **For spans/logs**: Single request that internally fetches both string + number attributes
394 | 
395 | ```typescript
396 | // For errors dataset
397 | const tagsResponse = await apiService.listTags({
398 |   organizationSlug,
399 |   dataset: "events",
400 |   statsPeriod: "14d",
401 |   useCache: true,
402 |   useFlagsBackend: true
403 | });
404 | 
405 | // For spans/logs datasets
406 | const attributesResponse = await apiService.listTraceItemAttributes({
407 |   organizationSlug,
408 |   itemType: "spans", // or "logs"
409 |   statsPeriod: "14d"
410 | });
411 | ```
412 | 
413 | Note: The `listTraceItemAttributes` method internally makes parallel requests for string and number attributes.
414 | 
415 | ### Custom Attributes Integration
416 | 
417 | After fetching, custom attributes are merged with base fields:
418 | 
419 | ```typescript
420 | const allFields = {
421 |   ...BASE_COMMON_FIELDS,      // Common fields across datasets
422 |   ...DATASET_FIELDS[dataset], // Dataset-specific fields
423 |   ...customAttributes         // Organization-specific fields
424 | };
425 | ```
426 | 
427 | This ensures the AI knows about all available fields when translating queries.
428 | 
429 | ### Error Handling
430 | 
431 | If attribute fetching fails:
432 | - The tool continues with just the base fields
433 | - Logs the error for debugging
434 | - Does not fail the entire query
435 | 
436 | This graceful degradation ensures queries still work even if custom attributes can't be fetched.
437 | 
438 | ## Best Practices
439 | 
440 | 1. **Be specific with fields**: Only request fields you need
441 | 2. **Use appropriate limits**: Default 10, max 100 per page
442 | 3. **Leverage aggregate functions**: For summaries and statistics
443 | 4. **Include context fields**: Add fields like `project`, `environment` when grouping
444 | 5. **Sort meaningfully**: Use `-count()` for popularity, `-timestamp` for recency
445 | 6. **Handle custom attributes**: Tool automatically fetches org-specific attributes
446 | 7. **Understand dataset differences**: Each dataset has different capabilities and constraints
447 | 
448 | ## Implementation Details
449 | 
450 | ### Code Architecture
451 | 
452 | The search_events tool handles the complexity of multiple API patterns:
453 | 
454 | 1. **AI Translation Layer**
455 |    - Uses OpenAI GPT-4o to translate natural language to Sentry query syntax
456 |    - Maintains dataset-specific system prompts with examples
457 |    - Temperature set to 0.1 for consistent translations
458 |    - Aggregate functions and groupBy fields are derived from the fields array
459 | 
460 | 2. **Field Handling**
461 |    - Aggregate queries: Only includes aggregate functions and groupBy fields
462 |    - Non-aggregate queries: Uses default fields or AI-specified fields
463 |    - Validates that sort fields are included in the field list
464 |    - Detects aggregate queries by checking for function syntax in fields
465 | 
466 | 3. **Field Type Validation**
467 |    - Validates numeric aggregate functions (avg, sum, min, max, percentiles) are only used with numeric fields
468 |    - Tracks field types from both known fields and custom attributes
469 |    - Returns error messages when invalid combinations are attempted
470 | 
471 | 4. **Web UI URL Generation** (for shareable links)
472 |    - `buildDiscoverUrl()` for errors dataset → creates Discover page URLs
473 |    - `buildEapUrl()` for spans/logs datasets → creates Explore page URLs
474 |    - Transforms API response format to web UI parameter format
475 |    - Note: These methods generate web URLs, not API URLs
476 | 
477 | ### Response Format Differences
478 | 
479 | **Legacy Discover Response (errors):**
480 | ```json
481 | {
482 |   "data": [
483 |     {
484 |       "error.type": "TypeError",
485 |       "count()": 150,
486 |       "last_seen()": "2025-01-16T12:00:00Z"
487 |     }
488 |   ]
489 | }
490 | ```
491 | 
492 | **EAP Response (spans/logs):**
493 | ```json
494 | {
495 |   "data": [
496 |     {
497 |       "span.op": "db.query",
498 |       "count()": 1250,
499 |       "avg(span.duration)": 45.3
500 |     }
501 |   ]
502 | }
503 | ```
504 | 
505 | ## Troubleshooting
506 | 
507 | ### "Ordered by columns not selected" Error
508 | This occurs when sorting by a field not included in the field list. Ensure your sort field is in the fields array.
509 | 
510 | ### Empty Results
511 | - Check query syntax is valid
512 | - Verify time range (`statsPeriod`)
513 | - Ensure project has data for the selected dataset
514 | - Try broadening the query
515 | 
516 | ### API Errors
517 | - 400: Invalid query syntax or parameters (often due to field mismatch in aggregates)
518 | - 404: Project or organization not found
519 | - 500: Internal error (check Sentry status)
```

--------------------------------------------------------------------------------
/packages/mcp-server/src/api-client/schema.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Zod schemas for Sentry API response validation.
  3 |  *
  4 |  * This module contains comprehensive Zod schemas that validate and type-check
  5 |  * responses from Sentry's REST API. All schemas are designed to handle Sentry's
  6 |  * flexible data model where most fields can be null or optional.
  7 |  *
  8 |  * Key Design Principles:
  9 |  * - Use .passthrough() for objects that may contain additional fields
 10 |  * - Support both string and number IDs (Sentry's legacy/modern ID formats)
 11 |  * - Handle nullable fields gracefully throughout the schema hierarchy
 12 |  * - Use union types for polymorphic data (events, assignedTo, etc.)
 13 |  *
 14 |  * Schema Categories:
 15 |  * - **Core Resources**: Users, Organizations, Teams, Projects
 16 |  * - **Issue Management**: Issues, Events, Assignments
 17 |  * - **Release Management**: Releases, Commits, Deployments
 18 |  * - **Search & Discovery**: Tags, Error Search, Span Search
 19 |  * - **Integrations**: Client Keys (DSNs), Autofix
 20 |  *
 21 |  * @example Schema Usage
 22 |  * ```typescript
 23 |  * import { IssueListSchema } from "./schema";
 24 |  *
 25 |  * const response = await fetch("/api/0/organizations/my-org/issues/");
 26 |  * const issues = IssueListSchema.parse(await response.json());
 27 |  * // TypeScript now knows the exact shape of issues
 28 |  * ```
 29 |  *
 30 |  * @example Error Handling
 31 |  * ```typescript
 32 |  * const { data, success, error } = ApiErrorSchema.safeParse(response);
 33 |  * if (success) {
 34 |  *   throw new ApiError(data.detail, statusCode);
 35 |  * }
 36 |  * ```
 37 |  */
 38 | import { z } from "zod";
 39 | 
 40 | /**
 41 |  * Schema for Sentry API error responses.
 42 |  *
 43 |  * Uses .passthrough() to allow additional fields that may be present
 44 |  * in different error scenarios.
 45 |  */
 46 | export const ApiErrorSchema = z
 47 |   .object({
 48 |     detail: z.string(),
 49 |   })
 50 |   .passthrough();
 51 | 
 52 | export const UserSchema = z.object({
 53 |   id: z.union([z.string(), z.number()]),
 54 |   name: z.string().nullable(),
 55 |   email: z.string(),
 56 | });
 57 | 
 58 | export const UserRegionsSchema = z.object({
 59 |   regions: z.array(
 60 |     z.object({
 61 |       name: z.string(),
 62 |       url: z.string().url(),
 63 |     }),
 64 |   ),
 65 | });
 66 | 
 67 | /**
 68 |  * Schema for Sentry organization API responses.
 69 |  *
 70 |  * Handles organizations from both Sentry's Cloud Service and self-hosted installations.
 71 |  * The links object and regionUrl field are optional to support self-hosted Sentry
 72 |  * instances that may not include these fields or return empty values.
 73 |  */
 74 | export const OrganizationSchema = z.object({
 75 |   id: z.union([z.string(), z.number()]),
 76 |   slug: z.string(),
 77 |   name: z.string(),
 78 |   links: z
 79 |     .object({
 80 |       regionUrl: z
 81 |         .string()
 82 |         .refine(
 83 |           (value) => !value || z.string().url().safeParse(value).success,
 84 |           {
 85 |             message:
 86 |               "Must be a valid URL or empty string (for self-hosted Sentry)",
 87 |           },
 88 |         )
 89 |         .optional(),
 90 |       organizationUrl: z.string().url(),
 91 |     })
 92 |     .optional(),
 93 | });
 94 | 
 95 | export const OrganizationListSchema = z.array(OrganizationSchema);
 96 | 
 97 | export const TeamSchema = z.object({
 98 |   id: z.union([z.string(), z.number()]),
 99 |   slug: z.string(),
100 |   name: z.string(),
101 | });
102 | 
103 | export const TeamListSchema = z.array(TeamSchema);
104 | 
105 | export const ProjectSchema = z.object({
106 |   id: z.union([z.string(), z.number()]),
107 |   slug: z.string(),
108 |   name: z.string(),
109 |   platform: z.string().nullable().optional(),
110 | });
111 | 
112 | export const ProjectListSchema = z.array(ProjectSchema);
113 | 
114 | export const ClientKeySchema = z.object({
115 |   id: z.union([z.string(), z.number()]),
116 |   name: z.string(),
117 |   dsn: z.object({
118 |     public: z.string(),
119 |   }),
120 |   isActive: z.boolean(),
121 |   dateCreated: z.string().datetime(),
122 | });
123 | 
124 | export const ClientKeyListSchema = z.array(ClientKeySchema);
125 | 
126 | export const ReleaseSchema = z.object({
127 |   id: z.union([z.string(), z.number()]),
128 |   version: z.string(),
129 |   shortVersion: z.string(),
130 |   dateCreated: z.string().datetime(),
131 |   dateReleased: z.string().datetime().nullable(),
132 |   firstEvent: z.string().datetime().nullable(),
133 |   lastEvent: z.string().datetime().nullable(),
134 |   newGroups: z.number(),
135 |   lastCommit: z
136 |     .object({
137 |       id: z.union([z.string(), z.number()]),
138 |       message: z.string(),
139 |       dateCreated: z.string().datetime(),
140 |       author: z.object({
141 |         name: z.string(),
142 |         email: z.string(),
143 |       }),
144 |     })
145 |     .nullable(),
146 |   lastDeploy: z
147 |     .object({
148 |       id: z.union([z.string(), z.number()]),
149 |       environment: z.string(),
150 |       dateStarted: z.string().datetime().nullable(),
151 |       dateFinished: z.string().datetime().nullable(),
152 |     })
153 |     .nullable(),
154 |   projects: z.array(ProjectSchema),
155 | });
156 | 
157 | export const ReleaseListSchema = z.array(ReleaseSchema);
158 | 
159 | export const TagSchema = z.object({
160 |   key: z.string(),
161 |   name: z.string(),
162 |   totalValues: z.number(),
163 | });
164 | 
165 | export const TagListSchema = z.array(TagSchema);
166 | 
167 | // Schema for assignedTo field - can be a user object, team object, string, or null
168 | export const AssignedToSchema = z.union([
169 |   z.null(),
170 |   z.string(), // username or actor ID
171 |   z
172 |     .object({
173 |       type: z.enum(["user", "team"]),
174 |       id: z.union([z.string(), z.number()]),
175 |       name: z.string(),
176 |       email: z.string().optional(), // only for users
177 |     })
178 |     .passthrough(), // Allow additional fields we might not know about
179 | ]);
180 | 
181 | export const IssueSchema = z.object({
182 |   id: z.union([z.string(), z.number()]),
183 |   shortId: z.string(),
184 |   title: z.string(),
185 |   firstSeen: z.string().datetime(),
186 |   lastSeen: z.string().datetime(),
187 |   count: z.union([z.string(), z.number()]),
188 |   userCount: z.union([z.string(), z.number()]),
189 |   permalink: z.string().url(),
190 |   project: ProjectSchema,
191 |   platform: z.string().nullable().optional(),
192 |   status: z.string(),
193 |   culprit: z.string(),
194 |   type: z.union([z.literal("error"), z.literal("transaction"), z.unknown()]),
195 |   assignedTo: AssignedToSchema.optional(),
196 |   issueType: z.string().optional(),
197 |   issueCategory: z.string().optional(),
198 |   metadata: z
199 |     .object({
200 |       title: z.string().nullable().optional(),
201 |       location: z.string().nullable().optional(),
202 |       value: z.string().nullable().optional(),
203 |     })
204 |     .optional(),
205 | });
206 | 
207 | export const IssueListSchema = z.array(IssueSchema);
208 | 
209 | export const FrameInterface = z
210 |   .object({
211 |     filename: z.string().nullable(),
212 |     function: z.string().nullable(),
213 |     lineNo: z.number().nullable(),
214 |     colNo: z.number().nullable(),
215 |     absPath: z.string().nullable(),
216 |     module: z.string().nullable(),
217 |     // lineno, source code
218 |     context: z.array(z.tuple([z.number(), z.string()])),
219 |     inApp: z.boolean().optional(),
220 |     vars: z.record(z.string(), z.unknown()).optional(),
221 |   })
222 |   .partial();
223 | 
224 | // XXX: Sentry's schema generally speaking is "assume all user input is missing"
225 | // so we need to handle effectively every field being optional or nullable.
226 | export const ExceptionInterface = z
227 |   .object({
228 |     mechanism: z
229 |       .object({
230 |         type: z.string().nullable(),
231 |         handled: z.boolean().nullable(),
232 |       })
233 |       .partial(),
234 |     type: z.string().nullable(),
235 |     value: z.string().nullable(),
236 |     stacktrace: z.object({
237 |       frames: z.array(FrameInterface),
238 |     }),
239 |   })
240 |   .partial();
241 | 
242 | export const ErrorEntrySchema = z
243 |   .object({
244 |     // XXX: Sentry can return either of these. Not sure why we never normalized it.
245 |     values: z.array(ExceptionInterface.optional()),
246 |     value: ExceptionInterface.nullable().optional(),
247 |   })
248 |   .partial();
249 | 
250 | export const RequestEntrySchema = z
251 |   .object({
252 |     method: z.string().nullable(),
253 |     url: z.string().url().nullable(),
254 |     // TODO:
255 |     // query: z.array(z.tuple([z.string(), z.string()])).nullable(),
256 |     // data: z.unknown().nullable(),
257 |     // headers: z.array(z.tuple([z.string(), z.string()])).nullable(),
258 |   })
259 |   .partial();
260 | 
261 | export const MessageEntrySchema = z
262 |   .object({
263 |     formatted: z.string().nullable(),
264 |     message: z.string().nullable(),
265 |     params: z.array(z.unknown()).optional(),
266 |   })
267 |   .partial();
268 | 
269 | export const ThreadEntrySchema = z
270 |   .object({
271 |     id: z.number().nullable(),
272 |     name: z.string().nullable(),
273 |     current: z.boolean().nullable(),
274 |     crashed: z.boolean().nullable(),
275 |     state: z.string().nullable(),
276 |     stacktrace: z
277 |       .object({
278 |         frames: z.array(FrameInterface),
279 |       })
280 |       .nullable(),
281 |   })
282 |   .partial();
283 | 
284 | export const ThreadsEntrySchema = z
285 |   .object({
286 |     values: z.array(ThreadEntrySchema),
287 |   })
288 |   .partial();
289 | 
290 | export const BreadcrumbSchema = z
291 |   .object({
292 |     timestamp: z.string().nullable(),
293 |     type: z.string().nullable(),
294 |     category: z.string().nullable(),
295 |     level: z.string().nullable(),
296 |     message: z.string().nullable(),
297 |     data: z.record(z.unknown()).nullable(),
298 |   })
299 |   .partial();
300 | 
301 | export const BreadcrumbsEntrySchema = z
302 |   .object({
303 |     values: z.array(BreadcrumbSchema),
304 |   })
305 |   .partial();
306 | 
307 | const BaseEventSchema = z.object({
308 |   id: z.string(),
309 |   title: z.string(),
310 |   message: z.string().nullable(),
311 |   platform: z.string().nullable().optional(),
312 |   type: z.unknown(),
313 |   entries: z.array(
314 |     z.union([
315 |       z.object({
316 |         type: z.literal("exception"),
317 |         data: ErrorEntrySchema,
318 |       }),
319 |       z.object({
320 |         type: z.literal("message"),
321 |         data: MessageEntrySchema,
322 |       }),
323 |       z.object({
324 |         type: z.literal("threads"),
325 |         data: ThreadsEntrySchema,
326 |       }),
327 |       z.object({
328 |         type: z.literal("request"),
329 |         data: RequestEntrySchema,
330 |       }),
331 |       z.object({
332 |         type: z.literal("breadcrumbs"),
333 |         data: BreadcrumbsEntrySchema,
334 |       }),
335 |       z.object({
336 |         type: z.literal("spans"),
337 |         data: z.unknown(),
338 |       }),
339 |       z.object({
340 |         type: z.string(),
341 |         data: z.unknown(),
342 |       }),
343 |     ]),
344 |   ),
345 |   contexts: z
346 |     .record(
347 |       z.string(),
348 |       z
349 |         .object({
350 |           type: z.union([
351 |             z.literal("default"),
352 |             z.literal("runtime"),
353 |             z.literal("os"),
354 |             z.literal("trace"),
355 |             z.unknown(),
356 |           ]),
357 |         })
358 |         .passthrough(),
359 |     )
360 |     .optional(),
361 |   // "context" (singular) is the legacy "extra" field for arbitrary user-defined data
362 |   // This is different from "contexts" (plural) which are structured contexts
363 |   context: z.record(z.string(), z.unknown()).optional(),
364 |   tags: z
365 |     .array(
366 |       z.object({
367 |         key: z.string(),
368 |         value: z.string().nullable(),
369 |       }),
370 |     )
371 |     .optional(),
372 |   // The _meta field contains metadata about fields in the response
373 |   // It's safer to type as unknown since its structure varies
374 |   _meta: z.unknown().optional(),
375 |   // dateReceived is when the server received the event (may not be present in all contexts)
376 |   dateReceived: z.string().datetime().optional(),
377 | });
378 | 
379 | export const ErrorEventSchema = BaseEventSchema.omit({
380 |   type: true,
381 | }).extend({
382 |   type: z.literal("error"),
383 |   culprit: z.string().nullable(),
384 |   dateCreated: z.string().datetime(),
385 | });
386 | 
387 | export const DefaultEventSchema = BaseEventSchema.omit({
388 |   type: true,
389 | }).extend({
390 |   type: z.literal("default"),
391 |   culprit: z.string().nullable().optional(),
392 |   dateCreated: z.string().datetime(),
393 | });
394 | 
395 | export const TransactionEventSchema = BaseEventSchema.omit({
396 |   type: true,
397 | }).extend({
398 |   type: z.literal("transaction"),
399 |   occurrence: z
400 |     .object({
401 |       id: z.string().optional(),
402 |       projectId: z.number().optional(),
403 |       eventId: z.string().optional(),
404 |       fingerprint: z.array(z.string()).optional(),
405 |       issueTitle: z.string(),
406 |       subtitle: z.string().optional(),
407 |       resourceId: z.string().nullable().optional(),
408 |       evidenceData: z.record(z.string(), z.any()).optional(),
409 |       evidenceDisplay: z
410 |         .array(
411 |           z.object({
412 |             name: z.string(),
413 |             value: z.string(),
414 |             important: z.boolean().optional(),
415 |           }),
416 |         )
417 |         .optional(),
418 |       type: z.number().optional(),
419 |       detectionTime: z.number().optional(),
420 |       level: z.string().optional(),
421 |       culprit: z.string().nullable(),
422 |       priority: z.number().optional(),
423 |       assignee: z.string().nullable().optional(),
424 |     })
425 |     .nullish(), // Allow both null and undefined
426 | });
427 | 
428 | export const UnknownEventSchema = BaseEventSchema.omit({
429 |   type: true,
430 | }).extend({
431 |   type: z.unknown(),
432 | });
433 | 
434 | // XXX: This API response is kind of a disaster. We are not propagating the appropriate
435 | // columns and it makes this really hard to work with. Errors and Transaction-based issues
436 | // are completely different, for example.
437 | export const EventSchema = z.union([
438 |   ErrorEventSchema,
439 |   DefaultEventSchema,
440 |   TransactionEventSchema,
441 |   UnknownEventSchema,
442 | ]);
443 | 
444 | export const EventsResponseSchema = z.object({
445 |   data: z.array(z.unknown()),
446 |   meta: z
447 |     .object({
448 |       fields: z.record(z.string(), z.string()),
449 |     })
450 |     .passthrough(),
451 | });
452 | 
453 | // https://us.sentry.io/api/0/organizations/sentry/events/?dataset=errors&field=issue&field=title&field=project&field=timestamp&field=trace&per_page=5&query=event.type%3Aerror&referrer=sentry-mcp&sort=-timestamp&statsPeriod=1w
454 | export const ErrorsSearchResponseSchema = EventsResponseSchema.extend({
455 |   data: z.array(
456 |     z.object({
457 |       issue: z.string(),
458 |       "issue.id": z.union([z.string(), z.number()]),
459 |       project: z.string(),
460 |       title: z.string(),
461 |       "count()": z.number(),
462 |       "last_seen()": z.string(),
463 |     }),
464 |   ),
465 | });
466 | 
467 | export const SpansSearchResponseSchema = EventsResponseSchema.extend({
468 |   data: z.array(
469 |     z.object({
470 |       id: z.string(),
471 |       trace: z.string(),
472 |       "span.op": z.string(),
473 |       "span.description": z.string(),
474 |       "span.duration": z.number(),
475 |       transaction: z.string(),
476 |       project: z.string(),
477 |       timestamp: z.string(),
478 |     }),
479 |   ),
480 | });
481 | 
482 | export const AutofixRunSchema = z
483 |   .object({
484 |     run_id: z.union([z.string(), z.number()]),
485 |   })
486 |   .passthrough();
487 | 
488 | const AutofixStatusSchema = z.enum([
489 |   "PENDING",
490 |   "PROCESSING",
491 |   "IN_PROGRESS",
492 |   "NEED_MORE_INFORMATION",
493 |   "COMPLETED",
494 |   "FAILED",
495 |   "ERROR",
496 |   "CANCELLED",
497 |   "WAITING_FOR_USER_RESPONSE",
498 | ]);
499 | 
500 | const AutofixRunStepBaseSchema = z.object({
501 |   type: z.string(),
502 |   key: z.string(),
503 |   index: z.number(),
504 |   status: AutofixStatusSchema,
505 |   title: z.string(),
506 |   output_stream: z.string().nullable(),
507 |   progress: z.array(
508 |     z.object({
509 |       data: z.unknown().nullable(),
510 |       message: z.string(),
511 |       timestamp: z.string(),
512 |       type: z.enum(["INFO", "WARNING", "ERROR"]),
513 |     }),
514 |   ),
515 | });
516 | 
517 | export const AutofixRunStepDefaultSchema = AutofixRunStepBaseSchema.extend({
518 |   type: z.literal("default"),
519 |   insights: z
520 |     .array(
521 |       z.object({
522 |         change_diff: z.unknown().nullable(),
523 |         generated_at_memory_index: z.number(),
524 |         insight: z.string(),
525 |         justification: z.string(),
526 |         type: z.literal("insight"),
527 |       }),
528 |     )
529 |     .nullable(),
530 | }).passthrough();
531 | 
532 | export const AutofixRunStepRootCauseAnalysisSchema =
533 |   AutofixRunStepBaseSchema.extend({
534 |     type: z.literal("root_cause_analysis"),
535 |     causes: z.array(
536 |       z.object({
537 |         description: z.string(),
538 |         id: z.number(),
539 |         root_cause_reproduction: z.array(
540 |           z.object({
541 |             code_snippet_and_analysis: z.string(),
542 |             is_most_important_event: z.boolean(),
543 |             relevant_code_file: z
544 |               .object({
545 |                 file_path: z.string(),
546 |                 repo_name: z.string(),
547 |               })
548 |               .nullable(),
549 |             timeline_item_type: z.string(),
550 |             title: z.string(),
551 |           }),
552 |         ),
553 |       }),
554 |     ),
555 |   }).passthrough();
556 | 
557 | export const AutofixRunStepSolutionSchema = AutofixRunStepBaseSchema.extend({
558 |   type: z.literal("solution"),
559 |   solution: z.array(
560 |     z.object({
561 |       code_snippet_and_analysis: z.string().nullable(),
562 |       is_active: z.boolean(),
563 |       is_most_important_event: z.boolean(),
564 |       relevant_code_file: z.null(),
565 |       timeline_item_type: z.union([
566 |         z.literal("internal_code"),
567 |         z.literal("repro_test"),
568 |       ]),
569 |       title: z.string(),
570 |     }),
571 |   ),
572 | }).passthrough();
573 | 
574 | export const AutofixRunStepSchema = z.union([
575 |   AutofixRunStepDefaultSchema,
576 |   AutofixRunStepRootCauseAnalysisSchema,
577 |   AutofixRunStepSolutionSchema,
578 |   AutofixRunStepBaseSchema.passthrough(),
579 | ]);
580 | 
581 | export const AutofixRunStateSchema = z.object({
582 |   autofix: z
583 |     .object({
584 |       run_id: z.number(),
585 |       request: z.unknown(),
586 |       updated_at: z.string(),
587 |       status: AutofixStatusSchema,
588 |       steps: z.array(AutofixRunStepSchema),
589 |     })
590 |     .passthrough()
591 |     .nullable(),
592 | });
593 | 
594 | export const EventAttachmentSchema = z.object({
595 |   id: z.string(),
596 |   name: z.string(),
597 |   type: z.string(),
598 |   size: z.number(),
599 |   mimetype: z.string(),
600 |   dateCreated: z.string().datetime(),
601 |   sha1: z.string(),
602 |   headers: z.record(z.string(), z.string()).optional(),
603 | });
604 | 
605 | export const EventAttachmentListSchema = z.array(EventAttachmentSchema);
606 | 
607 | /**
608 |  * Schema for Sentry trace metadata response.
609 |  *
610 |  * Contains high-level statistics about a trace including span counts,
611 |  * transaction breakdown, and operation type distribution.
612 |  */
613 | export const TraceMetaSchema = z.object({
614 |   logs: z.number(),
615 |   errors: z.number(),
616 |   performance_issues: z.number(),
617 |   span_count: z.number(),
618 |   transaction_child_count_map: z.array(
619 |     z.object({
620 |       "transaction.event_id": z.string().nullable(),
621 |       "count()": z.number(),
622 |     }),
623 |   ),
624 |   span_count_map: z.record(z.string(), z.number()),
625 | });
626 | 
627 | /**
628 |  * Schema for individual spans within a trace.
629 |  *
630 |  * Represents the hierarchical structure of spans with timing information,
631 |  * operation details, and nested children spans.
632 |  */
633 | export const TraceSpanSchema: z.ZodType<any> = z.lazy(() =>
634 |   z.object({
635 |     children: z.array(TraceSpanSchema),
636 |     errors: z.array(z.any()),
637 |     occurrences: z.array(z.any()),
638 |     event_id: z.string(),
639 |     transaction_id: z.string(),
640 |     project_id: z.union([z.string(), z.number()]),
641 |     project_slug: z.string(),
642 |     profile_id: z.string(),
643 |     profiler_id: z.string(),
644 |     parent_span_id: z.string().nullable(),
645 |     start_timestamp: z.number(),
646 |     end_timestamp: z.number(),
647 |     measurements: z.record(z.string(), z.number()).optional(),
648 |     duration: z.number(),
649 |     transaction: z.string(),
650 |     is_transaction: z.boolean(),
651 |     description: z.string(),
652 |     sdk_name: z.string(),
653 |     op: z.string(),
654 |     name: z.string(),
655 |     event_type: z.string(),
656 |     additional_attributes: z.record(z.string(), z.any()),
657 |   }),
658 | );
659 | 
660 | /**
661 |  * Schema for issue objects that can appear in trace responses.
662 |  *
663 |  * When Sentry's trace API returns standalone errors, they are returned as
664 |  * SerializedIssue objects that lack the span-specific fields.
665 |  */
666 | export const TraceIssueSchema = z
667 |   .object({
668 |     id: z.union([z.string(), z.number()]).optional(),
669 |     issue_id: z.union([z.string(), z.number()]).optional(),
670 |     project_id: z.union([z.string(), z.number()]).optional(),
671 |     project_slug: z.string().optional(),
672 |     title: z.string().optional(),
673 |     culprit: z.string().optional(),
674 |     type: z.string().optional(),
675 |     timestamp: z.union([z.string(), z.number()]).optional(),
676 |   })
677 |   .passthrough();
678 | 
679 | /**
680 |  * Schema for Sentry trace response.
681 |  *
682 |  * Contains the complete trace tree starting from root spans.
683 |  * The response is an array that can contain both root-level spans
684 |  * and standalone issue objects. The Sentry API's query_trace_data
685 |  * function returns a mixed list of SerializedSpan and SerializedIssue
686 |  * objects when there are errors not directly associated with spans.
687 |  */
688 | export const TraceSchema = z.array(
689 |   z.union([TraceSpanSchema, TraceIssueSchema]),
690 | );
691 | 
```

--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/routes/search.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, beforeEach } from "vitest";
  2 | import { Hono } from "hono";
  3 | import searchRoute from "./search";
  4 | import type { Env } from "../types";
  5 | import type { Ai, AutoRagSearchResponse } from "@cloudflare/workers-types";
  6 | 
  7 | // Create mock AutoRAG instance
  8 | interface MockAutoRAG {
  9 |   search: ReturnType<typeof vi.fn>;
 10 | }
 11 | 
 12 | // Create mock AI binding that matches Cloudflare's Ai interface
 13 | const mockAutorag: MockAutoRAG = {
 14 |   search: vi.fn(),
 15 | };
 16 | 
 17 | const mockAIBinding = {
 18 |   autorag: vi.fn(() => mockAutorag),
 19 | } as unknown as Ai;
 20 | 
 21 | // Create test app with mocked environment
 22 | function createTestApp() {
 23 |   const app = new Hono<{ Bindings: Env }>();
 24 |   app.route("/api/search", searchRoute);
 25 | 
 26 |   return app;
 27 | }
 28 | 
 29 | describe("search route", () => {
 30 |   let app: ReturnType<typeof createTestApp>;
 31 | 
 32 |   beforeEach(() => {
 33 |     app = createTestApp();
 34 |     vi.clearAllMocks();
 35 | 
 36 |     // Setup default mock behavior
 37 |     const defaultResponse: AutoRagSearchResponse = {
 38 |       object: "vector_store.search_results.page",
 39 |       search_query: "test query",
 40 |       data: [
 41 |         {
 42 |           file_id: "40d26845-75f9-478c-ab2e-30d30b1b049b",
 43 |           filename: "platforms/javascript/guides/react.md",
 44 |           score: 0.95,
 45 |           attributes: {
 46 |             timestamp: 1750952340000,
 47 |             folder: "platforms/javascript/guides/",
 48 |             filename: "react.md",
 49 |           },
 50 |           content: [
 51 |             {
 52 |               type: "text",
 53 |               text: "This is test documentation content about React setup and configuration.",
 54 |             },
 55 |           ],
 56 |         },
 57 |       ],
 58 |       has_more: false,
 59 |       next_page: null,
 60 |     };
 61 |     mockAutorag.search.mockResolvedValue(defaultResponse);
 62 |   });
 63 | 
 64 |   describe("POST /api/search", () => {
 65 |     it("should return 400 when query is missing", async () => {
 66 |       const res = await app.request(
 67 |         "/api/search",
 68 |         {
 69 |           method: "POST",
 70 |           headers: {
 71 |             "Content-Type": "application/json",
 72 |             "CF-Connecting-IP": "192.0.2.1",
 73 |           },
 74 |           body: JSON.stringify({}),
 75 |         },
 76 |         {
 77 |           AI: mockAIBinding,
 78 |         },
 79 |       );
 80 | 
 81 |       expect(res.status).toBe(400);
 82 |       const json = await res.json();
 83 |       expect(json).toHaveProperty("error", "Invalid request");
 84 |       expect(json).toHaveProperty("details");
 85 |     });
 86 | 
 87 |     it("should return 400 when query is empty", async () => {
 88 |       const res = await app.request(
 89 |         "/api/search",
 90 |         {
 91 |           method: "POST",
 92 |           headers: {
 93 |             "Content-Type": "application/json",
 94 |             "CF-Connecting-IP": "192.0.2.1",
 95 |           },
 96 |           body: JSON.stringify({ query: "" }),
 97 |         },
 98 |         {
 99 |           AI: mockAIBinding,
100 |         },
101 |       );
102 | 
103 |       expect(res.status).toBe(400);
104 |       const json = await res.json();
105 |       expect(json).toHaveProperty("error", "Invalid request");
106 |     });
107 | 
108 |     it("should return 400 when maxResults is out of range", async () => {
109 |       const res = await app.request(
110 |         "/api/search",
111 |         {
112 |           method: "POST",
113 |           headers: {
114 |             "Content-Type": "application/json",
115 |             "CF-Connecting-IP": "192.0.2.1",
116 |           },
117 |           body: JSON.stringify({ query: "test", maxResults: 15 }),
118 |         },
119 |         {
120 |           AI: mockAIBinding,
121 |         },
122 |       );
123 | 
124 |       expect(res.status).toBe(400);
125 |       const json = await res.json();
126 |       expect(json).toHaveProperty("error", "Invalid request");
127 |     });
128 | 
129 |     it("should return 503 when AI binding is not available", async () => {
130 |       const res = await app.request(
131 |         "/api/search",
132 |         {
133 |           method: "POST",
134 |           headers: {
135 |             "Content-Type": "application/json",
136 |             "CF-Connecting-IP": "192.0.2.1",
137 |           },
138 |           body: JSON.stringify({ query: "test" }),
139 |         },
140 |         {
141 |           AI: null as unknown as Ai,
142 |         },
143 |       );
144 | 
145 |       expect(res.status).toBe(503);
146 |       const json = await res.json();
147 |       expect(json).toEqual({
148 |         error: "AI service not available",
149 |         name: "AI_SERVICE_UNAVAILABLE",
150 |       });
151 |     });
152 | 
153 |     it("should successfully search with default parameters", async () => {
154 |       const res = await app.request(
155 |         "/api/search",
156 |         {
157 |           method: "POST",
158 |           headers: {
159 |             "Content-Type": "application/json",
160 |             "CF-Connecting-IP": "192.0.2.1",
161 |           },
162 |           body: JSON.stringify({ query: "rate limiting" }),
163 |         },
164 |         {
165 |           AI: mockAIBinding,
166 |         },
167 |       );
168 | 
169 |       expect(res.status).toBe(200);
170 |       const json = await res.json();
171 | 
172 |       expect(mockAIBinding.autorag).toHaveBeenCalledWith("sentry-docs");
173 |       expect(mockAutorag.search).toHaveBeenCalledWith({
174 |         query: "rate limiting",
175 |         max_num_results: 10,
176 |         ranking_options: {
177 |           score_threshold: 0.2,
178 |         },
179 |       });
180 | 
181 |       expect(json).toMatchObject({
182 |         query: "rate limiting",
183 |         results: [
184 |           {
185 |             id: "platforms/javascript/guides/react.md",
186 |             url: "https://docs.sentry.io/platforms/javascript/guides/react",
187 |             snippet:
188 |               "This is test documentation content about React setup and configuration.",
189 |             relevance: 0.95,
190 |           },
191 |         ],
192 |       });
193 |     });
194 | 
195 |     it("should filter by platform for platform/guide combination", async () => {
196 |       const res = await app.request(
197 |         "/api/search",
198 |         {
199 |           method: "POST",
200 |           headers: {
201 |             "Content-Type": "application/json",
202 |             "CF-Connecting-IP": "192.0.2.1",
203 |           },
204 |           body: JSON.stringify({
205 |             query: "setup configuration",
206 |             guide: "javascript/nextjs",
207 |           }),
208 |         },
209 |         {
210 |           AI: mockAIBinding,
211 |         },
212 |       );
213 | 
214 |       expect(res.status).toBe(200);
215 | 
216 |       expect(mockAutorag.search).toHaveBeenCalledWith({
217 |         query: "setup configuration",
218 |         max_num_results: 10,
219 |         ranking_options: {
220 |           score_threshold: 0.2,
221 |         },
222 |         filters: {
223 |           type: "and",
224 |           filters: [
225 |             {
226 |               type: "gte",
227 |               key: "folder",
228 |               value: "platforms/javascript/guides/nextjs/",
229 |             },
230 |             {
231 |               type: "lte",
232 |               key: "folder",
233 |               value: "platforms/javascript/guides/nextjs/z",
234 |             },
235 |           ],
236 |         },
237 |       });
238 |     });
239 | 
240 |     it("should filter by platform for platform only", async () => {
241 |       const res = await app.request(
242 |         "/api/search",
243 |         {
244 |           method: "POST",
245 |           headers: {
246 |             "Content-Type": "application/json",
247 |             "CF-Connecting-IP": "192.0.2.1",
248 |           },
249 |           body: JSON.stringify({
250 |             query: "setup configuration",
251 |             guide: "python",
252 |           }),
253 |         },
254 |         {
255 |           AI: mockAIBinding,
256 |         },
257 |       );
258 | 
259 |       expect(res.status).toBe(200);
260 | 
261 |       expect(mockAutorag.search).toHaveBeenCalledWith({
262 |         query: "setup configuration",
263 |         max_num_results: 10,
264 |         ranking_options: {
265 |           score_threshold: 0.2,
266 |         },
267 |         filters: {
268 |           type: "and",
269 |           filters: [
270 |             {
271 |               type: "gte",
272 |               key: "folder",
273 |               value: "platforms/python/",
274 |             },
275 |             {
276 |               type: "lte",
277 |               key: "folder",
278 |               value: "platforms/python/z",
279 |             },
280 |           ],
281 |         },
282 |       });
283 |     });
284 | 
285 |     it("should handle custom maxResults parameter", async () => {
286 |       const res = await app.request(
287 |         "/api/search",
288 |         {
289 |           method: "POST",
290 |           headers: {
291 |             "Content-Type": "application/json",
292 |             "CF-Connecting-IP": "192.0.2.1",
293 |           },
294 |           body: JSON.stringify({ query: "error handling", maxResults: 5 }),
295 |         },
296 |         {
297 |           AI: mockAIBinding,
298 |         },
299 |       );
300 | 
301 |       expect(res.status).toBe(200);
302 |       expect(mockAutorag.search).toHaveBeenCalledWith({
303 |         query: "error handling",
304 |         max_num_results: 5,
305 |         ranking_options: {
306 |           score_threshold: 0.2,
307 |         },
308 |       });
309 |     });
310 | 
311 |     it("should handle empty search results", async () => {
312 |       mockAutorag.search.mockResolvedValue({
313 |         object: "vector_store.search_results.page",
314 |         search_query: "test query",
315 |         data: [],
316 |         has_more: false,
317 |         next_page: null,
318 |       });
319 | 
320 |       const res = await app.request(
321 |         "/api/search",
322 |         {
323 |           method: "POST",
324 |           headers: {
325 |             "Content-Type": "application/json",
326 |             "CF-Connecting-IP": "192.0.2.1",
327 |           },
328 |           body: JSON.stringify({ query: "nonexistent topic" }),
329 |         },
330 |         {
331 |           AI: mockAIBinding,
332 |         },
333 |       );
334 | 
335 |       expect(res.status).toBe(200);
336 |       const json = await res.json();
337 |       expect(json).toMatchObject({
338 |         query: "nonexistent topic",
339 |         results: [],
340 |       });
341 |     });
342 | 
343 |     it("should handle AutoRAG search errors gracefully", async () => {
344 |       mockAutorag.search.mockRejectedValue(new Error("AutoRAG API error"));
345 | 
346 |       const res = await app.request(
347 |         "/api/search",
348 |         {
349 |           method: "POST",
350 |           headers: {
351 |             "Content-Type": "application/json",
352 |             "CF-Connecting-IP": "192.0.2.1",
353 |           },
354 |           body: JSON.stringify({ query: "test" }),
355 |         },
356 |         {
357 |           AI: mockAIBinding,
358 |         },
359 |       );
360 | 
361 |       expect(res.status).toBe(500);
362 |       const json = await res.json();
363 |       expect(json).toMatchObject({
364 |         error: "Failed to search documentation. Please try again later.",
365 |         name: "SEARCH_FAILED",
366 |       });
367 |     });
368 | 
369 |     it("should extract documentation paths correctly", async () => {
370 |       mockAutorag.search.mockResolvedValue({
371 |         object: "vector_store.search_results.page",
372 |         search_query: "test query",
373 |         data: [
374 |           {
375 |             file_id: "id-1",
376 |             filename: "platforms/javascript/index.md",
377 |             score: 0.9,
378 |             attributes: {
379 |               timestamp: 1750952340000,
380 |               folder: "platforms/javascript/",
381 |               filename: "index.md",
382 |             },
383 |             content: [
384 |               {
385 |                 type: "text",
386 |                 text: "Content 1",
387 |               },
388 |             ],
389 |           },
390 |           {
391 |             file_id: "id-2",
392 |             filename: "product/issues.md",
393 |             score: 0.8,
394 |             attributes: {
395 |               timestamp: 1750952340000,
396 |               folder: "product/",
397 |               filename: "issues.md",
398 |             },
399 |             content: [
400 |               {
401 |                 type: "text",
402 |                 text: "Content 2",
403 |               },
404 |             ],
405 |           },
406 |         ],
407 |         has_more: false,
408 |         next_page: null,
409 |       });
410 | 
411 |       const res = await app.request(
412 |         "/api/search",
413 |         {
414 |           method: "POST",
415 |           headers: {
416 |             "Content-Type": "application/json",
417 |             "CF-Connecting-IP": "192.0.2.1",
418 |           },
419 |           body: JSON.stringify({ query: "test" }),
420 |         },
421 |         {
422 |           AI: mockAIBinding,
423 |         },
424 |       );
425 | 
426 |       expect(res.status).toBe(200);
427 |       const json = (await res.json()) as {
428 |         results: Array<{ id: string; url: string }>;
429 |       };
430 |       expect(json.results[0]).toMatchInlineSnapshot(
431 |         {
432 |           id: "platforms/javascript/index.md",
433 |           url: "https://docs.sentry.io/platforms/javascript/index",
434 |         },
435 |         `
436 |         {
437 |           "id": "platforms/javascript/index.md",
438 |           "relevance": 0.9,
439 |           "snippet": "Content 1",
440 |           "url": "https://docs.sentry.io/platforms/javascript/index",
441 |         }
442 |       `,
443 |       );
444 |       expect(json.results[1]).toMatchInlineSnapshot(`
445 |         {
446 |           "id": "product/issues.md",
447 |           "relevance": 0.8,
448 |           "snippet": "Content 2",
449 |           "url": "https://docs.sentry.io/product/issues",
450 |         }
451 |       `);
452 |     });
453 | 
454 |     it("should handle index.md files correctly", async () => {
455 |       mockAutorag.search.mockResolvedValue({
456 |         object: "vector_store.search_results.page",
457 |         search_query: "test query",
458 |         data: [
459 |           {
460 |             file_id: "root-id",
461 |             filename: "index.md",
462 |             score: 0.9,
463 |             attributes: {
464 |               timestamp: 1750952340000,
465 |               folder: "",
466 |               filename: "index.md",
467 |             },
468 |             content: [
469 |               {
470 |                 type: "text",
471 |                 text: "Root documentation content",
472 |               },
473 |             ],
474 |           },
475 |         ],
476 |         has_more: false,
477 |         next_page: null,
478 |       });
479 | 
480 |       const res = await app.request(
481 |         "/api/search",
482 |         {
483 |           method: "POST",
484 |           headers: {
485 |             "Content-Type": "application/json",
486 |             "CF-Connecting-IP": "192.0.2.1",
487 |           },
488 |           body: JSON.stringify({ query: "test" }),
489 |         },
490 |         {
491 |           AI: mockAIBinding,
492 |         },
493 |       );
494 | 
495 |       expect(res.status).toBe(200);
496 |       const json = (await res.json()) as {
497 |         results: Array<{ id: string; url: string }>;
498 |       };
499 |       expect(json.results[0].id).toBe("index.md");
500 |       expect(json.results[0].url).toBe("https://docs.sentry.io/index");
501 |     });
502 | 
503 |     it("should handle missing metadata fields", async () => {
504 |       mockAutorag.search.mockResolvedValue({
505 |         object: "vector_store.search_results.page",
506 |         search_query: "test query",
507 |         data: [
508 |           {
509 |             // Missing filename, should use index.md as fallback
510 |             file_id: "some-id",
511 |             score: 0.5,
512 |             attributes: {},
513 |             content: [
514 |               {
515 |                 type: "text",
516 |                 text: "Content without metadata",
517 |               },
518 |             ],
519 |           },
520 |         ],
521 |         has_more: false,
522 |         next_page: null,
523 |       });
524 | 
525 |       const res = await app.request(
526 |         "/api/search",
527 |         {
528 |           method: "POST",
529 |           headers: {
530 |             "Content-Type": "application/json",
531 |             "CF-Connecting-IP": "192.0.2.1",
532 |           },
533 |           body: JSON.stringify({ query: "test" }),
534 |         },
535 |         {
536 |           AI: mockAIBinding,
537 |         },
538 |       );
539 | 
540 |       expect(res.status).toBe(200);
541 |       const json = (await res.json()) as {
542 |         results: Array<{ id: string; url: string }>;
543 |       };
544 |       expect(json.results[0]).toMatchInlineSnapshot(`
545 |         {
546 |           "id": "",
547 |           "relevance": 0.5,
548 |           "snippet": "Content without metadata",
549 |           "url": "",
550 |         }
551 |       `);
552 |     });
553 | 
554 |     it("should handle unexpected response structure", async () => {
555 |       mockAutorag.search.mockResolvedValue({
556 |         // Missing expected fields
557 |         unexpected: "response",
558 |       });
559 | 
560 |       const res = await app.request(
561 |         "/api/search",
562 |         {
563 |           method: "POST",
564 |           headers: {
565 |             "Content-Type": "application/json",
566 |             "CF-Connecting-IP": "192.0.2.1",
567 |           },
568 |           body: JSON.stringify({ query: "test" }),
569 |         },
570 |         {
571 |           AI: mockAIBinding,
572 |         },
573 |       );
574 | 
575 |       expect(res.status).toBe(200);
576 |       const json = await res.json();
577 |       expect(json).toMatchInlineSnapshot(`
578 |         {
579 |           "query": "test",
580 |           "results": [],
581 |         }
582 |       `);
583 |     });
584 |   });
585 | 
586 |   describe("rate limiting", () => {
587 |     it("should allow requests when rate limiter is not configured", async () => {
588 |       const res = await app.request(
589 |         "/api/search",
590 |         {
591 |           method: "POST",
592 |           headers: {
593 |             "Content-Type": "application/json",
594 |             "CF-Connecting-IP": "192.0.2.1",
595 |           },
596 |           body: JSON.stringify({ query: "test query" }),
597 |         },
598 |         {
599 |           AI: mockAIBinding,
600 |           // No SEARCH_RATE_LIMITER binding
601 |         },
602 |       );
603 | 
604 |       expect(res.status).toBe(200);
605 |     });
606 | 
607 |     it("should allow requests when rate limit is not exceeded", async () => {
608 |       const mockRateLimiter = {
609 |         limit: vi.fn().mockResolvedValue({ success: true }),
610 |       };
611 | 
612 |       const res = await app.request(
613 |         "/api/search",
614 |         {
615 |           method: "POST",
616 |           headers: {
617 |             "Content-Type": "application/json",
618 |             "CF-Connecting-IP": "192.0.2.1",
619 |           },
620 |           body: JSON.stringify({ query: "test query" }),
621 |         },
622 |         {
623 |           AI: mockAIBinding,
624 |           SEARCH_RATE_LIMITER: mockRateLimiter,
625 |         },
626 |       );
627 | 
628 |       expect(res.status).toBe(200);
629 |       expect(mockRateLimiter.limit).toHaveBeenCalledWith({
630 |         key: expect.stringMatching(/^search:ip:[a-f0-9]{16}$/),
631 |       });
632 |     });
633 | 
634 |     it("should reject requests when rate limit is exceeded", async () => {
635 |       const mockRateLimiter = {
636 |         limit: vi.fn().mockResolvedValue({ success: false }),
637 |       };
638 | 
639 |       const res = await app.request(
640 |         "/api/search",
641 |         {
642 |           method: "POST",
643 |           headers: {
644 |             "Content-Type": "application/json",
645 |             "CF-Connecting-IP": "192.0.2.1",
646 |           },
647 |           body: JSON.stringify({ query: "test query" }),
648 |         },
649 |         {
650 |           AI: mockAIBinding,
651 |           SEARCH_RATE_LIMITER: mockRateLimiter,
652 |         },
653 |       );
654 | 
655 |       expect(res.status).toBe(429);
656 |       const json = await res.json();
657 |       expect(json).toMatchInlineSnapshot(`
658 |         {
659 |           "error": "Rate limit exceeded. You can perform up to 20 documentation searches per minute. Please wait before searching again.",
660 |           "name": "RATE_LIMIT_EXCEEDED",
661 |         }
662 |       `);
663 |     });
664 | 
665 |     it("should handle rate limiter errors gracefully", async () => {
666 |       const mockRateLimiter = {
667 |         limit: vi
668 |           .fn()
669 |           .mockRejectedValue(new Error("Rate limiter connection failed")),
670 |       };
671 | 
672 |       const res = await app.request(
673 |         "/api/search",
674 |         {
675 |           method: "POST",
676 |           headers: {
677 |             "Content-Type": "application/json",
678 |             "CF-Connecting-IP": "192.0.2.1",
679 |           },
680 |           body: JSON.stringify({ query: "test query" }),
681 |         },
682 |         {
683 |           AI: mockAIBinding,
684 |           SEARCH_RATE_LIMITER: mockRateLimiter,
685 |         },
686 |       );
687 | 
688 |       expect(res.status).toBe(500);
689 |       const json = await res.json();
690 |       expect(json).toMatchObject({
691 |         error: "There was an error communicating with the rate limiter.",
692 |         name: "RATE_LIMITER_ERROR",
693 |       });
694 |     });
695 | 
696 |     it("should use different rate limit keys for different IPs", async () => {
697 |       const mockRateLimiter = {
698 |         limit: vi.fn().mockResolvedValue({ success: true }),
699 |       };
700 | 
701 |       // First request from IP 192.0.2.1
702 |       await app.request(
703 |         "/api/search",
704 |         {
705 |           method: "POST",
706 |           headers: {
707 |             "Content-Type": "application/json",
708 |             "CF-Connecting-IP": "192.0.2.1",
709 |           },
710 |           body: JSON.stringify({ query: "test query" }),
711 |         },
712 |         {
713 |           AI: mockAIBinding,
714 |           SEARCH_RATE_LIMITER: mockRateLimiter,
715 |         },
716 |       );
717 | 
718 |       const firstKey = mockRateLimiter.limit.mock.calls[0][0].key;
719 | 
720 |       // Second request from IP 192.0.2.2
721 |       await app.request(
722 |         "/api/search",
723 |         {
724 |           method: "POST",
725 |           headers: {
726 |             "Content-Type": "application/json",
727 |             "CF-Connecting-IP": "192.0.2.2",
728 |           },
729 |           body: JSON.stringify({ query: "test query" }),
730 |         },
731 |         {
732 |           AI: mockAIBinding,
733 |           SEARCH_RATE_LIMITER: mockRateLimiter,
734 |         },
735 |       );
736 | 
737 |       const secondKey = mockRateLimiter.limit.mock.calls[1][0].key;
738 | 
739 |       expect(firstKey).not.toBe(secondKey);
740 |       expect(firstKey).toMatch(/^search:ip:[a-f0-9]{16}$/);
741 |       expect(secondKey).toMatch(/^search:ip:[a-f0-9]{16}$/);
742 |     });
743 |   });
744 | 
745 |   describe("configurable index name", () => {
746 |     it("should use default index name when AUTORAG_INDEX_NAME is not set", async () => {
747 |       const res = await app.request(
748 |         "/api/search",
749 |         {
750 |           method: "POST",
751 |           headers: {
752 |             "Content-Type": "application/json",
753 |             "CF-Connecting-IP": "192.0.2.1",
754 |           },
755 |           body: JSON.stringify({ query: "test query" }),
756 |         },
757 |         {
758 |           AI: mockAIBinding,
759 |           // No AUTORAG_INDEX_NAME environment variable
760 |         },
761 |       );
762 | 
763 |       expect(res.status).toBe(200);
764 |       expect(mockAIBinding.autorag).toHaveBeenCalledWith("sentry-docs");
765 |     });
766 | 
767 |     it("should use custom index name when AUTORAG_INDEX_NAME is set", async () => {
768 |       const res = await app.request(
769 |         "/api/search",
770 |         {
771 |           method: "POST",
772 |           headers: {
773 |             "Content-Type": "application/json",
774 |             "CF-Connecting-IP": "192.0.2.1",
775 |           },
776 |           body: JSON.stringify({ query: "test query" }),
777 |         },
778 |         {
779 |           AI: mockAIBinding,
780 |           AUTORAG_INDEX_NAME: "custom-docs-index",
781 |         },
782 |       );
783 | 
784 |       expect(res.status).toBe(200);
785 |       expect(mockAIBinding.autorag).toHaveBeenCalledWith("custom-docs-index");
786 |     });
787 | 
788 |     it("should use default index name when AUTORAG_INDEX_NAME is empty", async () => {
789 |       const res = await app.request(
790 |         "/api/search",
791 |         {
792 |           method: "POST",
793 |           headers: {
794 |             "Content-Type": "application/json",
795 |             "CF-Connecting-IP": "192.0.2.1",
796 |           },
797 |           body: JSON.stringify({ query: "test query" }),
798 |         },
799 |         {
800 |           AI: mockAIBinding,
801 |           AUTORAG_INDEX_NAME: "",
802 |         },
803 |       );
804 | 
805 |       expect(res.status).toBe(200);
806 |       expect(mockAIBinding.autorag).toHaveBeenCalledWith("sentry-docs");
807 |     });
808 |   });
809 | });
810 | 
```
Page 10/15FirstPrevNextLast