This is page 7 of 15. Use http://codebase.md/getsentry/sentry-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .claude
│ ├── agents
│ │ └── claude-optimizer.md
│ ├── commands
│ │ ├── gh-pr.md
│ │ └── gh-review.md
│ └── settings.json
├── .craft.yml
├── .cursor
│ └── mcp.json
├── .env.example
├── .github
│ └── workflows
│ ├── deploy.yml
│ ├── eval.yml
│ ├── merge-jobs.yml
│ ├── release.yml
│ ├── smoke-tests.yml
│ └── test.yml
├── .gitignore
├── .mcp.json
├── .vscode
│ ├── extensions.json
│ ├── mcp.json
│ └── settings.json
├── AGENTS.md
├── bin
│ └── bump-version.sh
├── biome.json
├── CLAUDE.md
├── codecov.yml
├── core
├── docs
│ ├── adding-tools.mdc
│ ├── api-patterns.mdc
│ ├── architecture.mdc
│ ├── cloudflare
│ │ ├── architecture.md
│ │ ├── deployment.md
│ │ ├── oauth-architecture.md
│ │ └── overview.md
│ ├── coding-guidelines.mdc
│ ├── common-patterns.mdc
│ ├── cursor.mdc
│ ├── deployment.mdc
│ ├── error-handling.mdc
│ ├── github-actions.mdc
│ ├── llms
│ │ ├── document-scopes.mdc
│ │ ├── documentation-style-guide.mdc
│ │ └── README.md
│ ├── logging.mdc
│ ├── monitoring.mdc
│ ├── permissions-and-scopes.md
│ ├── pr-management.mdc
│ ├── quality-checks.mdc
│ ├── README.md
│ ├── search-events-api-patterns.md
│ ├── security.mdc
│ ├── specs
│ │ ├── README.md
│ │ ├── search-events.md
│ │ └── subpath-constraints.md
│ └── testing.mdc
├── LICENSE.md
├── Makefile
├── package.json
├── packages
│ ├── mcp-cloudflare
│ │ ├── .env.example
│ │ ├── components.json
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── favicon.ico
│ │ │ ├── flow-transparent.png
│ │ │ └── flow.jpg
│ │ ├── src
│ │ │ ├── client
│ │ │ │ ├── app.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── chat
│ │ │ │ │ │ ├── auth-form.tsx
│ │ │ │ │ │ ├── chat-input.tsx
│ │ │ │ │ │ ├── chat-message.tsx
│ │ │ │ │ │ ├── chat-messages.tsx
│ │ │ │ │ │ ├── chat-ui.tsx
│ │ │ │ │ │ ├── chat.tsx
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── tool-invocation.tsx
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── fragments
│ │ │ │ │ │ ├── remote-setup.tsx
│ │ │ │ │ │ ├── setup-guide.tsx
│ │ │ │ │ │ └── stdio-setup.tsx
│ │ │ │ │ └── ui
│ │ │ │ │ ├── accordion.tsx
│ │ │ │ │ ├── backdrop.tsx
│ │ │ │ │ ├── base.tsx
│ │ │ │ │ ├── button.tsx
│ │ │ │ │ ├── code-snippet.tsx
│ │ │ │ │ ├── header.tsx
│ │ │ │ │ ├── icon.tsx
│ │ │ │ │ ├── icons
│ │ │ │ │ │ └── sentry.tsx
│ │ │ │ │ ├── interactive-markdown.tsx
│ │ │ │ │ ├── json-schema-params.tsx
│ │ │ │ │ ├── markdown.tsx
│ │ │ │ │ ├── note.tsx
│ │ │ │ │ ├── prose.tsx
│ │ │ │ │ ├── section.tsx
│ │ │ │ │ ├── slash-command-actions.tsx
│ │ │ │ │ ├── slash-command-text.tsx
│ │ │ │ │ ├── sliding-panel.tsx
│ │ │ │ │ ├── template-vars.tsx
│ │ │ │ │ ├── tool-actions.tsx
│ │ │ │ │ └── typewriter.tsx
│ │ │ │ ├── contexts
│ │ │ │ │ └── auth-context.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ ├── use-mcp-metadata.ts
│ │ │ │ │ ├── use-persisted-chat.ts
│ │ │ │ │ ├── use-scroll-lock.ts
│ │ │ │ │ └── use-streaming-simulation.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── instrument.ts
│ │ │ │ ├── lib
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── main.tsx
│ │ │ │ ├── pages
│ │ │ │ │ └── home.tsx
│ │ │ │ ├── utils
│ │ │ │ │ ├── chat-error-handler.ts
│ │ │ │ │ └── index.ts
│ │ │ │ └── vite-env.d.ts
│ │ │ ├── constants.ts
│ │ │ ├── server
│ │ │ │ ├── app.test.ts
│ │ │ │ ├── app.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── approval-dialog.test.ts
│ │ │ │ │ ├── approval-dialog.ts
│ │ │ │ │ ├── constraint-utils.test.ts
│ │ │ │ │ ├── constraint-utils.ts
│ │ │ │ │ ├── html-utils.ts
│ │ │ │ │ ├── mcp-handler.test.ts
│ │ │ │ │ ├── mcp-handler.ts
│ │ │ │ │ └── slug-validation.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── oauth
│ │ │ │ │ ├── authorize.test.ts
│ │ │ │ │ ├── callback.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── helpers.test.ts
│ │ │ │ │ ├── helpers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── authorize.ts
│ │ │ │ │ │ ├── callback.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ └── state.ts
│ │ │ │ ├── routes
│ │ │ │ │ ├── chat-oauth.ts
│ │ │ │ │ ├── chat.ts
│ │ │ │ │ ├── mcp.ts
│ │ │ │ │ ├── metadata.ts
│ │ │ │ │ ├── search.test.ts
│ │ │ │ │ └── search.ts
│ │ │ │ ├── sentry.config.ts
│ │ │ │ ├── types
│ │ │ │ │ └── chat.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── utils
│ │ │ │ └── auth-errors.ts
│ │ │ └── test-setup.ts
│ │ ├── tsconfig.client.json
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ ├── tsconfig.server.json
│ │ ├── vite.config.ts
│ │ ├── vitest.config.ts
│ │ ├── worker-configuration.d.ts
│ │ ├── wrangler.canary.jsonc
│ │ └── wrangler.jsonc
│ ├── mcp-server
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── scripts
│ │ │ ├── generate-definitions.ts
│ │ │ └── generate-otel-namespaces.ts
│ │ ├── src
│ │ │ ├── api-client
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── client.ts
│ │ │ │ ├── errors.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── types.ts
│ │ │ ├── cli
│ │ │ │ ├── parse.test.ts
│ │ │ │ ├── parse.ts
│ │ │ │ ├── resolve.test.ts
│ │ │ │ ├── resolve.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── usage.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── index.ts
│ │ │ ├── internal
│ │ │ │ ├── agents
│ │ │ │ │ ├── callEmbeddedAgent.ts
│ │ │ │ │ ├── openai-provider.ts
│ │ │ │ │ └── tools
│ │ │ │ │ ├── data
│ │ │ │ │ │ ├── __namespaces.json
│ │ │ │ │ │ ├── android.json
│ │ │ │ │ │ ├── app.json
│ │ │ │ │ │ ├── artifact.json
│ │ │ │ │ │ ├── aspnetcore.json
│ │ │ │ │ │ ├── aws.json
│ │ │ │ │ │ ├── azure.json
│ │ │ │ │ │ ├── browser.json
│ │ │ │ │ │ ├── cassandra.json
│ │ │ │ │ │ ├── cicd.json
│ │ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ │ ├── client.json
│ │ │ │ │ │ ├── cloud.json
│ │ │ │ │ │ ├── cloudevents.json
│ │ │ │ │ │ ├── cloudfoundry.json
│ │ │ │ │ │ ├── code.json
│ │ │ │ │ │ ├── container.json
│ │ │ │ │ │ ├── cpu.json
│ │ │ │ │ │ ├── cpython.json
│ │ │ │ │ │ ├── database.json
│ │ │ │ │ │ ├── db.json
│ │ │ │ │ │ ├── deployment.json
│ │ │ │ │ │ ├── destination.json
│ │ │ │ │ │ ├── device.json
│ │ │ │ │ │ ├── disk.json
│ │ │ │ │ │ ├── dns.json
│ │ │ │ │ │ ├── dotnet.json
│ │ │ │ │ │ ├── elasticsearch.json
│ │ │ │ │ │ ├── enduser.json
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ ├── faas.json
│ │ │ │ │ │ ├── feature_flags.json
│ │ │ │ │ │ ├── file.json
│ │ │ │ │ │ ├── gcp.json
│ │ │ │ │ │ ├── gen_ai.json
│ │ │ │ │ │ ├── geo.json
│ │ │ │ │ │ ├── go.json
│ │ │ │ │ │ ├── graphql.json
│ │ │ │ │ │ ├── hardware.json
│ │ │ │ │ │ ├── heroku.json
│ │ │ │ │ │ ├── host.json
│ │ │ │ │ │ ├── http.json
│ │ │ │ │ │ ├── ios.json
│ │ │ │ │ │ ├── jvm.json
│ │ │ │ │ │ ├── k8s.json
│ │ │ │ │ │ ├── linux.json
│ │ │ │ │ │ ├── log.json
│ │ │ │ │ │ ├── mcp.json
│ │ │ │ │ │ ├── messaging.json
│ │ │ │ │ │ ├── network.json
│ │ │ │ │ │ ├── nodejs.json
│ │ │ │ │ │ ├── oci.json
│ │ │ │ │ │ ├── opentracing.json
│ │ │ │ │ │ ├── os.json
│ │ │ │ │ │ ├── otel.json
│ │ │ │ │ │ ├── peer.json
│ │ │ │ │ │ ├── process.json
│ │ │ │ │ │ ├── profile.json
│ │ │ │ │ │ ├── rpc.json
│ │ │ │ │ │ ├── server.json
│ │ │ │ │ │ ├── service.json
│ │ │ │ │ │ ├── session.json
│ │ │ │ │ │ ├── signalr.json
│ │ │ │ │ │ ├── source.json
│ │ │ │ │ │ ├── system.json
│ │ │ │ │ │ ├── telemetry.json
│ │ │ │ │ │ ├── test.json
│ │ │ │ │ │ ├── thread.json
│ │ │ │ │ │ ├── tls.json
│ │ │ │ │ │ ├── url.json
│ │ │ │ │ │ ├── user.json
│ │ │ │ │ │ ├── v8js.json
│ │ │ │ │ │ ├── vcs.json
│ │ │ │ │ │ ├── webengine.json
│ │ │ │ │ │ └── zos.json
│ │ │ │ │ ├── dataset-fields.test.ts
│ │ │ │ │ ├── dataset-fields.ts
│ │ │ │ │ ├── otel-semantics.test.ts
│ │ │ │ │ ├── otel-semantics.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ ├── whoami.test.ts
│ │ │ │ │ └── whoami.ts
│ │ │ │ ├── constraint-helpers.test.ts
│ │ │ │ ├── constraint-helpers.ts
│ │ │ │ ├── context-storage.ts
│ │ │ │ ├── error-handling.ts
│ │ │ │ ├── fetch-utils.test.ts
│ │ │ │ ├── fetch-utils.ts
│ │ │ │ ├── formatting.test.ts
│ │ │ │ ├── formatting.ts
│ │ │ │ ├── issue-helpers.test.ts
│ │ │ │ ├── issue-helpers.ts
│ │ │ │ ├── test-fixtures.ts
│ │ │ │ └── tool-helpers
│ │ │ │ ├── api.test.ts
│ │ │ │ ├── api.ts
│ │ │ │ ├── define.ts
│ │ │ │ ├── enhance-error.ts
│ │ │ │ ├── formatting.ts
│ │ │ │ ├── issue.ts
│ │ │ │ ├── seer.test.ts
│ │ │ │ ├── seer.ts
│ │ │ │ ├── validate-region-url.test.ts
│ │ │ │ └── validate-region-url.ts
│ │ │ ├── permissions.parseScopes.test.ts
│ │ │ ├── permissions.ts
│ │ │ ├── schema.ts
│ │ │ ├── server-context.test.ts
│ │ │ ├── server.ts
│ │ │ ├── telem
│ │ │ │ ├── index.ts
│ │ │ │ ├── logging.ts
│ │ │ │ ├── sentry.test.ts
│ │ │ │ └── sentry.ts
│ │ │ ├── test-setup.ts
│ │ │ ├── test-utils
│ │ │ │ └── context.ts
│ │ │ ├── toolDefinitions.ts
│ │ │ ├── tools
│ │ │ │ ├── analyze-issue-with-seer.test.ts
│ │ │ │ ├── analyze-issue-with-seer.ts
│ │ │ │ ├── create-dsn.test.ts
│ │ │ │ ├── create-dsn.ts
│ │ │ │ ├── create-project.test.ts
│ │ │ │ ├── create-project.ts
│ │ │ │ ├── create-team.test.ts
│ │ │ │ ├── create-team.ts
│ │ │ │ ├── find-dsns.test.ts
│ │ │ │ ├── find-dsns.ts
│ │ │ │ ├── find-organizations.test.ts
│ │ │ │ ├── find-organizations.ts
│ │ │ │ ├── find-projects.test.ts
│ │ │ │ ├── find-projects.ts
│ │ │ │ ├── find-releases.test.ts
│ │ │ │ ├── find-releases.ts
│ │ │ │ ├── find-teams.test.ts
│ │ │ │ ├── find-teams.ts
│ │ │ │ ├── get-doc.test.ts
│ │ │ │ ├── get-doc.ts
│ │ │ │ ├── get-event-attachment.test.ts
│ │ │ │ ├── get-event-attachment.ts
│ │ │ │ ├── get-issue-details.test.ts
│ │ │ │ ├── get-issue-details.ts
│ │ │ │ ├── get-trace-details.test.ts
│ │ │ │ ├── get-trace-details.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── search-docs.test.ts
│ │ │ │ ├── search-docs.ts
│ │ │ │ ├── search-events
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── utils.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── search-events.test.ts
│ │ │ │ ├── search-issues
│ │ │ │ │ ├── agent.ts
│ │ │ │ │ ├── CLAUDE.md
│ │ │ │ │ ├── config.ts
│ │ │ │ │ ├── formatters.ts
│ │ │ │ │ ├── handler.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── README.md
│ │ │ │ ├── tools.test.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── update-issue.test.ts
│ │ │ │ ├── update-issue.ts
│ │ │ │ ├── update-project.test.ts
│ │ │ │ ├── update-project.ts
│ │ │ │ ├── whoami.test.ts
│ │ │ │ └── whoami.ts
│ │ │ ├── transports
│ │ │ │ └── stdio.ts
│ │ │ ├── types.ts
│ │ │ ├── utils
│ │ │ │ ├── slug-validation.test.ts
│ │ │ │ ├── slug-validation.ts
│ │ │ │ ├── url-utils.test.ts
│ │ │ │ └── url-utils.ts
│ │ │ └── version.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ ├── mcp-server-evals
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── bin
│ │ │ │ └── start-mock-stdio.ts
│ │ │ ├── evals
│ │ │ │ ├── autofix.eval.ts
│ │ │ │ ├── create-dsn.eval.ts
│ │ │ │ ├── create-project.eval.ts
│ │ │ │ ├── create-team.eval.ts
│ │ │ │ ├── get-issue.eval.ts
│ │ │ │ ├── get-trace-details.eval.ts
│ │ │ │ ├── list-dsns.eval.ts
│ │ │ │ ├── list-issues.eval.ts
│ │ │ │ ├── list-organizations.eval.ts
│ │ │ │ ├── list-projects.eval.ts
│ │ │ │ ├── list-releases.eval.ts
│ │ │ │ ├── list-tags.eval.ts
│ │ │ │ ├── list-teams.eval.ts
│ │ │ │ ├── search-docs.eval.ts
│ │ │ │ ├── search-events-agent.eval.ts
│ │ │ │ ├── search-events.eval.ts
│ │ │ │ ├── search-issues-agent.eval.ts
│ │ │ │ ├── search-issues.eval.ts
│ │ │ │ ├── update-issue.eval.ts
│ │ │ │ ├── update-project.eval.ts
│ │ │ │ └── utils
│ │ │ │ ├── fixtures.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── runner.ts
│ │ │ │ ├── structuredOutputScorer.ts
│ │ │ │ └── toolPredictionScorer.ts
│ │ │ └── setup-env.ts
│ │ ├── tsconfig.json
│ │ └── vitest.config.ts
│ ├── mcp-server-mocks
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── fixtures
│ │ │ │ ├── autofix-state.json
│ │ │ │ ├── event-attachments.json
│ │ │ │ ├── event.json
│ │ │ │ ├── issue.json
│ │ │ │ ├── performance-event.json
│ │ │ │ ├── project.json
│ │ │ │ ├── tags.json
│ │ │ │ ├── team.json
│ │ │ │ ├── trace-event.json
│ │ │ │ ├── trace-items-attributes-logs-number.json
│ │ │ │ ├── trace-items-attributes-logs-string.json
│ │ │ │ ├── trace-items-attributes-spans-number.json
│ │ │ │ ├── trace-items-attributes-spans-string.json
│ │ │ │ ├── trace-items-attributes.json
│ │ │ │ ├── trace-meta-with-nulls.json
│ │ │ │ ├── trace-meta.json
│ │ │ │ ├── trace-mixed.json
│ │ │ │ └── trace.json
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── mcp-server-tsconfig
│ │ ├── package.json
│ │ ├── tsconfig.base.json
│ │ └── tsconfig.vite.json
│ ├── mcp-test-client
│ │ ├── .env.test
│ │ ├── .gitignore
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── agent.ts
│ │ │ ├── auth
│ │ │ │ ├── config.ts
│ │ │ │ └── oauth.ts
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.test.ts
│ │ │ ├── logger.ts
│ │ │ ├── mcp-test-client-remote.ts
│ │ │ ├── mcp-test-client.ts
│ │ │ ├── types.ts
│ │ │ └── version.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ └── smoke-tests
│ ├── package.json
│ ├── src
│ │ └── smoke.test.ts
│ └── vitest.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── scripts
│ └── check-doc-links.mjs
├── turbo.json
└── vitest.workspace.ts
```
# Files
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/oauth/callback.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from "vitest";
2 | import { Hono } from "hono";
3 | import oauthRoute from "./index";
4 | import { signState, type OAuthState } from "./state";
5 | import type { Env } from "../types";
6 |
7 | // Mock the OAuth provider
8 | const mockOAuthProvider = {
9 | parseAuthRequest: vi.fn(),
10 | lookupClient: vi.fn(),
11 | completeAuthorization: vi.fn(),
12 | };
13 |
14 | function createTestApp(env: Partial<Env> = {}) {
15 | const app = new Hono<{ Bindings: Env }>();
16 | app.route("/oauth", oauthRoute);
17 | return app;
18 | }
19 |
20 | describe("oauth callback routes", () => {
21 | let app: ReturnType<typeof createTestApp>;
22 | let testEnv: Partial<Env>;
23 |
24 | beforeEach(() => {
25 | vi.clearAllMocks();
26 | testEnv = {
27 | OAUTH_PROVIDER: mockOAuthProvider as unknown as Env["OAUTH_PROVIDER"],
28 | COOKIE_SECRET: "test-secret-key",
29 | SENTRY_CLIENT_ID: "test-client-id",
30 | SENTRY_CLIENT_SECRET: "test-client-secret",
31 | SENTRY_HOST: "sentry.io",
32 | };
33 | app = createTestApp(testEnv);
34 | });
35 |
36 | describe("GET /oauth/callback", () => {
37 | it("should reject callback with invalid state param", async () => {
38 | const request = new Request(
39 | `http://localhost/oauth/callback?code=test-code&state=%%%INVALID%%%`,
40 | { method: "GET" },
41 | );
42 | const response = await app.fetch(request, testEnv as Env);
43 | expect(response.status).toBe(400);
44 | const text = await response.text();
45 | expect(text).toBe("Invalid state");
46 | });
47 |
48 | it("should reject callback without approved client cookie", async () => {
49 | // Build signed state matching what /oauth/authorize issues
50 | const now = Date.now();
51 | const payload: OAuthState = {
52 | req: {
53 | clientId: "test-client",
54 | redirectUri: "https://example.com/callback",
55 | scope: ["read"],
56 | },
57 | iat: now,
58 | exp: now + 10 * 60 * 1000,
59 | } as unknown as OAuthState;
60 | const signedState = await signState(payload, testEnv.COOKIE_SECRET!);
61 |
62 | const request = new Request(
63 | `http://localhost/oauth/callback?code=test-code&state=${signedState}`,
64 | {
65 | method: "GET",
66 | headers: {},
67 | },
68 | );
69 | const response = await app.fetch(request, testEnv as Env);
70 | expect(response.status).toBe(403);
71 | const text = await response.text();
72 | expect(text).toBe("Authorization failed: Client not approved");
73 | });
74 |
75 | it("should reject callback with invalid client approval cookie", async () => {
76 | const now = Date.now();
77 | const payload: OAuthState = {
78 | req: {
79 | clientId: "test-client",
80 | redirectUri: "https://example.com/callback",
81 | scope: ["read"],
82 | },
83 | iat: now,
84 | exp: now + 10 * 60 * 1000,
85 | } as unknown as OAuthState;
86 | const signedState = await signState(payload, testEnv.COOKIE_SECRET!);
87 |
88 | const request = new Request(
89 | `http://localhost/oauth/callback?code=test-code&state=${signedState}`,
90 | {
91 | method: "GET",
92 | headers: {
93 | Cookie: "mcp-approved-clients=invalid-cookie-value",
94 | },
95 | },
96 | );
97 | const response = await app.fetch(request, testEnv as Env);
98 | expect(response.status).toBe(403);
99 | const text = await response.text();
100 | expect(text).toBe("Authorization failed: Client not approved");
101 | });
102 |
103 | it("should reject callback with cookie for different client", async () => {
104 | // Ensure authorize POST accepts the redirectUri
105 | mockOAuthProvider.lookupClient.mockResolvedValueOnce({
106 | clientId: "different-client",
107 | clientName: "Other Client",
108 | redirectUris: ["https://example.com/callback"],
109 | tokenEndpointAuthMethod: "client_secret_basic",
110 | });
111 |
112 | const approvalFormData = new FormData();
113 | approvalFormData.append(
114 | "state",
115 | btoa(
116 | JSON.stringify({
117 | oauthReqInfo: {
118 | clientId: "different-client",
119 | redirectUri: "https://example.com/callback",
120 | scope: ["read"],
121 | },
122 | }),
123 | ),
124 | );
125 | const approvalRequest = new Request("http://localhost/oauth/authorize", {
126 | method: "POST",
127 | body: approvalFormData,
128 | });
129 | const approvalResponse = await app.fetch(approvalRequest, testEnv as Env);
130 | expect(approvalResponse.status).toBe(302);
131 | const setCookie = approvalResponse.headers.get("Set-Cookie");
132 | expect(setCookie).toBeTruthy();
133 |
134 | // Build a signed state for a different client than the approved one
135 | const now = Date.now();
136 | const payload: OAuthState = {
137 | req: {
138 | clientId: "test-client",
139 | redirectUri: "https://example.com/callback",
140 | scope: ["read"],
141 | },
142 | iat: now,
143 | exp: now + 10 * 60 * 1000,
144 | } as unknown as OAuthState;
145 | const signedState = await signState(payload, testEnv.COOKIE_SECRET!);
146 |
147 | const request = new Request(
148 | `http://localhost/oauth/callback?code=test-code&state=${signedState}`,
149 | {
150 | method: "GET",
151 | headers: {
152 | Cookie: setCookie!.split(";")[0],
153 | },
154 | },
155 | );
156 | const response = await app.fetch(request, testEnv as Env);
157 | expect(response.status).toBe(403);
158 | const text = await response.text();
159 | expect(text).toBe("Authorization failed: Client not approved");
160 | });
161 |
162 | it("should reject callback when state signature is tampered", async () => {
163 | // Ensure client redirectUri is registered
164 | mockOAuthProvider.lookupClient.mockResolvedValueOnce({
165 | clientId: "test-client",
166 | clientName: "Test Client",
167 | redirectUris: ["https://example.com/callback"],
168 | tokenEndpointAuthMethod: "client_secret_basic",
169 | });
170 |
171 | // Prepare approval POST to generate a signed state
172 | const oauthReqInfo = {
173 | clientId: "test-client",
174 | redirectUri: "https://example.com/callback",
175 | scope: ["read"],
176 | };
177 | const approvalFormData = new FormData();
178 | approvalFormData.append(
179 | "state",
180 | btoa(
181 | JSON.stringify({
182 | oauthReqInfo,
183 | }),
184 | ),
185 | );
186 | const approvalRequest = new Request("http://localhost/oauth/authorize", {
187 | method: "POST",
188 | body: approvalFormData,
189 | });
190 | const approvalResponse = await app.fetch(approvalRequest, testEnv as Env);
191 | expect(approvalResponse.status).toBe(302);
192 | const setCookie = approvalResponse.headers.get("Set-Cookie");
193 | const location = approvalResponse.headers.get("location");
194 | expect(location).toBeTruthy();
195 | const redirectUrl = new URL(location!);
196 | const signedState = redirectUrl.searchParams.get("state")!;
197 |
198 | // Tamper with the signature portion (hex) without breaking payload parsing
199 | const [sig, b64] = signedState.split(".");
200 | const badSig = (sig[0] === "a" ? "b" : "a") + sig.slice(1);
201 | const tamperedState = `${badSig}.${b64}`;
202 |
203 | // Call callback with tampered state and valid approval cookie
204 | const callbackRequest = new Request(
205 | `http://localhost/oauth/callback?code=test-code&state=${tamperedState}`,
206 | {
207 | method: "GET",
208 | headers: {
209 | Cookie: setCookie!.split(";")[0],
210 | },
211 | },
212 | );
213 | const response = await app.fetch(callbackRequest, testEnv as Env);
214 | expect(response.status).toBe(400);
215 | const text = await response.text();
216 | expect(text).toBe("Invalid state");
217 | });
218 | });
219 | });
220 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/constraint-helpers.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import { z } from "zod";
3 | import {
4 | getConstraintKeysToFilter,
5 | getConstraintParametersToInject,
6 | } from "./constraint-helpers";
7 |
8 | /**
9 | * Test suite for constraint helper functions.
10 | *
11 | * These tests verify the logic for filtering schemas and injecting parameters
12 | * when handling MCP constraints with parameter aliases (projectSlug → projectSlugOrId).
13 | */
14 |
15 | describe("Constraint Helpers", () => {
16 | // Mock tool schemas for testing
17 | const schemaWithProjectSlugOrId = {
18 | organizationSlug: z.string(),
19 | projectSlugOrId: z.string().optional(),
20 | query: z.string().optional(),
21 | };
22 |
23 | const schemaWithProjectSlug = {
24 | organizationSlug: z.string(),
25 | projectSlug: z.string().optional(),
26 | query: z.string().optional(),
27 | };
28 |
29 | describe("getConstraintKeysToFilter", () => {
30 | it("filters direct constraint matches", () => {
31 | const constraints = {
32 | organizationSlug: "my-org",
33 | projectSlug: null,
34 | regionUrl: null,
35 | };
36 |
37 | const keys = getConstraintKeysToFilter(
38 | constraints,
39 | schemaWithProjectSlug,
40 | );
41 |
42 | expect(keys).toEqual(["organizationSlug"]);
43 | });
44 |
45 | it("applies projectSlug → projectSlugOrId alias when projectSlug is constrained", () => {
46 | const constraints = {
47 | organizationSlug: "my-org",
48 | projectSlug: "my-project",
49 | regionUrl: null,
50 | };
51 |
52 | const keys = getConstraintKeysToFilter(
53 | constraints,
54 | schemaWithProjectSlugOrId,
55 | );
56 |
57 | // Should filter both organizationSlug (direct match) and projectSlugOrId (alias)
58 | expect(keys).toEqual(["organizationSlug", "projectSlugOrId"]);
59 | });
60 |
61 | it("does NOT apply alias when projectSlugOrId is explicitly constrained", () => {
62 | const constraints = {
63 | organizationSlug: "my-org",
64 | projectSlug: "project-a",
65 | projectSlugOrId: "project-b", // Explicit constraint takes precedence
66 | regionUrl: null,
67 | };
68 |
69 | const keys = getConstraintKeysToFilter(
70 | constraints,
71 | schemaWithProjectSlugOrId,
72 | );
73 |
74 | // Should filter organizationSlug and projectSlugOrId (explicit), but NOT the alias
75 | expect(keys).toEqual(["organizationSlug", "projectSlugOrId"]);
76 | });
77 |
78 | it("handles null/falsy constraint values", () => {
79 | const constraints = {
80 | organizationSlug: "my-org",
81 | projectSlug: null, // Falsy - should not trigger alias
82 | regionUrl: null,
83 | };
84 |
85 | const keys = getConstraintKeysToFilter(
86 | constraints,
87 | schemaWithProjectSlugOrId,
88 | );
89 |
90 | expect(keys).toEqual(["organizationSlug"]);
91 | });
92 |
93 | it("handles empty string as falsy", () => {
94 | const constraints = {
95 | organizationSlug: "my-org",
96 | projectSlug: "", // Empty string is falsy
97 | regionUrl: null,
98 | };
99 |
100 | const keys = getConstraintKeysToFilter(
101 | constraints,
102 | schemaWithProjectSlugOrId,
103 | );
104 |
105 | // Empty string is falsy, so no alias should be applied
106 | expect(keys).toEqual(["organizationSlug"]);
107 | });
108 |
109 | it("only filters parameters that exist in the tool schema", () => {
110 | const constraints = {
111 | organizationSlug: "my-org",
112 | projectSlug: "my-project",
113 | regionUrl: "https://us.sentry.io",
114 | };
115 |
116 | const schemaWithoutRegion = {
117 | organizationSlug: z.string(),
118 | query: z.string(),
119 | };
120 |
121 | const keys = getConstraintKeysToFilter(constraints, schemaWithoutRegion);
122 |
123 | // regionUrl not in schema, so it shouldn't be filtered
124 | expect(keys).toEqual(["organizationSlug"]);
125 | });
126 | });
127 |
128 | describe("getConstraintParametersToInject", () => {
129 | it("injects direct constraint matches", () => {
130 | const constraints = {
131 | organizationSlug: "my-org",
132 | projectSlug: null,
133 | regionUrl: null,
134 | };
135 |
136 | const params = getConstraintParametersToInject(
137 | constraints,
138 | schemaWithProjectSlug,
139 | );
140 |
141 | expect(params).toEqual({
142 | organizationSlug: "my-org",
143 | });
144 | });
145 |
146 | it("injects projectSlug as projectSlugOrId when alias applies", () => {
147 | const constraints = {
148 | organizationSlug: "my-org",
149 | projectSlug: "my-project",
150 | regionUrl: null,
151 | };
152 |
153 | const params = getConstraintParametersToInject(
154 | constraints,
155 | schemaWithProjectSlugOrId,
156 | );
157 |
158 | expect(params).toEqual({
159 | organizationSlug: "my-org",
160 | projectSlugOrId: "my-project", // Injected via alias
161 | });
162 | });
163 |
164 | it("respects explicit projectSlugOrId constraint over alias", () => {
165 | const constraints = {
166 | organizationSlug: "my-org",
167 | projectSlug: "project-a",
168 | projectSlugOrId: "project-b", // Explicit constraint
169 | regionUrl: null,
170 | };
171 |
172 | const params = getConstraintParametersToInject(
173 | constraints,
174 | schemaWithProjectSlugOrId,
175 | );
176 |
177 | expect(params).toEqual({
178 | organizationSlug: "my-org",
179 | projectSlugOrId: "project-b", // Explicit wins, not alias
180 | });
181 | });
182 |
183 | it("handles null/falsy constraint values", () => {
184 | const constraints = {
185 | organizationSlug: "my-org",
186 | projectSlug: null,
187 | regionUrl: null,
188 | };
189 |
190 | const params = getConstraintParametersToInject(
191 | constraints,
192 | schemaWithProjectSlugOrId,
193 | );
194 |
195 | expect(params).toEqual({
196 | organizationSlug: "my-org",
197 | });
198 | });
199 |
200 | it("only injects parameters that exist in the tool schema", () => {
201 | const constraints = {
202 | organizationSlug: "my-org",
203 | projectSlug: "my-project",
204 | regionUrl: "https://us.sentry.io",
205 | };
206 |
207 | const schemaWithoutRegion = {
208 | organizationSlug: z.string(),
209 | query: z.string(),
210 | };
211 |
212 | const params = getConstraintParametersToInject(
213 | constraints,
214 | schemaWithoutRegion,
215 | );
216 |
217 | // regionUrl not in schema, so it shouldn't be injected
218 | expect(params).toEqual({
219 | organizationSlug: "my-org",
220 | });
221 | });
222 | });
223 |
224 | describe("Consistency between filtering and injection", () => {
225 | it("ensures filtered keys match injected keys", () => {
226 | const constraints = {
227 | organizationSlug: "my-org",
228 | projectSlug: "my-project",
229 | regionUrl: null,
230 | };
231 |
232 | const keysToFilter = getConstraintKeysToFilter(
233 | constraints,
234 | schemaWithProjectSlugOrId,
235 | );
236 | const paramsToInject = getConstraintParametersToInject(
237 | constraints,
238 | schemaWithProjectSlugOrId,
239 | );
240 |
241 | // Every key that's filtered should have a corresponding injected parameter
242 | const injectedKeys = Object.keys(paramsToInject);
243 | expect(keysToFilter.sort()).toEqual(injectedKeys.sort());
244 | });
245 |
246 | it("handles explicit constraint precedence consistently", () => {
247 | const constraints = {
248 | organizationSlug: "my-org",
249 | projectSlug: "project-a",
250 | projectSlugOrId: "project-b",
251 | regionUrl: null,
252 | };
253 |
254 | const keysToFilter = getConstraintKeysToFilter(
255 | constraints,
256 | schemaWithProjectSlugOrId,
257 | );
258 | const paramsToInject = getConstraintParametersToInject(
259 | constraints,
260 | schemaWithProjectSlugOrId,
261 | );
262 |
263 | // Both should handle the explicit constraint the same way
264 | expect(keysToFilter).toContain("projectSlugOrId");
265 | expect(paramsToInject.projectSlugOrId).toBe("project-b");
266 | expect(paramsToInject.projectSlug).toBeUndefined();
267 | });
268 | });
269 | });
270 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/otel-semantics.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import type { SentryApiService } from "../../../api-client";
3 | import { agentTool } from "./utils";
4 |
5 | // Import all JSON files directly
6 | import android from "./data/android.json";
7 | import app from "./data/app.json";
8 | import artifact from "./data/artifact.json";
9 | import aspnetcore from "./data/aspnetcore.json";
10 | import aws from "./data/aws.json";
11 | import azure from "./data/azure.json";
12 | import browser from "./data/browser.json";
13 | import cassandra from "./data/cassandra.json";
14 | import cicd from "./data/cicd.json";
15 | import client from "./data/client.json";
16 | import cloud from "./data/cloud.json";
17 | import cloudevents from "./data/cloudevents.json";
18 | import cloudfoundry from "./data/cloudfoundry.json";
19 | import code from "./data/code.json";
20 | import container from "./data/container.json";
21 | import cpu from "./data/cpu.json";
22 | import cpython from "./data/cpython.json";
23 | import database from "./data/database.json";
24 | import db from "./data/db.json";
25 | import deployment from "./data/deployment.json";
26 | import destination from "./data/destination.json";
27 | import device from "./data/device.json";
28 | import disk from "./data/disk.json";
29 | import dns from "./data/dns.json";
30 | import dotnet from "./data/dotnet.json";
31 | import elasticsearch from "./data/elasticsearch.json";
32 | import enduser from "./data/enduser.json";
33 | import error from "./data/error.json";
34 | import faas from "./data/faas.json";
35 | import feature_flags from "./data/feature_flags.json";
36 | import file from "./data/file.json";
37 | import gcp from "./data/gcp.json";
38 | import gen_ai from "./data/gen_ai.json";
39 | import geo from "./data/geo.json";
40 | import go from "./data/go.json";
41 | import graphql from "./data/graphql.json";
42 | import hardware from "./data/hardware.json";
43 | import heroku from "./data/heroku.json";
44 | import host from "./data/host.json";
45 | import http from "./data/http.json";
46 | import ios from "./data/ios.json";
47 | import jvm from "./data/jvm.json";
48 | import k8s from "./data/k8s.json";
49 | import linux from "./data/linux.json";
50 | import log from "./data/log.json";
51 | import mcp from "./data/mcp.json";
52 | import messaging from "./data/messaging.json";
53 | import network from "./data/network.json";
54 | import nodejs from "./data/nodejs.json";
55 | import oci from "./data/oci.json";
56 | import opentracing from "./data/opentracing.json";
57 | import os from "./data/os.json";
58 | import otel from "./data/otel.json";
59 | import peer from "./data/peer.json";
60 | import process from "./data/process.json";
61 | import profile from "./data/profile.json";
62 | import rpc from "./data/rpc.json";
63 | import server from "./data/server.json";
64 | import service from "./data/service.json";
65 | import session from "./data/session.json";
66 | import signalr from "./data/signalr.json";
67 | import source from "./data/source.json";
68 | import system from "./data/system.json";
69 | import telemetry from "./data/telemetry.json";
70 | import test from "./data/test.json";
71 | import thread from "./data/thread.json";
72 | import tls from "./data/tls.json";
73 | import url from "./data/url.json";
74 | import user from "./data/user.json";
75 | import v8js from "./data/v8js.json";
76 | import vcs from "./data/vcs.json";
77 | import webengine from "./data/webengine.json";
78 | import zos from "./data/zos.json";
79 |
80 | // Create the namespaceData object
81 | const namespaceData: Record<string, NamespaceData> = {
82 | android,
83 | app,
84 | artifact,
85 | aspnetcore,
86 | aws,
87 | azure,
88 | browser,
89 | cassandra,
90 | cicd,
91 | client,
92 | cloud,
93 | cloudevents,
94 | cloudfoundry,
95 | code,
96 | container,
97 | cpu,
98 | cpython,
99 | database,
100 | db,
101 | deployment,
102 | destination,
103 | device,
104 | disk,
105 | dns,
106 | dotnet,
107 | elasticsearch,
108 | enduser,
109 | error,
110 | faas,
111 | feature_flags,
112 | file,
113 | gcp,
114 | gen_ai,
115 | geo,
116 | go,
117 | graphql,
118 | hardware,
119 | heroku,
120 | host,
121 | http,
122 | ios,
123 | jvm,
124 | k8s,
125 | linux,
126 | log,
127 | mcp,
128 | messaging,
129 | network,
130 | nodejs,
131 | oci,
132 | opentracing,
133 | os,
134 | otel,
135 | peer,
136 | process,
137 | profile,
138 | rpc,
139 | server,
140 | service,
141 | session,
142 | signalr,
143 | source,
144 | system,
145 | telemetry,
146 | test,
147 | thread,
148 | tls,
149 | url,
150 | user,
151 | v8js,
152 | vcs,
153 | webengine,
154 | zos,
155 | };
156 |
157 | // TypeScript types
158 | interface NamespaceData {
159 | namespace: string;
160 | description: string;
161 | attributes: Record<
162 | string,
163 | {
164 | description: string;
165 | type: string;
166 | examples?: Array<any>;
167 | note?: string;
168 | stability?: string;
169 | }
170 | >;
171 | custom?: boolean;
172 | }
173 |
174 | /**
175 | * Lookup OpenTelemetry semantic convention attributes for a given namespace
176 | */
177 | export async function lookupOtelSemantics(
178 | namespace: string,
179 | dataset: "errors" | "logs" | "spans",
180 | apiService: SentryApiService,
181 | organizationSlug: string,
182 | projectId?: string,
183 | ): Promise<string> {
184 | // Normalize namespace (replace - with _)
185 | const normalizedNamespace = namespace.replace(/-/g, "_");
186 |
187 | // Check if namespace exists
188 | const data = namespaceData[normalizedNamespace];
189 | if (!data) {
190 | // Try to find similar namespaces
191 | const allNamespaces = Object.keys(namespaceData);
192 | const suggestions = allNamespaces
193 | .filter((ns) => ns.includes(namespace) || namespace.includes(ns))
194 | .slice(0, 3);
195 |
196 | return suggestions.length > 0
197 | ? `Namespace '${namespace}' not found. Did you mean: ${suggestions.join(", ")}?`
198 | : `Namespace '${namespace}' not found. Use 'list' to see all available namespaces.`;
199 | }
200 |
201 | // Format the response
202 | let response = `# OpenTelemetry Semantic Conventions: ${data.namespace}\n\n`;
203 | response += `${data.description}\n\n`;
204 |
205 | if (data.custom) {
206 | response +=
207 | "**Note:** This is a custom namespace, not part of standard OpenTelemetry conventions.\n\n";
208 | }
209 |
210 | // Get all attributes
211 | const attributes = Object.entries(data.attributes);
212 |
213 | response += `## Attributes (${attributes.length} total)\n\n`;
214 |
215 | // Sort attributes by key
216 | const sortedAttributes = attributes.sort(([a], [b]) => a.localeCompare(b));
217 |
218 | for (const [key, attr] of sortedAttributes) {
219 | response += `### \`${key}\`\n`;
220 | response += `- **Type:** ${attr.type}\n`;
221 | response += `- **Description:** ${attr.description}\n`;
222 |
223 | if (attr.stability) {
224 | response += `- **Stability:** ${attr.stability}\n`;
225 | }
226 |
227 | if (attr.examples && attr.examples.length > 0) {
228 | response += `- **Examples:** ${attr.examples
229 | .map(
230 | (ex) =>
231 | `\`${typeof ex === "object" ? JSON.stringify(ex) : String(ex)}\``,
232 | )
233 | .join(", ")}\n`;
234 | }
235 |
236 | if (attr.note) {
237 | response += `- **Note:** ${attr.note}\n`;
238 | }
239 |
240 | response += "\n";
241 | }
242 |
243 | return response;
244 | }
245 |
246 | /**
247 | * Create the otel-semantics-lookup tool for AI agents
248 | */
249 | /**
250 | * Create a tool for looking up OpenTelemetry semantic convention attributes
251 | * The tool is pre-bound with the API service and organization configured for the appropriate region
252 | */
253 | export function createOtelLookupTool(options: {
254 | apiService: SentryApiService;
255 | organizationSlug: string;
256 | projectId?: string;
257 | }) {
258 | const { apiService, organizationSlug, projectId } = options;
259 | return agentTool({
260 | description:
261 | "Look up OpenTelemetry semantic convention attributes for a specific namespace. OpenTelemetry attributes are universal standards that work across all datasets.",
262 | parameters: z.object({
263 | namespace: z
264 | .string()
265 | .describe(
266 | "The OpenTelemetry namespace to look up (e.g., 'gen_ai', 'db', 'http', 'mcp')",
267 | ),
268 | dataset: z
269 | .enum(["spans", "errors", "logs"])
270 | .describe(
271 | "REQUIRED: Dataset to check attribute availability in. The agent MUST specify this based on their chosen dataset.",
272 | ),
273 | }),
274 | execute: async ({ namespace, dataset }) => {
275 | return await lookupOtelSemantics(
276 | namespace,
277 | dataset,
278 | apiService,
279 | organizationSlug,
280 | projectId,
281 | );
282 | },
283 | });
284 | }
285 |
```
--------------------------------------------------------------------------------
/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { openai } from "@ai-sdk/openai";
2 | import { generateObject, type LanguageModel } from "ai";
3 | import { z } from "zod";
4 | import { experimental_createMCPClient } from "ai";
5 | import { Experimental_StdioMCPTransport } from "ai/mcp-stdio";
6 |
7 | // Cache for available tools to avoid reconnecting for each test
8 | let cachedTools: string[] | null = null;
9 |
10 | /**
11 | * Get available tools from the MCP server by connecting to it directly.
12 | * This ensures the tool list stays in sync with what's actually registered.
13 | */
14 | async function getAvailableTools(): Promise<string[]> {
15 | if (cachedTools) {
16 | return cachedTools;
17 | }
18 |
19 | // Use pnpm exec to run the binary from the workspace
20 | const transport = new Experimental_StdioMCPTransport({
21 | command: "pnpm",
22 | args: [
23 | "exec",
24 | "sentry-mcp",
25 | "--access-token=mocked-access-token",
26 | "--all-scopes",
27 | ],
28 | env: {
29 | ...process.env,
30 | SENTRY_ACCESS_TOKEN: "mocked-access-token",
31 | SENTRY_HOST: "sentry.io",
32 | },
33 | });
34 |
35 | const client = await experimental_createMCPClient({
36 | transport,
37 | });
38 |
39 | // Discover available tools
40 | const toolsMap = await client.tools();
41 |
42 | // Convert tools to the format expected by the scorer
43 | cachedTools = Object.entries(toolsMap).map(([name, tool]) => {
44 | // Extract the first line of description for a concise summary
45 | const shortDescription = (tool as any).description?.split("\n")[0] || "";
46 | return `${name} - ${shortDescription}`;
47 | });
48 |
49 | // Clean up
50 | await client.close();
51 |
52 | return cachedTools;
53 | }
54 |
55 | export interface ExpectedToolCall {
56 | name: string;
57 | arguments: Record<string, any>;
58 | }
59 |
60 | interface ToolPredictionScorerOptions {
61 | input: string;
62 | output: string;
63 | expectedTools?: ExpectedToolCall[];
64 | result?: any;
65 | }
66 |
67 | const defaultModel = openai("gpt-4o");
68 |
69 | const predictionSchema = z.object({
70 | score: z.number().min(0).max(1).describe("Score from 0 to 1"),
71 | rationale: z.string().describe("Explanation of the score"),
72 | predictedTools: z
73 | .array(
74 | z.object({
75 | name: z.string(),
76 | arguments: z.record(z.any()).optional().default({}),
77 | }),
78 | )
79 | .describe("What tools the AI would likely call"),
80 | });
81 |
82 | function generateSystemPrompt(
83 | availableTools: string[],
84 | task: string,
85 | expectedDescription: string,
86 | ): string {
87 | return `You are evaluating whether an AI assistant with access to Sentry MCP tools would make the correct tool calls for a given task.
88 |
89 | [AVAILABLE TOOLS]
90 | ${availableTools.join("\n")}
91 |
92 | [TASK]
93 | ${task}
94 |
95 | [EXPECTED TOOL CALLS]
96 | ${expectedDescription}
97 |
98 | Based on the task and available tools, predict what tools the AI would call to complete this task.
99 |
100 | IMPORTANT: Look at what information is already provided in the task:
101 | - When only an organization name is given (e.g., "in sentry-mcp-evals"), discovery calls ARE typically needed
102 | - When organization/project are given in "org/project" format, the AI may skip discovery if confident
103 | - The expected tool calls show what is ACTUALLY expected for this specific case - follow them exactly
104 | - Discovery calls (find_organizations, find_projects) are commonly used to get regionUrl and verify access
105 | - Match the expected tool sequence exactly - if expected includes discovery, predict discovery
106 |
107 | Consider:
108 | 1. Match the expected tool sequence exactly - the expected tools show realistic AI behavior
109 | 2. When a value like "sentry-mcp-evals" appears alone, it's typically an organizationSlug, not a projectSlug
110 | 3. Arguments should match expected values (organizationSlug, projectSlug, name, etc.)
111 | 4. For natural language queries in search_events, exact phrasing doesn't need to match
112 | 5. Extra parameters like regionUrl are acceptable
113 | 6. The AI commonly does discovery calls even when slugs appear to be provided, to get region info
114 |
115 | Score as follows:
116 | - 1.0: All expected tools would be called with correct arguments in the right order
117 | - 0.8: All expected tools would be called, minor differences (extra params, slight variations)
118 | - 0.6: Most expected tools would be called but missing some or wrong order
119 | - 0.3: Some expected tools would be called but significant issues
120 | - 0.0: Wrong tools or critical tools missing
121 |
122 | CRITICAL: The expected tools represent the actual realistic behavior for this specific case. Follow the expected sequence exactly:
123 | - If expected tools include discovery calls, predict discovery calls
124 | - If expected tools do NOT include discovery calls, do NOT predict them
125 | - The test author has determined what's appropriate for each specific scenario`;
126 | }
127 |
128 | /**
129 | * A scorer that uses AI to predict what tools would be called without executing them.
130 | * This is much faster than actually running the tools and checking what was called.
131 | *
132 | * @param model - Optional language model to use for predictions (defaults to gpt-4o)
133 | * @returns A scorer function that compares predicted vs expected tool calls
134 | *
135 | * @example
136 | * ```typescript
137 | * import { ToolPredictionScorer } from './utils/toolPredictionScorer';
138 | * import { NoOpTaskRunner } from './utils/runner';
139 | * import { describeEval } from 'vitest-evals';
140 | *
141 | * describeEval("Sentry issue search", {
142 | * data: async () => [
143 | * {
144 | * input: "Find the newest issues in my-org",
145 | * expectedTools: [
146 | * { name: "find_organizations", arguments: {} },
147 | * { name: "find_issues", arguments: { organizationSlug: "my-org", sortBy: "first_seen" } }
148 | * ]
149 | * }
150 | * ],
151 | * task: NoOpTaskRunner(), // Don't execute tools, just predict them
152 | * scorers: [ToolPredictionScorer()],
153 | * threshold: 0.8
154 | * });
155 | * ```
156 | *
157 | * The scorer works by:
158 | * 1. Connecting to the MCP server to get available tools and their descriptions
159 | * 2. Using AI to predict what tools would be called for the given task
160 | * 3. Comparing predictions against the expectedTools array
161 | * 4. Returning a score from 0.0 to 1.0 based on accuracy
162 | *
163 | * Scoring criteria:
164 | * - 1.0: All expected tools predicted with correct arguments in right order
165 | * - 0.8: All expected tools predicted, minor differences (extra params, slight variations)
166 | * - 0.6: Most expected tools predicted but missing some or wrong order
167 | * - 0.3: Some expected tools predicted but significant issues
168 | * - 0.0: Wrong tools or critical tools missing
169 | *
170 | * If `expectedTools` is not provided in test data, the scorer is automatically skipped
171 | * and returns `{ score: null }` to allow other scorers to run without interference.
172 | */
173 | export function ToolPredictionScorer(model: LanguageModel = defaultModel) {
174 | return async function ToolPredictionScorer(
175 | opts: ToolPredictionScorerOptions,
176 | ) {
177 | // If expectedTools is not defined, skip this scorer
178 | if (!opts.expectedTools) {
179 | return {
180 | score: null,
181 | metadata: {
182 | rationale: "Skipped: No expectedTools defined for this test case",
183 | },
184 | };
185 | }
186 |
187 | const expectedTools = opts.expectedTools;
188 |
189 | // Get available tools from the MCP server
190 | const AVAILABLE_TOOLS = await getAvailableTools();
191 |
192 | // Generate a description of the expected tools for the prompt
193 | const expectedDescription = expectedTools
194 | .map(
195 | (tool) =>
196 | `- ${tool.name} with arguments: ${JSON.stringify(tool.arguments)}`,
197 | )
198 | .join("\n");
199 |
200 | const { object } = await generateObject({
201 | model,
202 | prompt: generateSystemPrompt(
203 | AVAILABLE_TOOLS,
204 | opts.input,
205 | expectedDescription,
206 | ),
207 | schema: predictionSchema,
208 | experimental_telemetry: {
209 | isEnabled: true,
210 | functionId: "tool_prediction_scorer",
211 | },
212 | });
213 |
214 | return {
215 | score: object.score,
216 | metadata: {
217 | rationale: object.rationale,
218 | predictedTools: object.predictedTools,
219 | expectedTools: expectedTools,
220 | },
221 | };
222 | };
223 | }
224 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/vcs.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "namespace": "vcs",
3 | "description": "This group defines the attributes for [Version Control Systems (VCS)](https://wikipedia.org/wiki/Version_control).\n",
4 | "attributes": {
5 | "vcs.repository.url.full": {
6 | "description": "The [canonical URL](https://support.google.com/webmasters/answer/10347851?hl=en#:~:text=A%20canonical%20URL%20is%20the,Google%20chooses%20one%20as%20canonical.) of the repository providing the complete HTTP(S) address in order to locate and identify the repository through a browser.\n",
7 | "type": "string",
8 | "note": "In Git Version Control Systems, the canonical URL SHOULD NOT include\nthe `.git` extension.\n",
9 | "stability": "development",
10 | "examples": [
11 | "https://github.com/opentelemetry/open-telemetry-collector-contrib",
12 | "https://gitlab.com/my-org/my-project/my-projects-project/repo"
13 | ]
14 | },
15 | "vcs.repository.name": {
16 | "description": "The human readable name of the repository. It SHOULD NOT include any additional identifier like Group/SubGroup in GitLab or organization in GitHub.\n",
17 | "type": "string",
18 | "note": "Due to it only being the name, it can clash with forks of the same\nrepository if collecting telemetry across multiple orgs or groups in\nthe same backends.\n",
19 | "stability": "development",
20 | "examples": ["semantic-conventions", "my-cool-repo"]
21 | },
22 | "vcs.ref.base.name": {
23 | "description": "The name of the [reference](https://git-scm.com/docs/gitglossary#def_ref) such as **branch** or **tag** in the repository.\n",
24 | "type": "string",
25 | "note": "`base` refers to the starting point of a change. For example, `main`\nwould be the base reference of type branch if you've created a new\nreference of type branch from it and created new commits.\n",
26 | "stability": "development",
27 | "examples": ["my-feature-branch", "tag-1-test"]
28 | },
29 | "vcs.ref.base.type": {
30 | "description": "The type of the [reference](https://git-scm.com/docs/gitglossary#def_ref) in the repository.\n",
31 | "type": "string",
32 | "note": "`base` refers to the starting point of a change. For example, `main`\nwould be the base reference of type branch if you've created a new\nreference of type branch from it and created new commits.\n",
33 | "stability": "development",
34 | "examples": ["branch", "tag"]
35 | },
36 | "vcs.ref.base.revision": {
37 | "description": "The revision, literally [revised version](https://www.merriam-webster.com/dictionary/revision), The revision most often refers to a commit object in Git, or a revision number in SVN.\n",
38 | "type": "string",
39 | "note": "`base` refers to the starting point of a change. For example, `main`\nwould be the base reference of type branch if you've created a new\nreference of type branch from it and created new commits. The\nrevision can be a full [hash value (see\nglossary)](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-5.pdf),\nof the recorded change to a ref within a repository pointing to a\ncommit [commit](https://git-scm.com/docs/git-commit) object. It does\nnot necessarily have to be a hash; it can simply define a [revision\nnumber](https://svnbook.red-bean.com/en/1.7/svn.tour.revs.specifiers.html)\nwhich is an integer that is monotonically increasing. In cases where\nit is identical to the `ref.base.name`, it SHOULD still be included.\nIt is up to the implementer to decide which value to set as the\nrevision based on the VCS system and situational context.\n",
40 | "stability": "development",
41 | "examples": [
42 | "9d59409acf479dfa0df1aa568182e43e43df8bbe28d60fcf2bc52e30068802cc",
43 | "main",
44 | "123",
45 | "HEAD"
46 | ]
47 | },
48 | "vcs.ref.head.name": {
49 | "description": "The name of the [reference](https://git-scm.com/docs/gitglossary#def_ref) such as **branch** or **tag** in the repository.\n",
50 | "type": "string",
51 | "note": "`head` refers to where you are right now; the current reference at a\ngiven time.\n",
52 | "stability": "development",
53 | "examples": ["my-feature-branch", "tag-1-test"]
54 | },
55 | "vcs.ref.head.type": {
56 | "description": "The type of the [reference](https://git-scm.com/docs/gitglossary#def_ref) in the repository.\n",
57 | "type": "string",
58 | "note": "`head` refers to where you are right now; the current reference at a\ngiven time.\n",
59 | "stability": "development",
60 | "examples": ["branch", "tag"]
61 | },
62 | "vcs.ref.head.revision": {
63 | "description": "The revision, literally [revised version](https://www.merriam-webster.com/dictionary/revision), The revision most often refers to a commit object in Git, or a revision number in SVN.\n",
64 | "type": "string",
65 | "note": "`head` refers to where you are right now; the current reference at a\ngiven time.The revision can be a full [hash value (see\nglossary)](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-5.pdf),\nof the recorded change to a ref within a repository pointing to a\ncommit [commit](https://git-scm.com/docs/git-commit) object. It does\nnot necessarily have to be a hash; it can simply define a [revision\nnumber](https://svnbook.red-bean.com/en/1.7/svn.tour.revs.specifiers.html)\nwhich is an integer that is monotonically increasing. In cases where\nit is identical to the `ref.head.name`, it SHOULD still be included.\nIt is up to the implementer to decide which value to set as the\nrevision based on the VCS system and situational context.\n",
66 | "stability": "development",
67 | "examples": [
68 | "9d59409acf479dfa0df1aa568182e43e43df8bbe28d60fcf2bc52e30068802cc",
69 | "main",
70 | "123",
71 | "HEAD"
72 | ]
73 | },
74 | "vcs.ref.type": {
75 | "description": "The type of the [reference](https://git-scm.com/docs/gitglossary#def_ref) in the repository.\n",
76 | "type": "string",
77 | "stability": "development",
78 | "examples": ["branch", "tag"]
79 | },
80 | "vcs.revision_delta.direction": {
81 | "description": "The type of revision comparison.\n",
82 | "type": "string",
83 | "stability": "development",
84 | "examples": ["behind", "ahead"]
85 | },
86 | "vcs.line_change.type": {
87 | "description": "The type of line change being measured on a branch or change.\n",
88 | "type": "string",
89 | "stability": "development",
90 | "examples": ["added", "removed"]
91 | },
92 | "vcs.change.title": {
93 | "description": "The human readable title of the change (pull request/merge request/changelist). This title is often a brief summary of the change and may get merged in to a ref as the commit summary.\n",
94 | "type": "string",
95 | "stability": "development",
96 | "examples": [
97 | "Fixes broken thing",
98 | "feat: add my new feature",
99 | "[chore] update dependency"
100 | ]
101 | },
102 | "vcs.change.id": {
103 | "description": "The ID of the change (pull request/merge request/changelist) if applicable. This is usually a unique (within repository) identifier generated by the VCS system.\n",
104 | "type": "string",
105 | "stability": "development",
106 | "examples": ["123"]
107 | },
108 | "vcs.change.state": {
109 | "description": "The state of the change (pull request/merge request/changelist).\n",
110 | "type": "string",
111 | "stability": "development",
112 | "examples": ["open", "wip", "closed", "merged"]
113 | },
114 | "vcs.owner.name": {
115 | "description": "The group owner within the version control system.\n",
116 | "type": "string",
117 | "stability": "development",
118 | "examples": ["my-org", "myteam", "business-unit"]
119 | },
120 | "vcs.provider.name": {
121 | "description": "The name of the version control system provider.\n",
122 | "type": "string",
123 | "stability": "development",
124 | "examples": ["github", "gitlab", "gittea", "gitea", "bitbucket"]
125 | }
126 | }
127 | }
128 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/http.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "namespace": "http",
3 | "description": "This document defines semantic convention attributes in the HTTP namespace.",
4 | "attributes": {
5 | "http.request.body.size": {
6 | "description": "The size of the request payload body in bytes. This is the number of bytes transferred excluding headers and is often, but not always, present as the [Content-Length](https://www.rfc-editor.org/rfc/rfc9110.html#field.content-length) header. For requests using transport encoding, this should be the compressed size.\n",
7 | "type": "number",
8 | "stability": "development",
9 | "examples": ["3495"]
10 | },
11 | "http.request.header": {
12 | "description": "HTTP request headers, `<key>` being the normalized HTTP Header name (lowercase), the value being the header values.\n",
13 | "type": "string",
14 | "note": "Instrumentations SHOULD require an explicit configuration of which headers are to be captured.\nIncluding all request headers can be a security risk - explicit configuration helps avoid leaking sensitive information.\n\nThe `User-Agent` header is already captured in the `user_agent.original` attribute.\nUsers MAY explicitly configure instrumentations to capture them even though it is not recommended.\n\nThe attribute value MUST consist of either multiple header values as an array of strings\nor a single-item array containing a possibly comma-concatenated string, depending on the way\nthe HTTP library provides access to headers.\n\nExamples:\n\n- A header `Content-Type: application/json` SHOULD be recorded as the `http.request.header.content-type`\n attribute with value `[\"application/json\"]`.\n- A header `X-Forwarded-For: 1.2.3.4, 1.2.3.5` SHOULD be recorded as the `http.request.header.x-forwarded-for`\n attribute with value `[\"1.2.3.4\", \"1.2.3.5\"]` or `[\"1.2.3.4, 1.2.3.5\"]` depending on the HTTP library.\n",
15 | "stability": "stable",
16 | "examples": ["[\"application/json\"]", "[\"1.2.3.4\",\"1.2.3.5\"]"]
17 | },
18 | "http.request.method": {
19 | "description": "HTTP request method.",
20 | "type": "string",
21 | "note": "HTTP request method value SHOULD be \"known\" to the instrumentation.\nBy default, this convention defines \"known\" methods as the ones listed in [RFC9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-methods)\nand the PATCH method defined in [RFC5789](https://www.rfc-editor.org/rfc/rfc5789.html).\n\nIf the HTTP request method is not known to instrumentation, it MUST set the `http.request.method` attribute to `_OTHER`.\n\nIf the HTTP instrumentation could end up converting valid HTTP request methods to `_OTHER`, then it MUST provide a way to override\nthe list of known HTTP methods. If this override is done via environment variable, then the environment variable MUST be named\nOTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS and support a comma-separated list of case-sensitive known HTTP methods\n(this list MUST be a full override of the default known method, it is not a list of known methods in addition to the defaults).\n\nHTTP method names are case-sensitive and `http.request.method` attribute value MUST match a known HTTP method name exactly.\nInstrumentations for specific web frameworks that consider HTTP methods to be case insensitive, SHOULD populate a canonical equivalent.\nTracing instrumentations that do so, MUST also set `http.request.method_original` to the original value.\n",
22 | "stability": "stable",
23 | "examples": [
24 | "CONNECT",
25 | "DELETE",
26 | "GET",
27 | "HEAD",
28 | "OPTIONS",
29 | "PATCH",
30 | "POST",
31 | "PUT",
32 | "TRACE",
33 | "_OTHER"
34 | ]
35 | },
36 | "http.request.method_original": {
37 | "description": "Original HTTP method sent by the client in the request line.",
38 | "type": "string",
39 | "stability": "stable",
40 | "examples": ["GeT", "ACL", "foo"]
41 | },
42 | "http.request.resend_count": {
43 | "description": "The ordinal number of request resending attempt (for any reason, including redirects).\n",
44 | "type": "number",
45 | "note": "The resend count SHOULD be updated each time an HTTP request gets resent by the client, regardless of what was the cause of the resending (e.g. redirection, authorization failure, 503 Server Unavailable, network issues, or any other).\n",
46 | "stability": "stable",
47 | "examples": ["3"]
48 | },
49 | "http.request.size": {
50 | "description": "The total size of the request in bytes. This should be the total number of bytes sent over the wire, including the request line (HTTP/1.1), framing (HTTP/2 and HTTP/3), headers, and request body if any.\n",
51 | "type": "number",
52 | "stability": "development",
53 | "examples": ["1437"]
54 | },
55 | "http.response.body.size": {
56 | "description": "The size of the response payload body in bytes. This is the number of bytes transferred excluding headers and is often, but not always, present as the [Content-Length](https://www.rfc-editor.org/rfc/rfc9110.html#field.content-length) header. For requests using transport encoding, this should be the compressed size.\n",
57 | "type": "number",
58 | "stability": "development",
59 | "examples": ["3495"]
60 | },
61 | "http.response.header": {
62 | "description": "HTTP response headers, `<key>` being the normalized HTTP Header name (lowercase), the value being the header values.\n",
63 | "type": "string",
64 | "note": "Instrumentations SHOULD require an explicit configuration of which headers are to be captured.\nIncluding all response headers can be a security risk - explicit configuration helps avoid leaking sensitive information.\n\nUsers MAY explicitly configure instrumentations to capture them even though it is not recommended.\n\nThe attribute value MUST consist of either multiple header values as an array of strings\nor a single-item array containing a possibly comma-concatenated string, depending on the way\nthe HTTP library provides access to headers.\n\nExamples:\n\n- A header `Content-Type: application/json` header SHOULD be recorded as the `http.request.response.content-type`\n attribute with value `[\"application/json\"]`.\n- A header `My-custom-header: abc, def` header SHOULD be recorded as the `http.response.header.my-custom-header`\n attribute with value `[\"abc\", \"def\"]` or `[\"abc, def\"]` depending on the HTTP library.\n",
65 | "stability": "stable",
66 | "examples": ["[\"application/json\"]", "[\"abc\",\"def\"]"]
67 | },
68 | "http.response.size": {
69 | "description": "The total size of the response in bytes. This should be the total number of bytes sent over the wire, including the status line (HTTP/1.1), framing (HTTP/2 and HTTP/3), headers, and response body and trailers if any.\n",
70 | "type": "number",
71 | "stability": "development",
72 | "examples": ["1437"]
73 | },
74 | "http.response.status_code": {
75 | "description": "[HTTP response status code](https://tools.ietf.org/html/rfc7231#section-6).",
76 | "type": "number",
77 | "stability": "stable",
78 | "examples": ["200"]
79 | },
80 | "http.route": {
81 | "description": "The matched route, that is, the path template in the format used by the respective server framework.\n",
82 | "type": "string",
83 | "note": "MUST NOT be populated when this is not supported by the HTTP server framework as the route attribute should have low-cardinality and the URI path can NOT substitute it.\nSHOULD include the [application root](/docs/http/http-spans.md#http-server-definitions) if there is one.\n",
84 | "stability": "stable",
85 | "examples": ["/users/:userID?", "{controller}/{action}/{id?}"]
86 | },
87 | "http.connection.state": {
88 | "description": "State of the HTTP connection in the HTTP connection pool.",
89 | "type": "string",
90 | "stability": "development",
91 | "examples": ["active", "idle"]
92 | }
93 | }
94 | }
95 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/url.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "namespace": "url",
3 | "description": "Attributes describing URL.",
4 | "attributes": {
5 | "url.domain": {
6 | "description": "Domain extracted from the `url.full`, such as \"opentelemetry.io\".\n",
7 | "type": "string",
8 | "note": "In some cases a URL may refer to an IP and/or port directly, without a domain name. In this case, the IP address would go to the domain field. If the URL contains a [literal IPv6 address](https://www.rfc-editor.org/rfc/rfc2732#section-2) enclosed by `[` and `]`, the `[` and `]` characters should also be captured in the domain field.\n",
9 | "stability": "development",
10 | "examples": [
11 | "www.foo.bar",
12 | "opentelemetry.io",
13 | "3.12.167.2",
14 | "[1080:0:0:0:8:800:200C:417A]"
15 | ]
16 | },
17 | "url.extension": {
18 | "description": "The file extension extracted from the `url.full`, excluding the leading dot.\n",
19 | "type": "string",
20 | "note": "The file extension is only set if it exists, as not every url has a file extension. When the file name has multiple extensions `example.tar.gz`, only the last one should be captured `gz`, not `tar.gz`.\n",
21 | "stability": "development",
22 | "examples": ["png", "gz"]
23 | },
24 | "url.fragment": {
25 | "description": "The [URI fragment](https://www.rfc-editor.org/rfc/rfc3986#section-3.5) component\n",
26 | "type": "string",
27 | "stability": "stable",
28 | "examples": ["SemConv"]
29 | },
30 | "url.full": {
31 | "description": "Absolute URL describing a network resource according to [RFC3986](https://www.rfc-editor.org/rfc/rfc3986)",
32 | "type": "string",
33 | "note": "For network calls, URL usually has `scheme://host[:port][path][?query][#fragment]` format, where the fragment\nis not transmitted over HTTP, but if it is known, it SHOULD be included nevertheless.\n\n`url.full` MUST NOT contain credentials passed via URL in form of `https://username:[email protected]/`.\nIn such case username and password SHOULD be redacted and attribute's value SHOULD be `https://REDACTED:[email protected]/`.\n\n`url.full` SHOULD capture the absolute URL when it is available (or can be reconstructed).\n\nSensitive content provided in `url.full` SHOULD be scrubbed when instrumentations can identify it.\n\n\nQuery string values for the following keys SHOULD be redacted by default and replaced by the\nvalue `REDACTED`:\n\n* [`AWSAccessKeyId`](https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html#RESTAuthenticationQueryStringAuth)\n* [`Signature`](https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html#RESTAuthenticationQueryStringAuth)\n* [`sig`](https://learn.microsoft.com/azure/storage/common/storage-sas-overview#sas-token)\n* [`X-Goog-Signature`](https://cloud.google.com/storage/docs/access-control/signed-urls)\n\nThis list is subject to change over time.\n\nWhen a query string value is redacted, the query string key SHOULD still be preserved, e.g.\n`https://www.example.com/path?color=blue&sig=REDACTED`.\n",
34 | "stability": "stable",
35 | "examples": [
36 | "https://www.foo.bar/search?q=OpenTelemetry#SemConv",
37 | "//localhost"
38 | ]
39 | },
40 | "url.original": {
41 | "description": "Unmodified original URL as seen in the event source.\n",
42 | "type": "string",
43 | "note": "In network monitoring, the observed URL may be a full URL, whereas in access logs, the URL is often just represented as a path. This field is meant to represent the URL as it was observed, complete or not.\n`url.original` might contain credentials passed via URL in form of `https://username:[email protected]/`. In such case password and username SHOULD NOT be redacted and attribute's value SHOULD remain the same.\n",
44 | "stability": "development",
45 | "examples": [
46 | "https://www.foo.bar/search?q=OpenTelemetry#SemConv",
47 | "search?q=OpenTelemetry"
48 | ]
49 | },
50 | "url.path": {
51 | "description": "The [URI path](https://www.rfc-editor.org/rfc/rfc3986#section-3.3) component\n",
52 | "type": "string",
53 | "note": "Sensitive content provided in `url.path` SHOULD be scrubbed when instrumentations can identify it.\n",
54 | "stability": "stable",
55 | "examples": ["/search"]
56 | },
57 | "url.port": {
58 | "description": "Port extracted from the `url.full`\n",
59 | "type": "number",
60 | "stability": "development",
61 | "examples": ["443"]
62 | },
63 | "url.query": {
64 | "description": "The [URI query](https://www.rfc-editor.org/rfc/rfc3986#section-3.4) component\n",
65 | "type": "string",
66 | "note": "Sensitive content provided in `url.query` SHOULD be scrubbed when instrumentations can identify it.\n\n\nQuery string values for the following keys SHOULD be redacted by default and replaced by the value `REDACTED`:\n\n* [`AWSAccessKeyId`](https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html#RESTAuthenticationQueryStringAuth)\n* [`Signature`](https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html#RESTAuthenticationQueryStringAuth)\n* [`sig`](https://learn.microsoft.com/azure/storage/common/storage-sas-overview#sas-token)\n* [`X-Goog-Signature`](https://cloud.google.com/storage/docs/access-control/signed-urls)\n\nThis list is subject to change over time.\n\nWhen a query string value is redacted, the query string key SHOULD still be preserved, e.g.\n`q=OpenTelemetry&sig=REDACTED`.\n",
67 | "stability": "stable",
68 | "examples": ["q=OpenTelemetry"]
69 | },
70 | "url.registered_domain": {
71 | "description": "The highest registered url domain, stripped of the subdomain.\n",
72 | "type": "string",
73 | "note": "This value can be determined precisely with the [public suffix list](https://publicsuffix.org/). For example, the registered domain for `foo.example.com` is `example.com`. Trying to approximate this by simply taking the last two labels will not work well for TLDs such as `co.uk`.\n",
74 | "stability": "development",
75 | "examples": ["example.com", "foo.co.uk"]
76 | },
77 | "url.scheme": {
78 | "description": "The [URI scheme](https://www.rfc-editor.org/rfc/rfc3986#section-3.1) component identifying the used protocol.\n",
79 | "type": "string",
80 | "stability": "stable",
81 | "examples": ["https", "ftp", "telnet"]
82 | },
83 | "url.subdomain": {
84 | "description": "The subdomain portion of a fully qualified domain name includes all of the names except the host name under the registered_domain. In a partially qualified domain, or if the qualification level of the full name cannot be determined, subdomain contains all of the names below the registered domain.\n",
85 | "type": "string",
86 | "note": "The subdomain portion of `www.east.mydomain.co.uk` is `east`. If the domain has multiple levels of subdomain, such as `sub2.sub1.example.com`, the subdomain field should contain `sub2.sub1`, with no trailing period.\n",
87 | "stability": "development",
88 | "examples": ["east", "sub2.sub1"]
89 | },
90 | "url.template": {
91 | "description": "The low-cardinality template of an [absolute path reference](https://www.rfc-editor.org/rfc/rfc3986#section-4.2).\n",
92 | "type": "string",
93 | "stability": "development",
94 | "examples": ["/users/{id}", "/users/:id", "/users?id={id}"]
95 | },
96 | "url.top_level_domain": {
97 | "description": "The effective top level domain (eTLD), also known as the domain suffix, is the last part of the domain name. For example, the top level domain for example.com is `com`.\n",
98 | "type": "string",
99 | "note": "This value can be determined precisely with the [public suffix list](https://publicsuffix.org/).\n",
100 | "stability": "development",
101 | "examples": ["com", "co.uk"]
102 | }
103 | }
104 | }
105 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/internal/agents/tools/data/rpc.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "namespace": "rpc",
3 | "description": "This document defines attributes for remote procedure calls.",
4 | "attributes": {
5 | "rpc.connect_rpc.error_code": {
6 | "description": "The [error codes](https://connectrpc.com//docs/protocol/#error-codes) of the Connect request. Error codes are always string values.",
7 | "type": "string",
8 | "stability": "development",
9 | "examples": [
10 | "cancelled",
11 | "unknown",
12 | "invalid_argument",
13 | "deadline_exceeded",
14 | "not_found",
15 | "already_exists",
16 | "permission_denied",
17 | "resource_exhausted",
18 | "failed_precondition",
19 | "aborted",
20 | "out_of_range",
21 | "unimplemented",
22 | "internal",
23 | "unavailable",
24 | "data_loss",
25 | "unauthenticated"
26 | ]
27 | },
28 | "rpc.connect_rpc.request.metadata": {
29 | "description": "Connect request metadata, `<key>` being the normalized Connect Metadata key (lowercase), the value being the metadata values.\n",
30 | "type": "string",
31 | "note": "Instrumentations SHOULD require an explicit configuration of which metadata values are to be captured.\nIncluding all request metadata values can be a security risk - explicit configuration helps avoid leaking sensitive information.\n\nFor example, a property `my-custom-key` with value `[\"1.2.3.4\", \"1.2.3.5\"]` SHOULD be recorded as\nthe `rpc.connect_rpc.request.metadata.my-custom-key` attribute with value `[\"1.2.3.4\", \"1.2.3.5\"]`\n",
32 | "stability": "development",
33 | "examples": ["[\"1.2.3.4\",\"1.2.3.5\"]"]
34 | },
35 | "rpc.connect_rpc.response.metadata": {
36 | "description": "Connect response metadata, `<key>` being the normalized Connect Metadata key (lowercase), the value being the metadata values.\n",
37 | "type": "string",
38 | "note": "Instrumentations SHOULD require an explicit configuration of which metadata values are to be captured.\nIncluding all response metadata values can be a security risk - explicit configuration helps avoid leaking sensitive information.\n\nFor example, a property `my-custom-key` with value `\"attribute_value\"` SHOULD be recorded as\nthe `rpc.connect_rpc.response.metadata.my-custom-key` attribute with value `[\"attribute_value\"]`\n",
39 | "stability": "development",
40 | "examples": ["[\"attribute_value\"]"]
41 | },
42 | "rpc.grpc.status_code": {
43 | "description": "The [numeric status code](https://github.com/grpc/grpc/blob/v1.33.2/doc/statuscodes.md) of the gRPC request.",
44 | "type": "string",
45 | "stability": "development",
46 | "examples": [
47 | "0",
48 | "1",
49 | "2",
50 | "3",
51 | "4",
52 | "5",
53 | "6",
54 | "7",
55 | "8",
56 | "9",
57 | "10",
58 | "11",
59 | "12",
60 | "13",
61 | "14",
62 | "15",
63 | "16"
64 | ]
65 | },
66 | "rpc.grpc.request.metadata": {
67 | "description": "gRPC request metadata, `<key>` being the normalized gRPC Metadata key (lowercase), the value being the metadata values.\n",
68 | "type": "string",
69 | "note": "Instrumentations SHOULD require an explicit configuration of which metadata values are to be captured.\nIncluding all request metadata values can be a security risk - explicit configuration helps avoid leaking sensitive information.\n\nFor example, a property `my-custom-key` with value `[\"1.2.3.4\", \"1.2.3.5\"]` SHOULD be recorded as\n`rpc.grpc.request.metadata.my-custom-key` attribute with value `[\"1.2.3.4\", \"1.2.3.5\"]`\n",
70 | "stability": "development",
71 | "examples": ["[\"1.2.3.4\",\"1.2.3.5\"]"]
72 | },
73 | "rpc.grpc.response.metadata": {
74 | "description": "gRPC response metadata, `<key>` being the normalized gRPC Metadata key (lowercase), the value being the metadata values.\n",
75 | "type": "string",
76 | "note": "Instrumentations SHOULD require an explicit configuration of which metadata values are to be captured.\nIncluding all response metadata values can be a security risk - explicit configuration helps avoid leaking sensitive information.\n\nFor example, a property `my-custom-key` with value `[\"attribute_value\"]` SHOULD be recorded as\nthe `rpc.grpc.response.metadata.my-custom-key` attribute with value `[\"attribute_value\"]`\n",
77 | "stability": "development",
78 | "examples": ["[\"attribute_value\"]"]
79 | },
80 | "rpc.jsonrpc.error_code": {
81 | "description": "`error.code` property of response if it is an error response.",
82 | "type": "number",
83 | "stability": "development",
84 | "examples": ["-32700", "100"]
85 | },
86 | "rpc.jsonrpc.error_message": {
87 | "description": "`error.message` property of response if it is an error response.",
88 | "type": "string",
89 | "stability": "development",
90 | "examples": ["Parse error", "User already exists"]
91 | },
92 | "rpc.jsonrpc.request_id": {
93 | "description": "`id` property of request or response. Since protocol allows id to be int, string, `null` or missing (for notifications), value is expected to be cast to string for simplicity. Use empty string in case of `null` value. Omit entirely if this is a notification.\n",
94 | "type": "string",
95 | "stability": "development",
96 | "examples": ["10", "request-7", ""]
97 | },
98 | "rpc.jsonrpc.version": {
99 | "description": "Protocol version as in `jsonrpc` property of request/response. Since JSON-RPC 1.0 doesn't specify this, the value can be omitted.",
100 | "type": "string",
101 | "stability": "development",
102 | "examples": ["2.0", "1.0"]
103 | },
104 | "rpc.method": {
105 | "description": "The name of the (logical) method being called, must be equal to the $method part in the span name.",
106 | "type": "string",
107 | "note": "This is the logical name of the method from the RPC interface perspective, which can be different from the name of any implementing method/function. The `code.function.name` attribute may be used to store the latter (e.g., method actually executing the call on the server side, RPC client stub method on the client side).\n",
108 | "stability": "development",
109 | "examples": ["exampleMethod"]
110 | },
111 | "rpc.service": {
112 | "description": "The full (logical) name of the service being called, including its package name, if applicable.",
113 | "type": "string",
114 | "note": "This is the logical name of the service from the RPC interface perspective, which can be different from the name of any implementing class. The `code.namespace` attribute may be used to store the latter (despite the attribute name, it may include a class name; e.g., class with method actually executing the call on the server side, RPC client stub class on the client side).\n",
115 | "stability": "development",
116 | "examples": ["myservice.EchoService"]
117 | },
118 | "rpc.system": {
119 | "description": "A string identifying the remoting system. See below for a list of well-known identifiers.",
120 | "type": "string",
121 | "stability": "development",
122 | "examples": [
123 | "grpc",
124 | "java_rmi",
125 | "dotnet_wcf",
126 | "apache_dubbo",
127 | "connect_rpc"
128 | ]
129 | },
130 | "rpc.message.type": {
131 | "description": "Whether this is a received or sent message.",
132 | "type": "string",
133 | "stability": "development",
134 | "examples": ["SENT", "RECEIVED"]
135 | },
136 | "rpc.message.id": {
137 | "description": "MUST be calculated as two different counters starting from `1` one for sent messages and one for received message.",
138 | "type": "number",
139 | "note": "This way we guarantee that the values will be consistent between different implementations.",
140 | "stability": "development"
141 | },
142 | "rpc.message.compressed_size": {
143 | "description": "Compressed size of the message in bytes.",
144 | "type": "number",
145 | "stability": "development"
146 | },
147 | "rpc.message.uncompressed_size": {
148 | "description": "Uncompressed size of the message in bytes.",
149 | "type": "number",
150 | "stability": "development"
151 | }
152 | }
153 | }
154 |
```
--------------------------------------------------------------------------------
/packages/mcp-server-evals/src/evals/utils/structuredOutputScorer.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { Score, ScoreFn, BaseScorerOptions } from "vitest-evals";
2 |
3 | interface StructuredOutputScorerOptions extends BaseScorerOptions {
4 | expected?: Record<string, any>;
5 | }
6 |
7 | interface StructuredOutputScorerConfig {
8 | /**
9 | * How to match field values
10 | * - "strict": Exact equality required (default)
11 | * - "fuzzy": More flexible matching (regex patterns, type coercion)
12 | * - Custom function: Your own comparison logic
13 | * @default "strict"
14 | */
15 | match?:
16 | | "strict"
17 | | "fuzzy"
18 | | ((expected: any, actual: any, key: string) => boolean);
19 |
20 | /**
21 | * Whether all expected fields must be present for a passing score
22 | * When false: gives partial credit based on fields matched
23 | * @default true
24 | */
25 | requireAll?: boolean;
26 |
27 | /**
28 | * Whether to allow additional fields beyond those expected
29 | * @default true
30 | */
31 | allowExtras?: boolean;
32 |
33 | /**
34 | * Enable debug logging
35 | * @default false
36 | */
37 | debug?: boolean;
38 | }
39 |
40 | /**
41 | * A configurable scorer for evaluating structured outputs (e.g., JSON) from LLM responses.
42 | *
43 | * Similar to ToolCallScorer but for validating structured data outputs like API queries.
44 | *
45 | * @param config - Configuration options for the scorer
46 | * @param config.match - How to match field values: "strict", "fuzzy", or custom function
47 | * @param config.requireAll - Require all expected fields (vs partial credit)
48 | * @param config.allowExtras - Allow additional fields in output
49 | * @param config.debug - Enable debug logging
50 | *
51 | * @example
52 | * // Default: strict matching
53 | * describeEval("query generation", {
54 | * data: async () => [{
55 | * input: "Show me errors from today",
56 | * expected: {
57 | * dataset: "errors",
58 | * query: "",
59 | * sort: "-timestamp",
60 | * timeRange: { statsPeriod: "24h" }
61 | * }
62 | * }],
63 | * task: myTask,
64 | * scorers: [StructuredOutputScorer()]
65 | * });
66 | *
67 | * @example
68 | * // Fuzzy matching with regex patterns
69 | * describeEval("flexible query matching", {
70 | * data: async () => [{
71 | * input: "Find slow API calls",
72 | * expected: {
73 | * dataset: "spans",
74 | * query: /span\.duration:>1000|span\.duration:>1s/,
75 | * sort: "-span.duration"
76 | * }
77 | * }],
78 | * task: myTask,
79 | * scorers: [StructuredOutputScorer({ match: "fuzzy" })]
80 | * });
81 | */
82 | export function StructuredOutputScorer(
83 | config: StructuredOutputScorerConfig = {},
84 | ): ScoreFn<StructuredOutputScorerOptions> {
85 | const {
86 | match = "strict",
87 | requireAll = true,
88 | allowExtras = true,
89 | debug = false,
90 | } = config;
91 |
92 | return async (opts: StructuredOutputScorerOptions): Promise<Score> => {
93 | const { output, expected } = opts;
94 |
95 | // If no expected output provided, just check if we got valid JSON
96 | if (!expected) {
97 | try {
98 | JSON.parse(output);
99 | return { score: 1, metadata: { rationale: "Valid JSON output" } };
100 | } catch {
101 | return { score: 0, metadata: { rationale: "Invalid JSON output" } };
102 | }
103 | }
104 |
105 | let parsed: Record<string, any>;
106 | try {
107 | parsed = JSON.parse(output);
108 | } catch (error) {
109 | return {
110 | score: 0,
111 | metadata: { rationale: `Failed to parse output as JSON: ${error}` },
112 | };
113 | }
114 |
115 | // Check for error field in output
116 | if (parsed.error && parsed.error !== "" && parsed.error !== null) {
117 | return {
118 | score: 0,
119 | metadata: { rationale: `Output contains error: ${parsed.error}` },
120 | };
121 | }
122 |
123 | const matchFn = getMatchFunction(match);
124 | const { matches, mismatches, extras } = compareObjects(
125 | expected,
126 | parsed,
127 | matchFn,
128 | );
129 |
130 | if (debug) {
131 | console.log("StructuredOutputScorer debug:");
132 | console.log("Expected:", expected);
133 | console.log("Actual:", parsed);
134 | console.log("Matches:", matches);
135 | console.log("Mismatches:", mismatches);
136 | console.log("Extras:", extras);
137 | }
138 |
139 | // Calculate score
140 | const totalExpected = Object.keys(expected).length;
141 | const totalMatched = matches.length;
142 | const hasExtras = extras.length > 0;
143 |
144 | let score: number;
145 | let rationale: string;
146 |
147 | if (requireAll && mismatches.length > 0) {
148 | score = 0;
149 | rationale = `Missing required fields: ${mismatches.map((m) => m.key).join(", ")}`;
150 | } else if (!allowExtras && hasExtras) {
151 | score = 0;
152 | rationale = `Unexpected extra fields: ${extras.join(", ")}`;
153 | } else if (totalExpected === 0) {
154 | score = 1;
155 | rationale = "No expected fields to match";
156 | } else {
157 | score = totalMatched / totalExpected;
158 | if (score === 1) {
159 | rationale = "All expected fields match";
160 | } else {
161 | rationale = `Matched ${totalMatched}/${totalExpected} fields`;
162 | }
163 | }
164 |
165 | // Add mismatch details to rationale
166 | if (mismatches.length > 0 && score < 1) {
167 | const details = mismatches
168 | .map(
169 | (m) =>
170 | `${m.key}: expected ${formatValue(m.expected)}, got ${formatValue(m.actual)}`,
171 | )
172 | .join("; ");
173 | rationale += ` - ${details}`;
174 | }
175 |
176 | return {
177 | score,
178 | metadata: {
179 | rationale,
180 | output,
181 | },
182 | };
183 | };
184 | }
185 |
186 | function getMatchFunction(
187 | match: StructuredOutputScorerConfig["match"],
188 | ): (expected: any, actual: any, key: string) => boolean {
189 | if (typeof match === "function") {
190 | return match;
191 | }
192 |
193 | if (match === "fuzzy") {
194 | return fuzzyMatch;
195 | }
196 |
197 | return strictMatch;
198 | }
199 |
200 | function strictMatch(expected: any, actual: any): boolean {
201 | return JSON.stringify(expected) === JSON.stringify(actual);
202 | }
203 |
204 | function fuzzyMatch(expected: any, actual: any): boolean {
205 | // Handle regex patterns
206 | if (expected instanceof RegExp) {
207 | return typeof actual === "string" && expected.test(actual);
208 | }
209 |
210 | // Handle functions (custom validators)
211 | if (typeof expected === "function") {
212 | return expected(actual);
213 | }
214 |
215 | // Handle null/undefined (intentionally using == for null/undefined check)
216 | if (
217 | expected === null ||
218 | expected === undefined ||
219 | actual === null ||
220 | actual === undefined
221 | ) {
222 | return expected === actual;
223 | }
224 |
225 | // Handle arrays
226 | if (Array.isArray(expected) && Array.isArray(actual)) {
227 | if (expected.length !== actual.length) return false;
228 | return expected.every((exp, i) => fuzzyMatch(exp, actual[i]));
229 | }
230 |
231 | // Handle objects
232 | if (typeof expected === "object" && typeof actual === "object") {
233 | return Object.keys(expected).every((key) =>
234 | fuzzyMatch(expected[key], actual[key]),
235 | );
236 | }
237 |
238 | // Handle primitives - fuzzy match allows type coercion (e.g., "1" matches 1)
239 | // biome-ignore lint/suspicious/noDoubleEquals: Intentional for fuzzy matching with type coercion
240 | return expected == actual;
241 | }
242 |
243 | interface ComparisonResult {
244 | matches: Array<{ key: string; expected: any; actual: any }>;
245 | mismatches: Array<{ key: string; expected: any; actual: any }>;
246 | extras: string[];
247 | }
248 |
249 | function compareObjects(
250 | expected: Record<string, any>,
251 | actual: Record<string, any>,
252 | matchFn: (expected: any, actual: any, key: string) => boolean,
253 | ): ComparisonResult {
254 | const matches: ComparisonResult["matches"] = [];
255 | const mismatches: ComparisonResult["mismatches"] = [];
256 |
257 | // Check expected fields
258 | for (const [key, expectedValue] of Object.entries(expected)) {
259 | const actualValue = actual[key];
260 |
261 | if (matchFn(expectedValue, actualValue, key)) {
262 | matches.push({ key, expected: expectedValue, actual: actualValue });
263 | } else {
264 | mismatches.push({ key, expected: expectedValue, actual: actualValue });
265 | }
266 | }
267 |
268 | // Find extra fields
269 | const expectedKeys = new Set(Object.keys(expected));
270 | const extras = Object.keys(actual).filter((key) => !expectedKeys.has(key));
271 |
272 | return { matches, mismatches, extras };
273 | }
274 |
275 | function formatValue(value: any): string {
276 | if (value === undefined) return "undefined";
277 | if (value === null) return "null";
278 | if (value instanceof RegExp) return value.toString();
279 | if (typeof value === "string") return `"${value}"`;
280 | if (typeof value === "object") return JSON.stringify(value);
281 | return String(value);
282 | }
283 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/utils/url-utils.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, expect, it } from "vitest";
2 | import {
3 | validateSentryHostThrows,
4 | validateAndParseSentryUrlThrows,
5 | validateOpenAiBaseUrlThrows,
6 | getIssueUrl,
7 | getIssuesSearchUrl,
8 | getTraceUrl,
9 | getEventsExplorerUrl,
10 | } from "./url-utils";
11 |
12 | describe("url-utils", () => {
13 | describe("validateSentryHostThrows", () => {
14 | it("should accept valid hostnames", () => {
15 | expect(() => validateSentryHostThrows("sentry.io")).not.toThrow();
16 | expect(() => validateSentryHostThrows("example.com")).not.toThrow();
17 | expect(() => validateSentryHostThrows("localhost:8000")).not.toThrow();
18 | expect(() =>
19 | validateSentryHostThrows("sentry.example.com"),
20 | ).not.toThrow();
21 | });
22 |
23 | it("should reject hostnames with http protocol", () => {
24 | expect(() => validateSentryHostThrows("http://sentry.io")).toThrow(
25 | "SENTRY_HOST should only contain a hostname",
26 | );
27 | expect(() => validateSentryHostThrows("http://example.com:8000")).toThrow(
28 | "SENTRY_HOST should only contain a hostname",
29 | );
30 | });
31 |
32 | it("should reject hostnames with https protocol", () => {
33 | expect(() => validateSentryHostThrows("https://sentry.io")).toThrow(
34 | "SENTRY_HOST should only contain a hostname",
35 | );
36 | expect(() => validateSentryHostThrows("https://example.com:443")).toThrow(
37 | "SENTRY_HOST should only contain a hostname",
38 | );
39 | });
40 | });
41 |
42 | describe("validateOpenAiBaseUrlThrows", () => {
43 | it("should accept valid HTTPS URLs", () => {
44 | expect(() =>
45 | validateOpenAiBaseUrlThrows("https://api.openai.com/v1"),
46 | ).not.toThrow();
47 | expect(() =>
48 | validateOpenAiBaseUrlThrows(
49 | "https://custom.example.com/openai/deployments/model",
50 | ),
51 | ).not.toThrow();
52 | });
53 |
54 | it("should accept valid HTTP URLs for local development", () => {
55 | expect(() =>
56 | validateOpenAiBaseUrlThrows("http://localhost:8080/v1"),
57 | ).not.toThrow();
58 | });
59 |
60 | it("should reject empty strings", () => {
61 | expect(() => validateOpenAiBaseUrlThrows(" ")).toThrow(
62 | "OPENAI base URL must not be empty.",
63 | );
64 | });
65 |
66 | it("should reject URLs with unsupported protocols", () => {
67 | expect(() => validateOpenAiBaseUrlThrows("ftp://example.com")).toThrow(
68 | "OPENAI base URL must use http or https scheme",
69 | );
70 | });
71 |
72 | it("should reject invalid URLs", () => {
73 | expect(() => validateOpenAiBaseUrlThrows("not-a-url")).toThrow(
74 | "OPENAI base URL must be a valid HTTP or HTTPS URL",
75 | );
76 | });
77 | });
78 |
79 | describe("validateAndParseSentryUrlThrows", () => {
80 | it("should accept and parse valid HTTPS URLs", () => {
81 | expect(validateAndParseSentryUrlThrows("https://sentry.io")).toBe(
82 | "sentry.io",
83 | );
84 | expect(validateAndParseSentryUrlThrows("https://example.com")).toBe(
85 | "example.com",
86 | );
87 | expect(validateAndParseSentryUrlThrows("https://localhost:8000")).toBe(
88 | "localhost:8000",
89 | );
90 | expect(
91 | validateAndParseSentryUrlThrows("https://sentry.example.com"),
92 | ).toBe("sentry.example.com");
93 | expect(validateAndParseSentryUrlThrows("https://example.com:443")).toBe(
94 | "example.com",
95 | );
96 | });
97 |
98 | it("should reject URLs without protocol", () => {
99 | expect(() => validateAndParseSentryUrlThrows("sentry.io")).toThrow(
100 | "SENTRY_URL must be a full HTTPS URL",
101 | );
102 | expect(() => validateAndParseSentryUrlThrows("example.com")).toThrow(
103 | "SENTRY_URL must be a full HTTPS URL",
104 | );
105 | });
106 |
107 | it("should reject HTTP URLs", () => {
108 | expect(() => validateAndParseSentryUrlThrows("http://sentry.io")).toThrow(
109 | "SENTRY_URL must be a full HTTPS URL",
110 | );
111 | expect(() =>
112 | validateAndParseSentryUrlThrows("http://example.com:8000"),
113 | ).toThrow("SENTRY_URL must be a full HTTPS URL");
114 | });
115 |
116 | it("should reject invalid URLs", () => {
117 | expect(() => validateAndParseSentryUrlThrows("https://")).toThrow(
118 | "SENTRY_URL must be a valid HTTPS URL",
119 | );
120 | expect(() =>
121 | validateAndParseSentryUrlThrows("https://[invalid-bracket"),
122 | ).toThrow("SENTRY_URL must be a valid HTTPS URL");
123 | });
124 | });
125 |
126 | describe("getIssueUrl", () => {
127 | it("should handle regional URLs correctly for SaaS", () => {
128 | const result = getIssueUrl("us.sentry.io", "myorg", "PROJ-123");
129 | expect(result).toBe("https://myorg.sentry.io/issues/PROJ-123");
130 | });
131 |
132 | it("should handle EU regional URLs correctly", () => {
133 | const result = getIssueUrl("eu.sentry.io", "myorg", "PROJ-456");
134 | expect(result).toBe("https://myorg.sentry.io/issues/PROJ-456");
135 | });
136 |
137 | it("should handle standard sentry.io correctly", () => {
138 | const result = getIssueUrl("sentry.io", "myorg", "PROJ-789");
139 | expect(result).toBe("https://myorg.sentry.io/issues/PROJ-789");
140 | });
141 |
142 | it("should handle self-hosted correctly", () => {
143 | const result = getIssueUrl("sentry.example.com", "myorg", "PROJ-123");
144 | expect(result).toBe(
145 | "https://sentry.example.com/organizations/myorg/issues/PROJ-123",
146 | );
147 | });
148 | });
149 |
150 | describe("getIssuesSearchUrl", () => {
151 | it("should handle regional URLs correctly for SaaS", () => {
152 | const result = getIssuesSearchUrl(
153 | "us.sentry.io",
154 | "myorg",
155 | "is:unresolved",
156 | "proj1",
157 | );
158 | expect(result).toBe(
159 | "https://myorg.sentry.io/issues/?project=proj1&query=is%3Aunresolved",
160 | );
161 | });
162 |
163 | it("should handle EU regional URLs correctly", () => {
164 | const result = getIssuesSearchUrl("eu.sentry.io", "myorg", "is:resolved");
165 | expect(result).toBe(
166 | "https://myorg.sentry.io/issues/?query=is%3Aresolved",
167 | );
168 | });
169 |
170 | it("should handle self-hosted correctly", () => {
171 | const result = getIssuesSearchUrl(
172 | "sentry.example.com",
173 | "myorg",
174 | "is:unresolved",
175 | "proj1",
176 | );
177 | expect(result).toBe(
178 | "https://sentry.example.com/organizations/myorg/issues/?project=proj1&query=is%3Aunresolved",
179 | );
180 | });
181 | });
182 |
183 | describe("getTraceUrl", () => {
184 | it("should handle regional URLs correctly for SaaS", () => {
185 | const result = getTraceUrl("us.sentry.io", "myorg", "abc123def456");
186 | expect(result).toBe(
187 | "https://myorg.sentry.io/explore/traces/trace/abc123def456",
188 | );
189 | });
190 |
191 | it("should handle EU regional URLs correctly", () => {
192 | const result = getTraceUrl("eu.sentry.io", "myorg", "xyz789");
193 | expect(result).toBe(
194 | "https://myorg.sentry.io/explore/traces/trace/xyz789",
195 | );
196 | });
197 |
198 | it("should handle self-hosted correctly", () => {
199 | const result = getTraceUrl("sentry.example.com", "myorg", "abc123");
200 | expect(result).toBe(
201 | "https://sentry.example.com/organizations/myorg/explore/traces/trace/abc123",
202 | );
203 | });
204 | });
205 |
206 | describe("getEventsExplorerUrl", () => {
207 | it("should handle regional URLs correctly for SaaS", () => {
208 | const result = getEventsExplorerUrl(
209 | "us.sentry.io",
210 | "myorg",
211 | "level:error",
212 | "errors",
213 | "proj1",
214 | );
215 | expect(result).toBe(
216 | "https://myorg.sentry.io/explore/?query=level%3Aerror&dataset=errors&layout=table&project=proj1",
217 | );
218 | });
219 |
220 | it("should handle EU regional URLs correctly", () => {
221 | const result = getEventsExplorerUrl(
222 | "eu.sentry.io",
223 | "myorg",
224 | "level:warning",
225 | "spans",
226 | );
227 | expect(result).toBe(
228 | "https://myorg.sentry.io/explore/?query=level%3Awarning&dataset=spans&layout=table",
229 | );
230 | });
231 |
232 | it("should handle self-hosted correctly", () => {
233 | const result = getEventsExplorerUrl(
234 | "sentry.example.com",
235 | "myorg",
236 | "level:error",
237 | "logs",
238 | "proj1",
239 | );
240 | expect(result).toBe(
241 | "https://sentry.example.com/organizations/myorg/explore/?query=level%3Aerror&dataset=logs&layout=table&project=proj1",
242 | );
243 | });
244 | });
245 | });
246 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/get-issue-details.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import { setTag } from "@sentry/core";
3 | import { defineTool } from "../internal/tool-helpers/define";
4 | import { apiServiceFromContext } from "../internal/tool-helpers/api";
5 | import {
6 | parseIssueParams,
7 | formatIssueOutput,
8 | } from "../internal/tool-helpers/issue";
9 | import { enhanceNotFoundError } from "../internal/tool-helpers/enhance-error";
10 | import { ApiNotFoundError } from "../api-client";
11 | import type { SentryApiService } from "../api-client";
12 | import type {
13 | Event,
14 | ErrorEvent,
15 | DefaultEvent,
16 | TransactionEvent,
17 | Trace,
18 | } from "../api-client/types";
19 | import { UserInputError } from "../errors";
20 | import type { ServerContext } from "../types";
21 | import { logError } from "../telem/logging";
22 | import {
23 | ParamOrganizationSlug,
24 | ParamRegionUrl,
25 | ParamIssueShortId,
26 | ParamIssueUrl,
27 | } from "../schema";
28 |
29 | export default defineTool({
30 | name: "get_issue_details",
31 | requiredScopes: ["event:read"],
32 | description: [
33 | "Get detailed information about a specific Sentry issue by ID.",
34 | "",
35 | "🔍 USE THIS TOOL WHEN USERS:",
36 | "- Provide a specific issue ID (e.g., 'CLOUDFLARE-MCP-41', 'PROJECT-123')",
37 | "- Ask to 'explain [ISSUE-ID]', 'tell me about [ISSUE-ID]'",
38 | "- Want details/stacktrace/analysis for a known issue",
39 | "- Provide a Sentry issue URL",
40 | "",
41 | "❌ DO NOT USE for:",
42 | "- General searching or listing issues (use search_issues)",
43 | "- Root cause analysis (use analyze_issue_with_seer)",
44 | "",
45 | "TRIGGER PATTERNS:",
46 | "- 'Explain ISSUE-123' → use get_issue_details",
47 | "- 'Tell me about PROJECT-456' → use get_issue_details",
48 | "- 'What happened in [issue URL]' → use get_issue_details",
49 | "",
50 | "<examples>",
51 | "### Explain specific issue",
52 | "```",
53 | "get_issue_details(organizationSlug='my-organization', issueId='CLOUDFLARE-MCP-41')",
54 | "```",
55 | "",
56 | "### Get details for event ID",
57 | "```",
58 | "get_issue_details(organizationSlug='my-organization', eventId='c49541c747cb4d8aa3efb70ca5aba243')",
59 | "```",
60 | "</examples>",
61 | "",
62 | "<hints>",
63 | "- If the user provides the `issueUrl`, you can ignore the other parameters.",
64 | "- If the user provides `issueId` or `eventId` (only one is needed), `organizationSlug` is required.",
65 | "</hints>",
66 | ].join("\n"),
67 | inputSchema: {
68 | organizationSlug: ParamOrganizationSlug.optional(),
69 | regionUrl: ParamRegionUrl.optional(),
70 | issueId: ParamIssueShortId.optional(),
71 | eventId: z.string().trim().describe("The ID of the event.").optional(),
72 | issueUrl: ParamIssueUrl.optional(),
73 | },
74 | annotations: {
75 | readOnlyHint: true,
76 | openWorldHint: true,
77 | },
78 | async handler(params, context: ServerContext) {
79 | const apiService = apiServiceFromContext(context, {
80 | regionUrl: params.regionUrl,
81 | });
82 |
83 | if (params.eventId) {
84 | const orgSlug = params.organizationSlug;
85 | const eventId = params.eventId; // Capture eventId for type safety
86 | if (!orgSlug) {
87 | throw new UserInputError(
88 | "`organizationSlug` is required when providing `eventId`",
89 | );
90 | }
91 |
92 | setTag("organization.slug", orgSlug);
93 | // API client will throw ApiClientError/ApiServerError which the MCP server wrapper handles
94 | const [issue] = await apiService.listIssues({
95 | organizationSlug: orgSlug,
96 | query: eventId,
97 | });
98 | if (!issue) {
99 | return `# Event Not Found\n\nNo issue found for Event ID: ${eventId}`;
100 | }
101 | // For this call, we might want to provide context if it fails
102 | let event: Awaited<ReturnType<typeof apiService.getEventForIssue>>;
103 | try {
104 | event = await apiService.getEventForIssue({
105 | organizationSlug: orgSlug,
106 | issueId: issue.shortId,
107 | eventId,
108 | });
109 | } catch (error) {
110 | // Optionally enhance 404 errors with parameter context
111 | if (error instanceof ApiNotFoundError) {
112 | throw enhanceNotFoundError(error, {
113 | organizationSlug: orgSlug,
114 | issueId: issue.shortId,
115 | eventId,
116 | });
117 | }
118 | throw error;
119 | }
120 |
121 | // Try to fetch Seer analysis context (non-blocking)
122 | let autofixState:
123 | | Awaited<ReturnType<typeof apiService.getAutofixState>>
124 | | undefined;
125 | try {
126 | autofixState = await apiService.getAutofixState({
127 | organizationSlug: orgSlug,
128 | issueId: issue.shortId,
129 | });
130 | } catch (error) {
131 | // Silently continue if Seer analysis is not available
132 | // This ensures the tool works even if Seer is not enabled
133 | }
134 |
135 | const performanceTrace = await maybeFetchPerformanceTrace({
136 | apiService,
137 | organizationSlug: orgSlug,
138 | event,
139 | });
140 |
141 | return formatIssueOutput({
142 | organizationSlug: orgSlug,
143 | issue,
144 | event,
145 | apiService,
146 | autofixState,
147 | performanceTrace,
148 | });
149 | }
150 |
151 | // Validate that we have the minimum required parameters
152 | if (!params.issueUrl && !params.issueId) {
153 | throw new UserInputError(
154 | "Either `issueId` or `issueUrl` must be provided",
155 | );
156 | }
157 |
158 | if (!params.issueUrl && !params.organizationSlug) {
159 | throw new UserInputError(
160 | "`organizationSlug` is required when providing `issueId`",
161 | );
162 | }
163 |
164 | const { organizationSlug: orgSlug, issueId: parsedIssueId } =
165 | parseIssueParams({
166 | organizationSlug: params.organizationSlug,
167 | issueId: params.issueId,
168 | issueUrl: params.issueUrl,
169 | });
170 |
171 | setTag("organization.slug", orgSlug);
172 |
173 | // For the main issue lookup, provide parameter context on 404
174 | let issue: Awaited<ReturnType<typeof apiService.getIssue>>;
175 | try {
176 | issue = await apiService.getIssue({
177 | organizationSlug: orgSlug,
178 | issueId: parsedIssueId!,
179 | });
180 | } catch (error) {
181 | if (error instanceof ApiNotFoundError) {
182 | throw enhanceNotFoundError(error, {
183 | organizationSlug: orgSlug,
184 | issueId: parsedIssueId,
185 | });
186 | }
187 | throw error;
188 | }
189 |
190 | const event = await apiService.getLatestEventForIssue({
191 | organizationSlug: orgSlug,
192 | issueId: issue.shortId,
193 | });
194 |
195 | // Try to fetch Seer analysis context (non-blocking)
196 | let autofixState:
197 | | Awaited<ReturnType<typeof apiService.getAutofixState>>
198 | | undefined;
199 | try {
200 | autofixState = await apiService.getAutofixState({
201 | organizationSlug: orgSlug,
202 | issueId: issue.shortId,
203 | });
204 | } catch (error) {
205 | // Silently continue if Seer analysis is not available
206 | // This ensures the tool works even if Seer is not enabled
207 | }
208 |
209 | const performanceTrace = await maybeFetchPerformanceTrace({
210 | apiService,
211 | organizationSlug: orgSlug,
212 | event,
213 | });
214 |
215 | return formatIssueOutput({
216 | organizationSlug: orgSlug,
217 | issue,
218 | event,
219 | apiService,
220 | autofixState,
221 | performanceTrace,
222 | });
223 | },
224 | });
225 |
226 | async function maybeFetchPerformanceTrace({
227 | apiService,
228 | organizationSlug,
229 | event,
230 | }: {
231 | apiService: SentryApiService;
232 | organizationSlug: string;
233 | event: Event;
234 | }): Promise<Trace | undefined> {
235 | const context = shouldFetchTraceForEvent(event);
236 | if (!context) {
237 | return undefined;
238 | }
239 |
240 | try {
241 | return await apiService.getTrace({
242 | organizationSlug,
243 | traceId: context.traceId,
244 | limit: 10000,
245 | });
246 | } catch (error) {
247 | logError(error);
248 | return undefined;
249 | }
250 | }
251 |
252 | function isErrorEvent(event: Event): event is ErrorEvent | DefaultEvent {
253 | // "default" type represents error events without exception data
254 | return event.type === "error" || event.type === "default";
255 | }
256 |
257 | function isTransactionEvent(event: Event): event is TransactionEvent {
258 | return event.type === "transaction";
259 | }
260 |
261 | function shouldFetchTraceForEvent(event: Event): { traceId: string } | null {
262 | // Only fetch traces for non-error events (transactions, profiling, etc.)
263 | if (isErrorEvent(event)) {
264 | return null;
265 | }
266 |
267 | // Check if we have a trace ID
268 | const traceId = event.contexts?.trace?.trace_id;
269 |
270 | if (typeof traceId !== "string" || traceId.length === 0) {
271 | return null;
272 | }
273 |
274 | return { traceId };
275 | }
276 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/tools/analyze-issue-with-seer.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2 | import { http, HttpResponse } from "msw";
3 | import { mswServer, autofixStateFixture } from "@sentry/mcp-server-mocks";
4 | import analyzeIssueWithSeer from "./analyze-issue-with-seer.js";
5 |
6 | describe("analyze_issue_with_seer", () => {
7 | beforeEach(() => {
8 | vi.useFakeTimers();
9 | });
10 |
11 | afterEach(() => {
12 | vi.useRealTimers();
13 | vi.clearAllMocks();
14 | });
15 |
16 | it("handles combined workflow", async () => {
17 | // This test validates the tool works correctly
18 | // In a real scenario, it would poll multiple times, but for testing
19 | // we'll validate the key outputs are present
20 | const result = await analyzeIssueWithSeer.handler(
21 | {
22 | organizationSlug: "sentry-mcp-evals",
23 | issueId: "CLOUDFLARE-MCP-45",
24 | issueUrl: undefined,
25 | regionUrl: undefined,
26 | instruction: undefined,
27 | },
28 | {
29 | constraints: {
30 | organizationSlug: null,
31 | },
32 | accessToken: "access-token",
33 | userId: "1",
34 | },
35 | );
36 |
37 | expect(result).toContain("# Seer Analysis for Issue CLOUDFLARE-MCP-45");
38 | expect(result).toContain("Found existing analysis (Run ID: 13)");
39 | expect(result).toContain("## Analysis Complete");
40 | expect(result).toContain("## 1. **Root Cause Analysis**");
41 | expect(result).toContain("The analysis has completed successfully.");
42 | });
43 |
44 | it("handles network errors with retry", async () => {
45 | let attempts = 0;
46 | mswServer.use(
47 | http.get(
48 | "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-99/autofix/",
49 | () => {
50 | attempts++;
51 | if (attempts < 3) {
52 | // Simulate network error for first 2 attempts
53 | return HttpResponse.error();
54 | }
55 | // Success on third attempt
56 | return HttpResponse.json(autofixStateFixture);
57 | },
58 | ),
59 | );
60 |
61 | const promise = analyzeIssueWithSeer.handler(
62 | {
63 | organizationSlug: "sentry-mcp-evals",
64 | issueId: "CLOUDFLARE-MCP-99",
65 | },
66 | {
67 | constraints: {
68 | organizationSlug: null,
69 | },
70 | accessToken: "access-token",
71 | userId: "1",
72 | },
73 | );
74 |
75 | // Fast-forward through retries
76 | await vi.runAllTimersAsync();
77 |
78 | const result = await promise;
79 |
80 | expect(attempts).toBe(3);
81 | expect(result).toContain("# Seer Analysis for Issue CLOUDFLARE-MCP-99");
82 | expect(result).toContain("Found existing analysis");
83 | });
84 |
85 | it("handles 500 errors with retry", async () => {
86 | let attempts = 0;
87 | mswServer.use(
88 | http.get(
89 | "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-500/autofix/",
90 | () => {
91 | attempts++;
92 | if (attempts < 2) {
93 | // Simulate server error for first attempt
94 | return HttpResponse.json(
95 | { detail: "Internal Server Error" },
96 | { status: 500 },
97 | );
98 | }
99 | // Success on second attempt
100 | return HttpResponse.json(autofixStateFixture);
101 | },
102 | ),
103 | );
104 |
105 | const promise = analyzeIssueWithSeer.handler(
106 | {
107 | organizationSlug: "sentry-mcp-evals",
108 | issueId: "CLOUDFLARE-MCP-500",
109 | },
110 | {
111 | constraints: {
112 | organizationSlug: null,
113 | },
114 | accessToken: "access-token",
115 | userId: "1",
116 | },
117 | );
118 |
119 | // Fast-forward through retries
120 | await vi.runAllTimersAsync();
121 |
122 | const result = await promise;
123 |
124 | expect(attempts).toBe(2);
125 | expect(result).toContain("# Seer Analysis for Issue CLOUDFLARE-MCP-500");
126 | });
127 |
128 | it.skip("handles polling with transient errors", async () => {
129 | // This test is skipped because it's difficult to reliably trigger the error message
130 | // The functionality is covered by the error recovery logic in the retry tests
131 | });
132 |
133 | it("handles polling timeout", async () => {
134 | const inProgressState = {
135 | ...autofixStateFixture,
136 | autofix: {
137 | ...autofixStateFixture.autofix,
138 | status: "PROCESSING",
139 | steps: [
140 | {
141 | ...autofixStateFixture.autofix.steps[0],
142 | status: "PROCESSING",
143 | title: "Analyzing the issue",
144 | },
145 | ],
146 | },
147 | };
148 |
149 | mswServer.use(
150 | http.get(
151 | "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-TIMEOUT/autofix/",
152 | () => {
153 | // Always return in progress
154 | return HttpResponse.json(inProgressState);
155 | },
156 | ),
157 | );
158 |
159 | const promise = analyzeIssueWithSeer.handler(
160 | {
161 | organizationSlug: "sentry-mcp-evals",
162 | issueId: "CLOUDFLARE-MCP-TIMEOUT",
163 | },
164 | {
165 | constraints: {
166 | organizationSlug: null,
167 | },
168 | accessToken: "access-token",
169 | userId: "1",
170 | },
171 | );
172 |
173 | // Fast-forward past timeout
174 | await vi.advanceTimersByTimeAsync(6 * 60 * 1000); // 6 minutes
175 |
176 | const result = await promise;
177 |
178 | expect(result).toContain("## Analysis Timed Out");
179 | expect(result).toContain(
180 | "The analysis is taking longer than expected (>300s)",
181 | );
182 | expect(result).toContain("Processing: Analyzing the issue...");
183 | });
184 |
185 | it("handles consecutive polling errors", async () => {
186 | let pollAttempts = 0;
187 | const inProgressState = {
188 | ...autofixStateFixture,
189 | autofix: {
190 | ...autofixStateFixture.autofix,
191 | status: "PROCESSING",
192 | },
193 | };
194 |
195 | mswServer.use(
196 | http.get(
197 | "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-ERRORS/autofix/",
198 | () => {
199 | pollAttempts++;
200 | if (pollAttempts === 1) {
201 | // First call returns in progress
202 | return HttpResponse.json(inProgressState);
203 | }
204 | // All subsequent calls fail
205 | return HttpResponse.error();
206 | },
207 | ),
208 | );
209 |
210 | const promise = analyzeIssueWithSeer.handler(
211 | {
212 | organizationSlug: "sentry-mcp-evals",
213 | issueId: "CLOUDFLARE-MCP-ERRORS",
214 | },
215 | {
216 | constraints: {
217 | organizationSlug: null,
218 | },
219 | accessToken: "access-token",
220 | userId: "1",
221 | },
222 | );
223 |
224 | // Fast-forward through polling intervals
225 | for (let i = 0; i < 10; i++) {
226 | await vi.advanceTimersByTimeAsync(5000);
227 | }
228 |
229 | const result = await promise;
230 |
231 | expect(result).toContain("## Error During Analysis");
232 | expect(result).toContain(
233 | "Unable to retrieve analysis status after multiple attempts",
234 | );
235 | expect(result).toContain(
236 | "You can check the status later by running the same command again",
237 | );
238 | });
239 |
240 | it("handles start autofix with instruction", async () => {
241 | let getCallCount = 0;
242 |
243 | mswServer.use(
244 | http.get(
245 | "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-NEW/autofix/",
246 | () => {
247 | getCallCount++;
248 | if (getCallCount === 1) {
249 | // First call - no existing autofix
250 | return HttpResponse.json({ autofix: null });
251 | }
252 | // Subsequent calls - return completed state
253 | return HttpResponse.json(autofixStateFixture);
254 | },
255 | ),
256 | http.post(
257 | "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-NEW/autofix/",
258 | async ({ request }) => {
259 | const body = await request.json();
260 | expect(body).toEqual({
261 | event_id: undefined,
262 | instruction: "Focus on memory leaks",
263 | });
264 | return HttpResponse.json({
265 | run_id: "new-run-123",
266 | });
267 | },
268 | ),
269 | );
270 |
271 | const promise = analyzeIssueWithSeer.handler(
272 | {
273 | organizationSlug: "sentry-mcp-evals",
274 | issueId: "CLOUDFLARE-MCP-NEW",
275 | instruction: "Focus on memory leaks",
276 | },
277 | {
278 | constraints: {
279 | organizationSlug: null,
280 | },
281 | accessToken: "access-token",
282 | userId: "1",
283 | },
284 | );
285 |
286 | // Fast-forward through initial delay and polling
287 | await vi.runAllTimersAsync();
288 |
289 | const result = await promise;
290 |
291 | expect(result).toContain("Starting new analysis...");
292 | expect(result).toContain("Analysis started with Run ID: new-run-123");
293 | expect(result).toContain("## Analysis Complete");
294 | });
295 | });
296 |
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/client/pages/home.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import TOOL_DEFINITIONS from "@sentry/mcp-server/toolDefinitions";
2 | import { Link } from "../components/ui/base";
3 | import {
4 | Accordion,
5 | AccordionContent,
6 | AccordionItem,
7 | AccordionTrigger,
8 | } from "../components/ui/accordion";
9 | import Note from "../components/ui/note";
10 | import { Sparkles } from "lucide-react";
11 | import { Button } from "../components/ui/button";
12 | import RemoteSetup from "../components/fragments/remote-setup";
13 | import { useState } from "react";
14 | import StdioSetup from "../components/fragments/stdio-setup";
15 | import Section from "../components/ui/section";
16 | import { Prose } from "../components/ui/prose";
17 | import JsonSchemaParams from "../components/ui/json-schema-params";
18 |
19 | interface HomeProps {
20 | onChatClick: () => void;
21 | }
22 |
23 | export default function Home({ onChatClick }: HomeProps) {
24 | const [stdio, setStdio] = useState(false);
25 |
26 | return (
27 | <main className="flex gap-4 max-w-3xl">
28 | <article>
29 | <div id="top" />
30 | <Section className="space-y-4 mb-10">
31 | <Prose>
32 | <p>
33 | This service implements the Model Context Protocol (MCP) for
34 | interacting with <a href="https://sentry.io/welcome/">Sentry</a>,
35 | focused on human-in-the-loop coding agents and developer workflows
36 | rather than general-purpose API access.
37 | </p>
38 | </Prose>
39 |
40 | {/* Big Call to Action - Mobile Only */}
41 | <div className="md:hidden relative overflow-hidden bg-slate-950 p-8 text-center">
42 | <div className="absolute inset-0 bg-slate-950" />
43 | <div className="relative z-10">
44 | <p className="text-slate-300 mb-6 max-w-lg mx-auto">
45 | Chat with your stack traces. Argue with confidence. Lose
46 | gracefully.
47 | </p>
48 | <div className="flex flex-col sm:flex-row gap-3 justify-center items-center">
49 | <Button
50 | onClick={onChatClick}
51 | variant="default"
52 | className="gap-2 cursor-pointer"
53 | >
54 | <Sparkles className="h-5 w-5" />
55 | Try It Out
56 | </Button>
57 | <p className="text-sm text-slate-400">
58 | Ask: "What are my recent issues?"
59 | </p>
60 | </div>
61 | </div>
62 | </div>
63 |
64 | <Section>
65 | <Prose>
66 | <p>
67 | Simply put, it's a way to plug Sentry's API into an LLM, letting
68 | you ask questions about your data in context of the LLM itself.
69 | This lets you take a coding agent that you already use, like
70 | Cursor or Claude Code, and pull in additional information from
71 | Sentry to help with tasks like debugging, fixing production
72 | errors, and understanding your application's behavior.
73 | </p>
74 | <p>
75 | This project is still in its infancy as development of the MCP
76 | specification is ongoing. If you find any problems, or have an
77 | idea for how we can improve it, please let us know on{" "}
78 | <Link href="https://github.com/getsentry/sentry-mcp/issues">
79 | GitHub
80 | </Link>
81 | </p>
82 | <h3>Interested in learning more?</h3>
83 | <ul>
84 | <li>
85 | <Link href="https://www.youtube.com/watch?v=n4v0fR6mVTU">
86 | Using Sentry's Seer via MCP
87 | </Link>
88 | </li>
89 | <li>
90 | <Link href="https://www.youtube.com/watch?v=m3IE6JygT1o">
91 | Building Sentry's MCP on Cloudflare
92 | </Link>
93 | </li>
94 | </ul>
95 | </Prose>
96 | </Section>
97 |
98 | <Section
99 | heading={
100 | <>
101 | <div className="flex-1">Getting Started</div>
102 | <div className="flex items-center gap-2 text-xs">
103 | <Button
104 | variant={!stdio ? "default" : "secondary"}
105 | size="xs"
106 | onClick={() => setStdio(false)}
107 | className={!stdio ? "shadow-sm" : undefined}
108 | >
109 | Cloud
110 | </Button>
111 | <Button
112 | variant={stdio ? "default" : "secondary"}
113 | size="xs"
114 | onClick={() => setStdio(true)}
115 | className={stdio ? "shadow-sm" : undefined}
116 | >
117 | Stdio
118 | </Button>
119 | </div>
120 | </>
121 | }
122 | >
123 | <div className="relative min-h-0">
124 | {!stdio ? (
125 | <div
126 | key="cloud"
127 | className="animate-in fade-in slide-in-from-left-4 duration-300"
128 | >
129 | <RemoteSetup />
130 | </div>
131 | ) : (
132 | <div
133 | key="stdio-self-hosted"
134 | className="animate-in fade-in slide-in-from-right-4 duration-300"
135 | >
136 | <StdioSetup />
137 | </div>
138 | )}
139 | </div>
140 | </Section>
141 | </Section>
142 |
143 | <Section heading="Available Tools" id="tools">
144 | <Prose>
145 | <p>
146 | Tools are pre-configured functions that can be used to help with
147 | common tasks.
148 | </p>
149 | </Prose>
150 | <Note>
151 | <strong>Note:</strong> Any tool that takes an{" "}
152 | <code>organization_slug</code> parameter will try to infer a default
153 | organization, otherwise you should mention it in the prompt.
154 | </Note>
155 | <Accordion type="single" collapsible className="w-full space-y-1">
156 | {TOOL_DEFINITIONS.sort((a, b) => a.name.localeCompare(b.name)).map(
157 | (tool) => (
158 | <AccordionItem value={tool.name} key={tool.name}>
159 | <AccordionTrigger className="text-base text-white hover:text-violet-300 cursor-pointer font-mono font-semibold">
160 | {tool.name}
161 | </AccordionTrigger>
162 | <AccordionContent className="py-4">
163 | <Prose>
164 | <p className="mb-0">{tool.description.split("\n")[0]}</p>
165 | </Prose>
166 | <div className="mt-4 space-y-4">
167 | {/* Authorization / Scopes */}
168 | <section className="rounded-md border border-slate-700/60 bg-black/30 p-3">
169 | <div className="text-xs uppercase tracking-wide text-slate-300/80 mb-2">
170 | Authorization
171 | </div>
172 | <div className="flex flex-wrap gap-2">
173 | {tool.requiredScopes &&
174 | tool.requiredScopes.length > 0 ? (
175 | tool.requiredScopes.map((s) => (
176 | <span
177 | key={s}
178 | className="inline-flex items-center rounded-full border border-violet-500/40 bg-violet-500/10 px-2 py-0.5 text-xs font-mono text-violet-200"
179 | >
180 | {s}
181 | </span>
182 | ))
183 | ) : (
184 | <span className="text-sm text-slate-400">None</span>
185 | )}
186 | </div>
187 | </section>
188 | <JsonSchemaParams schema={tool.inputSchema as unknown} />
189 | </div>
190 | </AccordionContent>
191 | </AccordionItem>
192 | ),
193 | )}
194 | </Accordion>
195 | </Section>
196 |
197 | <Section heading="More Information" id="more-information">
198 | <Prose>
199 | <ul>
200 | <li>
201 | <Link href="https://github.com/getsentry/sentry-mcp">
202 | sentry-mcp on GitHub
203 | </Link>
204 | </li>
205 | </ul>
206 | </Prose>
207 | </Section>
208 | </article>
209 | </main>
210 | );
211 | }
212 |
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/oauth/helpers.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type {
2 | TokenExchangeCallbackOptions,
3 | TokenExchangeCallbackResult,
4 | } from "@cloudflare/workers-oauth-provider";
5 | import type { z } from "zod";
6 | import { logIssue } from "@sentry/mcp-server/telem/logging";
7 | import { TokenResponseSchema, SENTRY_TOKEN_URL } from "./constants";
8 | import type { WorkerProps } from "../types";
9 | import * as Sentry from "@sentry/cloudflare";
10 |
11 | /**
12 | * Constructs an authorization URL for Sentry.
13 | */
14 | export function getUpstreamAuthorizeUrl({
15 | upstream_url,
16 | client_id,
17 | scope,
18 | redirect_uri,
19 | state,
20 | }: {
21 | upstream_url: string;
22 | client_id: string;
23 | scope: string;
24 | redirect_uri: string;
25 | state?: string;
26 | }) {
27 | const upstream = new URL(upstream_url);
28 | upstream.searchParams.set("client_id", client_id);
29 | upstream.searchParams.set("redirect_uri", redirect_uri);
30 | upstream.searchParams.set("scope", scope);
31 | if (state) upstream.searchParams.set("state", state);
32 | upstream.searchParams.set("response_type", "code");
33 | return upstream.href;
34 | }
35 |
36 | /**
37 | * Exchanges an authorization code for an access token from Sentry.
38 | */
39 | export async function exchangeCodeForAccessToken({
40 | client_id,
41 | client_secret,
42 | code,
43 | upstream_url,
44 | redirect_uri,
45 | }: {
46 | code: string | undefined;
47 | upstream_url: string;
48 | client_secret: string;
49 | client_id: string;
50 | redirect_uri?: string;
51 | }): Promise<[z.infer<typeof TokenResponseSchema>, null] | [null, Response]> {
52 | if (!code) {
53 | const eventId = logIssue("[oauth] Missing code in token exchange", {
54 | oauth: {
55 | client_id,
56 | },
57 | });
58 | return [
59 | null,
60 | new Response("Invalid request: missing authorization code", {
61 | status: 400,
62 | headers: { "X-Event-ID": eventId ?? "" },
63 | }),
64 | ];
65 | }
66 |
67 | const resp = await fetch(upstream_url, {
68 | method: "POST",
69 | headers: {
70 | "Content-Type": "application/x-www-form-urlencoded",
71 | "User-Agent": "Sentry MCP Cloudflare",
72 | },
73 | body: new URLSearchParams({
74 | grant_type: "authorization_code",
75 | client_id,
76 | client_secret,
77 | code,
78 | ...(redirect_uri ? { redirect_uri } : {}),
79 | }).toString(),
80 | });
81 | if (!resp.ok) {
82 | const responseText = await resp.text();
83 | const eventId = logIssue(
84 | `[oauth] Failed to exchange code for access token: ${responseText}`,
85 | {
86 | oauth: {
87 | client_id,
88 | status: resp.status,
89 | statusText: resp.statusText,
90 | hasRedirectUri: !!redirect_uri,
91 | redirectUri: redirect_uri,
92 | hasCode: !!code,
93 | },
94 | },
95 | );
96 | return [
97 | null,
98 | new Response(
99 | "There was an issue authenticating your account and retrieving an access token. Please try again.",
100 | { status: 400, headers: { "X-Event-ID": eventId ?? "" } },
101 | ),
102 | ];
103 | }
104 |
105 | try {
106 | const body = await resp.json();
107 | const output = TokenResponseSchema.parse(body);
108 | return [output, null];
109 | } catch (e) {
110 | const eventId = logIssue(
111 | new Error("Failed to parse token response", {
112 | cause: e,
113 | }),
114 | {
115 | oauth: {
116 | client_id,
117 | },
118 | },
119 | );
120 | return [
121 | null,
122 | new Response(
123 | "There was an issue authenticating your account and retrieving an access token. Please try again.",
124 | { status: 500, headers: { "X-Event-ID": eventId ?? "" } },
125 | ),
126 | ];
127 | }
128 | }
129 |
130 | /**
131 | * Refreshes an access token using a refresh token from Sentry.
132 | */
133 | export async function refreshAccessToken({
134 | client_id,
135 | client_secret,
136 | refresh_token,
137 | upstream_url,
138 | }: {
139 | refresh_token: string | undefined;
140 | upstream_url: string;
141 | client_secret: string;
142 | client_id: string;
143 | }): Promise<[z.infer<typeof TokenResponseSchema>, null] | [null, Response]> {
144 | if (!refresh_token) {
145 | const eventId = logIssue("[oauth] Missing refresh token in token refresh", {
146 | oauth: {
147 | client_id,
148 | },
149 | });
150 | return [
151 | null,
152 | new Response("Invalid request: missing refresh token", {
153 | status: 400,
154 | headers: { "X-Event-ID": eventId ?? "" },
155 | }),
156 | ];
157 | }
158 |
159 | const resp = await fetch(upstream_url, {
160 | method: "POST",
161 | headers: {
162 | "Content-Type": "application/x-www-form-urlencoded",
163 | "User-Agent": "Sentry MCP Cloudflare",
164 | },
165 | body: new URLSearchParams({
166 | grant_type: "refresh_token",
167 | client_id,
168 | client_secret,
169 | refresh_token,
170 | }).toString(),
171 | });
172 |
173 | if (!resp.ok) {
174 | const eventId = logIssue(
175 | `[oauth] Failed to refresh access token: ${await resp.text()}`,
176 | {
177 | oauth: {
178 | client_id,
179 | },
180 | },
181 | );
182 | return [
183 | null,
184 | new Response(
185 | "There was an issue refreshing your access token. Please re-authenticate.",
186 | { status: 400, headers: { "X-Event-ID": eventId ?? "" } },
187 | ),
188 | ];
189 | }
190 |
191 | try {
192 | const body = await resp.json();
193 | const output = TokenResponseSchema.parse(body);
194 | return [output, null];
195 | } catch (e) {
196 | const eventId = logIssue(
197 | new Error("Failed to parse refresh token response", {
198 | cause: e,
199 | }),
200 | {
201 | oauth: {
202 | client_id,
203 | },
204 | },
205 | );
206 | return [
207 | null,
208 | new Response(
209 | "There was an issue refreshing your access token. Please re-authenticate.",
210 | { status: 500, headers: { "X-Event-ID": eventId ?? "" } },
211 | ),
212 | ];
213 | }
214 | }
215 |
216 | /**
217 | * Token exchange callback for handling Sentry OAuth token refreshes.
218 | */
219 | export async function tokenExchangeCallback(
220 | options: TokenExchangeCallbackOptions,
221 | env: {
222 | SENTRY_CLIENT_ID: string;
223 | SENTRY_CLIENT_SECRET: string;
224 | SENTRY_HOST?: string;
225 | },
226 | ): Promise<TokenExchangeCallbackResult | undefined> {
227 | // Only handle refresh_token grant type
228 | if (options.grantType !== "refresh_token") {
229 | return undefined; // No-op for other grant types
230 | }
231 |
232 | Sentry.setUser({ id: options.props.id });
233 |
234 | // Extract the refresh token from the stored props
235 | const currentRefreshToken = options.props.refreshToken;
236 | if (!currentRefreshToken) {
237 | logIssue("No refresh token available in stored props", {
238 | loggerScope: ["cloudflare", "oauth", "refresh"],
239 | });
240 |
241 | return undefined;
242 | }
243 |
244 | try {
245 | // If we have a cached upstream expiry, and there's ample time left,
246 | // avoid calling upstream to reduce unnecessary refreshes.
247 | // Mint a new provider token with the remaining TTL.
248 | const props = options.props as WorkerProps;
249 | const maybeExpiresAt = props.accessTokenExpiresAt;
250 | if (maybeExpiresAt && Number.isFinite(maybeExpiresAt)) {
251 | const remainingMs = maybeExpiresAt - Date.now();
252 | const SAFE_WINDOW_MS = 2 * 60 * 1000; // 2 minutes safety window
253 | if (remainingMs > SAFE_WINDOW_MS) {
254 | const remainingSec = Math.floor(remainingMs / 1000);
255 | return {
256 | newProps: { ...options.props },
257 | accessTokenTTL: remainingSec,
258 | };
259 | }
260 | }
261 |
262 | // Construct the upstream token URL for Sentry
263 | const upstreamTokenUrl = new URL(
264 | SENTRY_TOKEN_URL,
265 | `https://${env.SENTRY_HOST || "sentry.io"}`,
266 | ).href;
267 |
268 | // Use our refresh token function to get new tokens from Sentry
269 | const [tokenResponse, errorResponse] = await refreshAccessToken({
270 | client_id: env.SENTRY_CLIENT_ID,
271 | client_secret: env.SENTRY_CLIENT_SECRET,
272 | refresh_token: currentRefreshToken,
273 | upstream_url: upstreamTokenUrl,
274 | });
275 |
276 | if (errorResponse) {
277 | // Convert the Response to an Error for the OAuth provider
278 | const errorText = await errorResponse.text();
279 | throw new Error(
280 | `Failed to refresh upstream token in OAuth provider: ${errorText}`,
281 | );
282 | }
283 |
284 | if (!tokenResponse.refresh_token) {
285 | logIssue("[oauth] Upstream refresh response missing refresh_token", {
286 | loggerScope: ["cloudflare", "oauth", "refresh"],
287 | });
288 | return undefined;
289 | }
290 |
291 | // Return the updated props with new tokens and TTL
292 | return {
293 | // This updates ctx.props
294 | newProps: {
295 | ...options.props,
296 | accessToken: tokenResponse.access_token,
297 | refreshToken: tokenResponse.refresh_token,
298 | accessTokenExpiresAt: Date.now() + tokenResponse.expires_in * 1000,
299 | },
300 | accessTokenTTL: tokenResponse.expires_in,
301 | };
302 | } catch (error) {
303 | logIssue(error);
304 | throw new Error("Failed to refresh upstream token in OAuth provider", {
305 | cause: error,
306 | });
307 | }
308 | }
309 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/utils/url-utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Determines if a Sentry instance is SaaS or self-hosted based on the host.
3 | * @param host The Sentry host (e.g., "sentry.io" or "sentry.company.com")
4 | * @returns true if SaaS instance, false if self-hosted
5 | */
6 | export function isSentryHost(host: string): boolean {
7 | return host === "sentry.io" || host.endsWith(".sentry.io");
8 | }
9 |
10 | /**
11 | * Generates a Sentry issue URL.
12 | * @param host The Sentry host (may include regional subdomain for API access)
13 | * @param organizationSlug Organization identifier
14 | * @param issueId Issue identifier (e.g., "PROJECT-123")
15 | * @returns The complete issue URL
16 | */
17 | export function getIssueUrl(
18 | host: string,
19 | organizationSlug: string,
20 | issueId: string,
21 | ): string {
22 | const isSaas = isSentryHost(host);
23 | // For SaaS instances, always use sentry.io for web UI URLs regardless of region
24 | // Regional subdomains (e.g., us.sentry.io) are only for API endpoints
25 | const webHost = isSaas ? "sentry.io" : host;
26 | return isSaas
27 | ? `https://${organizationSlug}.${webHost}/issues/${issueId}`
28 | : `https://${host}/organizations/${organizationSlug}/issues/${issueId}`;
29 | }
30 |
31 | /**
32 | * Generates a Sentry issues search URL.
33 | * @param host The Sentry host (may include regional subdomain for API access)
34 | * @param organizationSlug Organization identifier
35 | * @param query Optional search query
36 | * @param projectSlugOrId Optional project slug or ID
37 | * @returns The complete issues search URL
38 | */
39 | export function getIssuesSearchUrl(
40 | host: string,
41 | organizationSlug: string,
42 | query?: string | null,
43 | projectSlugOrId?: string,
44 | ): string {
45 | const isSaas = isSentryHost(host);
46 | // For SaaS instances, always use sentry.io for web UI URLs regardless of region
47 | // Regional subdomains (e.g., us.sentry.io) are only for API endpoints
48 | const webHost = isSaas ? "sentry.io" : host;
49 | let url = isSaas
50 | ? `https://${organizationSlug}.${webHost}/issues/`
51 | : `https://${host}/organizations/${organizationSlug}/issues/`;
52 |
53 | const params = new URLSearchParams();
54 | if (projectSlugOrId) {
55 | params.append("project", projectSlugOrId);
56 | }
57 | if (query) {
58 | params.append("query", query);
59 | }
60 |
61 | const queryString = params.toString();
62 | if (queryString) {
63 | url += `?${queryString}`;
64 | }
65 |
66 | return url;
67 | }
68 |
69 | /**
70 | * Generates a Sentry trace URL for performance investigation.
71 | * @param host The Sentry host (may include regional subdomain for API access)
72 | * @param organizationSlug Organization identifier
73 | * @param traceId Trace identifier
74 | * @returns The complete trace URL
75 | */
76 | export function getTraceUrl(
77 | host: string,
78 | organizationSlug: string,
79 | traceId: string,
80 | ): string {
81 | const isSaas = isSentryHost(host);
82 | // For SaaS instances, always use sentry.io for web UI URLs regardless of region
83 | // Regional subdomains (e.g., us.sentry.io) are only for API endpoints
84 | const webHost = isSaas ? "sentry.io" : host;
85 | return isSaas
86 | ? `https://${organizationSlug}.${webHost}/explore/traces/trace/${traceId}`
87 | : `https://${host}/organizations/${organizationSlug}/explore/traces/trace/${traceId}`;
88 | }
89 |
90 | /**
91 | * Generates a Sentry events explorer URL.
92 | * @param host The Sentry host (may include regional subdomain for API access)
93 | * @param organizationSlug Organization identifier
94 | * @param query Search query
95 | * @param dataset Dataset type
96 | * @param projectSlug Optional project slug
97 | * @param fields Optional fields to display
98 | * @returns The complete events explorer URL
99 | */
100 | export function getEventsExplorerUrl(
101 | host: string,
102 | organizationSlug: string,
103 | query: string,
104 | dataset: "spans" | "errors" | "logs" = "spans",
105 | projectSlug?: string,
106 | fields?: string[],
107 | ): string {
108 | const isSaas = isSentryHost(host);
109 | // For SaaS instances, always use sentry.io for web UI URLs regardless of region
110 | // Regional subdomains (e.g., us.sentry.io) are only for API endpoints
111 | const webHost = isSaas ? "sentry.io" : host;
112 | let url = isSaas
113 | ? `https://${organizationSlug}.${webHost}/explore/`
114 | : `https://${host}/organizations/${organizationSlug}/explore/`;
115 |
116 | const params = new URLSearchParams();
117 | params.append("query", query);
118 | params.append("dataset", dataset);
119 | params.append("layout", "table");
120 |
121 | if (projectSlug) {
122 | params.append("project", projectSlug);
123 | }
124 |
125 | if (fields && fields.length > 0) {
126 | for (const field of fields) {
127 | params.append("field", field);
128 | }
129 | }
130 |
131 | url += `?${params.toString()}`;
132 | return url;
133 | }
134 |
135 | /**
136 | * Internal validation function that checks if a SENTRY_HOST value contains only hostname (no protocol).
137 | * Throws an error if validation fails instead of exiting the process.
138 | *
139 | * @param host The hostname to validate
140 | * @throws {Error} If the host contains a protocol
141 | */
142 | function _validateSentryHostInternal(host: string): void {
143 | if (host.startsWith("http://") || host.startsWith("https://")) {
144 | throw new Error(
145 | "SENTRY_HOST should only contain a hostname (e.g., sentry.example.com). Use SENTRY_URL if you want to provide a full URL.",
146 | );
147 | }
148 | }
149 |
150 | /**
151 | * Internal validation function that checks if a SENTRY_URL value is a valid HTTPS URL and extracts the hostname.
152 | * Throws an error if validation fails instead of exiting the process.
153 | *
154 | * @param url The HTTPS URL to validate and parse
155 | * @returns The extracted hostname from the URL
156 | * @throws {Error} If the URL is invalid or not HTTPS
157 | */
158 | function _validateAndParseSentryUrlInternal(url: string): string {
159 | if (!url.startsWith("https://")) {
160 | throw new Error(
161 | "SENTRY_URL must be a full HTTPS URL (e.g., https://sentry.example.com).",
162 | );
163 | }
164 |
165 | try {
166 | const parsedUrl = new URL(url);
167 | return parsedUrl.host;
168 | } catch (error) {
169 | throw new Error(
170 | "SENTRY_URL must be a valid HTTPS URL (e.g., https://sentry.example.com).",
171 | );
172 | }
173 | }
174 |
175 | /**
176 | * Validates that a SENTRY_HOST value contains only hostname (no protocol).
177 | * Exits the process with error code 1 if validation fails (CLI behavior).
178 | *
179 | * @param host The hostname to validate
180 | */
181 | export function validateSentryHost(host: string): void {
182 | try {
183 | _validateSentryHostInternal(host);
184 | } catch (error) {
185 | console.error(`Error: ${(error as Error).message}`);
186 | process.exit(1);
187 | }
188 | }
189 |
190 | /**
191 | * Validates that a SENTRY_URL value is a valid HTTPS URL and extracts the hostname.
192 | * Exits the process with error code 1 if validation fails (CLI behavior).
193 | *
194 | * @param url The HTTPS URL to validate and parse
195 | * @returns The extracted hostname from the URL
196 | */
197 | export function validateAndParseSentryUrl(url: string): string {
198 | try {
199 | return _validateAndParseSentryUrlInternal(url);
200 | } catch (error) {
201 | console.error(`Error: ${(error as Error).message}`);
202 | process.exit(1);
203 | }
204 | }
205 |
206 | /**
207 | * Validates that a SENTRY_HOST value contains only hostname (no protocol).
208 | * Throws an error instead of exiting the process (for testing).
209 | *
210 | * @param host The hostname to validate
211 | * @throws {Error} If the host contains a protocol
212 | */
213 | export function validateSentryHostThrows(host: string): void {
214 | _validateSentryHostInternal(host);
215 | }
216 |
217 | /**
218 | * Validates that a SENTRY_URL value is a valid HTTPS URL and extracts the hostname.
219 | * Throws an error instead of exiting the process (for testing).
220 | *
221 | * @param url The HTTPS URL to validate and parse
222 | * @returns The extracted hostname from the URL
223 | * @throws {Error} If the URL is invalid or not HTTPS
224 | */
225 | export function validateAndParseSentryUrlThrows(url: string): string {
226 | return _validateAndParseSentryUrlInternal(url);
227 | }
228 |
229 | /**
230 | * Validates that the provided OpenAI base URL is a valid HTTP(S) URL and returns a normalized string.
231 | *
232 | * @param url The URL to validate and normalize
233 | * @returns The normalized URL string
234 | * @throws {Error} If the URL is empty, invalid, or uses an unsupported protocol
235 | */
236 | export function validateOpenAiBaseUrlThrows(url: string): string {
237 | const trimmed = url.trim();
238 | if (trimmed.length === 0) {
239 | throw new Error("OPENAI base URL must not be empty.");
240 | }
241 |
242 | let parsed: URL;
243 | try {
244 | parsed = new URL(trimmed);
245 | } catch (error) {
246 | throw new Error(
247 | "OPENAI base URL must be a valid HTTP or HTTPS URL (e.g., https://example.com/v1).",
248 | { cause: error },
249 | );
250 | }
251 |
252 | if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
253 | throw new Error(
254 | "OPENAI base URL must use http or https scheme (e.g., https://example.com/v1).",
255 | );
256 | }
257 |
258 | // Preserve the exact path to support Azure or proxy endpoints that include version/path segments
259 | return parsed.toString();
260 | }
261 |
```
--------------------------------------------------------------------------------
/packages/mcp-server/src/utils/slug-validation.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from "vitest";
2 | import { z } from "zod";
3 | import { validateSlug, validateSlugOrId, isNumericId } from "./slug-validation";
4 |
5 | describe("slug-validation", () => {
6 | describe("validateSlug", () => {
7 | it("should accept valid slugs", () => {
8 | const validSlugs = [
9 | "my-project",
10 | "my_project",
11 | "myproject",
12 | "my.project",
13 | "project123",
14 | "123project",
15 | "a",
16 | "a-b-c-d-e-f",
17 | "test_123.abc-def",
18 | ];
19 |
20 | for (const slug of validSlugs) {
21 | const schema = z.string().superRefine(validateSlug);
22 | expect(() => schema.parse(slug)).not.toThrow();
23 | }
24 | });
25 |
26 | it("should reject path traversal patterns", () => {
27 | const dangerousSlugs = [
28 | "..",
29 | "../",
30 | "./..",
31 | "../../",
32 | "../../../",
33 | "..\\",
34 | "..%2f",
35 | "%2e%2e",
36 | "%252e%252e",
37 | "..%252f",
38 | "%2e%2e%2f",
39 | "%252e%252e%252f",
40 | "..%5c",
41 | "%2e%2e%5c",
42 | "%252e%252e%5c",
43 | "my-project/..",
44 | "../my-project",
45 | "my/../project",
46 | "..-auth",
47 | "../../..-welcome",
48 | ];
49 |
50 | for (const slug of dangerousSlugs) {
51 | const schema = z.string().superRefine(validateSlug);
52 | expect(() => schema.parse(slug)).toThrow(/alphanumeric/i);
53 | }
54 | });
55 |
56 | it("should reject URL-encoded characters", () => {
57 | const encodedSlugs = [
58 | "my%20project",
59 | "project%2Ftest",
60 | "%3Cscript%3E",
61 | "test%00null",
62 | ];
63 |
64 | for (const slug of encodedSlugs) {
65 | const schema = z.string().superRefine(validateSlug);
66 | expect(() => schema.parse(slug)).toThrow(/alphanumeric/i);
67 | }
68 | });
69 |
70 | it("should reject dangerous special characters", () => {
71 | const dangerousChars = [
72 | "my/project",
73 | "my\\project",
74 | "my?project",
75 | "my#project",
76 | "my&project",
77 | "my=project",
78 | "my;project",
79 | "my:project",
80 | "my@project",
81 | "my$project",
82 | "my,project",
83 | "my<project>",
84 | 'my"project"',
85 | "my'project'",
86 | "my`project`",
87 | "my{project}",
88 | "my[project]",
89 | "my|project",
90 | "my^project",
91 | "my~project",
92 | "my\tproject",
93 | "my\nproject",
94 | "my\rproject",
95 | ];
96 |
97 | for (const slug of dangerousChars) {
98 | const schema = z.string().superRefine(validateSlug);
99 | // Some characters are caught as path traversal (e.g., '/'), others as invalid characters
100 | expect(() => schema.parse(slug)).toThrow();
101 | }
102 | });
103 |
104 | it("should reject slugs exceeding maximum length", () => {
105 | const longSlug = "a".repeat(101);
106 | const schema = z.string().superRefine(validateSlug);
107 | expect(() => schema.parse(longSlug)).toThrow(/exceeds maximum length/i);
108 | });
109 |
110 | it("should reject slugs not matching valid pattern", () => {
111 | const invalidPatterns = [
112 | "-startwithdash",
113 | "_startwithunderscore",
114 | ".startwithdot",
115 | "has spaces",
116 | "has\ttabs",
117 | "",
118 | ];
119 |
120 | for (const slug of invalidPatterns) {
121 | const schema = z.string().superRefine(validateSlug);
122 | expect(() => schema.parse(slug)).toThrow();
123 | }
124 | });
125 | });
126 |
127 | describe("validateSlugOrId", () => {
128 | it("should accept valid numeric IDs", () => {
129 | const validIds = [
130 | "1",
131 | "123",
132 | "456789",
133 | "12345678901234567890", // 20 chars - max length
134 | ];
135 |
136 | for (const id of validIds) {
137 | const schema = z.string().superRefine(validateSlugOrId);
138 | expect(() => schema.parse(id)).not.toThrow();
139 | }
140 | });
141 |
142 | it("should reject numeric IDs that are too long", () => {
143 | const longId = "1".repeat(21);
144 | const schema = z.string().superRefine(validateSlugOrId);
145 | expect(() => schema.parse(longId)).toThrow(/exceeds maximum length/i);
146 | });
147 |
148 | it("should accept valid slugs", () => {
149 | const validSlugs = [
150 | "my-project",
151 | "my_project",
152 | "myproject",
153 | "project123",
154 | ];
155 |
156 | for (const slug of validSlugs) {
157 | const schema = z.string().superRefine(validateSlugOrId);
158 | expect(() => schema.parse(slug)).not.toThrow();
159 | }
160 | });
161 |
162 | it("should reject path traversal in slugs but not numeric IDs", () => {
163 | // Should reject path traversal in slugs
164 | const schema = z.string().superRefine(validateSlugOrId);
165 | expect(() => schema.parse("../project")).toThrow(/alphanumeric/i);
166 | expect(() => schema.parse("..-auth")).toThrow(/alphanumeric/i);
167 |
168 | // Should accept numeric IDs even if they contain patterns that would be dangerous in slugs
169 | // (numeric IDs can't contain these patterns anyway since they're only digits)
170 | expect(() => schema.parse("123456")).not.toThrow();
171 | });
172 | });
173 |
174 | describe("integration with ParamOrganizationSlug", () => {
175 | it("should validate organization slugs with transformations", () => {
176 | // Import the actual param schema to test integration
177 | const ParamOrganizationSlug = z
178 | .string()
179 | .toLowerCase()
180 | .trim()
181 | .superRefine(validateSlug);
182 |
183 | // Should transform and validate
184 | expect(ParamOrganizationSlug.parse(" MY-ORG ")).toBe("my-org");
185 | expect(ParamOrganizationSlug.parse("MY_ORG")).toBe("my_org");
186 |
187 | // Should reject dangerous patterns after transformation
188 | expect(() => ParamOrganizationSlug.parse(" ../MY-ORG ")).toThrow(
189 | /alphanumeric/i,
190 | );
191 | expect(() => ParamOrganizationSlug.parse("..-auth")).toThrow(
192 | /alphanumeric/i,
193 | );
194 | });
195 | });
196 |
197 | describe("isNumericId", () => {
198 | it("should correctly identify numeric IDs", () => {
199 | expect(isNumericId("123")).toBe(true);
200 | expect(isNumericId("0")).toBe(true);
201 | expect(isNumericId("999999999999999")).toBe(true);
202 |
203 | expect(isNumericId("abc")).toBe(false);
204 | expect(isNumericId("123abc")).toBe(false);
205 | expect(isNumericId("")).toBe(false);
206 | expect(isNumericId("12.34")).toBe(false);
207 | });
208 | });
209 |
210 | describe("edge cases", () => {
211 | it("should reject empty strings", () => {
212 | const schema = z.string().superRefine(validateSlug);
213 | expect(() => schema.parse("")).toThrow(/empty/i);
214 | });
215 |
216 | it("should reject null bytes", () => {
217 | const schema = z.string().superRefine(validateSlug);
218 | expect(() => schema.parse("my\0project")).toThrow(/alphanumeric/i);
219 | expect(() => schema.parse("\0")).toThrow(/alphanumeric/i);
220 | });
221 |
222 | it("should handle case sensitivity correctly", () => {
223 | const schema = z.string().toLowerCase().trim().superRefine(validateSlug);
224 |
225 | // Should normalize and then validate
226 | expect(() => schema.parse("..AUTH")).toThrow(/alphanumeric/i);
227 | expect(() => schema.parse("../AUTH")).toThrow(/alphanumeric/i);
228 | expect(() => schema.parse("%2E%2E")).toThrow(/alphanumeric/i);
229 | });
230 |
231 | it("should handle very long inputs efficiently", () => {
232 | const longSlug = "a".repeat(1000);
233 | const schema = z.string().superRefine(validateSlug);
234 |
235 | const start = Date.now();
236 | expect(() => schema.parse(longSlug)).toThrow(/exceeds maximum length/i);
237 | const duration = Date.now() - start;
238 |
239 | // Should fail quickly without processing entire string
240 | expect(duration).toBeLessThan(10); // milliseconds
241 | });
242 | });
243 |
244 | describe("real-world attack vectors", () => {
245 | it("should block the reported vulnerability examples", () => {
246 | const schema = z.string().superRefine(validateSlug);
247 |
248 | // From the vulnerability report
249 | expect(() => schema.parse("..-auth")).toThrow(/alphanumeric/i);
250 | expect(() => schema.parse("../../..-welcome")).toThrow(/alphanumeric/i);
251 |
252 | // Variations
253 | expect(() => schema.parse("valid/..-auth")).toThrow(/alphanumeric/i);
254 | expect(() => schema.parse("..-auth/valid")).toThrow(/alphanumeric/i);
255 | });
256 |
257 | it("should handle encoded variations", () => {
258 | const schema = z.string().superRefine(validateSlug);
259 |
260 | // URL encoded dots
261 | expect(() => schema.parse("%2e%2e-auth")).toThrow(/alphanumeric/i);
262 | expect(() => schema.parse("%252e%252e-auth")).toThrow(/alphanumeric/i);
263 |
264 | // Mixed encoding - path traversal is caught first if literal ".." exists
265 | expect(() => schema.parse("..%2fauth")).toThrow(/alphanumeric/i);
266 | expect(() => schema.parse("%2e.%2fauth")).toThrow(/alphanumeric/i);
267 | });
268 | });
269 | });
270 |
```
--------------------------------------------------------------------------------
/packages/mcp-cloudflare/src/server/oauth/helpers.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from "vitest";
2 | import type { TokenExchangeCallbackOptions } from "@cloudflare/workers-oauth-provider";
3 | import { tokenExchangeCallback, refreshAccessToken } from "./helpers";
4 | import type { WorkerProps } from "../types";
5 |
6 | // Mock fetch globally
7 | const mockFetch = vi.fn();
8 | global.fetch = mockFetch;
9 |
10 | describe("tokenExchangeCallback", () => {
11 | const mockEnv = {
12 | SENTRY_CLIENT_ID: "test-client-id",
13 | SENTRY_CLIENT_SECRET: "test-client-secret",
14 | SENTRY_HOST: "sentry.io",
15 | };
16 |
17 | beforeEach(() => {
18 | vi.clearAllMocks();
19 | });
20 |
21 | it("should skip non-refresh_token grant types", async () => {
22 | const options: TokenExchangeCallbackOptions = {
23 | grantType: "authorization_code",
24 | clientId: "test-client-id",
25 | userId: "test-user-id",
26 | scope: ["org:read", "project:read"],
27 | props: {} as WorkerProps,
28 | };
29 |
30 | const result = await tokenExchangeCallback(options, mockEnv);
31 | expect(result).toBeUndefined();
32 | expect(mockFetch).not.toHaveBeenCalled();
33 | });
34 |
35 | it("should return undefined when no refresh token in props", async () => {
36 | const options: TokenExchangeCallbackOptions = {
37 | grantType: "refresh_token",
38 | clientId: "test-client-id",
39 | userId: "test-user-id",
40 | scope: ["org:read", "project:read"],
41 | props: {
42 | id: "user-id",
43 | clientId: "test-client-id",
44 | scope: "org:read project:read",
45 | accessToken: "old-access-token",
46 | // No refreshToken
47 | } as WorkerProps,
48 | };
49 |
50 | await expect(
51 | tokenExchangeCallback(options, mockEnv),
52 | ).resolves.toBeUndefined();
53 | expect(mockFetch).not.toHaveBeenCalled();
54 | });
55 |
56 | it("should reuse cached token when it has sufficient TTL remaining", async () => {
57 | const futureExpiry = Date.now() + 10 * 60 * 1000; // 10 minutes from now
58 | const options: TokenExchangeCallbackOptions = {
59 | grantType: "refresh_token",
60 | clientId: "test-client-id",
61 | userId: "test-user-id",
62 | scope: ["org:read", "project:read"],
63 | props: {
64 | id: "user-id",
65 | clientId: "test-client-id",
66 | scope: "org:read project:read",
67 | accessToken: "cached-access-token",
68 | refreshToken: "refresh-token",
69 | accessTokenExpiresAt: futureExpiry,
70 | } as WorkerProps,
71 | };
72 |
73 | const result = await tokenExchangeCallback(options, mockEnv);
74 |
75 | // Should not call upstream API
76 | expect(mockFetch).not.toHaveBeenCalled();
77 |
78 | // Should return existing props with calculated TTL
79 | expect(result).toBeDefined();
80 | expect(result?.newProps).toEqual(options.props);
81 | expect(result?.accessTokenTTL).toBeGreaterThan(0);
82 | expect(result?.accessTokenTTL).toBeLessThanOrEqual(600); // Max 10 minutes
83 | });
84 |
85 | it("should refresh token when cached token is close to expiry", async () => {
86 | const nearExpiry = Date.now() + 1 * 60 * 1000; // 1 minute from now (less than 2 min safety window)
87 | const options: TokenExchangeCallbackOptions = {
88 | grantType: "refresh_token",
89 | clientId: "test-client-id",
90 | userId: "test-user-id",
91 | scope: ["org:read", "project:read"],
92 | props: {
93 | id: "user-id",
94 | clientId: "test-client-id",
95 | scope: "org:read project:read",
96 | accessToken: "old-access-token",
97 | refreshToken: "old-refresh-token",
98 | accessTokenExpiresAt: nearExpiry,
99 | } as WorkerProps,
100 | };
101 |
102 | // Mock successful refresh response
103 | mockFetch.mockResolvedValueOnce({
104 | ok: true,
105 | json: async () => ({
106 | access_token: "new-access-token",
107 | refresh_token: "new-refresh-token",
108 | expires_in: 3600,
109 | expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
110 | token_type: "bearer",
111 | user: {
112 | id: "user-id",
113 | name: "Test User",
114 | email: "[email protected]",
115 | },
116 | scope: "org:read project:read",
117 | }),
118 | });
119 |
120 | const result = await tokenExchangeCallback(options, mockEnv);
121 |
122 | // Should call upstream API
123 | expect(mockFetch).toHaveBeenCalledWith(
124 | "https://sentry.io/oauth/token/",
125 | expect.objectContaining({
126 | method: "POST",
127 | headers: expect.objectContaining({
128 | "Content-Type": "application/x-www-form-urlencoded",
129 | }),
130 | body: expect.stringContaining("grant_type=refresh_token"),
131 | }),
132 | );
133 |
134 | // Should return updated props with new tokens
135 | expect(result).toBeDefined();
136 | expect(result?.newProps).toMatchObject({
137 | accessToken: "new-access-token",
138 | refreshToken: "new-refresh-token",
139 | accessTokenExpiresAt: expect.any(Number),
140 | });
141 | expect(result?.accessTokenTTL).toBe(3600);
142 | });
143 |
144 | it("should refresh token when no cached expiry exists", async () => {
145 | const options: TokenExchangeCallbackOptions = {
146 | grantType: "refresh_token",
147 | clientId: "test-client-id",
148 | userId: "test-user-id",
149 | scope: ["org:read", "project:read"],
150 | props: {
151 | id: "user-id",
152 | clientId: "test-client-id",
153 | scope: "org:read project:read",
154 | accessToken: "old-access-token",
155 | refreshToken: "old-refresh-token",
156 | // No accessTokenExpiresAt
157 | } as WorkerProps,
158 | };
159 |
160 | // Mock successful refresh response
161 | mockFetch.mockResolvedValueOnce({
162 | ok: true,
163 | json: async () => ({
164 | access_token: "new-access-token",
165 | refresh_token: "new-refresh-token",
166 | expires_in: 3600,
167 | expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
168 | token_type: "bearer",
169 | user: {
170 | id: "user-id",
171 | name: "Test User",
172 | email: "[email protected]",
173 | },
174 | scope: "org:read project:read",
175 | }),
176 | });
177 |
178 | const result = await tokenExchangeCallback(options, mockEnv);
179 |
180 | // Should call upstream API
181 | expect(mockFetch).toHaveBeenCalled();
182 |
183 | // Should return updated props
184 | expect(result?.newProps).toMatchObject({
185 | accessToken: "new-access-token",
186 | refreshToken: "new-refresh-token",
187 | accessTokenExpiresAt: expect.any(Number),
188 | });
189 | });
190 |
191 | it("should throw error when upstream refresh fails", async () => {
192 | const options: TokenExchangeCallbackOptions = {
193 | grantType: "refresh_token",
194 | clientId: "test-client-id",
195 | userId: "test-user-id",
196 | scope: ["org:read", "project:read"],
197 | props: {
198 | id: "user-id",
199 | clientId: "test-client-id",
200 | scope: "org:read project:read",
201 | accessToken: "old-access-token",
202 | refreshToken: "invalid-refresh-token",
203 | } as WorkerProps,
204 | };
205 |
206 | // Mock failed refresh response
207 | mockFetch.mockResolvedValueOnce({
208 | ok: false,
209 | text: async () => "Invalid refresh token",
210 | });
211 |
212 | await expect(tokenExchangeCallback(options, mockEnv)).rejects.toThrow(
213 | "Failed to refresh upstream token in OAuth provider",
214 | );
215 | });
216 | });
217 |
218 | describe("refreshAccessToken", () => {
219 | beforeEach(() => {
220 | vi.clearAllMocks();
221 | });
222 |
223 | it("should successfully refresh access token", async () => {
224 | mockFetch.mockResolvedValueOnce({
225 | ok: true,
226 | json: async () => ({
227 | access_token: "new-access-token",
228 | refresh_token: "new-refresh-token",
229 | expires_in: 3600,
230 | expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
231 | token_type: "bearer",
232 | user: {
233 | id: "user-id",
234 | name: "Test User",
235 | email: "[email protected]",
236 | },
237 | scope: "org:read project:read",
238 | }),
239 | });
240 |
241 | const [result, error] = await refreshAccessToken({
242 | client_id: "test-client",
243 | client_secret: "test-secret",
244 | refresh_token: "valid-refresh-token",
245 | upstream_url: "https://sentry.io/oauth/token/",
246 | });
247 |
248 | expect(error).toBeNull();
249 | expect(result).toMatchObject({
250 | access_token: "new-access-token",
251 | refresh_token: "new-refresh-token",
252 | expires_in: 3600,
253 | });
254 | });
255 |
256 | it("should return error when refresh token is missing", async () => {
257 | const [result, error] = await refreshAccessToken({
258 | client_id: "test-client",
259 | client_secret: "test-secret",
260 | refresh_token: undefined,
261 | upstream_url: "https://sentry.io/oauth/token/",
262 | });
263 |
264 | expect(result).toBeNull();
265 | expect(error).toBeDefined();
266 | expect(error?.status).toBe(400);
267 | const text = await error?.text();
268 | expect(text).toBe("Invalid request: missing refresh token");
269 | });
270 |
271 | it("should return error when upstream returns non-OK status", async () => {
272 | mockFetch.mockResolvedValueOnce({
273 | ok: false,
274 | text: async () => "Invalid token",
275 | });
276 |
277 | const [result, error] = await refreshAccessToken({
278 | client_id: "test-client",
279 | client_secret: "test-secret",
280 | refresh_token: "invalid-token",
281 | upstream_url: "https://sentry.io/oauth/token/",
282 | });
283 |
284 | expect(result).toBeNull();
285 | expect(error).toBeDefined();
286 | expect(error?.status).toBe(400);
287 | const text = await error?.text();
288 | expect(text).toContain("issue refreshing your access token");
289 | });
290 | });
291 |
```