This is page 16 of 20. Use http://codebase.md/getsentry/sentry-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .claude
│ ├── agents
│ │ └── claude-optimizer.md
│ ├── commands
│ │ ├── gh-pr.md
│ │ └── gh-review.md
│ └── settings.json
├── .craft.yml
├── .cursor
│ └── mcp.json
├── .env.example
├── .github
│ └── workflows
│ ├── deploy.yml
│ ├── eval.yml
│ ├── merge-jobs.yml
│ ├── release.yml
│ ├── smoke-tests.yml
│ ├── test.yml
│ └── token-cost.yml
├── .gitignore
├── .mcp.json
├── .vscode
│ ├── extensions.json
│ ├── mcp.json
│ └── settings.json
├── AGENTS.md
├── benchmark-agent.sh
├── bin
│ └── bump-version.sh
├── biome.json
├── CLAUDE.md
├── codecov.yml
├── core
├── docs
│ ├── adding-tools.md
│ ├── api-patterns.md
│ ├── architecture.md
│ ├── cloudflare
│ │ ├── architecture.md
│ │ ├── oauth-architecture.md
│ │ └── overview.md
│ ├── coding-guidelines.md
│ ├── common-patterns.md
│ ├── error-handling.md
│ ├── github-actions.md
│ ├── llms
│ │ ├── document-scopes.md
│ │ ├── documentation-style-guide.md
│ │ └── README.md
│ ├── logging.md
│ ├── monitoring.md
│ ├── pr-management.md
│ ├── quality-checks.md
│ ├── README.md
│ ├── releases
│ │ ├── cloudflare.md
│ │ └── stdio.md
│ ├── search-events-api-patterns.md
│ ├── security.md
│ ├── specs
│ │ ├── README.md
│ │ ├── search-events.md
│ │ └── subpath-constraints.md
│ ├── testing-remote.md
│ ├── testing-stdio.md
│ ├── testing.md
│ └── token-cost-tracking.md
├── LICENSE.md
├── Makefile
├── package.json
├── packages
│ ├── mcp-cloudflare
│ │ ├── .env.example
│ │ ├── components.json
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── demo.cast
│ │ │ ├── favicon.ico
│ │ │ ├── flow-transparent.png
│ │ │ ├── flow.jpg
│ │ │ ├── keycap-⌘.png
│ │ │ ├── keycap-c.png
│ │ │ └── keycap-v.png
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── app.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── animation
│ │ │ │ │ │ ├── browser-ui
│ │ │ │ │ │ │ ├── BrowserWindow.tsx
│ │ │ │ │ │ │ ├── BrowserWindowIconSidebar.tsx
│ │ │ │ │ │ │ ├── DiffBlock.tsx
│ │ │ │ │ │ │ ├── IDEWindow.tsx
│ │ │ │ │ │ │ ├── IssueDetails.tsx
│ │ │ │ │ │ │ ├── keys-copy.tsx
│ │ │ │ │ │ │ ├── LoadingSquares.tsx
│ │ │ │ │ │ │ ├── RootCause.tsx
│ │ │ │ │ │ │ ├── seer-clipmask.tsx
│ │ │ │ │ │ │ ├── seer-noisefilter.tsx
│ │ │ │ │ │ │ ├── seer.tsx
│ │ │ │ │ │ │ └── WindowHeader.tsx
│ │ │ │ │ │ ├── BrowserAnimation.tsx
│ │ │ │ │ │ ├── DataWire.tsx
│ │ │ │ │ │ ├── dracula.css
│ │ │ │ │ │ ├── terminal-ui
│ │ │ │ │ │ │ ├── keys-paste.tsx
│ │ │ │ │ │ │ ├── SpeedDisplay.tsx
│ │ │ │ │ │ │ └── StepsList.tsx
│ │ │ │ │ │ ├── TerminalAnimation.tsx
│ │ │ │ │ │ └── tests.tsx
│ │ │ │ │ ├── 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
│ │ │ │ │ ├── docs
│ │ │ │ │ │ └── toc.tsx
│ │ │ │ │ ├── fragments
│ │ │ │ │ │ ├── install-tabs.tsx
│ │ │ │ │ │ ├── remote-setup.tsx
│ │ │ │ │ │ ├── setup-guide.tsx
│ │ │ │ │ │ └── stdio-setup.tsx
│ │ │ │ │ ├── getting-started.tsx
│ │ │ │ │ ├── hero
│ │ │ │ │ │ ├── header-divider.tsx
│ │ │ │ │ │ └── hero-block.tsx
│ │ │ │ │ ├── home-layout
│ │ │ │ │ │ ├── footer.tsx
│ │ │ │ │ │ └── sidebars.tsx
│ │ │ │ │ ├── ui
│ │ │ │ │ │ ├── accordion.tsx
│ │ │ │ │ │ ├── backdrop.tsx
│ │ │ │ │ │ ├── badge.tsx
│ │ │ │ │ │ ├── base.tsx
│ │ │ │ │ │ ├── button.tsx
│ │ │ │ │ │ ├── code-snippet.tsx
│ │ │ │ │ │ ├── header.tsx
│ │ │ │ │ │ ├── icon.tsx
│ │ │ │ │ │ ├── icons
│ │ │ │ │ │ │ ├── gemini.tsx
│ │ │ │ │ │ │ └── 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
│ │ │ │ │ └── usecases
│ │ │ │ │ ├── fix-bugs.tsx
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── instrument.tsx
│ │ │ │ │ ├── search-things.tsx
│ │ │ │ │ └── search-visual.tsx
│ │ │ │ ├── contexts
│ │ │ │ │ └── auth-context.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ ├── use-endpoint-mode.ts
│ │ │ │ │ ├── use-mcp-metadata.ts
│ │ │ │ │ ├── use-persisted-chat.ts
│ │ │ │ │ ├── use-scroll-lock.ts
│ │ │ │ │ └── use-streaming-simulation.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── instrument.ts
│ │ │ │ ├── lib
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── main.tsx
│ │ │ │ ├── utils
│ │ │ │ │ ├── chat-error-handler.ts
│ │ │ │ │ ├── cursor-deeplink.ts
│ │ │ │ │ └── index.ts
│ │ │ │ └── vite-env.d.ts
│ │ │ ├── constants.ts
│ │ │ ├── server
│ │ │ │ ├── app.test.ts
│ │ │ │ ├── app.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── approval-dialog.test.ts
│ │ │ │ │ ├── approval-dialog.ts
│ │ │ │ │ ├── constraint-utils.test.ts
│ │ │ │ │ ├── constraint-utils.ts
│ │ │ │ │ ├── html-utils.ts
│ │ │ │ │ ├── mcp-handler.test.ts
│ │ │ │ │ ├── mcp-handler.ts
│ │ │ │ │ └── slug-validation.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── oauth
│ │ │ │ │ ├── authorize.test.ts
│ │ │ │ │ ├── callback.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── helpers.test.ts
│ │ │ │ │ ├── helpers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── authorize.ts
│ │ │ │ │ │ ├── callback.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ └── state.ts
│ │ │ │ ├── routes
│ │ │ │ │ ├── chat-oauth.ts
│ │ │ │ │ ├── chat.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── metadata.ts
│ │ │ │ │ ├── search.test.ts
│ │ │ │ │ └── search.ts
│ │ │ │ ├── sentry.config.ts
│ │ │ │ ├── types
│ │ │ │ │ └── chat.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils
│ │ │ │ ├── auth-errors.ts
│ │ │ │ ├── client-ip.test.ts
│ │ │ │ ├── client-ip.ts
│ │ │ │ ├── rate-limiter.test.ts
│ │ │ │ └── rate-limiter.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-core
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── scripts
│ │ │ ├── generate-definitions.ts
│ │ │ ├── generate-otel-namespaces.ts
│ │ │ ├── measure-token-cost.ts
│ │ │ └── validate-skills-mapping.ts
│ │ ├── src
│ │ │ ├── api-client
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── client.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.test.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── types.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── internal
│ │ │ │ ├── agents
│ │ │ │ │ ├── callEmbeddedAgent.ts
│ │ │ │ │ ├── openai-provider.test.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
│ │ │ ├── skillDefinitions.json
│ │ │ ├── skillDefinitions.ts
│ │ │ ├── skills.test.ts
│ │ │ ├── skills.ts
│ │ │ ├── telem
│ │ │ │ ├── index.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── sentry.test.ts
│ │ │ │ └── sentry.ts
│ │ │ ├── test-setup.ts
│ │ │ ├── test-utils
│ │ │ │ └── context.ts
│ │ │ ├── toolDefinitions.json
│ │ │ ├── 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
│ │ │ │ ├── use-sentry
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── handler.test.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── tool-wrapper.test.ts
│ │ │ │ │ └── tool-wrapper.ts
│ │ │ │ ├── whoami.test.ts
│ │ │ │ └── whoami.ts
│ │ │ ├── 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
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── cli
│ │ │ │ ├── parse.test.ts
│ │ │ │ ├── parse.ts
│ │ │ │ ├── resolve.test.ts
│ │ │ │ ├── resolve.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── usage.ts
│ │ │ ├── index.ts
│ │ │ └── transports
│ │ │ └── stdio.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.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
│ │ │ │ ├── csp-event.json
│ │ │ │ ├── csp-issue.json
│ │ │ │ ├── default-event.json
│ │ │ │ ├── event-attachments.json
│ │ │ │ ├── event.json
│ │ │ │ ├── generic-event.json
│ │ │ │ ├── issue.json
│ │ │ │ ├── performance-event.json
│ │ │ │ ├── performance-issue.json
│ │ │ │ ├── project.json
│ │ │ │ ├── regressed-issue.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
│ │ │ │ ├── unknown-event.json
│ │ │ │ └── unsupported-issue.json
│ │ │ ├── fixtures.ts
│ │ │ ├── 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-core/src/skillDefinitions.json:
--------------------------------------------------------------------------------
```json
1 | [
2 | {
3 | "id": "inspect",
4 | "name": "Inspect Issues & Events",
5 | "description": "Search for errors, analyze traces, and explore event details",
6 | "defaultEnabled": true,
7 | "order": 1,
8 | "toolCount": 10,
9 | "tools": [
10 | {
11 | "name": "find_organizations",
12 | "description": "Find organizations that the user has access to in Sentry.\n\nUse this tool when you need to:\n- View organizations in Sentry\n- Find an organization's slug to aid other tool requests\n- Search for specific organizations by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
13 | "requiredScopes": ["org:read"]
14 | },
15 | {
16 | "name": "find_projects",
17 | "description": "Find projects in Sentry.\n\nUse this tool when you need to:\n- View projects in a Sentry organization\n- Find a project's slug to aid other tool requests\n- Search for specific projects by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
18 | "requiredScopes": ["project:read"]
19 | },
20 | {
21 | "name": "find_releases",
22 | "description": "Find releases in Sentry.\n\nUse this tool when you need to:\n- Find recent releases in a Sentry organization\n- Find the most recent version released of a specific project\n- Determine when a release was deployed to an environment\n\n<examples>\n### Find the most recent releases in the 'my-organization' organization\n\n```\nfind_releases(organizationSlug='my-organization')\n```\n\n### Find releases matching '2ce6a27' in the 'my-organization' organization\n\n```\nfind_releases(organizationSlug='my-organization', query='2ce6a27')\n```\n</examples>\n\n<hints>\n- If the user passes a parameter in the form of name/otherName, its likely in the format of <organizationSlug>/<projectSlug>.\n</hints>",
23 | "requiredScopes": ["project:read"]
24 | },
25 | {
26 | "name": "find_teams",
27 | "description": "Find teams in an organization in Sentry.\n\nUse this tool when you need to:\n- View teams in a Sentry organization\n- Find a team's slug to aid other tool requests\n- Search for specific teams by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
28 | "requiredScopes": ["team:read"]
29 | },
30 | {
31 | "name": "get_event_attachment",
32 | "description": "Download attachments from a Sentry event.\n\nUse this tool when you need to:\n- Download files attached to a specific event\n- Access screenshots, log files, or other attachments uploaded with an error report\n- Retrieve attachment metadata and download URLs\n\n<examples>\n### Download a specific attachment by ID\n\n```\nget_event_attachment(organizationSlug='my-organization', projectSlug='my-project', eventId='c49541c747cb4d8aa3efb70ca5aba243', attachmentId='12345')\n```\n\n### List all attachments for an event\n\n```\nget_event_attachment(organizationSlug='my-organization', projectSlug='my-project', eventId='c49541c747cb4d8aa3efb70ca5aba243')\n```\n\n</examples>\n\n<hints>\n- If `attachmentId` is provided, the specific attachment will be downloaded as an embedded resource\n- If `attachmentId` is omitted, all attachments for the event will be listed with download information\n- The `projectSlug` is required to identify which project the event belongs to\n</hints>",
33 | "requiredScopes": ["event:read"]
34 | },
35 | {
36 | "name": "get_issue_details",
37 | "description": "Get detailed information about a specific Sentry issue by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific issue ID (e.g., 'CLOUDFLARE-MCP-41', 'PROJECT-123')\n- Ask to 'explain [ISSUE-ID]', 'tell me about [ISSUE-ID]'\n- Want details/stacktrace/analysis for a known issue\n- Provide a Sentry issue URL\n\nDO NOT USE for:\n- General searching or listing issues (use search_issues)\n- Root cause analysis (use analyze_issue_with_seer)\n\nTRIGGER PATTERNS:\n- 'Explain ISSUE-123' → use get_issue_details\n- 'Tell me about PROJECT-456' → use get_issue_details\n- 'What happened in [issue URL]' → use get_issue_details\n\n<examples>\n### With Sentry URL (recommended - simplest approach)\n```\nget_issue_details(issueUrl='https://sentry.sentry.io/issues/6916805731/?project=4509062593708032&query=is%3Aunresolved')\n```\n\n### With issue ID and organization\n```\nget_issue_details(organizationSlug='my-organization', issueId='CLOUDFLARE-MCP-41')\n```\n\n### With event ID and organization\n```\nget_issue_details(organizationSlug='my-organization', eventId='c49541c747cb4d8aa3efb70ca5aba243')\n```\n</examples>\n\n<hints>\n- **IMPORTANT**: If user provides a Sentry URL, pass the ENTIRE URL to issueUrl parameter unchanged\n- When using issueUrl, all other parameters are automatically extracted - don't provide them separately\n- If using issueId (not URL), then organizationSlug is required\n</hints>",
38 | "requiredScopes": ["event:read"]
39 | },
40 | {
41 | "name": "get_trace_details",
42 | "description": "Get detailed information about a specific Sentry trace by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific trace ID (e.g., 'a4d1aae7216b47ff8117cf4e09ce9d0a')\n- Ask to 'show me trace [TRACE-ID]', 'explain trace [TRACE-ID]'\n- Want high-level overview and link to view trace details in Sentry\n- Need trace statistics and span breakdown\n\nDO NOT USE for:\n- General searching for traces (use search_events with trace queries)\n- Individual span details (this shows trace overview)\n\nTRIGGER PATTERNS:\n- 'Show me trace abc123' → use get_trace_details\n- 'Explain trace a4d1aae7216b47ff8117cf4e09ce9d0a' → use get_trace_details\n- 'What is trace [trace-id]' → use get_trace_details\n\n<examples>\n### Get trace overview\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a')\n```\n</examples>\n\n<hints>\n- Trace IDs are 32-character hexadecimal strings\n</hints>",
43 | "requiredScopes": ["event:read"]
44 | },
45 | {
46 | "name": "search_events",
47 | "description": "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.\n\nSupports TWO query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'count of issues', 'total tokens'\n2. Individual events with timestamps: 'show me error logs from last hour'\n\nUSE THIS FOR ALL COUNTS/STATISTICS:\n- 'how many errors today' → returns count\n- 'count of database failures' → returns count\n- 'total number of issues' → returns count\n- 'average response time' → returns avg()\n- 'sum of tokens used' → returns sum()\n\nALSO USE FOR INDIVIDUAL EVENTS:\n- 'error logs from last hour' → returns event list\n- 'database errors with timestamps' → returns event list\n- 'trace spans for slow API calls' → returns span list\n\nDataset Selection (AI automatically chooses):\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, AI/LLM calls, token usage\n\nDO NOT USE for grouped issue lists → use search_issues\n\n<examples>\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='how many errors today')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='count of database failures this week')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='total tokens used by model')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='error logs from the last hour')\n</examples>\n\n<hints>\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of <organizationSlug>/<projectSlug>.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n</hints>",
48 | "requiredScopes": ["event:read"]
49 | },
50 | {
51 | "name": "search_issues",
52 | "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nUses AI to translate natural language queries into Sentry issue search syntax.\nReturns grouped issues with metadata like title, status, and user count.\n\nUSE THIS TOOL WHEN USERS WANT:\n- A LIST of issues: 'show me issues', 'what problems do we have'\n- Filtered issue lists: 'unresolved issues', 'critical bugs'\n- Issues by impact: 'errors affecting more than 100 users'\n- Issues by assignment: 'issues assigned to me'\n\nDO NOT USE FOR COUNTS/AGGREGATIONS:\n- 'how many errors' → use search_events\n- 'count of issues' → use search_events\n- 'total number of errors today' → use search_events\n- 'sum/average/statistics' → use search_events\n\nALSO DO NOT USE FOR:\n- Individual error events with timestamps → use search_events\n- Details about a specific issue ID → use get_issue_details\n\nREMEMBER: This tool returns a LIST of issues, not counts or statistics!\n\n<examples>\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='unhandled errors affecting 100+ users')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='issues assigned to me')\n</examples>\n\n<hints>\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of <organizationSlug>/<projectSlugOrId>.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n</hints>",
53 | "requiredScopes": ["event:read"]
54 | },
55 | {
56 | "name": "whoami",
57 | "description": "Identify the authenticated user in Sentry.\n\nUse this tool when you need to:\n- Get the user's name and email address.",
58 | "requiredScopes": []
59 | }
60 | ]
61 | },
62 | {
63 | "id": "seer",
64 | "name": "Seer",
65 | "description": "Sentry's AI debugger that helps you analyze, root cause, and fix issues",
66 | "defaultEnabled": true,
67 | "order": 2,
68 | "toolCount": 7,
69 | "tools": [
70 | {
71 | "name": "analyze_issue_with_seer",
72 | "description": "Use Seer to analyze production errors and get detailed root cause analysis with specific code fixes.\n\nUse this tool when you need:\n- Detailed AI-powered root cause analysis\n- Specific code fixes and implementation guidance\n- Step-by-step troubleshooting for complex issues\n- Understanding why an error is happening in production\n\nWhat this tool provides:\n- Root cause analysis with code-level explanations\n- Specific file locations and line numbers where errors occur\n- Concrete code fixes you can apply\n- Step-by-step implementation guidance\n\nThis tool automatically:\n1. Checks if analysis already exists (instant results)\n2. Starts new AI analysis if needed (~2-5 minutes)\n3. Returns complete fix recommendations\n\n<examples>\n### User: \"What's causing this error? https://my-org.sentry.io/issues/PROJECT-1Z43\"\n\n```\nanalyze_issue_with_seer(issueUrl='https://my-org.sentry.io/issues/PROJECT-1Z43')\n```\n\n### User: \"Can you help me understand why this is failing in production?\"\n\n```\nanalyze_issue_with_seer(organizationSlug='my-organization', issueId='ERROR-456')\n```\n</examples>\n\n<hints>\n- Use this tool when you need deeper analysis beyond basic issue details\n- If the user provides an issueUrl, extract it and use that parameter alone\n- The analysis includes actual code snippets and fixes, not just error descriptions\n- Results are cached - subsequent calls return instantly\n</hints>",
73 | "requiredScopes": []
74 | },
75 | {
76 | "name": "find_organizations",
77 | "description": "Find organizations that the user has access to in Sentry.\n\nUse this tool when you need to:\n- View organizations in Sentry\n- Find an organization's slug to aid other tool requests\n- Search for specific organizations by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
78 | "requiredScopes": ["org:read"]
79 | },
80 | {
81 | "name": "find_projects",
82 | "description": "Find projects in Sentry.\n\nUse this tool when you need to:\n- View projects in a Sentry organization\n- Find a project's slug to aid other tool requests\n- Search for specific projects by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
83 | "requiredScopes": ["project:read"]
84 | },
85 | {
86 | "name": "get_issue_details",
87 | "description": "Get detailed information about a specific Sentry issue by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific issue ID (e.g., 'CLOUDFLARE-MCP-41', 'PROJECT-123')\n- Ask to 'explain [ISSUE-ID]', 'tell me about [ISSUE-ID]'\n- Want details/stacktrace/analysis for a known issue\n- Provide a Sentry issue URL\n\nDO NOT USE for:\n- General searching or listing issues (use search_issues)\n- Root cause analysis (use analyze_issue_with_seer)\n\nTRIGGER PATTERNS:\n- 'Explain ISSUE-123' → use get_issue_details\n- 'Tell me about PROJECT-456' → use get_issue_details\n- 'What happened in [issue URL]' → use get_issue_details\n\n<examples>\n### With Sentry URL (recommended - simplest approach)\n```\nget_issue_details(issueUrl='https://sentry.sentry.io/issues/6916805731/?project=4509062593708032&query=is%3Aunresolved')\n```\n\n### With issue ID and organization\n```\nget_issue_details(organizationSlug='my-organization', issueId='CLOUDFLARE-MCP-41')\n```\n\n### With event ID and organization\n```\nget_issue_details(organizationSlug='my-organization', eventId='c49541c747cb4d8aa3efb70ca5aba243')\n```\n</examples>\n\n<hints>\n- **IMPORTANT**: If user provides a Sentry URL, pass the ENTIRE URL to issueUrl parameter unchanged\n- When using issueUrl, all other parameters are automatically extracted - don't provide them separately\n- If using issueId (not URL), then organizationSlug is required\n</hints>",
88 | "requiredScopes": ["event:read"]
89 | },
90 | {
91 | "name": "search_events",
92 | "description": "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.\n\nSupports TWO query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'count of issues', 'total tokens'\n2. Individual events with timestamps: 'show me error logs from last hour'\n\nUSE THIS FOR ALL COUNTS/STATISTICS:\n- 'how many errors today' → returns count\n- 'count of database failures' → returns count\n- 'total number of issues' → returns count\n- 'average response time' → returns avg()\n- 'sum of tokens used' → returns sum()\n\nALSO USE FOR INDIVIDUAL EVENTS:\n- 'error logs from last hour' → returns event list\n- 'database errors with timestamps' → returns event list\n- 'trace spans for slow API calls' → returns span list\n\nDataset Selection (AI automatically chooses):\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, AI/LLM calls, token usage\n\nDO NOT USE for grouped issue lists → use search_issues\n\n<examples>\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='how many errors today')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='count of database failures this week')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='total tokens used by model')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='error logs from the last hour')\n</examples>\n\n<hints>\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of <organizationSlug>/<projectSlug>.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n</hints>",
93 | "requiredScopes": ["event:read"]
94 | },
95 | {
96 | "name": "search_issues",
97 | "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nUses AI to translate natural language queries into Sentry issue search syntax.\nReturns grouped issues with metadata like title, status, and user count.\n\nUSE THIS TOOL WHEN USERS WANT:\n- A LIST of issues: 'show me issues', 'what problems do we have'\n- Filtered issue lists: 'unresolved issues', 'critical bugs'\n- Issues by impact: 'errors affecting more than 100 users'\n- Issues by assignment: 'issues assigned to me'\n\nDO NOT USE FOR COUNTS/AGGREGATIONS:\n- 'how many errors' → use search_events\n- 'count of issues' → use search_events\n- 'total number of errors today' → use search_events\n- 'sum/average/statistics' → use search_events\n\nALSO DO NOT USE FOR:\n- Individual error events with timestamps → use search_events\n- Details about a specific issue ID → use get_issue_details\n\nREMEMBER: This tool returns a LIST of issues, not counts or statistics!\n\n<examples>\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='unhandled errors affecting 100+ users')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='issues assigned to me')\n</examples>\n\n<hints>\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of <organizationSlug>/<projectSlugOrId>.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n</hints>",
98 | "requiredScopes": ["event:read"]
99 | },
100 | {
101 | "name": "whoami",
102 | "description": "Identify the authenticated user in Sentry.\n\nUse this tool when you need to:\n- Get the user's name and email address.",
103 | "requiredScopes": []
104 | }
105 | ]
106 | },
107 | {
108 | "id": "docs",
109 | "name": "Documentation",
110 | "description": "Search and read Sentry SDK documentation",
111 | "defaultEnabled": false,
112 | "order": 3,
113 | "toolCount": 5,
114 | "tools": [
115 | {
116 | "name": "find_organizations",
117 | "description": "Find organizations that the user has access to in Sentry.\n\nUse this tool when you need to:\n- View organizations in Sentry\n- Find an organization's slug to aid other tool requests\n- Search for specific organizations by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
118 | "requiredScopes": ["org:read"]
119 | },
120 | {
121 | "name": "find_projects",
122 | "description": "Find projects in Sentry.\n\nUse this tool when you need to:\n- View projects in a Sentry organization\n- Find a project's slug to aid other tool requests\n- Search for specific projects by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
123 | "requiredScopes": ["project:read"]
124 | },
125 | {
126 | "name": "get_doc",
127 | "description": "Fetch the full markdown content of a Sentry documentation page.\n\nUse this tool when you need to:\n- Read the complete documentation for a specific topic\n- Get detailed implementation examples or code snippets\n- Access the full context of a documentation page\n- Extract specific sections from documentation\n\n<examples>\n### Get the Next.js integration guide\n\n```\nget_doc(path='/platforms/javascript/guides/nextjs.md')\n```\n</examples>\n\n<hints>\n- Use the path from search_docs results for accurate fetching\n- Paths should end with .md extension\n</hints>",
128 | "requiredScopes": []
129 | },
130 | {
131 | "name": "search_docs",
132 | "description": "Search Sentry documentation for SDK setup, instrumentation, and configuration guidance.\n\nUse this tool when you need to:\n- Set up Sentry SDK or framework integrations (Django, Flask, Express, Next.js, etc.)\n- Configure features like performance monitoring, error sampling, or release tracking\n- Implement custom instrumentation (spans, transactions, breadcrumbs)\n- Configure data scrubbing, filtering, or sampling rules\n\nReturns snippets only. Use `get_doc(path='...')` to fetch full documentation content.\n\n<examples>\n```\nsearch_docs(query='Django setup configuration SENTRY_DSN', guide='python/django')\nsearch_docs(query='source maps webpack upload', guide='javascript/nextjs')\n```\n</examples>\n\n<hints>\n- Use guide parameter to filter to specific technologies (e.g., 'javascript/nextjs')\n- Include specific feature names like 'beforeSend', 'tracesSampleRate', 'SENTRY_DSN'\n</hints>",
133 | "requiredScopes": []
134 | },
135 | {
136 | "name": "whoami",
137 | "description": "Identify the authenticated user in Sentry.\n\nUse this tool when you need to:\n- Get the user's name and email address.",
138 | "requiredScopes": []
139 | }
140 | ]
141 | },
142 | {
143 | "id": "triage",
144 | "name": "Triage Issues",
145 | "description": "Resolve, assign, and update issues",
146 | "defaultEnabled": false,
147 | "order": 4,
148 | "toolCount": 8,
149 | "tools": [
150 | {
151 | "name": "find_organizations",
152 | "description": "Find organizations that the user has access to in Sentry.\n\nUse this tool when you need to:\n- View organizations in Sentry\n- Find an organization's slug to aid other tool requests\n- Search for specific organizations by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
153 | "requiredScopes": ["org:read"]
154 | },
155 | {
156 | "name": "find_projects",
157 | "description": "Find projects in Sentry.\n\nUse this tool when you need to:\n- View projects in a Sentry organization\n- Find a project's slug to aid other tool requests\n- Search for specific projects by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
158 | "requiredScopes": ["project:read"]
159 | },
160 | {
161 | "name": "find_teams",
162 | "description": "Find teams in an organization in Sentry.\n\nUse this tool when you need to:\n- View teams in a Sentry organization\n- Find a team's slug to aid other tool requests\n- Search for specific teams by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
163 | "requiredScopes": ["team:read"]
164 | },
165 | {
166 | "name": "get_issue_details",
167 | "description": "Get detailed information about a specific Sentry issue by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific issue ID (e.g., 'CLOUDFLARE-MCP-41', 'PROJECT-123')\n- Ask to 'explain [ISSUE-ID]', 'tell me about [ISSUE-ID]'\n- Want details/stacktrace/analysis for a known issue\n- Provide a Sentry issue URL\n\nDO NOT USE for:\n- General searching or listing issues (use search_issues)\n- Root cause analysis (use analyze_issue_with_seer)\n\nTRIGGER PATTERNS:\n- 'Explain ISSUE-123' → use get_issue_details\n- 'Tell me about PROJECT-456' → use get_issue_details\n- 'What happened in [issue URL]' → use get_issue_details\n\n<examples>\n### With Sentry URL (recommended - simplest approach)\n```\nget_issue_details(issueUrl='https://sentry.sentry.io/issues/6916805731/?project=4509062593708032&query=is%3Aunresolved')\n```\n\n### With issue ID and organization\n```\nget_issue_details(organizationSlug='my-organization', issueId='CLOUDFLARE-MCP-41')\n```\n\n### With event ID and organization\n```\nget_issue_details(organizationSlug='my-organization', eventId='c49541c747cb4d8aa3efb70ca5aba243')\n```\n</examples>\n\n<hints>\n- **IMPORTANT**: If user provides a Sentry URL, pass the ENTIRE URL to issueUrl parameter unchanged\n- When using issueUrl, all other parameters are automatically extracted - don't provide them separately\n- If using issueId (not URL), then organizationSlug is required\n</hints>",
168 | "requiredScopes": ["event:read"]
169 | },
170 | {
171 | "name": "search_events",
172 | "description": "Search for events AND perform counts/aggregations - the ONLY tool for statistics and counts.\n\nSupports TWO query types:\n1. AGGREGATIONS (counts, sums, averages): 'how many errors', 'count of issues', 'total tokens'\n2. Individual events with timestamps: 'show me error logs from last hour'\n\nUSE THIS FOR ALL COUNTS/STATISTICS:\n- 'how many errors today' → returns count\n- 'count of database failures' → returns count\n- 'total number of issues' → returns count\n- 'average response time' → returns avg()\n- 'sum of tokens used' → returns sum()\n\nALSO USE FOR INDIVIDUAL EVENTS:\n- 'error logs from last hour' → returns event list\n- 'database errors with timestamps' → returns event list\n- 'trace spans for slow API calls' → returns span list\n\nDataset Selection (AI automatically chooses):\n- errors: Exception/crash events\n- logs: Log entries\n- spans: Performance data, AI/LLM calls, token usage\n\nDO NOT USE for grouped issue lists → use search_issues\n\n<examples>\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='how many errors today')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='count of database failures this week')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='total tokens used by model')\nsearch_events(organizationSlug='my-org', naturalLanguageQuery='error logs from the last hour')\n</examples>\n\n<hints>\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of <organizationSlug>/<projectSlug>.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n</hints>",
173 | "requiredScopes": ["event:read"]
174 | },
175 | {
176 | "name": "search_issues",
177 | "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nUses AI to translate natural language queries into Sentry issue search syntax.\nReturns grouped issues with metadata like title, status, and user count.\n\nUSE THIS TOOL WHEN USERS WANT:\n- A LIST of issues: 'show me issues', 'what problems do we have'\n- Filtered issue lists: 'unresolved issues', 'critical bugs'\n- Issues by impact: 'errors affecting more than 100 users'\n- Issues by assignment: 'issues assigned to me'\n\nDO NOT USE FOR COUNTS/AGGREGATIONS:\n- 'how many errors' → use search_events\n- 'count of issues' → use search_events\n- 'total number of errors today' → use search_events\n- 'sum/average/statistics' → use search_events\n\nALSO DO NOT USE FOR:\n- Individual error events with timestamps → use search_events\n- Details about a specific issue ID → use get_issue_details\n\nREMEMBER: This tool returns a LIST of issues, not counts or statistics!\n\n<examples>\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='unhandled errors affecting 100+ users')\nsearch_issues(organizationSlug='my-org', naturalLanguageQuery='issues assigned to me')\n</examples>\n\n<hints>\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of <organizationSlug>/<projectSlugOrId>.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n</hints>",
178 | "requiredScopes": ["event:read"]
179 | },
180 | {
181 | "name": "update_issue",
182 | "description": "Update an issue's status or assignment in Sentry. This allows you to resolve, ignore, or reassign issues.\n\nUse this tool when you need to:\n- Resolve an issue that has been fixed\n- Assign an issue to a team member or team for investigation\n- Mark an issue as ignored to reduce noise\n- Reopen a resolved issue by setting status to 'unresolved'\n\n<examples>\n### Resolve an issue\n\n```\nupdate_issue(organizationSlug='my-organization', issueId='PROJECT-123', status='resolved')\n```\n\n### Assign an issue to a user (use whoami to get your user ID)\n\n```\nupdate_issue(organizationSlug='my-organization', issueId='PROJECT-123', assignedTo='user:123456')\n```\n\n### Assign an issue to a team\n\n```\nupdate_issue(organizationSlug='my-organization', issueId='PROJECT-123', assignedTo='team:789')\n```\n\n### Mark an issue as ignored\n\n```\nupdate_issue(organizationSlug='my-organization', issueId='PROJECT-123', status='ignored')\n```\n\n</examples>\n\n<hints>\n- If the user provides the `issueUrl`, you can ignore the other required parameters and extract them from the URL.\n- At least one of `status` or `assignedTo` must be provided to update the issue.\n- assignedTo format: Use 'user:ID' for users (e.g., 'user:123456') or 'team:ID' for teams (e.g., 'team:789')\n- To find your user ID, first use the whoami tool which returns your numeric user ID\n- Valid status values are: 'resolved', 'resolvedInNextRelease', 'unresolved', 'ignored'.\n</hints>",
183 | "requiredScopes": ["event:write"]
184 | },
185 | {
186 | "name": "whoami",
187 | "description": "Identify the authenticated user in Sentry.\n\nUse this tool when you need to:\n- Get the user's name and email address.",
188 | "requiredScopes": []
189 | }
190 | ]
191 | },
192 | {
193 | "id": "project-management",
194 | "name": "Manage Projects & Teams",
195 | "description": "Create and modify projects, teams, and DSNs",
196 | "defaultEnabled": false,
197 | "order": 5,
198 | "toolCount": 9,
199 | "tools": [
200 | {
201 | "name": "create_dsn",
202 | "description": "Create an additional DSN for an EXISTING project.\n\nUSE THIS TOOL WHEN:\n- Project already exists and needs additional DSN\n- 'Create another DSN for project X'\n- 'I need a production DSN for existing project'\n\nDO NOT USE for new projects (use create_project instead)\n\nBe careful when using this tool!\n\n<examples>\n### Create additional DSN for existing project\n```\ncreate_dsn(organizationSlug='my-organization', projectSlug='my-project', name='Production')\n```\n</examples>\n\n<hints>\n- If the user passes a parameter in the form of name/otherName, its likely in the format of <organizationSlug>/<projectSlug>.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n</hints>",
203 | "requiredScopes": ["project:write"]
204 | },
205 | {
206 | "name": "create_project",
207 | "description": "Create a new project in Sentry (includes DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n\nDO NOT USE create_dsn after this - DSN is included in output.\n\nBe careful when using this tool!\n\n<examples>\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n</examples>\n\n<hints>\n- If the user passes a parameter in the form of name/otherName, its likely in the format of <organizationSlug>/<teamSlug>.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n</hints>",
208 | "requiredScopes": ["project:write", "team:read"]
209 | },
210 | {
211 | "name": "create_team",
212 | "description": "Create a new team in Sentry.\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new team'\n- 'Set up a team called [X]'\n- 'I need a team for my project'\n\nBe careful when using this tool!\n\n<examples>\n### Create a new team\n```\ncreate_team(organizationSlug='my-organization', name='the-goats')\n```\n</examples>\n\n<hints>\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n</hints>",
213 | "requiredScopes": ["team:write"]
214 | },
215 | {
216 | "name": "find_dsns",
217 | "description": "List all Sentry DSNs for a specific project.\n\nUse this tool when you need to:\n- Retrieve a SENTRY_DSN for a specific project\n\n<hints>\n- If the user passes a parameter in the form of name/otherName, its likely in the format of <organizationSlug>/<projectSlug>.\n- If only one parameter is provided, and it could be either `organizationSlug` or `projectSlug`, its probably `organizationSlug`, but if you're really uncertain you might want to call `find_organizations()` first.\n</hints>",
218 | "requiredScopes": ["project:read"]
219 | },
220 | {
221 | "name": "find_organizations",
222 | "description": "Find organizations that the user has access to in Sentry.\n\nUse this tool when you need to:\n- View organizations in Sentry\n- Find an organization's slug to aid other tool requests\n- Search for specific organizations by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
223 | "requiredScopes": ["org:read"]
224 | },
225 | {
226 | "name": "find_projects",
227 | "description": "Find projects in Sentry.\n\nUse this tool when you need to:\n- View projects in a Sentry organization\n- Find a project's slug to aid other tool requests\n- Search for specific projects by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
228 | "requiredScopes": ["project:read"]
229 | },
230 | {
231 | "name": "find_teams",
232 | "description": "Find teams in an organization in Sentry.\n\nUse this tool when you need to:\n- View teams in a Sentry organization\n- Find a team's slug to aid other tool requests\n- Search for specific teams by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.",
233 | "requiredScopes": ["team:read"]
234 | },
235 | {
236 | "name": "update_project",
237 | "description": "Update project settings in Sentry, such as name, slug, platform, and team assignment.\n\nBe careful when using this tool!\n\nUse this tool when you need to:\n- Update a project's name or slug to fix onboarding mistakes\n- Change the platform assigned to a project\n- Update team assignment for a project\n\n<examples>\n### Update a project's name and slug\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='old-project', name='New Project Name', slug='new-project-slug')\n```\n\n### Assign a project to a different team\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='backend-team')\n```\n\n### Update platform\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='my-project', platform='python')\n```\n\n</examples>\n\n<hints>\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of <organizationSlug>/<projectSlug>.\n- Team assignment is handled separately from other project settings\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n- When updating the slug, the project will be accessible at the new slug after the update\n</hints>",
238 | "requiredScopes": ["project:write"]
239 | },
240 | {
241 | "name": "whoami",
242 | "description": "Identify the authenticated user in Sentry.\n\nUse this tool when you need to:\n- Get the user's name and email address.",
243 | "requiredScopes": []
244 | }
245 | ]
246 | }
247 | ]
248 |
```
--------------------------------------------------------------------------------
/packages/mcp-server-mocks/src/fixtures/autofix-state.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "autofix": {
3 | "run_id": 21831,
4 | "request": {
5 | "project_id": 4505138086019073
6 | },
7 | "status": "COMPLETED",
8 | "updated_at": "2025-04-09T22:39:50.778146",
9 | "steps": [
10 | {
11 | "active_comment_thread": null,
12 | "agent_comment_thread": null,
13 | "completedMessage": null,
14 | "id": "5c3238ea-4c3a-4c02-a94b-92a3ca25c946",
15 | "index": 0,
16 | "initial_memory_length": 1,
17 | "insights": [
18 | {
19 | "change_diff": null,
20 | "generated_at_memory_index": 0,
21 | "insight": "The `bottleById` query fails because the input ID (3216) doesn't exist in the database.\n",
22 | "justification": "The exception details show that the `input` value at the time of the `TRPCError` in `bottleById.ts` was 3216, and the query likely failed because a bottle with ID 3216 was not found in the database.\n\n```\nVariable values at the time of the exception::\n{\n \"input\": 3216\n}\n```\n",
23 | "type": "insight"
24 | },
25 | {
26 | "change_diff": null,
27 | "generated_at_memory_index": 22,
28 | "insight": "However, the request also includes a different ID (16720) for `bottlePriceList`.\n",
29 | "justification": "The root cause is likely a mismatch of input IDs within the batched TRPC request, where `bottlePriceList` expects bottle ID 16720, but `bottleById` receives a different ID (3216) leading to the \"Bottle not found\" error.\n\n```\nGET http://api.peated.com/trpc/bottlePriceList,bottleById\n```\n\n```json\n{\n \"input\": 3216\n}\n```\n\n```\nTRPCError: Bottle not found. (occurred in: GET /trpc/bottlePriceList,bottleById)\n```\n",
30 | "type": "insight"
31 | },
32 | {
33 | "change_diff": null,
34 | "generated_at_memory_index": 22,
35 | "insight": "This suggests a data consistency issue or incorrect client-side request.\n",
36 | "justification": "The `TRPCError` originates from `bottleById.ts` with the input value being `3216`, indicating the procedure failed to find a bottle with that specific ID in the database.\n\n```\n <anonymous> in file /app/apps/server/src/trpc/routes/bottleById.ts [Line 33, column 13] (In app)\n .select({\n ...getTableColumns(bottles),\n })\n .from(bottleTombstones)\n .innerJoin(bottles, eq(bottleTombstones.newBottleId, bottles.id))\n .where(eq(bottleTombstones.bottleId, input));\n if (!bottle) {\n throw new TRPCError({ <-- SUSPECT LINE\n message: \"Bottle not found.\",\n code: \"NOT_FOUND\",\n });\n }\n }\n\n const createdBy = await db.query.users.findFirst({\n---\nVariable values at the time of the exception::\n{\n \"input\": 3216\n}\n```\n",
37 | "type": "insight"
38 | }
39 | ],
40 | "key": "root_cause_analysis_processing",
41 | "output_confidence_score": null,
42 | "output_stream": "Let me summarize what I know about this issue:\n\n1. The error occurs in the `bottleById.ts` endpoint, with the error \"Bottle not found.\"\n2. The request is to `/trpc/bottlePriceList,bottleById` - which suggests that two TRPC procedures are being called in the same request.\n3. According to the error trace, the input ID is 16720, but the exception details show 3216 as the input value.\n4. The code in `bottleById.ts` tries to look up a bottle in two places:\n - First in the main `bottles` table\n - Then in the `bottleTombstones` table (which handles redirects for bottles that have been merged/moved)\n\nThe most significant clue is the discrepancy between the ID mentioned in the error (16720) and the ID in the exception details (3216). This suggests there's something fishy going on with how the IDs are being processed.\n\nGiven that the URL shows `bottlePriceList,bottleById`, this suggests a batched TRPC request where two procedures are being called simultaneously. The batched nature of TRPC calls might be relevant here.\n\nLet's think about potential root causes:\n\n1. **Data problem**: Neither bottle ID 16720 nor 3216 exists in the database.\n2. **Race condition**: The bottle might have been deleted between the two calls.\n3. **TRPC batching issue**: There might be a problem with how the batched request is handling the different IDs for the two procedures.\n4. **Input switching**: The ID is somehow being switched between the procedures.\n\nBased on the evidence, I think the most likely explanation is related to the TRPC batching. When the client makes a request to `/trpc/bottlePriceList,bottleById`, it's calling two procedures:\n- `bottlePriceList` with one ID (maybe 16720)\n- `bottleById` with another ID (3216)\n\nThe error is occurring in the `bottleById` procedure with ID 3216, but the overall request is being reported with ID 16720 (which might be from the `bottlePriceList` part of the batch).\n\nSince the exception is specifically coming from `bottleById.ts` with input 3216, the root cause is that there is no bottle with ID 3216 in the database (neither in `bottles` nor in `bottleTombstones`).\n\nThe confusing part is that the error message mentions 16720, which might be:\n1. A UI issue where the ID displayed doesn't match what's being requested\n2. A client-side issue where the wrong ID is being provided to `bottleById`\n3. A batching issue where the ID from one procedure is being displayed in the error for another\n\nGiven this analysis, the true root cause is: **The `bottleById` procedure is being called with ID 3216, but no bottle with that ID exists in the database.**\n\nThis is definitely a data consistency issue - either the bottle with ID 3216 was deleted without creating a tombstone, or the client is requesting a non-existent ID.# Root Cause Analysis: TRPCError: Bottle not found\n\nAfter examining all the available evidence, I can identify the true root cause of this issue:\n\n## Root Cause\n\n**The issue is related to a TRPC batch request inconsistency between the requested bottle ID and what's being processed.**\n\nThis conclusion is based on several key observations:\n\n1. The request is made to `/trpc/bottlePriceList,bottleById` - this is a batched TRPC request calling two procedures simultaneously.\n\n2. **Critical discrepancy**: The error message references bottle ID 16720, but the exception details show the procedure was actually processing ID 3216.\n\n3. Both procedures (`bottleById` and `bottlePriceList`) contain similar \"Bottle not found\" error checks, but the one that's triggering is in `bottleById.ts`.\n\n4. The `bottleById` procedure is attempting to find bottle ID 3216, not finding it in the main bottles table, then failing to find it in the tombstones table as well.\n\n## Detailed Explanation\n\nWhat's happening is:\n\n1. The client makes a batched request to both `bottlePriceList` and `bottleById` endpoints.\n\n2. Based on the URL and error, it appears that:\n - The `bottlePriceList` procedure might be receiving ID 16720\n - The `bottleById` procedure is receiving ID 3216\n\n3. When `bottleById` tries to process ID 3216, it can't find this bottle in the database or in tombstones, so it throws the \"Bottle not found\" error.\n\n4. The error reporting is showing the ID from the overall request (16720) while the actual failure is happening with a different ID (3216).\n\n## Why This Is Happening\n\nThis is likely either:\n\n1. **Client-side issue**: The client is passing the wrong ID to `bottleById` in the batched request\n2. **Data integrity issue**: Bottle ID 3216 may have been deleted without creating a tombstone entry\n3. **Parameter mixing**: The batched TRPC request might be incorrectly mapping parameters between the two procedures\n\nThe fact that two different IDs are involved (16720 and 3216) strongly suggests this is not a simple case of a missing bottle, but rather a data inconsistency or parameter handling issue in the batched request.\n\nTo fix this issue, you would need to:\n1. Check if bottle ID 3216 should exist in the database\n2. Verify how the client is forming the batched request and what IDs it's sending to each procedure\n3. Consider adding more detailed logging that shows the exact inputs to each procedure in batched requests",
43 | "proceed_confidence_score": null,
44 | "progress": [
45 | {
46 | "data": null,
47 | "message": "Figuring out the root cause...",
48 | "timestamp": "2025-04-09T22:35:43.590244",
49 | "type": "INFO"
50 | },
51 | {
52 | "data": null,
53 | "message": "Looking at `apps/server/src/trpc/routes/bottleById.ts` in `dcramer/peated`...",
54 | "timestamp": "2025-04-09T22:35:52.808244",
55 | "type": "INFO"
56 | },
57 | {
58 | "data": null,
59 | "message": "Looking at `apps/server/src/db/schema.ts` in `dcramer/peated`...",
60 | "timestamp": "2025-04-09T22:36:14.424666",
61 | "type": "INFO"
62 | },
63 | {
64 | "data": null,
65 | "message": "Searching files with `find apps/server/src/db -type f -name \"*.ts\"`...",
66 | "timestamp": "2025-04-09T22:36:16.500156",
67 | "type": "INFO"
68 | },
69 | {
70 | "data": null,
71 | "message": "Looking at `apps/server/src/db/schema/index.ts` in `dcramer/peated`...",
72 | "timestamp": "2025-04-09T22:36:18.687233",
73 | "type": "INFO"
74 | },
75 | {
76 | "data": null,
77 | "message": "Looking at `apps/server/src/db/schema/bottles.ts` in `dcramer/peated`...",
78 | "timestamp": "2025-04-09T22:36:21.205568",
79 | "type": "INFO"
80 | },
81 | {
82 | "data": null,
83 | "message": "Looking at `apps/server/src/db/index.ts` in `dcramer/peated`...",
84 | "timestamp": "2025-04-09T22:36:24.013172",
85 | "type": "INFO"
86 | },
87 | {
88 | "data": null,
89 | "message": "Looking at `apps/server/src/db/schema/storePrices.ts` in `dcramer/peated`...",
90 | "timestamp": "2025-04-09T22:36:27.236327",
91 | "type": "INFO"
92 | },
93 | {
94 | "data": null,
95 | "message": "Looking at `apps/server/src/db/schema/stores.ts` in `dcramer/peated`...",
96 | "timestamp": "2025-04-09T22:36:29.450102",
97 | "type": "INFO"
98 | },
99 | {
100 | "data": null,
101 | "message": "Looking at `apps/server/src/db/schema/tastings.ts` in `dcramer/peated`...",
102 | "timestamp": "2025-04-09T22:36:31.593538",
103 | "type": "INFO"
104 | },
105 | {
106 | "data": null,
107 | "message": "Searching for \"Get information about the bottle price list controller or route that might be calling bottleById.ts\"...",
108 | "timestamp": "2025-04-09T22:36:34.373043",
109 | "type": "INFO"
110 | },
111 | {
112 | "data": null,
113 | "message": "Simulating profound thought...",
114 | "timestamp": "2025-04-09T22:36:56.212382",
115 | "type": "INFO"
116 | },
117 | {
118 | "data": null,
119 | "message": "Arranging data in a way that looks intentional...",
120 | "timestamp": "2025-04-09T22:37:33.353804",
121 | "type": "INFO"
122 | }
123 | ],
124 | "queued_user_messages": [],
125 | "status": "COMPLETED",
126 | "title": "Analyzing the Issue",
127 | "type": "default"
128 | },
129 | {
130 | "active_comment_thread": null,
131 | "agent_comment_thread": null,
132 | "causes": [
133 | {
134 | "description": "Batched TRPC request incorrectly passed bottle ID 3216 to `bottleById`, instead of 16720, resulting in a \"Bottle not found\" error.",
135 | "id": 0,
136 | "root_cause_reproduction": [
137 | {
138 | "code_snippet_and_analysis": "This is the entry point where the client requests data from two different procedures in a single HTTP request. The server needs to correctly route and process the parameters for each procedure.",
139 | "is_most_important_event": false,
140 | "relevant_code_file": null,
141 | "timeline_item_type": "human_action",
142 | "title": "The client initiates a batched TRPC request to the `/trpc/bottlePriceList,bottleById` endpoint, intending to fetch both the price list and details for a specific bottle."
143 | },
144 | {
145 | "code_snippet_and_analysis": "```typescript\n// apps/server/src/trpc/routes/bottlePriceList.ts\n.input(z.object({ bottle: z.number(), onlyValid: z.boolean().optional() }))\n.query(async function ({ input, ctx }) {\n const [bottle] = await db.select().from(bottles).where(eq(bottles.id, input.bottle));\n if (!bottle) { ... }\n```\nThis procedure expects a 'bottle' parameter in the input, which is used to query the database.",
146 | "is_most_important_event": false,
147 | "relevant_code_file": {
148 | "file_path": "apps/server/src/trpc/routes/bottlePriceList.ts",
149 | "repo_name": "dcramer/peated"
150 | },
151 | "timeline_item_type": "internal_code",
152 | "title": "The TRPC server receives the batched request and begins processing the `bottlePriceList` procedure, intending to fetch the price list for bottle ID 16720."
153 | },
154 | {
155 | "code_snippet_and_analysis": "```typescript\n// apps/server/src/trpc/routes/bottleById.ts\nexport default publicProcedure.input(z.number()).query(async function ({ input, ctx }) {\n let [bottle] = await db.select().from(bottles).where(eq(bottles.id, input));\n if (!bottle) { ... }\n```\nThis procedure expects a numerical ID as input to find the bottle.",
156 | "is_most_important_event": true,
157 | "relevant_code_file": {
158 | "file_path": "apps/server/src/trpc/routes/bottleById.ts",
159 | "repo_name": "dcramer/peated"
160 | },
161 | "timeline_item_type": "internal_code",
162 | "title": "The TRPC server also processes the `bottleById` procedure, but due to a parameter mapping issue or client-side error, it receives bottle ID 3216 as input instead of 16720."
163 | },
164 | {
165 | "code_snippet_and_analysis": "The database query returns no results because bottle ID 3216 is not present in the `bottles` table.",
166 | "is_most_important_event": false,
167 | "relevant_code_file": {
168 | "file_path": "apps/server/src/trpc/routes/bottleById.ts",
169 | "repo_name": "dcramer/peated"
170 | },
171 | "timeline_item_type": "external_system",
172 | "title": "The `bottleById` procedure queries the `bottles` table for a bottle with ID 3216, but no such bottle exists."
173 | },
174 | {
175 | "code_snippet_and_analysis": "The query to `bottleTombstones` also returns no results, indicating that bottle ID 3216 has not been redirected.",
176 | "is_most_important_event": false,
177 | "relevant_code_file": {
178 | "file_path": "apps/server/src/trpc/routes/bottleById.ts",
179 | "repo_name": "dcramer/peated"
180 | },
181 | "timeline_item_type": "external_system",
182 | "title": "The `bottleById` procedure then checks the `bottleTombstones` table to see if bottle ID 3216 has been tombstoned (redirected to a new ID), but no such tombstone exists."
183 | },
184 | {
185 | "code_snippet_and_analysis": "```typescript\n// apps/server/src/trpc/routes/bottleById.ts\nif (!bottle) {\n throw new TRPCError({ message: \"Bottle not found.\", code: \"NOT_FOUND\" });\n}\n```\nThis is where the error is thrown, indicating that the bottle could not be found.",
186 | "is_most_important_event": false,
187 | "relevant_code_file": {
188 | "file_path": "apps/server/src/trpc/routes/bottleById.ts",
189 | "repo_name": "dcramer/peated"
190 | },
191 | "timeline_item_type": "internal_code",
192 | "title": "Since the `bottleById` procedure cannot find a bottle with ID 3216 in either the `bottles` or `bottleTombstones` tables, it throws a `TRPCError` with the message \"Bottle not found.\""
193 | }
194 | ]
195 | }
196 | ],
197 | "completedMessage": null,
198 | "id": "39166714-b14d-4fa0-a122-3ac241f7b46a",
199 | "index": 1,
200 | "key": "root_cause_analysis",
201 | "output_confidence_score": 0.95,
202 | "output_stream": null,
203 | "proceed_confidence_score": 0.9,
204 | "progress": [
205 | {
206 | "data": null,
207 | "message": "Here is Seer's proposed root cause.",
208 | "timestamp": "2025-04-09T22:37:40.934397",
209 | "type": "INFO"
210 | }
211 | ],
212 | "queued_user_messages": [],
213 | "selection": { "cause_id": 0, "instruction": null },
214 | "status": "COMPLETED",
215 | "termination_reason": null,
216 | "title": "Root Cause Analysis",
217 | "type": "root_cause_analysis"
218 | },
219 | {
220 | "active_comment_thread": null,
221 | "agent_comment_thread": null,
222 | "completedMessage": null,
223 | "id": "649acf61-9922-4ac4-925d-9b73302ac8e9",
224 | "index": 2,
225 | "initial_memory_length": 5,
226 | "insights": [
227 | {
228 | "change_diff": null,
229 | "generated_at_memory_index": 4,
230 | "insight": "The discrepancy between inputs suggests a potential issue with batch request handling.\n",
231 | "justification": "The `TRPCError` occurred in `bottleById.ts` with an input of `3216`, while the request was initiated with a bottle ID of `16720`, indicating a mismatch in the bottle ID being processed.\n\n```\nVariable values at the time of the exception::\n{\n \"input\": 3216\n}\n```\n\n```\nGET http://api.peated.com/trpc/bottlePriceList,bottleById\n```\n",
232 | "type": "insight"
233 | },
234 | {
235 | "change_diff": null,
236 | "generated_at_memory_index": 44,
237 | "insight": "The client-side batch request construction is the most likely source of error.\n",
238 | "justification": "The discrepancy between the bottle ID in the error message (16720) and the ID being processed (3216) strongly suggests a client-side parameter mapping issue in the batched TRPC request.\n\nTo fix this, I propose the following steps:\n\n1. **Inspect the Client-Side Code:** Identify the code responsible for constructing the TRPC batch request to `/trpc/bottlePriceList,bottleById`.\n\n2. **Verify Parameter Mapping:** Ensure that both `bottlePriceList` and `bottleById` are receiving the correct bottle ID (which should be 16720 in this case).\n\n3. **Correct Parameter Assignment:** If the parameters are being incorrectly assigned, modify the client-side code to ensure that both procedures receive the same, correct bottle ID.\n\n4. **Add Logging (Client-Side):** Add temporary logging to the client-side code to confirm the parameters being sent to each procedure in the batch request. This will help verify the fix.\n\n ```typescript\n // Example logging (add to the client-side code where the batch request is created)\n console.log(\"bottlePriceList input:\", { bottle: 16720 }); // Replace 16720 with the actual ID\n console.log(\"bottleById input:\", { bottle: 16720 }); // Replace 16720 with the actual ID\n ```\n\n5. **Test the Fix:** After applying the fix, test the affected functionality to ensure that the \"Bottle not found\" error is resolved and that both procedures are working correctly.\n\n6. **Remove Logging:** Once the fix is verified, remove the temporary logging.\n\nThis approach directly addresses the most likely cause of the issue and provides a clear path to resolution.\n",
239 | "type": "insight"
240 | },
241 | {
242 | "change_diff": null,
243 | "generated_at_memory_index": 44,
244 | "insight": "Ensuring consistent bottle IDs will likely resolve this inconsistency.\n",
245 | "justification": "The error message references bottle ID 16720, but the exception details show the procedure was actually processing ID 3216, indicating a parameter mismatch in the batch request.\n\nTo fix this, I recommend the following steps:\n\n1. **Client-Side Investigation**:\n\n * Examine the client-side code where the TRPC batch request is constructed.\n * Verify that the same `bottleId` parameter is being passed to both `bottlePriceList` and `bottleById` procedures.\n * If the IDs are being passed correctly, inspect the network request payload to confirm the correct structure of the batched request.\n\n ```typescript\n // Example (Hypothetical) Client-Side Code\n const bottleId = 16720; // Example bottle ID\n\n // Ensure both procedures receive the same bottleId\n const [priceList, bottleDetails] = await trpc.batch(() => [\n trpc.bottlePriceList.fetch({ bottle: bottleId }),\n trpc.bottleById.fetch(bottleId),\n ]);\n ```\n\n2. **Server-Side Logging (Temporary)**:\n\n * Add temporary logging to both `bottlePriceList` and `bottleById` procedures to log the received `input` value.\n * This will help confirm whether the server is receiving the correct IDs from the client.\n * **Important**: Remove these logs after debugging to avoid unnecessary overhead.\n\n ```typescript\n // apps/server/src/trpc/routes/bottlePriceList.ts\n export default publicProcedure\n .input(\n z.object({\n bottle: z.number(),\n onlyValid: z.boolean().optional(),\n }),\n )\n .query(async function ({ input, ctx }) {\n console.log(\"bottlePriceList input:\", input); // Add this line\n // ... rest of the code\n });\n\n // apps/server/src/trpc/routes/bottleById.ts\n export default publicProcedure.input(z.number()).query(async function ({\n input,\n ctx,\n }) {\n console.log(\"bottleById input:\", input); // Add this line\n // ... rest of the code\n });\n ```\n\n3. **TRPC Batch Request Configuration**:\n\n * Review the TRPC batch link configuration on the client-side.\n * Ensure that the batching logic is correctly mapping parameters to the corresponding procedures.\n * If using a custom batching implementation, verify its correctness.\n\n4. **Data Integrity Check**:\n\n * If the client-side code appears correct, investigate whether bottle ID 3216 should exist in the database.\n * Check the `bottles` table and `bottleTombstones` table for any entries related to bottle ID 3216.\n * If the bottle should exist but is missing, investigate potential data deletion or migration issues.\n\n5. **Tombstone Logic**:\n\n * Double-check the logic for creating and using tombstones.\n * Ensure that when a bottle is deleted, a tombstone entry is created correctly, pointing to the new bottle (if any).\n\n6. **Error Handling**:\n\n * While this isn't the primary fix, consider improving the error message in `bottleById.ts` to include more context.\n * Include the original requested bottle ID (if available) in the error message to aid debugging.\n\n7. **Client-Side Retries**:\n\n * Implement a retry mechanism on the client-side for TRPC requests.\n * If a \"Bottle not found\" error occurs, retry the request a few times before giving up. This can help mitigate transient issues.\n\nBy following these steps, you should be able to identify the root cause of the parameter mismatch and implement a fix that ensures consistent bottle IDs are passed to both TRPC procedures in the batch request.\n",
246 | "type": "insight"
247 | }
248 | ],
249 | "key": "solution_processing",
250 | "output_confidence_score": null,
251 | "output_stream": "Based on all the information I've gathered, I'm going to develop a clear fix for this issue. Let's summarize the core problem first:\n\n1. We have a TRPC batch request that calls both `bottlePriceList` and `bottleById` procedures in a single HTTP request.\n2. The error shows a discrepancy between the bottle ID in the error message (16720) and the ID being processed when the error occurred (3216).\n3. The `bottleById` procedure fails because it's trying to find bottle ID 3216, which doesn't exist in the database or in tombstones.\n\nThe most likely issue is a parameter mismatch in the batch TRPC request. When examining the client code and the trace, it seems like the client-side code is sending different bottle IDs to the two procedures incorrectly.\n\nLet's analyze the most probable causes and their solutions:\n\n1. **Client-side parameter mismatch**: The client might be configuring the batch request incorrectly, sending bottle ID 16720 to `bottlePriceList` and bottle ID 3216 to `bottleById`.\n\n2. **Server-side parameter confusion**: There could be some issue with how parameters are being mapped in batched TRPC requests on the server side.\n\n3. **Application-level logic issue**: The application might be assuming that both procedures should operate on the same bottle ID, but this assumption is being violated during batch requests.\n\nFrom the code snippets I've seen, the most likely issue is in the client-side code where the batch request is being constructed. The solution should ensure consistency in parameters across the batch request.\n\nSince we can see from traces that the client is attempting to fetch bottle details, and the frontend code shows that the `bottlePriceList` and `bottleById` are supposed to operate on the same bottle ID, I'm going to recommend a solution that ensures parameter consistency.\n\nThe most actionable and effective fix would be to ensure that both procedures receive the same bottle ID in the batch request. Let's outline that solution in detail:# Solution to Fix the \"Bottle not found\" TRPC Error\n\n## Root Cause\nThe issue stems from a parameter mismatch in TRPC batch requests. When a request is made to `/trpc/bottlePriceList,bottleById`, the procedures are receiving different bottle IDs:\n- `bottlePriceList` receives ID **16720**\n- `bottleById` receives ID **3216**\n\nThe error occurs because bottle ID **3216** doesn't exist in the database.\n\n## Solution Steps\n\n### 1. Fix the Client-Side Batch Request Configuration\n\nThe most direct solution is to ensure parameter consistency in the batch request. Based on the web app's bottle page implementation, both procedures should receive the same bottle ID.\n\n```typescript\n// In apps/web/src/app/(default)/bottles/[bottleId]/(tabs)/page.tsx\nexport default async function BottleDetails({\n params: { bottleId },\n}: {\n params: { bottleId: string };\n}) {\n const trpcClient = await getTrpcClient();\n \n // PROBLEM: These are being called separately, potentially with different batching behavior\n const bottle = await trpcClient.bottleById.fetch(Number(bottleId));\n \n // ...rest of the function\n}\n\n// In apps/web/src/app/(default)/bottles/[bottleId]/(tabs)/prices/page.tsx\nexport default async function BottlePrices({\n params: { bottleId },\n}: {\n params: { bottleId: string };\n}) {\n const trpcClient = await getTrpcClient();\n const priceList = await trpcClient.bottlePriceList.fetch({\n bottle: Number(bottleId),\n });\n \n // ...rest of the function\n}\n```\n\n### 2. Implement Proper Error Handling for Missing Bottles\n\nAdd graceful handling when a bottle isn't found, especially in the context of batch requests:\n\n```typescript\n// In apps/server/src/trpc/routes/bottleById.ts\nexport default publicProcedure.input(z.number()).query(async function ({\n input,\n ctx,\n}) {\n try {\n let [bottle] = await db.select().from(bottles).where(eq(bottles.id, input));\n\n if (!bottle) {\n // check for a tombstone\n [bottle] = await db\n .select({\n ...getTableColumns(bottles),\n })\n .from(bottleTombstones)\n .innerJoin(bottles, eq(bottleTombstones.newBottleId, bottles.id))\n .where(eq(bottleTombstones.bottleId, input));\n if (!bottle) {\n throw new TRPCError({\n message: `Bottle not found with ID: ${input}`, // Include ID in error for better debugging\n code: \"NOT_FOUND\",\n });\n }\n }\n \n // Rest of the function...\n } catch (error) {\n // Log the error with context\n console.error(`Error fetching bottle ID ${input}:`, error);\n throw error;\n }\n});\n```\n\n### 3. Update the Batch Request Implementation to Ensure Parameter Consistency\n\nThe most thorough solution is to modify how pages that need both bottle details and price data fetch their information:\n\n```typescript\n// In a shared utility function or component\nexport async function getBottleWithPrices(bottleId: number) {\n const trpcClient = await getTrpcClient();\n \n // Use Promise.all to ensure both requests are part of the same batch\n // and receive the same parameters\n const [bottle, priceList] = await Promise.all([\n trpcClient.bottleById.fetch(bottleId),\n trpcClient.bottlePriceList.fetch({ bottle: bottleId }),\n ]);\n \n return { bottle, priceList };\n}\n\n// Then in the page components:\nconst { bottle, priceList } = await getBottleWithPrices(Number(bottleId));\n```\n\n### 4. Add Debugging to TRPC Batch Requests\n\nTo prevent similar issues in the future, add better logging for TRPC batch requests:\n\n```typescript\n// In apps/server/src/trpc/index.ts\nexport const publicProcedure = t.procedure.use(sentryMiddleware).use(\n t.middleware(async ({ ctx, next, path, input }) => {\n // Log the procedure name and input for debugging\n console.log(`TRPC procedure: ${path}, input:`, input);\n \n return next({\n ctx,\n });\n })\n);\n```\n\n## Implementation Priority\n\n1. **First implement solution #3** - Update how batch requests are made to ensure parameter consistency\n2. **Then implement solution #2** - Improve error messaging and handling\n3. **Consider solution #4** for monitoring and preventing future issues\n\nThis approach addresses the immediate problem while also improving the robustness of the application against similar issues in the future.",
252 | "proceed_confidence_score": null,
253 | "progress": [
254 | {
255 | "data": null,
256 | "message": "Figuring out a solution...",
257 | "timestamp": "2025-04-09T22:37:41.090125",
258 | "type": "INFO"
259 | },
260 | {
261 | "data": null,
262 | "message": "Looking at `apps/server/src/db/schema.ts` in `dcramer/peated`...",
263 | "timestamp": "2025-04-09T22:37:58.226774",
264 | "type": "INFO"
265 | },
266 | {
267 | "data": null,
268 | "message": "Searching files with `find . -name \"schema.ts\" -o -name \"*schema*.ts\"`...",
269 | "timestamp": "2025-04-09T22:38:06.650338",
270 | "type": "INFO"
271 | },
272 | {
273 | "data": null,
274 | "message": "Searching for \"database schema definition for bottles and bottleTombstones\"...",
275 | "timestamp": "2025-04-09T22:38:09.204410",
276 | "type": "INFO"
277 | },
278 | {
279 | "data": null,
280 | "message": "Looking at `apps/server/src/db/schema/storePrices.ts` in `dcramer/peated`...",
281 | "timestamp": "2025-04-09T22:38:12.727565",
282 | "type": "INFO"
283 | },
284 | {
285 | "data": null,
286 | "message": "Searching for \"database schema for storePrices and externalSites\"...",
287 | "timestamp": "2025-04-09T22:38:14.892955",
288 | "type": "INFO"
289 | },
290 | {
291 | "data": null,
292 | "message": "Searching for \"schema definition for storePrices table\"...",
293 | "timestamp": "2025-04-09T22:38:18.078667",
294 | "type": "INFO"
295 | },
296 | {
297 | "data": null,
298 | "message": "Grepping codebase with `grep -r \"public_trpc\" --include=\"*.ts\" --include=\"*.tsx\"`...",
299 | "timestamp": "2025-04-09T22:38:22.072610",
300 | "type": "INFO"
301 | },
302 | {
303 | "data": null,
304 | "message": "Grepping codebase with `grep -r \"createTRPCRouter\" --include=\"*.ts\" --include=\"*.tsx\"`...",
305 | "timestamp": "2025-04-09T22:38:24.640654",
306 | "type": "INFO"
307 | },
308 | {
309 | "data": null,
310 | "message": "Searching for \"TRPC router definition or main TRPC setup\"...",
311 | "timestamp": "2025-04-09T22:38:27.296050",
312 | "type": "INFO"
313 | },
314 | {
315 | "data": null,
316 | "message": "Looking at `apps/server/src/trpc/index.ts` in `dcramer/peated`...",
317 | "timestamp": "2025-04-09T22:38:31.342500",
318 | "type": "INFO"
319 | },
320 | {
321 | "data": null,
322 | "message": "Grepping codebase with `grep -r \"SELECT.*FROM bottle\" --include=\"*.ts\" --include=\"*.tsx\"`...",
323 | "timestamp": "2025-04-09T22:38:33.896598",
324 | "type": "INFO"
325 | },
326 | {
327 | "data": null,
328 | "message": "Looking at `apps/web/src/routes/[regionSlug]/[...bottleSlug].tsx` in `dcramer/peated`...",
329 | "timestamp": "2025-04-09T22:38:36.691936",
330 | "type": "INFO"
331 | },
332 | {
333 | "data": null,
334 | "message": "Searching files with `find . -path \"*bottle*\" -name \"*.tsx\"`...",
335 | "timestamp": "2025-04-09T22:38:39.431421",
336 | "type": "INFO"
337 | },
338 | {
339 | "data": null,
340 | "message": "Looking at `apps/web/src/app/(default)/bottles/[bottleId]/(tabs)/prices/page.tsx` in `dcramer/peated`...",
341 | "timestamp": "2025-04-09T22:38:43.030734",
342 | "type": "INFO"
343 | },
344 | {
345 | "data": null,
346 | "message": "Looking at `apps/web/src/app/(default)/bottles/[bottleId]/(tabs)/page.tsx` in `dcramer/peated`...",
347 | "timestamp": "2025-04-09T22:38:45.535641",
348 | "type": "INFO"
349 | },
350 | {
351 | "data": null,
352 | "message": "Looking at `apps/web/src/lib/trpc/client.server.ts` in `dcramer/peated`...",
353 | "timestamp": "2025-04-09T22:38:48.766893",
354 | "type": "INFO"
355 | },
356 | {
357 | "data": null,
358 | "message": "Looking at `apps/server/src/trpc/links.ts` in `dcramer/peated`...",
359 | "timestamp": "2025-04-09T22:38:51.786534",
360 | "type": "INFO"
361 | },
362 | {
363 | "data": null,
364 | "message": "Looking at `apps/server/src/trpc/context.ts` in `dcramer/peated`...",
365 | "timestamp": "2025-04-09T22:38:54.281514",
366 | "type": "INFO"
367 | },
368 | {
369 | "data": null,
370 | "message": "Studying spans under `http.server - GET /trpc/bottlePriceList,bottleById`...",
371 | "timestamp": "2025-04-09T22:39:00.325683",
372 | "type": "INFO"
373 | },
374 | {
375 | "data": null,
376 | "message": "Being artificially intelligent...",
377 | "timestamp": "2025-04-09T22:39:19.353863",
378 | "type": "INFO"
379 | },
380 | {
381 | "data": null,
382 | "message": "Formatting for human consumption...",
383 | "timestamp": "2025-04-09T22:39:47.228661",
384 | "type": "INFO"
385 | }
386 | ],
387 | "queued_user_messages": [],
388 | "status": "COMPLETED",
389 | "title": "Planning Solution",
390 | "type": "default"
391 | },
392 | {
393 | "active_comment_thread": null,
394 | "agent_comment_thread": null,
395 | "completedMessage": null,
396 | "custom_solution": null,
397 | "description": "Consolidate bottle and price data fetching into a single batched TRPC request using `Promise.all` to ensure ID consistency.",
398 | "id": "87ba0ee3-af4e-458e-b3a9-6fd5f9d4a471",
399 | "index": 3,
400 | "key": "solution",
401 | "output_confidence_score": null,
402 | "output_stream": null,
403 | "proceed_confidence_score": null,
404 | "progress": [
405 | {
406 | "data": null,
407 | "message": "Here is Seer's proposed solution.",
408 | "timestamp": "2025-04-09T22:39:50.191715",
409 | "type": "INFO"
410 | }
411 | ],
412 | "queued_user_messages": [],
413 | "selected_mode": null,
414 | "solution": [
415 | {
416 | "code_snippet_and_analysis": "```typescript\n// In a shared utility function or component\nexport async function getBottleWithPrices(bottleId: number) {\n const trpcClient = await getTrpcClient();\n \n // Use Promise.all to ensure both requests are part of the same batch\n // and receive the same parameters\n const [bottle, priceList] = await Promise.all([\n trpcClient.bottleById.fetch(bottleId),\n trpcClient.bottlePriceList.fetch({ bottle: bottleId }),\n ]);\n \n return { bottle, priceList };\n}\n```\nThis code creates a function that uses `Promise.all` to fetch both bottle details and price data concurrently. This ensures that both TRPC procedures are part of the same batch and receive the same `bottleId`.",
417 | "is_active": true,
418 | "is_most_important_event": true,
419 | "relevant_code_file": null,
420 | "timeline_item_type": "internal_code",
421 | "title": "Create a shared utility function to fetch bottle details and price data together."
422 | },
423 | {
424 | "code_snippet_and_analysis": "```typescript\n// Then in the page components:\nconst { bottle, priceList } = await getBottleWithPrices(Number(bottleId));\n```\nThis code replaces the separate calls to `bottleById` and `bottlePriceList` with a single call to the `getBottleWithPrices` function, ensuring that both components receive data for the same bottle.",
425 | "is_active": true,
426 | "is_most_important_event": false,
427 | "relevant_code_file": null,
428 | "timeline_item_type": "internal_code",
429 | "title": "Modify the page components to use the shared utility function."
430 | },
431 | {
432 | "code_snippet_and_analysis": null,
433 | "is_active": false,
434 | "is_most_important_event": false,
435 | "relevant_code_file": null,
436 | "timeline_item_type": "repro_test",
437 | "title": "Add a unit test that reproduces the issue."
438 | }
439 | ],
440 | "solution_selected": false,
441 | "status": "COMPLETED",
442 | "title": "Solution",
443 | "type": "solution"
444 | }
445 | ]
446 | }
447 | }
448 |
```
--------------------------------------------------------------------------------
/packages/mcp-core/src/api-client/client.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
2 | import { SentryApiService } from "./client";
3 | import { ConfigurationError } from "../errors";
4 |
5 | describe("getIssueUrl", () => {
6 | it("should work with sentry.io", () => {
7 | const apiService = new SentryApiService({ host: "sentry.io" });
8 | const result = apiService.getIssueUrl("sentry-mcp", "123456");
9 | expect(result).toMatchInlineSnapshot(
10 | `"https://sentry-mcp.sentry.io/issues/123456"`,
11 | );
12 | });
13 | it("should work with self-hosted", () => {
14 | const apiService = new SentryApiService({ host: "sentry.example.com" });
15 | const result = apiService.getIssueUrl("sentry-mcp", "123456");
16 | expect(result).toMatchInlineSnapshot(
17 | `"https://sentry.example.com/organizations/sentry-mcp/issues/123456"`,
18 | );
19 | });
20 | it("should work with full URL including protocol", () => {
21 | const apiService = new SentryApiService({
22 | host: "sentry.example.com",
23 | });
24 | const result = apiService.getIssueUrl("sentry-mcp", "123456");
25 | expect(result).toMatchInlineSnapshot(
26 | `"https://sentry.example.com/organizations/sentry-mcp/issues/123456"`,
27 | );
28 | });
29 | it("should always use HTTPS protocol", () => {
30 | const apiService = new SentryApiService({
31 | host: "localhost:8000",
32 | });
33 | const result = apiService.getIssueUrl("sentry-mcp", "123456");
34 | expect(result).toMatchInlineSnapshot(
35 | `"https://localhost:8000/organizations/sentry-mcp/issues/123456"`,
36 | );
37 | });
38 | it("should handle regional URLs correctly for SaaS", () => {
39 | const apiService = new SentryApiService({ host: "us.sentry.io" });
40 | const result = apiService.getIssueUrl("sentry", "PROJ-THREAD-LEAKS-12");
41 | // Should use sentry.io, not us.sentry.io for web UI
42 | expect(result).toEqual(
43 | "https://sentry.sentry.io/issues/PROJ-THREAD-LEAKS-12",
44 | );
45 | });
46 | it("should handle EU regional URLs correctly for SaaS", () => {
47 | const apiService = new SentryApiService({ host: "eu.sentry.io" });
48 | const result = apiService.getIssueUrl("myorg", "PROJECT-456");
49 | // Should use sentry.io, not eu.sentry.io for web UI
50 | expect(result).toEqual("https://myorg.sentry.io/issues/PROJECT-456");
51 | });
52 | });
53 |
54 | describe("getTraceUrl", () => {
55 | it("should work with sentry.io", () => {
56 | const apiService = new SentryApiService({ host: "sentry.io" });
57 | const result = apiService.getTraceUrl(
58 | "sentry-mcp",
59 | "6a477f5b0f31ef7b6b9b5e1dea66c91d",
60 | );
61 | expect(result).toMatchInlineSnapshot(
62 | `"https://sentry-mcp.sentry.io/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d"`,
63 | );
64 | });
65 | it("should work with self-hosted", () => {
66 | const apiService = new SentryApiService({ host: "sentry.example.com" });
67 | const result = apiService.getTraceUrl(
68 | "sentry-mcp",
69 | "6a477f5b0f31ef7b6b9b5e1dea66c91d",
70 | );
71 | expect(result).toMatchInlineSnapshot(
72 | `"https://sentry.example.com/organizations/sentry-mcp/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d"`,
73 | );
74 | });
75 | it("should always use HTTPS protocol", () => {
76 | const apiService = new SentryApiService({
77 | host: "localhost:8000",
78 | });
79 | const result = apiService.getTraceUrl(
80 | "sentry-mcp",
81 | "6a477f5b0f31ef7b6b9b5e1dea66c91d",
82 | );
83 | expect(result).toMatchInlineSnapshot(
84 | `"https://localhost:8000/organizations/sentry-mcp/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d"`,
85 | );
86 | });
87 | it("should handle regional URLs correctly for SaaS", () => {
88 | const apiService = new SentryApiService({ host: "us.sentry.io" });
89 | const result = apiService.getTraceUrl(
90 | "sentry",
91 | "6a477f5b0f31ef7b6b9b5e1dea66c91d",
92 | );
93 | // Should use sentry.io, not us.sentry.io for web UI
94 | expect(result).toEqual(
95 | "https://sentry.sentry.io/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d",
96 | );
97 | });
98 | });
99 |
100 | describe("getEventsExplorerUrl", () => {
101 | it("should work with sentry.io", () => {
102 | const apiService = new SentryApiService({ host: "sentry.io" });
103 | const result = apiService.getEventsExplorerUrl(
104 | "sentry-mcp",
105 | "level:error AND message:timeout",
106 | );
107 | expect(result).toMatchInlineSnapshot(
108 | `"https://sentry-mcp.sentry.io/explore/traces/?query=level%3Aerror+AND+message%3Atimeout&statsPeriod=24h&table=span"`,
109 | );
110 | });
111 | it("should work with self-hosted", () => {
112 | const apiService = new SentryApiService({ host: "sentry.example.com" });
113 | const result = apiService.getEventsExplorerUrl(
114 | "sentry-mcp",
115 | "level:error AND message:timeout",
116 | );
117 | expect(result).toMatchInlineSnapshot(
118 | `"https://sentry.example.com/organizations/sentry-mcp/explore/traces/?query=level%3Aerror+AND+message%3Atimeout&statsPeriod=24h&table=span"`,
119 | );
120 | });
121 | it("should include project parameter when provided", () => {
122 | const apiService = new SentryApiService({ host: "sentry.io" });
123 | const result = apiService.getEventsExplorerUrl(
124 | "sentry-mcp",
125 | "level:error",
126 | "backend",
127 | );
128 | expect(result).toMatchInlineSnapshot(
129 | `"https://sentry-mcp.sentry.io/explore/traces/?query=level%3Aerror&project=backend&statsPeriod=24h&table=span"`,
130 | );
131 | });
132 | it("should properly encode special characters in query", () => {
133 | const apiService = new SentryApiService({ host: "sentry.io" });
134 | const result = apiService.getEventsExplorerUrl(
135 | "sentry-mcp",
136 | 'message:"database timeout" AND level:error',
137 | );
138 | expect(result).toMatchInlineSnapshot(
139 | `"https://sentry-mcp.sentry.io/explore/traces/?query=message%3A%22database+timeout%22+AND+level%3Aerror&statsPeriod=24h&table=span"`,
140 | );
141 | });
142 | it("should always use HTTPS protocol", () => {
143 | const apiService = new SentryApiService({
144 | host: "localhost:8000",
145 | });
146 | const result = apiService.getEventsExplorerUrl("sentry-mcp", "level:error");
147 | expect(result).toMatchInlineSnapshot(
148 | `"https://localhost:8000/organizations/sentry-mcp/explore/traces/?query=level%3Aerror&statsPeriod=24h&table=span"`,
149 | );
150 | });
151 | it("should handle regional URLs correctly for SaaS", () => {
152 | const apiService = new SentryApiService({ host: "us.sentry.io" });
153 | const result = apiService.getEventsExplorerUrl("sentry", "level:error");
154 | // Should use sentry.io, not us.sentry.io for web UI
155 | expect(result).toEqual(
156 | "https://sentry.sentry.io/explore/traces/?query=level%3Aerror&statsPeriod=24h&table=span",
157 | );
158 | });
159 |
160 | describe("time parameters", () => {
161 | it("should use statsPeriod when provided for spans dataset", () => {
162 | const apiService = new SentryApiService({ host: "sentry.io" });
163 | const result = apiService.getEventsExplorerUrl(
164 | "sentry-mcp",
165 | "level:error",
166 | undefined, // projectId
167 | "spans", // dataset
168 | undefined, // fields
169 | undefined, // sort
170 | undefined, // aggregateFunctions
171 | undefined, // groupByFields
172 | "7d", // statsPeriod
173 | );
174 | expect(result).toContain("statsPeriod=7d");
175 | expect(result).not.toContain("start=");
176 | expect(result).not.toContain("end=");
177 | });
178 |
179 | it("should use start/end when provided for spans dataset", () => {
180 | const apiService = new SentryApiService({ host: "sentry.io" });
181 | const result = apiService.getEventsExplorerUrl(
182 | "sentry-mcp",
183 | "level:error",
184 | undefined, // projectId
185 | "spans", // dataset
186 | undefined, // fields
187 | undefined, // sort
188 | undefined, // aggregateFunctions
189 | undefined, // groupByFields
190 | undefined, // statsPeriod
191 | "2025-07-29T07:00:00", // start
192 | "2025-07-31T06:59:59", // end
193 | );
194 | expect(result).toContain("start=2025-07-29T07%3A00%3A00");
195 | expect(result).toContain("end=2025-07-31T06%3A59%3A59");
196 | expect(result).not.toContain("statsPeriod=");
197 | });
198 |
199 | it("should prefer start/end over statsPeriod when both provided for spans dataset", () => {
200 | const apiService = new SentryApiService({ host: "sentry.io" });
201 | const result = apiService.getEventsExplorerUrl(
202 | "sentry-mcp",
203 | "level:error",
204 | undefined, // projectId
205 | "spans", // dataset
206 | undefined, // fields
207 | undefined, // sort
208 | undefined, // aggregateFunctions
209 | undefined, // groupByFields
210 | "7d", // statsPeriod (should be ignored)
211 | "2025-07-29T07:00:00", // start
212 | "2025-07-31T06:59:59", // end
213 | );
214 | expect(result).toContain("start=2025-07-29T07%3A00%3A00");
215 | expect(result).toContain("end=2025-07-31T06%3A59%3A59");
216 | expect(result).not.toContain("statsPeriod=");
217 | });
218 |
219 | it("should use statsPeriod when provided for errors dataset", () => {
220 | const apiService = new SentryApiService({ host: "sentry.io" });
221 | const result = apiService.getEventsExplorerUrl(
222 | "sentry-mcp",
223 | "level:error",
224 | undefined, // projectId
225 | "errors", // dataset
226 | undefined, // fields
227 | undefined, // sort
228 | undefined, // aggregateFunctions
229 | undefined, // groupByFields
230 | "14d", // statsPeriod
231 | );
232 | expect(result).toContain("statsPeriod=14d");
233 | expect(result).not.toContain("start=");
234 | expect(result).not.toContain("end=");
235 | });
236 |
237 | it("should use start/end when provided for errors dataset", () => {
238 | const apiService = new SentryApiService({ host: "sentry.io" });
239 | const result = apiService.getEventsExplorerUrl(
240 | "sentry-mcp",
241 | "level:error",
242 | undefined, // projectId
243 | "errors", // dataset
244 | undefined, // fields
245 | undefined, // sort
246 | undefined, // aggregateFunctions
247 | undefined, // groupByFields
248 | undefined, // statsPeriod
249 | "2025-07-29T07:00:00", // start
250 | "2025-07-31T06:59:59", // end
251 | );
252 | expect(result).toContain("start=2025-07-29T07%3A00%3A00");
253 | expect(result).toContain("end=2025-07-31T06%3A59%3A59");
254 | expect(result).not.toContain("statsPeriod=");
255 | });
256 |
257 | it("should default to 24h when no time parameters provided", () => {
258 | const apiService = new SentryApiService({ host: "sentry.io" });
259 | const result = apiService.getEventsExplorerUrl(
260 | "sentry-mcp",
261 | "level:error",
262 | );
263 | expect(result).toContain("statsPeriod=24h");
264 | });
265 |
266 | it("should handle aggregate queries with time parameters for spans dataset", () => {
267 | const apiService = new SentryApiService({ host: "sentry.io" });
268 | const result = apiService.getEventsExplorerUrl(
269 | "sentry-mcp",
270 | "",
271 | "4509062593708032", // projectId
272 | "spans", // dataset
273 | [
274 | "equation|sum(gen_ai.usage.input_tokens) + sum(gen_ai.usage.output_tokens)",
275 | ], // fields
276 | "-equation|sum(gen_ai.usage.input_tokens) + sum(gen_ai.usage.output_tokens)", // sort
277 | [
278 | "equation|sum(gen_ai.usage.input_tokens) + sum(gen_ai.usage.output_tokens)",
279 | ], // aggregateFunctions
280 | [], // groupByFields
281 | "7d", // statsPeriod
282 | );
283 | expect(result).toContain("statsPeriod=7d");
284 | expect(result).toContain("project=4509062593708032");
285 | expect(result).toContain("mode=aggregate");
286 | });
287 | });
288 | });
289 |
290 | describe("network error handling", () => {
291 | let originalFetch: typeof globalThis.fetch;
292 |
293 | beforeEach(() => {
294 | originalFetch = globalThis.fetch;
295 | });
296 |
297 | afterEach(() => {
298 | globalThis.fetch = originalFetch;
299 | });
300 |
301 | it("should handle DNS errors with EAI_AGAIN", async () => {
302 | const dnsError = new Error("fetch failed");
303 | dnsError.cause = new Error("getaddrinfo EAI_AGAIN https");
304 |
305 | globalThis.fetch = vi.fn().mockRejectedValue(dnsError);
306 |
307 | const apiService = new SentryApiService({
308 | host: "sentry.io",
309 | accessToken: "test-token",
310 | });
311 |
312 | await expect(apiService.getAuthenticatedUser()).rejects.toThrow(
313 | /DNS temporarily unavailable/,
314 | );
315 | });
316 |
317 | it("should handle ENOTFOUND errors", async () => {
318 | const notFoundError = new Error("fetch failed");
319 | notFoundError.cause = new Error("getaddrinfo ENOTFOUND invalid.host");
320 |
321 | globalThis.fetch = vi.fn().mockRejectedValue(notFoundError);
322 |
323 | const apiService = new SentryApiService({
324 | host: "invalid.host",
325 | accessToken: "test-token",
326 | });
327 |
328 | await expect(apiService.getAuthenticatedUser()).rejects.toThrow(
329 | /Hostname not found/,
330 | );
331 | });
332 |
333 | it("should handle ECONNREFUSED errors", async () => {
334 | const refusedError = new Error("fetch failed");
335 | refusedError.cause = new Error("connect ECONNREFUSED 127.0.0.1:443");
336 |
337 | globalThis.fetch = vi.fn().mockRejectedValue(refusedError);
338 |
339 | const apiService = new SentryApiService({
340 | host: "localhost",
341 | accessToken: "test-token",
342 | });
343 |
344 | await expect(apiService.getAuthenticatedUser()).rejects.toThrow(
345 | /Connection refused/,
346 | );
347 | });
348 |
349 | it("should handle ETIMEDOUT errors", async () => {
350 | const timeoutError = new Error("fetch failed");
351 | timeoutError.cause = new Error("connect ETIMEDOUT 192.168.1.1:443");
352 |
353 | globalThis.fetch = vi.fn().mockRejectedValue(timeoutError);
354 |
355 | const apiService = new SentryApiService({
356 | host: "192.168.1.1",
357 | accessToken: "test-token",
358 | });
359 |
360 | await expect(apiService.getAuthenticatedUser()).rejects.toThrow(
361 | /Connection timed out/,
362 | );
363 | });
364 |
365 | it("should handle ECONNRESET errors", async () => {
366 | const resetError = new Error("fetch failed");
367 | resetError.cause = new Error("read ECONNRESET");
368 |
369 | globalThis.fetch = vi.fn().mockRejectedValue(resetError);
370 |
371 | const apiService = new SentryApiService({
372 | host: "sentry.io",
373 | accessToken: "test-token",
374 | });
375 |
376 | await expect(apiService.getAuthenticatedUser()).rejects.toThrow(
377 | /Connection reset/,
378 | );
379 | });
380 |
381 | it("should handle generic network errors", async () => {
382 | const genericError = new Error("Network request failed");
383 |
384 | globalThis.fetch = vi.fn().mockRejectedValue(genericError);
385 |
386 | const apiService = new SentryApiService({
387 | host: "sentry.io",
388 | accessToken: "test-token",
389 | });
390 |
391 | await expect(apiService.getAuthenticatedUser()).rejects.toThrow(
392 | /Unable to connect to .* - Network request failed/,
393 | );
394 | });
395 |
396 | it("should preserve the original error in the cause chain", async () => {
397 | const originalError = new Error("getaddrinfo EAI_AGAIN");
398 | const fetchError = new Error("fetch failed");
399 | fetchError.cause = originalError;
400 |
401 | globalThis.fetch = vi.fn().mockRejectedValue(fetchError);
402 |
403 | const apiService = new SentryApiService({
404 | host: "sentry.io",
405 | accessToken: "test-token",
406 | });
407 |
408 | try {
409 | await apiService.getAuthenticatedUser();
410 | } catch (error) {
411 | expect(error).toBeInstanceOf(Error);
412 | expect((error as Error).cause).toBe(fetchError);
413 | expect(((error as Error).cause as Error).cause).toBe(originalError);
414 | }
415 | });
416 |
417 | it("should throw ConfigurationError for DNS errors", async () => {
418 | const dnsError = new Error("fetch failed");
419 | dnsError.cause = new Error("getaddrinfo ENOTFOUND invalid.host");
420 |
421 | globalThis.fetch = vi.fn().mockRejectedValue(dnsError);
422 |
423 | const apiService = new SentryApiService({
424 | host: "invalid.host",
425 | accessToken: "test-token",
426 | });
427 |
428 | await expect(apiService.getAuthenticatedUser()).rejects.toThrow(
429 | ConfigurationError,
430 | );
431 | });
432 |
433 | it("should throw ConfigurationError for connection timeout errors", async () => {
434 | const timeoutError = new Error("fetch failed");
435 | timeoutError.cause = new Error("connect ETIMEDOUT 192.168.1.1:443");
436 |
437 | globalThis.fetch = vi.fn().mockRejectedValue(timeoutError);
438 |
439 | const apiService = new SentryApiService({
440 | host: "192.168.1.1",
441 | accessToken: "test-token",
442 | });
443 |
444 | await expect(apiService.getAuthenticatedUser()).rejects.toThrow(
445 | ConfigurationError,
446 | );
447 | });
448 | });
449 |
450 | describe("listOrganizations", () => {
451 | let originalFetch: typeof globalThis.fetch;
452 |
453 | beforeEach(() => {
454 | originalFetch = globalThis.fetch;
455 | });
456 |
457 | afterEach(() => {
458 | globalThis.fetch = originalFetch;
459 | });
460 |
461 | it("should fetch from regions endpoint for SaaS", async () => {
462 | const mockRegionsResponse = {
463 | regions: [
464 | { name: "US", url: "https://us.sentry.io" },
465 | { name: "EU", url: "https://eu.sentry.io" },
466 | ],
467 | };
468 |
469 | const mockOrgsUs = [{ id: "1", slug: "org-us", name: "Org US" }];
470 | const mockOrgsEu = [{ id: "2", slug: "org-eu", name: "Org EU" }];
471 |
472 | let callCount = 0;
473 | globalThis.fetch = vi.fn().mockImplementation((url: string) => {
474 | callCount++;
475 | if (url.includes("/users/me/regions/")) {
476 | return Promise.resolve({
477 | ok: true,
478 | json: () => Promise.resolve(mockRegionsResponse),
479 | });
480 | }
481 | if (url.includes("us.sentry.io")) {
482 | return Promise.resolve({
483 | ok: true,
484 | json: () => Promise.resolve(mockOrgsUs),
485 | });
486 | }
487 | if (url.includes("eu.sentry.io")) {
488 | return Promise.resolve({
489 | ok: true,
490 | json: () => Promise.resolve(mockOrgsEu),
491 | });
492 | }
493 | return Promise.reject(new Error("Unexpected URL"));
494 | });
495 |
496 | const apiService = new SentryApiService({
497 | host: "sentry.io",
498 | accessToken: "test-token",
499 | });
500 |
501 | const result = await apiService.listOrganizations();
502 |
503 | expect(callCount).toBe(3); // 1 regions call + 2 org calls
504 | expect(result).toHaveLength(2);
505 | expect(result).toContainEqual(expect.objectContaining({ slug: "org-us" }));
506 | expect(result).toContainEqual(expect.objectContaining({ slug: "org-eu" }));
507 | });
508 |
509 | it("should fetch directly from organizations endpoint for self-hosted", async () => {
510 | const mockOrgs = [
511 | { id: "1", slug: "org-1", name: "Organization 1" },
512 | { id: "2", slug: "org-2", name: "Organization 2" },
513 | ];
514 |
515 | let callCount = 0;
516 | globalThis.fetch = vi.fn().mockImplementation((url: string) => {
517 | callCount++;
518 | if (url.includes("/organizations/")) {
519 | return Promise.resolve({
520 | ok: true,
521 | json: () => Promise.resolve(mockOrgs),
522 | });
523 | }
524 | return Promise.reject(new Error("Unexpected URL"));
525 | });
526 |
527 | const apiService = new SentryApiService({
528 | host: "sentry.example.com",
529 | accessToken: "test-token",
530 | });
531 |
532 | const result = await apiService.listOrganizations();
533 |
534 | expect(callCount).toBe(1); // Only 1 org call, no regions call
535 | expect(result).toHaveLength(2);
536 | expect(result).toEqual(mockOrgs);
537 | // Verify that regions endpoint was not called
538 | expect(globalThis.fetch).not.toHaveBeenCalledWith(
539 | expect.stringContaining("/users/me/regions/"),
540 | expect.any(Object),
541 | );
542 | });
543 |
544 | it("should fall back to direct organizations endpoint when regions endpoint returns 404 on SaaS", async () => {
545 | const mockOrgs = [
546 | { id: "1", slug: "org-1", name: "Organization 1" },
547 | { id: "2", slug: "org-2", name: "Organization 2" },
548 | ];
549 |
550 | globalThis.fetch = vi.fn().mockImplementation((url: string) => {
551 | if (url.includes("/users/me/regions/")) {
552 | return Promise.resolve({
553 | ok: false,
554 | status: 404,
555 | statusText: "Not Found",
556 | text: () => Promise.resolve(JSON.stringify({ detail: "Not found" })),
557 | });
558 | }
559 | if (url.includes("/organizations/")) {
560 | return Promise.resolve({
561 | ok: true,
562 | json: () => Promise.resolve(mockOrgs),
563 | });
564 | }
565 | return Promise.reject(new Error("Unexpected URL"));
566 | });
567 |
568 | const apiService = new SentryApiService({
569 | host: "sentry.io",
570 | accessToken: "test-token",
571 | });
572 |
573 | const result = await apiService.listOrganizations();
574 |
575 | expect(result).toHaveLength(2);
576 | expect(result).toEqual(mockOrgs);
577 |
578 | // Verify it tried regions first, then fell back to organizations
579 | expect(globalThis.fetch).toHaveBeenCalledWith(
580 | expect.stringContaining("/users/me/regions/"),
581 | expect.any(Object),
582 | );
583 | expect(globalThis.fetch).toHaveBeenCalledWith(
584 | expect.stringContaining("/organizations/"),
585 | expect.any(Object),
586 | );
587 | });
588 | });
589 |
590 | describe("host configuration", () => {
591 | it("should handle hostname without protocol", () => {
592 | const apiService = new SentryApiService({ host: "sentry.io" });
593 | // @ts-expect-error - accessing private property for testing
594 | expect(apiService.host).toBe("sentry.io");
595 | // @ts-expect-error - accessing private property for testing
596 | expect(apiService.apiPrefix).toBe("https://sentry.io/api/0");
597 | });
598 |
599 | it("should handle hostname with port", () => {
600 | const apiService = new SentryApiService({ host: "localhost:8000" });
601 | // @ts-expect-error - accessing private property for testing
602 | expect(apiService.host).toBe("localhost:8000");
603 | // @ts-expect-error - accessing private property for testing
604 | expect(apiService.apiPrefix).toBe("https://localhost:8000/api/0");
605 | });
606 |
607 | it("should always use HTTPS protocol", () => {
608 | const apiService = new SentryApiService({
609 | host: "sentry.example.com",
610 | });
611 | // @ts-expect-error - accessing private property for testing
612 | expect(apiService.host).toBe("sentry.example.com");
613 | // @ts-expect-error - accessing private property for testing
614 | expect(apiService.apiPrefix).toBe("https://sentry.example.com/api/0");
615 | });
616 |
617 | it("should always use HTTPS even for localhost", () => {
618 | const apiService = new SentryApiService({
619 | host: "localhost:8000",
620 | });
621 | // @ts-expect-error - accessing private property for testing
622 | expect(apiService.host).toBe("localhost:8000");
623 | // @ts-expect-error - accessing private property for testing
624 | expect(apiService.apiPrefix).toBe("https://localhost:8000/api/0");
625 | });
626 |
627 | it("should update host and API prefix with setHost", () => {
628 | const apiService = new SentryApiService({ host: "sentry.io" });
629 |
630 | apiService.setHost("eu.sentry.io");
631 | // @ts-expect-error - accessing private property for testing
632 | expect(apiService.host).toBe("eu.sentry.io");
633 | // @ts-expect-error - accessing private property for testing
634 | expect(apiService.apiPrefix).toBe("https://eu.sentry.io/api/0");
635 |
636 | apiService.setHost("localhost:9000");
637 | // @ts-expect-error - accessing private property for testing
638 | expect(apiService.host).toBe("localhost:9000");
639 | // @ts-expect-error - accessing private property for testing
640 | expect(apiService.apiPrefix).toBe("https://localhost:9000/api/0");
641 | });
642 | });
643 |
644 | describe("Content-Type validation", () => {
645 | it("should throw error when receiving HTML instead of JSON", async () => {
646 | const htmlContent = `<!DOCTYPE html>
647 | <html>
648 | <head><title>Login Required</title></head>
649 | <body><h1>Please log in</h1></body>
650 | </html>`;
651 |
652 | globalThis.fetch = vi.fn().mockResolvedValue({
653 | ok: true,
654 | status: 200,
655 | statusText: "OK",
656 | headers: {
657 | get: (key: string) =>
658 | key === "content-type" ? "text/html; charset=utf-8" : null,
659 | },
660 | text: () => Promise.resolve(htmlContent),
661 | });
662 |
663 | const apiService = new SentryApiService({
664 | host: "sentry.io",
665 | accessToken: "test-token",
666 | });
667 |
668 | await expect(apiService.getAuthenticatedUser()).rejects.toThrow(
669 | "Expected JSON response but received HTML (200 OK). This may indicate you're not authenticated, the URL is incorrect, or there's a server issue.",
670 | );
671 | });
672 |
673 | it("should throw error when receiving non-JSON content type", async () => {
674 | globalThis.fetch = vi.fn().mockResolvedValue({
675 | ok: true,
676 | status: 200,
677 | statusText: "OK",
678 | headers: {
679 | get: (key: string) => (key === "content-type" ? "text/plain" : null),
680 | },
681 | text: () => Promise.resolve("Error: Something went wrong"),
682 | });
683 |
684 | const apiService = new SentryApiService({
685 | host: "sentry.io",
686 | accessToken: "test-token",
687 | });
688 |
689 | await expect(apiService.getAuthenticatedUser()).rejects.toThrow(
690 | "Expected JSON response but received text/plain (200 OK)",
691 | );
692 | });
693 |
694 | it("should throw error when no content-type header is present", async () => {
695 | globalThis.fetch = vi.fn().mockResolvedValue({
696 | ok: true,
697 | status: 200,
698 | statusText: "OK",
699 | headers: {
700 | get: () => null,
701 | },
702 | text: () => Promise.resolve("Some non-JSON response"),
703 | });
704 |
705 | const apiService = new SentryApiService({
706 | host: "sentry.io",
707 | accessToken: "test-token",
708 | });
709 |
710 | await expect(apiService.getAuthenticatedUser()).rejects.toThrow(
711 | "Expected JSON response but received unknown content type (200 OK)",
712 | );
713 | });
714 |
715 | it("should parse JSON successfully when content-type is application/json", async () => {
716 | const mockUser = {
717 | id: "123",
718 | name: "Test User",
719 | email: "[email protected]",
720 | };
721 |
722 | globalThis.fetch = vi.fn().mockResolvedValue({
723 | ok: true,
724 | status: 200,
725 | statusText: "OK",
726 | headers: {
727 | get: (key: string) =>
728 | key === "content-type" ? "application/json; charset=utf-8" : null,
729 | },
730 | json: () => Promise.resolve(mockUser),
731 | });
732 |
733 | const apiService = new SentryApiService({
734 | host: "sentry.io",
735 | accessToken: "test-token",
736 | });
737 |
738 | const result = await apiService.getAuthenticatedUser();
739 | expect(result).toEqual(mockUser);
740 | });
741 |
742 | it("should detect HTML content even without content-type header", async () => {
743 | const htmlContent = "<!DOCTYPE html><html><body>Error page</body></html>";
744 |
745 | globalThis.fetch = vi.fn().mockResolvedValue({
746 | ok: true,
747 | status: 200,
748 | statusText: "OK",
749 | headers: {
750 | get: () => null,
751 | },
752 | text: () => Promise.resolve(htmlContent),
753 | });
754 |
755 | const apiService = new SentryApiService({
756 | host: "sentry.io",
757 | accessToken: "test-token",
758 | });
759 |
760 | await expect(apiService.getAuthenticatedUser()).rejects.toThrow(
761 | "Expected JSON response but received HTML (200 OK). This may indicate you're not authenticated, the URL is incorrect, or there's a server issue.",
762 | );
763 | });
764 |
765 | it("should handle HTML response from regions endpoint", async () => {
766 | const htmlContent = `<!DOCTYPE html>
767 | <html>
768 | <head><title>Login Required</title></head>
769 | <body><h1>Please log in</h1></body>
770 | </html>`;
771 |
772 | globalThis.fetch = vi.fn().mockResolvedValue({
773 | ok: true,
774 | status: 200,
775 | statusText: "OK",
776 | headers: {
777 | get: (key: string) =>
778 | key === "content-type" ? "text/html; charset=utf-8" : null,
779 | },
780 | text: () => Promise.resolve(htmlContent),
781 | });
782 |
783 | const apiService = new SentryApiService({
784 | host: "sentry.io",
785 | accessToken: "test-token",
786 | });
787 |
788 | await expect(apiService.listOrganizations()).rejects.toThrow(
789 | "Expected JSON response but received HTML (200 OK). This may indicate you're not authenticated, the URL is incorrect, or there's a server issue.",
790 | );
791 | });
792 | });
793 |
794 | describe("API query builders", () => {
795 | describe("buildDiscoverApiQuery", () => {
796 | it("should build correct query for errors dataset", () => {
797 | const apiService = new SentryApiService({ host: "sentry.io" });
798 |
799 | // @ts-expect-error - accessing private method for testing
800 | const params = apiService.buildDiscoverApiQuery({
801 | query: "level:error",
802 | fields: ["title", "project", "count()"],
803 | limit: 50,
804 | projectId: "backend",
805 | statsPeriod: "24h",
806 | sort: "-count()",
807 | });
808 |
809 | expect(params.toString()).toMatchInlineSnapshot(
810 | `"per_page=50&query=level%3Aerror&dataset=errors&statsPeriod=24h&project=backend&sort=-count&field=title&field=project&field=count%28%29"`,
811 | );
812 | });
813 |
814 | it("should transform aggregate sort parameters correctly", () => {
815 | const apiService = new SentryApiService({ host: "sentry.io" });
816 |
817 | // @ts-expect-error - accessing private method for testing
818 | const params = apiService.buildDiscoverApiQuery({
819 | query: "",
820 | fields: ["error.type", "count()", "count_unique(user)"],
821 | limit: 10,
822 | sort: "-count(span.duration)",
823 | });
824 |
825 | expect(params.get("sort")).toBe("-count_span_duration");
826 | });
827 |
828 | it("should handle empty aggregate functions in sort", () => {
829 | const apiService = new SentryApiService({ host: "sentry.io" });
830 |
831 | // @ts-expect-error - accessing private method for testing
832 | const params = apiService.buildDiscoverApiQuery({
833 | query: "",
834 | fields: ["title", "count()"],
835 | limit: 10,
836 | sort: "-count()",
837 | });
838 |
839 | expect(params.get("sort")).toBe("-count");
840 | });
841 |
842 | it("should safely handle malformed sort parameters", () => {
843 | const apiService = new SentryApiService({ host: "sentry.io" });
844 |
845 | // @ts-expect-error - accessing private method for testing
846 | const params = apiService.buildDiscoverApiQuery({
847 | query: "",
848 | fields: ["title"],
849 | limit: 10,
850 | sort: "-count(((",
851 | });
852 |
853 | // Should not crash and should return the original sort if malformed
854 | expect(params.get("sort")).toBe("-count(((");
855 | });
856 | });
857 |
858 | describe("buildEapApiQuery", () => {
859 | it("should build correct query for spans dataset with sampling", () => {
860 | const apiService = new SentryApiService({ host: "sentry.io" });
861 |
862 | // @ts-expect-error - accessing private method for testing
863 | const params = apiService.buildEapApiQuery({
864 | query: "span.op:db",
865 | fields: ["span.op", "span.description", "span.duration"],
866 | limit: 20,
867 | projectId: "frontend",
868 | dataset: "spans",
869 | statsPeriod: "1h",
870 | sort: "-span.duration",
871 | });
872 |
873 | expect(params.toString()).toMatchInlineSnapshot(
874 | `"per_page=20&query=span.op%3Adb&dataset=spans&statsPeriod=1h&project=frontend&sampling=NORMAL&sort=-span.duration&field=span.op&field=span.description&field=span.duration"`,
875 | );
876 | });
877 |
878 | it("should build correct query for logs dataset without sampling", () => {
879 | const apiService = new SentryApiService({ host: "sentry.io" });
880 |
881 | // @ts-expect-error - accessing private method for testing
882 | const params = apiService.buildEapApiQuery({
883 | query: "severity:error",
884 | fields: ["timestamp", "message", "severity"],
885 | limit: 30,
886 | dataset: "logs",
887 | sort: "-timestamp",
888 | });
889 |
890 | expect(params.toString()).toMatchInlineSnapshot(
891 | `"per_page=30&query=severity%3Aerror&dataset=logs&sort=-timestamp&field=timestamp&field=message&field=severity"`,
892 | );
893 |
894 | // Verify sampling is not added for logs
895 | expect(params.has("sampling")).toBe(false);
896 | });
897 |
898 | it("should transform complex aggregate sorts with dots", () => {
899 | const apiService = new SentryApiService({ host: "sentry.io" });
900 |
901 | // @ts-expect-error - accessing private method for testing
902 | const params = apiService.buildEapApiQuery({
903 | query: "",
904 | fields: ["span.op", "avg(span.self_time)"],
905 | limit: 10,
906 | dataset: "spans",
907 | sort: "-avg(span.self_time)",
908 | });
909 |
910 | expect(params.get("sort")).toBe("-avg_span_self_time");
911 | });
912 | });
913 |
914 | describe("searchEvents integration", () => {
915 | it("should route errors dataset to Discover API builder", async () => {
916 | const apiService = new SentryApiService({
917 | host: "sentry.io",
918 | accessToken: "test-token",
919 | });
920 |
921 | // Mock the API response
922 | globalThis.fetch = vi.fn().mockResolvedValue({
923 | ok: true,
924 | headers: {
925 | get: (key: string) =>
926 | key === "content-type" ? "application/json" : null,
927 | },
928 | json: () => Promise.resolve({ data: [] }),
929 | });
930 |
931 | await apiService.searchEvents({
932 | organizationSlug: "test-org",
933 | query: "level:error",
934 | fields: ["title", "count()"],
935 | dataset: "errors",
936 | sort: "-count()",
937 | });
938 |
939 | // Verify the URL contains correct parameters
940 | expect(globalThis.fetch).toHaveBeenCalledWith(
941 | expect.stringContaining("dataset=errors"),
942 | expect.any(Object),
943 | );
944 | expect(globalThis.fetch).toHaveBeenCalledWith(
945 | expect.stringContaining("sort=-count"),
946 | expect.any(Object),
947 | );
948 | });
949 |
950 | it("should route spans dataset to EAP API builder with sampling", async () => {
951 | const apiService = new SentryApiService({
952 | host: "sentry.io",
953 | accessToken: "test-token",
954 | });
955 |
956 | // Mock the API response
957 | globalThis.fetch = vi.fn().mockResolvedValue({
958 | ok: true,
959 | headers: {
960 | get: (key: string) =>
961 | key === "content-type" ? "application/json" : null,
962 | },
963 | json: () => Promise.resolve({ data: [] }),
964 | });
965 |
966 | await apiService.searchEvents({
967 | organizationSlug: "test-org",
968 | query: "span.op:http",
969 | fields: ["span.op", "span.duration"],
970 | dataset: "spans",
971 | });
972 |
973 | // Verify the URL contains correct parameters
974 | expect(globalThis.fetch).toHaveBeenCalledWith(
975 | expect.stringContaining("dataset=spans"),
976 | expect.any(Object),
977 | );
978 | expect(globalThis.fetch).toHaveBeenCalledWith(
979 | expect.stringContaining("sampling=NORMAL"),
980 | expect.any(Object),
981 | );
982 | });
983 | });
984 |
985 | describe("Web URL builders", () => {
986 | describe("buildDiscoverUrl", () => {
987 | it("should build correct URL for errors dataset on SaaS", () => {
988 | const apiService = new SentryApiService({ host: "sentry.io" });
989 |
990 | // @ts-expect-error - accessing private method for testing
991 | const url = apiService.buildDiscoverUrl({
992 | organizationSlug: "my-org",
993 | query: "level:error",
994 | projectId: "backend",
995 | fields: ["title", "project", "timestamp"],
996 | sort: "-timestamp",
997 | });
998 |
999 | expect(url).toMatchInlineSnapshot(
1000 | `"https://my-org.sentry.io/explore/discover/homepage/?dataset=errors&queryDataset=error-events&query=level%3Aerror&project=backend&field=title&field=project&field=timestamp&sort=-timestamp&statsPeriod=24h&yAxis=count%28%29"`,
1001 | );
1002 | });
1003 |
1004 | it("should include aggregate mode and yAxis for aggregate queries", () => {
1005 | const apiService = new SentryApiService({ host: "sentry.io" });
1006 |
1007 | // @ts-expect-error - accessing private method for testing
1008 | const url = apiService.buildDiscoverUrl({
1009 | organizationSlug: "my-org",
1010 | query: "is:unresolved",
1011 | fields: ["title", "count()"],
1012 | sort: "-count()",
1013 | aggregateFunctions: ["count()"],
1014 | groupByFields: ["title"],
1015 | });
1016 |
1017 | expect(url).toContain("mode=aggregate");
1018 | expect(url).toContain("yAxis=count%28%29");
1019 | expect(url).toContain("field=title");
1020 | expect(url).toContain("field=count%28%29");
1021 | });
1022 |
1023 | it("should build correct URL for self-hosted", () => {
1024 | const apiService = new SentryApiService({ host: "sentry.example.com" });
1025 |
1026 | // @ts-expect-error - accessing private method for testing
1027 | const url = apiService.buildDiscoverUrl({
1028 | organizationSlug: "my-org",
1029 | query: "level:error",
1030 | fields: ["title", "project"],
1031 | });
1032 |
1033 | expect(url).toMatchInlineSnapshot(
1034 | `"https://sentry.example.com/organizations/my-org/explore/discover/homepage/?dataset=errors&queryDataset=error-events&query=level%3Aerror&field=title&field=project&sort=-timestamp&statsPeriod=24h&yAxis=count%28%29"`,
1035 | );
1036 | });
1037 | });
1038 |
1039 | describe("buildEapUrl", () => {
1040 | it("should build correct URL for spans dataset with aggregate fields", () => {
1041 | const apiService = new SentryApiService({ host: "sentry.io" });
1042 |
1043 | // @ts-expect-error - accessing private method for testing
1044 | const url = apiService.buildEapUrl({
1045 | organizationSlug: "my-org",
1046 | query: "is_transaction:True",
1047 | dataset: "spans",
1048 | projectId: "123456",
1049 | fields: ["span.description", "count()"],
1050 | sort: "-count()",
1051 | aggregateFunctions: ["count()"],
1052 | groupByFields: ["span.description"],
1053 | });
1054 |
1055 | expect(url).toContain("https://my-org.sentry.io/explore/traces/");
1056 | expect(url).toContain("mode=aggregate");
1057 | expect(url).toContain(
1058 | `aggregateField=%7B%22groupBy%22%3A%22span.description%22%7D`,
1059 | );
1060 | expect(url).toContain(
1061 | `aggregateField=%7B%22yAxes%22%3A%5B%22count%28%29%22%5D%7D`,
1062 | );
1063 | expect(url).toContain("project=123456");
1064 | expect(url).toContain("query=is_transaction%3ATrue");
1065 | expect(url).toContain("statsPeriod=24h");
1066 | });
1067 |
1068 | it("should not include empty groupBy in aggregateField", () => {
1069 | const apiService = new SentryApiService({ host: "sentry.io" });
1070 |
1071 | // @ts-expect-error - accessing private method for testing
1072 | const url = apiService.buildEapUrl({
1073 | organizationSlug: "my-org",
1074 | query: "span.op:db",
1075 | dataset: "spans",
1076 | fields: ["count()"],
1077 | sort: "-count()",
1078 | aggregateFunctions: ["count()"],
1079 | groupByFields: [],
1080 | });
1081 |
1082 | expect(url).toContain("mode=aggregate");
1083 | expect(url).toContain(
1084 | `aggregateField=%7B%22yAxes%22%3A%5B%22count%28%29%22%5D%7D`,
1085 | );
1086 | expect(url).not.toContain("groupBy");
1087 | });
1088 |
1089 | it("should handle multiple groupBy fields", () => {
1090 | const apiService = new SentryApiService({ host: "sentry.io" });
1091 |
1092 | // @ts-expect-error - accessing private method for testing
1093 | const url = apiService.buildEapUrl({
1094 | organizationSlug: "my-org",
1095 | query: "",
1096 | dataset: "spans",
1097 | fields: ["span.op", "span.description", "count()"],
1098 | sort: "-count()",
1099 | aggregateFunctions: ["count()"],
1100 | groupByFields: ["span.op", "span.description"],
1101 | });
1102 |
1103 | expect(url).toContain(
1104 | `aggregateField=%7B%22groupBy%22%3A%22span.op%22%7D`,
1105 | );
1106 | expect(url).toContain(
1107 | `aggregateField=%7B%22groupBy%22%3A%22span.description%22%7D`,
1108 | );
1109 | expect(url).toContain(
1110 | `aggregateField=%7B%22yAxes%22%3A%5B%22count%28%29%22%5D%7D`,
1111 | );
1112 | });
1113 |
1114 | it("should handle non-aggregate queries", () => {
1115 | const apiService = new SentryApiService({ host: "sentry.io" });
1116 |
1117 | // @ts-expect-error - accessing private method for testing
1118 | const url = apiService.buildEapUrl({
1119 | organizationSlug: "my-org",
1120 | query: "span.op:http",
1121 | dataset: "spans",
1122 | fields: ["span.op", "span.description", "span.duration"],
1123 | sort: "-span.duration",
1124 | });
1125 |
1126 | expect(url).not.toContain("mode=aggregate");
1127 | expect(url).not.toContain("aggregateField");
1128 | expect(url).toContain("field=span.op");
1129 | expect(url).toContain("field=span.description");
1130 | expect(url).toContain("field=span.duration");
1131 | expect(url).toContain("sort=-span.duration");
1132 | });
1133 |
1134 | it("should use correct path for logs dataset", () => {
1135 | const apiService = new SentryApiService({ host: "sentry.io" });
1136 |
1137 | // @ts-expect-error - accessing private method for testing
1138 | const url = apiService.buildEapUrl({
1139 | organizationSlug: "my-org",
1140 | query: "severity:error",
1141 | dataset: "logs",
1142 | fields: ["timestamp", "message"],
1143 | });
1144 |
1145 | expect(url).toContain("/explore/logs/");
1146 | expect(url).not.toContain("/explore/traces/");
1147 | });
1148 |
1149 | it("should handle self-hosted URLs correctly", () => {
1150 | const apiService = new SentryApiService({ host: "sentry.example.com" });
1151 |
1152 | // @ts-expect-error - accessing private method for testing
1153 | const url = apiService.buildEapUrl({
1154 | organizationSlug: "my-org",
1155 | query: "",
1156 | dataset: "spans",
1157 | fields: ["span.op"],
1158 | });
1159 |
1160 | expect(url).toMatchInlineSnapshot(
1161 | `"https://sentry.example.com/organizations/my-org/explore/traces/?query=&field=span.op&statsPeriod=24h&table=span"`,
1162 | );
1163 | });
1164 | });
1165 | });
1166 | });
1167 |
```