Skip to content

ref(mcp): Send Sentry API requests through the shared transport - #1722

Merged
BYK merged 3 commits into
mainfrom
feat/mcp-transport-injection
Oct 29, 2026
Merged

BYK merged 3 commits into
mainfrom
feat/mcp-transport-injection

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

This PR sends every MCP request to the Sentry API through the shared transport, and moves retries out of SentryApiService into it. It is task 6.1 of #1669 (unify-sentry-api-layer) and builds on the shared transport (#1719) and the fresh-read hook (#1720).

What changed

  • One transport: SentryApiService sends every request through createApiTransport from @sentry/toolkit-core/api-transport, using the MCP's existing REQUEST_RETRY_POLICY. That includes @sentry/api operations, which already go through request().
  • One retry layer: SentryApiService no longer has its own retry loop or network-failure bookkeeping. The shared transport retries a 429 for any method, and a 500/502/503/504 or a network failure for GET and HEAD. SentryApiService still parses the final error response, so ApiRateLimitError.retryAfterMs is still read from Retry-After or X-Sentry-Rate-Limit-Reset.
  • Transport options: credentials and timeoutMs on createApiTransport are now optional. Without a CredentialProvider, the transport keeps the Authorization header the request already has, never refreshes, and skips the cache, because cache entries are tied to a credential. The MCP uses it this way, and still sets no timeout, as before.
  • Abort reason: an abort while a request is in flight now rejects with the caller's signal.reason, instead of being treated as a retryable network failure.

An earlier revision also added a host-supplied fetch option to McpServerOptions and ServerContext. That is gone: per the revised #1669 plan, the shared API layer moves into toolkit-core and then @sentry/api, and the CLI passing its fetch to sentry mcp is deferred.

Intentional behavior changes

  • allowStatuses: a status a caller lists there is now returned only after the transport has finished retrying it. Before, it came back on the first attempt. Today's callers only list 404 and 400, which are never retried, so they see no difference.
  • Abort during a retry wait: if the caller's signal aborts while the transport is waiting to retry, the abort error is rethrown as-is. It is no longer wrapped as a connection failure.

Relation to #1669

Verification

  • New mcp-core tests:

    • A GET that is rate-limited on every attempt reaches the API at most maxRetries + 1 times.
    • An in-flight abort rejects with the caller's reason, after one attempt.
  • New toolkit-core tests:

    • Without credentials, the request's own Authorization is kept.
    • Without credentials, the cache is never consulted.
    • An in-flight abort keeps the caller's reason.
  • Suites:

    Package Result
    toolkit-core 273 pass
    mcp-core 1,899 pass, 6 skipped
    mcp-server 87 pass
    mcp-cloudflare 443 pass
  • tsc --noEmit passes for toolkit-core, mcp-core, mcp-server, mcp-cloudflare, and cli. oxlint and oxfmt --check are clean on the changed files.

  • Test changes:

    • Hand-built response mocks in client.test.ts gained a status, because the transport reads it to decide on a retry.
    • Header assertions in client.test.ts and mcp-handler.test.ts now read through Headers, because the transport passes a Headers object.

via BYK.

--

View Junior Session in Sentry

@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
sentry-local Ready Ready Preview Oct 29, 2026 4:38pm UTC

Request Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b3e98f3. Configure here.

Comment thread packages/mcp-core/src/api-client/client.ts
sentry-junior Bot and others added 3 commits October 29, 2026 16:31
Add `fetch` to McpServerOptions and ServerContext. SentryApiService sends
every request through it, including @sentry/api operations, so a host can
supply its authenticated transport. Without one, requests go through the
shared @sentry/toolkit-core/api-transport with REQUEST_RETRY_POLICY.

SentryApiService drops its own retry loop, so retries happen in one layer
and do not stack. ApiRateLimitError still takes retryAfterMs from the final
response's headers, and an abort during a retry wait keeps the caller's
reason.

The transport's credentials and timeouts become optional: without a
CredentialProvider it keeps the request's Authorization header, never
refreshes, and skips the cache.

Implements #1669 task 6.1.

Co-Authored-By: BYK <burak.kaya@sentry.io>
The transport aborted its per-attempt controller without the caller's
reason, so an in-flight fetch rejected with a generic AbortError. Forward
signal.reason, and treat a rejection with that reason as the caller's
abort rather than a retryable network failure.

Co-Authored-By: BYK <burak.kaya@sentry.io>
The shared API layer moves into toolkit-core and then @sentry/api, so the
MCP no longer needs a host fetch hook. SentryApiService keeps sending every
request through the shared transport with no retry loop of its own.

Co-Authored-By: BYK <burak.kaya@sentry.io>
@sentry-junior
sentry-junior Bot force-pushed the feat/mcp-transport-injection branch from 3e4d8d0 to 0a3ca36 Compare October 29, 2026 16:36
@sentry-junior sentry-junior Bot changed the title feat(mcp): Route Sentry API requests through an injected fetch ref(mcp): Send Sentry API requests through the shared transport Oct 29, 2026
@BYK
BYK merged commit 3e7198d into main Oct 29, 2026
36 checks passed
@BYK
BYK deleted the feat/mcp-transport-injection branch October 29, 2026 19:19
sentry-junior Bot added a commit that referenced this pull request Oct 29, 2026
Group 8 now ships as a stack split by layer: the foundation (#1732),
the transport-layer move, endpoint wrappers with the MCP switch, the
CLI host seam and switch, then merged shared wrappers. 6.1 is done in
#1722.

Co-Authored-By: BYK <burak.kaya@sentry.io>
BYK pushed a commit that referenced this pull request Oct 29, 2026
…se reading (#1732)

Starts the shared Sentry API client in `toolkit-core/src/api-client/`
(step 1 of #1669, task group 8). The MCP and the CLI both use it for
errors, API paths, and response reading.

It is the first of a stack of PRs by layer (see "Next" below).

## What changes

- **Portability guard (8.1).** Lint bans `node:*` imports and Node
globals such as `Buffer` and `process` in `toolkit-core`. A second
typecheck (`tsconfig.browser.json`) checks it against the DOM library
with no Node types, and `pnpm run tsc` runs both. The `node:zlib` cache
codec is the one exception. Its types move to `response-cache/body.ts`,
so the policy and store modules stay portable.
- **Standard Schema validation.** `validateResponse` takes any Standard
Schema validator: Zod from the MCP, valibot from the CLI. `toolkit-core`
depends only on `@standard-schema/spec` types. Lint bans `zod` and
`valibot` imports in its source; they are dev dependencies for the tests
only.
- **Shared errors and `apiPath`.** `ApiError`, its status subclasses,
`createApiError`, `UserInputError`, `ConfigurationError`, and `apiPath`
move from `mcp-core` to `@sentry/toolkit-core/api-client/*`.
`mcp-core/src/errors.ts` re-exports the two general errors, so its
importers don't change.
- **Shared response reading.** `readErrorResponse` reads a failed
response once: its text, parsed JSON, string `detail`, whether it is an
HTML page, and the rate-limit wait. `readJsonResponse` is the MCP's
Content-Type-checked JSON parse.
  - `SentryApiService` uses both.
  - The CLI's `throwRawApiError` uses `readErrorResponse`.
- Each product still maps the result to its own errors (MCP `ApiError`
subclasses, CLI exit-coded `ApiError`).
- **mcp-core builds ESM only.** Once the errors moved, the CJS build hit
a rolldown "canonical name not found" panic. Nothing loads that CJS
output:
  - `@sentry/mcp-core` is `private` and `"type": "module"`.
  - All 29 of its exports point at `.js`, with no `require` condition.
- Every consumer is in the workspace: `mcp-server` bundles it as a
devDependency through those `.js` exports, `mcp-cloudflare` bundles it
with Vite, and `mcp-test-client` and `mcp-server-evals` are private ESM
packages.
  - No source requires it or reads its `.cjs` files.

`@sentry/mcp-server`'s own CJS and ESM output is unchanged. I tried
upgrading tsdown first, to 0.15.12, 0.18.4, 0.20.3, 0.22.14, and 0.23.4.
Every version broke the `mcp-server-mocks` build: 0.15.12 failed
declaration output (`members is not iterable`), and 0.18.4 and later hit
a rolldown JSON parse "recursion limit exceeded" on
`src/fixtures/trace.json`. 0.18.4 and 0.20.3 also failed the `mcp-core`
and `mcp-server` builds. Bundling toolkit-core into the dist
(`noExternal`) would have duplicated the error classes across dists and
broken `instanceof` between packages.
- **Docs.** The `toolkit-core` README now has an "API client" section.
`api-patterns.md`, `error-handling.md`, and the logging skill point at
the new error module.

## Behavior changes

- **MCP, unreadable error body.** If the body of a failed response
cannot be read, the caller now gets an `ApiError` for the status instead
of the raw stream error.
- **MCP, "no string detail" warning.** The `validationErrorMessage` on
the "Failed to parse validated API error response" warning is now a
fixed string instead of Zod's message. `ApiErrorSchema` is deleted;
nothing else used it.

Messages, details, `retryAfterMs`, and the CLI's error detail are
otherwise unchanged.

## Next

This is the bottom of a stack of PRs by layer for the rest of #1669
group 8. Each one is based on the PR below it:

1. Move pagination, cache policy, retry, and transport under
`api-client/`.
2. Shared endpoint wrappers on top of `@sentry/api`, with
`SentryApiService` switched onto them.
3. The CLI host seam, with the CLI's `lib/api/` wrappers switched onto
the shared client.
4. Merge the wrappers both products share.

This PR does not touch `packages/mcp-server`,
`packages/cli/src/lib/mcp.ts`, or the evals stdio entry; the
`@sentry/mcp-server` retirement PR owns those.

## Commits

Each codemod run is a generated commit whose message gives the command,
and each hand fix is a separate commit:

1. `13053adab` Portability guard (hand)
2. `f13df0d24` Standard Schema validation (hand)
3. `bea21d648` mcp-core ESM only (hand)
4. `0d083bdf3` Move errors and `apiPath` (**generated**,
`move-shared-errors.mjs`)
5. `4ddc54813` Move the `client.ts` request-path test back next to
`client.ts` (hand)
6. `143da6e39` Shared response reading (hand)
7. `b776eb55b` MCP JSON parsing on the shared reader (**generated**,
`use-shared-json-response.mjs`)
8. `44976334d` MCP error responses on the shared reader (hand)
9. `7027a482d` CLI raw error responses on the shared reader (hand)
10. `bf0b248ac` Docs (hand)

## Codemods

The scripts are not in the tree. To re-run one, check out the parent of
its generated commit, run the script from a scratch directory with
`ts-morph@28.0.0` installed, then run oxfmt on the changed files. The
output should match the commit.

<details>
<summary><code>move-shared-errors.mjs</code> (commit
0d083bd)</summary>

```sh
node move-shared-errors.mjs <repo-root>
oxfmt <added and modified files>
```

```js
// Codemod: move the MCP's API error classes and API path helper into
// toolkit-core's shared api-client, and rewrite every importer.
//
// Usage: node move-shared-errors.mjs <toolkit-repo-root>
// (run from a directory where `ts-morph@28.0.0` is installed)
import { realpathSync } from "node:fs";
import path from "node:path";
import { Project } from "ts-morph";

const root = path.resolve(process.argv[2] ?? ".");
const mcpSrc = path.join(root, "packages/mcp-core/src");
const coreSrc = path.join(root, "packages/toolkit-core/src");

// Classes the shared client throws, moved out of mcp-core's general errors.
const MOVED_CLASSES = ["UserInputError", "ConfigurationError"];
// mcp-core files that move into toolkit-core/src/api-client/.
const MOVED_FILES = [
  "api-client/errors.ts",
  "api-client/api-path.ts",
  "api-client/api-path.test.ts",
];

const project = new Project({
  tsConfigFilePath: path.join(root, "packages/mcp-core/tsconfig.json"),
});
project.addSourceFilesAtPaths(path.join(coreSrc, "**/*.ts"));

// 1. Move the classes into the API errors module; mcp-core/src/errors.ts
//    re-exports them so its existing importers keep working.
const generalErrors = project.getSourceFileOrThrow(
  path.join(mcpSrc, "errors.ts"),
);
const apiErrors = project.getSourceFileOrThrow(
  path.join(mcpSrc, "api-client/errors.ts"),
);
const classTexts = MOVED_CLASSES.map((name) => {
  const declaration = generalErrors.getClassOrThrow(name);
  const text = declaration.getFullText().trim();
  declaration.remove();
  return text;
});
apiErrors.insertText(0, `${classTexts.join("\n\n")}\n\n`);
generalErrors.insertExportDeclaration(0, {
  namedExports: [...MOVED_CLASSES].sort(),
  moduleSpecifier: "./api-client/errors",
});

// 2. Move the files. ts-morph rewrites relative imports in and to them.
const movedPaths = new Set();
for (const file of MOVED_FILES) {
  const sourceFile = project.getSourceFileOrThrow(path.join(mcpSrc, file));
  sourceFile.move(path.join(coreSrc, "api-client", path.basename(file)));
  movedPaths.add(sourceFile.getFilePath());
}

// Moved files take the moved classes from the API errors module directly,
// not through mcp-core's re-export.
for (const movedPath of movedPaths) {
  const sourceFile = project.getSourceFileOrThrow(movedPath);
  for (const declaration of sourceFile.getImportDeclarations()) {
    if (declaration.getModuleSpecifierSourceFile() !== generalErrors) continue;
    const names = declaration.getNamedImports().map((n) => n.getName());
    if (names.every((name) => MOVED_CLASSES.includes(name))) {
      declaration.setModuleSpecifier(apiErrors);
    }
  }
}

// 3. Normalize specifiers that point into toolkit-core: relative with `.js`
//    inside toolkit-core, `@sentry/toolkit-core/<path>` everywhere else.
function realPath(file) {
  try {
    return realpathSync(file);
  } catch {
    return file;
  }
}

for (const sourceFile of project.getSourceFiles()) {
  const importer = sourceFile.getFilePath();
  const declarations = [
    ...sourceFile.getImportDeclarations(),
    ...sourceFile.getExportDeclarations(),
  ];
  for (const declaration of declarations) {
    const target = declaration.getModuleSpecifierSourceFile();
    if (!target) continue;
    const targetPath = realPath(target.getFilePath());
    if (!targetPath.startsWith(`${coreSrc}/`)) continue;
    // Leave specifiers the codemod did not affect as they are.
    if (!movedPaths.has(importer) && !movedPaths.has(targetPath)) continue;
    const withoutExt = targetPath.replace(/\.ts$/, "");
    let specifier;
    if (importer.startsWith(`${coreSrc}/`)) {
      specifier = path.relative(path.dirname(importer), withoutExt);
      if (!specifier.startsWith(".")) specifier = `./${specifier}`;
      specifier = `${specifier}.js`;
    } else {
      specifier = `@sentry/toolkit-core/${path
        .relative(coreSrc, withoutExt)
        .replace(/\/index$/, "")}`;
    }
    if (declaration.getModuleSpecifierValue() !== specifier) {
      declaration.setModuleSpecifier(specifier);
    }
  }
}

await project.save();
```

</details>

<details>
<summary><code>use-shared-json-response.mjs</code> (commit
b776eb5)</summary>

```sh
node use-shared-json-response.mjs <repo-root>
oxfmt packages/mcp-core/src/api-client/client.ts
```

```js
// Codemod: replace SentryApiService's private parseJsonResponse with the
// shared readJsonResponse from toolkit-core, and delete the private copy.
//
// Usage: node use-shared-json-response.mjs <toolkit-repo-root>
// (run from a directory where `ts-morph@28.0.0` is installed)
import path from "node:path";
import { Project, SyntaxKind } from "ts-morph";

const root = path.resolve(process.argv[2] ?? ".");
const clientPath = path.join(root, "packages/mcp-core/src/api-client/client.ts");

const project = new Project({ skipAddingFilesFromTsConfig: true });
const client = project.addSourceFileAtPath(clientPath);
const service = client.getClassOrThrow("SentryApiService");

// 1. `this.parseJsonResponse(x)` -> `readJsonResponse(x)`.
const calls = client
  .getDescendantsOfKind(SyntaxKind.CallExpression)
  .filter((call) => call.getExpression().getText() === "this.parseJsonResponse")
  .reverse();
for (const call of calls) {
  call.getExpression().replaceWithText("readJsonResponse");
}

// 2. Delete the private method it replaces.
service.getMethodOrThrow("parseJsonResponse").remove();

// 3. Import the shared helper.
client.addImportDeclaration({
  namedImports: ["readJsonResponse"],
  moduleSpecifier: "@sentry/toolkit-core/api-client/response",
});

await project.save();
console.log(`rewrote ${calls.length} call sites`);
```

</details>

## Verification

At `bf0b248ac`, rebased on `main` after #1722 merged:

- `pnpm run tsc` passes. It includes toolkit-core's browser-target
typecheck and the CLI typecheck.
- `pnpm run lint` passes (oxlint, oxfmt, ast-grep rules), and so does
`pnpm run docs:check`.
- Tests: toolkit-core 306, mcp-core 1881 (6 skipped), and mcp-cloudflare
443 pass.
- Before the rebase, the full `pnpm run test` also passed mcp-server
(87), mcp-test-client (80), docs-mcp (49), local (322), and cli-docs.
- CLI (`test/lib`, `test/commands`): 11718 pass and 6 fail in 5 files
(`completions`, `dsn/code-scanner`, `init/tools/file-changes`,
`telemetry`, `upgrade`).
- All 6 test permission errors (EACCES/EPERM/read-only). They fail only
because the sandbox runs with `CAP_DAC_OVERRIDE`, so a `chmod 000` file
stays readable.
  - This PR touches none of that code; CI is the check for them.
- Both generated commits reproduce exactly when their codemod is re-run
on the rebased parent.
- The diff has no codemod scripts, ast-grep or ts-morph config, or
ts-morph dependency.

<!-- junior-request-attribution:start -->
via **BYK**.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3Aa6eddc0161cff61914454da9f74fcdf1 -->

--

[View Junior Session in
Sentry](https://sentry.sentry.io/explore/conversations/agent%3Aa6eddc0161cff61914454da9f74fcdf1/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview — 0a3ca363 Deployed Oct 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant