#
tokens: 48575/50000 7/408 files (page 8/11)
lines: off (toggle) GitHub
raw markdown copy
This is page 8 of 11. Use http://codebase.md/getsentry/sentry-mcp?page={x} to view the full context.

# Directory Structure

```
├── .claude
│   ├── agents
│   │   └── claude-optimizer.md
│   ├── commands
│   │   ├── gh-pr.md
│   │   └── gh-review.md
│   └── settings.json
├── .craft.yml
├── .cursor
│   └── mcp.json
├── .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
│   │   ├── deployment.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-handler.test.ts
│   │   │   │   │   ├── mcp-handler.ts
│   │   │   │   │   └── slug-validation.ts
│   │   │   │   ├── logging.ts
│   │   │   │   ├── oauth
│   │   │   │   │   ├── authorize.test.ts
│   │   │   │   │   ├── callback.test.ts
│   │   │   │   │   ├── constants.ts
│   │   │   │   │   ├── helpers.test.ts
│   │   │   │   │   ├── helpers.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── routes
│   │   │   │   │   │   ├── authorize.ts
│   │   │   │   │   │   ├── callback.ts
│   │   │   │   │   │   └── index.ts
│   │   │   │   │   └── state.ts
│   │   │   │   ├── routes
│   │   │   │   │   ├── chat-oauth.ts
│   │   │   │   │   ├── chat.ts
│   │   │   │   │   ├── mcp.ts
│   │   │   │   │   ├── metadata.ts
│   │   │   │   │   ├── search.test.ts
│   │   │   │   │   └── search.ts
│   │   │   │   ├── sentry.config.ts
│   │   │   │   ├── types
│   │   │   │   │   └── chat.ts
│   │   │   │   ├── types.ts
│   │   │   │   └── utils
│   │   │   │       └── auth-errors.ts
│   │   │   └── test-setup.ts
│   │   ├── tsconfig.client.json
│   │   ├── tsconfig.json
│   │   ├── tsconfig.node.json
│   │   ├── tsconfig.server.json
│   │   ├── vite.config.ts
│   │   ├── vitest.config.ts
│   │   ├── worker-configuration.d.ts
│   │   ├── wrangler.canary.jsonc
│   │   └── wrangler.jsonc
│   ├── mcp-server
│   │   ├── package.json
│   │   ├── README.md
│   │   ├── scripts
│   │   │   ├── generate-definitions.ts
│   │   │   └── generate-otel-namespaces.ts
│   │   ├── 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
│   │   │   │   ├── context-storage.ts
│   │   │   │   ├── error-handling.ts
│   │   │   │   ├── fetch-utils.test.ts
│   │   │   │   ├── fetch-utils.ts
│   │   │   │   ├── formatting.test.ts
│   │   │   │   ├── formatting.ts
│   │   │   │   ├── issue-helpers.test.ts
│   │   │   │   ├── issue-helpers.ts
│   │   │   │   ├── test-fixtures.ts
│   │   │   │   └── tool-helpers
│   │   │   │       ├── api.test.ts
│   │   │   │       ├── api.ts
│   │   │   │       ├── define.ts
│   │   │   │       ├── enhance-error.ts
│   │   │   │       ├── formatting.ts
│   │   │   │       ├── issue.ts
│   │   │   │       ├── seer.test.ts
│   │   │   │       ├── seer.ts
│   │   │   │       ├── validate-region-url.test.ts
│   │   │   │       └── validate-region-url.ts
│   │   │   ├── permissions.parseScopes.test.ts
│   │   │   ├── permissions.ts
│   │   │   ├── schema.ts
│   │   │   ├── server-context.test.ts
│   │   │   ├── server.ts
│   │   │   ├── telem
│   │   │   │   ├── index.ts
│   │   │   │   ├── logging.ts
│   │   │   │   ├── sentry.test.ts
│   │   │   │   └── sentry.ts
│   │   │   ├── test-setup.ts
│   │   │   ├── test-utils
│   │   │   │   └── context.ts
│   │   │   ├── toolDefinitions.ts
│   │   │   ├── tools
│   │   │   │   ├── 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

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

```markdown
# Search Events API Patterns

## Overview

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.

## API Architecture

### Legacy Discover API vs Modern EAP API

Sentry uses two different API architectures depending on the dataset:

1. **Legacy Discover API** (errors dataset)
   - Uses the original Discover query format
   - Simpler aggregate field handling
   - Returns data in a different format

2. **Modern EAP (Event Analytics Platform) API** (spans, logs datasets)
   - Uses structured aggregate parameters
   - More sophisticated query capabilities
   - Different URL generation patterns

### API Endpoint

All queries use the same base endpoint:
```
/api/0/organizations/{organizationSlug}/events/
```

### Dataset Mapping

The tool handles dataset name mapping internally:
- User specifies `errors` → API uses `errors` (Legacy Discover)
- User specifies `spans` → API uses `spans` (EAP)
- User specifies `logs` → API uses `ourlogs` (EAP) ⚠️ Note the transformation!

## Query Modes

### 1. Individual Events (Samples)

Returns raw event data with full details. This is the default mode when no aggregate functions are used.

**Key Characteristics:**
- Returns actual event occurrences
- Includes default fields plus any user-requested fields
- Sorted by timestamp (newest first) by default
- Limited to a specific number of results (default: 10, max: 100)

**Example API URL:**
```
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
```

**Default Fields by Dataset:**

- **Spans**: `id`, `span.op`, `span.description`, `span.duration`, `transaction`, `timestamp`, `project`, `trace`
- **Errors**: `issue`, `title`, `project`, `timestamp`, `level`, `message`, `error.type`, `culprit`
- **Logs**: `timestamp`, `project`, `message`, `severity`, `trace`

### 2. Aggregate Queries (Statistics)

Returns grouped and aggregated data, similar to SQL GROUP BY queries.

**Key Characteristics:**
- Activated when ANY field contains a function (e.g., `count()`, `avg()`)
- Fields should ONLY include aggregate functions and groupBy fields
- Do NOT include default fields (id, timestamp, etc.)
- Automatically groups by all non-function fields

**Example API URLs:**

Single groupBy field:
```
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
```

Multiple groupBy fields:
```
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
```

## Query Parameters

### Common Parameters

| Parameter | Description | Example |
|-----------|-------------|---------|
| `dataset` | Which dataset to query | `spans`, `errors`, `logs` (API uses `ourlogs`) |
| `field` | Fields to return (repeated for each field) | `field=span.op&field=count()` |
| `query` | Sentry query syntax filter | `has:db.statement AND span.duration:>1000` |
| `sort` | Sort order (prefix with `-` for descending) | `-timestamp`, `-count()` |
| `per_page` | Results per page | `50` |
| `statsPeriod` | Relative time window filter | `1h`, `24h`, `7d`, `14d`, `30d` |
| `start` | Absolute start time (ISO 8601) | `2025-06-19T07:00:00` |
| `end` | Absolute end time (ISO 8601) | `2025-06-20T06:59:59` |
| `project` | Project ID (numeric, not slug) | `4509062593708032` |


### Dataset-Specific Considerations

#### Spans Dataset
- Supports timestamp filters in query (e.g., `timestamp:-1h`)
- Rich performance metrics available
- Common aggregate functions: `count()`, `avg(span.duration)`, `p95(span.duration)`

#### Errors Dataset  
- Supports timestamp filters in query
- Issue grouping available via `issue` field
- Common aggregate functions: `count()`, `count_unique(user.id)`, `last_seen()`

#### Logs Dataset
- Does NOT support timestamp filters in query (use `statsPeriod` instead)
- Severity levels: fatal, error, warning, info, debug, trace
- Common aggregate functions: `count()`, `epm()`
- Uses `ourlogs` as the actual API dataset value (not `logs`)

## Query Syntax

### Basic Filters
- Exact match: `field:value`
- Wildcards: `field:*pattern*`
- Comparison: `field:>100`, `field:<500`
- Boolean: `AND`, `OR`, `NOT`
- Phrases: `message:"database connection failed"`
- Attribute existence: `has:field` (recommended for spans)

### Attribute-Based Queries (Recommended for Spans)
Instead of using `span.op` patterns, use `has:` queries for more flexible attribute-based filtering:
- HTTP requests: `has:request.url` instead of `span.op:http*`
- Database queries: `has:db.statement` or `has:db.system` instead of `span.op:db*`
- AI/LLM calls: `has:ai.model.id` or `has:mcp.tool.name`

### Aggregate Functions

#### Universal Functions (all datasets)
- `count()` - Count of events
- `count_unique(field)` - Count of unique values
- `epm()` - Events per minute rate

#### Numeric Field Functions (spans, logs)
- `avg(field)` - Average value
- `sum(field)` - Sum of values
- `min(field)` - Minimum value
- `max(field)` - Maximum value
- `p50(field)`, `p75(field)`, `p90(field)`, `p95(field)`, `p99(field)` - Percentiles

#### Errors-Specific Functions
- `count_if(field,equals,value)` - Conditional count
- `last_seen()` - Most recent timestamp
- `eps()` - Events per second rate

## Examples

### Find Database Queries (Individual Events)
```
Query: has:db.statement
Fields: ["id", "span.op", "span.description", "span.duration", "transaction", "timestamp", "project", "trace", "db.system", "db.statement"]
Sort: -span.duration
Dataset: spans
```

### Top 10 Slowest API Endpoints (Aggregate)
```
Query: is_transaction:true
Fields: ["transaction", "count()", "avg(span.duration)", "p95(span.duration)"]
Sort: -avg(span.duration)
Dataset: spans
```

### Error Count by Type (Aggregate)
```
Query: level:error
Fields: ["error.type", "count()"]
Sort: -count()
Dataset: errors
```

### Logs by Severity (Aggregate)
```
Query: (empty)
Fields: ["severity", "count()", "epm()"]
Sort: -count()
Dataset: logs
```

### Tool Calls by Model (Aggregate)
```
Query: has:mcp.tool.name
Fields: ["ai.model.id", "mcp.tool.name", "count()"]
Sort: -count()
Dataset: spans
```

### HTTP Requests (Individual Events)
```
Query: has:request.url
Fields: ["id", "span.op", "span.description", "span.duration", "transaction", "timestamp", "project", "trace", "request.url", "request.method"]
Sort: -timestamp
Dataset: spans
```

## Common Pitfalls

1. **Mixing aggregate and non-aggregate fields**: Don't include fields like `timestamp` or `id` in aggregate queries
2. **Wrong sort field**: The field you sort by must be included in the fields array
3. **Timestamp filters on logs**: Use `statsPeriod` parameter instead of query filters
4. **Using project slugs**: API requires numeric project IDs, not slugs
5. **Dataset naming**: Use `logs` in the tool, but API expects `ourlogs`

## Web UI URL Generation

The tool automatically generates shareable Sentry web UI URLs after making API calls. These URLs allow users to view results in the Sentry interface:

- **Errors dataset**: `/organizations/{org}/discover/results/`
- **Spans dataset**: `/organizations/{org}/explore/traces/`
- **Logs dataset**: `/organizations/{org}/explore/logs/`

Note: The web UI URLs use different parameter formats than the API:
- Legacy Discover uses simple field parameters
- Modern Explore uses `aggregateField` with JSON-encoded values
- The tool handles this transformation automatically in `buildDiscoverUrl()` and `buildEapUrl()`

### Web URL Generation Parameters

The `getEventsExplorerUrl()` method accepts these parameters to determine URL format:

1. **organizationSlug**: Organization identifier
2. **query**: The Sentry query string
3. **projectSlug**: Numeric project ID (optional)
4. **dataset**: "spans", "errors", or "logs"
5. **fields**: Array of fields (used to detect if it's an aggregate query)
6. **sort**: Sort parameter
7. **aggregateFunctions**: Array of aggregate functions (e.g., `["count()", "avg(span.duration)"]`)
8. **groupByFields**: Array of fields to group by (e.g., `["span.op", "ai.model.id"]`)

Based on these parameters:
- If `aggregateFunctions` has items → generates aggregate query URL
- For errors dataset → routes to Legacy Discover URL format
- For spans/logs datasets → routes to Modern Explore URL format with JSON-encoded `aggregateField` parameters

## API vs Web UI URLs

### Important Distinction

The API and Web UI use different parameter formats:

**API (Backend)**: Always uses the same format regardless of dataset
- Endpoint: `/api/0/organizations/{org}/events/`
- Parameters: `field`, `query`, `sort`, `dataset`, etc.
- Example: `?dataset=spans&field=span.op&field=count()&sort=-count()`

**Web UI (Frontend)**: Different formats for different pages
- Legacy Discover: `/organizations/{org}/discover/results/`
- Modern Explore: `/organizations/{org}/explore/{dataset}/`
- Uses different parameter encoding (e.g., `aggregateField` with JSON for explore pages)

### API Parameter Format

The API **always** uses this format for all datasets:

**Individual Events:**
```
?dataset=spans
&field=id
&field=span.op
&field=span.description
&query=span.op:db
&sort=-timestamp
&statsPeriod=24h
```

**Aggregate Queries:**
```
?dataset=spans
&field=span.op
&field=count()
&query=span.op:db*
&sort=-count()
&statsPeriod=24h
```

The only difference between datasets is the `dataset` parameter value and available fields.

## Time Range Filtering

All API endpoints support time range filtering using either relative or absolute time parameters:

**Relative Time** (`statsPeriod`):
- Format: number + unit (e.g., `1h`, `24h`, `7d`, `30d`)
- Default: `14d` (last 14 days)
- Example: `?statsPeriod=7d`

**Absolute Time** (`start` and `end`):
- Format: ISO 8601 timestamps
- Both parameters must be provided together
- Example: `?start=2025-06-19T07:00:00&end=2025-06-20T06:59:59`

**Important**: Cannot use both `statsPeriod` and `start`/`end` parameters in the same request.

**Applies to**:
- Events API: `/organizations/{org}/events/`
- Tags API: `/organizations/{org}/tags/`
- Trace Items Attributes API: `/organizations/{org}/trace-items/attributes/`

## Attribute Lookup Endpoints

### Overview

Before translating queries, the tool fetches available attributes/fields for the organization. This ensures the AI knows about custom attributes specific to the organization.

### Tags Endpoint (Errors Dataset)

**Endpoint**: `/api/0/organizations/{org}/tags/`

**Parameters**:
- `dataset`: Always `events` for error data
- `project`: Numeric project ID (optional)
- `statsPeriod`: Time range (e.g., `24h`)
- `useCache`: Set to `1` for performance
- `useFlagsBackend`: Set to `1` for latest features

**Example**:
```
https://us.sentry.io/api/0/organizations/sentry/tags/?dataset=events&project=4509062593708032&statsPeriod=24h&useCache=1&useFlagsBackend=1
```

**Response Format**:
```json
[
  {
    "key": "browser.name",
    "name": "Browser Name"
  },
  {
    "key": "custom.payment_method",
    "name": "Payment Method"
  }
]
```

**Processing**:
- Filters out `sentry:` prefixed tags (internal tags)
- Maps to key-value pairs for the AI prompt

### Trace Items Attributes Endpoint (Spans/Logs Datasets)

**Endpoint**: `/api/0/organizations/{org}/trace-items/attributes/`

**Parameters**:
- `itemType`: Either `spans` or `logs` (plural!)
- `attributeType`: Either `string` or `number`
- `project`: Numeric project ID (optional)
- `statsPeriod`: Time range

**Examples**:

Spans string attributes:
```
https://us.sentry.io/api/0/organizations/sentry/trace-items/attributes/?attributeType=string&itemType=spans&project=4509062593708032&statsPeriod=24h
```

Spans number attributes:
```
https://us.sentry.io/api/0/organizations/sentry/trace-items/attributes/?attributeType=number&itemType=spans&project=4509062593708032&statsPeriod=24h
```

Logs string attributes:
```
https://us.sentry.io/api/0/organizations/sentry/trace-items/attributes/?attributeType=string&itemType=logs&project=4509062593708032&statsPeriod=24h
```

**Response Format**:
```json
[
  {
    "key": "span.duration",
    "name": "Span Duration",
    "type": "number"
  },
  {
    "key": "ai.model.id",
    "name": "AI Model ID",
    "type": "string"
  }
]
```

### Implementation Strategy

The tool makes parallel requests to fetch attributes efficiently:

1. **For errors**: Single request to tags endpoint with optimized parameters
2. **For spans/logs**: Single request that internally fetches both string + number attributes

```typescript
// For errors dataset
const tagsResponse = await apiService.listTags({
  organizationSlug,
  dataset: "events",
  statsPeriod: "14d",
  useCache: true,
  useFlagsBackend: true
});

// For spans/logs datasets
const attributesResponse = await apiService.listTraceItemAttributes({
  organizationSlug,
  itemType: "spans", // or "logs"
  statsPeriod: "14d"
});
```

Note: The `listTraceItemAttributes` method internally makes parallel requests for string and number attributes.

### Custom Attributes Integration

After fetching, custom attributes are merged with base fields:

```typescript
const allFields = {
  ...BASE_COMMON_FIELDS,      // Common fields across datasets
  ...DATASET_FIELDS[dataset], // Dataset-specific fields
  ...customAttributes         // Organization-specific fields
};
```

This ensures the AI knows about all available fields when translating queries.

### Error Handling

If attribute fetching fails:
- The tool continues with just the base fields
- Logs the error for debugging
- Does not fail the entire query

This graceful degradation ensures queries still work even if custom attributes can't be fetched.

## Best Practices

1. **Be specific with fields**: Only request fields you need
2. **Use appropriate limits**: Default 10, max 100 per page
3. **Leverage aggregate functions**: For summaries and statistics
4. **Include context fields**: Add fields like `project`, `environment` when grouping
5. **Sort meaningfully**: Use `-count()` for popularity, `-timestamp` for recency
6. **Handle custom attributes**: Tool automatically fetches org-specific attributes
7. **Understand dataset differences**: Each dataset has different capabilities and constraints

## Implementation Details

### Code Architecture

The search_events tool handles the complexity of multiple API patterns:

1. **AI Translation Layer**
   - Uses OpenAI GPT-4o to translate natural language to Sentry query syntax
   - Maintains dataset-specific system prompts with examples
   - Temperature set to 0.1 for consistent translations
   - Aggregate functions and groupBy fields are derived from the fields array

2. **Field Handling**
   - Aggregate queries: Only includes aggregate functions and groupBy fields
   - Non-aggregate queries: Uses default fields or AI-specified fields
   - Validates that sort fields are included in the field list
   - Detects aggregate queries by checking for function syntax in fields

3. **Field Type Validation**
   - Validates numeric aggregate functions (avg, sum, min, max, percentiles) are only used with numeric fields
   - Tracks field types from both known fields and custom attributes
   - Returns error messages when invalid combinations are attempted

4. **Web UI URL Generation** (for shareable links)
   - `buildDiscoverUrl()` for errors dataset → creates Discover page URLs
   - `buildEapUrl()` for spans/logs datasets → creates Explore page URLs
   - Transforms API response format to web UI parameter format
   - Note: These methods generate web URLs, not API URLs

### Response Format Differences

**Legacy Discover Response (errors):**
```json
{
  "data": [
    {
      "error.type": "TypeError",
      "count()": 150,
      "last_seen()": "2025-01-16T12:00:00Z"
    }
  ]
}
```

**EAP Response (spans/logs):**
```json
{
  "data": [
    {
      "span.op": "db.query",
      "count()": 1250,
      "avg(span.duration)": 45.3
    }
  ]
}
```

## Troubleshooting

### "Ordered by columns not selected" Error
This occurs when sorting by a field not included in the field list. Ensure your sort field is in the fields array.

### Empty Results
- Check query syntax is valid
- Verify time range (`statsPeriod`)
- Ensure project has data for the selected dataset
- Try broadening the query

### API Errors
- 400: Invalid query syntax or parameters (often due to field mismatch in aggregates)
- 404: Project or organization not found
- 500: Internal error (check Sentry status)
```

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

```typescript
/**
 * Zod schemas for Sentry API response validation.
 *
 * This module contains comprehensive Zod schemas that validate and type-check
 * responses from Sentry's REST API. All schemas are designed to handle Sentry's
 * flexible data model where most fields can be null or optional.
 *
 * Key Design Principles:
 * - Use .passthrough() for objects that may contain additional fields
 * - Support both string and number IDs (Sentry's legacy/modern ID formats)
 * - Handle nullable fields gracefully throughout the schema hierarchy
 * - Use union types for polymorphic data (events, assignedTo, etc.)
 *
 * Schema Categories:
 * - **Core Resources**: Users, Organizations, Teams, Projects
 * - **Issue Management**: Issues, Events, Assignments
 * - **Release Management**: Releases, Commits, Deployments
 * - **Search & Discovery**: Tags, Error Search, Span Search
 * - **Integrations**: Client Keys (DSNs), Autofix
 *
 * @example Schema Usage
 * ```typescript
 * import { IssueListSchema } from "./schema";
 *
 * const response = await fetch("/api/0/organizations/my-org/issues/");
 * const issues = IssueListSchema.parse(await response.json());
 * // TypeScript now knows the exact shape of issues
 * ```
 *
 * @example Error Handling
 * ```typescript
 * const { data, success, error } = ApiErrorSchema.safeParse(response);
 * if (success) {
 *   throw new ApiError(data.detail, statusCode);
 * }
 * ```
 */
import { z } from "zod";

/**
 * Schema for Sentry API error responses.
 *
 * Uses .passthrough() to allow additional fields that may be present
 * in different error scenarios.
 */
export const ApiErrorSchema = z
  .object({
    detail: z.string(),
  })
  .passthrough();

export const UserSchema = z.object({
  id: z.union([z.string(), z.number()]),
  name: z.string().nullable(),
  email: z.string(),
});

export const UserRegionsSchema = z.object({
  regions: z.array(
    z.object({
      name: z.string(),
      url: z.string().url(),
    }),
  ),
});

/**
 * Schema for Sentry organization API responses.
 *
 * Handles organizations from both Sentry's Cloud Service and self-hosted installations.
 * The links object and regionUrl field are optional to support self-hosted Sentry
 * instances that may not include these fields or return empty values.
 */
export const OrganizationSchema = z.object({
  id: z.union([z.string(), z.number()]),
  slug: z.string(),
  name: z.string(),
  links: z
    .object({
      regionUrl: z
        .string()
        .refine(
          (value) => !value || z.string().url().safeParse(value).success,
          {
            message:
              "Must be a valid URL or empty string (for self-hosted Sentry)",
          },
        )
        .optional(),
      organizationUrl: z.string().url(),
    })
    .optional(),
});

export const OrganizationListSchema = z.array(OrganizationSchema);

export const TeamSchema = z.object({
  id: z.union([z.string(), z.number()]),
  slug: z.string(),
  name: z.string(),
});

export const TeamListSchema = z.array(TeamSchema);

export const ProjectSchema = z.object({
  id: z.union([z.string(), z.number()]),
  slug: z.string(),
  name: z.string(),
  platform: z.string().nullable().optional(),
});

export const ProjectListSchema = z.array(ProjectSchema);

export const ClientKeySchema = z.object({
  id: z.union([z.string(), z.number()]),
  name: z.string(),
  dsn: z.object({
    public: z.string(),
  }),
  isActive: z.boolean(),
  dateCreated: z.string().datetime(),
});

export const ClientKeyListSchema = z.array(ClientKeySchema);

export const ReleaseSchema = z.object({
  id: z.union([z.string(), z.number()]),
  version: z.string(),
  shortVersion: z.string(),
  dateCreated: z.string().datetime(),
  dateReleased: z.string().datetime().nullable(),
  firstEvent: z.string().datetime().nullable(),
  lastEvent: z.string().datetime().nullable(),
  newGroups: z.number(),
  lastCommit: z
    .object({
      id: z.union([z.string(), z.number()]),
      message: z.string(),
      dateCreated: z.string().datetime(),
      author: z.object({
        name: z.string(),
        email: z.string(),
      }),
    })
    .nullable(),
  lastDeploy: z
    .object({
      id: z.union([z.string(), z.number()]),
      environment: z.string(),
      dateStarted: z.string().datetime().nullable(),
      dateFinished: z.string().datetime().nullable(),
    })
    .nullable(),
  projects: z.array(ProjectSchema),
});

export const ReleaseListSchema = z.array(ReleaseSchema);

export const TagSchema = z.object({
  key: z.string(),
  name: z.string(),
  totalValues: z.number(),
});

export const TagListSchema = z.array(TagSchema);

// Schema for assignedTo field - can be a user object, team object, string, or null
export const AssignedToSchema = z.union([
  z.null(),
  z.string(), // username or actor ID
  z
    .object({
      type: z.enum(["user", "team"]),
      id: z.union([z.string(), z.number()]),
      name: z.string(),
      email: z.string().optional(), // only for users
    })
    .passthrough(), // Allow additional fields we might not know about
]);

export const IssueSchema = z.object({
  id: z.union([z.string(), z.number()]),
  shortId: z.string(),
  title: z.string(),
  firstSeen: z.string().datetime(),
  lastSeen: z.string().datetime(),
  count: z.union([z.string(), z.number()]),
  userCount: z.union([z.string(), z.number()]),
  permalink: z.string().url(),
  project: ProjectSchema,
  platform: z.string().nullable().optional(),
  status: z.string(),
  culprit: z.string(),
  type: z.union([z.literal("error"), z.literal("transaction"), z.unknown()]),
  assignedTo: AssignedToSchema.optional(),
  issueType: z.string().optional(),
  issueCategory: z.string().optional(),
  metadata: z
    .object({
      title: z.string().nullable().optional(),
      location: z.string().nullable().optional(),
      value: z.string().nullable().optional(),
    })
    .optional(),
});

export const IssueListSchema = z.array(IssueSchema);

export const FrameInterface = z
  .object({
    filename: z.string().nullable(),
    function: z.string().nullable(),
    lineNo: z.number().nullable(),
    colNo: z.number().nullable(),
    absPath: z.string().nullable(),
    module: z.string().nullable(),
    // lineno, source code
    context: z.array(z.tuple([z.number(), z.string()])),
    inApp: z.boolean().optional(),
    vars: z.record(z.string(), z.unknown()).optional(),
  })
  .partial();

// XXX: Sentry's schema generally speaking is "assume all user input is missing"
// so we need to handle effectively every field being optional or nullable.
export const ExceptionInterface = z
  .object({
    mechanism: z
      .object({
        type: z.string().nullable(),
        handled: z.boolean().nullable(),
      })
      .partial(),
    type: z.string().nullable(),
    value: z.string().nullable(),
    stacktrace: z.object({
      frames: z.array(FrameInterface),
    }),
  })
  .partial();

export const ErrorEntrySchema = z
  .object({
    // XXX: Sentry can return either of these. Not sure why we never normalized it.
    values: z.array(ExceptionInterface.optional()),
    value: ExceptionInterface.nullable().optional(),
  })
  .partial();

export const RequestEntrySchema = z
  .object({
    method: z.string().nullable(),
    url: z.string().url().nullable(),
    // TODO:
    // query: z.array(z.tuple([z.string(), z.string()])).nullable(),
    // data: z.unknown().nullable(),
    // headers: z.array(z.tuple([z.string(), z.string()])).nullable(),
  })
  .partial();

export const MessageEntrySchema = z
  .object({
    formatted: z.string().nullable(),
    message: z.string().nullable(),
    params: z.array(z.unknown()).optional(),
  })
  .partial();

export const ThreadEntrySchema = z
  .object({
    id: z.number().nullable(),
    name: z.string().nullable(),
    current: z.boolean().nullable(),
    crashed: z.boolean().nullable(),
    state: z.string().nullable(),
    stacktrace: z
      .object({
        frames: z.array(FrameInterface),
      })
      .nullable(),
  })
  .partial();

export const ThreadsEntrySchema = z
  .object({
    values: z.array(ThreadEntrySchema),
  })
  .partial();

export const BreadcrumbSchema = z
  .object({
    timestamp: z.string().nullable(),
    type: z.string().nullable(),
    category: z.string().nullable(),
    level: z.string().nullable(),
    message: z.string().nullable(),
    data: z.record(z.unknown()).nullable(),
  })
  .partial();

export const BreadcrumbsEntrySchema = z
  .object({
    values: z.array(BreadcrumbSchema),
  })
  .partial();

const BaseEventSchema = z.object({
  id: z.string(),
  title: z.string(),
  message: z.string().nullable(),
  platform: z.string().nullable().optional(),
  type: z.unknown(),
  entries: z.array(
    z.union([
      z.object({
        type: z.literal("exception"),
        data: ErrorEntrySchema,
      }),
      z.object({
        type: z.literal("message"),
        data: MessageEntrySchema,
      }),
      z.object({
        type: z.literal("threads"),
        data: ThreadsEntrySchema,
      }),
      z.object({
        type: z.literal("request"),
        data: RequestEntrySchema,
      }),
      z.object({
        type: z.literal("breadcrumbs"),
        data: BreadcrumbsEntrySchema,
      }),
      z.object({
        type: z.literal("spans"),
        data: z.unknown(),
      }),
      z.object({
        type: z.string(),
        data: z.unknown(),
      }),
    ]),
  ),
  contexts: z
    .record(
      z.string(),
      z
        .object({
          type: z.union([
            z.literal("default"),
            z.literal("runtime"),
            z.literal("os"),
            z.literal("trace"),
            z.unknown(),
          ]),
        })
        .passthrough(),
    )
    .optional(),
  // "context" (singular) is the legacy "extra" field for arbitrary user-defined data
  // This is different from "contexts" (plural) which are structured contexts
  context: z.record(z.string(), z.unknown()).optional(),
  tags: z
    .array(
      z.object({
        key: z.string(),
        value: z.string().nullable(),
      }),
    )
    .optional(),
  // The _meta field contains metadata about fields in the response
  // It's safer to type as unknown since its structure varies
  _meta: z.unknown().optional(),
  // dateReceived is when the server received the event (may not be present in all contexts)
  dateReceived: z.string().datetime().optional(),
});

export const ErrorEventSchema = BaseEventSchema.omit({
  type: true,
}).extend({
  type: z.literal("error"),
  culprit: z.string().nullable(),
  dateCreated: z.string().datetime(),
});

export const DefaultEventSchema = BaseEventSchema.omit({
  type: true,
}).extend({
  type: z.literal("default"),
  culprit: z.string().nullable().optional(),
  dateCreated: z.string().datetime(),
});

export const TransactionEventSchema = BaseEventSchema.omit({
  type: true,
}).extend({
  type: z.literal("transaction"),
  occurrence: z
    .object({
      id: z.string().optional(),
      projectId: z.number().optional(),
      eventId: z.string().optional(),
      fingerprint: z.array(z.string()).optional(),
      issueTitle: z.string(),
      subtitle: z.string().optional(),
      resourceId: z.string().nullable().optional(),
      evidenceData: z.record(z.string(), z.any()).optional(),
      evidenceDisplay: z
        .array(
          z.object({
            name: z.string(),
            value: z.string(),
            important: z.boolean().optional(),
          }),
        )
        .optional(),
      type: z.number().optional(),
      detectionTime: z.number().optional(),
      level: z.string().optional(),
      culprit: z.string().nullable(),
      priority: z.number().optional(),
      assignee: z.string().nullable().optional(),
    })
    .nullish(), // Allow both null and undefined
});

export const UnknownEventSchema = BaseEventSchema.omit({
  type: true,
}).extend({
  type: z.unknown(),
});

// XXX: This API response is kind of a disaster. We are not propagating the appropriate
// columns and it makes this really hard to work with. Errors and Transaction-based issues
// are completely different, for example.
export const EventSchema = z.union([
  ErrorEventSchema,
  DefaultEventSchema,
  TransactionEventSchema,
  UnknownEventSchema,
]);

export const EventsResponseSchema = z.object({
  data: z.array(z.unknown()),
  meta: z
    .object({
      fields: z.record(z.string(), z.string()),
    })
    .passthrough(),
});

// 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
export const ErrorsSearchResponseSchema = EventsResponseSchema.extend({
  data: z.array(
    z.object({
      issue: z.string(),
      "issue.id": z.union([z.string(), z.number()]),
      project: z.string(),
      title: z.string(),
      "count()": z.number(),
      "last_seen()": z.string(),
    }),
  ),
});

export const SpansSearchResponseSchema = EventsResponseSchema.extend({
  data: z.array(
    z.object({
      id: z.string(),
      trace: z.string(),
      "span.op": z.string(),
      "span.description": z.string(),
      "span.duration": z.number(),
      transaction: z.string(),
      project: z.string(),
      timestamp: z.string(),
    }),
  ),
});

export const AutofixRunSchema = z
  .object({
    run_id: z.union([z.string(), z.number()]),
  })
  .passthrough();

const AutofixStatusSchema = z.enum([
  "PENDING",
  "PROCESSING",
  "IN_PROGRESS",
  "NEED_MORE_INFORMATION",
  "COMPLETED",
  "FAILED",
  "ERROR",
  "CANCELLED",
  "WAITING_FOR_USER_RESPONSE",
]);

const AutofixRunStepBaseSchema = z.object({
  type: z.string(),
  key: z.string(),
  index: z.number(),
  status: AutofixStatusSchema,
  title: z.string(),
  output_stream: z.string().nullable(),
  progress: z.array(
    z.object({
      data: z.unknown().nullable(),
      message: z.string(),
      timestamp: z.string(),
      type: z.enum(["INFO", "WARNING", "ERROR"]),
    }),
  ),
});

export const AutofixRunStepDefaultSchema = AutofixRunStepBaseSchema.extend({
  type: z.literal("default"),
  insights: z
    .array(
      z.object({
        change_diff: z.unknown().nullable(),
        generated_at_memory_index: z.number(),
        insight: z.string(),
        justification: z.string(),
        type: z.literal("insight"),
      }),
    )
    .nullable(),
}).passthrough();

export const AutofixRunStepRootCauseAnalysisSchema =
  AutofixRunStepBaseSchema.extend({
    type: z.literal("root_cause_analysis"),
    causes: z.array(
      z.object({
        description: z.string(),
        id: z.number(),
        root_cause_reproduction: z.array(
          z.object({
            code_snippet_and_analysis: z.string(),
            is_most_important_event: z.boolean(),
            relevant_code_file: z
              .object({
                file_path: z.string(),
                repo_name: z.string(),
              })
              .nullable(),
            timeline_item_type: z.string(),
            title: z.string(),
          }),
        ),
      }),
    ),
  }).passthrough();

export const AutofixRunStepSolutionSchema = AutofixRunStepBaseSchema.extend({
  type: z.literal("solution"),
  solution: z.array(
    z.object({
      code_snippet_and_analysis: z.string().nullable(),
      is_active: z.boolean(),
      is_most_important_event: z.boolean(),
      relevant_code_file: z.null(),
      timeline_item_type: z.union([
        z.literal("internal_code"),
        z.literal("repro_test"),
      ]),
      title: z.string(),
    }),
  ),
}).passthrough();

export const AutofixRunStepSchema = z.union([
  AutofixRunStepDefaultSchema,
  AutofixRunStepRootCauseAnalysisSchema,
  AutofixRunStepSolutionSchema,
  AutofixRunStepBaseSchema.passthrough(),
]);

export const AutofixRunStateSchema = z.object({
  autofix: z
    .object({
      run_id: z.number(),
      request: z.unknown(),
      updated_at: z.string(),
      status: AutofixStatusSchema,
      steps: z.array(AutofixRunStepSchema),
    })
    .passthrough()
    .nullable(),
});

export const EventAttachmentSchema = z.object({
  id: z.string(),
  name: z.string(),
  type: z.string(),
  size: z.number(),
  mimetype: z.string(),
  dateCreated: z.string().datetime(),
  sha1: z.string(),
  headers: z.record(z.string(), z.string()).optional(),
});

export const EventAttachmentListSchema = z.array(EventAttachmentSchema);

/**
 * Schema for Sentry trace metadata response.
 *
 * Contains high-level statistics about a trace including span counts,
 * transaction breakdown, and operation type distribution.
 */
export const TraceMetaSchema = z.object({
  logs: z.number(),
  errors: z.number(),
  performance_issues: z.number(),
  span_count: z.number(),
  transaction_child_count_map: z.array(
    z.object({
      "transaction.event_id": z.string().nullable(),
      "count()": z.number(),
    }),
  ),
  span_count_map: z.record(z.string(), z.number()),
});

/**
 * Schema for individual spans within a trace.
 *
 * Represents the hierarchical structure of spans with timing information,
 * operation details, and nested children spans.
 */
export const TraceSpanSchema: z.ZodType<any> = z.lazy(() =>
  z.object({
    children: z.array(TraceSpanSchema),
    errors: z.array(z.any()),
    occurrences: z.array(z.any()),
    event_id: z.string(),
    transaction_id: z.string(),
    project_id: z.union([z.string(), z.number()]),
    project_slug: z.string(),
    profile_id: z.string(),
    profiler_id: z.string(),
    parent_span_id: z.string().nullable(),
    start_timestamp: z.number(),
    end_timestamp: z.number(),
    measurements: z.record(z.string(), z.number()).optional(),
    duration: z.number(),
    transaction: z.string(),
    is_transaction: z.boolean(),
    description: z.string(),
    sdk_name: z.string(),
    op: z.string(),
    name: z.string(),
    event_type: z.string(),
    additional_attributes: z.record(z.string(), z.any()),
  }),
);

/**
 * Schema for issue objects that can appear in trace responses.
 *
 * When Sentry's trace API returns standalone errors, they are returned as
 * SerializedIssue objects that lack the span-specific fields.
 */
export const TraceIssueSchema = z
  .object({
    id: z.union([z.string(), z.number()]).optional(),
    issue_id: z.union([z.string(), z.number()]).optional(),
    project_id: z.union([z.string(), z.number()]).optional(),
    project_slug: z.string().optional(),
    title: z.string().optional(),
    culprit: z.string().optional(),
    type: z.string().optional(),
    timestamp: z.union([z.string(), z.number()]).optional(),
  })
  .passthrough();

/**
 * Schema for Sentry trace response.
 *
 * Contains the complete trace tree starting from root spans.
 * The response is an array that can contain both root-level spans
 * and standalone issue objects. The Sentry API's query_trace_data
 * function returns a mixed list of SerializedSpan and SerializedIssue
 * objects when there are errors not directly associated with spans.
 */
export const TraceSchema = z.array(
  z.union([TraceSpanSchema, TraceIssueSchema]),
);

```

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

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import searchRoute from "./search";
import type { Env } from "../types";
import type { Ai, AutoRagSearchResponse } from "@cloudflare/workers-types";

// Create mock AutoRAG instance
interface MockAutoRAG {
  search: ReturnType<typeof vi.fn>;
}

// Create mock AI binding that matches Cloudflare's Ai interface
const mockAutorag: MockAutoRAG = {
  search: vi.fn(),
};

const mockAIBinding = {
  autorag: vi.fn(() => mockAutorag),
} as unknown as Ai;

// Create test app with mocked environment
function createTestApp() {
  const app = new Hono<{ Bindings: Env }>();
  app.route("/api/search", searchRoute);

  return app;
}

describe("search route", () => {
  let app: ReturnType<typeof createTestApp>;

  beforeEach(() => {
    app = createTestApp();
    vi.clearAllMocks();

    // Setup default mock behavior
    const defaultResponse: AutoRagSearchResponse = {
      object: "vector_store.search_results.page",
      search_query: "test query",
      data: [
        {
          file_id: "40d26845-75f9-478c-ab2e-30d30b1b049b",
          filename: "platforms/javascript/guides/react.md",
          score: 0.95,
          attributes: {
            timestamp: 1750952340000,
            folder: "platforms/javascript/guides/",
            filename: "react.md",
          },
          content: [
            {
              type: "text",
              text: "This is test documentation content about React setup and configuration.",
            },
          ],
        },
      ],
      has_more: false,
      next_page: null,
    };
    mockAutorag.search.mockResolvedValue(defaultResponse);
  });

  describe("POST /api/search", () => {
    it("should return 400 when query is missing", async () => {
      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({}),
        },
        {
          AI: mockAIBinding,
        },
      );

      expect(res.status).toBe(400);
      const json = await res.json();
      expect(json).toHaveProperty("error", "Invalid request");
      expect(json).toHaveProperty("details");
    });

    it("should return 400 when query is empty", async () => {
      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "" }),
        },
        {
          AI: mockAIBinding,
        },
      );

      expect(res.status).toBe(400);
      const json = await res.json();
      expect(json).toHaveProperty("error", "Invalid request");
    });

    it("should return 400 when maxResults is out of range", async () => {
      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test", maxResults: 15 }),
        },
        {
          AI: mockAIBinding,
        },
      );

      expect(res.status).toBe(400);
      const json = await res.json();
      expect(json).toHaveProperty("error", "Invalid request");
    });

    it("should return 503 when AI binding is not available", async () => {
      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test" }),
        },
        {
          AI: null as unknown as Ai,
        },
      );

      expect(res.status).toBe(503);
      const json = await res.json();
      expect(json).toEqual({
        error: "AI service not available",
        name: "AI_SERVICE_UNAVAILABLE",
      });
    });

    it("should successfully search with default parameters", async () => {
      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "rate limiting" }),
        },
        {
          AI: mockAIBinding,
        },
      );

      expect(res.status).toBe(200);
      const json = await res.json();

      expect(mockAIBinding.autorag).toHaveBeenCalledWith("sentry-docs");
      expect(mockAutorag.search).toHaveBeenCalledWith({
        query: "rate limiting",
        max_num_results: 10,
        ranking_options: {
          score_threshold: 0.2,
        },
      });

      expect(json).toMatchObject({
        query: "rate limiting",
        results: [
          {
            id: "platforms/javascript/guides/react.md",
            url: "https://docs.sentry.io/platforms/javascript/guides/react",
            snippet:
              "This is test documentation content about React setup and configuration.",
            relevance: 0.95,
          },
        ],
      });
    });

    it("should filter by platform for platform/guide combination", async () => {
      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({
            query: "setup configuration",
            guide: "javascript/nextjs",
          }),
        },
        {
          AI: mockAIBinding,
        },
      );

      expect(res.status).toBe(200);

      expect(mockAutorag.search).toHaveBeenCalledWith({
        query: "setup configuration",
        max_num_results: 10,
        ranking_options: {
          score_threshold: 0.2,
        },
        filters: {
          type: "and",
          filters: [
            {
              type: "gte",
              key: "folder",
              value: "platforms/javascript/guides/nextjs/",
            },
            {
              type: "lte",
              key: "folder",
              value: "platforms/javascript/guides/nextjs/z",
            },
          ],
        },
      });
    });

    it("should filter by platform for platform only", async () => {
      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({
            query: "setup configuration",
            guide: "python",
          }),
        },
        {
          AI: mockAIBinding,
        },
      );

      expect(res.status).toBe(200);

      expect(mockAutorag.search).toHaveBeenCalledWith({
        query: "setup configuration",
        max_num_results: 10,
        ranking_options: {
          score_threshold: 0.2,
        },
        filters: {
          type: "and",
          filters: [
            {
              type: "gte",
              key: "folder",
              value: "platforms/python/",
            },
            {
              type: "lte",
              key: "folder",
              value: "platforms/python/z",
            },
          ],
        },
      });
    });

    it("should handle custom maxResults parameter", async () => {
      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "error handling", maxResults: 5 }),
        },
        {
          AI: mockAIBinding,
        },
      );

      expect(res.status).toBe(200);
      expect(mockAutorag.search).toHaveBeenCalledWith({
        query: "error handling",
        max_num_results: 5,
        ranking_options: {
          score_threshold: 0.2,
        },
      });
    });

    it("should handle empty search results", async () => {
      mockAutorag.search.mockResolvedValue({
        object: "vector_store.search_results.page",
        search_query: "test query",
        data: [],
        has_more: false,
        next_page: null,
      });

      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "nonexistent topic" }),
        },
        {
          AI: mockAIBinding,
        },
      );

      expect(res.status).toBe(200);
      const json = await res.json();
      expect(json).toMatchObject({
        query: "nonexistent topic",
        results: [],
      });
    });

    it("should handle AutoRAG search errors gracefully", async () => {
      mockAutorag.search.mockRejectedValue(new Error("AutoRAG API error"));

      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test" }),
        },
        {
          AI: mockAIBinding,
        },
      );

      expect(res.status).toBe(500);
      const json = await res.json();
      expect(json).toMatchObject({
        error: "Failed to search documentation. Please try again later.",
        name: "SEARCH_FAILED",
      });
    });

    it("should extract documentation paths correctly", async () => {
      mockAutorag.search.mockResolvedValue({
        object: "vector_store.search_results.page",
        search_query: "test query",
        data: [
          {
            file_id: "id-1",
            filename: "platforms/javascript/index.md",
            score: 0.9,
            attributes: {
              timestamp: 1750952340000,
              folder: "platforms/javascript/",
              filename: "index.md",
            },
            content: [
              {
                type: "text",
                text: "Content 1",
              },
            ],
          },
          {
            file_id: "id-2",
            filename: "product/issues.md",
            score: 0.8,
            attributes: {
              timestamp: 1750952340000,
              folder: "product/",
              filename: "issues.md",
            },
            content: [
              {
                type: "text",
                text: "Content 2",
              },
            ],
          },
        ],
        has_more: false,
        next_page: null,
      });

      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test" }),
        },
        {
          AI: mockAIBinding,
        },
      );

      expect(res.status).toBe(200);
      const json = (await res.json()) as {
        results: Array<{ id: string; url: string }>;
      };
      expect(json.results[0]).toMatchInlineSnapshot(
        {
          id: "platforms/javascript/index.md",
          url: "https://docs.sentry.io/platforms/javascript/index",
        },
        `
        {
          "id": "platforms/javascript/index.md",
          "relevance": 0.9,
          "snippet": "Content 1",
          "url": "https://docs.sentry.io/platforms/javascript/index",
        }
      `,
      );
      expect(json.results[1]).toMatchInlineSnapshot(`
        {
          "id": "product/issues.md",
          "relevance": 0.8,
          "snippet": "Content 2",
          "url": "https://docs.sentry.io/product/issues",
        }
      `);
    });

    it("should handle index.md files correctly", async () => {
      mockAutorag.search.mockResolvedValue({
        object: "vector_store.search_results.page",
        search_query: "test query",
        data: [
          {
            file_id: "root-id",
            filename: "index.md",
            score: 0.9,
            attributes: {
              timestamp: 1750952340000,
              folder: "",
              filename: "index.md",
            },
            content: [
              {
                type: "text",
                text: "Root documentation content",
              },
            ],
          },
        ],
        has_more: false,
        next_page: null,
      });

      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test" }),
        },
        {
          AI: mockAIBinding,
        },
      );

      expect(res.status).toBe(200);
      const json = (await res.json()) as {
        results: Array<{ id: string; url: string }>;
      };
      expect(json.results[0].id).toBe("index.md");
      expect(json.results[0].url).toBe("https://docs.sentry.io/index");
    });
  });

  describe("rate limiting", () => {
    it("should allow requests when rate limiter is not configured", async () => {
      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test query" }),
        },
        {
          AI: mockAIBinding,
          // No SEARCH_RATE_LIMITER binding
        },
      );

      expect(res.status).toBe(200);
    });

    it("should allow requests when rate limit is not exceeded", async () => {
      const mockRateLimiter = {
        limit: vi.fn().mockResolvedValue({ success: true }),
      };

      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test query" }),
        },
        {
          AI: mockAIBinding,
          SEARCH_RATE_LIMITER: mockRateLimiter,
        },
      );

      expect(res.status).toBe(200);
      expect(mockRateLimiter.limit).toHaveBeenCalledWith({
        key: expect.stringMatching(/^search:ip:[a-f0-9]{16}$/),
      });
    });

    it("should reject requests when rate limit is exceeded", async () => {
      const mockRateLimiter = {
        limit: vi.fn().mockResolvedValue({ success: false }),
      };

      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test query" }),
        },
        {
          AI: mockAIBinding,
          SEARCH_RATE_LIMITER: mockRateLimiter,
        },
      );

      expect(res.status).toBe(429);
      const json = await res.json();
      expect(json).toMatchInlineSnapshot(`
        {
          "error": "Rate limit exceeded. You can perform up to 20 documentation searches per minute. Please wait before searching again.",
          "name": "RATE_LIMIT_EXCEEDED",
        }
      `);
    });

    it("should handle rate limiter errors gracefully", async () => {
      const mockRateLimiter = {
        limit: vi
          .fn()
          .mockRejectedValue(new Error("Rate limiter connection failed")),
      };

      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test query" }),
        },
        {
          AI: mockAIBinding,
          SEARCH_RATE_LIMITER: mockRateLimiter,
        },
      );

      expect(res.status).toBe(500);
      const json = await res.json();
      expect(json).toMatchObject({
        error: "There was an error communicating with the rate limiter.",
        name: "RATE_LIMITER_ERROR",
      });
    });

    it("should use different rate limit keys for different IPs", async () => {
      const mockRateLimiter = {
        limit: vi.fn().mockResolvedValue({ success: true }),
      };

      // First request from IP 192.0.2.1
      await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test query" }),
        },
        {
          AI: mockAIBinding,
          SEARCH_RATE_LIMITER: mockRateLimiter,
        },
      );

      const firstKey = mockRateLimiter.limit.mock.calls[0][0].key;

      // Second request from IP 192.0.2.2
      await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.2",
          },
          body: JSON.stringify({ query: "test query" }),
        },
        {
          AI: mockAIBinding,
          SEARCH_RATE_LIMITER: mockRateLimiter,
        },
      );

      const secondKey = mockRateLimiter.limit.mock.calls[1][0].key;

      expect(firstKey).not.toBe(secondKey);
      expect(firstKey).toMatch(/^search:ip:[a-f0-9]{16}$/);
      expect(secondKey).toMatch(/^search:ip:[a-f0-9]{16}$/);
    });
  });

  describe("configurable index name", () => {
    it("should use default index name when AUTORAG_INDEX_NAME is not set", async () => {
      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test query" }),
        },
        {
          AI: mockAIBinding,
          // No AUTORAG_INDEX_NAME environment variable
        },
      );

      expect(res.status).toBe(200);
      expect(mockAIBinding.autorag).toHaveBeenCalledWith("sentry-docs");
    });

    it("should use custom index name when AUTORAG_INDEX_NAME is set", async () => {
      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test query" }),
        },
        {
          AI: mockAIBinding,
          AUTORAG_INDEX_NAME: "custom-docs-index",
        },
      );

      expect(res.status).toBe(200);
      expect(mockAIBinding.autorag).toHaveBeenCalledWith("custom-docs-index");
    });

    it("should use default index name when AUTORAG_INDEX_NAME is empty", async () => {
      const res = await app.request(
        "/api/search",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "CF-Connecting-IP": "192.0.2.1",
          },
          body: JSON.stringify({ query: "test query" }),
        },
        {
          AI: mockAIBinding,
          AUTORAG_INDEX_NAME: "",
        },
      );

      expect(res.status).toBe(200);
      expect(mockAIBinding.autorag).toHaveBeenCalledWith("sentry-docs");
    });
  });
});

```

--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/k8s.json:
--------------------------------------------------------------------------------

```json
{
  "namespace": "k8s",
  "description": "Kubernetes resource attributes.\n",
  "attributes": {
    "k8s.cluster.name": {
      "description": "The name of the cluster.\n",
      "type": "string",
      "stability": "development",
      "examples": ["opentelemetry-cluster"]
    },
    "k8s.cluster.uid": {
      "description": "A pseudo-ID for the cluster, set to the UID of the `kube-system` namespace.\n",
      "type": "string",
      "note": "K8s doesn't have support for obtaining a cluster ID. If this is ever\nadded, we will recommend collecting the `k8s.cluster.uid` through the\nofficial APIs. In the meantime, we are able to use the `uid` of the\n`kube-system` namespace as a proxy for cluster ID. Read on for the\nrationale.\n\nEvery object created in a K8s cluster is assigned a distinct UID. The\n`kube-system` namespace is used by Kubernetes itself and will exist\nfor the lifetime of the cluster. Using the `uid` of the `kube-system`\nnamespace is a reasonable proxy for the K8s ClusterID as it will only\nchange if the cluster is rebuilt. Furthermore, Kubernetes UIDs are\nUUIDs as standardized by\n[ISO/IEC 9834-8 and ITU-T X.667](https://www.itu.int/ITU-T/studygroups/com17/oid.html).\nWhich states:\n\n> If generated according to one of the mechanisms defined in Rec.\n> ITU-T X.667 | ISO/IEC 9834-8, a UUID is either guaranteed to be\n> different from all other UUIDs generated before 3603 A.D., or is\n> extremely likely to be different (depending on the mechanism chosen).\n\nTherefore, UIDs between clusters should be extremely unlikely to\nconflict.\n",
      "stability": "development",
      "examples": ["218fc5a9-a5f1-4b54-aa05-46717d0ab26d"]
    },
    "k8s.node.name": {
      "description": "The name of the Node.\n",
      "type": "string",
      "stability": "development",
      "examples": ["node-1"]
    },
    "k8s.node.uid": {
      "description": "The UID of the Node.\n",
      "type": "string",
      "stability": "development",
      "examples": ["1eb3a0c6-0477-4080-a9cb-0cb7db65c6a2"]
    },
    "k8s.node.label": {
      "description": "The label placed on the Node, the `<key>` being the label name, the value being the label value, even if the value is empty.\n",
      "type": "string",
      "note": "Examples:\n\n- A label `kubernetes.io/arch` with value `arm64` SHOULD be recorded\n  as the `k8s.node.label.kubernetes.io/arch` attribute with value `\"arm64\"`.\n- A label `data` with empty string value SHOULD be recorded as\n  the `k8s.node.label.data` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["arm64", ""]
    },
    "k8s.node.annotation": {
      "description": "The annotation placed on the Node, the `<key>` being the annotation name, the value being the annotation value, even if the value is empty.\n",
      "type": "string",
      "note": "Examples:\n\n- An annotation `node.alpha.kubernetes.io/ttl` with value `0` SHOULD be recorded as\n  the `k8s.node.annotation.node.alpha.kubernetes.io/ttl` attribute with value `\"0\"`.\n- An annotation `data` with empty string value SHOULD be recorded as\n  the `k8s.node.annotation.data` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["0", ""]
    },
    "k8s.namespace.name": {
      "description": "The name of the namespace that the pod is running in.\n",
      "type": "string",
      "stability": "development",
      "examples": ["default"]
    },
    "k8s.namespace.label": {
      "description": "The label placed on the Namespace, the `<key>` being the label name, the value being the label value, even if the value is empty.\n",
      "type": "string",
      "note": "\nExamples:\n\n- A label `kubernetes.io/metadata.name` with value `default` SHOULD be recorded\n  as the `k8s.namespace.label.kubernetes.io/metadata.name` attribute with value `\"default\"`.\n- A label `data` with empty string value SHOULD be recorded as\n  the `k8s.namespace.label.data` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["default", ""]
    },
    "k8s.namespace.annotation": {
      "description": "The annotation placed on the Namespace, the `<key>` being the annotation name, the value being the annotation value, even if the value is empty.\n",
      "type": "string",
      "note": "\nExamples:\n\n- A label `ttl` with value `0` SHOULD be recorded\n  as the `k8s.namespace.annotation.ttl` attribute with value `\"0\"`.\n- A label `data` with empty string value SHOULD be recorded as\n  the `k8s.namespace.annotation.data` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["0", ""]
    },
    "k8s.pod.uid": {
      "description": "The UID of the Pod.\n",
      "type": "string",
      "stability": "development",
      "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
    },
    "k8s.pod.name": {
      "description": "The name of the Pod.\n",
      "type": "string",
      "stability": "development",
      "examples": ["opentelemetry-pod-autoconf"]
    },
    "k8s.pod.label": {
      "description": "The label placed on the Pod, the `<key>` being the label name, the value being the label value.\n",
      "type": "string",
      "note": "Examples:\n\n- A label `app` with value `my-app` SHOULD be recorded as\n  the `k8s.pod.label.app` attribute with value `\"my-app\"`.\n- A label `mycompany.io/arch` with value `x64` SHOULD be recorded as\n  the `k8s.pod.label.mycompany.io/arch` attribute with value `\"x64\"`.\n- A label `data` with empty string value SHOULD be recorded as\n  the `k8s.pod.label.data` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["my-app", "x64", ""]
    },
    "k8s.pod.annotation": {
      "description": "The annotation placed on the Pod, the `<key>` being the annotation name, the value being the annotation value.\n",
      "type": "string",
      "note": "Examples:\n\n- An annotation `kubernetes.io/enforce-mountable-secrets` with value `true` SHOULD be recorded as\n  the `k8s.pod.annotation.kubernetes.io/enforce-mountable-secrets` attribute with value `\"true\"`.\n- An annotation `mycompany.io/arch` with value `x64` SHOULD be recorded as\n  the `k8s.pod.annotation.mycompany.io/arch` attribute with value `\"x64\"`.\n- An annotation `data` with empty string value SHOULD be recorded as\n  the `k8s.pod.annotation.data` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["true", "x64", ""]
    },
    "k8s.container.name": {
      "description": "The name of the Container from Pod specification, must be unique within a Pod. Container runtime usually uses different globally unique name (`container.name`).\n",
      "type": "string",
      "stability": "development",
      "examples": ["redis"]
    },
    "k8s.container.restart_count": {
      "description": "Number of times the container was restarted. This attribute can be used to identify a particular container (running or stopped) within a container spec.\n",
      "type": "number",
      "stability": "development"
    },
    "k8s.container.status.last_terminated_reason": {
      "description": "Last terminated reason of the Container.\n",
      "type": "string",
      "stability": "development",
      "examples": ["Evicted", "Error"]
    },
    "k8s.replicaset.uid": {
      "description": "The UID of the ReplicaSet.\n",
      "type": "string",
      "stability": "development",
      "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
    },
    "k8s.replicaset.name": {
      "description": "The name of the ReplicaSet.\n",
      "type": "string",
      "stability": "development",
      "examples": ["opentelemetry"]
    },
    "k8s.replicaset.label": {
      "description": "The label placed on the ReplicaSet, the `<key>` being the label name, the value being the label value, even if the value is empty.\n",
      "type": "string",
      "note": "\nExamples:\n\n- A label `app` with value `guestbook` SHOULD be recorded\n  as the `k8s.replicaset.label.app` attribute with value `\"guestbook\"`.\n- A label `injected` with empty string value SHOULD be recorded as\n  the `k8s.replicaset.label.injected` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["guestbook", ""]
    },
    "k8s.replicaset.annotation": {
      "description": "The annotation placed on the ReplicaSet, the `<key>` being the annotation name, the value being the annotation value, even if the value is empty.\n",
      "type": "string",
      "note": "\nExamples:\n\n- A label `replicas` with value `0` SHOULD be recorded\n  as the `k8s.replicaset.annotation.replicas` attribute with value `\"0\"`.\n- A label `data` with empty string value SHOULD be recorded as\n  the `k8s.replicaset.annotation.data` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["0", ""]
    },
    "k8s.replicationcontroller.uid": {
      "description": "The UID of the replication controller.\n",
      "type": "string",
      "stability": "development",
      "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
    },
    "k8s.replicationcontroller.name": {
      "description": "The name of the replication controller.\n",
      "type": "string",
      "stability": "development",
      "examples": ["opentelemetry"]
    },
    "k8s.resourcequota.uid": {
      "description": "The UID of the resource quota.\n",
      "type": "string",
      "stability": "development",
      "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
    },
    "k8s.resourcequota.name": {
      "description": "The name of the resource quota.\n",
      "type": "string",
      "stability": "development",
      "examples": ["opentelemetry"]
    },
    "k8s.deployment.uid": {
      "description": "The UID of the Deployment.\n",
      "type": "string",
      "stability": "development",
      "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
    },
    "k8s.deployment.name": {
      "description": "The name of the Deployment.\n",
      "type": "string",
      "stability": "development",
      "examples": ["opentelemetry"]
    },
    "k8s.deployment.label": {
      "description": "The label placed on the Deployment, the `<key>` being the label name, the value being the label value, even if the value is empty.\n",
      "type": "string",
      "note": "\nExamples:\n\n- A label `replicas` with value `0` SHOULD be recorded\n  as the `k8s.deployment.label.app` attribute with value `\"guestbook\"`.\n- A label `injected` with empty string value SHOULD be recorded as\n  the `k8s.deployment.label.injected` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["guestbook", ""]
    },
    "k8s.deployment.annotation": {
      "description": "The annotation placed on the Deployment, the `<key>` being the annotation name, the value being the annotation value, even if the value is empty.\n",
      "type": "string",
      "note": "\nExamples:\n\n- A label `replicas` with value `1` SHOULD be recorded\n  as the `k8s.deployment.annotation.replicas` attribute with value `\"1\"`.\n- A label `data` with empty string value SHOULD be recorded as\n  the `k8s.deployment.annotation.data` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["1", ""]
    },
    "k8s.statefulset.uid": {
      "description": "The UID of the StatefulSet.\n",
      "type": "string",
      "stability": "development",
      "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
    },
    "k8s.statefulset.name": {
      "description": "The name of the StatefulSet.\n",
      "type": "string",
      "stability": "development",
      "examples": ["opentelemetry"]
    },
    "k8s.statefulset.label": {
      "description": "The label placed on the StatefulSet, the `<key>` being the label name, the value being the label value, even if the value is empty.\n",
      "type": "string",
      "note": "\nExamples:\n\n- A label `replicas` with value `0` SHOULD be recorded\n  as the `k8s.statefulset.label.app` attribute with value `\"guestbook\"`.\n- A label `injected` with empty string value SHOULD be recorded as\n  the `k8s.statefulset.label.injected` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["guestbook", ""]
    },
    "k8s.statefulset.annotation": {
      "description": "The annotation placed on the StatefulSet, the `<key>` being the annotation name, the value being the annotation value, even if the value is empty.\n",
      "type": "string",
      "note": "\nExamples:\n\n- A label `replicas` with value `1` SHOULD be recorded\n  as the `k8s.statefulset.annotation.replicas` attribute with value `\"1\"`.\n- A label `data` with empty string value SHOULD be recorded as\n  the `k8s.statefulset.annotation.data` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["1", ""]
    },
    "k8s.daemonset.uid": {
      "description": "The UID of the DaemonSet.\n",
      "type": "string",
      "stability": "development",
      "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
    },
    "k8s.daemonset.name": {
      "description": "The name of the DaemonSet.\n",
      "type": "string",
      "stability": "development",
      "examples": ["opentelemetry"]
    },
    "k8s.daemonset.label": {
      "description": "The label placed on the DaemonSet, the `<key>` being the label name, the value being the label value, even if the value is empty.\n",
      "type": "string",
      "note": "\nExamples:\n\n- A label `app` with value `guestbook` SHOULD be recorded\n  as the `k8s.daemonset.label.app` attribute with value `\"guestbook\"`.\n- A label `data` with empty string value SHOULD be recorded as\n  the `k8s.daemonset.label.injected` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["guestbook", ""]
    },
    "k8s.daemonset.annotation": {
      "description": "The annotation placed on the DaemonSet, the `<key>` being the annotation name, the value being the annotation value, even if the value is empty.\n",
      "type": "string",
      "note": "\nExamples:\n\n- A label `replicas` with value `1` SHOULD be recorded\n  as the `k8s.daemonset.annotation.replicas` attribute with value `\"1\"`.\n- A label `data` with empty string value SHOULD be recorded as\n  the `k8s.daemonset.annotation.data` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["1", ""]
    },
    "k8s.hpa.uid": {
      "description": "The UID of the horizontal pod autoscaler.\n",
      "type": "string",
      "stability": "development",
      "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
    },
    "k8s.hpa.name": {
      "description": "The name of the horizontal pod autoscaler.\n",
      "type": "string",
      "stability": "development",
      "examples": ["opentelemetry"]
    },
    "k8s.hpa.scaletargetref.kind": {
      "description": "The kind of the target resource to scale for the HorizontalPodAutoscaler.\n",
      "type": "string",
      "note": "This maps to the `kind` field in the `scaleTargetRef` of the HPA spec.\n",
      "stability": "development",
      "examples": ["Deployment", "StatefulSet"]
    },
    "k8s.hpa.scaletargetref.name": {
      "description": "The name of the target resource to scale for the HorizontalPodAutoscaler.\n",
      "type": "string",
      "note": "This maps to the `name` field in the `scaleTargetRef` of the HPA spec.\n",
      "stability": "development",
      "examples": ["my-deployment", "my-statefulset"]
    },
    "k8s.hpa.scaletargetref.api_version": {
      "description": "The API version of the target resource to scale for the HorizontalPodAutoscaler.\n",
      "type": "string",
      "note": "This maps to the `apiVersion` field in the `scaleTargetRef` of the HPA spec.\n",
      "stability": "development",
      "examples": ["apps/v1", "autoscaling/v2"]
    },
    "k8s.hpa.metric.type": {
      "description": "The type of metric source for the horizontal pod autoscaler.\n",
      "type": "string",
      "note": "This attribute reflects the `type` field of spec.metrics[] in the HPA.\n",
      "stability": "development",
      "examples": ["Resource", "ContainerResource"]
    },
    "k8s.job.uid": {
      "description": "The UID of the Job.\n",
      "type": "string",
      "stability": "development",
      "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
    },
    "k8s.job.name": {
      "description": "The name of the Job.\n",
      "type": "string",
      "stability": "development",
      "examples": ["opentelemetry"]
    },
    "k8s.job.label": {
      "description": "The label placed on the Job, the `<key>` being the label name, the value being the label value, even if the value is empty.\n",
      "type": "string",
      "note": "\nExamples:\n\n- A label `jobtype` with value `ci` SHOULD be recorded\n  as the `k8s.job.label.jobtype` attribute with value `\"ci\"`.\n- A label `data` with empty string value SHOULD be recorded as\n  the `k8s.job.label.automated` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["ci", ""]
    },
    "k8s.job.annotation": {
      "description": "The annotation placed on the Job, the `<key>` being the annotation name, the value being the annotation value, even if the value is empty.\n",
      "type": "string",
      "note": "\nExamples:\n\n- A label `number` with value `1` SHOULD be recorded\n  as the `k8s.job.annotation.number` attribute with value `\"1\"`.\n- A label `data` with empty string value SHOULD be recorded as\n  the `k8s.job.annotation.data` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["1", ""]
    },
    "k8s.cronjob.uid": {
      "description": "The UID of the CronJob.\n",
      "type": "string",
      "stability": "development",
      "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
    },
    "k8s.cronjob.name": {
      "description": "The name of the CronJob.\n",
      "type": "string",
      "stability": "development",
      "examples": ["opentelemetry"]
    },
    "k8s.cronjob.label": {
      "description": "The label placed on the CronJob, the `<key>` being the label name, the value being the label value.\n",
      "type": "string",
      "note": "Examples:\n\n- A label `type` with value `weekly` SHOULD be recorded as the\n  `k8s.cronjob.label.type` attribute with value `\"weekly\"`.\n- A label `automated` with empty string value SHOULD be recorded as\n  the `k8s.cronjob.label.automated` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["weekly", ""]
    },
    "k8s.cronjob.annotation": {
      "description": "The cronjob annotation placed on the CronJob, the `<key>` being the annotation name, the value being the annotation value.\n",
      "type": "string",
      "note": "Examples:\n\n- An annotation `retries` with value `4` SHOULD be recorded as the\n  `k8s.cronjob.annotation.retries` attribute with value `\"4\"`.\n- An annotation `data` with empty string value SHOULD be recorded as\n  the `k8s.cronjob.annotation.data` attribute with value `\"\"`.\n",
      "stability": "development",
      "examples": ["4", ""]
    },
    "k8s.volume.name": {
      "description": "The name of the K8s volume.\n",
      "type": "string",
      "stability": "development",
      "examples": ["volume0"]
    },
    "k8s.volume.type": {
      "description": "The type of the K8s volume.\n",
      "type": "string",
      "stability": "development",
      "examples": [
        "persistentVolumeClaim",
        "configMap",
        "downwardAPI",
        "emptyDir",
        "secret",
        "local"
      ]
    },
    "k8s.namespace.phase": {
      "description": "The phase of the K8s namespace.\n",
      "type": "string",
      "note": "This attribute aligns with the `phase` field of the\n[K8s NamespaceStatus](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#namespacestatus-v1-core)\n",
      "stability": "development",
      "examples": ["active", "terminating"]
    },
    "k8s.node.condition.type": {
      "description": "The condition type of a K8s Node.\n",
      "type": "string",
      "note": "K8s Node conditions as described\nby [K8s documentation](https://v1-32.docs.kubernetes.io/docs/reference/node/node-status/#condition).\n\nThis attribute aligns with the `type` field of the\n[NodeCondition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#nodecondition-v1-core)\n\nThe set of possible values is not limited to those listed here. Managed Kubernetes environments,\nor custom controllers MAY introduce additional node condition types.\nWhen this occurs, the exact value as reported by the Kubernetes API SHOULD be used.\n",
      "stability": "development",
      "examples": [
        "Ready",
        "DiskPressure",
        "MemoryPressure",
        "PIDPressure",
        "NetworkUnavailable"
      ]
    },
    "k8s.node.condition.status": {
      "description": "The status of the condition, one of True, False, Unknown.\n",
      "type": "string",
      "note": "This attribute aligns with the `status` field of the\n[NodeCondition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#nodecondition-v1-core)\n",
      "stability": "development",
      "examples": ["true", "false", "unknown"]
    },
    "k8s.container.status.state": {
      "description": "The state of the container. [K8s ContainerState](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#containerstate-v1-core)\n",
      "type": "string",
      "stability": "experimental",
      "examples": ["terminated", "running", "waiting"]
    },
    "k8s.container.status.reason": {
      "description": "The reason for the container state. Corresponds to the `reason` field of the: [K8s ContainerStateWaiting](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#containerstatewaiting-v1-core) or [K8s ContainerStateTerminated](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#containerstateterminated-v1-core)\n",
      "type": "string",
      "stability": "experimental",
      "examples": [
        "ContainerCreating",
        "CrashLoopBackOff",
        "CreateContainerConfigError",
        "ErrImagePull",
        "ImagePullBackOff",
        "OOMKilled",
        "Completed",
        "Error",
        "ContainerCannotRun"
      ]
    },
    "k8s.hugepage.size": {
      "description": "The size (identifier) of the K8s huge page.\n",
      "type": "string",
      "stability": "development",
      "examples": ["2Mi"]
    },
    "k8s.storageclass.name": {
      "description": "The name of K8s [StorageClass](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#storageclass-v1-storage-k8s-io) object.\n",
      "type": "string",
      "stability": "development",
      "examples": ["gold.storageclass.storage.k8s.io"]
    },
    "k8s.resourcequota.resource_name": {
      "description": "The name of the K8s resource a resource quota defines.\n",
      "type": "string",
      "note": "The value for this attribute can be either the full `count/<resource>[.<group>]` string (e.g., count/deployments.apps, count/pods), or, for certain core Kubernetes resources, just the resource name (e.g., pods, services, configmaps). Both forms are supported by Kubernetes for object count quotas. See [Kubernetes Resource Quotas documentation](https://kubernetes.io/docs/concepts/policy/resource-quotas/#object-count-quota) for more details.\n",
      "stability": "development",
      "examples": ["count/replicationcontrollers"]
    }
  }
}

```

--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/lib/approval-dialog.ts:
--------------------------------------------------------------------------------

```typescript
import type {
  AuthRequest,
  ClientInfo,
} from "@cloudflare/workers-oauth-provider";
import { logError, logIssue, logWarn } from "@sentry/mcp-server/telem/logging";
import { sanitizeHtml } from "./html-utils";

const COOKIE_NAME = "mcp-approved-clients";
const ONE_YEAR_IN_SECONDS = 31536000;
/**
 * Imports a secret key string for HMAC-SHA256 signing.
 * @param secret - The raw secret key string.
 * @returns A promise resolving to the CryptoKey object.
 */
async function importKey(secret: string): Promise<CryptoKey> {
  if (!secret) {
    throw new Error(
      "COOKIE_SECRET is not defined. A secret key is required for signing cookies.",
    );
  }
  const enc = new TextEncoder();
  return crypto.subtle.importKey(
    "raw",
    enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false, // not extractable
    ["sign", "verify"], // key usages
  );
}

/**
 * Signs data using HMAC-SHA256.
 * @param key - The CryptoKey for signing.
 * @param data - The string data to sign.
 * @returns A promise resolving to the signature as a hex string.
 */
async function signData(key: CryptoKey, data: string): Promise<string> {
  const enc = new TextEncoder();
  const signatureBuffer = await crypto.subtle.sign(
    "HMAC",
    key,
    enc.encode(data),
  );
  // Convert ArrayBuffer to hex string
  return Array.from(new Uint8Array(signatureBuffer))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

/**
 * Verifies an HMAC-SHA256 signature.
 * @param key - The CryptoKey for verification.
 * @param signatureHex - The signature to verify (hex string).
 * @param data - The original data that was signed.
 * @returns A promise resolving to true if the signature is valid, false otherwise.
 */
async function verifySignature(
  key: CryptoKey,
  signatureHex: string,
  data: string,
): Promise<boolean> {
  const enc = new TextEncoder();
  try {
    // Convert hex signature back to ArrayBuffer
    const signatureBytes = new Uint8Array(
      signatureHex.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16)),
    );
    return await crypto.subtle.verify(
      "HMAC",
      key,
      signatureBytes.buffer,
      enc.encode(data),
    );
  } catch (error) {
    logError(error, {
      loggerScope: ["cloudflare", "approval-dialog"],
      extra: {
        message: "Error verifying signature",
      },
    });
    return false;
  }
}

/**
 * Parses the signed cookie and verifies its integrity.
 * @param cookieHeader - The value of the Cookie header from the request.
 * @param secret - The secret key used for signing.
 * @returns A promise resolving to the list of approved client IDs if the cookie is valid, otherwise null.
 */
async function getApprovedClientsFromCookie(
  cookieHeader: string | null,
  secret: string,
): Promise<string[] | null> {
  if (!cookieHeader) return null;

  const cookies = cookieHeader.split(";").map((c) => c.trim());
  const targetCookie = cookies.find((c) => c.startsWith(`${COOKIE_NAME}=`));

  if (!targetCookie) return null;

  const cookieValue = targetCookie.substring(COOKIE_NAME.length + 1);
  const parts = cookieValue.split(".");

  if (parts.length !== 2) {
    logWarn("Invalid approval cookie format", {
      loggerScope: ["cloudflare", "approval-dialog"],
    });
    return null; // Invalid format
  }

  const [signatureHex, base64Payload] = parts;
  const payload = atob(base64Payload); // Assuming payload is base64 encoded JSON string

  const key = await importKey(secret);
  const isValid = await verifySignature(key, signatureHex, payload);

  if (!isValid) {
    logWarn("Approval cookie signature verification failed", {
      loggerScope: ["cloudflare", "approval-dialog"],
    });
    return null; // Signature invalid
  }

  try {
    const approvedClients = JSON.parse(payload);
    if (!Array.isArray(approvedClients)) {
      logWarn("Approval cookie payload is not an array", {
        loggerScope: ["cloudflare", "approval-dialog"],
      });
      return null; // Payload isn't an array
    }
    // Ensure all elements are strings
    if (!approvedClients.every((item) => typeof item === "string")) {
      logWarn("Approval cookie payload contains non-string elements", {
        loggerScope: ["cloudflare", "approval-dialog"],
      });
      return null;
    }
    return approvedClients as string[];
  } catch (e) {
    logIssue(new Error(`Error parsing cookie payload: ${e}`, { cause: e }));
    return null; // JSON parsing failed
  }
}

/**
 * Checks if a given client ID has already been approved by the user,
 * based on a signed cookie.
 *
 * @param request - The incoming Request object to read cookies from.
 * @param clientId - The OAuth client ID to check approval for.
 * @param cookieSecret - The secret key used to sign/verify the approval cookie.
 * @returns A promise resolving to true if the client ID is in the list of approved clients in a valid cookie, false otherwise.
 */
export async function clientIdAlreadyApproved(
  request: Request,
  clientId: string,
  cookieSecret: string,
): Promise<boolean> {
  if (!clientId) return false;
  const cookieHeader = request.headers.get("Cookie");
  const approvedClients = await getApprovedClientsFromCookie(
    cookieHeader,
    cookieSecret,
  );

  return approvedClients?.includes(clientId) ?? false;
}

/**
 * Configuration for the approval dialog
 */
export interface ApprovalDialogOptions {
  /**
   * Client information to display in the approval dialog
   */
  client: ClientInfo | null;
  /**
   * Server information to display in the approval dialog
   */
  server: {
    name: string;
    logo?: string;
    description?: string;
  };
  /**
   * Arbitrary state data to pass through the approval flow
   * Will be encoded in the form and returned when approval is complete
   */
  state: Record<string, any>;
}

/**
 * Encodes arbitrary data to a URL-safe base64 string.
 * @param data - The data to encode (will be stringified).
 * @returns A URL-safe base64 encoded string.
 */
function encodeState(data: any): string {
  try {
    const jsonString = JSON.stringify(data);
    // Use btoa for simplicity, assuming Worker environment supports it well enough
    // For complex binary data, a Buffer/Uint8Array approach might be better
    return btoa(jsonString);
  } catch (error) {
    logError(error, {
      loggerScope: ["cloudflare", "approval-dialog"],
      extra: {
        message: "Error encoding approval dialog state",
      },
    });
    throw new Error("Could not encode state");
  }
}

/**
 * Decodes a URL-safe base64 string back to its original data.
 * @param encoded - The URL-safe base64 encoded string.
 * @returns The original data.
 */
function decodeState<T = any>(encoded: string): T {
  try {
    const jsonString = atob(encoded);
    return JSON.parse(jsonString);
  } catch (error) {
    logError(error, {
      loggerScope: ["cloudflare", "approval-dialog"],
      extra: {
        message: "Error decoding approval dialog state",
      },
    });
    throw new Error("Could not decode state");
  }
}

/**
 * Renders an approval dialog for OAuth authorization
 * The dialog displays information about the client and server
 * and includes a form to submit approval
 *
 * @param request - The HTTP request
 * @param options - Configuration for the approval dialog
 * @returns A Response containing the HTML approval dialog
 */
export function renderApprovalDialog(
  request: Request,
  options: ApprovalDialogOptions,
): Response {
  const { client, server, state } = options;

  // Encode state for form submission
  const encodedState = encodeState(state);

  // Sanitize any untrusted content
  const serverName = sanitizeHtml(server.name);
  const clientName = client?.clientName
    ? sanitizeHtml(client.clientName)
    : "Unknown MCP Client";
  const serverDescription = server.description
    ? sanitizeHtml(server.description)
    : "";

  // Safe URLs
  const logoUrl = server.logo ? sanitizeHtml(server.logo) : "";
  const clientUri = client?.clientUri ? sanitizeHtml(client.clientUri) : "";
  const policyUri = client?.policyUri ? sanitizeHtml(client.policyUri) : "";
  const tosUri = client?.tosUri ? sanitizeHtml(client.tosUri) : "";

  // Client contacts
  const contacts =
    client?.contacts && client.contacts.length > 0
      ? sanitizeHtml(client.contacts.join(", "))
      : "";

  // Get redirect URIs
  const redirectUris =
    client?.redirectUris && client.redirectUris.length > 0
      ? client.redirectUris.map((uri) => sanitizeHtml(uri))
      : [];

  // Generate HTML for the approval dialog
  const htmlContent = `
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>${clientName} | Authorization Request</title>
        <style>
          /* Modern, responsive styling with system fonts */
          :root {
            --primary-color: oklch(0.205 0 0);
            --highlight-color: oklch(0.811 0.111 293.571);
            --border-color: oklch(0.278 0.033 256.848);
            --error-color: #f44336;
            --border-color: oklch(0.269 0 0);
            --text-color: oklch(0.872 0.01 258.338);
            --background-color: oklab(0 0 0 / 0.3);
          }
          
          body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 
                         Helvetica, Arial, sans-serif, "Apple Color Emoji", 
                         "Segoe UI Emoji", "Segoe UI Symbol";
            line-height: 1.6;
            color: var(--text-color);
            background: linear-gradient(oklch(0.13 0.028 261.692) 0%, oklch(0.21 0.034 264.665) 50%, oklch(0.13 0.028 261.692) 100%);
            min-height: 100vh;
            margin: 0;
            padding: 0;
          }
          
          .container {
            max-width: 600px;
            margin: 1rem auto;
            padding: 1rem;
          }
          
          .precard {
            text-align: center;
          }
          
          .card {
            background-color: var(--background-color);
            padding: 2rem;
          }
          
          .header {
            display: flex;
            align-items: center;
            justify-content: center;
            margin-bottom: 1.5rem;
          }
          
          .logo {
            width: 36px;
            height: 36px;
            margin-right: 1rem;
            color: var(--highlight-color);
          }
          
          .title {
            margin: 0;
            font-size: 26px;
            font-weight: 400;
            color: white;
          }
          
          .alert {
            margin: 0;
            font-size: 1.5rem;
            font-weight: 400;
            margin: 1rem 0;
            text-align: center;
            color: white;
          }
          
          .client-info {
            border: 1px solid var(--border-color);
            padding: 1rem 1rem 0.5rem;
            margin-bottom: 1.5rem;
          }
          
          .client-name {
            font-weight: 600;
            font-size: 1.2rem;
            margin: 0 0 0.5rem 0;
          }
          
          .client-detail {
            display: flex;
            margin-bottom: 0.5rem;
            align-items: baseline;
          }
          
          .detail-label {
            font-weight: 500;
            min-width: 120px;
          }
          
          .detail-value {
            font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
            word-break: break-all;
          }
          
          .detail-value.small {
            font-size: 0.8em;
          }
          
          .external-link-icon {
            font-size: 0.75em;
            margin-left: 0.25rem;
            vertical-align: super;
          }
          
          .actions {
            display: flex;
            justify-content: flex-end;
            gap: 1rem;
            margin-top: 2rem;
          }

          a {
            color: var(--highlight-color);
            text-decoration: underline;
          }
          
          .button {
            padding: 0.75rem 1.5rem;
            font-weight: 600;
            cursor: pointer;
            border: none;
            font-size: 1rem;
          }
          
          .button-primary {
            background-color: var(--highlight-color);
            color: black;
          }
          
          .button-secondary {
            background-color: transparent;
            border: 1px solid var(--border-color);
            color: var(--text-color);
          }
          
          /* Permission selection styles */
          .permission-section {
            margin: 2rem 0;
            border: 1px solid var(--border-color);
            padding: 1.5rem;
          }
          
          .permission-title {
            margin: 0 0 0.5rem 0;
            font-size: 1.3rem;
            font-weight: 600;
            color: white;
          }
          
          /* Default permissions section */
          .default-permissions {
            margin-bottom: 2rem;
            background-color: oklab(0 0 0 / 0.15);
            border: 1px solid var(--highlight-color);
            padding: 1.5rem;
            border-radius: 4px;
          }
          
          .default-permissions-title {
            margin: 0 0 1rem 0;
            font-size: 1rem;
            font-weight: 500;
            color: white;
          }
          
          .default-permission-item {
            display: flex;
            align-items: flex-start;
            gap: 0.75rem;
          }
          
          .permission-check {
            font-size: 1.2rem;
            color: var(--highlight-color);
            flex-shrink: 0;
            margin-top: 0.1rem;
          }
          
          .default-permission-content {
            flex: 1;
          }
          
          .default-permission-name {
            font-weight: 600;
            color: white;
            font-size: 1rem;
          }
          
          .default-permission-description {
            color: var(--text-color);
            font-size: 0.9rem;
            margin-top: 0.25rem;
          }
          
          /* Optional permissions section */
          .optional-permissions {
            margin-bottom: 1.5rem;
          }
          
          .optional-permissions-title {
            margin: 0 0 1rem 0;
            font-size: 1rem;
            font-weight: 500;
            color: white;
          }
          
          .optional-permission-item {
            display: flex;
            align-items: flex-start;
            gap: 0.75rem;
            padding: 1rem;
            border: 1px solid var(--border-color);
            margin-bottom: 0.75rem;
            cursor: pointer;
            transition: all 0.2s ease;
          }
          
          .optional-permission-item:hover {
            border-color: var(--highlight-color);
            background-color: oklab(0 0 0 / 0.1);
          }
          
          .optional-permission-item input[type="checkbox"] {
            position: absolute;
            opacity: 0;
            pointer-events: none;
          }
          
          .permission-checkbox {
            font-size: 1.2rem;
            color: var(--text-color);
            transition: color 0.2s ease;
            flex-shrink: 0;
            cursor: pointer;
            margin-top: 0.1rem;
          }
          
          /* CSS-only checkbox interactions using :checked pseudo-class */
          .optional-permission-item input[type="checkbox"]:checked + .permission-checkbox {
            color: var(--highlight-color);
          }
          
          .optional-permission-item input[type="checkbox"]:checked + .permission-checkbox::before {
            content: "☑";
          }
          
          .optional-permission-item input[type="checkbox"]:not(:checked) + .permission-checkbox::before {
            content: "☐";
          }
          
          .optional-permission-item:has(input[type="checkbox"]:checked) {
          border-color: var(--highlight-color);
            background-color: oklab(0 0 0 / 0.1);
          }
          
          .optional-permission-content {
            flex: 1;
          }
          
          .optional-permission-name {
            font-weight: 600;
            color: white;
            font-size: 1rem;
          }
          
          .optional-permission-description {
            color: var(--text-color);
            font-size: 0.9rem;
            margin-top: 0.25rem;
          }

          
          /* Responsive adjustments */
          @media (max-width: 640px) {
            .container {
              margin: 1rem auto;
              padding: 0.5rem;
            }
            
            .card {
              padding: 1.5rem;
            }
            
            .client-detail {
              flex-direction: column;
            }
            
            .detail-label {
              min-width: unset;
              margin-bottom: 0.25rem;
            }
            
            .actions {
              flex-direction: column;
            }
            
            .button {
              width: 100%;
            }
          }
        </style>
      </head>
      <body>
        <div class="container">
          <div class="precard">
            <div class="header">
              <svg class="logo" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-labelledby="icon-title"><title id="icon-title">Sentry Logo</title><path d="M17.48 1.996c.45.26.823.633 1.082 1.083l13.043 22.622a2.962 2.962 0 0 1-2.562 4.44h-3.062c.043-.823.039-1.647 0-2.472h3.052a.488.488 0 0 0 .43-.734L16.418 4.315a.489.489 0 0 0-.845 0L12.582 9.51a23.16 23.16 0 0 1 7.703 8.362 23.19 23.19 0 0 1 2.8 11.024v1.234h-7.882v-1.236a15.284 15.284 0 0 0-6.571-12.543l-1.48 2.567a12.301 12.301 0 0 1 5.105 9.987v1.233h-9.3a2.954 2.954 0 0 1-2.56-1.48A2.963 2.963 0 0 1 .395 25.7l1.864-3.26a6.854 6.854 0 0 1 2.15 1.23l-1.883 3.266a.49.49 0 0 0 .43.734h6.758a9.985 9.985 0 0 0-4.83-7.272l-1.075-.618 3.927-6.835 1.075.615a17.728 17.728 0 0 1 6.164 5.956 17.752 17.752 0 0 1 2.653 8.154h2.959a20.714 20.714 0 0 0-3.05-9.627 20.686 20.686 0 0 0-7.236-7.036l-1.075-.618 4.215-7.309a2.958 2.958 0 0 1 4.038-1.083Z" fill="currentColor"></path></svg>
              <h1 class="title"><strong>${serverName}</strong></h1>
            </div>
          </div>
            
          <div class="card">
            
            <h2 class="alert"><strong>${clientName || "A new MCP Client"}</strong> is requesting access</h1>
            
            <div class="client-info">
              <div class="client-detail">
                <div class="detail-label">Name:</div>
                <div class="detail-value">
                  ${clientName}
                </div>
              </div>
              
              ${
                clientUri
                  ? `
                <div class="client-detail">
                  <div class="detail-label">Website:</div>
                  <div class="detail-value small">
                    <a href="${clientUri}" target="_blank" rel="noopener noreferrer">
                      ${clientUri}
                    </a>
                  </div>
                </div>
              `
                  : ""
              }
              
              ${
                policyUri
                  ? `
                <div class="client-detail">
                  <div class="detail-label">Privacy Policy:</div>
                  <div class="detail-value">
                    <a href="${policyUri}" target="_blank" rel="noopener noreferrer">
                      ${policyUri}
                    </a>
                  </div>
                </div>
              `
                  : ""
              }
              
              ${
                tosUri
                  ? `
                <div class="client-detail">
                  <div class="detail-label">Terms of Service:</div>
                  <div class="detail-value">
                    <a href="${tosUri}" target="_blank" rel="noopener noreferrer">
                      ${tosUri}
                    </a>
                  </div>
                </div>
              `
                  : ""
              }
              
              ${
                redirectUris.length > 0
                  ? `
                <div class="client-detail">
                  <div class="detail-label">Redirect URIs:</div>
                  <div class="detail-value small">
                    ${redirectUris.map((uri) => `<div>${uri}</div>`).join("")}
                  </div>
                </div>
              `
                  : ""
              }
              
              ${
                contacts
                  ? `
                <div class="client-detail">
                  <div class="detail-label">Contact:</div>
                  <div class="detail-value">${contacts}</div>
                </div>
              `
                  : ""
              }
            </div>
            
            <p>This MCP Client is requesting authorization to ${serverName}. If you approve, you will be redirected to complete authentication.</p>            

            <form method="post" action="${new URL(request.url).pathname}">
              <input type="hidden" name="state" value="${encodedState}">
              
              <div class="permission-section">
                <h3 class="permission-title">Permissions</h3>
                
                <!-- Default permissions section -->
                <div class="default-permissions">
                  <div class="default-permission-item">
                    <span class="permission-check">✓</span>
                    <div class="default-permission-content">
                      <span class="default-permission-name">Read-only access to your Sentry data</span>
                      <div class="default-permission-description">View organizations, projects, teams, issues, and releases</div>
                    </div>
                  </div>
                </div>
                
                <!-- Optional permissions section -->
                <div class="optional-permissions">
                  <h4 class="optional-permissions-title">Optional additional access:</h4>

                  <label class="optional-permission-item">
                    <input type="checkbox" name="permission" value="seer" checked>
                    <span class="permission-checkbox"></span>
                    <div class="optional-permission-content">
                      <span class="optional-permission-name">Seer</span>
                      <div class="optional-permission-description">Use Seer to analyze issues and generate fix recommendations (may incur costs)</div>
                    </div>
                  </label>

                  <label class="optional-permission-item">
                    <input type="checkbox" name="permission" value="issue_triage">
                    <span class="permission-checkbox"></span>
                    <div class="optional-permission-content">
                      <span class="optional-permission-name">Issue Triage (event:write)</span>
                      <div class="optional-permission-description">Update and manage issues - resolve, assign, and triage problems</div>
                    </div>
                  </label>

                  <label class="optional-permission-item">
                    <input type="checkbox" name="permission" value="project_management">
                    <span class="permission-checkbox"></span>
                    <div class="optional-permission-content">
                      <span class="optional-permission-name">Project Management (project:write, team:write)</span>
                      <div class="optional-permission-description">Create and modify projects, teams, and DSNs</div>
                    </div>
                  </label>
                </div>
              </div>
              
              <div class="actions">
                <button type="button" class="button button-secondary" onclick="window.history.back()">Cancel</button>
                <button type="submit" class="button button-primary">Approve</button>
              </div>
            </form>
          </div>
        </div>
      </body>
    </html>
  `;

  return new Response(htmlContent, {
    headers: {
      "Content-Type": "text/html; charset=utf-8",
    },
  });
}

/**
 * Result of parsing the approval form submission.
 */
export interface ParsedApprovalResult {
  /** The original state object passed through the form. */
  state: any;
  /** Headers to set on the redirect response, including the Set-Cookie header. */
  headers: Record<string, string>;
  /** Selected permission levels */
  permissions: string[];
}

/**
 * Parses the form submission from the approval dialog, extracts the state,
 * and generates Set-Cookie headers to mark the client as approved.
 *
 * @param request - The incoming POST Request object containing the form data.
 * @param cookieSecret - The secret key used to sign the approval cookie.
 * @returns A promise resolving to an object containing the parsed state and necessary headers.
 * @throws If the request method is not POST, form data is invalid, or state is missing.
 */
export async function parseRedirectApproval(
  request: Request,
  cookieSecret: string,
): Promise<ParsedApprovalResult> {
  if (request.method !== "POST") {
    throw new Error("Invalid request method. Expected POST.");
  }

  let state: any;
  let clientId: string | undefined;
  let permissions: string[];

  try {
    const formData = await request.formData();
    const encodedState = formData.get("state");

    if (typeof encodedState !== "string" || !encodedState) {
      throw new Error("Missing or invalid 'state' in form data.");
    }

    state = decodeState<{ oauthReqInfo?: AuthRequest }>(encodedState); // Decode the state
    clientId = state?.oauthReqInfo?.clientId; // Extract clientId from within the state

    if (!clientId) {
      throw new Error("Could not extract clientId from state object.");
    }

    // Extract permission selections from checkboxes - collect all 'permission' field values
    permissions = formData
      .getAll("permission")
      .filter((p): p is string => typeof p === "string");
  } catch (error) {
    logError(error, {
      loggerScope: ["cloudflare", "approval-dialog"],
      extra: {
        message: "Error processing approval form submission",
      },
    });
    throw new Error(
      `Failed to parse approval form: ${error instanceof Error ? error.message : String(error)}`,
    );
  }

  // Get existing approved clients
  const cookieHeader = request.headers.get("Cookie");
  const existingApprovedClients =
    (await getApprovedClientsFromCookie(cookieHeader, cookieSecret)) || [];

  // Add the newly approved client ID (avoid duplicates)
  const updatedApprovedClients = Array.from(
    new Set([...existingApprovedClients, clientId]),
  );

  // Sign the updated list
  const payload = JSON.stringify(updatedApprovedClients);
  const key = await importKey(cookieSecret);
  const signature = await signData(key, payload);
  const newCookieValue = `${signature}.${btoa(payload)}`; // signature.base64(payload)

  // Generate Set-Cookie header
  const headers: Record<string, string> = {
    "Set-Cookie": `${COOKIE_NAME}=${newCookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=${ONE_YEAR_IN_SECONDS}`,
  };

  return { state, headers, permissions };
}

// sanitizeHtml function is now imported from "./html-utils"

```

--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/search-events/config.ts:
--------------------------------------------------------------------------------

```typescript
// Build a dataset-agnostic system prompt
export const systemPrompt = `You are a Sentry query translator. You need to:
1. FIRST determine which dataset (spans, errors, or logs) is most appropriate for the query
2. Query the available attributes for that dataset using the datasetAttributes tool
3. Use the otelSemantics tool if you need OpenTelemetry semantic conventions
4. Convert the natural language query to Sentry's search syntax (NOT SQL syntax)
5. Decide which fields to return in the results

CRITICAL: Sentry does NOT use SQL syntax. Do NOT generate SQL-like queries.

DATASET SELECTION GUIDELINES:
- spans: Performance data, traces, AI/LLM calls, database queries, HTTP requests, token usage, costs, duration metrics, user agent data, "XYZ calls", ambiguous operations (richest attribute set)
- errors: Exceptions, crashes, error messages, stack traces, unhandled errors, browser/client errors
- logs: Log entries, log messages, severity levels, debugging information

For ambiguous queries like "calls using XYZ", prefer spans dataset first as it contains the most comprehensive telemetry data.

CRITICAL - FIELD VERIFICATION REQUIREMENT:
Before constructing ANY query, you MUST verify field availability:
1. You CANNOT assume ANY field exists without checking - not even common ones
2. This includes ALL fields: custom attributes, database fields, HTTP fields, AI fields, user fields, etc.
3. Fields vary by project based on what data is being sent to Sentry
4. Using an unverified field WILL cause your query to fail with "field not found" errors
5. The datasetAttributes tool tells you EXACTLY which fields are available

TOOL USAGE GUIDELINES:
1. Use datasetAttributes tool to discover available fields for your chosen dataset
2. Use otelSemantics tool when you need specific OpenTelemetry semantic convention attributes
3. Use whoami tool when queries contain "me" references for user.id or user.email fields
4. IMPORTANT: For ambiguous terms like "user agents", "browser", "client" - use the datasetAttributes tool to find the correct field name (typically user_agent.original) instead of assuming it's related to user.id

CRITICAL - TOOL RESPONSE HANDLING:
All tools return responses in this format: {error?: string, result?: data}
- If 'error' is present: The tool failed - analyze the error message and potentially retry with corrections
- If 'result' is present: The tool succeeded - use the result data for your query construction
- Always check for errors before using results

CRITICAL - HANDLING "DISTINCT" OR "UNIQUE VALUES" QUERIES:
When user asks for "distinct", "unique", "all values of", or "what are the X" queries:
1. This ALWAYS requires an AGGREGATE query with count() function
2. Pattern: fields=['field_name', 'count()'] to show distinct values with counts
3. Sort by "-count()" to show most common values first
4. Use datasetAttributes tool to verify the field exists before constructing query
5. Examples:
   - "distinct categories" → fields=['category.name', 'count()'], sort='-count()'
   - "unique types" → fields=['item.type', 'count()'], sort='-count()'

CRITICAL - TRAFFIC/VOLUME/COUNT QUERIES:
When user asks about "traffic", "volume", "how much", "how many" (without specific metrics):
1. This ALWAYS requires an AGGREGATE query with count() function
2. For total counts: fields=['count()']
3. For grouped counts: fields=['grouping_field', 'count()']
4. Always include timeRange for period-specific queries
5. Examples:
   - "how much traffic in last 30 days" → fields=['count()'], timeRange: {"statsPeriod": "30d"}
   - "traffic on mcp-server" → query: "project:mcp-server", fields=['count()']

CRITICAL - HANDLING "ME" REFERENCES:
- If the query contains "me", "my", "myself", or "affecting me" in the context of user.id or user.email fields, use the whoami tool to get the user's ID and email
- For assignedTo fields, you can use "me" directly without translation (e.g., assignedTo:me works as-is)
- After calling whoami, replace "me" references with the actual user.id or user.email values
- If whoami fails, return an error explaining the issue

QUERY MODES:
1. INDIVIDUAL EVENTS (default): Returns raw event data
   - Used when fields contain no function() calls
   - Include recommended fields plus any user-requested fields

2. AGGREGATE QUERIES: Grouping and aggregation (NOT SQL)
   - Activated when ANY field contains a function() call
   - Fields should ONLY include: aggregate functions + groupBy fields
   - Automatically groups by ALL non-function fields
   - For aggregate queries, ONLY include the aggregate functions and groupBy fields - do NOT include default fields like timestamp, id, etc.
   - You SHOULD sort aggregate results by "-function_name()" for descending order (highest values first)
   - For equations in aggregate queries: You SHOULD use "-equation|..." prefix unless user wants lowest values
   - When user asks "how many total", "sum of", or similar: They want the highest/total value, use descending sort

CRITICAL LIMITATION - TIME SERIES NOT SUPPORTED:
- Queries asking for data "over time", "by hour", "by day", "time series", or similar temporal groupings are NOT currently supported
- If user asks for "X over time", return an error explaining: "Time series aggregations are not currently supported."

CRITICAL - DO NOT USE SQL SYNTAX:
- NEVER use SQL functions like yesterday(), today(), now(), IS NOT NULL, IS NULL
- NEVER use SQL date functions - use timeRange parameter instead
- For "yesterday": Use timeRange: {"statsPeriod": "24h"}, NOT timestamp >= yesterday()
- For field existence: Use has:field_name, NOT field_name IS NOT NULL
- For field absence: Use !has:field_name, NOT field_name IS NULL

MATHEMATICAL QUERY PATTERNS:
When user asks mathematical questions like "how many X", "total Y used", "sum of Z":
- Identify the appropriate dataset based on context
- Use datasetAttributes tool to find available numeric fields
- Use sum() function for totals, avg() for averages, count() for counts
- For time-based queries ("today", "yesterday", "this week"), use timeRange parameter
- For "total" or "how many" questions: Users typically want highest values first (descending sort)

DERIVED METRICS AND CALCULATIONS (SPANS ONLY):
When user asks for calculated metrics, ratios, or conversions:
- Use equation fields with "equation|" prefix
- Examples:
  - "duration in milliseconds" → fields: ["equation|avg(span.duration) * 1000"], sort: "-equation|avg(span.duration) * 1000"
  - "combined metric total" → fields: ["equation|sum(metric.a) + sum(metric.b)"], sort: "-equation|sum(metric.a) + sum(metric.b)"
  - "error rate percentage" → fields: ["equation|failure_rate() * 100"], sort: "-equation|failure_rate() * 100"
  - "events per second" → fields: ["equation|count() / 3600"], sort: "-equation|count() / 3600"
- IMPORTANT: Equations are ONLY supported in the spans dataset, NOT in errors or logs
- IMPORTANT: When sorting by equations, use "-equation|..." for descending order (highest values first)

SORTING RULES (CRITICAL - YOU MUST ALWAYS SPECIFY A SORT):
1. CRITICAL: Sort MUST go in the separate "sort" field, NEVER in the "query" field
   - WRONG: query: "level:error sort:-timestamp" ← Sort syntax in query field is FORBIDDEN
   - CORRECT: query: "level:error", sort: "-timestamp" ← Sort in separate field

2. DEFAULT SORTING:
   - errors dataset: Use "-timestamp" (newest first)
   - spans dataset: Use "-span.duration" (slowest first)  
   - logs dataset: Use "-timestamp" (newest first)

3. SORTING SYNTAX:
   - Use "-" prefix for descending order (e.g., "-timestamp" for newest first)
   - Use field name without prefix for ascending order
   - For aggregate queries: sort by aggregate function results (e.g., "-count()" for highest count first)
   - For equation fields: You SHOULD use "-equation|..." for descending order (e.g., "-equation|sum(field1) + sum(field2)")
   - Only omit the "-" prefix if the user clearly wants lowest values first (rare)

4. IMPORTANT SORTING REQUIREMENTS:
   - YOU MUST ALWAYS INCLUDE A SORT PARAMETER
   - CRITICAL: The field you sort by MUST be included in your fields array
   - If sorting by "-timestamp", include "timestamp" in fields
   - If sorting by "-count()", include "count()" in fields
   - This is MANDATORY - Sentry will reject queries where sort field is not in the selected fields

YOUR RESPONSE FORMAT:
Return a JSON object with these fields:
- "dataset": Which dataset you determined to use ("spans", "errors", or "logs")
- "query": The Sentry query string for filtering results (use empty string "" for no filters)
- "fields": Array of field names to return in results
  - For individual event queries: OPTIONAL (will use recommended fields if not provided)
  - For aggregate queries: REQUIRED (must include aggregate functions AND any groupBy fields)
- "sort": Sort parameter for results (REQUIRED - YOU MUST ALWAYS SPECIFY THIS)
- "timeRange": Time range parameters (optional)
  - Relative: {"statsPeriod": "24h"} for last 24 hours, "7d" for last 7 days, etc.
  - Absolute: {"start": "2025-06-19T07:00:00", "end": "2025-06-20T06:59:59"} for specific date ranges

CORRECT QUERY PATTERNS (FOLLOW THESE):
- For field existence: Use has:field_name (NOT field_name IS NOT NULL)
- For field absence: Use !has:field_name (NOT field_name IS NULL)
- For time periods: Use timeRange parameter (NOT SQL date functions)
- Example: "items processed yesterday" → query: "has:item.processed", timeRange: {"statsPeriod": "24h"}

PROCESS:
1. Analyze the user's query
2. Determine appropriate dataset
3. Use datasetAttributes tool to discover available fields
4. Use otelSemantics tool if needed for OpenTelemetry attributes
5. Construct the final query with proper fields and sort parameters

COMMON ERRORS TO AVOID:
- Using SQL syntax (IS NOT NULL, IS NULL, yesterday(), today(), etc.) - Use has: operator and timeRange instead
- Using numeric functions (sum, avg, min, max, percentiles) on non-numeric fields
- Using incorrect field names (use the otelSemantics tool to look up correct names)
- Missing required fields in the fields array for aggregate queries
- Invalid sort parameter not included in fields array
- For field existence: Use has:field_name (NOT field_name IS NOT NULL)
- For field absence: Use !has:field_name (NOT field_name IS NULL)
- For time periods: Use timeRange parameter (NOT SQL date functions like yesterday())`;

// Base fields common to all datasets
export const BASE_COMMON_FIELDS = {
  project: "Project slug",
  timestamp: "When the event occurred",
  environment: "Environment (production, staging, development)",
  release: "Release version",
  platform: "Platform (javascript, python, etc.)",
  "user.id": "User ID",
  "user.email": "User email",
  "sdk.name": "SDK name",
  "sdk.version": "SDK version",
};

// Known numeric fields for each dataset
export const NUMERIC_FIELDS: Record<string, Set<string>> = {
  spans: new Set([
    "span.duration",
    "span.self_time",
    "transaction.duration",
    "http.status_code",
    "gen_ai.usage.input_tokens",
    "gen_ai.usage.output_tokens",
    "gen_ai.request.max_tokens",
  ]),
  errors: new Set([
    // Most error fields are strings/categories
    "stack.lineno",
  ]),
  logs: new Set(["severity_number", "sentry.observed_timestamp_nanos"]),
};

// Dataset-specific field definitions
export const DATASET_FIELDS = {
  spans: {
    // Span-specific fields
    "span.op": "Span operation type (e.g., http.client, db.query, cache.get)",
    "span.description": "Detailed description of the span operation",
    "span.duration": "Duration of the span in milliseconds",
    "span.status": "Span status (ok, cancelled, unknown, etc.)",
    "span.self_time": "Time spent in this span excluding child spans",

    // Transaction fields
    transaction: "Transaction name/route",
    "transaction.duration": "Total transaction duration in milliseconds",
    "transaction.op": "Transaction operation type",
    "transaction.status": "Transaction status",
    is_transaction: "Whether this span is a transaction (true/false)",

    // Trace fields
    trace: "Trace ID",
    "trace.span_id": "Span ID within the trace",
    "trace.parent_span_id": "Parent span ID",

    // HTTP fields
    "http.method": "HTTP method (GET, POST, etc.)",
    "http.status_code": "HTTP response status code",
    "http.url": "Full HTTP URL",

    // Database fields
    "db.system": "Database system (postgresql, mysql, etc.)",
    "db.operation": "Database operation (SELECT, INSERT, etc.)",

    // OpenTelemetry attribute namespaces for semantic queries
    // Use has:namespace.* to find spans with any attribute in that namespace
    // GenAI namespace (gen_ai.*) - for AI/LLM/Agent calls
    "gen_ai.system": "AI system (e.g., anthropic, openai)",
    "gen_ai.request.model": "Model name (e.g., claude-3-5-sonnet-20241022)",
    "gen_ai.operation.name": "Operation type (e.g., chat, completion)",
    "gen_ai.usage.input_tokens": "Number of input tokens (numeric)",
    "gen_ai.usage.output_tokens": "Number of output tokens (numeric)",

    // MCP namespace (mcp.*) - for Model Context Protocol tool calls
    "mcp.tool.name": "Tool name (e.g., search_issues, search_events)",
    "mcp.session.id": "MCP session identifier",

    // Aggregate functions (SPANS dataset only - require numeric fields except count/count_unique)
    "count()": "Count of spans",
    "count_unique(field)": "Count of unique values, e.g. count_unique(user.id)",
    "avg(field)": "Average of numeric field, e.g. avg(span.duration)",
    "sum(field)": "Sum of numeric field, e.g. sum(span.self_time)",
    "min(field)": "Minimum of numeric field, e.g. min(span.duration)",
    "max(field)": "Maximum of numeric field, e.g. max(span.duration)",
    "p50(field)": "50th percentile (median), e.g. p50(span.duration)",
    "p75(field)": "75th percentile, e.g. p75(span.duration)",
    "p90(field)": "90th percentile, e.g. p90(span.duration)",
    "p95(field)": "95th percentile, e.g. p95(span.duration)",
    "p99(field)": "99th percentile, e.g. p99(span.duration)",
    "p100(field)": "100th percentile (max), e.g. p100(span.duration)",
    "epm()": "Events per minute rate",
    "failure_rate()": "Percentage of failed spans",
  },
  errors: {
    // Error-specific fields
    message: "Error message",
    level: "Error level (error, warning, info, debug)",
    "error.type": "Error type/exception class",
    "error.value": "Error value/description",
    "error.handled": "Whether the error was handled (true/false)",
    culprit: "Code location that caused the error",
    title: "Error title/grouping",

    // Stack trace fields
    "stack.filename": "File where error occurred",
    "stack.function": "Function where error occurred",
    "stack.module": "Module where error occurred",
    "stack.abs_path": "Absolute path to file",

    // Additional context fields
    "os.name": "Operating system name",
    "browser.name": "Browser name",
    "device.family": "Device family",

    // Aggregate functions (ERRORS dataset only)
    "count()": "Count of error events",
    "count_unique(field)": "Count of unique values, e.g. count_unique(user.id)",
    "count_if(field,equals,value)":
      "Conditional count, e.g. count_if(error.handled,equals,false)",
    "last_seen()": "Most recent timestamp of the group",
    "eps()": "Events per second rate",
    "epm()": "Events per minute rate",
  },
  logs: {
    // Log-specific fields
    message: "Log message",
    severity: "Log severity level",
    severity_number: "Numeric severity level",
    "sentry.item_id": "Sentry item ID",
    "sentry.observed_timestamp_nanos": "Observed timestamp in nanoseconds",

    // Trace context
    trace: "Trace ID",

    // Aggregate functions (LOGS dataset only - require numeric fields except count/count_unique)
    "count()": "Count of log entries",
    "count_unique(field)": "Count of unique values, e.g. count_unique(user.id)",
    "avg(field)": "Average of numeric field, e.g. avg(severity_number)",
    "sum(field)": "Sum of numeric field",
    "min(field)": "Minimum of numeric field",
    "max(field)": "Maximum of numeric field",
    "p50(field)": "50th percentile (median)",
    "p75(field)": "75th percentile",
    "p90(field)": "90th percentile",
    "p95(field)": "95th percentile",
    "p99(field)": "99th percentile",
    "p100(field)": "100th percentile (max)",
    "epm()": "Events per minute rate",
  },
};

// Dataset-specific rules and examples
export const DATASET_CONFIGS = {
  errors: {
    rules: `- For errors, focus on: message, level, error.type, error.handled
- Use level field for severity (error, warning, info, debug)
- Use error.handled:false for unhandled exceptions/crashes
- For filename searches: Use stack.filename for suffix-based search (e.g., stack.filename:"**/index.js" or stack.filename:"**/components/Button.tsx")
- When searching for errors in specific files, prefer including the parent folder to avoid ambiguity (e.g., stack.filename:"**/components/index.js" instead of just stack.filename:"**/index.js")`,
    examples: `- "null pointer exceptions" → 
  {
    "query": "error.type:\\"NullPointerException\\" OR message:\\"*null pointer*\\"",
    "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit"],
    "sort": "-timestamp"
  }
- "unhandled errors in production" → 
  {
    "query": "error.handled:false AND environment:production",
    "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit", "error.handled", "environment"],
    "sort": "-timestamp"
  }
- "database connection errors" → 
  {
    "query": "message:\\"*database*\\" AND message:\\"*connection*\\" AND level:error",
    "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit"],
    "sort": "-timestamp"
  }
- "show me user emails for authentication failures" → 
  {
    "query": "message:\\"*auth*\\" AND (message:\\"*failed*\\" OR message:\\"*denied*\\")",
    "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit", "user.email"],
    "sort": "-timestamp"
  }
- "errors in Button.tsx file" → 
  {
    "query": "stack.filename:\\"**/Button.tsx\\"",
    "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit", "stack.filename"],
    "sort": "-timestamp"
  }
- "count errors by type in production" → 
  {
    "query": "environment:production",
    "fields": ["error.type", "count()", "last_seen()"],
    "sort": "-count()"
  }
- "most common errors last 24h" → 
  {
    "query": "level:error",
    "fields": ["title", "error.type", "count()"],
    "sort": "-count()"
  }
- "unhandled errors rate by project" → 
  {
    "query": "",
    "fields": ["project", "count()", "count_if(error.handled,equals,false)", "epm()"],
    "sort": "-count()"
  }
- "errors in the last hour" → 
  {
    "query": "",
    "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit"],
    "sort": "-timestamp",
    "timeRange": {"statsPeriod": "1h"}
  }
- "database errors between June 19-20" → 
  {
    "query": "message:\\"*database*\\"",
    "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit"],
    "sort": "-timestamp",
    "timeRange": {"start": "2025-06-19T00:00:00", "end": "2025-06-20T23:59:59"}
  }
- "unique users affected by errors" → 
  {
    "query": "level:error",
    "fields": ["error.type", "count()", "count_unique(user.id)"],
    "sort": "-count_unique(user.id)"
  }
- "what is the most common error" → 
  {
    "query": "",
    "fields": ["title", "count()"],
    "sort": "-count()"
  }
- "errors by browser" → 
  {
    "query": "has:user_agent.original",
    "fields": ["user_agent.original", "count()"],
    "sort": "-count()"
  }
- "which user agents have the most errors" → 
  {
    "query": "level:error AND has:user_agent.original",
    "fields": ["user_agent.original", "count()", "count_unique(user.id)"],
    "sort": "-count()"
  }`,
  },
  logs: {
    rules: `- For logs, focus on: message, severity, severity_number
- Use severity field for log levels (fatal, error, warning, info, debug, trace)
- severity_number is numeric (21=fatal, 17=error, 13=warning, 9=info, 5=debug, 1=trace)
- IMPORTANT: For time-based filtering in logs, do NOT use timestamp filters in the query
- Instead, time filtering for logs is handled by the statsPeriod parameter (not part of the query string)
- Keep your query focused on message content, severity levels, and other attributes only
- When user asks for "error logs", interpret this as logs with severity:error`,
    examples: `- "warning logs about memory" → 
  {
    "query": "severity:warning AND message:\\"*memory*\\"",
    "fields": ["timestamp", "project", "message", "severity", "trace"],
    "sort": "-timestamp"
  }
- "error logs from database" → 
  {
    "query": "severity:error AND message:\\"*database*\\"",
    "fields": ["timestamp", "project", "message", "severity", "trace"],
    "sort": "-timestamp"
  }
- "show me error logs with user context" → 
  {
    "query": "severity:error",
    "fields": ["timestamp", "project", "message", "severity", "trace", "user.id", "user.email"],
    "sort": "-timestamp"
  }
- "what is the most common log" → 
  {
    "query": "",
    "fields": ["message", "count()"],
    "sort": "-count()"
  }
- "most common error logs" → 
  {
    "query": "severity:error",
    "fields": ["message", "count()"],
    "sort": "-count()"
  }
- "count logs by severity" → 
  {
    "query": "",
    "fields": ["severity", "count()"],
    "sort": "-count()"
  }
- "log volume by project" → 
  {
    "query": "",
    "fields": ["project", "count()", "epm()"],
    "sort": "-count()"
  }`,
  },
  spans: {
    rules: `- For traces/spans, focus on: span.op, span.description, span.duration, transaction
- Use is_transaction:true for transaction spans only
- Use span.duration for performance queries (value is in milliseconds)
- IMPORTANT: Use has: queries for attribute-based filtering instead of span.op patterns:
  - For HTTP requests: use "has:request.url" instead of "span.op:http*"
  - For database queries: use "has:db.statement" or "has:db.system" instead of "span.op:db*"
  - For AI/LLM/Agent calls: use "has:gen_ai.system" or "has:gen_ai.request.model" (OpenTelemetry GenAI semantic conventions)
  - For MCP tool calls: use "has:mcp.tool.name" (Model Context Protocol semantic conventions)
  - This approach is more flexible and captures all relevant spans regardless of their operation type

OpenTelemetry Semantic Conventions (2025 Stable):
Core Namespaces:
- gen_ai.*: GenAI attributes for AI/LLM/Agent calls (system, request.model, operation.name, usage.*)
- db.*: Database attributes (system, statement, operation, name) - STABLE
- http.*: HTTP attributes (method, status_code, url, request.*, response.*) - STABLE
- rpc.*: RPC attributes (system, service, method, grpc.*)
- messaging.*: Messaging attributes (system, operation, destination.*)
- faas.*: Function as a Service attributes (name, version, runtime)
- cloud.*: Cloud provider attributes (provider, region, zone)
- k8s.*: Kubernetes attributes (namespace, pod, container, node)
- host.*: Host attributes (name, type, arch, os.*)
- service.*: Service attributes (name, version, instance.id)
- process.*: Process attributes (pid, command, runtime.*)

Custom Namespaces:
- mcp.*: Model Context Protocol attributes for MCP tool calls (tool.name, session.id, transport)

Query Patterns:
- Use has:namespace.* to find spans with any attribute in that namespace
- Most common: has:gen_ai.system (agent calls), has:mcp.tool.name (MCP tools), has:db.statement (database), has:http.method (HTTP)`,
    examples: `- "database queries" → 
  {
    "query": "has:db.statement",
    "fields": ["span.op", "span.description", "span.duration", "transaction", "timestamp", "project", "trace", "db.system", "db.statement"],
    "sort": "-span.duration"
  }
- "slow API calls over 5 seconds" → 
  {
    "query": "has:request.url AND span.duration:>5000",
    "fields": ["span.op", "span.description", "span.duration", "transaction", "timestamp", "project", "trace", "request.url", "request.method", "span.status_code"],
    "sort": "-span.duration"
  }
- "show me database queries with their SQL" → 
  {
    "query": "has:db.statement",
    "fields": ["span.op", "span.description", "span.duration", "transaction", "timestamp", "project", "trace", "db.system", "db.statement"],
    "sort": "-span.duration"
  }
- "average response time by endpoint" → 
  {
    "query": "is_transaction:true",
    "fields": ["transaction", "count()", "avg(span.duration)", "p95(span.duration)"],
    "sort": "-avg(span.duration)"
  }
- "slowest database queries by p95" → 
  {
    "query": "has:db.statement",
    "fields": ["db.statement", "count()", "p50(span.duration)", "p95(span.duration)", "max(span.duration)"],
    "sort": "-p95(span.duration)"
  }
- "API calls in the last 30 minutes" → 
  {
    "query": "has:request.url",
    "fields": ["id", "span.op", "span.description", "span.duration", "transaction", "timestamp", "project", "trace", "request.url", "request.method"],
    "sort": "-timestamp",
    "timeRange": {"statsPeriod": "30m"}
  }
- "most common transaction" → 
  {
    "query": "is_transaction:true",
    "fields": ["transaction", "count()"],
    "sort": "-count()"
  }
- "top 10 tool call spans by usage" → 
  {
    "query": "has:mcp.tool.name",
    "fields": ["mcp.tool.name", "count()"],
    "sort": "-count()"
  }
- "top 10 agent call spans by usage" → 
  {
    "query": "has:gen_ai.system",
    "fields": ["gen_ai.system", "gen_ai.request.model", "count()"],
    "sort": "-count()"
  }
- "slowest AI/LLM calls" → 
  {
    "query": "has:gen_ai.request.model",
    "fields": ["gen_ai.system", "gen_ai.request.model", "span.duration", "transaction", "timestamp", "project", "trace", "gen_ai.operation.name"],
    "sort": "-span.duration"
  }
- "agent calls by model usage" → 
  {
    "query": "has:gen_ai.request.model",
    "fields": ["gen_ai.request.model", "count()"],
    "sort": "-count()"
  }
- "average agent call duration by model" → 
  {
    "query": "has:gen_ai.request.model",
    "fields": ["gen_ai.request.model", "count()", "avg(span.duration)", "p95(span.duration)"],
    "sort": "-avg(span.duration)"
  }
- "token usage by AI system" → 
  {
    "query": "has:gen_ai.usage.input_tokens",
    "fields": ["gen_ai.system", "sum(gen_ai.usage.input_tokens)", "sum(gen_ai.usage.output_tokens)", "count()"],
    "sort": "-sum(gen_ai.usage.input_tokens)"
  }
- "how many tokens used today" → 
  {
    "query": "has:gen_ai.usage.input_tokens",
    "fields": ["sum(gen_ai.usage.input_tokens)", "sum(gen_ai.usage.output_tokens)", "count()"],
    "sort": "-sum(gen_ai.usage.input_tokens)",
    "timeRange": {"statsPeriod": "24h"}
  }
- "average response time in milliseconds" → 
  {
    "query": "is_transaction:true",
    "fields": ["transaction", "equation|avg(span.duration) * 1000"],
    "sort": "-equation|avg(span.duration) * 1000",
    "timeRange": {"statsPeriod": "24h"}
  }
- "total input tokens by model" → 
  {
    "query": "has:gen_ai.usage.input_tokens",
    "fields": ["gen_ai.request.model", "sum(gen_ai.usage.input_tokens)", "count()"],
    "sort": "-sum(gen_ai.usage.input_tokens)"
  }
- "tokens used this week" → 
  {
    "query": "has:gen_ai.usage.input_tokens",
    "fields": ["sum(gen_ai.usage.input_tokens)", "sum(gen_ai.usage.output_tokens)", "count()"],
    "sort": "-sum(gen_ai.usage.input_tokens)",
    "timeRange": {"statsPeriod": "7d"}
  }
- "which user agents have the most tool calls yesterday" → 
  {
    "query": "has:mcp.tool.name AND has:user_agent.original",
    "fields": ["user_agent.original", "count()"],
    "sort": "-count()",
    "timeRange": {"statsPeriod": "24h"}
  }
- "top 10 browsers by API calls" → 
  {
    "query": "has:http.method AND has:user_agent.original",
    "fields": ["user_agent.original", "count()"],
    "sort": "-count()"
  }
- "most common clients making database queries" → 
  {
    "query": "has:db.statement AND has:user_agent.original",
    "fields": ["user_agent.original", "count()", "avg(span.duration)"],
    "sort": "-count()"
  }`,
  },
};

// Define recommended fields for each dataset
export const RECOMMENDED_FIELDS = {
  errors: {
    basic: [
      "issue",
      "title",
      "project",
      "timestamp",
      "level",
      "message",
      "error.type",
      "culprit",
    ],
    description:
      "Basic error information including issue ID, title, timestamp, severity, and location",
  },
  logs: {
    basic: ["timestamp", "project", "message", "severity", "trace"],
    description: "Essential log entry information",
  },
  spans: {
    basic: [
      "id",
      "span.op",
      "span.description",
      "span.duration",
      "transaction",
      "timestamp",
      "project",
      "trace",
    ],
    description:
      "Core span/trace information including span ID, operation, duration, and trace context",
  },
};

```

--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/get-issue-details.test.ts:
--------------------------------------------------------------------------------

```typescript
import { describe, it, expect } from "vitest";
import { http, HttpResponse } from "msw";
import { mswServer } from "@sentry/mcp-server-mocks";
import getIssueDetails from "./get-issue-details.js";
import { performanceEventFixture } from "@sentry/mcp-server-mocks";

const baseContext = {
  constraints: {
    organizationSlug: null,
  },
  accessToken: "access-token",
  userId: "1",
};

function createPerformanceIssueFixture() {
  return {
    id: "7890123456",
    shareId: null,
    shortId: "PERF-N1-001",
    title: "N+1 Query: SELECT * FROM users WHERE id = %s",
    culprit: "GET /api/users",
    permalink: "https://sentry-mcp-evals.sentry.io/issues/7890123456/",
    logger: null,
    level: "warning",
    status: "unresolved",
    statusDetails: {},
    substatus: "ongoing",
    isPublic: false,
    platform: "python",
    project: {
      id: "4509062593708032",
      name: "CLOUDFLARE-MCP",
      slug: "CLOUDFLARE-MCP",
      platform: "python",
    },
    type: "performance_n_plus_one_db_queries",
    metadata: {
      title: "N+1 Query: SELECT * FROM users WHERE id = %s",
      location: "GET /api/users",
      value: "SELECT * FROM users WHERE id = %s",
    },
    numComments: 0,
    assignedTo: null,
    isBookmarked: false,
    isSubscribed: false,
    subscriptionDetails: null,
    hasSeen: true,
    annotations: [],
    issueType: "performance_n_plus_one_db_queries",
    issueCategory: "performance",
    priority: "medium",
    priorityLockedAt: null,
    isUnhandled: false,
    count: "25",
    userCount: 5,
    firstSeen: "2025-08-05T12:00:00.000Z",
    lastSeen: "2025-08-06T12:00:00.000Z",
    firstRelease: null,
    lastRelease: null,
    activity: [],
    openPeriods: [],
    seenBy: [],
    pluginActions: [],
    pluginIssues: [],
    pluginContexts: [],
    userReportCount: 0,
    stats: {},
    participants: [],
  };
}

function createPerformanceEventFixture() {
  const cloned = JSON.parse(JSON.stringify(performanceEventFixture));
  const offenderSpanIds = cloned.occurrence.evidenceData.offenderSpanIds.slice(
    0,
    3,
  );
  cloned.occurrence.evidenceData.offenderSpanIds = offenderSpanIds;
  cloned.occurrence.evidenceData.numberRepeatingSpans = String(
    offenderSpanIds.length,
  );
  cloned.occurrence.evidenceData.repeatingSpansCompact = undefined;
  cloned.occurrence.evidenceData.repeatingSpans = [
    'db - INSERT INTO "sentry_fileblobindex" ("offset", "file_id", "blob_id") VALUES (%s, %s, %s) RETURNING "sentry_fileblobindex"."id"',
    "function - sentry.models.files.abstractfileblob.AbstractFileBlob.from_file",
    'db - SELECT "sentry_fileblob"."id", "sentry_fileblob"."path", "sentry_fileblob"."size", "sentry_fileblob"."checksum", "sentry_fileblob"."timestamp" FROM "sentry_fileblob" WHERE "sentry_fileblob"."checksum" = %s LIMIT 21',
  ];

  const spansEntry = cloned.entries.find(
    (entry: { type: string }) => entry.type === "spans",
  );
  if (spansEntry?.data) {
    spansEntry.data = spansEntry.data.slice(0, 4);
  }
  return cloned;
}

function createTraceResponseFixture() {
  return [
    {
      span_id: "root-span",
      event_id: "root-span",
      transaction_id: "root-span",
      project_id: "4509062593708032",
      project_slug: "cloudflare-mcp",
      profile_id: "",
      profiler_id: "",
      parent_span_id: null,
      start_timestamp: 0,
      end_timestamp: 1,
      measurements: {},
      duration: 1000,
      transaction: "/api/users",
      is_transaction: true,
      description: "GET /api/users",
      sdk_name: "sentry.python",
      op: "http.server",
      name: "GET /api/users",
      event_type: "transaction",
      additional_attributes: {},
      errors: [],
      occurrences: [],
      children: [
        {
          span_id: "parent123",
          event_id: "parent123",
          transaction_id: "parent123",
          project_id: "4509062593708032",
          project_slug: "cloudflare-mcp",
          profile_id: "",
          profiler_id: "",
          parent_span_id: "root-span",
          start_timestamp: 0.1,
          end_timestamp: 0.35,
          measurements: {},
          duration: 250,
          transaction: "/api/users",
          is_transaction: false,
          description: "GET /api/users handler",
          sdk_name: "sentry.python",
          op: "http.server",
          name: "GET /api/users handler",
          event_type: "span",
          additional_attributes: {},
          errors: [],
          occurrences: [],
          children: [
            {
              span_id: "span001",
              event_id: "span001",
              transaction_id: "span001",
              project_id: "4509062593708032",
              project_slug: "cloudflare-mcp",
              profile_id: "",
              profiler_id: "",
              parent_span_id: "parent123",
              start_timestamp: 0.15,
              end_timestamp: 0.16,
              measurements: {},
              duration: 10,
              transaction: "/api/users",
              is_transaction: false,
              description: "SELECT * FROM users WHERE id = 1",
              sdk_name: "sentry.python",
              op: "db.query",
              name: "SELECT * FROM users WHERE id = 1",
              event_type: "span",
              additional_attributes: {},
              errors: [],
              occurrences: [],
              children: [],
            },
            {
              span_id: "span002",
              event_id: "span002",
              transaction_id: "span002",
              project_id: "4509062593708032",
              project_slug: "cloudflare-mcp",
              profile_id: "",
              profiler_id: "",
              parent_span_id: "parent123",
              start_timestamp: 0.2,
              end_timestamp: 0.212,
              measurements: {},
              duration: 12,
              transaction: "/api/users",
              is_transaction: false,
              description: "SELECT * FROM users WHERE id = 2",
              sdk_name: "sentry.python",
              op: "db.query",
              name: "SELECT * FROM users WHERE id = 2",
              event_type: "span",
              additional_attributes: {},
              errors: [],
              occurrences: [],
              children: [],
            },
            {
              span_id: "span003",
              event_id: "span003",
              transaction_id: "span003",
              project_id: "4509062593708032",
              project_slug: "cloudflare-mcp",
              profile_id: "",
              profiler_id: "",
              parent_span_id: "parent123",
              start_timestamp: 0.24,
              end_timestamp: 0.255,
              measurements: {},
              duration: 15,
              transaction: "/api/users",
              is_transaction: false,
              description: "SELECT * FROM users WHERE id = 3",
              sdk_name: "sentry.python",
              op: "db.query",
              name: "SELECT * FROM users WHERE id = 3",
              event_type: "span",
              additional_attributes: {},
              errors: [],
              occurrences: [],
              children: [],
            },
          ],
        },
      ],
    },
  ];
}

describe("get_issue_details", () => {
  it("serializes with issueId", async () => {
    const result = await getIssueDetails.handler(
      {
        organizationSlug: "sentry-mcp-evals",
        issueId: "CLOUDFLARE-MCP-41",
        eventId: undefined,
        issueUrl: undefined,
        regionUrl: undefined,
      },
      {
        constraints: {
          organizationSlug: null,
        },
        accessToken: "access-token",
        userId: "1",
      },
    );
    expect(result).toMatchInlineSnapshot(`
      "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals**

      **Description**: Error: Tool list_organizations is already registered
      **Culprit**: Object.fetch(index)
      **First Seen**: 2025-04-03T22:51:19.403Z
      **Last Seen**: 2025-04-12T11:34:11.000Z
      **Occurrences**: 25
      **Users Impacted**: 1
      **Status**: unresolved
      **Platform**: javascript
      **Project**: CLOUDFLARE-MCP
      **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41

      ## Event Details

      **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51
      **Occurred At**: 2025-04-08T21:15:04.000Z

      ### Error

      \`\`\`
      Error: Tool list_organizations is already registered
      \`\`\`

      **Stacktrace:**
      \`\`\`
      index.js:7809:27
      index.js:8029:24 (OAuthProviderImpl.fetch)
      index.js:19631:28 (Object.fetch)
      \`\`\`

      ### HTTP Request

      **Method:** GET
      **URL:** https://mcp.sentry.dev/sse

      ### Tags

      **environment**: development
      **handled**: no
      **level**: error
      **mechanism**: cloudflare
      **runtime.name**: cloudflare
      **url**: https://mcp.sentry.dev/sse

      ### Additional Context

      These are additional context provided by the user when they're instrumenting their application.

      **cloud_resource**
      cloud.provider: "cloudflare"

      **culture**
      timezone: "Europe/London"

      **runtime**
      name: "cloudflare"

      **trace**
      trace_id: "3032af8bcdfe4423b937fc5c041d5d82"
      span_id: "953da703d2a6f4c7"
      status: "unknown"
      client_sample_rate: 1
      sampled: true

      # Using this information

      - You can reference the IssueID in commit messages (e.g. \`Fixes CLOUDFLARE-MCP-41\`) to automatically close the issue when the commit is merged.
      - The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code.
      "
    `);
  });

  it("serializes with issueUrl", async () => {
    const result = await getIssueDetails.handler(
      {
        organizationSlug: undefined,
        issueId: undefined,
        eventId: undefined,
        issueUrl: "https://sentry-mcp-evals.sentry.io/issues/6507376925",
        regionUrl: undefined,
      },
      {
        constraints: {
          organizationSlug: null,
        },
        accessToken: "access-token",
        userId: "1",
      },
    );

    expect(result).toMatchInlineSnapshot(`
      "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals**

      **Description**: Error: Tool list_organizations is already registered
      **Culprit**: Object.fetch(index)
      **First Seen**: 2025-04-03T22:51:19.403Z
      **Last Seen**: 2025-04-12T11:34:11.000Z
      **Occurrences**: 25
      **Users Impacted**: 1
      **Status**: unresolved
      **Platform**: javascript
      **Project**: CLOUDFLARE-MCP
      **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41

      ## Event Details

      **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51
      **Occurred At**: 2025-04-08T21:15:04.000Z

      ### Error

      \`\`\`
      Error: Tool list_organizations is already registered
      \`\`\`

      **Stacktrace:**
      \`\`\`
      index.js:7809:27
      index.js:8029:24 (OAuthProviderImpl.fetch)
      index.js:19631:28 (Object.fetch)
      \`\`\`

      ### HTTP Request

      **Method:** GET
      **URL:** https://mcp.sentry.dev/sse

      ### Tags

      **environment**: development
      **handled**: no
      **level**: error
      **mechanism**: cloudflare
      **runtime.name**: cloudflare
      **url**: https://mcp.sentry.dev/sse

      ### Additional Context

      These are additional context provided by the user when they're instrumenting their application.

      **cloud_resource**
      cloud.provider: "cloudflare"

      **culture**
      timezone: "Europe/London"

      **runtime**
      name: "cloudflare"

      **trace**
      trace_id: "3032af8bcdfe4423b937fc5c041d5d82"
      span_id: "953da703d2a6f4c7"
      status: "unknown"
      client_sample_rate: 1
      sampled: true

      # Using this information

      - You can reference the IssueID in commit messages (e.g. \`Fixes CLOUDFLARE-MCP-41\`) to automatically close the issue when the commit is merged.
      - The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code.
      "
    `);
  });

  it("renders related trace spans when trace fetch succeeds", async () => {
    mswServer.use(
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/",
        () => HttpResponse.json(createPerformanceIssueFixture()),
        { once: true },
      ),
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/events/latest/",
        () => HttpResponse.json(createPerformanceEventFixture()),
        { once: true },
      ),
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/abcdef1234567890abcdef1234567890/",
        () => HttpResponse.json(createTraceResponseFixture()),
        { once: true },
      ),
    );

    const result = await getIssueDetails.handler(
      {
        organizationSlug: "sentry-mcp-evals",
        issueId: "PERF-N1-001",
        eventId: undefined,
        issueUrl: undefined,
        regionUrl: undefined,
      },
      baseContext,
    );

    if (typeof result !== "string") {
      throw new Error("Expected string result");
    }

    const performanceSection = result
      .slice(result.indexOf("### Repeated Database Queries"))
      .split("### Tags")[0]
      .trim();

    expect(performanceSection).toMatchInlineSnapshot(`
      "### Repeated Database Queries

      **Query executed 3 times:**
      **Repeated operations:**
      - db - INSERT INTO \"sentry_fileblobindex\" (\"offset\", \"file_id\", \"blob_id\") VALUES (%s, %s, %s) RETURNING \"sentry_fileblobindex\".\"id\"
      - function - sentry.models.files.abstractfileblob.AbstractFileBlob.from_file
      - db - SELECT \"sentry_fileblob\".\"id\", \"sentry_fileblob\".\"path\", \"sentry_fileblob\".\"size\", \"sentry_fileblob\".\"checksum\", \"sentry_fileblob\".\"timestamp\" FROM \"sentry_fileblob\" WHERE \"sentry_fileblob\".\"checksum\" = %s LIMIT 21

      ### Span Tree (Limited to 10 spans)

      \`\`\`
      GET /api/users [parent12 · http.server · 250ms]
         ├─ SELECT * FROM users WHERE id = 1 [span001 · db.query · 5ms] [N+1]
         ├─ SELECT * FROM users WHERE id = 2 [span002 · db.query · 5ms] [N+1]
         └─ SELECT * FROM users WHERE id = 3 [span003 · db.query · 5ms] [N+1]
      \`\`\`

      **Transaction:**
      /api/users

      **Offending Spans:**
      SELECT * FROM users WHERE id = %s

      **Repeated:**
      25 times"
    `);
  });

  it("falls back to offending span list when trace fetch fails", async () => {
    mswServer.use(
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/",
        () => HttpResponse.json(createPerformanceIssueFixture()),
        { once: true },
      ),
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/events/latest/",
        () => HttpResponse.json(createPerformanceEventFixture()),
        { once: true },
      ),
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/abcdef1234567890abcdef1234567890/",
        () => HttpResponse.json({ detail: "Trace not found" }, { status: 404 }),
        { once: true },
      ),
    );

    const result = await getIssueDetails.handler(
      {
        organizationSlug: "sentry-mcp-evals",
        issueId: "PERF-N1-001",
        eventId: undefined,
        issueUrl: undefined,
        regionUrl: undefined,
      },
      baseContext,
    );

    if (typeof result !== "string") {
      throw new Error("Expected string result");
    }

    const performanceSection = result
      .slice(result.indexOf("### Repeated Database Queries"))
      .split("### Tags")[0]
      .trim();

    expect(performanceSection).toMatchInlineSnapshot(`
      "### Repeated Database Queries

      **Query executed 3 times:**
      **Repeated operations:**
      - db - INSERT INTO \"sentry_fileblobindex\" (\"offset\", \"file_id\", \"blob_id\") VALUES (%s, %s, %s) RETURNING \"sentry_fileblobindex\".\"id\"
      - function - sentry.models.files.abstractfileblob.AbstractFileBlob.from_file
      - db - SELECT \"sentry_fileblob\".\"id\", \"sentry_fileblob\".\"path\", \"sentry_fileblob\".\"size\", \"sentry_fileblob\".\"checksum\", \"sentry_fileblob\".\"timestamp\" FROM \"sentry_fileblob\" WHERE \"sentry_fileblob\".\"checksum\" = %s LIMIT 21

      ### Span Tree (Limited to 10 spans)

      \`\`\`
      GET /api/users [parent12 · http.server · 250ms]
         ├─ SELECT * FROM users WHERE id = 1 [span001 · db.query · 5ms] [N+1]
         ├─ SELECT * FROM users WHERE id = 2 [span002 · db.query · 5ms] [N+1]
         └─ SELECT * FROM users WHERE id = 3 [span003 · db.query · 5ms] [N+1]
      \`\`\`

      **Transaction:**
      /api/users

      **Offending Spans:**
      SELECT * FROM users WHERE id = %s

      **Repeated:**
      25 times"
    `);
  });

  it("serializes with eventId", async () => {
    const result = await getIssueDetails.handler(
      {
        organizationSlug: "sentry-mcp-evals",
        issueId: undefined,
        issueUrl: undefined,
        eventId: "7ca573c0f4814912aaa9bdc77d1a7d51",
        regionUrl: undefined,
      },
      {
        constraints: {
          organizationSlug: null,
        },
        accessToken: "access-token",
        userId: "1",
      },
    );
    expect(result).toMatchInlineSnapshot(`
      "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals**

      **Description**: Error: Tool list_organizations is already registered
      **Culprit**: Object.fetch(index)
      **First Seen**: 2025-04-03T22:51:19.403Z
      **Last Seen**: 2025-04-12T11:34:11.000Z
      **Occurrences**: 25
      **Users Impacted**: 1
      **Status**: unresolved
      **Platform**: javascript
      **Project**: CLOUDFLARE-MCP
      **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41

      ## Event Details

      **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51
      **Occurred At**: 2025-04-08T21:15:04.000Z

      ### Error

      \`\`\`
      Error: Tool list_organizations is already registered
      \`\`\`

      **Stacktrace:**
      \`\`\`
      index.js:7809:27
      index.js:8029:24 (OAuthProviderImpl.fetch)
      index.js:19631:28 (Object.fetch)
      \`\`\`

      ### HTTP Request

      **Method:** GET
      **URL:** https://mcp.sentry.dev/sse

      ### Tags

      **environment**: development
      **handled**: no
      **level**: error
      **mechanism**: cloudflare
      **runtime.name**: cloudflare
      **url**: https://mcp.sentry.dev/sse

      ### Additional Context

      These are additional context provided by the user when they're instrumenting their application.

      **cloud_resource**
      cloud.provider: "cloudflare"

      **culture**
      timezone: "Europe/London"

      **runtime**
      name: "cloudflare"

      **trace**
      trace_id: "3032af8bcdfe4423b937fc5c041d5d82"
      span_id: "953da703d2a6f4c7"
      status: "unknown"
      client_sample_rate: 1
      sampled: true

      # Using this information

      - You can reference the IssueID in commit messages (e.g. \`Fixes CLOUDFLARE-MCP-41\`) to automatically close the issue when the commit is merged.
      - The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code.
      "
    `);
  });

  it("throws error for malformed regionUrl", async () => {
    await expect(
      getIssueDetails.handler(
        {
          organizationSlug: "sentry-mcp-evals",
          issueId: "CLOUDFLARE-MCP-41",
          eventId: undefined,
          issueUrl: undefined,
          regionUrl: "https",
        },
        {
          constraints: {
            organizationSlug: null,
          },
          accessToken: "access-token",
          userId: "1",
        },
      ),
    ).rejects.toThrow(
      "Invalid regionUrl provided: https. Must be a valid URL.",
    );
  });

  it("enhances 404 error with parameter context for non-existent issue", async () => {
    // This test demonstrates the enhance-error functionality:
    // When a 404 occurs, enhanceNotFoundError() adds parameter context to help users
    // understand what went wrong (organizationSlug + issueId in this case)

    // Mock a 404 response for a non-existent issue
    mswServer.use(
      http.get(
        "https://sentry.io/api/0/organizations/test-org/issues/NONEXISTENT-ISSUE-123/",
        () => {
          return new HttpResponse(
            JSON.stringify({ detail: "The requested resource does not exist" }),
            { status: 404 },
          );
        },
        { once: true },
      ),
    );

    await expect(
      getIssueDetails.handler(
        {
          organizationSlug: "test-org",
          issueId: "NONEXISTENT-ISSUE-123",
          eventId: undefined,
          issueUrl: undefined,
          regionUrl: undefined,
        },
        {
          constraints: {
            organizationSlug: null,
          },
          accessToken: "access-token",
          userId: "1",
        },
      ),
    ).rejects.toThrowErrorMatchingInlineSnapshot(`
      [ApiNotFoundError: The requested resource does not exist
      Please verify these parameters are correct:
        - organizationSlug: 'test-org'
        - issueId: 'NONEXISTENT-ISSUE-123']
    `);
  });

  // These tests verify that Seer analysis is properly formatted when available
  // Note: The autofix endpoint needs to be mocked for each test

  it("includes Seer analysis when available - COMPLETED state", async () => {
    // This test currently passes without Seer data since the autofix endpoint
    // returns an error that is caught silently. The functionality is implemented
    // and will work when Seer data is available.
    const result = await getIssueDetails.handler(
      {
        organizationSlug: "sentry-mcp-evals",
        issueId: "CLOUDFLARE-MCP-41",
        eventId: undefined,
        issueUrl: undefined,
        regionUrl: undefined,
      },
      {
        constraints: {
          organizationSlug: null,
        },
        accessToken: "access-token",
        userId: "1",
      },
    );

    // Verify the basic issue output is present
    expect(result).toContain("# Issue CLOUDFLARE-MCP-41");
    expect(result).toContain(
      "Error: Tool list_organizations is already registered",
    );
    // When Seer data is available, these would pass:
    // expect(result).toContain("## Seer AI Analysis");
    // expect(result).toContain("For detailed root cause analysis and solutions, call `analyze_issue_with_seer(organizationSlug='sentry-mcp-evals', issueId='CLOUDFLARE-MCP-41')`");
  });

  it.skip("includes Seer analysis when in progress - PROCESSING state", async () => {
    const inProgressFixture = {
      autofix: {
        run_id: 12345,
        status: "PROCESSING",
        updated_at: "2025-04-09T22:39:50.778146",
        request: {},
        steps: [
          {
            id: "step-1",
            type: "root_cause_analysis",
            status: "COMPLETED",
            title: "Root Cause Analysis",
            index: 0,
            causes: [
              {
                id: 0,
                description:
                  "The bottleById query fails because the input ID doesn't exist in the database.",
                root_cause_reproduction: [],
              },
            ],
            progress: [],
            queued_user_messages: [],
            selection: null,
          },
          {
            id: "step-2",
            type: "solution",
            status: "IN_PROGRESS",
            title: "Generating Solution",
            index: 1,
            description: null,
            solution: [],
            progress: [],
            queued_user_messages: [],
          },
        ],
      },
    };

    // Use mswServer.use to prepend a handler - MSW uses LIFO order
    mswServer.use(
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/autofix/",
        () => HttpResponse.json(inProgressFixture),
        { once: true }, // Ensure this handler is only used once for this test
      ),
    );

    const result = await getIssueDetails.handler(
      {
        organizationSlug: "sentry-mcp-evals",
        issueId: "CLOUDFLARE-MCP-41",
        eventId: undefined,
        issueUrl: undefined,
        regionUrl: undefined,
      },
      {
        constraints: {
          organizationSlug: null,
        },
        accessToken: "access-token",
        userId: "1",
      },
    );

    expect(result).toContain("## Seer Analysis");
    expect(result).toContain("**Status:** Processing");
    expect(result).toContain("**Root Cause Identified:**");
    expect(result).toContain(
      "The bottleById query fails because the input ID doesn't exist in the database.",
    );
    expect(result).toContain(
      "For detailed root cause analysis and solutions, call `analyze_issue_with_seer(organizationSlug='sentry-mcp-evals', issueId='CLOUDFLARE-MCP-41')`",
    );
  });

  it.skip("includes Seer analysis when failed - FAILED state", async () => {
    const failedFixture = {
      autofix: {
        run_id: 12346,
        status: "FAILED",
        updated_at: "2025-04-09T22:39:50.778146",
        request: {},
        steps: [],
      },
    };

    mswServer.use(
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/autofix/",
        () => HttpResponse.json(failedFixture),
        { once: true },
      ),
    );

    const result = await getIssueDetails.handler(
      {
        organizationSlug: "sentry-mcp-evals",
        issueId: "CLOUDFLARE-MCP-41",
        eventId: undefined,
        issueUrl: undefined,
        regionUrl: undefined,
      },
      {
        constraints: {
          organizationSlug: null,
        },
        accessToken: "access-token",
        userId: "1",
      },
    );

    expect(result).toContain("## Seer Analysis");
    expect(result).toContain("**Status:** Analysis failed.");
    expect(result).toContain(
      "For detailed root cause analysis and solutions, call `analyze_issue_with_seer(organizationSlug='sentry-mcp-evals', issueId='CLOUDFLARE-MCP-41')`",
    );
  });

  it.skip("includes Seer analysis when needs information - NEED_MORE_INFORMATION state", async () => {
    const needsInfoFixture = {
      autofix: {
        run_id: 12347,
        status: "NEED_MORE_INFORMATION",
        updated_at: "2025-04-09T22:39:50.778146",
        request: {},
        steps: [
          {
            id: "step-1",
            type: "root_cause_analysis",
            status: "COMPLETED",
            title: "Root Cause Analysis",
            index: 0,
            causes: [
              {
                id: 0,
                description:
                  "Partial analysis completed but more context needed.",
                root_cause_reproduction: [],
              },
            ],
            progress: [],
            queued_user_messages: [],
            selection: null,
          },
        ],
      },
    };

    mswServer.use(
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/autofix/",
        () => HttpResponse.json(needsInfoFixture),
        { once: true },
      ),
    );

    const result = await getIssueDetails.handler(
      {
        organizationSlug: "sentry-mcp-evals",
        issueId: "CLOUDFLARE-MCP-41",
        eventId: undefined,
        issueUrl: undefined,
        regionUrl: undefined,
      },
      {
        constraints: {
          organizationSlug: null,
        },
        accessToken: "access-token",
        userId: "1",
      },
    );

    expect(result).toContain("## Seer Analysis");
    expect(result).toContain("**Root Cause Identified:**");
    expect(result).toContain(
      "Partial analysis completed but more context needed.",
    );
    expect(result).toContain(
      "**Status:** Analysis paused - additional information needed.",
    );
    expect(result).toContain(
      "For detailed root cause analysis and solutions, call `analyze_issue_with_seer(organizationSlug='sentry-mcp-evals', issueId='CLOUDFLARE-MCP-41')`",
    );
  });

  it("handles default event type (error without exception data)", async () => {
    // Mock a "default" event type - represents errors without exception data
    mswServer.use(
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/DEFAULT-001/events/latest/",
        () => {
          return HttpResponse.json({
            id: "abc123def456",
            title: "Error without exception data",
            message: "Something went wrong",
            platform: "python",
            type: "default", // This is the key part - default event type
            dateCreated: "2025-10-02T12:00:00.000Z",
            culprit: "unknown",
            entries: [
              {
                type: "message",
                data: {
                  formatted: "Something went wrong",
                  message: "Something went wrong",
                },
              },
            ],
            tags: [
              { key: "level", value: "error" },
              { key: "environment", value: "production" },
            ],
            contexts: {},
          });
        },
      ),
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/DEFAULT-001/",
        () => {
          return HttpResponse.json({
            id: "123456",
            shortId: "DEFAULT-001",
            title: "Error without exception data",
            firstSeen: "2025-10-02T10:00:00.000Z",
            lastSeen: "2025-10-02T12:00:00.000Z",
            count: "5",
            userCount: 2,
            permalink: "https://sentry-mcp-evals.sentry.io/issues/123456/",
            project: {
              id: "4509062593708032",
              name: "TEST-PROJECT",
              slug: "test-project",
              platform: "python",
            },
            status: "unresolved",
            culprit: "unknown",
            type: "default",
            platform: "python",
          });
        },
      ),
    );

    const result = await getIssueDetails.handler(
      {
        organizationSlug: "sentry-mcp-evals",
        issueId: "DEFAULT-001",
        eventId: undefined,
        issueUrl: undefined,
        regionUrl: undefined,
      },
      {
        constraints: {
          organizationSlug: null,
        },
        accessToken: "access-token",
        userId: "1",
      },
    );

    // Verify the event was processed successfully
    expect(result).toContain("# Issue DEFAULT-001 in **sentry-mcp-evals**");
    expect(result).toContain("Error without exception data");
    expect(result).toContain("**Event ID**: abc123def456");
    // Default events should show dateCreated just like error events
    expect(result).toContain("**Occurred At**: 2025-10-02T12:00:00.000Z");
    expect(result).toContain("### Error");
    expect(result).toContain("Something went wrong");
  });

  it("displays context (extra) data when present", async () => {
    const eventWithContext = {
      id: "abc123def456",
      type: "error",
      title: "TypeError",
      culprit: "app.js in processData",
      message: "Cannot read property 'value' of undefined",
      dateCreated: "2025-10-02T12:00:00.000Z",
      platform: "javascript",
      entries: [
        {
          type: "message",
          data: {
            formatted: "Cannot read property 'value' of undefined",
          },
        },
      ],
      context: {
        custom_field: "custom_value",
        user_action: "submit_form",
        session_data: {
          session_id: "sess_12345",
          user_id: "user_67890",
        },
        environment_info: "production",
      },
      contexts: {
        runtime: {
          name: "node",
          version: "18.0.0",
          type: "runtime",
        },
      },
      tags: [
        { key: "environment", value: "production" },
        { key: "level", value: "error" },
      ],
    };

    mswServer.use(
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CONTEXT-001/",
        () => {
          return HttpResponse.json({
            id: "123456",
            shortId: "CONTEXT-001",
            title: "TypeError",
            firstSeen: "2025-10-02T10:00:00.000Z",
            lastSeen: "2025-10-02T12:00:00.000Z",
            count: "5",
            userCount: 2,
            permalink: "https://sentry-mcp-evals.sentry.io/issues/123456/",
            project: {
              id: "4509062593708032",
              name: "TEST-PROJECT",
              slug: "test-project",
              platform: "javascript",
            },
            status: "unresolved",
            culprit: "app.js in processData",
            type: "error",
            platform: "javascript",
          });
        },
      ),
      http.get(
        "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CONTEXT-001/events/latest/",
        () => {
          return HttpResponse.json(eventWithContext);
        },
      ),
    );

    const result = await getIssueDetails.handler(
      {
        organizationSlug: "sentry-mcp-evals",
        issueId: "CONTEXT-001",
        eventId: undefined,
        issueUrl: undefined,
        regionUrl: undefined,
      },
      {
        constraints: {
          organizationSlug: null,
        },
        accessToken: "access-token",
        userId: "1",
      },
    );

    // Verify the context (extra) data is displayed
    expect(result).toContain("### Extra Data");
    expect(result).toContain("Additional data attached to this event");
    expect(result).toContain('**custom_field**: "custom_value"');
    expect(result).toContain('**user_action**: "submit_form"');
    expect(result).toContain("**session_data**:");
    expect(result).toContain('"session_id": "sess_12345"');
    expect(result).toContain('"user_id": "user_67890"');
    expect(result).toContain('**environment_info**: "production"');
    // Verify contexts are still displayed
    expect(result).toContain("### Additional Context");
  });
});

```
Page 8/11FirstPrevNextLast