This is page 11 of 15. Use http://codebase.md/getsentry/sentry-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .claude
│ ├── agents
│ │ └── claude-optimizer.md
│ ├── commands
│ │ ├── gh-pr.md
│ │ └── gh-review.md
│ └── settings.json
├── .craft.yml
├── .cursor
│ ├── mcp.json
│ └── rules
├── .env.example
├── .github
│ └── workflows
│ ├── deploy.yml
│ ├── eval.yml
│ ├── merge-jobs.yml
│ ├── release.yml
│ ├── smoke-tests.yml
│ └── test.yml
├── .gitignore
├── .mcp.json
├── .vscode
│ ├── extensions.json
│ ├── mcp.json
│ └── settings.json
├── AGENTS.md
├── bin
│ └── bump-version.sh
├── biome.json
├── CLAUDE.md
├── codecov.yml
├── core
├── docs
│ ├── adding-tools.mdc
│ ├── api-patterns.mdc
│ ├── architecture.mdc
│ ├── cloudflare
│ │ ├── architecture.md
│ │ ├── constraint-do-analysis.md
│ │ ├── deployment.md
│ │ ├── mcpagent-architecture.md
│ │ ├── oauth-architecture.md
│ │ └── overview.md
│ ├── coding-guidelines.mdc
│ ├── common-patterns.mdc
│ ├── cursor.mdc
│ ├── deployment.mdc
│ ├── error-handling.mdc
│ ├── github-actions.mdc
│ ├── llms
│ │ ├── document-scopes.mdc
│ │ ├── documentation-style-guide.mdc
│ │ └── README.md
│ ├── logging.mdc
│ ├── monitoring.mdc
│ ├── permissions-and-scopes.md
│ ├── pr-management.mdc
│ ├── quality-checks.mdc
│ ├── README.md
│ ├── search-events-api-patterns.md
│ ├── security.mdc
│ ├── specs
│ │ ├── README.md
│ │ ├── search-events.md
│ │ └── subpath-constraints.md
│ └── testing.mdc
├── LICENSE.md
├── Makefile
├── package.json
├── packages
│ ├── mcp-cloudflare
│ │ ├── .env.example
│ │ ├── components.json
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── favicon.ico
│ │ │ ├── flow-transparent.png
│ │ │ └── flow.jpg
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── app.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── chat
│ │ │ │ │ │ ├── auth-form.tsx
│ │ │ │ │ │ ├── chat-input.tsx
│ │ │ │ │ │ ├── chat-message.tsx
│ │ │ │ │ │ ├── chat-messages.tsx
│ │ │ │ │ │ ├── chat-ui.tsx
│ │ │ │ │ │ ├── chat.tsx
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── tool-invocation.tsx
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── fragments
│ │ │ │ │ │ ├── remote-setup.tsx
│ │ │ │ │ │ ├── setup-guide.tsx
│ │ │ │ │ │ └── stdio-setup.tsx
│ │ │ │ │ └── ui
│ │ │ │ │ ├── accordion.tsx
│ │ │ │ │ ├── backdrop.tsx
│ │ │ │ │ ├── base.tsx
│ │ │ │ │ ├── button.tsx
│ │ │ │ │ ├── code-snippet.tsx
│ │ │ │ │ ├── header.tsx
│ │ │ │ │ ├── icon.tsx
│ │ │ │ │ ├── icons
│ │ │ │ │ │ └── sentry.tsx
│ │ │ │ │ ├── interactive-markdown.tsx
│ │ │ │ │ ├── json-schema-params.tsx
│ │ │ │ │ ├── markdown.tsx
│ │ │ │ │ ├── note.tsx
│ │ │ │ │ ├── prose.tsx
│ │ │ │ │ ├── section.tsx
│ │ │ │ │ ├── slash-command-actions.tsx
│ │ │ │ │ ├── slash-command-text.tsx
│ │ │ │ │ ├── sliding-panel.tsx
│ │ │ │ │ ├── template-vars.tsx
│ │ │ │ │ ├── tool-actions.tsx
│ │ │ │ │ └── typewriter.tsx
│ │ │ │ ├── contexts
│ │ │ │ │ └── auth-context.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ ├── use-mcp-metadata.ts
│ │ │ │ │ ├── use-persisted-chat.ts
│ │ │ │ │ ├── use-scroll-lock.ts
│ │ │ │ │ └── use-streaming-simulation.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── instrument.ts
│ │ │ │ ├── lib
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── main.tsx
│ │ │ │ ├── pages
│ │ │ │ │ └── home.tsx
│ │ │ │ ├── utils
│ │ │ │ │ ├── chat-error-handler.ts
│ │ │ │ │ └── index.ts
│ │ │ │ └── vite-env.d.ts
│ │ │ ├── constants.ts
│ │ │ ├── server
│ │ │ │ ├── app.test.ts
│ │ │ │ ├── app.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── approval-dialog.test.ts
│ │ │ │ │ ├── approval-dialog.ts
│ │ │ │ │ ├── constraint-utils.test.ts
│ │ │ │ │ ├── constraint-utils.ts
│ │ │ │ │ ├── html-utils.ts
│ │ │ │ │ ├── mcp-agent.ts
│ │ │ │ │ ├── slug-validation.test.ts
│ │ │ │ │ └── slug-validation.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── oauth
│ │ │ │ │ ├── authorize.test.ts
│ │ │ │ │ ├── callback.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── helpers.test.ts
│ │ │ │ │ ├── helpers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── authorize.ts
│ │ │ │ │ │ ├── callback.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ └── state.ts
│ │ │ │ ├── routes
│ │ │ │ │ ├── chat-oauth.ts
│ │ │ │ │ ├── chat.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── metadata.ts
│ │ │ │ │ ├── search.test.ts
│ │ │ │ │ └── search.ts
│ │ │ │ ├── sentry.config.ts
│ │ │ │ ├── types
│ │ │ │ │ └── chat.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils
│ │ │ │ └── auth-errors.ts
│ │ │ └── test-setup.ts
│ │ ├── tsconfig.client.json
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ ├── tsconfig.server.json
│ │ ├── vite.config.ts
│ │ ├── vitest.config.ts
│ │ ├── worker-configuration.d.ts
│ │ ├── wrangler.canary.jsonc
│ │ └── wrangler.jsonc
│ ├── mcp-server
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── scripts
│ │ │ ├── generate-definitions.ts
│ │ │ └── generate-otel-namespaces.ts
│ │ ├── src
│ │ │ ├── api-client
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── client.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── types.ts
│ │ │ ├── cli
│ │ │ │ ├── parse.test.ts
│ │ │ │ ├── parse.ts
│ │ │ │ ├── resolve.test.ts
│ │ │ │ ├── resolve.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── usage.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── index.ts
│ │ │ ├── internal
│ │ │ │ ├── agents
│ │ │ │ │ ├── callEmbeddedAgent.ts
│ │ │ │ │ ├── openai-provider.ts
│ │ │ │ │ └── tools
│ │ │ │ │ ├── data
│ │ │ │ │ │ ├── __namespaces.json
│ │ │ │ │ │ ├── android.json
│ │ │ │ │ │ ├── app.json
│ │ │ │ │ │ ├── artifact.json
│ │ │ │ │ │ ├── aspnetcore.json
│ │ │ │ │ │ ├── aws.json
│ │ │ │ │ │ ├── azure.json
│ │ │ │ │ │ ├── browser.json
│ │ │ │ │ │ ├── cassandra.json
│ │ │ │ │ │ ├── cicd.json
│ │ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ │ ├── client.json
│ │ │ │ │ │ ├── cloud.json
│ │ │ │ │ │ ├── cloudevents.json
│ │ │ │ │ │ ├── cloudfoundry.json
│ │ │ │ │ │ ├── code.json
│ │ │ │ │ │ ├── container.json
│ │ │ │ │ │ ├── cpu.json
│ │ │ │ │ │ ├── cpython.json
│ │ │ │ │ │ ├── database.json
│ │ │ │ │ │ ├── db.json
│ │ │ │ │ │ ├── deployment.json
│ │ │ │ │ │ ├── destination.json
│ │ │ │ │ │ ├── device.json
│ │ │ │ │ │ ├── disk.json
│ │ │ │ │ │ ├── dns.json
│ │ │ │ │ │ ├── dotnet.json
│ │ │ │ │ │ ├── elasticsearch.json
│ │ │ │ │ │ ├── enduser.json
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ ├── faas.json
│ │ │ │ │ │ ├── feature_flags.json
│ │ │ │ │ │ ├── file.json
│ │ │ │ │ │ ├── gcp.json
│ │ │ │ │ │ ├── gen_ai.json
│ │ │ │ │ │ ├── geo.json
│ │ │ │ │ │ ├── go.json
│ │ │ │ │ │ ├── graphql.json
│ │ │ │ │ │ ├── hardware.json
│ │ │ │ │ │ ├── heroku.json
│ │ │ │ │ │ ├── host.json
│ │ │ │ │ │ ├── http.json
│ │ │ │ │ │ ├── ios.json
│ │ │ │ │ │ ├── jvm.json
│ │ │ │ │ │ ├── k8s.json
│ │ │ │ │ │ ├── linux.json
│ │ │ │ │ │ ├── log.json
│ │ │ │ │ │ ├── mcp.json
│ │ │ │ │ │ ├── messaging.json
│ │ │ │ │ │ ├── network.json
│ │ │ │ │ │ ├── nodejs.json
│ │ │ │ │ │ ├── oci.json
│ │ │ │ │ │ ├── opentracing.json
│ │ │ │ │ │ ├── os.json
│ │ │ │ │ │ ├── otel.json
│ │ │ │ │ │ ├── peer.json
│ │ │ │ │ │ ├── process.json
│ │ │ │ │ │ ├── profile.json
│ │ │ │ │ │ ├── rpc.json
│ │ │ │ │ │ ├── server.json
│ │ │ │ │ │ ├── service.json
│ │ │ │ │ │ ├── session.json
│ │ │ │ │ │ ├── signalr.json
│ │ │ │ │ │ ├── source.json
│ │ │ │ │ │ ├── system.json
│ │ │ │ │ │ ├── telemetry.json
│ │ │ │ │ │ ├── test.json
│ │ │ │ │ │ ├── thread.json
│ │ │ │ │ │ ├── tls.json
│ │ │ │ │ │ ├── url.json
│ │ │ │ │ │ ├── user.json
│ │ │ │ │ │ ├── v8js.json
│ │ │ │ │ │ ├── vcs.json
│ │ │ │ │ │ ├── webengine.json
│ │ │ │ │ │ └── zos.json
│ │ │ │ │ ├── dataset-fields.test.ts
│ │ │ │ │ ├── dataset-fields.ts
│ │ │ │ │ ├── otel-semantics.test.ts
│ │ │ │ │ ├── otel-semantics.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ ├── whoami.test.ts
│ │ │ │ │ └── whoami.ts
│ │ │ │ ├── constraint-helpers.test.ts
│ │ │ │ ├── constraint-helpers.ts
│ │ │ │ ├── error-handling.ts
│ │ │ │ ├── fetch-utils.test.ts
│ │ │ │ ├── fetch-utils.ts
│ │ │ │ ├── formatting.test.ts
│ │ │ │ ├── formatting.ts
│ │ │ │ ├── issue-helpers.test.ts
│ │ │ │ ├── issue-helpers.ts
│ │ │ │ ├── test-fixtures.ts
│ │ │ │ └── tool-helpers
│ │ │ │ ├── api.test.ts
│ │ │ │ ├── api.ts
│ │ │ │ ├── define.ts
│ │ │ │ ├── enhance-error.ts
│ │ │ │ ├── formatting.ts
│ │ │ │ ├── issue.ts
│ │ │ │ ├── seer.test.ts
│ │ │ │ ├── seer.ts
│ │ │ │ ├── validate-region-url.test.ts
│ │ │ │ └── validate-region-url.ts
│ │ │ ├── permissions.parseScopes.test.ts
│ │ │ ├── permissions.ts
│ │ │ ├── schema.ts
│ │ │ ├── server.ts
│ │ │ ├── telem
│ │ │ │ ├── index.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── sentry.test.ts
│ │ │ │ └── sentry.ts
│ │ │ ├── test-setup.ts
│ │ │ ├── test-utils
│ │ │ │ └── context.ts
│ │ │ ├── toolDefinitions.ts
│ │ │ ├── tools
│ │ │ │ ├── analyze-issue-with-seer.test.ts
│ │ │ │ ├── analyze-issue-with-seer.ts
│ │ │ │ ├── create-dsn.test.ts
│ │ │ │ ├── create-dsn.ts
│ │ │ │ ├── create-project.test.ts
│ │ │ │ ├── create-project.ts
│ │ │ │ ├── create-team.test.ts
│ │ │ │ ├── create-team.ts
│ │ │ │ ├── find-dsns.test.ts
│ │ │ │ ├── find-dsns.ts
│ │ │ │ ├── find-organizations.test.ts
│ │ │ │ ├── find-organizations.ts
│ │ │ │ ├── find-projects.test.ts
│ │ │ │ ├── find-projects.ts
│ │ │ │ ├── find-releases.test.ts
│ │ │ │ ├── find-releases.ts
│ │ │ │ ├── find-teams.test.ts
│ │ │ │ ├── find-teams.ts
│ │ │ │ ├── get-doc.test.ts
│ │ │ │ ├── get-doc.ts
│ │ │ │ ├── get-event-attachment.test.ts
│ │ │ │ ├── get-event-attachment.ts
│ │ │ │ ├── get-issue-details.test.ts
│ │ │ │ ├── get-issue-details.ts
│ │ │ │ ├── get-trace-details.test.ts
│ │ │ │ ├── get-trace-details.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── search-docs.test.ts
│ │ │ │ ├── search-docs.ts
│ │ │ │ ├── search-events
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── utils.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── search-events.test.ts
│ │ │ │ ├── search-issues
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── README.md
│ │ │ │ ├── tools.test.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── update-issue.test.ts
│ │ │ │ ├── update-issue.ts
│ │ │ │ ├── update-project.test.ts
│ │ │ │ ├── update-project.ts
│ │ │ │ ├── whoami.test.ts
│ │ │ │ └── whoami.ts
│ │ │ ├── transports
│ │ │ │ └── stdio.ts
│ │ │ ├── types.ts
│ │ │ ├── utils
│ │ │ │ ├── slug-validation.test.ts
│ │ │ │ ├── slug-validation.ts
│ │ │ │ ├── url-utils.test.ts
│ │ │ │ └── url-utils.ts
│ │ │ └── version.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ ├── mcp-server-evals
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── bin
│ │ │ │ └── start-mock-stdio.ts
│ │ │ ├── evals
│ │ │ │ ├── autofix.eval.ts
│ │ │ │ ├── create-dsn.eval.ts
│ │ │ │ ├── create-project.eval.ts
│ │ │ │ ├── create-team.eval.ts
│ │ │ │ ├── get-issue.eval.ts
│ │ │ │ ├── get-trace-details.eval.ts
│ │ │ │ ├── list-dsns.eval.ts
│ │ │ │ ├── list-issues.eval.ts
│ │ │ │ ├── list-organizations.eval.ts
│ │ │ │ ├── list-projects.eval.ts
│ │ │ │ ├── list-releases.eval.ts
│ │ │ │ ├── list-tags.eval.ts
│ │ │ │ ├── list-teams.eval.ts
│ │ │ │ ├── search-docs.eval.ts
│ │ │ │ ├── search-events-agent.eval.ts
│ │ │ │ ├── search-events.eval.ts
│ │ │ │ ├── search-issues-agent.eval.ts
│ │ │ │ ├── search-issues.eval.ts
│ │ │ │ ├── update-issue.eval.ts
│ │ │ │ ├── update-project.eval.ts
│ │ │ │ └── utils
│ │ │ │ ├── fixtures.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── runner.ts
│ │ │ │ ├── structuredOutputScorer.ts
│ │ │ │ └── toolPredictionScorer.ts
│ │ │ └── setup-env.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── mcp-server-mocks
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── fixtures
│ │ │ │ ├── autofix-state.json
│ │ │ │ ├── event-attachments.json
│ │ │ │ ├── event.json
│ │ │ │ ├── issue.json
│ │ │ │ ├── performance-event.json
│ │ │ │ ├── project.json
│ │ │ │ ├── tags.json
│ │ │ │ ├── team.json
│ │ │ │ ├── trace-event.json
│ │ │ │ ├── trace-items-attributes-logs-number.json
│ │ │ │ ├── trace-items-attributes-logs-string.json
│ │ │ │ ├── trace-items-attributes-spans-number.json
│ │ │ │ ├── trace-items-attributes-spans-string.json
│ │ │ │ ├── trace-items-attributes.json
│ │ │ │ ├── trace-meta-with-nulls.json
│ │ │ │ ├── trace-meta.json
│ │ │ │ ├── trace-mixed.json
│ │ │ │ └── trace.json
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── mcp-server-tsconfig
│ │ ├── package.json
│ │ ├── tsconfig.base.json
│ │ └── tsconfig.vite.json
│ ├── mcp-test-client
│ │ ├── .env.test
│ │ ├── .gitignore
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── agent.ts
│ │ │ ├── auth
│ │ │ │ ├── config.ts
│ │ │ │ └── oauth.ts
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.test.ts
│ │ │ ├── logger.ts
│ │ │ ├── mcp-test-client-remote.ts
│ │ │ ├── mcp-test-client.ts
│ │ │ ├── types.ts
│ │ │ └── version.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ └── smoke-tests
│ ├── package.json
│ ├── src
│ │ └── smoke.test.ts
│ └── vitest.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── scripts
│ └── check-doc-links.mjs
├── turbo.json
└── vitest.workspace.ts
```
# Files
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/k8s.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "namespace": "k8s",
3 | "description": "Kubernetes resource attributes.\n",
4 | "attributes": {
5 | "k8s.cluster.name": {
6 | "description": "The name of the cluster.\n",
7 | "type": "string",
8 | "stability": "development",
9 | "examples": ["opentelemetry-cluster"]
10 | },
11 | "k8s.cluster.uid": {
12 | "description": "A pseudo-ID for the cluster, set to the UID of the `kube-system` namespace.\n",
13 | "type": "string",
14 | "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",
15 | "stability": "development",
16 | "examples": ["218fc5a9-a5f1-4b54-aa05-46717d0ab26d"]
17 | },
18 | "k8s.node.name": {
19 | "description": "The name of the Node.\n",
20 | "type": "string",
21 | "stability": "development",
22 | "examples": ["node-1"]
23 | },
24 | "k8s.node.uid": {
25 | "description": "The UID of the Node.\n",
26 | "type": "string",
27 | "stability": "development",
28 | "examples": ["1eb3a0c6-0477-4080-a9cb-0cb7db65c6a2"]
29 | },
30 | "k8s.node.label": {
31 | "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",
32 | "type": "string",
33 | "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",
34 | "stability": "development",
35 | "examples": ["arm64", ""]
36 | },
37 | "k8s.node.annotation": {
38 | "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",
39 | "type": "string",
40 | "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",
41 | "stability": "development",
42 | "examples": ["0", ""]
43 | },
44 | "k8s.namespace.name": {
45 | "description": "The name of the namespace that the pod is running in.\n",
46 | "type": "string",
47 | "stability": "development",
48 | "examples": ["default"]
49 | },
50 | "k8s.namespace.label": {
51 | "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",
52 | "type": "string",
53 | "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",
54 | "stability": "development",
55 | "examples": ["default", ""]
56 | },
57 | "k8s.namespace.annotation": {
58 | "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",
59 | "type": "string",
60 | "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",
61 | "stability": "development",
62 | "examples": ["0", ""]
63 | },
64 | "k8s.pod.uid": {
65 | "description": "The UID of the Pod.\n",
66 | "type": "string",
67 | "stability": "development",
68 | "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
69 | },
70 | "k8s.pod.name": {
71 | "description": "The name of the Pod.\n",
72 | "type": "string",
73 | "stability": "development",
74 | "examples": ["opentelemetry-pod-autoconf"]
75 | },
76 | "k8s.pod.label": {
77 | "description": "The label placed on the Pod, the `<key>` being the label name, the value being the label value.\n",
78 | "type": "string",
79 | "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",
80 | "stability": "development",
81 | "examples": ["my-app", "x64", ""]
82 | },
83 | "k8s.pod.annotation": {
84 | "description": "The annotation placed on the Pod, the `<key>` being the annotation name, the value being the annotation value.\n",
85 | "type": "string",
86 | "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",
87 | "stability": "development",
88 | "examples": ["true", "x64", ""]
89 | },
90 | "k8s.container.name": {
91 | "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",
92 | "type": "string",
93 | "stability": "development",
94 | "examples": ["redis"]
95 | },
96 | "k8s.container.restart_count": {
97 | "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",
98 | "type": "number",
99 | "stability": "development"
100 | },
101 | "k8s.container.status.last_terminated_reason": {
102 | "description": "Last terminated reason of the Container.\n",
103 | "type": "string",
104 | "stability": "development",
105 | "examples": ["Evicted", "Error"]
106 | },
107 | "k8s.replicaset.uid": {
108 | "description": "The UID of the ReplicaSet.\n",
109 | "type": "string",
110 | "stability": "development",
111 | "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
112 | },
113 | "k8s.replicaset.name": {
114 | "description": "The name of the ReplicaSet.\n",
115 | "type": "string",
116 | "stability": "development",
117 | "examples": ["opentelemetry"]
118 | },
119 | "k8s.replicaset.label": {
120 | "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",
121 | "type": "string",
122 | "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",
123 | "stability": "development",
124 | "examples": ["guestbook", ""]
125 | },
126 | "k8s.replicaset.annotation": {
127 | "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",
128 | "type": "string",
129 | "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",
130 | "stability": "development",
131 | "examples": ["0", ""]
132 | },
133 | "k8s.replicationcontroller.uid": {
134 | "description": "The UID of the replication controller.\n",
135 | "type": "string",
136 | "stability": "development",
137 | "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
138 | },
139 | "k8s.replicationcontroller.name": {
140 | "description": "The name of the replication controller.\n",
141 | "type": "string",
142 | "stability": "development",
143 | "examples": ["opentelemetry"]
144 | },
145 | "k8s.resourcequota.uid": {
146 | "description": "The UID of the resource quota.\n",
147 | "type": "string",
148 | "stability": "development",
149 | "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
150 | },
151 | "k8s.resourcequota.name": {
152 | "description": "The name of the resource quota.\n",
153 | "type": "string",
154 | "stability": "development",
155 | "examples": ["opentelemetry"]
156 | },
157 | "k8s.deployment.uid": {
158 | "description": "The UID of the Deployment.\n",
159 | "type": "string",
160 | "stability": "development",
161 | "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
162 | },
163 | "k8s.deployment.name": {
164 | "description": "The name of the Deployment.\n",
165 | "type": "string",
166 | "stability": "development",
167 | "examples": ["opentelemetry"]
168 | },
169 | "k8s.deployment.label": {
170 | "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",
171 | "type": "string",
172 | "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",
173 | "stability": "development",
174 | "examples": ["guestbook", ""]
175 | },
176 | "k8s.deployment.annotation": {
177 | "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",
178 | "type": "string",
179 | "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",
180 | "stability": "development",
181 | "examples": ["1", ""]
182 | },
183 | "k8s.statefulset.uid": {
184 | "description": "The UID of the StatefulSet.\n",
185 | "type": "string",
186 | "stability": "development",
187 | "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
188 | },
189 | "k8s.statefulset.name": {
190 | "description": "The name of the StatefulSet.\n",
191 | "type": "string",
192 | "stability": "development",
193 | "examples": ["opentelemetry"]
194 | },
195 | "k8s.statefulset.label": {
196 | "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",
197 | "type": "string",
198 | "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",
199 | "stability": "development",
200 | "examples": ["guestbook", ""]
201 | },
202 | "k8s.statefulset.annotation": {
203 | "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",
204 | "type": "string",
205 | "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",
206 | "stability": "development",
207 | "examples": ["1", ""]
208 | },
209 | "k8s.daemonset.uid": {
210 | "description": "The UID of the DaemonSet.\n",
211 | "type": "string",
212 | "stability": "development",
213 | "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
214 | },
215 | "k8s.daemonset.name": {
216 | "description": "The name of the DaemonSet.\n",
217 | "type": "string",
218 | "stability": "development",
219 | "examples": ["opentelemetry"]
220 | },
221 | "k8s.daemonset.label": {
222 | "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",
223 | "type": "string",
224 | "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",
225 | "stability": "development",
226 | "examples": ["guestbook", ""]
227 | },
228 | "k8s.daemonset.annotation": {
229 | "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",
230 | "type": "string",
231 | "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",
232 | "stability": "development",
233 | "examples": ["1", ""]
234 | },
235 | "k8s.hpa.uid": {
236 | "description": "The UID of the horizontal pod autoscaler.\n",
237 | "type": "string",
238 | "stability": "development",
239 | "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
240 | },
241 | "k8s.hpa.name": {
242 | "description": "The name of the horizontal pod autoscaler.\n",
243 | "type": "string",
244 | "stability": "development",
245 | "examples": ["opentelemetry"]
246 | },
247 | "k8s.hpa.scaletargetref.kind": {
248 | "description": "The kind of the target resource to scale for the HorizontalPodAutoscaler.\n",
249 | "type": "string",
250 | "note": "This maps to the `kind` field in the `scaleTargetRef` of the HPA spec.\n",
251 | "stability": "development",
252 | "examples": ["Deployment", "StatefulSet"]
253 | },
254 | "k8s.hpa.scaletargetref.name": {
255 | "description": "The name of the target resource to scale for the HorizontalPodAutoscaler.\n",
256 | "type": "string",
257 | "note": "This maps to the `name` field in the `scaleTargetRef` of the HPA spec.\n",
258 | "stability": "development",
259 | "examples": ["my-deployment", "my-statefulset"]
260 | },
261 | "k8s.hpa.scaletargetref.api_version": {
262 | "description": "The API version of the target resource to scale for the HorizontalPodAutoscaler.\n",
263 | "type": "string",
264 | "note": "This maps to the `apiVersion` field in the `scaleTargetRef` of the HPA spec.\n",
265 | "stability": "development",
266 | "examples": ["apps/v1", "autoscaling/v2"]
267 | },
268 | "k8s.hpa.metric.type": {
269 | "description": "The type of metric source for the horizontal pod autoscaler.\n",
270 | "type": "string",
271 | "note": "This attribute reflects the `type` field of spec.metrics[] in the HPA.\n",
272 | "stability": "development",
273 | "examples": ["Resource", "ContainerResource"]
274 | },
275 | "k8s.job.uid": {
276 | "description": "The UID of the Job.\n",
277 | "type": "string",
278 | "stability": "development",
279 | "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
280 | },
281 | "k8s.job.name": {
282 | "description": "The name of the Job.\n",
283 | "type": "string",
284 | "stability": "development",
285 | "examples": ["opentelemetry"]
286 | },
287 | "k8s.job.label": {
288 | "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",
289 | "type": "string",
290 | "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",
291 | "stability": "development",
292 | "examples": ["ci", ""]
293 | },
294 | "k8s.job.annotation": {
295 | "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",
296 | "type": "string",
297 | "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",
298 | "stability": "development",
299 | "examples": ["1", ""]
300 | },
301 | "k8s.cronjob.uid": {
302 | "description": "The UID of the CronJob.\n",
303 | "type": "string",
304 | "stability": "development",
305 | "examples": ["275ecb36-5aa8-4c2a-9c47-d8bb681b9aff"]
306 | },
307 | "k8s.cronjob.name": {
308 | "description": "The name of the CronJob.\n",
309 | "type": "string",
310 | "stability": "development",
311 | "examples": ["opentelemetry"]
312 | },
313 | "k8s.cronjob.label": {
314 | "description": "The label placed on the CronJob, the `<key>` being the label name, the value being the label value.\n",
315 | "type": "string",
316 | "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",
317 | "stability": "development",
318 | "examples": ["weekly", ""]
319 | },
320 | "k8s.cronjob.annotation": {
321 | "description": "The cronjob annotation placed on the CronJob, the `<key>` being the annotation name, the value being the annotation value.\n",
322 | "type": "string",
323 | "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",
324 | "stability": "development",
325 | "examples": ["4", ""]
326 | },
327 | "k8s.volume.name": {
328 | "description": "The name of the K8s volume.\n",
329 | "type": "string",
330 | "stability": "development",
331 | "examples": ["volume0"]
332 | },
333 | "k8s.volume.type": {
334 | "description": "The type of the K8s volume.\n",
335 | "type": "string",
336 | "stability": "development",
337 | "examples": [
338 | "persistentVolumeClaim",
339 | "configMap",
340 | "downwardAPI",
341 | "emptyDir",
342 | "secret",
343 | "local"
344 | ]
345 | },
346 | "k8s.namespace.phase": {
347 | "description": "The phase of the K8s namespace.\n",
348 | "type": "string",
349 | "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",
350 | "stability": "development",
351 | "examples": ["active", "terminating"]
352 | },
353 | "k8s.node.condition.type": {
354 | "description": "The condition type of a K8s Node.\n",
355 | "type": "string",
356 | "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",
357 | "stability": "development",
358 | "examples": [
359 | "Ready",
360 | "DiskPressure",
361 | "MemoryPressure",
362 | "PIDPressure",
363 | "NetworkUnavailable"
364 | ]
365 | },
366 | "k8s.node.condition.status": {
367 | "description": "The status of the condition, one of True, False, Unknown.\n",
368 | "type": "string",
369 | "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",
370 | "stability": "development",
371 | "examples": ["true", "false", "unknown"]
372 | },
373 | "k8s.container.status.state": {
374 | "description": "The state of the container. [K8s ContainerState](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#containerstate-v1-core)\n",
375 | "type": "string",
376 | "stability": "experimental",
377 | "examples": ["terminated", "running", "waiting"]
378 | },
379 | "k8s.container.status.reason": {
380 | "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",
381 | "type": "string",
382 | "stability": "experimental",
383 | "examples": [
384 | "ContainerCreating",
385 | "CrashLoopBackOff",
386 | "CreateContainerConfigError",
387 | "ErrImagePull",
388 | "ImagePullBackOff",
389 | "OOMKilled",
390 | "Completed",
391 | "Error",
392 | "ContainerCannotRun"
393 | ]
394 | },
395 | "k8s.hugepage.size": {
396 | "description": "The size (identifier) of the K8s huge page.\n",
397 | "type": "string",
398 | "stability": "development",
399 | "examples": ["2Mi"]
400 | },
401 | "k8s.storageclass.name": {
402 | "description": "The name of K8s [StorageClass](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#storageclass-v1-storage-k8s-io) object.\n",
403 | "type": "string",
404 | "stability": "development",
405 | "examples": ["gold.storageclass.storage.k8s.io"]
406 | },
407 | "k8s.resourcequota.resource_name": {
408 | "description": "The name of the K8s resource a resource quota defines.\n",
409 | "type": "string",
410 | "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",
411 | "stability": "development",
412 | "examples": ["count/replicationcontrollers"]
413 | }
414 | }
415 | }
416 |
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/lib/approval-dialog.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type {
2 | AuthRequest,
3 | ClientInfo,
4 | } from "@cloudflare/workers-oauth-provider";
5 | import { logError, logIssue, logWarn } from "@sentry/mcp-server/telem/logging";
6 | import { sanitizeHtml } from "./html-utils";
7 |
8 | const COOKIE_NAME = "mcp-approved-clients";
9 | const ONE_YEAR_IN_SECONDS = 31536000;
10 | /**
11 | * Imports a secret key string for HMAC-SHA256 signing.
12 | * @param secret - The raw secret key string.
13 | * @returns A promise resolving to the CryptoKey object.
14 | */
15 | async function importKey(secret: string): Promise<CryptoKey> {
16 | if (!secret) {
17 | throw new Error(
18 | "COOKIE_SECRET is not defined. A secret key is required for signing cookies.",
19 | );
20 | }
21 | const enc = new TextEncoder();
22 | return crypto.subtle.importKey(
23 | "raw",
24 | enc.encode(secret),
25 | { name: "HMAC", hash: "SHA-256" },
26 | false, // not extractable
27 | ["sign", "verify"], // key usages
28 | );
29 | }
30 |
31 | /**
32 | * Signs data using HMAC-SHA256.
33 | * @param key - The CryptoKey for signing.
34 | * @param data - The string data to sign.
35 | * @returns A promise resolving to the signature as a hex string.
36 | */
37 | async function signData(key: CryptoKey, data: string): Promise<string> {
38 | const enc = new TextEncoder();
39 | const signatureBuffer = await crypto.subtle.sign(
40 | "HMAC",
41 | key,
42 | enc.encode(data),
43 | );
44 | // Convert ArrayBuffer to hex string
45 | return Array.from(new Uint8Array(signatureBuffer))
46 | .map((b) => b.toString(16).padStart(2, "0"))
47 | .join("");
48 | }
49 |
50 | /**
51 | * Verifies an HMAC-SHA256 signature.
52 | * @param key - The CryptoKey for verification.
53 | * @param signatureHex - The signature to verify (hex string).
54 | * @param data - The original data that was signed.
55 | * @returns A promise resolving to true if the signature is valid, false otherwise.
56 | */
57 | async function verifySignature(
58 | key: CryptoKey,
59 | signatureHex: string,
60 | data: string,
61 | ): Promise<boolean> {
62 | const enc = new TextEncoder();
63 | try {
64 | // Convert hex signature back to ArrayBuffer
65 | const signatureBytes = new Uint8Array(
66 | signatureHex.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16)),
67 | );
68 | return await crypto.subtle.verify(
69 | "HMAC",
70 | key,
71 | signatureBytes.buffer,
72 | enc.encode(data),
73 | );
74 | } catch (error) {
75 | logError(error, {
76 | loggerScope: ["cloudflare", "approval-dialog"],
77 | extra: {
78 | message: "Error verifying signature",
79 | },
80 | });
81 | return false;
82 | }
83 | }
84 |
85 | /**
86 | * Parses the signed cookie and verifies its integrity.
87 | * @param cookieHeader - The value of the Cookie header from the request.
88 | * @param secret - The secret key used for signing.
89 | * @returns A promise resolving to the list of approved client IDs if the cookie is valid, otherwise null.
90 | */
91 | async function getApprovedClientsFromCookie(
92 | cookieHeader: string | null,
93 | secret: string,
94 | ): Promise<string[] | null> {
95 | if (!cookieHeader) return null;
96 |
97 | const cookies = cookieHeader.split(";").map((c) => c.trim());
98 | const targetCookie = cookies.find((c) => c.startsWith(`${COOKIE_NAME}=`));
99 |
100 | if (!targetCookie) return null;
101 |
102 | const cookieValue = targetCookie.substring(COOKIE_NAME.length + 1);
103 | const parts = cookieValue.split(".");
104 |
105 | if (parts.length !== 2) {
106 | logWarn("Invalid approval cookie format", {
107 | loggerScope: ["cloudflare", "approval-dialog"],
108 | });
109 | return null; // Invalid format
110 | }
111 |
112 | const [signatureHex, base64Payload] = parts;
113 | const payload = atob(base64Payload); // Assuming payload is base64 encoded JSON string
114 |
115 | const key = await importKey(secret);
116 | const isValid = await verifySignature(key, signatureHex, payload);
117 |
118 | if (!isValid) {
119 | logWarn("Approval cookie signature verification failed", {
120 | loggerScope: ["cloudflare", "approval-dialog"],
121 | });
122 | return null; // Signature invalid
123 | }
124 |
125 | try {
126 | const approvedClients = JSON.parse(payload);
127 | if (!Array.isArray(approvedClients)) {
128 | logWarn("Approval cookie payload is not an array", {
129 | loggerScope: ["cloudflare", "approval-dialog"],
130 | });
131 | return null; // Payload isn't an array
132 | }
133 | // Ensure all elements are strings
134 | if (!approvedClients.every((item) => typeof item === "string")) {
135 | logWarn("Approval cookie payload contains non-string elements", {
136 | loggerScope: ["cloudflare", "approval-dialog"],
137 | });
138 | return null;
139 | }
140 | return approvedClients as string[];
141 | } catch (e) {
142 | logIssue(new Error(`Error parsing cookie payload: ${e}`, { cause: e }));
143 | return null; // JSON parsing failed
144 | }
145 | }
146 |
147 | /**
148 | * Checks if a given client ID has already been approved by the user,
149 | * based on a signed cookie.
150 | *
151 | * @param request - The incoming Request object to read cookies from.
152 | * @param clientId - The OAuth client ID to check approval for.
153 | * @param cookieSecret - The secret key used to sign/verify the approval cookie.
154 | * @returns A promise resolving to true if the client ID is in the list of approved clients in a valid cookie, false otherwise.
155 | */
156 | export async function clientIdAlreadyApproved(
157 | request: Request,
158 | clientId: string,
159 | cookieSecret: string,
160 | ): Promise<boolean> {
161 | if (!clientId) return false;
162 | const cookieHeader = request.headers.get("Cookie");
163 | const approvedClients = await getApprovedClientsFromCookie(
164 | cookieHeader,
165 | cookieSecret,
166 | );
167 |
168 | return approvedClients?.includes(clientId) ?? false;
169 | }
170 |
171 | /**
172 | * Configuration for the approval dialog
173 | */
174 | export interface ApprovalDialogOptions {
175 | /**
176 | * Client information to display in the approval dialog
177 | */
178 | client: ClientInfo | null;
179 | /**
180 | * Server information to display in the approval dialog
181 | */
182 | server: {
183 | name: string;
184 | logo?: string;
185 | description?: string;
186 | };
187 | /**
188 | * Arbitrary state data to pass through the approval flow
189 | * Will be encoded in the form and returned when approval is complete
190 | */
191 | state: Record<string, any>;
192 | }
193 |
194 | /**
195 | * Encodes arbitrary data to a URL-safe base64 string.
196 | * @param data - The data to encode (will be stringified).
197 | * @returns A URL-safe base64 encoded string.
198 | */
199 | function encodeState(data: any): string {
200 | try {
201 | const jsonString = JSON.stringify(data);
202 | // Use btoa for simplicity, assuming Worker environment supports it well enough
203 | // For complex binary data, a Buffer/Uint8Array approach might be better
204 | return btoa(jsonString);
205 | } catch (error) {
206 | logError(error, {
207 | loggerScope: ["cloudflare", "approval-dialog"],
208 | extra: {
209 | message: "Error encoding approval dialog state",
210 | },
211 | });
212 | throw new Error("Could not encode state");
213 | }
214 | }
215 |
216 | /**
217 | * Decodes a URL-safe base64 string back to its original data.
218 | * @param encoded - The URL-safe base64 encoded string.
219 | * @returns The original data.
220 | */
221 | function decodeState<T = any>(encoded: string): T {
222 | try {
223 | const jsonString = atob(encoded);
224 | return JSON.parse(jsonString);
225 | } catch (error) {
226 | logError(error, {
227 | loggerScope: ["cloudflare", "approval-dialog"],
228 | extra: {
229 | message: "Error decoding approval dialog state",
230 | },
231 | });
232 | throw new Error("Could not decode state");
233 | }
234 | }
235 |
236 | /**
237 | * Renders an approval dialog for OAuth authorization
238 | * The dialog displays information about the client and server
239 | * and includes a form to submit approval
240 | *
241 | * @param request - The HTTP request
242 | * @param options - Configuration for the approval dialog
243 | * @returns A Response containing the HTML approval dialog
244 | */
245 | export function renderApprovalDialog(
246 | request: Request,
247 | options: ApprovalDialogOptions,
248 | ): Response {
249 | const { client, server, state } = options;
250 |
251 | // Encode state for form submission
252 | const encodedState = encodeState(state);
253 |
254 | // Sanitize any untrusted content
255 | const serverName = sanitizeHtml(server.name);
256 | const clientName = client?.clientName
257 | ? sanitizeHtml(client.clientName)
258 | : "Unknown MCP Client";
259 | const serverDescription = server.description
260 | ? sanitizeHtml(server.description)
261 | : "";
262 |
263 | // Safe URLs
264 | const logoUrl = server.logo ? sanitizeHtml(server.logo) : "";
265 | const clientUri = client?.clientUri ? sanitizeHtml(client.clientUri) : "";
266 | const policyUri = client?.policyUri ? sanitizeHtml(client.policyUri) : "";
267 | const tosUri = client?.tosUri ? sanitizeHtml(client.tosUri) : "";
268 |
269 | // Client contacts
270 | const contacts =
271 | client?.contacts && client.contacts.length > 0
272 | ? sanitizeHtml(client.contacts.join(", "))
273 | : "";
274 |
275 | // Get redirect URIs
276 | const redirectUris =
277 | client?.redirectUris && client.redirectUris.length > 0
278 | ? client.redirectUris.map((uri) => sanitizeHtml(uri))
279 | : [];
280 |
281 | // Generate HTML for the approval dialog
282 | const htmlContent = `
283 | <!DOCTYPE html>
284 | <html lang="en">
285 | <head>
286 | <meta charset="UTF-8">
287 | <meta name="viewport" content="width=device-width, initial-scale=1.0">
288 | <title>${clientName} | Authorization Request</title>
289 | <style>
290 | /* Modern, responsive styling with system fonts */
291 | :root {
292 | --primary-color: oklch(0.205 0 0);
293 | --highlight-color: oklch(0.811 0.111 293.571);
294 | --border-color: oklch(0.278 0.033 256.848);
295 | --error-color: #f44336;
296 | --border-color: oklch(0.269 0 0);
297 | --text-color: oklch(0.872 0.01 258.338);
298 | --background-color: oklab(0 0 0 / 0.3);
299 | }
300 |
301 | body {
302 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
303 | Helvetica, Arial, sans-serif, "Apple Color Emoji",
304 | "Segoe UI Emoji", "Segoe UI Symbol";
305 | line-height: 1.6;
306 | color: var(--text-color);
307 | 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%);
308 | min-height: 100vh;
309 | margin: 0;
310 | padding: 0;
311 | }
312 |
313 | .container {
314 | max-width: 600px;
315 | margin: 1rem auto;
316 | padding: 1rem;
317 | }
318 |
319 | .precard {
320 | text-align: center;
321 | }
322 |
323 | .card {
324 | background-color: var(--background-color);
325 | padding: 2rem;
326 | }
327 |
328 | .header {
329 | display: flex;
330 | align-items: center;
331 | justify-content: center;
332 | margin-bottom: 1.5rem;
333 | }
334 |
335 | .logo {
336 | width: 36px;
337 | height: 36px;
338 | margin-right: 1rem;
339 | color: var(--highlight-color);
340 | }
341 |
342 | .title {
343 | margin: 0;
344 | font-size: 26px;
345 | font-weight: 400;
346 | color: white;
347 | }
348 |
349 | .alert {
350 | margin: 0;
351 | font-size: 1.5rem;
352 | font-weight: 400;
353 | margin: 1rem 0;
354 | text-align: center;
355 | color: white;
356 | }
357 |
358 | .client-info {
359 | border: 1px solid var(--border-color);
360 | padding: 1rem 1rem 0.5rem;
361 | margin-bottom: 1.5rem;
362 | }
363 |
364 | .client-name {
365 | font-weight: 600;
366 | font-size: 1.2rem;
367 | margin: 0 0 0.5rem 0;
368 | }
369 |
370 | .client-detail {
371 | display: flex;
372 | margin-bottom: 0.5rem;
373 | align-items: baseline;
374 | }
375 |
376 | .detail-label {
377 | font-weight: 500;
378 | min-width: 120px;
379 | }
380 |
381 | .detail-value {
382 | font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
383 | word-break: break-all;
384 | }
385 |
386 | .detail-value.small {
387 | font-size: 0.8em;
388 | }
389 |
390 | .external-link-icon {
391 | font-size: 0.75em;
392 | margin-left: 0.25rem;
393 | vertical-align: super;
394 | }
395 |
396 | .actions {
397 | display: flex;
398 | justify-content: flex-end;
399 | gap: 1rem;
400 | margin-top: 2rem;
401 | }
402 |
403 | a {
404 | color: var(--highlight-color);
405 | text-decoration: underline;
406 | }
407 |
408 | .button {
409 | padding: 0.75rem 1.5rem;
410 | font-weight: 600;
411 | cursor: pointer;
412 | border: none;
413 | font-size: 1rem;
414 | }
415 |
416 | .button-primary {
417 | background-color: var(--highlight-color);
418 | color: black;
419 | }
420 |
421 | .button-secondary {
422 | background-color: transparent;
423 | border: 1px solid var(--border-color);
424 | color: var(--text-color);
425 | }
426 |
427 | /* Permission selection styles */
428 | .permission-section {
429 | margin: 2rem 0;
430 | border: 1px solid var(--border-color);
431 | padding: 1.5rem;
432 | }
433 |
434 | .permission-title {
435 | margin: 0 0 0.5rem 0;
436 | font-size: 1.3rem;
437 | font-weight: 600;
438 | color: white;
439 | }
440 |
441 | /* Default permissions section */
442 | .default-permissions {
443 | margin-bottom: 2rem;
444 | background-color: oklab(0 0 0 / 0.15);
445 | border: 1px solid var(--highlight-color);
446 | padding: 1.5rem;
447 | border-radius: 4px;
448 | }
449 |
450 | .default-permissions-title {
451 | margin: 0 0 1rem 0;
452 | font-size: 1rem;
453 | font-weight: 500;
454 | color: white;
455 | }
456 |
457 | .default-permission-item {
458 | display: flex;
459 | align-items: flex-start;
460 | gap: 0.75rem;
461 | }
462 |
463 | .permission-check {
464 | font-size: 1.2rem;
465 | color: var(--highlight-color);
466 | flex-shrink: 0;
467 | margin-top: 0.1rem;
468 | }
469 |
470 | .default-permission-content {
471 | flex: 1;
472 | }
473 |
474 | .default-permission-name {
475 | font-weight: 600;
476 | color: white;
477 | font-size: 1rem;
478 | }
479 |
480 | .default-permission-description {
481 | color: var(--text-color);
482 | font-size: 0.9rem;
483 | margin-top: 0.25rem;
484 | }
485 |
486 | /* Optional permissions section */
487 | .optional-permissions {
488 | margin-bottom: 1.5rem;
489 | }
490 |
491 | .optional-permissions-title {
492 | margin: 0 0 1rem 0;
493 | font-size: 1rem;
494 | font-weight: 500;
495 | color: white;
496 | }
497 |
498 | .optional-permission-item {
499 | display: flex;
500 | align-items: flex-start;
501 | gap: 0.75rem;
502 | padding: 1rem;
503 | border: 1px solid var(--border-color);
504 | margin-bottom: 0.75rem;
505 | cursor: pointer;
506 | transition: all 0.2s ease;
507 | }
508 |
509 | .optional-permission-item:hover {
510 | border-color: var(--highlight-color);
511 | background-color: oklab(0 0 0 / 0.1);
512 | }
513 |
514 | .optional-permission-item input[type="checkbox"] {
515 | position: absolute;
516 | opacity: 0;
517 | pointer-events: none;
518 | }
519 |
520 | .permission-checkbox {
521 | font-size: 1.2rem;
522 | color: var(--text-color);
523 | transition: color 0.2s ease;
524 | flex-shrink: 0;
525 | cursor: pointer;
526 | margin-top: 0.1rem;
527 | }
528 |
529 | /* CSS-only checkbox interactions using :checked pseudo-class */
530 | .optional-permission-item input[type="checkbox"]:checked + .permission-checkbox {
531 | color: var(--highlight-color);
532 | }
533 |
534 | .optional-permission-item input[type="checkbox"]:checked + .permission-checkbox::before {
535 | content: "☑";
536 | }
537 |
538 | .optional-permission-item input[type="checkbox"]:not(:checked) + .permission-checkbox::before {
539 | content: "☐";
540 | }
541 |
542 | .optional-permission-item:has(input[type="checkbox"]:checked) {
543 | border-color: var(--highlight-color);
544 | background-color: oklab(0 0 0 / 0.1);
545 | }
546 |
547 | .optional-permission-content {
548 | flex: 1;
549 | }
550 |
551 | .optional-permission-name {
552 | font-weight: 600;
553 | color: white;
554 | font-size: 1rem;
555 | }
556 |
557 | .optional-permission-description {
558 | color: var(--text-color);
559 | font-size: 0.9rem;
560 | margin-top: 0.25rem;
561 | }
562 |
563 |
564 | /* Responsive adjustments */
565 | @media (max-width: 640px) {
566 | .container {
567 | margin: 1rem auto;
568 | padding: 0.5rem;
569 | }
570 |
571 | .card {
572 | padding: 1.5rem;
573 | }
574 |
575 | .client-detail {
576 | flex-direction: column;
577 | }
578 |
579 | .detail-label {
580 | min-width: unset;
581 | margin-bottom: 0.25rem;
582 | }
583 |
584 | .actions {
585 | flex-direction: column;
586 | }
587 |
588 | .button {
589 | width: 100%;
590 | }
591 | }
592 | </style>
593 | </head>
594 | <body>
595 | <div class="container">
596 | <div class="precard">
597 | <div class="header">
598 | <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>
599 | <h1 class="title"><strong>${serverName}</strong></h1>
600 | </div>
601 | </div>
602 |
603 | <div class="card">
604 |
605 | <h2 class="alert"><strong>${clientName || "A new MCP Client"}</strong> is requesting access</h1>
606 |
607 | <div class="client-info">
608 | <div class="client-detail">
609 | <div class="detail-label">Name:</div>
610 | <div class="detail-value">
611 | ${clientName}
612 | </div>
613 | </div>
614 |
615 | ${
616 | clientUri
617 | ? `
618 | <div class="client-detail">
619 | <div class="detail-label">Website:</div>
620 | <div class="detail-value small">
621 | <a href="${clientUri}" target="_blank" rel="noopener noreferrer">
622 | ${clientUri}
623 | </a>
624 | </div>
625 | </div>
626 | `
627 | : ""
628 | }
629 |
630 | ${
631 | policyUri
632 | ? `
633 | <div class="client-detail">
634 | <div class="detail-label">Privacy Policy:</div>
635 | <div class="detail-value">
636 | <a href="${policyUri}" target="_blank" rel="noopener noreferrer">
637 | ${policyUri}
638 | </a>
639 | </div>
640 | </div>
641 | `
642 | : ""
643 | }
644 |
645 | ${
646 | tosUri
647 | ? `
648 | <div class="client-detail">
649 | <div class="detail-label">Terms of Service:</div>
650 | <div class="detail-value">
651 | <a href="${tosUri}" target="_blank" rel="noopener noreferrer">
652 | ${tosUri}
653 | </a>
654 | </div>
655 | </div>
656 | `
657 | : ""
658 | }
659 |
660 | ${
661 | redirectUris.length > 0
662 | ? `
663 | <div class="client-detail">
664 | <div class="detail-label">Redirect URIs:</div>
665 | <div class="detail-value small">
666 | ${redirectUris.map((uri) => `<div>${uri}</div>`).join("")}
667 | </div>
668 | </div>
669 | `
670 | : ""
671 | }
672 |
673 | ${
674 | contacts
675 | ? `
676 | <div class="client-detail">
677 | <div class="detail-label">Contact:</div>
678 | <div class="detail-value">${contacts}</div>
679 | </div>
680 | `
681 | : ""
682 | }
683 | </div>
684 |
685 | <p>This MCP Client is requesting authorization to ${serverName}. If you approve, you will be redirected to complete authentication.</p>
686 |
687 | <form method="post" action="${new URL(request.url).pathname}">
688 | <input type="hidden" name="state" value="${encodedState}">
689 |
690 | <div class="permission-section">
691 | <h3 class="permission-title">Permissions</h3>
692 |
693 | <!-- Default permissions section -->
694 | <div class="default-permissions">
695 | <div class="default-permission-item">
696 | <span class="permission-check">✓</span>
697 | <div class="default-permission-content">
698 | <span class="default-permission-name">Read-only access to your Sentry data</span>
699 | <div class="default-permission-description">View organizations, projects, teams, issues, and releases</div>
700 | </div>
701 | </div>
702 | </div>
703 |
704 | <!-- Optional permissions section -->
705 | <div class="optional-permissions">
706 | <h4 class="optional-permissions-title">Optional additional access:</h4>
707 |
708 | <label class="optional-permission-item">
709 | <input type="checkbox" name="permission" value="seer" checked>
710 | <span class="permission-checkbox"></span>
711 | <div class="optional-permission-content">
712 | <span class="optional-permission-name">Seer</span>
713 | <div class="optional-permission-description">Use Seer to analyze issues and generate fix recommendations (may incur costs)</div>
714 | </div>
715 | </label>
716 |
717 | <label class="optional-permission-item">
718 | <input type="checkbox" name="permission" value="issue_triage">
719 | <span class="permission-checkbox"></span>
720 | <div class="optional-permission-content">
721 | <span class="optional-permission-name">Issue Triage (event:write)</span>
722 | <div class="optional-permission-description">Update and manage issues - resolve, assign, and triage problems</div>
723 | </div>
724 | </label>
725 |
726 | <label class="optional-permission-item">
727 | <input type="checkbox" name="permission" value="project_management">
728 | <span class="permission-checkbox"></span>
729 | <div class="optional-permission-content">
730 | <span class="optional-permission-name">Project Management (project:write, team:write)</span>
731 | <div class="optional-permission-description">Create and modify projects, teams, and DSNs</div>
732 | </div>
733 | </label>
734 | </div>
735 | </div>
736 |
737 | <div class="actions">
738 | <button type="button" class="button button-secondary" onclick="window.history.back()">Cancel</button>
739 | <button type="submit" class="button button-primary">Approve</button>
740 | </div>
741 | </form>
742 | </div>
743 | </div>
744 | </body>
745 | </html>
746 | `;
747 |
748 | return new Response(htmlContent, {
749 | headers: {
750 | "Content-Type": "text/html; charset=utf-8",
751 | },
752 | });
753 | }
754 |
755 | /**
756 | * Result of parsing the approval form submission.
757 | */
758 | export interface ParsedApprovalResult {
759 | /** The original state object passed through the form. */
760 | state: any;
761 | /** Headers to set on the redirect response, including the Set-Cookie header. */
762 | headers: Record<string, string>;
763 | /** Selected permission levels */
764 | permissions: string[];
765 | }
766 |
767 | /**
768 | * Parses the form submission from the approval dialog, extracts the state,
769 | * and generates Set-Cookie headers to mark the client as approved.
770 | *
771 | * @param request - The incoming POST Request object containing the form data.
772 | * @param cookieSecret - The secret key used to sign the approval cookie.
773 | * @returns A promise resolving to an object containing the parsed state and necessary headers.
774 | * @throws If the request method is not POST, form data is invalid, or state is missing.
775 | */
776 | export async function parseRedirectApproval(
777 | request: Request,
778 | cookieSecret: string,
779 | ): Promise<ParsedApprovalResult> {
780 | if (request.method !== "POST") {
781 | throw new Error("Invalid request method. Expected POST.");
782 | }
783 |
784 | let state: any;
785 | let clientId: string | undefined;
786 | let permissions: string[];
787 |
788 | try {
789 | const formData = await request.formData();
790 | const encodedState = formData.get("state");
791 |
792 | if (typeof encodedState !== "string" || !encodedState) {
793 | throw new Error("Missing or invalid 'state' in form data.");
794 | }
795 |
796 | state = decodeState<{ oauthReqInfo?: AuthRequest }>(encodedState); // Decode the state
797 | clientId = state?.oauthReqInfo?.clientId; // Extract clientId from within the state
798 |
799 | if (!clientId) {
800 | throw new Error("Could not extract clientId from state object.");
801 | }
802 |
803 | // Extract permission selections from checkboxes - collect all 'permission' field values
804 | permissions = formData
805 | .getAll("permission")
806 | .filter((p): p is string => typeof p === "string");
807 | } catch (error) {
808 | logError(error, {
809 | loggerScope: ["cloudflare", "approval-dialog"],
810 | extra: {
811 | message: "Error processing approval form submission",
812 | },
813 | });
814 | throw new Error(
815 | `Failed to parse approval form: ${error instanceof Error ? error.message : String(error)}`,
816 | );
817 | }
818 |
819 | // Get existing approved clients
820 | const cookieHeader = request.headers.get("Cookie");
821 | const existingApprovedClients =
822 | (await getApprovedClientsFromCookie(cookieHeader, cookieSecret)) || [];
823 |
824 | // Add the newly approved client ID (avoid duplicates)
825 | const updatedApprovedClients = Array.from(
826 | new Set([...existingApprovedClients, clientId]),
827 | );
828 |
829 | // Sign the updated list
830 | const payload = JSON.stringify(updatedApprovedClients);
831 | const key = await importKey(cookieSecret);
832 | const signature = await signData(key, payload);
833 | const newCookieValue = `${signature}.${btoa(payload)}`; // signature.base64(payload)
834 |
835 | // Generate Set-Cookie header
836 | const headers: Record<string, string> = {
837 | "Set-Cookie": `${COOKIE_NAME}=${newCookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=${ONE_YEAR_IN_SECONDS}`,
838 | };
839 |
840 | return { state, headers, permissions };
841 | }
842 |
843 | // sanitizeHtml function is now imported from "./html-utils"
844 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/search-events/config.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Build a dataset-agnostic system prompt
2 | export const systemPrompt = `You are a Sentry query translator. You need to:
3 | 1. FIRST determine which dataset (spans, errors, or logs) is most appropriate for the query
4 | 2. Query the available attributes for that dataset using the datasetAttributes tool
5 | 3. Use the otelSemantics tool if you need OpenTelemetry semantic conventions
6 | 4. Convert the natural language query to Sentry's search syntax (NOT SQL syntax)
7 | 5. Decide which fields to return in the results
8 |
9 | CRITICAL: Sentry does NOT use SQL syntax. Do NOT generate SQL-like queries.
10 |
11 | DATASET SELECTION GUIDELINES:
12 | - 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)
13 | - errors: Exceptions, crashes, error messages, stack traces, unhandled errors, browser/client errors
14 | - logs: Log entries, log messages, severity levels, debugging information
15 |
16 | For ambiguous queries like "calls using XYZ", prefer spans dataset first as it contains the most comprehensive telemetry data.
17 |
18 | CRITICAL - FIELD VERIFICATION REQUIREMENT:
19 | Before constructing ANY query, you MUST verify field availability:
20 | 1. You CANNOT assume ANY field exists without checking - not even common ones
21 | 2. This includes ALL fields: custom attributes, database fields, HTTP fields, AI fields, user fields, etc.
22 | 3. Fields vary by project based on what data is being sent to Sentry
23 | 4. Using an unverified field WILL cause your query to fail with "field not found" errors
24 | 5. The datasetAttributes tool tells you EXACTLY which fields are available
25 |
26 | TOOL USAGE GUIDELINES:
27 | 1. Use datasetAttributes tool to discover available fields for your chosen dataset
28 | 2. Use otelSemantics tool when you need specific OpenTelemetry semantic convention attributes
29 | 3. Use whoami tool when queries contain "me" references for user.id or user.email fields
30 | 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
31 |
32 | CRITICAL - TOOL RESPONSE HANDLING:
33 | All tools return responses in this format: {error?: string, result?: data}
34 | - If 'error' is present: The tool failed - analyze the error message and potentially retry with corrections
35 | - If 'result' is present: The tool succeeded - use the result data for your query construction
36 | - Always check for errors before using results
37 |
38 | CRITICAL - HANDLING "DISTINCT" OR "UNIQUE VALUES" QUERIES:
39 | When user asks for "distinct", "unique", "all values of", or "what are the X" queries:
40 | 1. This ALWAYS requires an AGGREGATE query with count() function
41 | 2. Pattern: fields=['field_name', 'count()'] to show distinct values with counts
42 | 3. Sort by "-count()" to show most common values first
43 | 4. Use datasetAttributes tool to verify the field exists before constructing query
44 | 5. Examples:
45 | - "distinct categories" → fields=['category.name', 'count()'], sort='-count()'
46 | - "unique types" → fields=['item.type', 'count()'], sort='-count()'
47 |
48 | CRITICAL - TRAFFIC/VOLUME/COUNT QUERIES:
49 | When user asks about "traffic", "volume", "how much", "how many" (without specific metrics):
50 | 1. This ALWAYS requires an AGGREGATE query with count() function
51 | 2. For total counts: fields=['count()']
52 | 3. For grouped counts: fields=['grouping_field', 'count()']
53 | 4. Always include timeRange for period-specific queries
54 | 5. Examples:
55 | - "how much traffic in last 30 days" → fields=['count()'], timeRange: {"statsPeriod": "30d"}
56 | - "traffic on mcp-server" → query: "project:mcp-server", fields=['count()']
57 |
58 | CRITICAL - HANDLING "ME" REFERENCES:
59 | - 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
60 | - For assignedTo fields, you can use "me" directly without translation (e.g., assignedTo:me works as-is)
61 | - After calling whoami, replace "me" references with the actual user.id or user.email values
62 | - If whoami fails, return an error explaining the issue
63 |
64 | QUERY MODES:
65 | 1. INDIVIDUAL EVENTS (default): Returns raw event data
66 | - Used when fields contain no function() calls
67 | - Include recommended fields plus any user-requested fields
68 |
69 | 2. AGGREGATE QUERIES: Grouping and aggregation (NOT SQL)
70 | - Activated when ANY field contains a function() call
71 | - Fields should ONLY include: aggregate functions + groupBy fields
72 | - Automatically groups by ALL non-function fields
73 | - For aggregate queries, ONLY include the aggregate functions and groupBy fields - do NOT include default fields like timestamp, id, etc.
74 | - You SHOULD sort aggregate results by "-function_name()" for descending order (highest values first)
75 | - For equations in aggregate queries: You SHOULD use "-equation|..." prefix unless user wants lowest values
76 | - When user asks "how many total", "sum of", or similar: They want the highest/total value, use descending sort
77 |
78 | CRITICAL LIMITATION - TIME SERIES NOT SUPPORTED:
79 | - Queries asking for data "over time", "by hour", "by day", "time series", or similar temporal groupings are NOT currently supported
80 | - If user asks for "X over time", return an error explaining: "Time series aggregations are not currently supported."
81 |
82 | CRITICAL - DO NOT USE SQL SYNTAX:
83 | - NEVER use SQL functions like yesterday(), today(), now(), IS NOT NULL, IS NULL
84 | - NEVER use SQL date functions - use timeRange parameter instead
85 | - For "yesterday": Use timeRange: {"statsPeriod": "24h"}, NOT timestamp >= yesterday()
86 | - For field existence: Use has:field_name, NOT field_name IS NOT NULL
87 | - For field absence: Use !has:field_name, NOT field_name IS NULL
88 |
89 | MATHEMATICAL QUERY PATTERNS:
90 | When user asks mathematical questions like "how many X", "total Y used", "sum of Z":
91 | - Identify the appropriate dataset based on context
92 | - Use datasetAttributes tool to find available numeric fields
93 | - Use sum() function for totals, avg() for averages, count() for counts
94 | - For time-based queries ("today", "yesterday", "this week"), use timeRange parameter
95 | - For "total" or "how many" questions: Users typically want highest values first (descending sort)
96 |
97 | DERIVED METRICS AND CALCULATIONS (SPANS ONLY):
98 | When user asks for calculated metrics, ratios, or conversions:
99 | - Use equation fields with "equation|" prefix
100 | - Examples:
101 | - "duration in milliseconds" → fields: ["equation|avg(span.duration) * 1000"], sort: "-equation|avg(span.duration) * 1000"
102 | - "combined metric total" → fields: ["equation|sum(metric.a) + sum(metric.b)"], sort: "-equation|sum(metric.a) + sum(metric.b)"
103 | - "error rate percentage" → fields: ["equation|failure_rate() * 100"], sort: "-equation|failure_rate() * 100"
104 | - "events per second" → fields: ["equation|count() / 3600"], sort: "-equation|count() / 3600"
105 | - IMPORTANT: Equations are ONLY supported in the spans dataset, NOT in errors or logs
106 | - IMPORTANT: When sorting by equations, use "-equation|..." for descending order (highest values first)
107 |
108 | SORTING RULES (CRITICAL - YOU MUST ALWAYS SPECIFY A SORT):
109 | 1. CRITICAL: Sort MUST go in the separate "sort" field, NEVER in the "query" field
110 | - WRONG: query: "level:error sort:-timestamp" ← Sort syntax in query field is FORBIDDEN
111 | - CORRECT: query: "level:error", sort: "-timestamp" ← Sort in separate field
112 |
113 | 2. DEFAULT SORTING:
114 | - errors dataset: Use "-timestamp" (newest first)
115 | - spans dataset: Use "-span.duration" (slowest first)
116 | - logs dataset: Use "-timestamp" (newest first)
117 |
118 | 3. SORTING SYNTAX:
119 | - Use "-" prefix for descending order (e.g., "-timestamp" for newest first)
120 | - Use field name without prefix for ascending order
121 | - For aggregate queries: sort by aggregate function results (e.g., "-count()" for highest count first)
122 | - For equation fields: You SHOULD use "-equation|..." for descending order (e.g., "-equation|sum(field1) + sum(field2)")
123 | - Only omit the "-" prefix if the user clearly wants lowest values first (rare)
124 |
125 | 4. IMPORTANT SORTING REQUIREMENTS:
126 | - YOU MUST ALWAYS INCLUDE A SORT PARAMETER
127 | - CRITICAL: The field you sort by MUST be included in your fields array
128 | - If sorting by "-timestamp", include "timestamp" in fields
129 | - If sorting by "-count()", include "count()" in fields
130 | - This is MANDATORY - Sentry will reject queries where sort field is not in the selected fields
131 |
132 | YOUR RESPONSE FORMAT:
133 | Return a JSON object with these fields:
134 | - "dataset": Which dataset you determined to use ("spans", "errors", or "logs")
135 | - "query": The Sentry query string for filtering results (use empty string "" for no filters)
136 | - "fields": Array of field names to return in results
137 | - For individual event queries: OPTIONAL (will use recommended fields if not provided)
138 | - For aggregate queries: REQUIRED (must include aggregate functions AND any groupBy fields)
139 | - "sort": Sort parameter for results (REQUIRED - YOU MUST ALWAYS SPECIFY THIS)
140 | - "timeRange": Time range parameters (optional)
141 | - Relative: {"statsPeriod": "24h"} for last 24 hours, "7d" for last 7 days, etc.
142 | - Absolute: {"start": "2025-06-19T07:00:00", "end": "2025-06-20T06:59:59"} for specific date ranges
143 |
144 | CORRECT QUERY PATTERNS (FOLLOW THESE):
145 | - For field existence: Use has:field_name (NOT field_name IS NOT NULL)
146 | - For field absence: Use !has:field_name (NOT field_name IS NULL)
147 | - For time periods: Use timeRange parameter (NOT SQL date functions)
148 | - Example: "items processed yesterday" → query: "has:item.processed", timeRange: {"statsPeriod": "24h"}
149 |
150 | PROCESS:
151 | 1. Analyze the user's query
152 | 2. Determine appropriate dataset
153 | 3. Use datasetAttributes tool to discover available fields
154 | 4. Use otelSemantics tool if needed for OpenTelemetry attributes
155 | 5. Construct the final query with proper fields and sort parameters
156 |
157 | COMMON ERRORS TO AVOID:
158 | - Using SQL syntax (IS NOT NULL, IS NULL, yesterday(), today(), etc.) - Use has: operator and timeRange instead
159 | - Using numeric functions (sum, avg, min, max, percentiles) on non-numeric fields
160 | - Using incorrect field names (use the otelSemantics tool to look up correct names)
161 | - Missing required fields in the fields array for aggregate queries
162 | - Invalid sort parameter not included in fields array
163 | - For field existence: Use has:field_name (NOT field_name IS NOT NULL)
164 | - For field absence: Use !has:field_name (NOT field_name IS NULL)
165 | - For time periods: Use timeRange parameter (NOT SQL date functions like yesterday())`;
166 |
167 | // Base fields common to all datasets
168 | export const BASE_COMMON_FIELDS = {
169 | project: "Project slug",
170 | timestamp: "When the event occurred",
171 | environment: "Environment (production, staging, development)",
172 | release: "Release version",
173 | platform: "Platform (javascript, python, etc.)",
174 | "user.id": "User ID",
175 | "user.email": "User email",
176 | "sdk.name": "SDK name",
177 | "sdk.version": "SDK version",
178 | };
179 |
180 | // Known numeric fields for each dataset
181 | export const NUMERIC_FIELDS: Record<string, Set<string>> = {
182 | spans: new Set([
183 | "span.duration",
184 | "span.self_time",
185 | "transaction.duration",
186 | "http.status_code",
187 | "gen_ai.usage.input_tokens",
188 | "gen_ai.usage.output_tokens",
189 | "gen_ai.request.max_tokens",
190 | ]),
191 | errors: new Set([
192 | // Most error fields are strings/categories
193 | "stack.lineno",
194 | ]),
195 | logs: new Set(["severity_number", "sentry.observed_timestamp_nanos"]),
196 | };
197 |
198 | // Dataset-specific field definitions
199 | export const DATASET_FIELDS = {
200 | spans: {
201 | // Span-specific fields
202 | "span.op": "Span operation type (e.g., http.client, db.query, cache.get)",
203 | "span.description": "Detailed description of the span operation",
204 | "span.duration": "Duration of the span in milliseconds",
205 | "span.status": "Span status (ok, cancelled, unknown, etc.)",
206 | "span.self_time": "Time spent in this span excluding child spans",
207 |
208 | // Transaction fields
209 | transaction: "Transaction name/route",
210 | "transaction.duration": "Total transaction duration in milliseconds",
211 | "transaction.op": "Transaction operation type",
212 | "transaction.status": "Transaction status",
213 | is_transaction: "Whether this span is a transaction (true/false)",
214 |
215 | // Trace fields
216 | trace: "Trace ID",
217 | "trace.span_id": "Span ID within the trace",
218 | "trace.parent_span_id": "Parent span ID",
219 |
220 | // HTTP fields
221 | "http.method": "HTTP method (GET, POST, etc.)",
222 | "http.status_code": "HTTP response status code",
223 | "http.url": "Full HTTP URL",
224 |
225 | // Database fields
226 | "db.system": "Database system (postgresql, mysql, etc.)",
227 | "db.operation": "Database operation (SELECT, INSERT, etc.)",
228 |
229 | // OpenTelemetry attribute namespaces for semantic queries
230 | // Use has:namespace.* to find spans with any attribute in that namespace
231 | // GenAI namespace (gen_ai.*) - for AI/LLM/Agent calls
232 | "gen_ai.system": "AI system (e.g., anthropic, openai)",
233 | "gen_ai.request.model": "Model name (e.g., claude-3-5-sonnet-20241022)",
234 | "gen_ai.operation.name": "Operation type (e.g., chat, completion)",
235 | "gen_ai.usage.input_tokens": "Number of input tokens (numeric)",
236 | "gen_ai.usage.output_tokens": "Number of output tokens (numeric)",
237 |
238 | // MCP namespace (mcp.*) - for Model Context Protocol tool calls
239 | "mcp.tool.name": "Tool name (e.g., search_issues, search_events)",
240 | "mcp.session.id": "MCP session identifier",
241 |
242 | // Aggregate functions (SPANS dataset only - require numeric fields except count/count_unique)
243 | "count()": "Count of spans",
244 | "count_unique(field)": "Count of unique values, e.g. count_unique(user.id)",
245 | "avg(field)": "Average of numeric field, e.g. avg(span.duration)",
246 | "sum(field)": "Sum of numeric field, e.g. sum(span.self_time)",
247 | "min(field)": "Minimum of numeric field, e.g. min(span.duration)",
248 | "max(field)": "Maximum of numeric field, e.g. max(span.duration)",
249 | "p50(field)": "50th percentile (median), e.g. p50(span.duration)",
250 | "p75(field)": "75th percentile, e.g. p75(span.duration)",
251 | "p90(field)": "90th percentile, e.g. p90(span.duration)",
252 | "p95(field)": "95th percentile, e.g. p95(span.duration)",
253 | "p99(field)": "99th percentile, e.g. p99(span.duration)",
254 | "p100(field)": "100th percentile (max), e.g. p100(span.duration)",
255 | "epm()": "Events per minute rate",
256 | "failure_rate()": "Percentage of failed spans",
257 | },
258 | errors: {
259 | // Error-specific fields
260 | message: "Error message",
261 | level: "Error level (error, warning, info, debug)",
262 | "error.type": "Error type/exception class",
263 | "error.value": "Error value/description",
264 | "error.handled": "Whether the error was handled (true/false)",
265 | culprit: "Code location that caused the error",
266 | title: "Error title/grouping",
267 |
268 | // Stack trace fields
269 | "stack.filename": "File where error occurred",
270 | "stack.function": "Function where error occurred",
271 | "stack.module": "Module where error occurred",
272 | "stack.abs_path": "Absolute path to file",
273 |
274 | // Additional context fields
275 | "os.name": "Operating system name",
276 | "browser.name": "Browser name",
277 | "device.family": "Device family",
278 |
279 | // Aggregate functions (ERRORS dataset only)
280 | "count()": "Count of error events",
281 | "count_unique(field)": "Count of unique values, e.g. count_unique(user.id)",
282 | "count_if(field,equals,value)":
283 | "Conditional count, e.g. count_if(error.handled,equals,false)",
284 | "last_seen()": "Most recent timestamp of the group",
285 | "eps()": "Events per second rate",
286 | "epm()": "Events per minute rate",
287 | },
288 | logs: {
289 | // Log-specific fields
290 | message: "Log message",
291 | severity: "Log severity level",
292 | severity_number: "Numeric severity level",
293 | "sentry.item_id": "Sentry item ID",
294 | "sentry.observed_timestamp_nanos": "Observed timestamp in nanoseconds",
295 |
296 | // Trace context
297 | trace: "Trace ID",
298 |
299 | // Aggregate functions (LOGS dataset only - require numeric fields except count/count_unique)
300 | "count()": "Count of log entries",
301 | "count_unique(field)": "Count of unique values, e.g. count_unique(user.id)",
302 | "avg(field)": "Average of numeric field, e.g. avg(severity_number)",
303 | "sum(field)": "Sum of numeric field",
304 | "min(field)": "Minimum of numeric field",
305 | "max(field)": "Maximum of numeric field",
306 | "p50(field)": "50th percentile (median)",
307 | "p75(field)": "75th percentile",
308 | "p90(field)": "90th percentile",
309 | "p95(field)": "95th percentile",
310 | "p99(field)": "99th percentile",
311 | "p100(field)": "100th percentile (max)",
312 | "epm()": "Events per minute rate",
313 | },
314 | };
315 |
316 | // Dataset-specific rules and examples
317 | export const DATASET_CONFIGS = {
318 | errors: {
319 | rules: `- For errors, focus on: message, level, error.type, error.handled
320 | - Use level field for severity (error, warning, info, debug)
321 | - Use error.handled:false for unhandled exceptions/crashes
322 | - For filename searches: Use stack.filename for suffix-based search (e.g., stack.filename:"**/index.js" or stack.filename:"**/components/Button.tsx")
323 | - 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")`,
324 | examples: `- "null pointer exceptions" →
325 | {
326 | "query": "error.type:\\"NullPointerException\\" OR message:\\"*null pointer*\\"",
327 | "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit"],
328 | "sort": "-timestamp"
329 | }
330 | - "unhandled errors in production" →
331 | {
332 | "query": "error.handled:false AND environment:production",
333 | "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit", "error.handled", "environment"],
334 | "sort": "-timestamp"
335 | }
336 | - "database connection errors" →
337 | {
338 | "query": "message:\\"*database*\\" AND message:\\"*connection*\\" AND level:error",
339 | "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit"],
340 | "sort": "-timestamp"
341 | }
342 | - "show me user emails for authentication failures" →
343 | {
344 | "query": "message:\\"*auth*\\" AND (message:\\"*failed*\\" OR message:\\"*denied*\\")",
345 | "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit", "user.email"],
346 | "sort": "-timestamp"
347 | }
348 | - "errors in Button.tsx file" →
349 | {
350 | "query": "stack.filename:\\"**/Button.tsx\\"",
351 | "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit", "stack.filename"],
352 | "sort": "-timestamp"
353 | }
354 | - "count errors by type in production" →
355 | {
356 | "query": "environment:production",
357 | "fields": ["error.type", "count()", "last_seen()"],
358 | "sort": "-count()"
359 | }
360 | - "most common errors last 24h" →
361 | {
362 | "query": "level:error",
363 | "fields": ["title", "error.type", "count()"],
364 | "sort": "-count()"
365 | }
366 | - "unhandled errors rate by project" →
367 | {
368 | "query": "",
369 | "fields": ["project", "count()", "count_if(error.handled,equals,false)", "epm()"],
370 | "sort": "-count()"
371 | }
372 | - "errors in the last hour" →
373 | {
374 | "query": "",
375 | "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit"],
376 | "sort": "-timestamp",
377 | "timeRange": {"statsPeriod": "1h"}
378 | }
379 | - "database errors between June 19-20" →
380 | {
381 | "query": "message:\\"*database*\\"",
382 | "fields": ["issue", "title", "project", "timestamp", "level", "message", "error.type", "culprit"],
383 | "sort": "-timestamp",
384 | "timeRange": {"start": "2025-06-19T00:00:00", "end": "2025-06-20T23:59:59"}
385 | }
386 | - "unique users affected by errors" →
387 | {
388 | "query": "level:error",
389 | "fields": ["error.type", "count()", "count_unique(user.id)"],
390 | "sort": "-count_unique(user.id)"
391 | }
392 | - "what is the most common error" →
393 | {
394 | "query": "",
395 | "fields": ["title", "count()"],
396 | "sort": "-count()"
397 | }
398 | - "errors by browser" →
399 | {
400 | "query": "has:user_agent.original",
401 | "fields": ["user_agent.original", "count()"],
402 | "sort": "-count()"
403 | }
404 | - "which user agents have the most errors" →
405 | {
406 | "query": "level:error AND has:user_agent.original",
407 | "fields": ["user_agent.original", "count()", "count_unique(user.id)"],
408 | "sort": "-count()"
409 | }`,
410 | },
411 | logs: {
412 | rules: `- For logs, focus on: message, severity, severity_number
413 | - Use severity field for log levels (fatal, error, warning, info, debug, trace)
414 | - severity_number is numeric (21=fatal, 17=error, 13=warning, 9=info, 5=debug, 1=trace)
415 | - IMPORTANT: For time-based filtering in logs, do NOT use timestamp filters in the query
416 | - Instead, time filtering for logs is handled by the statsPeriod parameter (not part of the query string)
417 | - Keep your query focused on message content, severity levels, and other attributes only
418 | - When user asks for "error logs", interpret this as logs with severity:error`,
419 | examples: `- "warning logs about memory" →
420 | {
421 | "query": "severity:warning AND message:\\"*memory*\\"",
422 | "fields": ["timestamp", "project", "message", "severity", "trace"],
423 | "sort": "-timestamp"
424 | }
425 | - "error logs from database" →
426 | {
427 | "query": "severity:error AND message:\\"*database*\\"",
428 | "fields": ["timestamp", "project", "message", "severity", "trace"],
429 | "sort": "-timestamp"
430 | }
431 | - "show me error logs with user context" →
432 | {
433 | "query": "severity:error",
434 | "fields": ["timestamp", "project", "message", "severity", "trace", "user.id", "user.email"],
435 | "sort": "-timestamp"
436 | }
437 | - "what is the most common log" →
438 | {
439 | "query": "",
440 | "fields": ["message", "count()"],
441 | "sort": "-count()"
442 | }
443 | - "most common error logs" →
444 | {
445 | "query": "severity:error",
446 | "fields": ["message", "count()"],
447 | "sort": "-count()"
448 | }
449 | - "count logs by severity" →
450 | {
451 | "query": "",
452 | "fields": ["severity", "count()"],
453 | "sort": "-count()"
454 | }
455 | - "log volume by project" →
456 | {
457 | "query": "",
458 | "fields": ["project", "count()", "epm()"],
459 | "sort": "-count()"
460 | }`,
461 | },
462 | spans: {
463 | rules: `- For traces/spans, focus on: span.op, span.description, span.duration, transaction
464 | - Use is_transaction:true for transaction spans only
465 | - Use span.duration for performance queries (value is in milliseconds)
466 | - IMPORTANT: Use has: queries for attribute-based filtering instead of span.op patterns:
467 | - For HTTP requests: use "has:request.url" instead of "span.op:http*"
468 | - For database queries: use "has:db.statement" or "has:db.system" instead of "span.op:db*"
469 | - For AI/LLM/Agent calls: use "has:gen_ai.system" or "has:gen_ai.request.model" (OpenTelemetry GenAI semantic conventions)
470 | - For MCP tool calls: use "has:mcp.tool.name" (Model Context Protocol semantic conventions)
471 | - This approach is more flexible and captures all relevant spans regardless of their operation type
472 |
473 | OpenTelemetry Semantic Conventions (2025 Stable):
474 | Core Namespaces:
475 | - gen_ai.*: GenAI attributes for AI/LLM/Agent calls (system, request.model, operation.name, usage.*)
476 | - db.*: Database attributes (system, statement, operation, name) - STABLE
477 | - http.*: HTTP attributes (method, status_code, url, request.*, response.*) - STABLE
478 | - rpc.*: RPC attributes (system, service, method, grpc.*)
479 | - messaging.*: Messaging attributes (system, operation, destination.*)
480 | - faas.*: Function as a Service attributes (name, version, runtime)
481 | - cloud.*: Cloud provider attributes (provider, region, zone)
482 | - k8s.*: Kubernetes attributes (namespace, pod, container, node)
483 | - host.*: Host attributes (name, type, arch, os.*)
484 | - service.*: Service attributes (name, version, instance.id)
485 | - process.*: Process attributes (pid, command, runtime.*)
486 |
487 | Custom Namespaces:
488 | - mcp.*: Model Context Protocol attributes for MCP tool calls (tool.name, session.id, transport)
489 |
490 | Query Patterns:
491 | - Use has:namespace.* to find spans with any attribute in that namespace
492 | - Most common: has:gen_ai.system (agent calls), has:mcp.tool.name (MCP tools), has:db.statement (database), has:http.method (HTTP)`,
493 | examples: `- "database queries" →
494 | {
495 | "query": "has:db.statement",
496 | "fields": ["span.op", "span.description", "span.duration", "transaction", "timestamp", "project", "trace", "db.system", "db.statement"],
497 | "sort": "-span.duration"
498 | }
499 | - "slow API calls over 5 seconds" →
500 | {
501 | "query": "has:request.url AND span.duration:>5000",
502 | "fields": ["span.op", "span.description", "span.duration", "transaction", "timestamp", "project", "trace", "request.url", "request.method", "span.status_code"],
503 | "sort": "-span.duration"
504 | }
505 | - "show me database queries with their SQL" →
506 | {
507 | "query": "has:db.statement",
508 | "fields": ["span.op", "span.description", "span.duration", "transaction", "timestamp", "project", "trace", "db.system", "db.statement"],
509 | "sort": "-span.duration"
510 | }
511 | - "average response time by endpoint" →
512 | {
513 | "query": "is_transaction:true",
514 | "fields": ["transaction", "count()", "avg(span.duration)", "p95(span.duration)"],
515 | "sort": "-avg(span.duration)"
516 | }
517 | - "slowest database queries by p95" →
518 | {
519 | "query": "has:db.statement",
520 | "fields": ["db.statement", "count()", "p50(span.duration)", "p95(span.duration)", "max(span.duration)"],
521 | "sort": "-p95(span.duration)"
522 | }
523 | - "API calls in the last 30 minutes" →
524 | {
525 | "query": "has:request.url",
526 | "fields": ["id", "span.op", "span.description", "span.duration", "transaction", "timestamp", "project", "trace", "request.url", "request.method"],
527 | "sort": "-timestamp",
528 | "timeRange": {"statsPeriod": "30m"}
529 | }
530 | - "most common transaction" →
531 | {
532 | "query": "is_transaction:true",
533 | "fields": ["transaction", "count()"],
534 | "sort": "-count()"
535 | }
536 | - "top 10 tool call spans by usage" →
537 | {
538 | "query": "has:mcp.tool.name",
539 | "fields": ["mcp.tool.name", "count()"],
540 | "sort": "-count()"
541 | }
542 | - "top 10 agent call spans by usage" →
543 | {
544 | "query": "has:gen_ai.system",
545 | "fields": ["gen_ai.system", "gen_ai.request.model", "count()"],
546 | "sort": "-count()"
547 | }
548 | - "slowest AI/LLM calls" →
549 | {
550 | "query": "has:gen_ai.request.model",
551 | "fields": ["gen_ai.system", "gen_ai.request.model", "span.duration", "transaction", "timestamp", "project", "trace", "gen_ai.operation.name"],
552 | "sort": "-span.duration"
553 | }
554 | - "agent calls by model usage" →
555 | {
556 | "query": "has:gen_ai.request.model",
557 | "fields": ["gen_ai.request.model", "count()"],
558 | "sort": "-count()"
559 | }
560 | - "average agent call duration by model" →
561 | {
562 | "query": "has:gen_ai.request.model",
563 | "fields": ["gen_ai.request.model", "count()", "avg(span.duration)", "p95(span.duration)"],
564 | "sort": "-avg(span.duration)"
565 | }
566 | - "token usage by AI system" →
567 | {
568 | "query": "has:gen_ai.usage.input_tokens",
569 | "fields": ["gen_ai.system", "sum(gen_ai.usage.input_tokens)", "sum(gen_ai.usage.output_tokens)", "count()"],
570 | "sort": "-sum(gen_ai.usage.input_tokens)"
571 | }
572 | - "how many tokens used today" →
573 | {
574 | "query": "has:gen_ai.usage.input_tokens",
575 | "fields": ["sum(gen_ai.usage.input_tokens)", "sum(gen_ai.usage.output_tokens)", "count()"],
576 | "sort": "-sum(gen_ai.usage.input_tokens)",
577 | "timeRange": {"statsPeriod": "24h"}
578 | }
579 | - "average response time in milliseconds" →
580 | {
581 | "query": "is_transaction:true",
582 | "fields": ["transaction", "equation|avg(span.duration) * 1000"],
583 | "sort": "-equation|avg(span.duration) * 1000",
584 | "timeRange": {"statsPeriod": "24h"}
585 | }
586 | - "total input tokens by model" →
587 | {
588 | "query": "has:gen_ai.usage.input_tokens",
589 | "fields": ["gen_ai.request.model", "sum(gen_ai.usage.input_tokens)", "count()"],
590 | "sort": "-sum(gen_ai.usage.input_tokens)"
591 | }
592 | - "tokens used this week" →
593 | {
594 | "query": "has:gen_ai.usage.input_tokens",
595 | "fields": ["sum(gen_ai.usage.input_tokens)", "sum(gen_ai.usage.output_tokens)", "count()"],
596 | "sort": "-sum(gen_ai.usage.input_tokens)",
597 | "timeRange": {"statsPeriod": "7d"}
598 | }
599 | - "which user agents have the most tool calls yesterday" →
600 | {
601 | "query": "has:mcp.tool.name AND has:user_agent.original",
602 | "fields": ["user_agent.original", "count()"],
603 | "sort": "-count()",
604 | "timeRange": {"statsPeriod": "24h"}
605 | }
606 | - "top 10 browsers by API calls" →
607 | {
608 | "query": "has:http.method AND has:user_agent.original",
609 | "fields": ["user_agent.original", "count()"],
610 | "sort": "-count()"
611 | }
612 | - "most common clients making database queries" →
613 | {
614 | "query": "has:db.statement AND has:user_agent.original",
615 | "fields": ["user_agent.original", "count()", "avg(span.duration)"],
616 | "sort": "-count()"
617 | }`,
618 | },
619 | };
620 |
621 | // Define recommended fields for each dataset
622 | export const RECOMMENDED_FIELDS = {
623 | errors: {
624 | basic: [
625 | "issue",
626 | "title",
627 | "project",
628 | "timestamp",
629 | "level",
630 | "message",
631 | "error.type",
632 | "culprit",
633 | ],
634 | description:
635 | "Basic error information including issue ID, title, timestamp, severity, and location",
636 | },
637 | logs: {
638 | basic: ["timestamp", "project", "message", "severity", "trace"],
639 | description: "Essential log entry information",
640 | },
641 | spans: {
642 | basic: [
643 | "id",
644 | "span.op",
645 | "span.description",
646 | "span.duration",
647 | "transaction",
648 | "timestamp",
649 | "project",
650 | "trace",
651 | ],
652 | description:
653 | "Core span/trace information including span ID, operation, duration, and trace context",
654 | },
655 | };
656 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/get-issue-details.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import { http, HttpResponse } from "msw";
3 | import { mswServer } from "@sentry/mcp-server-mocks";
4 | import getIssueDetails from "./get-issue-details.js";
5 | import { performanceEventFixture } from "@sentry/mcp-server-mocks";
6 |
7 | const baseContext = {
8 | constraints: {
9 | organizationSlug: null,
10 | },
11 | accessToken: "access-token",
12 | userId: "1",
13 | };
14 |
15 | function createPerformanceIssueFixture() {
16 | return {
17 | id: "7890123456",
18 | shareId: null,
19 | shortId: "PERF-N1-001",
20 | title: "N+1 Query: SELECT * FROM users WHERE id = %s",
21 | culprit: "GET /api/users",
22 | permalink: "https://sentry-mcp-evals.sentry.io/issues/7890123456/",
23 | logger: null,
24 | level: "warning",
25 | status: "unresolved",
26 | statusDetails: {},
27 | substatus: "ongoing",
28 | isPublic: false,
29 | platform: "python",
30 | project: {
31 | id: "4509062593708032",
32 | name: "CLOUDFLARE-MCP",
33 | slug: "CLOUDFLARE-MCP",
34 | platform: "python",
35 | },
36 | type: "performance_n_plus_one_db_queries",
37 | metadata: {
38 | title: "N+1 Query: SELECT * FROM users WHERE id = %s",
39 | location: "GET /api/users",
40 | value: "SELECT * FROM users WHERE id = %s",
41 | },
42 | numComments: 0,
43 | assignedTo: null,
44 | isBookmarked: false,
45 | isSubscribed: false,
46 | subscriptionDetails: null,
47 | hasSeen: true,
48 | annotations: [],
49 | issueType: "performance_n_plus_one_db_queries",
50 | issueCategory: "performance",
51 | priority: "medium",
52 | priorityLockedAt: null,
53 | isUnhandled: false,
54 | count: "25",
55 | userCount: 5,
56 | firstSeen: "2025-08-05T12:00:00.000Z",
57 | lastSeen: "2025-08-06T12:00:00.000Z",
58 | firstRelease: null,
59 | lastRelease: null,
60 | activity: [],
61 | openPeriods: [],
62 | seenBy: [],
63 | pluginActions: [],
64 | pluginIssues: [],
65 | pluginContexts: [],
66 | userReportCount: 0,
67 | stats: {},
68 | participants: [],
69 | };
70 | }
71 |
72 | function createPerformanceEventFixture() {
73 | const cloned = JSON.parse(JSON.stringify(performanceEventFixture));
74 | const offenderSpanIds = cloned.occurrence.evidenceData.offenderSpanIds.slice(
75 | 0,
76 | 3,
77 | );
78 | cloned.occurrence.evidenceData.offenderSpanIds = offenderSpanIds;
79 | cloned.occurrence.evidenceData.numberRepeatingSpans = String(
80 | offenderSpanIds.length,
81 | );
82 | cloned.occurrence.evidenceData.repeatingSpansCompact = undefined;
83 | cloned.occurrence.evidenceData.repeatingSpans = [
84 | 'db - INSERT INTO "sentry_fileblobindex" ("offset", "file_id", "blob_id") VALUES (%s, %s, %s) RETURNING "sentry_fileblobindex"."id"',
85 | "function - sentry.models.files.abstractfileblob.AbstractFileBlob.from_file",
86 | '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',
87 | ];
88 |
89 | const spansEntry = cloned.entries.find(
90 | (entry: { type: string }) => entry.type === "spans",
91 | );
92 | if (spansEntry?.data) {
93 | spansEntry.data = spansEntry.data.slice(0, 4);
94 | }
95 | return cloned;
96 | }
97 |
98 | function createTraceResponseFixture() {
99 | return [
100 | {
101 | span_id: "root-span",
102 | event_id: "root-span",
103 | transaction_id: "root-span",
104 | project_id: "4509062593708032",
105 | project_slug: "cloudflare-mcp",
106 | profile_id: "",
107 | profiler_id: "",
108 | parent_span_id: null,
109 | start_timestamp: 0,
110 | end_timestamp: 1,
111 | measurements: {},
112 | duration: 1000,
113 | transaction: "/api/users",
114 | is_transaction: true,
115 | description: "GET /api/users",
116 | sdk_name: "sentry.python",
117 | op: "http.server",
118 | name: "GET /api/users",
119 | event_type: "transaction",
120 | additional_attributes: {},
121 | errors: [],
122 | occurrences: [],
123 | children: [
124 | {
125 | span_id: "parent123",
126 | event_id: "parent123",
127 | transaction_id: "parent123",
128 | project_id: "4509062593708032",
129 | project_slug: "cloudflare-mcp",
130 | profile_id: "",
131 | profiler_id: "",
132 | parent_span_id: "root-span",
133 | start_timestamp: 0.1,
134 | end_timestamp: 0.35,
135 | measurements: {},
136 | duration: 250,
137 | transaction: "/api/users",
138 | is_transaction: false,
139 | description: "GET /api/users handler",
140 | sdk_name: "sentry.python",
141 | op: "http.server",
142 | name: "GET /api/users handler",
143 | event_type: "span",
144 | additional_attributes: {},
145 | errors: [],
146 | occurrences: [],
147 | children: [
148 | {
149 | span_id: "span001",
150 | event_id: "span001",
151 | transaction_id: "span001",
152 | project_id: "4509062593708032",
153 | project_slug: "cloudflare-mcp",
154 | profile_id: "",
155 | profiler_id: "",
156 | parent_span_id: "parent123",
157 | start_timestamp: 0.15,
158 | end_timestamp: 0.16,
159 | measurements: {},
160 | duration: 10,
161 | transaction: "/api/users",
162 | is_transaction: false,
163 | description: "SELECT * FROM users WHERE id = 1",
164 | sdk_name: "sentry.python",
165 | op: "db.query",
166 | name: "SELECT * FROM users WHERE id = 1",
167 | event_type: "span",
168 | additional_attributes: {},
169 | errors: [],
170 | occurrences: [],
171 | children: [],
172 | },
173 | {
174 | span_id: "span002",
175 | event_id: "span002",
176 | transaction_id: "span002",
177 | project_id: "4509062593708032",
178 | project_slug: "cloudflare-mcp",
179 | profile_id: "",
180 | profiler_id: "",
181 | parent_span_id: "parent123",
182 | start_timestamp: 0.2,
183 | end_timestamp: 0.212,
184 | measurements: {},
185 | duration: 12,
186 | transaction: "/api/users",
187 | is_transaction: false,
188 | description: "SELECT * FROM users WHERE id = 2",
189 | sdk_name: "sentry.python",
190 | op: "db.query",
191 | name: "SELECT * FROM users WHERE id = 2",
192 | event_type: "span",
193 | additional_attributes: {},
194 | errors: [],
195 | occurrences: [],
196 | children: [],
197 | },
198 | {
199 | span_id: "span003",
200 | event_id: "span003",
201 | transaction_id: "span003",
202 | project_id: "4509062593708032",
203 | project_slug: "cloudflare-mcp",
204 | profile_id: "",
205 | profiler_id: "",
206 | parent_span_id: "parent123",
207 | start_timestamp: 0.24,
208 | end_timestamp: 0.255,
209 | measurements: {},
210 | duration: 15,
211 | transaction: "/api/users",
212 | is_transaction: false,
213 | description: "SELECT * FROM users WHERE id = 3",
214 | sdk_name: "sentry.python",
215 | op: "db.query",
216 | name: "SELECT * FROM users WHERE id = 3",
217 | event_type: "span",
218 | additional_attributes: {},
219 | errors: [],
220 | occurrences: [],
221 | children: [],
222 | },
223 | ],
224 | },
225 | ],
226 | },
227 | ];
228 | }
229 |
230 | describe("get_issue_details", () => {
231 | it("serializes with issueId", async () => {
232 | const result = await getIssueDetails.handler(
233 | {
234 | organizationSlug: "sentry-mcp-evals",
235 | issueId: "CLOUDFLARE-MCP-41",
236 | eventId: undefined,
237 | issueUrl: undefined,
238 | regionUrl: undefined,
239 | },
240 | {
241 | constraints: {
242 | organizationSlug: null,
243 | },
244 | accessToken: "access-token",
245 | userId: "1",
246 | },
247 | );
248 | expect(result).toMatchInlineSnapshot(`
249 | "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals**
250 |
251 | **Description**: Error: Tool list_organizations is already registered
252 | **Culprit**: Object.fetch(index)
253 | **First Seen**: 2025-04-03T22:51:19.403Z
254 | **Last Seen**: 2025-04-12T11:34:11.000Z
255 | **Occurrences**: 25
256 | **Users Impacted**: 1
257 | **Status**: unresolved
258 | **Platform**: javascript
259 | **Project**: CLOUDFLARE-MCP
260 | **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41
261 |
262 | ## Event Details
263 |
264 | **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51
265 | **Occurred At**: 2025-04-08T21:15:04.000Z
266 |
267 | ### Error
268 |
269 | \`\`\`
270 | Error: Tool list_organizations is already registered
271 | \`\`\`
272 |
273 | **Stacktrace:**
274 | \`\`\`
275 | index.js:7809:27
276 | index.js:8029:24 (OAuthProviderImpl.fetch)
277 | index.js:19631:28 (Object.fetch)
278 | \`\`\`
279 |
280 | ### HTTP Request
281 |
282 | **Method:** GET
283 | **URL:** https://mcp.sentry.dev/sse
284 |
285 | ### Tags
286 |
287 | **environment**: development
288 | **handled**: no
289 | **level**: error
290 | **mechanism**: cloudflare
291 | **runtime.name**: cloudflare
292 | **url**: https://mcp.sentry.dev/sse
293 |
294 | ### Additional Context
295 |
296 | These are additional context provided by the user when they're instrumenting their application.
297 |
298 | **cloud_resource**
299 | cloud.provider: "cloudflare"
300 |
301 | **culture**
302 | timezone: "Europe/London"
303 |
304 | **runtime**
305 | name: "cloudflare"
306 |
307 | **trace**
308 | trace_id: "3032af8bcdfe4423b937fc5c041d5d82"
309 | span_id: "953da703d2a6f4c7"
310 | status: "unknown"
311 | client_sample_rate: 1
312 | sampled: true
313 |
314 | # Using this information
315 |
316 | - You can reference the IssueID in commit messages (e.g. \`Fixes CLOUDFLARE-MCP-41\`) to automatically close the issue when the commit is merged.
317 | - The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code.
318 | "
319 | `);
320 | });
321 |
322 | it("serializes with issueUrl", async () => {
323 | const result = await getIssueDetails.handler(
324 | {
325 | organizationSlug: undefined,
326 | issueId: undefined,
327 | eventId: undefined,
328 | issueUrl: "https://sentry-mcp-evals.sentry.io/issues/6507376925",
329 | regionUrl: undefined,
330 | },
331 | {
332 | constraints: {
333 | organizationSlug: null,
334 | },
335 | accessToken: "access-token",
336 | userId: "1",
337 | },
338 | );
339 |
340 | expect(result).toMatchInlineSnapshot(`
341 | "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals**
342 |
343 | **Description**: Error: Tool list_organizations is already registered
344 | **Culprit**: Object.fetch(index)
345 | **First Seen**: 2025-04-03T22:51:19.403Z
346 | **Last Seen**: 2025-04-12T11:34:11.000Z
347 | **Occurrences**: 25
348 | **Users Impacted**: 1
349 | **Status**: unresolved
350 | **Platform**: javascript
351 | **Project**: CLOUDFLARE-MCP
352 | **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41
353 |
354 | ## Event Details
355 |
356 | **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51
357 | **Occurred At**: 2025-04-08T21:15:04.000Z
358 |
359 | ### Error
360 |
361 | \`\`\`
362 | Error: Tool list_organizations is already registered
363 | \`\`\`
364 |
365 | **Stacktrace:**
366 | \`\`\`
367 | index.js:7809:27
368 | index.js:8029:24 (OAuthProviderImpl.fetch)
369 | index.js:19631:28 (Object.fetch)
370 | \`\`\`
371 |
372 | ### HTTP Request
373 |
374 | **Method:** GET
375 | **URL:** https://mcp.sentry.dev/sse
376 |
377 | ### Tags
378 |
379 | **environment**: development
380 | **handled**: no
381 | **level**: error
382 | **mechanism**: cloudflare
383 | **runtime.name**: cloudflare
384 | **url**: https://mcp.sentry.dev/sse
385 |
386 | ### Additional Context
387 |
388 | These are additional context provided by the user when they're instrumenting their application.
389 |
390 | **cloud_resource**
391 | cloud.provider: "cloudflare"
392 |
393 | **culture**
394 | timezone: "Europe/London"
395 |
396 | **runtime**
397 | name: "cloudflare"
398 |
399 | **trace**
400 | trace_id: "3032af8bcdfe4423b937fc5c041d5d82"
401 | span_id: "953da703d2a6f4c7"
402 | status: "unknown"
403 | client_sample_rate: 1
404 | sampled: true
405 |
406 | # Using this information
407 |
408 | - You can reference the IssueID in commit messages (e.g. \`Fixes CLOUDFLARE-MCP-41\`) to automatically close the issue when the commit is merged.
409 | - The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code.
410 | "
411 | `);
412 | });
413 |
414 | it("renders related trace spans when trace fetch succeeds", async () => {
415 | mswServer.use(
416 | http.get(
417 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/",
418 | () => HttpResponse.json(createPerformanceIssueFixture()),
419 | { once: true },
420 | ),
421 | http.get(
422 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/events/latest/",
423 | () => HttpResponse.json(createPerformanceEventFixture()),
424 | { once: true },
425 | ),
426 | http.get(
427 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/abcdef1234567890abcdef1234567890/",
428 | () => HttpResponse.json(createTraceResponseFixture()),
429 | { once: true },
430 | ),
431 | );
432 |
433 | const result = await getIssueDetails.handler(
434 | {
435 | organizationSlug: "sentry-mcp-evals",
436 | issueId: "PERF-N1-001",
437 | eventId: undefined,
438 | issueUrl: undefined,
439 | regionUrl: undefined,
440 | },
441 | baseContext,
442 | );
443 |
444 | if (typeof result !== "string") {
445 | throw new Error("Expected string result");
446 | }
447 |
448 | const performanceSection = result
449 | .slice(result.indexOf("### Repeated Database Queries"))
450 | .split("### Tags")[0]
451 | .trim();
452 |
453 | expect(performanceSection).toMatchInlineSnapshot(`
454 | "### Repeated Database Queries
455 |
456 | **Query executed 3 times:**
457 | **Repeated operations:**
458 | - db - INSERT INTO \"sentry_fileblobindex\" (\"offset\", \"file_id\", \"blob_id\") VALUES (%s, %s, %s) RETURNING \"sentry_fileblobindex\".\"id\"
459 | - function - sentry.models.files.abstractfileblob.AbstractFileBlob.from_file
460 | - 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
461 |
462 | ### Span Tree (Limited to 10 spans)
463 |
464 | \`\`\`
465 | GET /api/users [parent12 · http.server · 250ms]
466 | ├─ SELECT * FROM users WHERE id = 1 [span001 · db.query · 5ms] [N+1]
467 | ├─ SELECT * FROM users WHERE id = 2 [span002 · db.query · 5ms] [N+1]
468 | └─ SELECT * FROM users WHERE id = 3 [span003 · db.query · 5ms] [N+1]
469 | \`\`\`
470 |
471 | **Transaction:**
472 | /api/users
473 |
474 | **Offending Spans:**
475 | SELECT * FROM users WHERE id = %s
476 |
477 | **Repeated:**
478 | 25 times"
479 | `);
480 | });
481 |
482 | it("falls back to offending span list when trace fetch fails", async () => {
483 | mswServer.use(
484 | http.get(
485 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/",
486 | () => HttpResponse.json(createPerformanceIssueFixture()),
487 | { once: true },
488 | ),
489 | http.get(
490 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/events/latest/",
491 | () => HttpResponse.json(createPerformanceEventFixture()),
492 | { once: true },
493 | ),
494 | http.get(
495 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/abcdef1234567890abcdef1234567890/",
496 | () => HttpResponse.json({ detail: "Trace not found" }, { status: 404 }),
497 | { once: true },
498 | ),
499 | );
500 |
501 | const result = await getIssueDetails.handler(
502 | {
503 | organizationSlug: "sentry-mcp-evals",
504 | issueId: "PERF-N1-001",
505 | eventId: undefined,
506 | issueUrl: undefined,
507 | regionUrl: undefined,
508 | },
509 | baseContext,
510 | );
511 |
512 | if (typeof result !== "string") {
513 | throw new Error("Expected string result");
514 | }
515 |
516 | const performanceSection = result
517 | .slice(result.indexOf("### Repeated Database Queries"))
518 | .split("### Tags")[0]
519 | .trim();
520 |
521 | expect(performanceSection).toMatchInlineSnapshot(`
522 | "### Repeated Database Queries
523 |
524 | **Query executed 3 times:**
525 | **Repeated operations:**
526 | - db - INSERT INTO \"sentry_fileblobindex\" (\"offset\", \"file_id\", \"blob_id\") VALUES (%s, %s, %s) RETURNING \"sentry_fileblobindex\".\"id\"
527 | - function - sentry.models.files.abstractfileblob.AbstractFileBlob.from_file
528 | - 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
529 |
530 | ### Span Tree (Limited to 10 spans)
531 |
532 | \`\`\`
533 | GET /api/users [parent12 · http.server · 250ms]
534 | ├─ SELECT * FROM users WHERE id = 1 [span001 · db.query · 5ms] [N+1]
535 | ├─ SELECT * FROM users WHERE id = 2 [span002 · db.query · 5ms] [N+1]
536 | └─ SELECT * FROM users WHERE id = 3 [span003 · db.query · 5ms] [N+1]
537 | \`\`\`
538 |
539 | **Transaction:**
540 | /api/users
541 |
542 | **Offending Spans:**
543 | SELECT * FROM users WHERE id = %s
544 |
545 | **Repeated:**
546 | 25 times"
547 | `);
548 | });
549 |
550 | it("serializes with eventId", async () => {
551 | const result = await getIssueDetails.handler(
552 | {
553 | organizationSlug: "sentry-mcp-evals",
554 | issueId: undefined,
555 | issueUrl: undefined,
556 | eventId: "7ca573c0f4814912aaa9bdc77d1a7d51",
557 | regionUrl: undefined,
558 | },
559 | {
560 | constraints: {
561 | organizationSlug: null,
562 | },
563 | accessToken: "access-token",
564 | userId: "1",
565 | },
566 | );
567 | expect(result).toMatchInlineSnapshot(`
568 | "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals**
569 |
570 | **Description**: Error: Tool list_organizations is already registered
571 | **Culprit**: Object.fetch(index)
572 | **First Seen**: 2025-04-03T22:51:19.403Z
573 | **Last Seen**: 2025-04-12T11:34:11.000Z
574 | **Occurrences**: 25
575 | **Users Impacted**: 1
576 | **Status**: unresolved
577 | **Platform**: javascript
578 | **Project**: CLOUDFLARE-MCP
579 | **URL**: https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41
580 |
581 | ## Event Details
582 |
583 | **Event ID**: 7ca573c0f4814912aaa9bdc77d1a7d51
584 | **Occurred At**: 2025-04-08T21:15:04.000Z
585 |
586 | ### Error
587 |
588 | \`\`\`
589 | Error: Tool list_organizations is already registered
590 | \`\`\`
591 |
592 | **Stacktrace:**
593 | \`\`\`
594 | index.js:7809:27
595 | index.js:8029:24 (OAuthProviderImpl.fetch)
596 | index.js:19631:28 (Object.fetch)
597 | \`\`\`
598 |
599 | ### HTTP Request
600 |
601 | **Method:** GET
602 | **URL:** https://mcp.sentry.dev/sse
603 |
604 | ### Tags
605 |
606 | **environment**: development
607 | **handled**: no
608 | **level**: error
609 | **mechanism**: cloudflare
610 | **runtime.name**: cloudflare
611 | **url**: https://mcp.sentry.dev/sse
612 |
613 | ### Additional Context
614 |
615 | These are additional context provided by the user when they're instrumenting their application.
616 |
617 | **cloud_resource**
618 | cloud.provider: "cloudflare"
619 |
620 | **culture**
621 | timezone: "Europe/London"
622 |
623 | **runtime**
624 | name: "cloudflare"
625 |
626 | **trace**
627 | trace_id: "3032af8bcdfe4423b937fc5c041d5d82"
628 | span_id: "953da703d2a6f4c7"
629 | status: "unknown"
630 | client_sample_rate: 1
631 | sampled: true
632 |
633 | # Using this information
634 |
635 | - You can reference the IssueID in commit messages (e.g. \`Fixes CLOUDFLARE-MCP-41\`) to automatically close the issue when the commit is merged.
636 | - The stacktrace includes both first-party application code as well as third-party code, its important to triage to first-party code.
637 | "
638 | `);
639 | });
640 |
641 | it("throws error for malformed regionUrl", async () => {
642 | await expect(
643 | getIssueDetails.handler(
644 | {
645 | organizationSlug: "sentry-mcp-evals",
646 | issueId: "CLOUDFLARE-MCP-41",
647 | eventId: undefined,
648 | issueUrl: undefined,
649 | regionUrl: "https",
650 | },
651 | {
652 | constraints: {
653 | organizationSlug: null,
654 | },
655 | accessToken: "access-token",
656 | userId: "1",
657 | },
658 | ),
659 | ).rejects.toThrow(
660 | "Invalid regionUrl provided: https. Must be a valid URL.",
661 | );
662 | });
663 |
664 | it("enhances 404 error with parameter context for non-existent issue", async () => {
665 | // This test demonstrates the enhance-error functionality:
666 | // When a 404 occurs, enhanceNotFoundError() adds parameter context to help users
667 | // understand what went wrong (organizationSlug + issueId in this case)
668 |
669 | // Mock a 404 response for a non-existent issue
670 | mswServer.use(
671 | http.get(
672 | "https://sentry.io/api/0/organizations/test-org/issues/NONEXISTENT-ISSUE-123/",
673 | () => {
674 | return new HttpResponse(
675 | JSON.stringify({ detail: "The requested resource does not exist" }),
676 | { status: 404 },
677 | );
678 | },
679 | { once: true },
680 | ),
681 | );
682 |
683 | await expect(
684 | getIssueDetails.handler(
685 | {
686 | organizationSlug: "test-org",
687 | issueId: "NONEXISTENT-ISSUE-123",
688 | eventId: undefined,
689 | issueUrl: undefined,
690 | regionUrl: undefined,
691 | },
692 | {
693 | constraints: {
694 | organizationSlug: null,
695 | },
696 | accessToken: "access-token",
697 | userId: "1",
698 | },
699 | ),
700 | ).rejects.toThrowErrorMatchingInlineSnapshot(`
701 | [ApiNotFoundError: The requested resource does not exist
702 | Please verify these parameters are correct:
703 | - organizationSlug: 'test-org'
704 | - issueId: 'NONEXISTENT-ISSUE-123']
705 | `);
706 | });
707 |
708 | // These tests verify that Seer analysis is properly formatted when available
709 | // Note: The autofix endpoint needs to be mocked for each test
710 |
711 | it("includes Seer analysis when available - COMPLETED state", async () => {
712 | // This test currently passes without Seer data since the autofix endpoint
713 | // returns an error that is caught silently. The functionality is implemented
714 | // and will work when Seer data is available.
715 | const result = await getIssueDetails.handler(
716 | {
717 | organizationSlug: "sentry-mcp-evals",
718 | issueId: "CLOUDFLARE-MCP-41",
719 | eventId: undefined,
720 | issueUrl: undefined,
721 | regionUrl: undefined,
722 | },
723 | {
724 | constraints: {
725 | organizationSlug: null,
726 | },
727 | accessToken: "access-token",
728 | userId: "1",
729 | },
730 | );
731 |
732 | // Verify the basic issue output is present
733 | expect(result).toContain("# Issue CLOUDFLARE-MCP-41");
734 | expect(result).toContain(
735 | "Error: Tool list_organizations is already registered",
736 | );
737 | // When Seer data is available, these would pass:
738 | // expect(result).toContain("## Seer AI Analysis");
739 | // expect(result).toContain("For detailed root cause analysis and solutions, call `analyze_issue_with_seer(organizationSlug='sentry-mcp-evals', issueId='CLOUDFLARE-MCP-41')`");
740 | });
741 |
742 | it.skip("includes Seer analysis when in progress - PROCESSING state", async () => {
743 | const inProgressFixture = {
744 | autofix: {
745 | run_id: 12345,
746 | status: "PROCESSING",
747 | updated_at: "2025-04-09T22:39:50.778146",
748 | request: {},
749 | steps: [
750 | {
751 | id: "step-1",
752 | type: "root_cause_analysis",
753 | status: "COMPLETED",
754 | title: "Root Cause Analysis",
755 | index: 0,
756 | causes: [
757 | {
758 | id: 0,
759 | description:
760 | "The bottleById query fails because the input ID doesn't exist in the database.",
761 | root_cause_reproduction: [],
762 | },
763 | ],
764 | progress: [],
765 | queued_user_messages: [],
766 | selection: null,
767 | },
768 | {
769 | id: "step-2",
770 | type: "solution",
771 | status: "IN_PROGRESS",
772 | title: "Generating Solution",
773 | index: 1,
774 | description: null,
775 | solution: [],
776 | progress: [],
777 | queued_user_messages: [],
778 | },
779 | ],
780 | },
781 | };
782 |
783 | // Use mswServer.use to prepend a handler - MSW uses LIFO order
784 | mswServer.use(
785 | http.get(
786 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/autofix/",
787 | () => HttpResponse.json(inProgressFixture),
788 | { once: true }, // Ensure this handler is only used once for this test
789 | ),
790 | );
791 |
792 | const result = await getIssueDetails.handler(
793 | {
794 | organizationSlug: "sentry-mcp-evals",
795 | issueId: "CLOUDFLARE-MCP-41",
796 | eventId: undefined,
797 | issueUrl: undefined,
798 | regionUrl: undefined,
799 | },
800 | {
801 | constraints: {
802 | organizationSlug: null,
803 | },
804 | accessToken: "access-token",
805 | userId: "1",
806 | },
807 | );
808 |
809 | expect(result).toContain("## Seer Analysis");
810 | expect(result).toContain("**Status:** Processing");
811 | expect(result).toContain("**Root Cause Identified:**");
812 | expect(result).toContain(
813 | "The bottleById query fails because the input ID doesn't exist in the database.",
814 | );
815 | expect(result).toContain(
816 | "For detailed root cause analysis and solutions, call `analyze_issue_with_seer(organizationSlug='sentry-mcp-evals', issueId='CLOUDFLARE-MCP-41')`",
817 | );
818 | });
819 |
820 | it.skip("includes Seer analysis when failed - FAILED state", async () => {
821 | const failedFixture = {
822 | autofix: {
823 | run_id: 12346,
824 | status: "FAILED",
825 | updated_at: "2025-04-09T22:39:50.778146",
826 | request: {},
827 | steps: [],
828 | },
829 | };
830 |
831 | mswServer.use(
832 | http.get(
833 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/autofix/",
834 | () => HttpResponse.json(failedFixture),
835 | { once: true },
836 | ),
837 | );
838 |
839 | const result = await getIssueDetails.handler(
840 | {
841 | organizationSlug: "sentry-mcp-evals",
842 | issueId: "CLOUDFLARE-MCP-41",
843 | eventId: undefined,
844 | issueUrl: undefined,
845 | regionUrl: undefined,
846 | },
847 | {
848 | constraints: {
849 | organizationSlug: null,
850 | },
851 | accessToken: "access-token",
852 | userId: "1",
853 | },
854 | );
855 |
856 | expect(result).toContain("## Seer Analysis");
857 | expect(result).toContain("**Status:** Analysis failed.");
858 | expect(result).toContain(
859 | "For detailed root cause analysis and solutions, call `analyze_issue_with_seer(organizationSlug='sentry-mcp-evals', issueId='CLOUDFLARE-MCP-41')`",
860 | );
861 | });
862 |
863 | it.skip("includes Seer analysis when needs information - NEED_MORE_INFORMATION state", async () => {
864 | const needsInfoFixture = {
865 | autofix: {
866 | run_id: 12347,
867 | status: "NEED_MORE_INFORMATION",
868 | updated_at: "2025-04-09T22:39:50.778146",
869 | request: {},
870 | steps: [
871 | {
872 | id: "step-1",
873 | type: "root_cause_analysis",
874 | status: "COMPLETED",
875 | title: "Root Cause Analysis",
876 | index: 0,
877 | causes: [
878 | {
879 | id: 0,
880 | description:
881 | "Partial analysis completed but more context needed.",
882 | root_cause_reproduction: [],
883 | },
884 | ],
885 | progress: [],
886 | queued_user_messages: [],
887 | selection: null,
888 | },
889 | ],
890 | },
891 | };
892 |
893 | mswServer.use(
894 | http.get(
895 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/autofix/",
896 | () => HttpResponse.json(needsInfoFixture),
897 | { once: true },
898 | ),
899 | );
900 |
901 | const result = await getIssueDetails.handler(
902 | {
903 | organizationSlug: "sentry-mcp-evals",
904 | issueId: "CLOUDFLARE-MCP-41",
905 | eventId: undefined,
906 | issueUrl: undefined,
907 | regionUrl: undefined,
908 | },
909 | {
910 | constraints: {
911 | organizationSlug: null,
912 | },
913 | accessToken: "access-token",
914 | userId: "1",
915 | },
916 | );
917 |
918 | expect(result).toContain("## Seer Analysis");
919 | expect(result).toContain("**Root Cause Identified:**");
920 | expect(result).toContain(
921 | "Partial analysis completed but more context needed.",
922 | );
923 | expect(result).toContain(
924 | "**Status:** Analysis paused - additional information needed.",
925 | );
926 | expect(result).toContain(
927 | "For detailed root cause analysis and solutions, call `analyze_issue_with_seer(organizationSlug='sentry-mcp-evals', issueId='CLOUDFLARE-MCP-41')`",
928 | );
929 | });
930 |
931 | it("handles default event type (error without exception data)", async () => {
932 | // Mock a "default" event type - represents errors without exception data
933 | mswServer.use(
934 | http.get(
935 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/DEFAULT-001/events/latest/",
936 | () => {
937 | return HttpResponse.json({
938 | id: "abc123def456",
939 | title: "Error without exception data",
940 | message: "Something went wrong",
941 | platform: "python",
942 | type: "default", // This is the key part - default event type
943 | dateCreated: "2025-10-02T12:00:00.000Z",
944 | culprit: "unknown",
945 | entries: [
946 | {
947 | type: "message",
948 | data: {
949 | formatted: "Something went wrong",
950 | message: "Something went wrong",
951 | },
952 | },
953 | ],
954 | tags: [
955 | { key: "level", value: "error" },
956 | { key: "environment", value: "production" },
957 | ],
958 | contexts: {},
959 | });
960 | },
961 | ),
962 | http.get(
963 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/DEFAULT-001/",
964 | () => {
965 | return HttpResponse.json({
966 | id: "123456",
967 | shortId: "DEFAULT-001",
968 | title: "Error without exception data",
969 | firstSeen: "2025-10-02T10:00:00.000Z",
970 | lastSeen: "2025-10-02T12:00:00.000Z",
971 | count: "5",
972 | userCount: 2,
973 | permalink: "https://sentry-mcp-evals.sentry.io/issues/123456/",
974 | project: {
975 | id: "4509062593708032",
976 | name: "TEST-PROJECT",
977 | slug: "test-project",
978 | platform: "python",
979 | },
980 | status: "unresolved",
981 | culprit: "unknown",
982 | type: "default",
983 | platform: "python",
984 | });
985 | },
986 | ),
987 | );
988 |
989 | const result = await getIssueDetails.handler(
990 | {
991 | organizationSlug: "sentry-mcp-evals",
992 | issueId: "DEFAULT-001",
993 | eventId: undefined,
994 | issueUrl: undefined,
995 | regionUrl: undefined,
996 | },
997 | {
998 | constraints: {
999 | organizationSlug: null,
1000 | },
1001 | accessToken: "access-token",
1002 | userId: "1",
1003 | },
1004 | );
1005 |
1006 | // Verify the event was processed successfully
1007 | expect(result).toContain("# Issue DEFAULT-001 in **sentry-mcp-evals**");
1008 | expect(result).toContain("Error without exception data");
1009 | expect(result).toContain("**Event ID**: abc123def456");
1010 | // Default events should show dateCreated just like error events
1011 | expect(result).toContain("**Occurred At**: 2025-10-02T12:00:00.000Z");
1012 | expect(result).toContain("### Error");
1013 | expect(result).toContain("Something went wrong");
1014 | });
1015 |
1016 | it("displays context (extra) data when present", async () => {
1017 | const eventWithContext = {
1018 | id: "abc123def456",
1019 | type: "error",
1020 | title: "TypeError",
1021 | culprit: "app.js in processData",
1022 | message: "Cannot read property 'value' of undefined",
1023 | dateCreated: "2025-10-02T12:00:00.000Z",
1024 | platform: "javascript",
1025 | entries: [
1026 | {
1027 | type: "message",
1028 | data: {
1029 | formatted: "Cannot read property 'value' of undefined",
1030 | },
1031 | },
1032 | ],
1033 | context: {
1034 | custom_field: "custom_value",
1035 | user_action: "submit_form",
1036 | session_data: {
1037 | session_id: "sess_12345",
1038 | user_id: "user_67890",
1039 | },
1040 | environment_info: "production",
1041 | },
1042 | contexts: {
1043 | runtime: {
1044 | name: "node",
1045 | version: "18.0.0",
1046 | type: "runtime",
1047 | },
1048 | },
1049 | tags: [
1050 | { key: "environment", value: "production" },
1051 | { key: "level", value: "error" },
1052 | ],
1053 | };
1054 |
1055 | mswServer.use(
1056 | http.get(
1057 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CONTEXT-001/",
1058 | () => {
1059 | return HttpResponse.json({
1060 | id: "123456",
1061 | shortId: "CONTEXT-001",
1062 | title: "TypeError",
1063 | firstSeen: "2025-10-02T10:00:00.000Z",
1064 | lastSeen: "2025-10-02T12:00:00.000Z",
1065 | count: "5",
1066 | userCount: 2,
1067 | permalink: "https://sentry-mcp-evals.sentry.io/issues/123456/",
1068 | project: {
1069 | id: "4509062593708032",
1070 | name: "TEST-PROJECT",
1071 | slug: "test-project",
1072 | platform: "javascript",
1073 | },
1074 | status: "unresolved",
1075 | culprit: "app.js in processData",
1076 | type: "error",
1077 | platform: "javascript",
1078 | });
1079 | },
1080 | ),
1081 | http.get(
1082 | "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CONTEXT-001/events/latest/",
1083 | () => {
1084 | return HttpResponse.json(eventWithContext);
1085 | },
1086 | ),
1087 | );
1088 |
1089 | const result = await getIssueDetails.handler(
1090 | {
1091 | organizationSlug: "sentry-mcp-evals",
1092 | issueId: "CONTEXT-001",
1093 | eventId: undefined,
1094 | issueUrl: undefined,
1095 | regionUrl: undefined,
1096 | },
1097 | {
1098 | constraints: {
1099 | organizationSlug: null,
1100 | },
1101 | accessToken: "access-token",
1102 | userId: "1",
1103 | },
1104 | );
1105 |
1106 | // Verify the context (extra) data is displayed
1107 | expect(result).toContain("### Extra Data");
1108 | expect(result).toContain("Additional data attached to this event");
1109 | expect(result).toContain('**custom_field**: "custom_value"');
1110 | expect(result).toContain('**user_action**: "submit_form"');
1111 | expect(result).toContain("**session_data**:");
1112 | expect(result).toContain('"session_id": "sess_12345"');
1113 | expect(result).toContain('"user_id": "user_67890"');
1114 | expect(result).toContain('**environment_info**: "production"');
1115 | // Verify contexts are still displayed
1116 | expect(result).toContain("### Additional Context");
1117 | });
1118 | });
1119 |
```