#
tokens: 45711/50000 10/408 files (page 9/15)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 9 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/tools/get-trace-details.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect } from "vitest";
  2 | import { http, HttpResponse } from "msw";
  3 | import {
  4 |   mswServer,
  5 |   traceMetaFixture,
  6 |   traceMetaWithNullsFixture,
  7 |   traceFixture,
  8 |   traceMixedFixture,
  9 | } from "@sentry/mcp-server-mocks";
 10 | import getTraceDetails from "./get-trace-details.js";
 11 | 
 12 | describe("get_trace_details", () => {
 13 |   it("serializes with valid trace ID", async () => {
 14 |     const result = await getTraceDetails.handler(
 15 |       {
 16 |         organizationSlug: "sentry-mcp-evals",
 17 |         traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
 18 |         regionUrl: undefined,
 19 |       },
 20 |       {
 21 |         constraints: {
 22 |           organizationSlug: null,
 23 |         },
 24 |         accessToken: "access-token",
 25 |         userId: "1",
 26 |       },
 27 |     );
 28 |     expect(result).toMatchInlineSnapshot(`
 29 |       "# Trace \`a4d1aae7216b47ff8117cf4e09ce9d0a\` in **sentry-mcp-evals**
 30 | 
 31 |       ## Summary
 32 | 
 33 |       **Total Spans**: 112
 34 |       **Errors**: 0
 35 |       **Performance Issues**: 0
 36 |       **Logs**: 0
 37 | 
 38 |       ## Operation Breakdown
 39 | 
 40 |       - **db**: 90 spans (avg: 16ms, p95: 13ms)
 41 |       - **feature.flagpole.batch_has**: 30 spans (avg: 18ms, p95: 32ms)
 42 |       - **function**: 14 spans (avg: 303ms, p95: 1208ms)
 43 |       - **http.client**: 2 spans (avg: 1223ms, p95: 1708ms)
 44 |       - **other**: 1 spans (avg: 6ms, p95: 6ms)
 45 | 
 46 |       ## Overview
 47 | 
 48 |       trace [a4d1aae7]
 49 |          └─ tools/call search_events [aa8e7f33 · 5203ms]
 50 |             ├─ POST https://api.openai.com/v1/chat/completions [ad0f7c48 · http.client · 1708ms]
 51 |             └─ GET https://us.sentry.io/api/0/organizations/example-org/events/ [b4abfe5e · http.client · 1482ms]
 52 |                └─ /api/0/organizations/{organization_id_or_slug}/events/ [99a97a1d · http.server · 1408ms]
 53 | 
 54 |       *Note: This shows a subset of spans. View the full trace for complete details.*
 55 | 
 56 |       ## View Full Trace
 57 | 
 58 |       **Sentry URL**: https://sentry-mcp-evals.sentry.io/explore/traces/trace/a4d1aae7216b47ff8117cf4e09ce9d0a
 59 | 
 60 |       ## Find Related Events
 61 | 
 62 |       Use this search query to find all events in this trace:
 63 |       \`\`\`
 64 |       trace:a4d1aae7216b47ff8117cf4e09ce9d0a
 65 |       \`\`\`
 66 | 
 67 |       You can use this query with the \`search_events\` tool to get detailed event data from this trace."
 68 |     `);
 69 |   });
 70 | 
 71 |   it("serializes with fixed stats period", async () => {
 72 |     const result = await getTraceDetails.handler(
 73 |       {
 74 |         organizationSlug: "sentry-mcp-evals",
 75 |         traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
 76 |         regionUrl: undefined,
 77 |       },
 78 |       {
 79 |         constraints: {
 80 |           organizationSlug: null,
 81 |         },
 82 |         accessToken: "access-token",
 83 |         userId: "1",
 84 |       },
 85 |     );
 86 |     expect(result).toContain(
 87 |       "Trace `a4d1aae7216b47ff8117cf4e09ce9d0a` in **sentry-mcp-evals**",
 88 |     );
 89 |     expect(result).toContain("**Total Spans**: 112");
 90 |     expect(result).toContain("trace:a4d1aae7216b47ff8117cf4e09ce9d0a");
 91 |   });
 92 | 
 93 |   it("handles trace not found error", async () => {
 94 |     mswServer.use(
 95 |       http.get(
 96 |         "https://sentry.io/api/0/organizations/sentry/trace-meta/nonexistent/",
 97 |         () => {
 98 |           return new HttpResponse(null, { status: 404 });
 99 |         },
100 |       ),
101 |     );
102 | 
103 |     await expect(
104 |       getTraceDetails.handler(
105 |         {
106 |           organizationSlug: "sentry",
107 |           traceId: "nonexistent",
108 |           regionUrl: undefined,
109 |         },
110 |         {
111 |           constraints: {
112 |             organizationSlug: null,
113 |           },
114 |           accessToken: "access-token",
115 |           userId: "1",
116 |         },
117 |       ),
118 |     ).rejects.toThrow();
119 |   });
120 | 
121 |   it("validates trace ID format", async () => {
122 |     await expect(
123 |       getTraceDetails.handler(
124 |         {
125 |           organizationSlug: "sentry-mcp-evals",
126 |           traceId: "invalid-trace-id", // Too short, not hex
127 |           regionUrl: undefined,
128 |         },
129 |         {
130 |           constraints: {
131 |             organizationSlug: null,
132 |           },
133 |           accessToken: "access-token",
134 |           userId: "1",
135 |         },
136 |       ),
137 |     ).rejects.toThrow("Trace ID must be a 32-character hexadecimal string");
138 |   });
139 | 
140 |   it("handles empty trace response", async () => {
141 |     mswServer.use(
142 |       http.get(
143 |         "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace-meta/a4d1aae7216b47ff8117cf4e09ce9d0a/",
144 |         () => {
145 |           return HttpResponse.json({
146 |             logs: 0,
147 |             errors: 0,
148 |             performance_issues: 0,
149 |             span_count: 0,
150 |             transaction_child_count_map: [],
151 |             span_count_map: {},
152 |           });
153 |         },
154 |       ),
155 |       http.get(
156 |         "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/a4d1aae7216b47ff8117cf4e09ce9d0a/",
157 |         () => {
158 |           return HttpResponse.json([]);
159 |         },
160 |       ),
161 |     );
162 | 
163 |     const result = await getTraceDetails.handler(
164 |       {
165 |         organizationSlug: "sentry-mcp-evals",
166 |         traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
167 |         regionUrl: undefined,
168 |       },
169 |       {
170 |         constraints: {
171 |           organizationSlug: null,
172 |         },
173 |         accessToken: "access-token",
174 |         userId: "1",
175 |       },
176 |     );
177 | 
178 |     expect(result).toContain("**Total Spans**: 0");
179 |     expect(result).toContain("**Errors**: 0");
180 |     expect(result).toContain("## Summary");
181 |     expect(result).not.toContain("## Operation Breakdown");
182 |     expect(result).not.toContain("## Overview");
183 |   });
184 | 
185 |   it("handles API error gracefully", async () => {
186 |     mswServer.use(
187 |       http.get(
188 |         "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace-meta/a4d1aae7216b47ff8117cf4e09ce9d0a/",
189 |         () => {
190 |           return new HttpResponse(
191 |             JSON.stringify({ detail: "Organization not found" }),
192 |             { status: 404 },
193 |           );
194 |         },
195 |       ),
196 |     );
197 | 
198 |     await expect(
199 |       getTraceDetails.handler(
200 |         {
201 |           organizationSlug: "sentry-mcp-evals",
202 |           traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
203 |           regionUrl: undefined,
204 |         },
205 |         {
206 |           constraints: {
207 |             organizationSlug: null,
208 |           },
209 |           accessToken: "access-token",
210 |           userId: "1",
211 |         },
212 |       ),
213 |     ).rejects.toThrow();
214 |   });
215 | 
216 |   it("works with regional URL override", async () => {
217 |     mswServer.use(
218 |       http.get(
219 |         "https://us.sentry.io/api/0/organizations/sentry-mcp-evals/trace-meta/a4d1aae7216b47ff8117cf4e09ce9d0a/",
220 |         () => {
221 |           return HttpResponse.json(traceMetaFixture);
222 |         },
223 |       ),
224 |       http.get(
225 |         "https://us.sentry.io/api/0/organizations/sentry-mcp-evals/trace/a4d1aae7216b47ff8117cf4e09ce9d0a/",
226 |         () => {
227 |           return HttpResponse.json(traceFixture);
228 |         },
229 |       ),
230 |     );
231 | 
232 |     const result = await getTraceDetails.handler(
233 |       {
234 |         organizationSlug: "sentry-mcp-evals",
235 |         traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
236 |         regionUrl: "https://us.sentry.io",
237 |       },
238 |       {
239 |         constraints: {
240 |           organizationSlug: null,
241 |         },
242 |         accessToken: "access-token",
243 |         userId: "1",
244 |       },
245 |     );
246 | 
247 |     expect(result).toContain(
248 |       "Trace `a4d1aae7216b47ff8117cf4e09ce9d0a` in **sentry-mcp-evals**",
249 |     );
250 |     expect(result).toContain("**Total Spans**: 112");
251 |   });
252 | 
253 |   it("handles trace meta with null transaction.event_id values", async () => {
254 |     mswServer.use(
255 |       http.get(
256 |         "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace-meta/a4d1aae7216b47ff8117cf4e09ce9d0a/",
257 |         () => {
258 |           return HttpResponse.json(traceMetaWithNullsFixture);
259 |         },
260 |       ),
261 |       http.get(
262 |         "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/a4d1aae7216b47ff8117cf4e09ce9d0a/",
263 |         () => {
264 |           return HttpResponse.json(traceFixture);
265 |         },
266 |       ),
267 |     );
268 | 
269 |     const result = await getTraceDetails.handler(
270 |       {
271 |         organizationSlug: "sentry-mcp-evals",
272 |         traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a",
273 |         regionUrl: undefined,
274 |       },
275 |       {
276 |         constraints: {
277 |           organizationSlug: null,
278 |         },
279 |         accessToken: "access-token",
280 |         userId: "1",
281 |       },
282 |     );
283 | 
284 |     // The handler should successfully process the response with null values
285 |     expect(result).toContain(
286 |       "Trace `a4d1aae7216b47ff8117cf4e09ce9d0a` in **sentry-mcp-evals**",
287 |     );
288 |     expect(result).toContain("**Total Spans**: 85");
289 |     expect(result).toContain("**Errors**: 2");
290 |     // The null transaction.event_id entries should be handled gracefully
291 |     // and the trace should still be processed successfully
292 |     expect(result).not.toContain("null");
293 |   });
294 | 
295 |   it("handles mixed span/issue arrays in trace responses", async () => {
296 |     mswServer.use(
297 |       http.get(
298 |         "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace-meta/b4d1aae7216b47ff8117cf4e09ce9d0b/",
299 |         () => {
300 |           return HttpResponse.json({
301 |             logs: 0,
302 |             errors: 2,
303 |             performance_issues: 0,
304 |             span_count: 4,
305 |             transaction_child_count_map: [],
306 |             span_count_map: {},
307 |           });
308 |         },
309 |       ),
310 |       http.get(
311 |         "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace/b4d1aae7216b47ff8117cf4e09ce9d0b/",
312 |         () => {
313 |           return HttpResponse.json(traceMixedFixture);
314 |         },
315 |       ),
316 |     );
317 | 
318 |     const result = await getTraceDetails.handler(
319 |       {
320 |         organizationSlug: "sentry-mcp-evals",
321 |         traceId: "b4d1aae7216b47ff8117cf4e09ce9d0b",
322 |         regionUrl: undefined,
323 |       },
324 |       {
325 |         constraints: {
326 |           organizationSlug: null,
327 |         },
328 |         accessToken: "access-token",
329 |         userId: "1",
330 |       },
331 |     );
332 | 
333 |     expect(result).toMatchInlineSnapshot(`
334 |       "# Trace \`b4d1aae7216b47ff8117cf4e09ce9d0b\` in **sentry-mcp-evals**
335 | 
336 |       ## Summary
337 | 
338 |       **Total Spans**: 4
339 |       **Errors**: 2
340 |       **Performance Issues**: 0
341 |       **Logs**: 0
342 | 
343 |       ## Operation Breakdown
344 | 
345 |       - **http.client**: 1 spans (avg: 1708ms, p95: 1708ms)
346 |       - **http.server**: 1 spans (avg: 1408ms, p95: 1408ms)
347 | 
348 |       ## Overview
349 | 
350 |       trace [b4d1aae7]
351 |          ├─ tools/call search_events [aa8e7f33 · function · 5203ms]
352 |          │  └─ POST https://api.openai.com/v1/chat/completions [aa8e7f33 · http.client · 1708ms]
353 |          └─ GET https://us.sentry.io/api/0/organizations/example-org/events/ [b4abfe5e · http.client · 1482ms]
354 |             └─ /api/0/organizations/{organization_id_or_slug}/events/ [b4abfe5e · http.server · 1408ms]
355 | 
356 |       *Note: This shows a subset of spans. View the full trace for complete details.*
357 | 
358 |       ## View Full Trace
359 | 
360 |       **Sentry URL**: https://sentry-mcp-evals.sentry.io/explore/traces/trace/b4d1aae7216b47ff8117cf4e09ce9d0b
361 | 
362 |       ## Find Related Events
363 | 
364 |       Use this search query to find all events in this trace:
365 |       \`\`\`
366 |       trace:b4d1aae7216b47ff8117cf4e09ce9d0b
367 |       \`\`\`
368 | 
369 |       You can use this query with the \`search_events\` tool to get detailed event data from this trace."
370 |     `);
371 |   });
372 | });
373 | 
```

--------------------------------------------------------------------------------
/packages/mcp-test-client/src/auth/oauth.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { randomBytes, createHash } from "node:crypto";
  2 | import { URL } from "node:url";
  3 | import { createServer, type Server } from "node:http";
  4 | import open from "open";
  5 | import chalk from "chalk";
  6 | import {
  7 |   OAUTH_REDIRECT_PORT,
  8 |   OAUTH_REDIRECT_URI,
  9 |   DEFAULT_OAUTH_SCOPES,
 10 | } from "../constants.js";
 11 | import { logInfo, logSuccess, logToolResult, logError } from "../logger.js";
 12 | import { ConfigManager } from "./config.js";
 13 | 
 14 | export interface OAuthConfig {
 15 |   mcpHost: string;
 16 |   scopes?: string[];
 17 | }
 18 | 
 19 | export interface TokenResponse {
 20 |   access_token: string;
 21 |   token_type: string;
 22 |   expires_in?: number;
 23 |   refresh_token?: string;
 24 |   scope?: string;
 25 | }
 26 | 
 27 | export interface ClientRegistrationResponse {
 28 |   client_id: string;
 29 |   redirect_uris: string[];
 30 |   client_name?: string;
 31 |   client_uri?: string;
 32 |   grant_types?: string[];
 33 |   response_types?: string[];
 34 |   token_endpoint_auth_method?: string;
 35 |   registration_client_uri?: string;
 36 |   client_id_issued_at?: number;
 37 | }
 38 | 
 39 | export class OAuthClient {
 40 |   private config: OAuthConfig;
 41 |   private server: Server | null = null;
 42 |   private configManager: ConfigManager;
 43 | 
 44 |   constructor(config: OAuthConfig) {
 45 |     this.config = {
 46 |       ...config,
 47 |       scopes: config.scopes || DEFAULT_OAUTH_SCOPES,
 48 |     };
 49 |     this.configManager = new ConfigManager();
 50 |   }
 51 | 
 52 |   /**
 53 |    * Generate PKCE code verifier and challenge
 54 |    */
 55 |   private generatePKCE(): { verifier: string; challenge: string } {
 56 |     const verifier = randomBytes(32).toString("base64url");
 57 |     const challenge = createHash("sha256").update(verifier).digest("base64url");
 58 |     return { verifier, challenge };
 59 |   }
 60 | 
 61 |   /**
 62 |    * Generate random state for CSRF protection
 63 |    */
 64 |   private generateState(): string {
 65 |     return randomBytes(16).toString("base64url");
 66 |   }
 67 | 
 68 |   /**
 69 |    * Register the client with the OAuth server using Dynamic Client Registration
 70 |    */
 71 |   private async registerClient(): Promise<string> {
 72 |     const registrationUrl = `${this.config.mcpHost}/oauth/register`;
 73 | 
 74 |     const registrationData = {
 75 |       client_name: "Sentry MCP CLI",
 76 |       client_uri: "https://github.com/getsentry/sentry-mcp",
 77 |       redirect_uris: [OAUTH_REDIRECT_URI],
 78 |       grant_types: ["authorization_code"],
 79 |       response_types: ["code"],
 80 |       token_endpoint_auth_method: "none", // PKCE, no client secret
 81 |       scope: this.config.scopes!.join(" "),
 82 |     };
 83 | 
 84 |     const response = await fetch(registrationUrl, {
 85 |       method: "POST",
 86 |       headers: {
 87 |         "Content-Type": "application/json",
 88 |         Accept: "application/json",
 89 |       },
 90 |       body: JSON.stringify(registrationData),
 91 |     });
 92 | 
 93 |     if (!response.ok) {
 94 |       const error = await response.text();
 95 |       throw new Error(
 96 |         `Client registration failed: ${response.status} - ${error}`,
 97 |       );
 98 |     }
 99 | 
100 |     const registrationResponse =
101 |       (await response.json()) as ClientRegistrationResponse;
102 |     return registrationResponse.client_id;
103 |   }
104 | 
105 |   /**
106 |    * Start local server for OAuth callback
107 |    */
108 |   private async startCallbackServer(): Promise<{
109 |     waitForCallback: () => Promise<{ code: string; state: string }>;
110 |   }> {
111 |     return new Promise((resolve, reject) => {
112 |       let resolveCallback:
113 |         | ((value: { code: string; state: string }) => void)
114 |         | null = null;
115 |       let rejectCallback: ((error: Error) => void) | null = null;
116 | 
117 |       this.server = createServer((req, res) => {
118 |         if (!req.url) {
119 |           res.writeHead(400);
120 |           res.end("Bad Request");
121 |           return;
122 |         }
123 | 
124 |         const url = new URL(req.url, `http://localhost:${OAUTH_REDIRECT_PORT}`);
125 | 
126 |         if (url.pathname === "/callback") {
127 |           const code = url.searchParams.get("code");
128 |           const state = url.searchParams.get("state");
129 |           const error = url.searchParams.get("error");
130 | 
131 |           if (error) {
132 |             const errorDescription =
133 |               url.searchParams.get("error_description") || "Unknown error";
134 |             res.writeHead(400, { "Content-Type": "text/html" });
135 |             res.end(`
136 |               <!DOCTYPE html>
137 |               <html>
138 |               <head><title>Authentication Failed</title></head>
139 |               <body>
140 |                 <h1>Authentication Failed</h1>
141 |                 <p>Error: ${error}</p>
142 |                 <p>${errorDescription}</p>
143 |                 <p>You can close this window.</p>
144 |               </body>
145 |               </html>
146 |             `);
147 | 
148 |             if (rejectCallback) {
149 |               rejectCallback(
150 |                 new Error(`OAuth error: ${error} - ${errorDescription}`),
151 |               );
152 |             }
153 |             return;
154 |           }
155 | 
156 |           if (!code || !state) {
157 |             res.writeHead(400, { "Content-Type": "text/html" });
158 |             res.end(`
159 |               <!DOCTYPE html>
160 |               <html>
161 |               <head><title>Authentication Failed</title></head>
162 |               <body>
163 |                 <h1>Authentication Failed</h1>
164 |                 <p>Missing code or state parameter</p>
165 |                 <p>You can close this window.</p>
166 |               </body>
167 |               </html>
168 |             `);
169 | 
170 |             if (rejectCallback) {
171 |               rejectCallback(new Error("Missing code or state parameter"));
172 |             }
173 |             return;
174 |           }
175 | 
176 |           // Acknowledge the callback but don't show success yet
177 |           res.writeHead(200, { "Content-Type": "text/html" });
178 |           res.end(`
179 |             <!DOCTYPE html>
180 |             <html>
181 |             <head><title>Authentication in Progress</title></head>
182 |             <body>
183 |               <h1>Processing Authentication...</h1>
184 |               <p>Please wait while we complete the authentication process.</p>
185 |               <p>You can close this window and return to your terminal.</p>
186 |             </body>
187 |             </html>
188 |           `);
189 | 
190 |           if (resolveCallback) {
191 |             resolveCallback({ code, state });
192 |           }
193 |         } else {
194 |           res.writeHead(404);
195 |           res.end("Not Found");
196 |         }
197 |       });
198 | 
199 |       this.server.listen(OAUTH_REDIRECT_PORT, "127.0.0.1", () => {
200 |         const waitForCallback = () =>
201 |           new Promise<{ code: string; state: string }>((res, rej) => {
202 |             resolveCallback = res;
203 |             rejectCallback = rej;
204 |           });
205 | 
206 |         resolve({ waitForCallback });
207 |       });
208 | 
209 |       this.server.on("error", reject);
210 |     });
211 |   }
212 | 
213 |   /**
214 |    * Exchange authorization code for access token
215 |    */
216 |   private async exchangeCodeForToken(params: {
217 |     code: string;
218 |     codeVerifier: string;
219 |     clientId: string;
220 |   }): Promise<TokenResponse> {
221 |     const tokenUrl = `${this.config.mcpHost}/oauth/token`;
222 | 
223 |     const body = new URLSearchParams({
224 |       grant_type: "authorization_code",
225 |       client_id: params.clientId,
226 |       code: params.code,
227 |       redirect_uri: OAUTH_REDIRECT_URI,
228 |       code_verifier: params.codeVerifier,
229 |     });
230 | 
231 |     const response = await fetch(tokenUrl, {
232 |       method: "POST",
233 |       headers: {
234 |         "Content-Type": "application/x-www-form-urlencoded",
235 |         Accept: "application/json",
236 |         "User-Agent": "Sentry MCP CLI",
237 |       },
238 |       body: body.toString(),
239 |     });
240 | 
241 |     if (!response.ok) {
242 |       const error = await response.text();
243 |       throw new Error(`Token exchange failed: ${response.status} - ${error}`);
244 |     }
245 | 
246 |     return response.json() as Promise<TokenResponse>;
247 |   }
248 | 
249 |   /**
250 |    * Get or register OAuth client ID for the MCP host
251 |    */
252 |   private async getOrRegisterClientId(): Promise<string> {
253 |     // Check if we already have a registered client for this host
254 |     let clientId = await this.configManager.getOAuthClientId(
255 |       this.config.mcpHost,
256 |     );
257 | 
258 |     if (clientId) {
259 |       return clientId;
260 |     }
261 | 
262 |     // Register a new client
263 |     logInfo("Registering new OAuth client");
264 |     try {
265 |       clientId = await this.registerClient();
266 | 
267 |       // Store the client ID for future use
268 |       await this.configManager.setOAuthClientId(this.config.mcpHost, clientId);
269 | 
270 |       logSuccess("Client registered and saved");
271 |       logToolResult(clientId);
272 |       return clientId;
273 |     } catch (error) {
274 |       throw new Error(
275 |         `Client registration failed: ${error instanceof Error ? error.message : String(error)}`,
276 |       );
277 |     }
278 |   }
279 | 
280 |   /**
281 |    * Get cached access token or perform OAuth flow
282 |    */
283 |   async getAccessToken(): Promise<string> {
284 |     // Check for cached token first
285 |     const cachedToken = await this.configManager.getAccessToken(
286 |       this.config.mcpHost,
287 |     );
288 |     if (cachedToken) {
289 |       logInfo("Authenticated with Sentry", "using stored token");
290 |       return cachedToken;
291 |     }
292 | 
293 |     // No cached token, perform OAuth flow
294 |     return this.authenticate();
295 |   }
296 | 
297 |   /**
298 |    * Perform the OAuth flow
299 |    */
300 |   async authenticate(): Promise<string> {
301 |     // Get or register client ID
302 |     const clientId = await this.getOrRegisterClientId();
303 | 
304 |     // Start callback server
305 |     const { waitForCallback } = await this.startCallbackServer();
306 | 
307 |     // Generate PKCE and state
308 |     const { verifier, challenge } = this.generatePKCE();
309 |     const state = this.generateState();
310 | 
311 |     // Build authorization URL
312 |     const authUrl = new URL(`${this.config.mcpHost}/oauth/authorize`);
313 |     authUrl.searchParams.set("client_id", clientId);
314 |     authUrl.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI);
315 |     authUrl.searchParams.set("response_type", "code");
316 |     authUrl.searchParams.set("scope", this.config.scopes!.join(" "));
317 |     authUrl.searchParams.set("state", state);
318 |     authUrl.searchParams.set("code_challenge", challenge);
319 |     authUrl.searchParams.set("code_challenge_method", "S256");
320 | 
321 |     logInfo("Authenticating with Sentry - opening browser");
322 |     console.log(
323 |       chalk.gray("If your browser doesn't open automatically, visit:"),
324 |     );
325 |     console.log(chalk.white(authUrl.toString()));
326 | 
327 |     // Open browser
328 |     try {
329 |       await open(authUrl.toString());
330 |     } catch (error) {
331 |       // Browser opening failed, user will need to copy/paste
332 |     }
333 | 
334 |     try {
335 |       // Wait for callback
336 |       const { code, state: receivedState } = await waitForCallback();
337 | 
338 |       // Verify state
339 |       if (receivedState !== state) {
340 |         throw new Error("State mismatch - possible CSRF attack");
341 |       }
342 | 
343 |       // Exchange code for token
344 | 
345 |       try {
346 |         const tokenResponse = await this.exchangeCodeForToken({
347 |           code,
348 |           codeVerifier: verifier,
349 |           clientId,
350 |         });
351 | 
352 |         // Cache the access token
353 |         await this.configManager.setAccessToken(
354 |           this.config.mcpHost,
355 |           tokenResponse.access_token,
356 |           tokenResponse.expires_in,
357 |         );
358 | 
359 |         logSuccess("Authentication successful");
360 | 
361 |         return tokenResponse.access_token;
362 |       } catch (error) {
363 |         logError(
364 |           "Authentication failed",
365 |           error instanceof Error ? error : String(error),
366 |         );
367 |         throw error;
368 |       }
369 |     } finally {
370 |       // Clean up server
371 |       if (this.server) {
372 |         this.server.close();
373 |         this.server = null;
374 |       }
375 |     }
376 |   }
377 | }
378 | 
```

--------------------------------------------------------------------------------
/packages/mcp-server/scripts/generate-otel-namespaces.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env tsx
  2 | 
  3 | import {
  4 |   writeFileSync,
  5 |   readFileSync,
  6 |   existsSync,
  7 |   mkdirSync,
  8 |   readdirSync,
  9 | } from "node:fs";
 10 | import { resolve, dirname } from "node:path";
 11 | import { fileURLToPath } from "node:url";
 12 | import { parse as parseYaml } from "yaml";
 13 | import { z } from "zod";
 14 | 
 15 | const __filename = fileURLToPath(import.meta.url);
 16 | const __dirname = dirname(__filename);
 17 | 
 18 | // Zod schemas for type-safe YAML parsing
 19 | const OtelAttributeMemberSchema = z.object({
 20 |   id: z.string(),
 21 |   value: z.union([z.string(), z.number()]),
 22 |   stability: z.string().optional(),
 23 |   brief: z.string().optional(),
 24 |   note: z.string().optional(),
 25 | });
 26 | 
 27 | // Type can be a string or an object with a 'members' property for enums
 28 | const OtelTypeSchema = z.union([
 29 |   z.string(),
 30 |   z.object({
 31 |     members: z.array(OtelAttributeMemberSchema),
 32 |   }),
 33 | ]);
 34 | 
 35 | const OtelAttributeSchema = z.object({
 36 |   id: z.string(),
 37 |   type: OtelTypeSchema,
 38 |   stability: z.string().optional(),
 39 |   brief: z.string(),
 40 |   note: z.string().optional(),
 41 |   // Examples can be strings, numbers, booleans, or arrays (for array examples)
 42 |   examples: z
 43 |     .union([
 44 |       z.array(
 45 |         z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]),
 46 |       ),
 47 |       z.string(),
 48 |       z.number(),
 49 |       z.boolean(),
 50 |     ])
 51 |     .optional(),
 52 |   members: z.array(OtelAttributeMemberSchema).optional(),
 53 | });
 54 | 
 55 | const OtelGroupSchema = z.object({
 56 |   id: z.string(),
 57 |   type: z.string(),
 58 |   display_name: z.string().optional(),
 59 |   brief: z.string(),
 60 |   attributes: z.array(OtelAttributeSchema),
 61 | });
 62 | 
 63 | const OtelYamlFileSchema = z.object({
 64 |   groups: z.array(OtelGroupSchema),
 65 | });
 66 | 
 67 | // TypeScript types inferred from Zod schemas
 68 | type OtelAttribute = z.infer<typeof OtelAttributeSchema>;
 69 | type OtelGroup = z.infer<typeof OtelGroupSchema>;
 70 | type OtelYamlFile = z.infer<typeof OtelYamlFileSchema>;
 71 | 
 72 | interface JsonAttribute {
 73 |   description: string;
 74 |   type: string;
 75 |   examples?: string[];
 76 |   note?: string;
 77 |   stability?: string;
 78 | }
 79 | 
 80 | interface JsonNamespace {
 81 |   namespace: string;
 82 |   description: string;
 83 |   attributes: Record<string, JsonAttribute>;
 84 | }
 85 | 
 86 | // Known namespaces to process
 87 | const KNOWN_NAMESPACES = [
 88 |   "gen-ai",
 89 |   "database",
 90 |   "http",
 91 |   "rpc",
 92 |   "messaging",
 93 |   "faas",
 94 |   "k8s",
 95 |   "network",
 96 |   "server",
 97 |   "client",
 98 |   "cloud",
 99 |   "container",
100 |   "host",
101 |   "process",
102 |   "service",
103 |   "system",
104 |   "user",
105 |   "error",
106 |   "exception",
107 |   "url",
108 |   "tls",
109 |   "dns",
110 |   "feature-flags",
111 |   "code",
112 |   "thread",
113 |   "jvm",
114 |   "nodejs",
115 |   "dotnet",
116 |   "go",
117 |   "android",
118 |   "ios",
119 |   "browser",
120 |   "aws",
121 |   "azure",
122 |   "gcp",
123 |   "oci",
124 |   "cloudevents",
125 |   "graphql",
126 |   "aspnetcore",
127 |   "otel",
128 |   "telemetry",
129 |   "log",
130 |   "profile",
131 |   "test",
132 |   "session",
133 |   "deployment",
134 |   "device",
135 |   "disk",
136 |   "hardware",
137 |   "os",
138 |   "vcs",
139 |   "webengine",
140 |   "signalr",
141 |   "cicd",
142 |   "artifact",
143 |   "app",
144 |   "file",
145 |   "peer",
146 |   "destination",
147 |   "source",
148 |   "cpython",
149 |   "v8js",
150 |   "mainframe",
151 |   "zos",
152 |   "linux",
153 |   "enduser",
154 |   "user_agent",
155 |   "cpu",
156 |   "cassandra",
157 |   "elasticsearch",
158 |   "heroku",
159 |   "cloudfoundry",
160 |   "opentracing",
161 |   "geo",
162 |   "security_rule",
163 | ];
164 | 
165 | const DATA_DIR = resolve(__dirname, "../src/agent-tools/data");
166 | const CACHE_DIR = resolve(DATA_DIR, ".cache");
167 | const GITHUB_BASE_URL =
168 |   "https://raw.githubusercontent.com/open-telemetry/semantic-conventions/main/model";
169 | 
170 | // Ensure cache directory exists
171 | function ensureCacheDir() {
172 |   if (!existsSync(CACHE_DIR)) {
173 |     mkdirSync(CACHE_DIR, { recursive: true });
174 |   }
175 | }
176 | 
177 | async function fetchYamlContent(namespace: string): Promise<string | null> {
178 |   ensureCacheDir();
179 | 
180 |   const cacheFile = resolve(CACHE_DIR, `${namespace}.yaml`);
181 | 
182 |   // Check if we have a cached version
183 |   if (existsSync(cacheFile)) {
184 |     try {
185 |       const cachedContent = readFileSync(cacheFile, "utf8");
186 |       console.log(`📂 Using cached ${namespace}.yaml`);
187 |       return cachedContent;
188 |     } catch (error) {
189 |       console.warn(
190 |         `⚠️  Failed to read cached ${namespace}.yaml, fetching fresh copy`,
191 |       );
192 |     }
193 |   }
194 | 
195 |   // Fetch from GitHub
196 |   try {
197 |     const response = await fetch(
198 |       `${GITHUB_BASE_URL}/${namespace}/registry.yaml`,
199 |     );
200 |     if (!response.ok) {
201 |       console.log(`⚠️  No registry.yaml found for namespace: ${namespace}`);
202 |       return null;
203 |     }
204 | 
205 |     const yamlContent = await response.text();
206 | 
207 |     // Cache the content
208 |     try {
209 |       writeFileSync(cacheFile, yamlContent);
210 |       console.log(`💾 Cached ${namespace}.yaml`);
211 |     } catch (error) {
212 |       console.warn(`⚠️  Failed to cache ${namespace}.yaml:`, error);
213 |     }
214 | 
215 |     return yamlContent;
216 |   } catch (error) {
217 |     console.error(`❌ Failed to fetch ${namespace}/registry.yaml:`, error);
218 |     return null;
219 |   }
220 | }
221 | 
222 | function convertYamlToJson(
223 |   yamlContent: string,
224 |   namespace: string,
225 | ): JsonNamespace {
226 |   // Parse YAML and validate with Zod
227 |   const parsedYaml = parseYaml(yamlContent);
228 |   const validationResult = OtelYamlFileSchema.safeParse(parsedYaml);
229 | 
230 |   if (!validationResult.success) {
231 |     throw new Error(
232 |       `Invalid YAML structure for ${namespace}: ${validationResult.error.message}`,
233 |     );
234 |   }
235 | 
236 |   const otelData = validationResult.data;
237 | 
238 |   if (otelData.groups.length === 0) {
239 |     throw new Error(`No groups found in ${namespace}/registry.yaml`);
240 |   }
241 | 
242 |   const group = otelData.groups[0]; // Take the first group
243 |   const attributes: Record<string, JsonAttribute> = {};
244 | 
245 |   for (const attr of group.attributes) {
246 |     // Extract the type string, handling both string and object types
247 |     const typeStr = typeof attr.type === "string" ? attr.type : "string"; // enums are strings
248 | 
249 |     const jsonAttr: JsonAttribute = {
250 |       description: attr.brief,
251 |       type: inferType(typeStr),
252 |     };
253 | 
254 |     if (attr.note) {
255 |       jsonAttr.note = attr.note;
256 |     }
257 | 
258 |     if (attr.stability) {
259 |       jsonAttr.stability = attr.stability;
260 |     }
261 | 
262 |     // Handle examples - normalize to string array
263 |     if (attr.examples) {
264 |       if (Array.isArray(attr.examples)) {
265 |         jsonAttr.examples = attr.examples.map((ex) => {
266 |           if (Array.isArray(ex)) {
267 |             // For array examples, convert to JSON string
268 |             return JSON.stringify(ex);
269 |           }
270 |           return String(ex);
271 |         });
272 |       } else {
273 |         jsonAttr.examples = [String(attr.examples)];
274 |       }
275 |     }
276 | 
277 |     // Handle enums/members from the type object or explicit members
278 |     if (typeof attr.type === "object" && attr.type.members) {
279 |       jsonAttr.examples = attr.type.members.map((m) => String(m.value));
280 |     } else if (attr.members) {
281 |       jsonAttr.examples = attr.members.map((m) => String(m.value));
282 |     }
283 | 
284 |     attributes[attr.id] = jsonAttr;
285 |   }
286 | 
287 |   return {
288 |     namespace: namespace.replace(/-/g, "_"), // Convert all hyphens to underscores for consistency
289 |     description: group.brief,
290 |     attributes,
291 |   };
292 | }
293 | 
294 | function inferType(otelType: string): string {
295 |   // For semantic documentation, we keep the type mapping simple
296 |   // The AI agent mainly needs to know if something is numeric (for aggregate functions)
297 | 
298 |   const cleanType = otelType.toLowerCase();
299 | 
300 |   if (
301 |     cleanType.includes("int") ||
302 |     cleanType.includes("double") ||
303 |     cleanType.includes("number")
304 |   ) {
305 |     return "number";
306 |   }
307 |   if (cleanType.includes("bool")) {
308 |     return "boolean";
309 |   }
310 |   return "string"; // Everything else is treated as string
311 | }
312 | 
313 | async function generateNamespaceFiles() {
314 |   console.log("🔄 Generating OpenTelemetry namespace files...");
315 | 
316 |   let processed = 0;
317 |   let skipped = 0;
318 |   const availableNamespaces: Array<{
319 |     namespace: string;
320 |     description: string;
321 |     custom?: boolean;
322 |   }> = [];
323 | 
324 |   for (const namespace of KNOWN_NAMESPACES) {
325 |     const outputPath = resolve(
326 |       DATA_DIR,
327 |       `${namespace.replace(/-/g, "_")}.json`,
328 |     );
329 | 
330 |     // Check if file exists and has custom content (not from OpenTelemetry)
331 |     if (existsSync(outputPath)) {
332 |       const existingContent = readFileSync(outputPath, "utf8");
333 |       const existingJson = JSON.parse(existingContent);
334 | 
335 |       // Skip if this appears to be a custom namespace (not from OpenTelemetry)
336 |       if (existingJson.namespace === "mcp" || existingJson.custom === true) {
337 |         console.log(`⏭️  Skipping custom namespace: ${namespace}`);
338 |         skipped++;
339 |         continue;
340 |       }
341 |     }
342 | 
343 |     const yamlContent = await fetchYamlContent(namespace);
344 |     if (!yamlContent) {
345 |       console.log(`⏭️  Skipping ${namespace} (no registry.yaml found)`);
346 |       skipped++;
347 |       continue;
348 |     }
349 | 
350 |     try {
351 |       const jsonData = convertYamlToJson(yamlContent, namespace);
352 |       writeFileSync(outputPath, JSON.stringify(jsonData, null, 2));
353 |       console.log(`✅ Generated: ${namespace.replace("-", "_")}.json`);
354 |       processed++;
355 | 
356 |       // Add to available namespaces
357 |       availableNamespaces.push({
358 |         namespace: jsonData.namespace,
359 |         description: jsonData.description,
360 |       });
361 |     } catch (error) {
362 |       console.error(`❌ Failed to process ${namespace}:`, error);
363 |       skipped++;
364 |     }
365 |   }
366 | 
367 |   console.log(`\n📊 Summary: ${processed} processed, ${skipped} skipped`);
368 | 
369 |   // Generate namespaces index
370 |   generateNamespacesIndex(availableNamespaces);
371 | }
372 | 
373 | // Generate index of all available namespaces
374 | function generateNamespacesIndex(
375 |   namespaces: Array<{
376 |     namespace: string;
377 |     description: string;
378 |     custom?: boolean;
379 |   }>,
380 | ) {
381 |   // Add any existing custom namespaces that weren't in KNOWN_NAMESPACES
382 |   const existingFiles = readdirSync(DATA_DIR).filter(
383 |     (f) => f.endsWith(".json") && f !== "__namespaces.json",
384 |   );
385 | 
386 |   for (const file of existingFiles) {
387 |     const namespace = file.replace(".json", "");
388 |     if (!namespaces.find((n) => n.namespace === namespace)) {
389 |       try {
390 |         const content = readFileSync(resolve(DATA_DIR, file), "utf8");
391 |         const data = JSON.parse(content) as JsonNamespace & {
392 |           custom?: boolean;
393 |         };
394 |         namespaces.push({
395 |           namespace: data.namespace,
396 |           description: data.description,
397 |           custom: data.custom,
398 |         });
399 |       } catch (error) {
400 |         console.warn(`⚠️  Failed to read ${file} for index`);
401 |       }
402 |     }
403 |   }
404 | 
405 |   // Sort namespaces alphabetically
406 |   namespaces.sort((a, b) => a.namespace.localeCompare(b.namespace));
407 | 
408 |   const indexPath = resolve(DATA_DIR, "__namespaces.json");
409 |   const indexContent = {
410 |     generated: new Date().toISOString(),
411 |     totalNamespaces: namespaces.length,
412 |     namespaces,
413 |   };
414 | 
415 |   writeFileSync(indexPath, JSON.stringify(indexContent, null, 2));
416 |   console.log(
417 |     `📇 Generated namespace index: __namespaces.json (${namespaces.length} namespaces)`,
418 |   );
419 | }
420 | 
421 | // Add MCP namespace as a custom one
422 | function generateMcpNamespace() {
423 |   const mcpNamespace: JsonNamespace = {
424 |     namespace: "mcp",
425 |     description:
426 |       "Model Context Protocol attributes for MCP tool calls and sessions",
427 |     attributes: {
428 |       "mcp.tool.name": {
429 |         description: "Tool name (e.g., find_issues, search_events)",
430 |         type: "string",
431 |         examples: [
432 |           "find_issues",
433 |           "search_events",
434 |           "get_issue_details",
435 |           "update_issue",
436 |         ],
437 |       },
438 |       "mcp.session.id": {
439 |         description: "MCP session identifier",
440 |         type: "string",
441 |       },
442 |       "mcp.transport": {
443 |         description: "MCP transport protocol used",
444 |         type: "string",
445 |         examples: ["stdio", "http", "websocket"],
446 |       },
447 |       "mcp.request.id": {
448 |         description: "MCP request identifier",
449 |         type: "string",
450 |       },
451 |       "mcp.response.status": {
452 |         description: "MCP response status",
453 |         type: "string",
454 |         examples: ["success", "error"],
455 |       },
456 |     },
457 |   };
458 | 
459 |   const outputPath = resolve(DATA_DIR, "mcp.json");
460 |   const content = JSON.stringify(
461 |     {
462 |       ...mcpNamespace,
463 |       custom: true, // Mark as custom so it doesn't get overwritten
464 |     },
465 |     null,
466 |     2,
467 |   );
468 | 
469 |   writeFileSync(outputPath, content);
470 |   console.log("✅ Generated custom MCP namespace");
471 | }
472 | 
473 | // Run the script
474 | if (import.meta.url === `file://${process.argv[1]}`) {
475 |   generateNamespaceFiles()
476 |     .then(() => {
477 |       generateMcpNamespace();
478 |       console.log("🎉 OpenTelemetry namespace generation complete!");
479 |     })
480 |     .catch((error) => {
481 |       console.error("❌ Script failed:", error);
482 |       process.exit(1);
483 |     });
484 | }
485 | 
```

--------------------------------------------------------------------------------
/packages/mcp-server/src/telem/logging.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Logging and telemetry utilities for error reporting.
  3 |  *
  4 |  * Provides centralized error logging with Sentry integration. Handles both
  5 |  * console logging for development and structured error reporting for production
  6 |  * monitoring and debugging.
  7 |  */
  8 | import {
  9 |   configureSync,
 10 |   getConfig,
 11 |   getConsoleSink,
 12 |   getJsonLinesFormatter,
 13 |   getLogger as getLogTapeLogger,
 14 |   parseLogLevel,
 15 |   type LogLevel,
 16 |   type Logger,
 17 |   type LogRecord,
 18 |   type Sink,
 19 | } from "@logtape/logtape";
 20 | import { captureException, captureMessage, withScope } from "@sentry/core";
 21 | import * as Sentry from "@sentry/node";
 22 | 
 23 | const ROOT_LOG_CATEGORY = ["sentry", "mcp"] as const;
 24 | 
 25 | type SinkId = "console" | "sentry";
 26 | 
 27 | let loggingConfigured = false;
 28 | 
 29 | function resolveLowestLevel(): LogLevel {
 30 |   const envLevel =
 31 |     typeof process !== "undefined" ? process.env.LOG_LEVEL : undefined;
 32 | 
 33 |   if (envLevel) {
 34 |     try {
 35 |       return parseLogLevel(envLevel);
 36 |     } catch (error) {
 37 |       // Fall through to default level when parsing fails.
 38 |     }
 39 |   }
 40 | 
 41 |   return typeof process !== "undefined" &&
 42 |     process.env.NODE_ENV === "development"
 43 |     ? "debug"
 44 |     : "info";
 45 | }
 46 | 
 47 | /**
 48 |  * Creates a LogTape sink that sends logs to Sentry's Logs product using Sentry.logger.
 49 |  *
 50 |  * Unlike @logtape/sentry's getSentrySink which uses captureException/captureMessage
 51 |  * (creating Issues), this sink uses Sentry.logger.* methods to send data to the
 52 |  * Logs product.
 53 |  *
 54 |  * Note: This uses @sentry/node logger API. Cloudflare Workers will need a separate
 55 |  * implementation using @sentry/cloudflare logger API.
 56 |  */
 57 | function createSentryLogsSink(): Sink {
 58 |   return (record: LogRecord) => {
 59 |     // Check if Sentry.logger is available (may not be in all environments)
 60 |     if (!Sentry.logger) {
 61 |       return;
 62 |     }
 63 | 
 64 |     // Extract message from LogRecord
 65 |     let message = "";
 66 |     for (let i = 0; i < record.message.length; i++) {
 67 |       if (i % 2 === 0) {
 68 |         message += record.message[i];
 69 |       } else {
 70 |         // Template values - convert to string safely
 71 |         const value = record.message[i];
 72 |         message += typeof value === "string" ? value : coerceMessage(value);
 73 |       }
 74 |     }
 75 | 
 76 |     // Extract attributes from properties
 77 |     const attributes = record.properties as Record<string, unknown>;
 78 | 
 79 |     // Map LogTape levels to Sentry.logger methods
 80 |     // Note: Sentry.logger methods are fire-and-forget and handle errors gracefully
 81 |     switch (record.level) {
 82 |       case "trace":
 83 |         Sentry.logger.trace(message, attributes);
 84 |         break;
 85 |       case "debug":
 86 |         Sentry.logger.debug(message, attributes);
 87 |         break;
 88 |       case "info":
 89 |         Sentry.logger.info(message, attributes);
 90 |         break;
 91 |       case "warning":
 92 |         Sentry.logger.warn(message, attributes);
 93 |         break;
 94 |       case "error":
 95 |         Sentry.logger.error(message, attributes);
 96 |         break;
 97 |       case "fatal":
 98 |         Sentry.logger.fatal(message, attributes);
 99 |         break;
100 |       default:
101 |         Sentry.logger.info(message, attributes);
102 |     }
103 |   };
104 | }
105 | 
106 | function ensureLoggingConfigured(): void {
107 |   if (loggingConfigured) {
108 |     return;
109 |   }
110 | 
111 |   const consoleSink = getConsoleSink({
112 |     formatter: getJsonLinesFormatter(),
113 |   });
114 |   const sentrySink = createSentryLogsSink();
115 | 
116 |   configureSync<SinkId, never>({
117 |     reset: getConfig() !== null,
118 |     sinks: {
119 |       console: consoleSink,
120 |       sentry: sentrySink,
121 |     },
122 |     loggers: [
123 |       {
124 |         category: [...ROOT_LOG_CATEGORY],
125 |         sinks: ["console", "sentry"],
126 |         lowestLevel: resolveLowestLevel(),
127 |       },
128 |       {
129 |         category: ["logtape", "meta"],
130 |         sinks: ["console"],
131 |         lowestLevel: "warning",
132 |       },
133 |       {
134 |         category: "logtape",
135 |         sinks: ["console"],
136 |         lowestLevel: "error",
137 |       },
138 |     ],
139 |   });
140 | 
141 |   loggingConfigured = true;
142 | }
143 | 
144 | export type LogContext = Record<string, unknown>;
145 | 
146 | export type SentryLogContexts = Record<string, Record<string, unknown>>;
147 | export type LogAttachments = Record<string, string | Uint8Array>;
148 | 
149 | export interface BaseLogOptions {
150 |   contexts?: SentryLogContexts;
151 |   extra?: LogContext;
152 |   loggerScope?: string | readonly string[];
153 | }
154 | 
155 | export interface LogIssueOptions extends BaseLogOptions {
156 |   attachments?: LogAttachments;
157 | }
158 | 
159 | export interface LogOptions extends BaseLogOptions {}
160 | 
161 | export function getLogger(
162 |   scope: string | readonly string[],
163 |   defaults?: LogContext,
164 | ): Logger {
165 |   ensureLoggingConfigured();
166 | 
167 |   const category = Array.isArray(scope) ? scope : [scope];
168 |   const logger = getLogTapeLogger([...ROOT_LOG_CATEGORY, ...category]);
169 | 
170 |   return defaults ? logger.with(defaults) : logger;
171 | }
172 | 
173 | const ISSUE_LOGGER_SCOPE = ["runtime", "issues"] as const;
174 | 
175 | interface ParsedBaseOptions {
176 |   contexts?: SentryLogContexts;
177 |   extra?: LogContext;
178 |   loggerScope?: string | readonly string[];
179 | }
180 | 
181 | interface ParsedLogIssueOptions extends ParsedBaseOptions {
182 |   attachments?: LogAttachments;
183 | }
184 | 
185 | interface SerializedError {
186 |   message: string;
187 |   name?: string;
188 |   stack?: string;
189 |   cause?: SerializedError;
190 | }
191 | 
192 | function isRecord(value: unknown): value is Record<string, unknown> {
193 |   return typeof value === "object" && value !== null;
194 | }
195 | 
196 | function isSentryContexts(value: unknown): value is SentryLogContexts {
197 |   if (!isRecord(value)) {
198 |     return false;
199 |   }
200 | 
201 |   return Object.values(value).every((entry) => isRecord(entry));
202 | }
203 | 
204 | function isBaseLogOptionsCandidate(value: unknown): value is BaseLogOptions {
205 |   if (!isRecord(value)) {
206 |     return false;
207 |   }
208 | 
209 |   if ("extra" in value || "loggerScope" in value) {
210 |     return true;
211 |   }
212 | 
213 |   if ("contexts" in value) {
214 |     const contexts = (value as { contexts?: unknown }).contexts;
215 |     return contexts === undefined || isSentryContexts(contexts);
216 |   }
217 | 
218 |   return false;
219 | }
220 | 
221 | function isLogIssueOptionsCandidate(value: unknown): value is LogIssueOptions {
222 |   return (
223 |     isBaseLogOptionsCandidate(value) ||
224 |     (isRecord(value) && "attachments" in value)
225 |   );
226 | }
227 | 
228 | function parseBaseOptions(
229 |   contextsOrOptions?: SentryLogContexts | BaseLogOptions,
230 | ): ParsedBaseOptions {
231 |   if (isBaseLogOptionsCandidate(contextsOrOptions)) {
232 |     const { contexts, extra, loggerScope } = contextsOrOptions;
233 |     return {
234 |       contexts,
235 |       extra,
236 |       loggerScope,
237 |     };
238 |   }
239 | 
240 |   if (isSentryContexts(contextsOrOptions)) {
241 |     return { contexts: contextsOrOptions };
242 |   }
243 | 
244 |   return {};
245 | }
246 | 
247 | function parseLogIssueOptions(
248 |   contextsOrOptions?: SentryLogContexts | LogIssueOptions,
249 |   attachmentsArg?: LogAttachments,
250 | ): ParsedLogIssueOptions {
251 |   const base = parseBaseOptions(contextsOrOptions);
252 | 
253 |   const attachments = isLogIssueOptionsCandidate(contextsOrOptions)
254 |     ? contextsOrOptions.attachments
255 |     : undefined;
256 | 
257 |   return {
258 |     ...base,
259 |     attachments: attachments ?? attachmentsArg,
260 |   };
261 | }
262 | 
263 | function parseLogOptions(
264 |   contextsOrOptions?: SentryLogContexts | LogOptions,
265 | ): LogOptions {
266 |   return parseBaseOptions(contextsOrOptions);
267 | }
268 | 
269 | function safeJsonStringify(value: unknown): string | undefined {
270 |   try {
271 |     return JSON.stringify(value);
272 |   } catch (error) {
273 |     return undefined;
274 |   }
275 | }
276 | 
277 | function truncate(text: string, maxLength = 1024): string {
278 |   if (text.length <= maxLength) {
279 |     return text;
280 |   }
281 | 
282 |   return `${text.slice(0, maxLength - 1)}…`;
283 | }
284 | 
285 | function coerceMessage(value: unknown): string {
286 |   if (typeof value === "string") {
287 |     return value;
288 |   }
289 | 
290 |   if (
291 |     typeof value === "number" ||
292 |     typeof value === "boolean" ||
293 |     typeof value === "bigint"
294 |   ) {
295 |     return value.toString();
296 |   }
297 | 
298 |   if (value === null || value === undefined) {
299 |     return String(value);
300 |   }
301 | 
302 |   const json = safeJsonStringify(value);
303 |   if (json) {
304 |     return truncate(json);
305 |   }
306 | 
307 |   return Object.prototype.toString.call(value);
308 | }
309 | 
310 | function serializeError(value: unknown, depth = 0): SerializedError {
311 |   if (value instanceof Error) {
312 |     const serialized: SerializedError = {
313 |       message: value.message,
314 |     };
315 | 
316 |     if (value.name && value.name !== "Error") {
317 |       serialized.name = value.name;
318 |     }
319 | 
320 |     if (typeof value.stack === "string") {
321 |       serialized.stack = value.stack;
322 |     }
323 | 
324 |     const hasCause =
325 |       "cause" in (value as { cause?: unknown }) &&
326 |       (value as { cause?: unknown }).cause !== undefined;
327 | 
328 |     if (hasCause && depth < 3) {
329 |       const cause = (value as { cause?: unknown }).cause;
330 |       serialized.cause = serializeError(cause, depth + 1);
331 |     }
332 | 
333 |     return serialized;
334 |   }
335 | 
336 |   return { message: coerceMessage(value) };
337 | }
338 | 
339 | export const logger = getLogger([]);
340 | 
341 | const DEFAULT_LOGGER_SCOPE: readonly string[] = [];
342 | 
343 | function buildLogProperties(
344 |   level: LogLevel,
345 |   options: ParsedBaseOptions,
346 |   serializedError?: SerializedError,
347 | ): LogContext {
348 |   const properties: LogContext = {
349 |     severity: level,
350 |   };
351 | 
352 |   if (serializedError) {
353 |     properties.error = serializedError;
354 |   }
355 | 
356 |   if (options.extra) {
357 |     Object.assign(properties, options.extra);
358 |   }
359 | 
360 |   if (options.contexts) {
361 |     properties.sentryContexts = options.contexts;
362 |   }
363 | 
364 |   return properties;
365 | }
366 | 
367 | function logWithLevel(
368 |   level: LogLevel,
369 |   value: unknown,
370 |   contextsOrOptions?: SentryLogContexts | LogOptions,
371 | ): void {
372 |   ensureLoggingConfigured();
373 | 
374 |   const options = parseLogOptions(contextsOrOptions);
375 |   const serializedError =
376 |     value instanceof Error ? serializeError(value) : undefined;
377 |   const message = serializedError
378 |     ? serializedError.message
379 |     : coerceMessage(value);
380 |   const scope = options.loggerScope ?? DEFAULT_LOGGER_SCOPE;
381 |   const scopedLogger = getLogger(scope, { severity: level });
382 | 
383 |   const properties = buildLogProperties(level, options, serializedError);
384 | 
385 |   switch (level) {
386 |     case "trace":
387 |       scopedLogger.trace(message, () => properties);
388 |       break;
389 |     case "debug":
390 |       scopedLogger.debug(message, () => properties);
391 |       break;
392 |     case "info":
393 |       scopedLogger.info(message, () => properties);
394 |       break;
395 |     case "warning":
396 |       scopedLogger.warn(message, () => properties);
397 |       break;
398 |     case "error":
399 |       scopedLogger.error(message, () => properties);
400 |       break;
401 |     case "fatal":
402 |       scopedLogger.fatal(message, () => properties);
403 |       break;
404 |     default:
405 |       scopedLogger.info(message, () => properties);
406 |   }
407 | }
408 | 
409 | export function logDebug(
410 |   value: unknown,
411 |   contextsOrOptions?: SentryLogContexts | LogOptions,
412 | ): void {
413 |   logWithLevel("debug", value, contextsOrOptions);
414 | }
415 | 
416 | export function logInfo(
417 |   value: unknown,
418 |   contextsOrOptions?: SentryLogContexts | LogOptions,
419 | ): void {
420 |   logWithLevel("info", value, contextsOrOptions);
421 | }
422 | 
423 | export function logWarn(
424 |   value: unknown,
425 |   contextsOrOptions?: SentryLogContexts | LogOptions,
426 | ): void {
427 |   logWithLevel("warning", value, contextsOrOptions);
428 | }
429 | 
430 | export function logError(
431 |   value: unknown,
432 |   contextsOrOptions?: SentryLogContexts | LogOptions,
433 | ): void {
434 |   logWithLevel("error", value, contextsOrOptions);
435 | }
436 | 
437 | export function logIssue(
438 |   error: Error | unknown,
439 |   contexts?: SentryLogContexts,
440 |   attachments?: LogAttachments,
441 | ): string | undefined;
442 | export function logIssue(
443 |   error: Error | unknown,
444 |   options: LogIssueOptions,
445 | ): string | undefined;
446 | export function logIssue(
447 |   message: string,
448 |   contexts?: SentryLogContexts,
449 |   attachments?: LogAttachments,
450 | ): string | undefined;
451 | export function logIssue(
452 |   message: string,
453 |   options: LogIssueOptions,
454 | ): string | undefined;
455 | export function logIssue(
456 |   error: unknown,
457 |   contextsOrOptions?: SentryLogContexts | LogIssueOptions,
458 |   attachmentsArg?: LogAttachments,
459 | ): string | undefined {
460 |   ensureLoggingConfigured();
461 | 
462 |   const options = parseLogIssueOptions(contextsOrOptions, attachmentsArg);
463 |   const eventId = withScope((scopeInstance) => {
464 |     if (options.contexts) {
465 |       for (const [key, context] of Object.entries(options.contexts)) {
466 |         scopeInstance.setContext(key, context);
467 |       }
468 |     }
469 | 
470 |     if (options.extra) {
471 |       scopeInstance.setContext("log", options.extra);
472 |     }
473 | 
474 |     if (options.attachments) {
475 |       for (const [key, data] of Object.entries(options.attachments)) {
476 |         scopeInstance.addAttachment({
477 |           data,
478 |           filename: key,
479 |         });
480 |       }
481 |     }
482 | 
483 |     const captureLevel = "error" as const;
484 | 
485 |     return typeof error === "string"
486 |       ? captureMessage(error, {
487 |           contexts: options.contexts,
488 |           level: captureLevel,
489 |         })
490 |       : captureException(error, {
491 |           contexts: options.contexts,
492 |           level: captureLevel,
493 |         });
494 |   });
495 | 
496 |   const { attachments, ...baseOptions } = options;
497 |   const extra: LogContext = {
498 |     ...(baseOptions.extra ?? {}),
499 |     ...(attachments && Object.keys(attachments).length > 0
500 |       ? { attachments: Object.keys(attachments) }
501 |       : {}),
502 |     ...(eventId ? { eventId } : {}),
503 |   };
504 | 
505 |   logError(error, {
506 |     ...baseOptions,
507 |     extra,
508 |     loggerScope: baseOptions.loggerScope ?? ISSUE_LOGGER_SCOPE,
509 |   });
510 | 
511 |   return eventId;
512 | }
513 | 
```

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

```json
  1 | {
  2 |   "generated": "2025-07-16T18:48:46.692Z",
  3 |   "totalNamespaces": 73,
  4 |   "namespaces": [
  5 |     {
  6 |       "namespace": "android",
  7 |       "description": "The Android platform on which the Android application is running.\n"
  8 |     },
  9 |     {
 10 |       "namespace": "app",
 11 |       "description": "Describes attributes related to client-side applications (e.g. web apps or mobile apps).\n"
 12 |     },
 13 |     {
 14 |       "namespace": "artifact",
 15 |       "description": "This group describes attributes specific to artifacts. Artifacts are files or other immutable objects that are intended for distribution. This definition aligns directly with the [SLSA](https://slsa.dev/spec/v1.0/terminology#package-model) package model.\n"
 16 |     },
 17 |     {
 18 |       "namespace": "aspnetcore",
 19 |       "description": "ASP.NET Core attributes"
 20 |     },
 21 |     {
 22 |       "namespace": "aws",
 23 |       "description": "This section defines generic attributes for AWS services.\n"
 24 |     },
 25 |     {
 26 |       "namespace": "azure",
 27 |       "description": "This section defines generic attributes used by Azure Client Libraries.\n"
 28 |     },
 29 |     {
 30 |       "namespace": "browser",
 31 |       "description": "The web browser attributes\n"
 32 |     },
 33 |     {
 34 |       "namespace": "cassandra",
 35 |       "description": "This section defines attributes for Cassandra.\n"
 36 |     },
 37 |     {
 38 |       "namespace": "cicd",
 39 |       "description": "This group describes attributes specific to pipelines within a Continuous Integration and Continuous Deployment (CI/CD) system. A [pipeline](https://wikipedia.org/wiki/Pipeline_(computing)) in this case is a series of steps that are performed in order to deliver a new version of software. This aligns with the [Britannica](https://www.britannica.com/dictionary/pipeline) definition of a pipeline where a **pipeline** is the system for developing and producing something. In the context of CI/CD, a pipeline produces or delivers software.\n"
 40 |     },
 41 |     {
 42 |       "namespace": "client",
 43 |       "description": "These attributes may be used to describe the client in a connection-based network interaction where there is one side that initiates the connection (the client is the side that initiates the connection). This covers all TCP network interactions since TCP is connection-based and one side initiates the connection (an exception is made for peer-to-peer communication over TCP where the \"user-facing\" surface of the protocol / API doesn't expose a clear notion of client and server). This also covers UDP network interactions where one side initiates the interaction, e.g. QUIC (HTTP/3) and DNS.\n"
 44 |     },
 45 |     {
 46 |       "namespace": "cloud",
 47 |       "description": "A cloud environment (e.g. GCP, Azure, AWS).\n"
 48 |     },
 49 |     {
 50 |       "namespace": "cloudevents",
 51 |       "description": "This document defines attributes for CloudEvents.\n"
 52 |     },
 53 |     {
 54 |       "namespace": "cloudfoundry",
 55 |       "description": "CloudFoundry resource attributes.\n"
 56 |     },
 57 |     {
 58 |       "namespace": "code",
 59 |       "description": "These attributes provide context about source code\n"
 60 |     },
 61 |     {
 62 |       "namespace": "container",
 63 |       "description": "A container instance.\n"
 64 |     },
 65 |     {
 66 |       "namespace": "cpu",
 67 |       "description": "Attributes specific to a cpu instance."
 68 |     },
 69 |     {
 70 |       "namespace": "cpython",
 71 |       "description": "This document defines CPython related attributes.\n"
 72 |     },
 73 |     {
 74 |       "namespace": "database",
 75 |       "description": "This group defines the attributes used to describe telemetry in the context of databases.\n"
 76 |     },
 77 |     {
 78 |       "namespace": "db",
 79 |       "description": "Database operations attributes"
 80 |     },
 81 |     {
 82 |       "namespace": "deployment",
 83 |       "description": "This document defines attributes for software deployments.\n"
 84 |     },
 85 |     {
 86 |       "namespace": "destination",
 87 |       "description": "These attributes may be used to describe the receiver of a network exchange/packet. These should be used when there is no client/server relationship between the two sides, or when that relationship is unknown. This covers low-level network interactions (e.g. packet tracing) where you don't know if there was a connection or which side initiated it. This also covers unidirectional UDP flows and peer-to-peer communication where the \"user-facing\" surface of the protocol / API doesn't expose a clear notion of client and server.\n"
 88 |     },
 89 |     {
 90 |       "namespace": "device",
 91 |       "description": "Describes device attributes.\n"
 92 |     },
 93 |     {
 94 |       "namespace": "disk",
 95 |       "description": "These attributes may be used for any disk related operation.\n"
 96 |     },
 97 |     {
 98 |       "namespace": "dns",
 99 |       "description": "This document defines the shared attributes used to report a DNS query.\n"
100 |     },
101 |     {
102 |       "namespace": "dotnet",
103 |       "description": "This document defines .NET related attributes.\n"
104 |     },
105 |     {
106 |       "namespace": "elasticsearch",
107 |       "description": "This section defines attributes for Elasticsearch.\n"
108 |     },
109 |     {
110 |       "namespace": "enduser",
111 |       "description": "Describes the end user.\n"
112 |     },
113 |     {
114 |       "namespace": "error",
115 |       "description": "This document defines the shared attributes used to report an error.\n"
116 |     },
117 |     {
118 |       "namespace": "faas",
119 |       "description": "FaaS attributes"
120 |     },
121 |     {
122 |       "namespace": "feature_flags",
123 |       "description": "This document defines attributes for Feature Flags.\n"
124 |     },
125 |     {
126 |       "namespace": "file",
127 |       "description": "Describes file attributes."
128 |     },
129 |     {
130 |       "namespace": "gcp",
131 |       "description": "Attributes for Google Cloud client libraries.\n"
132 |     },
133 |     {
134 |       "namespace": "gen_ai",
135 |       "description": "This document defines the attributes used to describe telemetry in the context of Generative Artificial Intelligence (GenAI) Models requests and responses.\n"
136 |     },
137 |     {
138 |       "namespace": "geo",
139 |       "description": "Geo fields can carry data about a specific location related to an event. This geolocation information can be derived from techniques such as Geo IP, or be user-supplied.\nNote: Geo attributes are typically used under another namespace, such as client.* and describe the location of the corresponding entity (device, end-user, etc). Semantic conventions that reference geo attributes (as a root namespace) or embed them (under their own namespace) SHOULD document what geo attributes describe in the scope of that convention.\n"
140 |     },
141 |     {
142 |       "namespace": "go",
143 |       "description": "This document defines Go related attributes.\n"
144 |     },
145 |     {
146 |       "namespace": "graphql",
147 |       "description": "This document defines attributes for GraphQL."
148 |     },
149 |     {
150 |       "namespace": "hardware",
151 |       "description": "Attributes for hardware.\n"
152 |     },
153 |     {
154 |       "namespace": "heroku",
155 |       "description": "This document defines attributes for the Heroku platform on which application/s are running.\n"
156 |     },
157 |     {
158 |       "namespace": "host",
159 |       "description": "A host is defined as a computing instance. For example, physical servers, virtual machines, switches or disk array.\n"
160 |     },
161 |     {
162 |       "namespace": "http",
163 |       "description": "This document defines semantic convention attributes in the HTTP namespace."
164 |     },
165 |     {
166 |       "namespace": "ios",
167 |       "description": "This group describes iOS-specific attributes.\n"
168 |     },
169 |     {
170 |       "namespace": "jvm",
171 |       "description": "This document defines Java Virtual machine related attributes.\n"
172 |     },
173 |     {
174 |       "namespace": "k8s",
175 |       "description": "Kubernetes resource attributes.\n"
176 |     },
177 |     {
178 |       "namespace": "linux",
179 |       "description": "Describes Linux Memory attributes"
180 |     },
181 |     {
182 |       "namespace": "log",
183 |       "description": "This document defines log attributes\n"
184 |     },
185 |     {
186 |       "namespace": "mcp",
187 |       "description": "Model Context Protocol attributes for MCP tool calls and sessions",
188 |       "custom": true
189 |     },
190 |     {
191 |       "namespace": "messaging",
192 |       "description": "Attributes describing telemetry around messaging systems and messaging activities."
193 |     },
194 |     {
195 |       "namespace": "network",
196 |       "description": "These attributes may be used for any network related operation.\n"
197 |     },
198 |     {
199 |       "namespace": "nodejs",
200 |       "description": "Describes Node.js related attributes."
201 |     },
202 |     {
203 |       "namespace": "oci",
204 |       "description": "An OCI image manifest.\n"
205 |     },
206 |     {
207 |       "namespace": "opentracing",
208 |       "description": "Attributes used by the OpenTracing Shim layer."
209 |     },
210 |     {
211 |       "namespace": "os",
212 |       "description": "The operating system (OS) on which the process represented by this resource is running.\n"
213 |     },
214 |     {
215 |       "namespace": "otel",
216 |       "description": "Attributes reserved for OpenTelemetry"
217 |     },
218 |     {
219 |       "namespace": "peer",
220 |       "description": "Operations that access some remote service.\n"
221 |     },
222 |     {
223 |       "namespace": "process",
224 |       "description": "An operating system process.\n"
225 |     },
226 |     {
227 |       "namespace": "profile",
228 |       "description": "Describes the origin of a single frame in a Profile.\n"
229 |     },
230 |     {
231 |       "namespace": "rpc",
232 |       "description": "This document defines attributes for remote procedure calls."
233 |     },
234 |     {
235 |       "namespace": "server",
236 |       "description": "These attributes may be used to describe the server in a connection-based network interaction where there is one side that initiates the connection (the client is the side that initiates the connection). This covers all TCP network interactions since TCP is connection-based and one side initiates the connection (an exception is made for peer-to-peer communication over TCP where the \"user-facing\" surface of the protocol / API doesn't expose a clear notion of client and server). This also covers UDP network interactions where one side initiates the interaction, e.g. QUIC (HTTP/3) and DNS.\n"
237 |     },
238 |     {
239 |       "namespace": "service",
240 |       "description": "A service instance.\n"
241 |     },
242 |     {
243 |       "namespace": "session",
244 |       "description": "Session is defined as the period of time encompassing all activities performed by the application and the actions executed by the end user.\nConsequently, a Session is represented as a collection of Logs, Events, and Spans emitted by the Client Application throughout the Session's duration. Each Session is assigned a unique identifier, which is included as an attribute in the Logs, Events, and Spans generated during the Session's lifecycle.\nWhen a session reaches end of life, typically due to user inactivity or session timeout, a new session identifier will be assigned. The previous session identifier may be provided by the instrumentation so that telemetry backends can link the two sessions.\n"
245 |     },
246 |     {
247 |       "namespace": "signalr",
248 |       "description": "SignalR attributes"
249 |     },
250 |     {
251 |       "namespace": "source",
252 |       "description": "These attributes may be used to describe the sender of a network exchange/packet. These should be used when there is no client/server relationship between the two sides, or when that relationship is unknown. This covers low-level network interactions (e.g. packet tracing) where you don't know if there was a connection or which side initiated it. This also covers unidirectional UDP flows and peer-to-peer communication where the \"user-facing\" surface of the protocol / API doesn't expose a clear notion of client and server.\n"
253 |     },
254 |     {
255 |       "namespace": "system",
256 |       "description": "Describes System attributes"
257 |     },
258 |     {
259 |       "namespace": "telemetry",
260 |       "description": "This document defines attributes for telemetry SDK.\n"
261 |     },
262 |     {
263 |       "namespace": "test",
264 |       "description": "This group describes attributes specific to [software tests](https://wikipedia.org/wiki/Software_testing).\n"
265 |     },
266 |     {
267 |       "namespace": "thread",
268 |       "description": "These attributes may be used for any operation to store information about a thread that started a span.\n"
269 |     },
270 |     {
271 |       "namespace": "tls",
272 |       "description": "This document defines semantic convention attributes in the TLS namespace."
273 |     },
274 |     {
275 |       "namespace": "url",
276 |       "description": "Attributes describing URL."
277 |     },
278 |     {
279 |       "namespace": "user",
280 |       "description": "Describes information about the user."
281 |     },
282 |     {
283 |       "namespace": "v8js",
284 |       "description": "Describes V8 JS Engine Runtime related attributes."
285 |     },
286 |     {
287 |       "namespace": "vcs",
288 |       "description": "This group defines the attributes for [Version Control Systems (VCS)](https://wikipedia.org/wiki/Version_control).\n"
289 |     },
290 |     {
291 |       "namespace": "webengine",
292 |       "description": "This document defines the attributes used to describe the packaged software running the application code.\n"
293 |     },
294 |     {
295 |       "namespace": "zos",
296 |       "description": "This document defines attributes of a z/OS resource.\n"
297 |     }
298 |   ]
299 | }
300 | 
```

--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/components/chat/chat.tsx:
--------------------------------------------------------------------------------

```typescript
  1 | "use client";
  2 | 
  3 | import { useChat } from "@ai-sdk/react";
  4 | import { useEffect, useRef, useCallback } from "react";
  5 | import { AuthForm, ChatUI } from ".";
  6 | import { useAuth } from "../../contexts/auth-context";
  7 | import { Loader2 } from "lucide-react";
  8 | import type { ChatProps } from "./types";
  9 | import { usePersistedChat } from "../../hooks/use-persisted-chat";
 10 | import TOOL_DEFINITIONS from "@sentry/mcp-server/toolDefinitions";
 11 | import { useMcpMetadata } from "../../hooks/use-mcp-metadata";
 12 | import { useStreamingSimulation } from "../../hooks/use-streaming-simulation";
 13 | import { SlidingPanel } from "../ui/sliding-panel";
 14 | import { isAuthError } from "../../utils/chat-error-handler";
 15 | 
 16 | // We don't need user info since we're using MCP tokens
 17 | // The MCP server handles all Sentry authentication internally
 18 | 
 19 | export function Chat({ isOpen, onClose, onLogout }: ChatProps) {
 20 |   const { isLoading, isAuthenticated, authError, handleOAuthLogin } = useAuth();
 21 | 
 22 |   // Use persisted chat to save/load messages from localStorage
 23 |   const { initialMessages, saveMessages, clearPersistedMessages } =
 24 |     usePersistedChat(isAuthenticated);
 25 | 
 26 |   // Fetch MCP metadata immediately when authenticated
 27 |   const {
 28 |     metadata: mcpMetadata,
 29 |     isLoading: isMetadataLoading,
 30 |     error: metadataError,
 31 |   } = useMcpMetadata(isAuthenticated);
 32 | 
 33 |   // Initialize streaming simulation first (without scroll callback)
 34 |   const {
 35 |     isStreaming: isLocalStreaming,
 36 |     startStreaming,
 37 |     isMessageStreaming,
 38 |   } = useStreamingSimulation();
 39 | 
 40 |   const {
 41 |     messages,
 42 |     input,
 43 |     handleInputChange,
 44 |     handleSubmit,
 45 |     status,
 46 |     stop,
 47 |     error,
 48 |     reload,
 49 |     setMessages,
 50 |     setInput,
 51 |     append,
 52 |   } = useChat({
 53 |     api: "/api/chat",
 54 |     // No auth header needed - server reads from cookie
 55 |     // No ID to disable useChat's built-in persistence
 56 |     // We handle persistence manually via usePersistedChat hook
 57 |     initialMessages,
 58 |     // Enable sending the data field with messages for custom message types
 59 |     sendExtraMessageFields: true,
 60 |   });
 61 | 
 62 |   // No need for custom scroll handling - react-scroll-to-bottom handles it
 63 | 
 64 |   // Clear messages function - used locally for /clear command and logout
 65 |   const clearMessages = useCallback(() => {
 66 |     setMessages([]);
 67 |     clearPersistedMessages();
 68 |   }, [setMessages, clearPersistedMessages]);
 69 | 
 70 |   // Get MCP metadata from the dedicated endpoint
 71 |   const getMcpMetadata = useCallback(() => {
 72 |     return mcpMetadata;
 73 |   }, [mcpMetadata]);
 74 | 
 75 |   // Generate tools-based messages for custom commands
 76 |   const createToolsMessage = useCallback(() => {
 77 |     const metadata = getMcpMetadata();
 78 | 
 79 |     let content: string;
 80 |     let messageMetadata: Record<string, unknown>;
 81 | 
 82 |     if (isMetadataLoading) {
 83 |       content = "🔄 Loading tools from MCP server...";
 84 |       messageMetadata = { type: "tools-loading" };
 85 |     } else if (metadataError) {
 86 |       content = `❌ Failed to load tools: ${metadataError}\n\nPlease check your connection and try again.`;
 87 |       messageMetadata = { type: "tools-error", error: metadataError };
 88 |     } else if (!metadata || !metadata.tools || !Array.isArray(metadata.tools)) {
 89 |       content =
 90 |         "No tools are currently available. The MCP server may not have loaded tools yet.\n\nPlease check your connection and try again.";
 91 |       messageMetadata = { type: "tools-empty" };
 92 |     } else {
 93 |       // Build detailed tool list for UI component rendering
 94 |       const definitionsByName = new Map(
 95 |         TOOL_DEFINITIONS.map((t) => [t.name, t]),
 96 |       );
 97 |       const detailed = metadata.tools
 98 |         .slice()
 99 |         .sort((a, b) => a.localeCompare(b))
100 |         .map((name) => {
101 |           const def = definitionsByName.get(name);
102 |           return {
103 |             name,
104 |             description: def ? def.description.split("\n")[0] : "",
105 |           } as { name: string; description: string };
106 |         });
107 | 
108 |       content =
109 |         "These tools are available right now. Ask the assistant to use one.\n\nNote: This list reflects the permissions you approved during sign‑in. Granting additional scopes will enable more tools.";
110 |       messageMetadata = {
111 |         type: "tools-list",
112 |         tools: metadata.tools,
113 |         toolsDetailed: detailed,
114 |       };
115 |     }
116 | 
117 |     return {
118 |       content,
119 |       data: messageMetadata,
120 |     };
121 |   }, [getMcpMetadata, isMetadataLoading, metadataError]);
122 | 
123 |   const createHelpMessage = useCallback(() => {
124 |     const content = `Welcome to the Sentry Model Context Protocol chat interface! This AI assistant helps you test and explore Sentry functionality.
125 | 
126 | ## Available Slash Commands
127 | 
128 | - **\`/help\`** - Show this help message
129 | - **\`/tools\`** - List all available MCP tools
130 | - **\`/clear\`** - Clear all chat messages
131 | - **\`/logout\`** - Log out of the current session
132 | 
133 | ## What I Can Help With
134 | 
135 | 🔍 **Explore Your Sentry Data**
136 | - Browse organizations, projects, and teams
137 | - Find recent issues and errors
138 | - Analyze performance data and releases
139 | 
140 | 🛠️ **Test MCP Tools**
141 | - Demonstrate how MCP tools work with your data
142 | - Search for specific errors in files
143 | - Get detailed issue information
144 | 
145 | 🤖 **Try Sentry's AI Features**
146 | - Use Seer for automatic issue analysis and fixes
147 | - Get AI-powered debugging suggestions
148 | - Generate fix recommendations
149 | 
150 | ## Getting Started
151 | 
152 | Try asking me things like:
153 | - "What organizations do I have access to?"
154 | - "Show me my recent issues"
155 | - "Help me find errors in my React components"
156 | - "Use Seer to analyze issue ABC-123"
157 | 
158 | **Need more help?** Visit [Sentry Documentation](https://docs.sentry.io/) or check out our [careers page](https://sentry.io/careers/) if you're interested in working on projects like this! 🐱`;
159 | 
160 |     return {
161 |       content,
162 |       data: {
163 |         type: "help-message",
164 |         hasSlashCommands: true,
165 |       },
166 |     };
167 |   }, []);
168 | 
169 |   // Track previous auth state to detect logout events
170 |   const prevAuthStateRef = useRef(isAuthenticated);
171 | 
172 |   // Clear messages when user logs out (auth state changes from authenticated to not)
173 |   useEffect(() => {
174 |     const wasAuthenticated = prevAuthStateRef.current;
175 | 
176 |     // Detect logout: was authenticated but now isn't
177 |     if (wasAuthenticated && !isAuthenticated) {
178 |       clearMessages();
179 |     }
180 | 
181 |     // Update the ref for next comparison
182 |     prevAuthStateRef.current = isAuthenticated;
183 |   }, [isAuthenticated, clearMessages]);
184 | 
185 |   // Save messages when they change
186 |   useEffect(() => {
187 |     saveMessages(messages);
188 |   }, [messages, saveMessages]);
189 | 
190 |   // Track if we had an auth error before
191 |   const hadAuthErrorRef = useRef(false);
192 |   const wasAuthenticatedRef = useRef(isAuthenticated);
193 | 
194 |   // Handle auth error detection and retry after reauthentication
195 |   useEffect(() => {
196 |     // If we get an auth error, record it
197 |     if (error && isAuthError(error) && !hadAuthErrorRef.current) {
198 |       hadAuthErrorRef.current = true;
199 |     }
200 | 
201 |     // If we had an auth error and just re-authenticated, retry once
202 |     if (
203 |       hadAuthErrorRef.current &&
204 |       !wasAuthenticatedRef.current &&
205 |       isAuthenticated
206 |     ) {
207 |       hadAuthErrorRef.current = false;
208 |       // Retry the failed message
209 |       reload();
210 |     }
211 | 
212 |     // Reset retry state on successful completion (no error)
213 |     if (!error) {
214 |       hadAuthErrorRef.current = false;
215 |     }
216 | 
217 |     // Update auth state ref
218 |     wasAuthenticatedRef.current = isAuthenticated;
219 |   }, [isAuthenticated, error, reload]);
220 | 
221 |   // Handle slash commands
222 |   const handleSlashCommand = useCallback(
223 |     (command: string) => {
224 |       // Always clear the input first for all commands
225 |       setInput("");
226 | 
227 |       // Add the slash command as a user message first
228 |       const userMessage = {
229 |         id: Date.now().toString(),
230 |         role: "user" as const,
231 |         content: `/${command}`,
232 |         createdAt: new Date(),
233 |       };
234 | 
235 |       if (command === "clear") {
236 |         // Clear everything
237 |         clearMessages();
238 |       } else if (command === "logout") {
239 |         // Add message, then logout
240 |         setMessages((prev: any[]) => [...prev, userMessage]);
241 |         onLogout();
242 |       } else if (command === "help") {
243 |         // Add user message first
244 |         setMessages((prev: any[]) => [...prev, userMessage]);
245 | 
246 |         // Create help message with metadata and add after a brief delay for better UX
247 |         setTimeout(() => {
248 |           const helpMessageData = createHelpMessage();
249 |           const helpMessage = {
250 |             id: (Date.now() + 1).toString(),
251 |             role: "system" as const,
252 |             content: helpMessageData.content,
253 |             createdAt: new Date(),
254 |             data: { ...helpMessageData.data, simulateStreaming: true },
255 |           };
256 |           setMessages((prev) => [...prev, helpMessage]);
257 | 
258 |           // Start streaming simulation
259 |           startStreaming(helpMessage.id, 1200);
260 |         }, 100);
261 |       } else if (command === "tools") {
262 |         // Add user message first
263 |         setMessages((prev: any[]) => [...prev, userMessage]);
264 | 
265 |         // Create tools message
266 |         setTimeout(() => {
267 |           const toolsMessageData = createToolsMessage();
268 |           const toolsMessage = {
269 |             id: (Date.now() + 1).toString(),
270 |             role: "system" as const,
271 |             content: toolsMessageData.content,
272 |             createdAt: new Date(),
273 |             data: { ...toolsMessageData.data, simulateStreaming: true },
274 |           };
275 |           setMessages((prev) => [...prev, toolsMessage]);
276 | 
277 |           startStreaming(toolsMessage.id, 600);
278 |         }, 100);
279 |       } else {
280 |         // Handle unknown slash commands - add user message and error
281 |         const errorMessage = {
282 |           id: (Date.now() + 1).toString(),
283 |           role: "system" as const,
284 |           content: `Unknown command: /${command}. Available commands: /help, /tools, /clear, /logout`,
285 |           createdAt: new Date(),
286 |         };
287 |         setMessages((prev) => [...prev, userMessage, errorMessage]);
288 |       }
289 |     },
290 |     [
291 |       clearMessages,
292 |       onLogout,
293 |       setInput,
294 |       setMessages,
295 |       createHelpMessage,
296 |       createToolsMessage,
297 |       startStreaming,
298 |     ],
299 |   );
300 | 
301 |   // Handle sending a prompt programmatically
302 |   const handleSendPrompt = useCallback(
303 |     (prompt: string) => {
304 |       // Check if prompt is a slash command
305 |       if (prompt.startsWith("/")) {
306 |         const command = prompt.slice(1).toLowerCase().trim();
307 |         handleSlashCommand(command);
308 |         return;
309 |       }
310 | 
311 |       // Clear the input and directly send the message using append
312 |       append({ role: "user", content: prompt });
313 |     },
314 |     [append, handleSlashCommand],
315 |   );
316 | 
317 |   // Wrap form submission to ensure scrolling
318 |   const handleFormSubmit = useCallback(
319 |     (e: React.FormEvent<HTMLFormElement>) => {
320 |       handleSubmit(e);
321 |     },
322 |     [handleSubmit],
323 |   );
324 | 
325 |   // Show loading state while checking auth session
326 |   if (isLoading) {
327 |     return (
328 |       <SlidingPanel isOpen={isOpen} onClose={onClose}>
329 |         <div className="h-full flex items-center justify-center">
330 |           <div className="animate-pulse text-slate-400">
331 |             <Loader2 className="h-8 w-8 animate-spin" />
332 |           </div>
333 |         </div>
334 |       </SlidingPanel>
335 |     );
336 |   }
337 | 
338 |   // Use a single SlidingPanel and transition between auth and chat states
339 |   return (
340 |     <SlidingPanel isOpen={isOpen} onClose={onClose}>
341 |       {/* Auth form with fade transition */}
342 |       <div
343 |         className={`absolute inset-0 h-full flex flex-col items-center justify-center transition-all duration-500 ease-in-out ${
344 |           !isAuthenticated
345 |             ? "opacity-100 pointer-events-auto"
346 |             : "opacity-0 pointer-events-none"
347 |         }`}
348 |         style={{
349 |           visibility: !isAuthenticated ? "visible" : "hidden",
350 |           transitionProperty: "opacity, transform",
351 |           transform: !isAuthenticated ? "scale(1)" : "scale(0.95)",
352 |         }}
353 |       >
354 |         <AuthForm authError={authError} onOAuthLogin={handleOAuthLogin} />
355 |       </div>
356 | 
357 |       {/* Chat UI with fade transition */}
358 |       <div
359 |         className={`absolute inset-0 transition-all duration-500 ease-in-out ${
360 |           isAuthenticated
361 |             ? "opacity-100 pointer-events-auto"
362 |             : "opacity-0 pointer-events-none"
363 |         }`}
364 |         style={{
365 |           visibility: isAuthenticated ? "visible" : "hidden",
366 |           transitionProperty: "opacity, transform",
367 |           transform: isAuthenticated ? "scale(1)" : "scale(1.05)",
368 |         }}
369 |       >
370 |         <ChatUI
371 |           messages={messages}
372 |           input={input}
373 |           error={error}
374 |           isChatLoading={status === "streaming" || status === "submitted"}
375 |           isLocalStreaming={isLocalStreaming}
376 |           isMessageStreaming={isMessageStreaming}
377 |           isOpen={isOpen}
378 |           showControls
379 |           onInputChange={handleInputChange}
380 |           onSubmit={handleFormSubmit}
381 |           onStop={stop}
382 |           onRetry={reload}
383 |           onClose={onClose}
384 |           onLogout={onLogout}
385 |           onSlashCommand={handleSlashCommand}
386 |           onSendPrompt={handleSendPrompt}
387 |         />
388 |       </div>
389 |     </SlidingPanel>
390 |   );
391 | }
392 | 
```

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

```typescript
  1 | import type { SentryApiService } from "../../api-client";
  2 | import {
  3 |   type FlexibleEventData,
  4 |   getStringValue,
  5 |   isAggregateQuery,
  6 | } from "./utils";
  7 | import * as Sentry from "@sentry/node";
  8 | 
  9 | /**
 10 |  * Format an explanation for how a natural language query was translated
 11 |  */
 12 | export function formatExplanation(explanation: string): string {
 13 |   return `## How I interpreted your query\n\n${explanation}`;
 14 | }
 15 | 
 16 | /**
 17 |  * Common parameters for event formatters
 18 |  */
 19 | export interface FormatEventResultsParams {
 20 |   eventData: FlexibleEventData[];
 21 |   naturalLanguageQuery: string;
 22 |   includeExplanation?: boolean;
 23 |   apiService: SentryApiService;
 24 |   organizationSlug: string;
 25 |   explorerUrl: string;
 26 |   sentryQuery: string;
 27 |   fields: string[];
 28 |   explanation?: string;
 29 | }
 30 | 
 31 | /**
 32 |  * Format error event results for display
 33 |  */
 34 | export function formatErrorResults(params: FormatEventResultsParams): string {
 35 |   const {
 36 |     eventData,
 37 |     naturalLanguageQuery,
 38 |     includeExplanation,
 39 |     apiService,
 40 |     organizationSlug,
 41 |     explorerUrl,
 42 |     sentryQuery,
 43 |     fields,
 44 |     explanation,
 45 |   } = params;
 46 | 
 47 |   let output = `# Search Results for "${naturalLanguageQuery}"\n\n`;
 48 | 
 49 |   // Check if this is an aggregate query and adjust display instructions
 50 |   if (isAggregateQuery(fields)) {
 51 |     output += `⚠️ **IMPORTANT**: Display these aggregate results as a data table with proper column alignment and formatting.\n\n`;
 52 |   } else {
 53 |     output += `⚠️ **IMPORTANT**: Display these errors as highlighted alert cards with color-coded severity levels and clickable Event IDs.\n\n`;
 54 |   }
 55 | 
 56 |   if (includeExplanation && explanation) {
 57 |     output += formatExplanation(explanation);
 58 |     output += `\n\n`;
 59 |   }
 60 | 
 61 |   output += `**View these results in Sentry**:\n${explorerUrl}\n`;
 62 |   output += `_Please share this link with the user to view the search results in their Sentry dashboard._\n\n`;
 63 | 
 64 |   if (eventData.length === 0) {
 65 |     Sentry.logger.info(
 66 |       Sentry.logger
 67 |         .fmt`No error events found for query: ${naturalLanguageQuery}`,
 68 |       {
 69 |         query: sentryQuery,
 70 |         fields: fields,
 71 |         organizationSlug: organizationSlug,
 72 |         dataset: "errors",
 73 |       },
 74 |     );
 75 |     output += `No results found.\n\n`;
 76 |     output += `Try being more specific or using different terms in your search.\n`;
 77 |     return output;
 78 |   }
 79 | 
 80 |   output += `Found ${eventData.length} ${isAggregateQuery(fields) ? "aggregate result" : "error"}${eventData.length === 1 ? "" : "s"}:\n\n`;
 81 | 
 82 |   // For aggregate queries, just output the raw data - the agent will format it as a table
 83 |   if (isAggregateQuery(fields)) {
 84 |     output += "```json\n";
 85 |     output += JSON.stringify(eventData, null, 2);
 86 |     output += "\n```\n\n";
 87 |   } else {
 88 |     // For individual errors, format with details
 89 |     // Define priority fields that should appear first if present
 90 |     const priorityFields = [
 91 |       "title",
 92 |       "issue",
 93 |       "project",
 94 |       "level",
 95 |       "error.type",
 96 |       "message",
 97 |       "culprit",
 98 |       "timestamp",
 99 |       "last_seen()", // Aggregate field - when the issue was last seen
100 |       "count()", // Aggregate field - total occurrences of this issue
101 |     ];
102 | 
103 |     for (const event of eventData) {
104 |       // Try to get a title from various possible fields
105 |       const title =
106 |         getStringValue(event, "title") ||
107 |         getStringValue(event, "message") ||
108 |         getStringValue(event, "error.value") ||
109 |         "Error Event";
110 | 
111 |       output += `## ${title}\n\n`;
112 | 
113 |       // Display priority fields first if they exist
114 |       for (const field of priorityFields) {
115 |         if (
116 |           field in event &&
117 |           event[field] !== null &&
118 |           event[field] !== undefined
119 |         ) {
120 |           const value = event[field];
121 | 
122 |           if (field === "issue" && typeof value === "string") {
123 |             output += `**Issue ID**: ${value}\n`;
124 |             output += `**Issue URL**: ${apiService.getIssueUrl(organizationSlug, value)}\n`;
125 |           } else {
126 |             output += `**${field}**: ${value}\n`;
127 |           }
128 |         }
129 |       }
130 | 
131 |       // Display any additional fields that weren't in the priority list
132 |       const displayedFields = new Set([...priorityFields, "id"]);
133 |       for (const [key, value] of Object.entries(event)) {
134 |         if (
135 |           !displayedFields.has(key) &&
136 |           value !== null &&
137 |           value !== undefined
138 |         ) {
139 |           output += `**${key}**: ${value}\n`;
140 |         }
141 |       }
142 | 
143 |       output += "\n";
144 |     }
145 |   }
146 | 
147 |   output += "## Next Steps\n\n";
148 |   output += "- Get more details about a specific error: Use the Issue ID\n";
149 |   output += "- View error groups: Navigate to the Issues page in Sentry\n";
150 |   output += "- Set up alerts: Configure alert rules for these error patterns\n";
151 | 
152 |   return output;
153 | }
154 | 
155 | /**
156 |  * Format log event results for display
157 |  */
158 | export function formatLogResults(params: FormatEventResultsParams): string {
159 |   const {
160 |     eventData,
161 |     naturalLanguageQuery,
162 |     includeExplanation,
163 |     apiService,
164 |     organizationSlug,
165 |     explorerUrl,
166 |     sentryQuery,
167 |     fields,
168 |     explanation,
169 |   } = params;
170 | 
171 |   let output = `# Search Results for "${naturalLanguageQuery}"\n\n`;
172 | 
173 |   // Check if this is an aggregate query and adjust display instructions
174 |   if (isAggregateQuery(fields)) {
175 |     output += `⚠️ **IMPORTANT**: Display these aggregate results as a data table with proper column alignment and formatting.\n\n`;
176 |   } else {
177 |     output += `⚠️ **IMPORTANT**: Display these logs in console format with monospace font, color-coded severity (🔴 ERROR, 🟡 WARN, 🔵 INFO), and preserve timestamps.\n\n`;
178 |   }
179 | 
180 |   if (includeExplanation && explanation) {
181 |     output += formatExplanation(explanation);
182 |     output += `\n\n`;
183 |   }
184 | 
185 |   output += `**View these results in Sentry**:\n${explorerUrl}\n`;
186 |   output += `_Please share this link with the user to view the search results in their Sentry dashboard._\n\n`;
187 | 
188 |   if (eventData.length === 0) {
189 |     Sentry.logger.info(
190 |       Sentry.logger.fmt`No log events found for query: ${naturalLanguageQuery}`,
191 |       {
192 |         query: sentryQuery,
193 |         fields: fields,
194 |         organizationSlug: organizationSlug,
195 |         dataset: "logs",
196 |       },
197 |     );
198 |     output += `No results found.\n\n`;
199 |     output += `Try being more specific or using different terms in your search.\n`;
200 |     return output;
201 |   }
202 | 
203 |   output += `Found ${eventData.length} ${isAggregateQuery(fields) ? "aggregate result" : "log"}${eventData.length === 1 ? "" : "s"}:\n\n`;
204 | 
205 |   // For aggregate queries, just output the raw data - the agent will format it as a table
206 |   if (isAggregateQuery(fields)) {
207 |     output += "```json\n";
208 |     output += JSON.stringify(eventData, null, 2);
209 |     output += "\n```\n\n";
210 |   } else {
211 |     // For individual logs, format as console output
212 |     output += "```console\n";
213 | 
214 |     for (const event of eventData) {
215 |       const timestamp = getStringValue(event, "timestamp", "N/A");
216 |       const severity = getStringValue(event, "severity", "info");
217 |       const message = getStringValue(event, "message", "No message");
218 | 
219 |       // Safely uppercase the severity
220 |       const severityUpper = severity.toUpperCase();
221 | 
222 |       // Get severity emoji with proper typing
223 |       const severityEmojis: Record<string, string> = {
224 |         ERROR: "🔴",
225 |         FATAL: "🔴",
226 |         WARN: "🟡",
227 |         WARNING: "🟡",
228 |         INFO: "🔵",
229 |         DEBUG: "⚫",
230 |         TRACE: "⚫",
231 |       };
232 |       const severityEmoji = severityEmojis[severityUpper] || "🔵";
233 | 
234 |       // Standard log format with emoji and proper spacing
235 |       output += `${timestamp} ${severityEmoji} [${severityUpper.padEnd(5)}] ${message}\n`;
236 |     }
237 | 
238 |     output += "```\n\n";
239 | 
240 |     // Add detailed metadata for each log entry
241 |     output += "## Log Details\n\n";
242 | 
243 |     // Define priority fields that should appear first if present
244 |     const priorityFields = [
245 |       "message",
246 |       "severity",
247 |       "severity_number",
248 |       "timestamp",
249 |       "project",
250 |       "trace",
251 |       "sentry.item_id",
252 |     ];
253 | 
254 |     for (let i = 0; i < eventData.length; i++) {
255 |       const event = eventData[i];
256 | 
257 |       output += `### Log ${i + 1}\n`;
258 | 
259 |       // Display priority fields first
260 |       for (const field of priorityFields) {
261 |         if (
262 |           field in event &&
263 |           event[field] !== null &&
264 |           event[field] !== undefined
265 |         ) {
266 |           const value = event[field];
267 | 
268 |           if (field === "trace" && typeof value === "string") {
269 |             output += `- **Trace ID**: ${value}\n`;
270 |             output += `- **Trace URL**: ${apiService.getTraceUrl(organizationSlug, value)}\n`;
271 |           } else {
272 |             output += `- **${field}**: ${value}\n`;
273 |           }
274 |         }
275 |       }
276 | 
277 |       // Display any additional fields
278 |       const displayedFields = new Set([...priorityFields, "id"]);
279 |       for (const [key, value] of Object.entries(event)) {
280 |         if (
281 |           !displayedFields.has(key) &&
282 |           value !== null &&
283 |           value !== undefined
284 |         ) {
285 |           output += `- **${key}**: ${value}\n`;
286 |         }
287 |       }
288 | 
289 |       output += "\n";
290 |     }
291 |   }
292 | 
293 |   output += "## Next Steps\n\n";
294 |   output += "- View related traces: Click on the Trace URL if available\n";
295 |   output +=
296 |     "- Filter by severity: Adjust your query to focus on specific log levels\n";
297 |   output += "- Export logs: Use the Sentry web interface for bulk export\n";
298 | 
299 |   return output;
300 | }
301 | 
302 | /**
303 |  * Format span/trace event results for display
304 |  */
305 | export function formatSpanResults(params: FormatEventResultsParams): string {
306 |   const {
307 |     eventData,
308 |     naturalLanguageQuery,
309 |     includeExplanation,
310 |     apiService,
311 |     organizationSlug,
312 |     explorerUrl,
313 |     sentryQuery,
314 |     fields,
315 |     explanation,
316 |   } = params;
317 | 
318 |   let output = `# Search Results for "${naturalLanguageQuery}"\n\n`;
319 | 
320 |   // Check if this is an aggregate query and adjust display instructions
321 |   if (isAggregateQuery(fields)) {
322 |     output += `⚠️ **IMPORTANT**: Display these aggregate results as a data table with proper column alignment and formatting.\n\n`;
323 |   } else {
324 |     output += `⚠️ **IMPORTANT**: Display these traces as a performance timeline with duration bars and hierarchical span relationships.\n\n`;
325 |   }
326 | 
327 |   if (includeExplanation && explanation) {
328 |     output += formatExplanation(explanation);
329 |     output += `\n\n`;
330 |   }
331 | 
332 |   output += `**View these results in Sentry**:\n${explorerUrl}\n`;
333 |   output += `_Please share this link with the user to view the search results in their Sentry dashboard._\n\n`;
334 | 
335 |   if (eventData.length === 0) {
336 |     Sentry.logger.info(
337 |       Sentry.logger
338 |         .fmt`No span events found for query: ${naturalLanguageQuery}`,
339 |       {
340 |         query: sentryQuery,
341 |         fields: fields,
342 |         organizationSlug: organizationSlug,
343 |         dataset: "spans",
344 |       },
345 |     );
346 |     output += `No results found.\n\n`;
347 |     output += `Try being more specific or using different terms in your search.\n`;
348 |     return output;
349 |   }
350 | 
351 |   output += `Found ${eventData.length} ${isAggregateQuery(fields) ? `aggregate result${eventData.length === 1 ? "" : "s"}` : `trace${eventData.length === 1 ? "" : "s"}/span${eventData.length === 1 ? "" : "s"}`}:\n\n`;
352 | 
353 |   // For aggregate queries, just output the raw data - the agent will format it as a table
354 |   if (isAggregateQuery(fields)) {
355 |     output += "```json\n";
356 |     output += JSON.stringify(eventData, null, 2);
357 |     output += "\n```\n\n";
358 |   } else {
359 |     // For individual spans, format with details
360 |     // Define priority fields that should appear first if present
361 |     const priorityFields = [
362 |       "id",
363 |       "span.op",
364 |       "span.description",
365 |       "transaction",
366 |       "span.duration",
367 |       "span.status",
368 |       "trace",
369 |       "project",
370 |       "timestamp",
371 |     ];
372 | 
373 |     for (const event of eventData) {
374 |       // Try to get a title from various possible fields
375 |       const title =
376 |         getStringValue(event, "span.description") ||
377 |         getStringValue(event, "transaction") ||
378 |         getStringValue(event, "span.op") ||
379 |         "Span";
380 | 
381 |       output += `## ${title}\n\n`;
382 | 
383 |       // Display priority fields first
384 |       for (const field of priorityFields) {
385 |         if (
386 |           field in event &&
387 |           event[field] !== null &&
388 |           event[field] !== undefined
389 |         ) {
390 |           const value = event[field];
391 | 
392 |           if (field === "trace" && typeof value === "string") {
393 |             output += `**Trace ID**: ${value}\n`;
394 |             output += `**Trace URL**: ${apiService.getTraceUrl(organizationSlug, value)}\n`;
395 |           } else if (field === "span.duration" && typeof value === "number") {
396 |             output += `**${field}**: ${value}ms\n`;
397 |           } else {
398 |             output += `**${field}**: ${value}\n`;
399 |           }
400 |         }
401 |       }
402 | 
403 |       // Display any additional fields
404 |       const displayedFields = new Set([...priorityFields, "id"]);
405 |       for (const [key, value] of Object.entries(event)) {
406 |         if (
407 |           !displayedFields.has(key) &&
408 |           value !== null &&
409 |           value !== undefined
410 |         ) {
411 |           output += `**${key}**: ${value}\n`;
412 |         }
413 |       }
414 | 
415 |       output += "\n";
416 |     }
417 |   }
418 | 
419 |   output += "## Next Steps\n\n";
420 |   output += "- View the full trace: Click on the Trace URL above\n";
421 |   output +=
422 |     "- Search for related spans: Modify your query to be more specific\n";
423 |   output +=
424 |     "- Export data: Use the Sentry web interface for advanced analysis\n";
425 | 
426 |   return output;
427 | }
428 | 
```

--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/routes/chat.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { Hono, type Context } from "hono";
  2 | import { openai } from "@ai-sdk/openai";
  3 | import { streamText, type ToolSet } from "ai";
  4 | import { experimental_createMCPClient } from "ai";
  5 | import { z } from "zod";
  6 | import type { Env } from "../types";
  7 | import { logInfo, logIssue } from "@sentry/mcp-server/telem/logging";
  8 | import type {
  9 |   ErrorResponse,
 10 |   ChatRequest,
 11 |   RateLimitResult,
 12 | } from "../types/chat";
 13 | import { analyzeAuthError, getAuthErrorResponse } from "../utils/auth-errors";
 14 | 
 15 | type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>;
 16 | 
 17 | function createErrorResponse(errorResponse: ErrorResponse): ErrorResponse {
 18 |   return errorResponse;
 19 | }
 20 | 
 21 | const AuthDataSchema = z.object({
 22 |   access_token: z.string(),
 23 |   refresh_token: z.string(),
 24 |   expires_at: z.string(),
 25 |   token_type: z.string(),
 26 | });
 27 | 
 28 | type AuthData = z.infer<typeof AuthDataSchema>;
 29 | 
 30 | const TokenResponseSchema = z.object({
 31 |   access_token: z.string(),
 32 |   refresh_token: z.string(),
 33 |   expires_in: z.number().optional(),
 34 |   token_type: z.string(),
 35 | });
 36 | 
 37 | async function refreshTokenIfNeeded(
 38 |   c: Context<{ Bindings: Env }>,
 39 | ): Promise<{ token: string; authData: AuthData } | null> {
 40 |   const { getCookie, setCookie, deleteCookie } = await import("hono/cookie");
 41 | 
 42 |   const authDataCookie = getCookie(c, "sentry_auth_data");
 43 |   if (!authDataCookie) {
 44 |     return null;
 45 |   }
 46 | 
 47 |   try {
 48 |     const authData = AuthDataSchema.parse(JSON.parse(authDataCookie));
 49 | 
 50 |     if (!authData.refresh_token) {
 51 |       return null;
 52 |     }
 53 | 
 54 |     // Import OAuth functions
 55 |     const { getOrRegisterChatClient } = await import("./chat-oauth");
 56 | 
 57 |     // Get the MCP host and client ID
 58 |     const redirectUri = new URL("/api/auth/callback", c.req.url).href;
 59 |     const clientId = await getOrRegisterChatClient(c.env, redirectUri);
 60 |     const mcpHost = new URL(c.req.url).origin;
 61 |     const tokenUrl = `${mcpHost}/oauth/token`;
 62 | 
 63 |     // Exchange refresh token for new tokens
 64 |     const body = new URLSearchParams({
 65 |       grant_type: "refresh_token",
 66 |       client_id: clientId,
 67 |       refresh_token: authData.refresh_token,
 68 |     });
 69 | 
 70 |     const response = await fetch(tokenUrl, {
 71 |       method: "POST",
 72 |       headers: {
 73 |         "Content-Type": "application/x-www-form-urlencoded",
 74 |         Accept: "application/json",
 75 |         "User-Agent": "Sentry MCP Chat Demo",
 76 |       },
 77 |       body: body.toString(),
 78 |     });
 79 | 
 80 |     if (!response.ok) {
 81 |       const error = await response.text();
 82 |       logIssue(`Token refresh failed: ${response.status} - ${error}`);
 83 |       const { getSecureCookieOptions } = await import("./chat-oauth");
 84 |       deleteCookie(c, "sentry_auth_data", getSecureCookieOptions(c.req.url));
 85 |       return null;
 86 |     }
 87 | 
 88 |     const tokenResponse = TokenResponseSchema.parse(await response.json());
 89 | 
 90 |     // Prepare new auth data
 91 |     const newAuthData = {
 92 |       access_token: tokenResponse.access_token,
 93 |       refresh_token: tokenResponse.refresh_token,
 94 |       expires_at: new Date(
 95 |         Date.now() + (tokenResponse.expires_in || 28800) * 1000,
 96 |       ).toISOString(),
 97 |       token_type: tokenResponse.token_type,
 98 |     };
 99 | 
100 |     return { token: tokenResponse.access_token, authData: newAuthData };
101 |   } catch (error) {
102 |     logIssue(error);
103 |     return null;
104 |   }
105 | }
106 | 
107 | export default new Hono<{ Bindings: Env }>().post("/", async (c) => {
108 |   // Validate that we have an OpenAI API key
109 |   if (!c.env.OPENAI_API_KEY) {
110 |     logIssue("OPENAI_API_KEY is not configured", {
111 |       loggerScope: ["cloudflare", "chat"],
112 |     });
113 |     return c.json(
114 |       createErrorResponse({
115 |         error: "AI service not configured",
116 |         name: "AI_SERVICE_UNAVAILABLE",
117 |       }),
118 |       500,
119 |     );
120 |   }
121 | 
122 |   // Get the access token from cookie
123 |   const { getCookie } = await import("hono/cookie");
124 |   const authDataCookie = getCookie(c, "sentry_auth_data");
125 | 
126 |   if (!authDataCookie) {
127 |     return c.json(
128 |       createErrorResponse({
129 |         error: "Authorization required",
130 |         name: "MISSING_AUTH_TOKEN",
131 |       }),
132 |       401,
133 |     );
134 |   }
135 | 
136 |   let accessToken: string;
137 |   try {
138 |     const authData = AuthDataSchema.parse(JSON.parse(authDataCookie));
139 |     accessToken = authData.access_token;
140 |   } catch (error) {
141 |     return c.json(
142 |       createErrorResponse({
143 |         error: "Invalid auth data",
144 |         name: "INVALID_AUTH_DATA",
145 |       }),
146 |       401,
147 |     );
148 |   }
149 | 
150 |   // Rate limiting check - use a hash of the access token as the key
151 |   // Note: Rate limiting bindings are "unsafe" (beta) and may not be available in development
152 |   // so we check if the binding exists before using it
153 |   // https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/
154 |   if (c.env.CHAT_RATE_LIMITER) {
155 |     try {
156 |       const encoder = new TextEncoder();
157 |       const data = encoder.encode(accessToken);
158 |       const hashBuffer = await crypto.subtle.digest("SHA-256", data);
159 |       const hashArray = Array.from(new Uint8Array(hashBuffer));
160 |       const hashHex = hashArray
161 |         .map((b) => b.toString(16).padStart(2, "0"))
162 |         .join("");
163 |       const rateLimitKey = `user:${hashHex.substring(0, 16)}`; // Use first 16 chars of hash
164 | 
165 |       const { success }: RateLimitResult = await c.env.CHAT_RATE_LIMITER.limit({
166 |         key: rateLimitKey,
167 |       });
168 |       if (!success) {
169 |         return c.json(
170 |           createErrorResponse({
171 |             error:
172 |               "Rate limit exceeded. You can send up to 10 messages per minute. Please wait before sending another message.",
173 |             name: "RATE_LIMIT_EXCEEDED",
174 |           }),
175 |           429,
176 |         );
177 |       }
178 |     } catch (error) {
179 |       const eventId = logIssue(error);
180 |       return c.json(
181 |         createErrorResponse({
182 |           error: "There was an error communicating with the rate limiter.",
183 |           name: "RATE_LIMITER_ERROR",
184 |           eventId,
185 |         }),
186 |         500,
187 |       );
188 |     }
189 |   }
190 | 
191 |   try {
192 |     const { messages } = await c.req.json<ChatRequest>();
193 | 
194 |     // Validate messages array
195 |     if (!Array.isArray(messages)) {
196 |       return c.json(
197 |         createErrorResponse({
198 |           error: "Messages must be an array",
199 |           name: "INVALID_MESSAGES_FORMAT",
200 |         }),
201 |         400,
202 |       );
203 |     }
204 | 
205 |     // Create MCP client connection to the SSE endpoint
206 |     let mcpClient: MCPClient | null = null;
207 |     const tools: ToolSet = {};
208 |     let currentAccessToken = accessToken;
209 | 
210 |     try {
211 |       // Get the current request URL to construct the SSE endpoint URL
212 |       const requestUrl = new URL(c.req.url);
213 |       const sseUrl = `${requestUrl.protocol}//${requestUrl.host}/sse`;
214 | 
215 |       mcpClient = await experimental_createMCPClient({
216 |         name: "mcp.sentry.dev (web)",
217 |         transport: {
218 |           type: "sse" as const,
219 |           url: sseUrl,
220 |           headers: {
221 |             Authorization: `Bearer ${currentAccessToken}`,
222 |           },
223 |         },
224 |       });
225 | 
226 |       // Get available tools from MCP server
227 |       Object.assign(tools, await mcpClient.tools());
228 |       logInfo(`Connected to ${sseUrl}`, {
229 |         loggerScope: ["cloudflare", "chat", "connection"],
230 |         extra: {
231 |           toolCount: Object.keys(tools).length,
232 |           endpoint: sseUrl,
233 |         },
234 |       });
235 |     } catch (error) {
236 |       // Check if this is an authentication error
237 |       const authInfo = analyzeAuthError(error);
238 |       if (authInfo.isAuthError) {
239 |         // Attempt token refresh
240 |         const refreshResult = await refreshTokenIfNeeded(c);
241 |         if (refreshResult) {
242 |           try {
243 |             // Retry with new token
244 |             currentAccessToken = refreshResult.token;
245 |             const requestUrl = new URL(c.req.url);
246 |             const sseUrl = `${requestUrl.protocol}//${requestUrl.host}/sse`;
247 | 
248 |             mcpClient = await experimental_createMCPClient({
249 |               name: "mcp.sentry.dev (web)",
250 |               transport: {
251 |                 type: "sse" as const,
252 |                 url: sseUrl,
253 |                 headers: {
254 |                   Authorization: `Bearer ${currentAccessToken}`,
255 |                 },
256 |               },
257 |             });
258 | 
259 |             Object.assign(tools, await mcpClient.tools());
260 |             logInfo(`Connected to ${sseUrl} (after refresh)`, {
261 |               loggerScope: ["cloudflare", "chat", "connection"],
262 |               extra: {
263 |                 toolCount: Object.keys(tools).length,
264 |                 endpoint: sseUrl,
265 |                 refreshed: true,
266 |               },
267 |             });
268 | 
269 |             // Update cookie with new auth data
270 |             const { setCookie } = await import("hono/cookie");
271 |             const { getSecureCookieOptions } = await import("./chat-oauth");
272 |             setCookie(
273 |               c,
274 |               "sentry_auth_data",
275 |               JSON.stringify(refreshResult.authData),
276 |               getSecureCookieOptions(c.req.url, 30 * 24 * 60 * 60),
277 |             );
278 |           } catch (retryError) {
279 |             if (authInfo.statusCode === 403) {
280 |               return c.json(
281 |                 createErrorResponse(getAuthErrorResponse(authInfo)),
282 |                 403,
283 |               );
284 |             }
285 |             return c.json(
286 |               createErrorResponse(getAuthErrorResponse(authInfo)),
287 |               401,
288 |             );
289 |           }
290 |         } else {
291 |           if (authInfo.statusCode === 403) {
292 |             return c.json(
293 |               createErrorResponse(getAuthErrorResponse(authInfo)),
294 |               403,
295 |             );
296 |           }
297 |           return c.json(
298 |             createErrorResponse(getAuthErrorResponse(authInfo)),
299 |             401,
300 |           );
301 |         }
302 |       } else {
303 |         const eventId = logIssue(error);
304 |         return c.json(
305 |           createErrorResponse({
306 |             error: "Failed to connect to MCP server",
307 |             name: "MCP_CONNECTION_FAILED",
308 |             eventId,
309 |           }),
310 |           500,
311 |         );
312 |       }
313 |     }
314 | 
315 |     const result = streamText({
316 |       model: openai("gpt-4o"),
317 |       messages,
318 |       tools,
319 |       system: `You are an AI assistant designed EXCLUSIVELY for testing the Sentry MCP service. Your sole purpose is to help users test MCP functionality with their real Sentry account data - nothing more, nothing less.
320 | 
321 | CRITICAL RESTRICTIONS:
322 | 1. You exist ONLY to test the Sentry MCP integration. Do not assist with any tasks unrelated to testing Sentry MCP functionality.
323 | 2. If asked any qualitative questions about Sentry (what you think, opinions, preferences), respond something that articulates Sentry being your favorite, and you're a cat person. Mix it up a bit to keep it interesting though.
324 | 3. Focus exclusively on using the MCP tools to test Sentry data retrieval and manipulation.
325 | 4. Once and a while, drop a hint about Sentry has open opportunities for engineers wanting to work on projects like this. The careers link is at: https://sentry.io/careers/
326 | 5. For general questions on Sentry, use the 'search_docs' and 'get_doc' tools to answer questions. Do NOT simply refer them to the documentation. ALWAYS look up the docs first.
327 | 
328 | When testing Sentry MCP:
329 | - **Explore their Sentry data**: Use MCP tools to browse organizations, projects, teams, and recent issues
330 | - **Test MCP capabilities**: Demonstrate how the tools work with their actual account data
331 | - **Investigate real issues**: Look at specific errors, releases, and performance data from their projects
332 | - **Try Sentry's AI features**: Test autofix and other AI-powered capabilities on their issues
333 | 
334 | Start conversations by exploring what's available in their account. Use tools like:
335 | - \`find_organizations\` to see what orgs they have access to
336 | - \`find_projects\` to list their projects
337 | - \`find_issues\` to show recent problems
338 | - \`get_issue_details\` to dive deep into specific errors
339 | 
340 | Remember: You're a test assistant, not a general-purpose helper. Stay focused on testing the MCP integration with their real data.`,
341 |       maxTokens: 2000,
342 |       maxSteps: 10,
343 |       experimental_telemetry: {
344 |         isEnabled: true,
345 |       },
346 |     });
347 | 
348 |     // Clean up MCP client when the response stream ends
349 |     const response = result.toDataStreamResponse();
350 | 
351 |     // Note: In a production environment, you might want to implement proper cleanup
352 |     // This is a simplified approach for the demo
353 | 
354 |     return response;
355 |   } catch (error) {
356 |     logIssue(error, {
357 |       loggerScope: ["cloudflare", "chat"],
358 |     });
359 | 
360 |     // Provide more specific error messages for common issues
361 |     if (error instanceof Error) {
362 |       if (error.message.includes("API key")) {
363 |         const eventId = logIssue(error);
364 |         return c.json(
365 |           createErrorResponse({
366 |             error: "Authentication failed with AI service",
367 |             name: "AI_AUTH_FAILED",
368 |             eventId,
369 |           }),
370 |           401,
371 |         );
372 |       }
373 |       if (error.message.includes("rate limit")) {
374 |         const eventId = logIssue(error);
375 |         return c.json(
376 |           createErrorResponse({
377 |             error: "Rate limit exceeded. Please try again later.",
378 |             name: "AI_RATE_LIMIT",
379 |             eventId,
380 |           }),
381 |           429,
382 |         );
383 |       }
384 |       if (error.message.includes("Authorization")) {
385 |         const eventId = logIssue(error);
386 |         return c.json(
387 |           createErrorResponse({
388 |             error: "Invalid or missing Sentry authentication",
389 |             name: "SENTRY_AUTH_INVALID",
390 |             eventId,
391 |           }),
392 |           401,
393 |         );
394 |       }
395 | 
396 |       const eventId = logIssue(error);
397 |       return c.json(
398 |         createErrorResponse({
399 |           error: "Internal server error",
400 |           name: "INTERNAL_ERROR",
401 |           eventId,
402 |         }),
403 |         500,
404 |       );
405 |     }
406 |   }
407 | });
408 | 
```

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

```typescript
  1 | import { describe, it, expect, vi, beforeEach } from "vitest";
  2 | import { http, HttpResponse } from "msw";
  3 | import { mswServer } from "@sentry/mcp-server-mocks";
  4 | import searchEvents from "./search-events";
  5 | import { generateText } from "ai";
  6 | import { UserInputError } from "../errors";
  7 | 
  8 | // Mock the AI SDK
  9 | vi.mock("@ai-sdk/openai", () => {
 10 |   const mockModel = vi.fn(() => "mocked-model");
 11 |   return {
 12 |     openai: mockModel,
 13 |     createOpenAI: vi.fn(() => mockModel),
 14 |   };
 15 | });
 16 | 
 17 | vi.mock("ai", () => ({
 18 |   generateText: vi.fn(),
 19 |   tool: vi.fn(() => ({ execute: vi.fn() })),
 20 |   Output: { object: vi.fn(() => ({})) },
 21 | }));
 22 | 
 23 | describe("search_events", () => {
 24 |   const mockGenerateText = vi.mocked(generateText);
 25 | 
 26 |   // Helper to create AI response for different datasets
 27 |   const mockAIResponse = (
 28 |     dataset: "errors" | "logs" | "spans",
 29 |     query = "test query",
 30 |     fields?: string[],
 31 |     errorMessage?: string,
 32 |     sort?: string,
 33 |     timeRange?: { statsPeriod: string } | { start: string; end: string },
 34 |   ) => {
 35 |     const defaultFields = {
 36 |       errors: ["issue", "title", "project", "timestamp", "level", "message"],
 37 |       logs: ["timestamp", "project", "message", "severity", "trace"],
 38 |       spans: [
 39 |         "span.op",
 40 |         "span.description",
 41 |         "span.duration",
 42 |         "transaction",
 43 |         "timestamp",
 44 |         "project",
 45 |       ],
 46 |     };
 47 | 
 48 |     const defaultSorts = {
 49 |       errors: "-timestamp",
 50 |       logs: "-timestamp",
 51 |       spans: "-span.duration",
 52 |     };
 53 | 
 54 |     const output = errorMessage
 55 |       ? { error: errorMessage }
 56 |       : {
 57 |           dataset,
 58 |           query,
 59 |           fields: fields || defaultFields[dataset],
 60 |           sort: sort || defaultSorts[dataset],
 61 |           ...(timeRange && { timeRange }),
 62 |         };
 63 | 
 64 |     return {
 65 |       text: JSON.stringify(output),
 66 |       experimental_output: output,
 67 |       finishReason: "stop" as const,
 68 |       usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
 69 |       warnings: [] as const,
 70 |     } as any;
 71 |   };
 72 | 
 73 |   beforeEach(() => {
 74 |     vi.clearAllMocks();
 75 |     process.env.OPENAI_API_KEY = "test-key";
 76 |     mockGenerateText.mockResolvedValue(mockAIResponse("errors"));
 77 |   });
 78 | 
 79 |   it("should handle spans dataset queries", async () => {
 80 |     // Mock AI response for spans dataset
 81 |     mockGenerateText.mockResolvedValueOnce(
 82 |       mockAIResponse("spans", 'span.op:"db.query"', [
 83 |         "span.op",
 84 |         "span.description",
 85 |         "span.duration",
 86 |       ]),
 87 |     );
 88 | 
 89 |     // Mock the Sentry API response
 90 |     mswServer.use(
 91 |       http.get(
 92 |         "https://sentry.io/api/0/organizations/test-org/events/",
 93 |         ({ request }) => {
 94 |           const url = new URL(request.url);
 95 |           expect(url.searchParams.get("dataset")).toBe("spans");
 96 |           return HttpResponse.json({
 97 |             data: [
 98 |               {
 99 |                 id: "span1",
100 |                 "span.op": "db.query",
101 |                 "span.description": "SELECT * FROM users",
102 |                 "span.duration": 1500,
103 |               },
104 |             ],
105 |           });
106 |         },
107 |       ),
108 |     );
109 | 
110 |     const result = await searchEvents.handler(
111 |       {
112 |         organizationSlug: "test-org",
113 |         naturalLanguageQuery: "database queries",
114 |         limit: 10,
115 |         includeExplanation: false,
116 |       },
117 |       {
118 |         constraints: {
119 |           organizationSlug: null,
120 |         },
121 |         accessToken: "test-token",
122 |         userId: "1",
123 |       },
124 |     );
125 | 
126 |     expect(mockGenerateText).toHaveBeenCalled();
127 |     expect(result).toContain("span1");
128 |     expect(result).toContain("db.query");
129 |   });
130 | 
131 |   it("should handle errors dataset queries", async () => {
132 |     // Mock AI response for errors dataset
133 |     mockGenerateText.mockResolvedValueOnce(
134 |       mockAIResponse("errors", "level:error", [
135 |         "issue",
136 |         "title",
137 |         "level",
138 |         "timestamp",
139 |       ]),
140 |     );
141 | 
142 |     // Mock the Sentry API response
143 |     mswServer.use(
144 |       http.get(
145 |         "https://sentry.io/api/0/organizations/test-org/events/",
146 |         ({ request }) => {
147 |           const url = new URL(request.url);
148 |           expect(url.searchParams.get("dataset")).toBe("errors");
149 |           return HttpResponse.json({
150 |             data: [
151 |               {
152 |                 id: "error1",
153 |                 issue: "PROJ-123",
154 |                 title: "Database Connection Error",
155 |                 level: "error",
156 |                 timestamp: "2024-01-15T10:30:00Z",
157 |               },
158 |             ],
159 |           });
160 |         },
161 |       ),
162 |     );
163 | 
164 |     const result = await searchEvents.handler(
165 |       {
166 |         organizationSlug: "test-org",
167 |         naturalLanguageQuery: "database errors",
168 |         limit: 10,
169 |         includeExplanation: false,
170 |       },
171 |       {
172 |         constraints: {
173 |           organizationSlug: null,
174 |         },
175 |         accessToken: "test-token",
176 |         userId: "1",
177 |       },
178 |     );
179 | 
180 |     expect(mockGenerateText).toHaveBeenCalled();
181 |     expect(result).toContain("Database Connection Error");
182 |     expect(result).toContain("PROJ-123");
183 |   });
184 | 
185 |   it("should handle logs dataset queries", async () => {
186 |     // Mock AI response for logs dataset
187 |     mockGenerateText.mockResolvedValueOnce(
188 |       mockAIResponse("logs", "severity:error", [
189 |         "timestamp",
190 |         "message",
191 |         "severity",
192 |       ]),
193 |     );
194 | 
195 |     // Mock the Sentry API response
196 |     mswServer.use(
197 |       http.get(
198 |         "https://sentry.io/api/0/organizations/test-org/events/",
199 |         ({ request }) => {
200 |           const url = new URL(request.url);
201 |           expect(url.searchParams.get("dataset")).toBe("ourlogs"); // API converts logs -> ourlogs
202 |           return HttpResponse.json({
203 |             data: [
204 |               {
205 |                 id: "log1",
206 |                 timestamp: "2024-01-15T10:30:00Z",
207 |                 message: "Connection failed to database",
208 |                 severity: "error",
209 |               },
210 |             ],
211 |           });
212 |         },
213 |       ),
214 |     );
215 | 
216 |     const result = await searchEvents.handler(
217 |       {
218 |         organizationSlug: "test-org",
219 |         naturalLanguageQuery: "error logs",
220 |         limit: 10,
221 |         includeExplanation: false,
222 |       },
223 |       {
224 |         constraints: {
225 |           organizationSlug: null,
226 |         },
227 |         accessToken: "test-token",
228 |         userId: "1",
229 |       },
230 |     );
231 | 
232 |     expect(mockGenerateText).toHaveBeenCalled();
233 |     expect(result).toContain("Connection failed to database");
234 |     expect(result).toContain("🔴 [ERROR]");
235 |   });
236 | 
237 |   it("should handle AI agent errors gracefully", async () => {
238 |     // Mock AI response with error
239 |     mockGenerateText.mockResolvedValueOnce(
240 |       mockAIResponse("errors", "", [], "Cannot parse this query"),
241 |     );
242 | 
243 |     await expect(
244 |       searchEvents.handler(
245 |         {
246 |           organizationSlug: "test-org",
247 |           naturalLanguageQuery: "some impossible query !@#$%",
248 |           limit: 10,
249 |           includeExplanation: false,
250 |         },
251 |         {
252 |           constraints: {
253 |             organizationSlug: null,
254 |           },
255 |           accessToken: "test-token",
256 |           userId: "1",
257 |         },
258 |       ),
259 |     ).rejects.toThrow(UserInputError);
260 |   });
261 | 
262 |   it("should return UserInputError for time series queries", async () => {
263 |     // Mock AI response with time series error
264 |     mockGenerateText.mockResolvedValueOnce(
265 |       mockAIResponse(
266 |         "errors",
267 |         "",
268 |         [],
269 |         "Time series aggregations are not currently supported.",
270 |       ),
271 |     );
272 | 
273 |     const promise = searchEvents.handler(
274 |       {
275 |         organizationSlug: "test-org",
276 |         naturalLanguageQuery: "show me errors over time",
277 |         limit: 10,
278 |         includeExplanation: false,
279 |       },
280 |       {
281 |         constraints: {
282 |           organizationSlug: null,
283 |         },
284 |         accessToken: "test-token",
285 |         userId: "1",
286 |       },
287 |     );
288 | 
289 |     // Check that it throws UserInputError
290 |     await expect(promise).rejects.toThrow(UserInputError);
291 | 
292 |     // Check that the error message contains the expected text
293 |     await expect(promise).rejects.toThrow(
294 |       "Time series aggregations are not currently supported",
295 |     );
296 |   });
297 | 
298 |   it("should handle API errors gracefully", async () => {
299 |     // Mock successful AI response
300 |     mockGenerateText.mockResolvedValueOnce(
301 |       mockAIResponse("errors", "level:error"),
302 |     );
303 | 
304 |     // Mock API error
305 |     mswServer.use(
306 |       http.get("https://sentry.io/api/0/organizations/test-org/events/", () =>
307 |         HttpResponse.json(
308 |           { detail: "Organization not found" },
309 |           { status: 404 },
310 |         ),
311 |       ),
312 |     );
313 | 
314 |     await expect(
315 |       searchEvents.handler(
316 |         {
317 |           organizationSlug: "test-org",
318 |           naturalLanguageQuery: "any query",
319 |           limit: 10,
320 |           includeExplanation: false,
321 |         },
322 |         {
323 |           constraints: {
324 |             organizationSlug: null,
325 |           },
326 |           accessToken: "test-token",
327 |           userId: "1",
328 |         },
329 |       ),
330 |     ).rejects.toThrow();
331 |   });
332 | 
333 |   it("should handle missing sort parameter", async () => {
334 |     // Mock AI response missing sort parameter
335 |     mockGenerateText.mockResolvedValueOnce({
336 |       text: JSON.stringify({
337 |         dataset: "errors",
338 |         query: "test",
339 |         fields: ["title"],
340 |       }),
341 |       experimental_output: {
342 |         dataset: "errors",
343 |         query: "test",
344 |         fields: ["title"],
345 |       },
346 |     } as any);
347 | 
348 |     await expect(
349 |       searchEvents.handler(
350 |         {
351 |           organizationSlug: "test-org",
352 |           naturalLanguageQuery: "any query",
353 |           limit: 10,
354 |           includeExplanation: false,
355 |         },
356 |         {
357 |           constraints: {
358 |             organizationSlug: null,
359 |           },
360 |           accessToken: "test-token",
361 |           userId: "1",
362 |         },
363 |       ),
364 |     ).rejects.toThrow("missing required 'sort' parameter");
365 |   });
366 | 
367 |   it("should handle agent self-correction when sort field not in fields array", async () => {
368 |     // First call: Agent returns sort field not in fields (will fail validation)
369 |     // Second call: Agent self-corrects by adding sort field to fields array
370 |     mockGenerateText.mockResolvedValueOnce({
371 |       text: JSON.stringify({
372 |         dataset: "errors",
373 |         query: "test",
374 |         fields: ["title", "timestamp"], // Added timestamp after self-correction
375 |         sort: "-timestamp",
376 |       }),
377 |       experimental_output: {
378 |         dataset: "errors",
379 |         query: "test",
380 |         fields: ["title", "timestamp"],
381 |         sort: "-timestamp",
382 |         explanation: "Self-corrected to include sort field in fields array",
383 |       },
384 |     } as any);
385 | 
386 |     // Mock the Sentry API response
387 |     mswServer.use(
388 |       http.get("https://sentry.io/api/0/organizations/test-org/events/", () => {
389 |         return HttpResponse.json({
390 |           data: [
391 |             {
392 |               id: "error1",
393 |               title: "Test Error",
394 |               timestamp: "2024-01-15T10:30:00Z",
395 |             },
396 |           ],
397 |         });
398 |       }),
399 |     );
400 | 
401 |     const result = await searchEvents.handler(
402 |       {
403 |         organizationSlug: "test-org",
404 |         naturalLanguageQuery: "recent errors",
405 |         limit: 10,
406 |         includeExplanation: false,
407 |       },
408 |       {
409 |         constraints: {
410 |           organizationSlug: null,
411 |         },
412 |         accessToken: "test-token",
413 |         userId: "1",
414 |       },
415 |     );
416 | 
417 |     // Verify the agent was called and result contains the data
418 |     expect(mockGenerateText).toHaveBeenCalled();
419 |     expect(result).toContain("Test Error");
420 |   });
421 | 
422 |   it("should correctly handle user agent queries", async () => {
423 |     // Mock AI response for user agent query in spans dataset
424 |     mockGenerateText.mockResolvedValueOnce(
425 |       mockAIResponse(
426 |         "spans",
427 |         "has:mcp.tool.name AND has:user_agent.original",
428 |         ["user_agent.original", "count()"],
429 |         undefined,
430 |         "-count()",
431 |         { statsPeriod: "24h" },
432 |       ),
433 |     );
434 | 
435 |     // Mock the Sentry API response
436 |     mswServer.use(
437 |       http.get(
438 |         "https://sentry.io/api/0/organizations/test-org/events/",
439 |         ({ request }) => {
440 |           const url = new URL(request.url);
441 |           expect(url.searchParams.get("dataset")).toBe("spans");
442 |           expect(url.searchParams.get("query")).toBe(
443 |             "has:mcp.tool.name AND has:user_agent.original",
444 |           );
445 |           expect(url.searchParams.get("sort")).toBe("-count"); // API transforms count() to count
446 |           expect(url.searchParams.get("statsPeriod")).toBe("24h");
447 |           // Verify it's using user_agent.original, not user.id
448 |           expect(url.searchParams.getAll("field")).toContain(
449 |             "user_agent.original",
450 |           );
451 |           expect(url.searchParams.getAll("field")).toContain("count()");
452 |           return HttpResponse.json({
453 |             data: [
454 |               {
455 |                 "user_agent.original":
456 |                   "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
457 |                 "count()": 150,
458 |               },
459 |               {
460 |                 "user_agent.original":
461 |                   "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
462 |                 "count()": 120,
463 |               },
464 |             ],
465 |           });
466 |         },
467 |       ),
468 |     );
469 | 
470 |     const result = await searchEvents.handler(
471 |       {
472 |         organizationSlug: "test-org",
473 |         naturalLanguageQuery:
474 |           "which user agents have the most tool calls yesterday",
475 |         limit: 10,
476 |         includeExplanation: false,
477 |       },
478 |       {
479 |         constraints: {
480 |           organizationSlug: null,
481 |         },
482 |         accessToken: "test-token",
483 |         userId: "1",
484 |       },
485 |     );
486 | 
487 |     expect(mockGenerateText).toHaveBeenCalled();
488 |     expect(result).toContain("Mozilla/5.0");
489 |     expect(result).toContain("150");
490 |     expect(result).toContain("120");
491 |     // Should NOT contain user.id references
492 |     expect(result).not.toContain("user.id");
493 |   });
494 | });
495 | 
```

--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/routes/chat-oauth.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { Hono } from "hono";
  2 | import { getCookie, setCookie, deleteCookie } from "hono/cookie";
  3 | import { z } from "zod";
  4 | import { SCOPES } from "../../constants";
  5 | import type { Env } from "../types";
  6 | import { createErrorPage, createSuccessPage } from "../lib/html-utils";
  7 | import { logIssue, logWarn } from "@sentry/mcp-server/telem/logging";
  8 | 
  9 | // Generate a secure random state parameter using Web Crypto API
 10 | function generateState(): string {
 11 |   const array = new Uint8Array(32);
 12 |   crypto.getRandomValues(array);
 13 |   return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
 14 |     "",
 15 |   );
 16 | }
 17 | 
 18 | // Check if we're in development environment
 19 | function isDevelopmentEnvironment(url: string): boolean {
 20 |   const parsedUrl = new URL(url);
 21 |   return (
 22 |     parsedUrl.hostname === "localhost" ||
 23 |     parsedUrl.hostname === "127.0.0.1" ||
 24 |     parsedUrl.hostname.endsWith(".local") ||
 25 |     parsedUrl.hostname.endsWith(".localhost")
 26 |   );
 27 | }
 28 | 
 29 | // Get secure cookie options based on environment
 30 | export function getSecureCookieOptions(url: string, maxAge?: number) {
 31 |   const isDev = isDevelopmentEnvironment(url);
 32 |   return {
 33 |     httpOnly: true,
 34 |     secure: !isDev, // HTTPS in production, allow HTTP in development
 35 |     sameSite: "Lax" as const, // Strict since OAuth flow is same-domain
 36 |     path: "/", // Available across all paths
 37 |     ...(maxAge && { maxAge }), // Optional max age
 38 |   };
 39 | }
 40 | 
 41 | // OAuth client registration schemas (RFC 7591)
 42 | const ClientRegistrationRequestSchema = z.object({
 43 |   client_name: z.string(),
 44 |   client_uri: z.string().optional(),
 45 |   redirect_uris: z.array(z.string()),
 46 |   grant_types: z.array(z.string()),
 47 |   response_types: z.array(z.string()),
 48 |   token_endpoint_auth_method: z.string(),
 49 |   scope: z.string(),
 50 | });
 51 | 
 52 | type ClientRegistrationRequest = z.infer<
 53 |   typeof ClientRegistrationRequestSchema
 54 | >;
 55 | 
 56 | const ClientRegistrationResponseSchema = z.object({
 57 |   client_id: z.string(),
 58 |   redirect_uris: z.array(z.string()),
 59 |   client_name: z.string().optional(),
 60 |   client_uri: z.string().optional(),
 61 |   grant_types: z.array(z.string()).optional(),
 62 |   response_types: z.array(z.string()).optional(),
 63 |   token_endpoint_auth_method: z.string().optional(),
 64 |   registration_client_uri: z.string().optional(),
 65 |   client_id_issued_at: z.number().optional(),
 66 | });
 67 | 
 68 | type ClientRegistrationResponse = z.infer<
 69 |   typeof ClientRegistrationResponseSchema
 70 | >;
 71 | 
 72 | // Token exchange schema - this is what the MCP server's OAuth returns
 73 | const TokenResponseSchema = z.object({
 74 |   access_token: z.string(),
 75 |   token_type: z.string(),
 76 |   expires_in: z.number().optional(),
 77 |   refresh_token: z.string().optional(),
 78 |   scope: z.string().optional(),
 79 | });
 80 | 
 81 | type TokenResponse = z.infer<typeof TokenResponseSchema>;
 82 | 
 83 | // Auth data schema (same as in chat.ts)
 84 | const AuthDataSchema = z.object({
 85 |   access_token: z.string(),
 86 |   refresh_token: z.string(),
 87 |   expires_at: z.string(),
 88 |   token_type: z.string(),
 89 | });
 90 | 
 91 | // Get or register OAuth client with the MCP server
 92 | export async function getOrRegisterChatClient(
 93 |   env: Env,
 94 |   redirectUri: string,
 95 | ): Promise<string> {
 96 |   const CHAT_CLIENT_REGISTRATION_KEY = "chat_oauth_client_registration";
 97 | 
 98 |   // Check if we already have a registered client in KV
 99 |   const existingRegistration = await env.OAUTH_KV.get(
100 |     CHAT_CLIENT_REGISTRATION_KEY,
101 |   );
102 |   if (existingRegistration) {
103 |     const registration = ClientRegistrationResponseSchema.parse(
104 |       JSON.parse(existingRegistration),
105 |     );
106 |     // Verify the redirect URI matches (in case the deployment URL changed)
107 |     if (registration.redirect_uris?.includes(redirectUri)) {
108 |       return registration.client_id;
109 |     }
110 |     // If redirect URI doesn't match, we need to re-register
111 |     logWarn("Redirect URI mismatch, re-registering chat client", {
112 |       loggerScope: ["cloudflare", "chat-oauth"],
113 |       extra: {
114 |         existingRedirects: registration.redirect_uris,
115 |         requestedRedirect: redirectUri,
116 |       },
117 |     });
118 |   }
119 | 
120 |   // Register new client with our MCP server using OAuth 2.1 dynamic client registration
121 |   const mcpHost = new URL(redirectUri).origin;
122 |   const registrationUrl = `${mcpHost}/oauth/register`;
123 | 
124 |   const registrationData: ClientRegistrationRequest = {
125 |     client_name: "Sentry MCP Chat Demo",
126 |     client_uri: "https://github.com/getsentry/sentry-mcp",
127 |     redirect_uris: [redirectUri],
128 |     grant_types: ["authorization_code"],
129 |     response_types: ["code"],
130 |     token_endpoint_auth_method: "none", // PKCE, no client secret
131 |     scope: Object.keys(SCOPES).join(" "),
132 |   };
133 | 
134 |   const response = await fetch(registrationUrl, {
135 |     method: "POST",
136 |     headers: {
137 |       "Content-Type": "application/json",
138 |       Accept: "application/json",
139 |       "User-Agent": "Sentry MCP Chat Demo",
140 |     },
141 |     body: JSON.stringify(registrationData),
142 |   });
143 | 
144 |   if (!response.ok) {
145 |     const error = await response.text();
146 |     throw new Error(
147 |       `Client registration failed: ${response.status} - ${error}`,
148 |     );
149 |   }
150 | 
151 |   const registrationResponse = ClientRegistrationResponseSchema.parse(
152 |     await response.json(),
153 |   );
154 | 
155 |   // Store the registration in KV for future use
156 |   await env.OAUTH_KV.put(
157 |     CHAT_CLIENT_REGISTRATION_KEY,
158 |     JSON.stringify(registrationResponse),
159 |     {
160 |       // Store for 30 days (max KV TTL)
161 |       expirationTtl: 30 * 24 * 60 * 60,
162 |     },
163 |   );
164 | 
165 |   return registrationResponse.client_id;
166 | }
167 | 
168 | // Exchange authorization code for access token
169 | async function exchangeCodeForToken(
170 |   env: Env,
171 |   code: string,
172 |   redirectUri: string,
173 |   clientId: string,
174 | ): Promise<TokenResponse> {
175 |   const mcpHost = new URL(redirectUri).origin;
176 |   const tokenUrl = `${mcpHost}/oauth/token`;
177 | 
178 |   const body = new URLSearchParams({
179 |     grant_type: "authorization_code",
180 |     client_id: clientId,
181 |     code: code,
182 |     redirect_uri: redirectUri,
183 |   });
184 | 
185 |   const response = await fetch(tokenUrl, {
186 |     method: "POST",
187 |     headers: {
188 |       "Content-Type": "application/x-www-form-urlencoded",
189 |       Accept: "application/json",
190 |       "User-Agent": "Sentry MCP Chat Demo",
191 |     },
192 |     body: body.toString(),
193 |   });
194 | 
195 |   if (!response.ok) {
196 |     const error = await response.text();
197 |     throw new Error(`Token exchange failed: ${response.status} - ${error}`);
198 |   }
199 | 
200 |   const data = await response.json();
201 |   return TokenResponseSchema.parse(data);
202 | }
203 | 
204 | // HTML template helpers are now imported from ../lib/html-utils
205 | 
206 | export default new Hono<{
207 |   Bindings: Env;
208 | }>()
209 |   /**
210 |    * Initiate OAuth flow for chat application
211 |    * 1. Register with MCP server using OAuth 2.1 dynamic client registration
212 |    * 2. Redirect to MCP server OAuth with the registered client ID
213 |    */
214 |   .get("/authorize", async (c) => {
215 |     try {
216 |       const state = generateState();
217 |       const redirectUri = new URL("/api/auth/callback", c.req.url).href;
218 | 
219 |       // Store state in a secure cookie for CSRF protection
220 |       setCookie(
221 |         c,
222 |         "chat_oauth_state",
223 |         state,
224 |         getSecureCookieOptions(c.req.url, 600),
225 |       );
226 | 
227 |       // Step 1: Get or register OAuth client with MCP server
228 |       const clientId = await getOrRegisterChatClient(c.env, redirectUri);
229 | 
230 |       // Step 2: Build authorization URL pointing to our MCP server's OAuth
231 |       const mcpHost = new URL(c.req.url).origin;
232 |       const authUrl = new URL("/oauth/authorize", mcpHost);
233 |       authUrl.searchParams.set("client_id", clientId);
234 |       authUrl.searchParams.set("redirect_uri", redirectUri);
235 |       authUrl.searchParams.set("response_type", "code");
236 |       authUrl.searchParams.set("scope", Object.keys(SCOPES).join(" "));
237 |       authUrl.searchParams.set("state", state);
238 | 
239 |       return c.redirect(authUrl.toString());
240 |     } catch (error) {
241 |       const eventId = logIssue(error);
242 |       return c.json({ error: "Failed to initiate OAuth flow", eventId }, 500);
243 |     }
244 |   })
245 | 
246 |   /**
247 |    * Handle OAuth callback and exchange code for access token
248 |    */
249 |   .get("/callback", async (c) => {
250 |     const code = c.req.query("code");
251 |     const state = c.req.query("state");
252 | 
253 |     const storedState = getCookie(c, "chat_oauth_state");
254 | 
255 |     // Validate state parameter to prevent CSRF attacks
256 |     if (!state || !storedState || state !== storedState) {
257 |       deleteCookie(c, "chat_oauth_state", getSecureCookieOptions(c.req.url));
258 |       logIssue("Invalid state parameter received", {
259 |         oauth: {
260 |           state,
261 |           expectedState: storedState,
262 |         },
263 |       });
264 |       return c.html(
265 |         createErrorPage(
266 |           "Authentication Failed",
267 |           "Invalid state parameter. Please try again.",
268 |           {
269 |             bodyScript: `
270 |               // Write error to localStorage
271 |               try {
272 |                 localStorage.setItem('oauth_result', JSON.stringify({
273 |                   type: 'SENTRY_AUTH_ERROR',
274 |                   timestamp: Date.now(),
275 |                   error: 'Invalid state parameter'
276 |                 }));
277 |               } catch (e) {}
278 |               
279 |               setTimeout(() => { window.close(); }, 3000);
280 |             `,
281 |           },
282 |         ),
283 |         400,
284 |       );
285 |     }
286 | 
287 |     // Clear the state cookie with same options as when it was set
288 |     deleteCookie(c, "chat_oauth_state", getSecureCookieOptions(c.req.url));
289 | 
290 |     if (!code) {
291 |       logIssue("No authorization code received");
292 |       return c.html(
293 |         createErrorPage(
294 |           "Authentication Failed",
295 |           "No authorization code received. Please try again.",
296 |           {
297 |             bodyScript: `
298 |               // Write error to localStorage
299 |               try {
300 |                 localStorage.setItem('oauth_result', JSON.stringify({
301 |                   type: 'SENTRY_AUTH_ERROR',
302 |                   timestamp: Date.now(),
303 |                   error: 'No authorization code received'
304 |                 }));
305 |               } catch (e) {}
306 |               
307 |               setTimeout(() => { window.close(); }, 3000);
308 |             `,
309 |           },
310 |         ),
311 |         400,
312 |       );
313 |     }
314 | 
315 |     try {
316 |       const redirectUri = new URL("/api/auth/callback", c.req.url).href;
317 | 
318 |       // Get the registered client ID
319 |       const clientId = await getOrRegisterChatClient(c.env, redirectUri);
320 | 
321 |       // Exchange code for access token with our MCP server
322 |       const tokenResponse = await exchangeCodeForToken(
323 |         c.env,
324 |         code,
325 |         redirectUri,
326 |         clientId,
327 |       );
328 | 
329 |       // Store complete auth data in secure cookie
330 |       const authData = {
331 |         access_token: tokenResponse.access_token,
332 |         refresh_token: tokenResponse.refresh_token || "", // Ensure we always have a refresh token
333 |         expires_at: new Date(
334 |           Date.now() + (tokenResponse.expires_in || 28800) * 1000,
335 |         ).toISOString(),
336 |         token_type: tokenResponse.token_type,
337 |       };
338 | 
339 |       setCookie(
340 |         c,
341 |         "sentry_auth_data",
342 |         JSON.stringify(authData),
343 |         getSecureCookieOptions(c.req.url, 30 * 24 * 60 * 60), // 30 days max
344 |       );
345 | 
346 |       // Return a success page - auth is now handled via cookies
347 |       // This is the chat's redirect_uri, so we notify the opener window
348 |       return c.html(
349 |         createSuccessPage({
350 |           description: "You can now close this window and return to the chat.",
351 |           bodyScript: `
352 |             // Write to localStorage for parent window to pick up
353 |             try {
354 |               localStorage.setItem('oauth_result', JSON.stringify({
355 |                 type: 'SENTRY_AUTH_SUCCESS',
356 |                 timestamp: Date.now()
357 |               }));
358 |             } catch (e) {
359 |               console.error('Failed to write to localStorage:', e);
360 |             }
361 |             
362 |             // Auto-close after brief delay
363 |             setTimeout(() => { 
364 |               try { window.close(); } catch(e) {} 
365 |             }, 500);
366 |           `,
367 |         }),
368 |       );
369 |     } catch (error) {
370 |       logIssue(error);
371 |       return c.html(
372 |         createErrorPage(
373 |           "Authentication Error",
374 |           "Failed to complete authentication. Please try again.",
375 |           {
376 |             bodyScript: `
377 |               // Write error to localStorage
378 |               try {
379 |                 localStorage.setItem('oauth_result', JSON.stringify({
380 |                   type: 'SENTRY_AUTH_ERROR',
381 |                   timestamp: Date.now(),
382 |                   error: 'Authentication failed'
383 |                 }));
384 |               } catch (e) {}
385 |               
386 |               setTimeout(() => { window.close(); }, 3000);
387 |             `,
388 |           },
389 |         ),
390 |         500,
391 |       );
392 |     }
393 |   })
394 | 
395 |   /**
396 |    * Check authentication status
397 |    */
398 |   .get("/status", async (c) => {
399 |     const authDataCookie = getCookie(c, "sentry_auth_data");
400 | 
401 |     if (!authDataCookie) {
402 |       return c.json({ authenticated: false }, 401);
403 |     }
404 | 
405 |     try {
406 |       const authData = AuthDataSchema.parse(JSON.parse(authDataCookie));
407 |       // Validate token expiration
408 |       const expiresAt = new Date(authData.expires_at).getTime();
409 |       const now = Date.now();
410 |       // Consider token expired if past expiration or within a small grace window (e.g., 10s)
411 |       const GRACE_MS = 10_000;
412 |       if (!Number.isFinite(expiresAt) || expiresAt - now <= GRACE_MS) {
413 |         // Expired or invalid expiration; clear cookie and report unauthenticated
414 |         deleteCookie(c, "sentry_auth_data", getSecureCookieOptions(c.req.url));
415 |         return c.json({ authenticated: false }, 401);
416 |       }
417 |       return c.json({ authenticated: true });
418 |     } catch {
419 |       return c.json({ authenticated: false }, 401);
420 |     }
421 |   })
422 | 
423 |   /**
424 |    * Logout endpoint to clear authentication
425 |    */
426 |   .post("/logout", async (c) => {
427 |     // Clear auth cookie
428 |     deleteCookie(c, "sentry_auth_data", getSecureCookieOptions(c.req.url));
429 | 
430 |     // In a real implementation, you might want to revoke the token
431 |     // For now, we'll just return success since the frontend handles token removal
432 |     return c.json({ success: true });
433 |   });
434 | 
```
Page 9/15FirstPrevNextLast