Skip to content

feat(protocol): serve modern MCP with SDK v2 - #255

Merged
ihor-sokoliuk merged 9 commits into
mainfrom
codex/protocol/sdk-v2-modern-serving
Aug 20, 2026
Merged

feat(protocol): serve modern MCP with SDK v2#255
ihor-sokoliuk merged 9 commits into
mainfrom
codex/protocol/sdk-v2-modern-serving

Conversation

@ihor-sokoliuk

Copy link
Copy Markdown
Owner

Summary

  • migrate MCP serving to the official split SDK v2 packages
  • add modern 2026-07-28 HTTP and STDIO serving while retaining supported legacy transports
  • preserve tool/resource contracts, admission controls, credential-safe diagnostics, and bounded HTTP cleanup
  • document the protocol matrix and operator controls

Verification

  • npm run lint
  • npm run build
  • npm run test:coverage (all suites passed; 95.77% lines, 91.92% branches)
  • npm run test:e2e (35/35)
  • npm run verify:packed-consumer
  • npm audit --audit-level=moderate (0 vulnerabilities)
  • npm pack --dry-run

Copilot AI lite review requested due to automatic review settings August 20, 2026 20:27
Comment thread src/index.ts Fixed
@codacy-production

codacy-production Bot commented Aug 20, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 2 medium

Alerts:
⚠ 2 issues (≤ 0 issues of at least minor severity)

Results:
2 new issues

Category Results
BestPractice 1 medium
Security 1 medium

View in Codacy

🟢 Metrics 151 complexity · 14 duplication

Metric Results
Complexity 151
Duplication 14

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Copilot AI 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.

Pull request overview

This PR migrates the server from the monolithic @modelcontextprotocol/sdk to the split MCP SDK v2 packages and adds support for the modern 2026-07-28 protocol over both HTTP and STDIO, while preserving legacy transport behavior, contracts, and security/operational bounds.

Changes:

  • Switched MCP imports to @modelcontextprotocol/{server,node,client,core} and updated dependency/runtime verification to forbid the legacy monolithic SDK.
  • Reworked HTTP + STDIO serving to support modern sessionless requests (official handler) alongside retained legacy stateful/stateless behavior.
  • Added request-scoped modern logging bridging (AsyncLocalStorage) and updated docs/tests to lock in protocol/tool/resource contracts.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/url-reader.ts Updates MCP server import to SDK v2 split package.
src/types.ts Switches Tool type import to @modelcontextprotocol/server.
src/suggestions.ts Updates MCP server import to SDK v2 split package.
src/searxng-response.ts Updates MCP server import to SDK v2 split package.
src/search.ts Updates MCP server import to SDK v2 split package.
src/resources.ts Updates McpServer type import to SDK v2 split package.
src/logging.ts Adds modern request-scoped logging bridge and modern/legacy routing behavior.
src/instance-info.ts Updates MCP server import to SDK v2 split package.
src/index.ts Rebuilds server registration/dispatch for SDK v2 and wires modern vs legacy eras for STDIO/HTTP.
src/http-server.ts Migrates to Node SDK v2 HTTP transport + handler; adds modern request path + compatibility guard.
src/browser-solver.ts Updates McpServer type import to SDK v2 split package.
SECURITY.md Documents modern vs legacy protocol matrix and modern header requirement/guard behavior.
scripts/verify-packed-consumer.mjs Updates verifier output to report split SDK runtime versions.
scripts/packed-consumer-contracts.mjs Enforces exact split SDK runtime dependency set and forbids monolithic SDK.
README.md Updates transport docs to describe modern protocol support and legacy retention.
package.json Replaces monolithic SDK dependency with split v2 packages; adds zod@4.2.0 and v2 client devDependency.
package-lock.json Locks dependency graph to split v2 packages and zod@4.2.0; removes monolithic SDK and related deps.
CONFIGURATION.md Documents modern/legacy transport behavior and modern header expectations.
tests/unit/packed-consumer.test.ts Updates dependency-contract expectations for split SDK runtime.
tests/unit/packed-consumer-fixtures.ts Updates fixtures to model split SDK runtime dependency tree.
tests/unit/logging.test.ts Adds tests for modern request-scoped logging behavior and fail-closed behavior outside scope.
tests/integration/mcp-handlers.test.ts Updates integration tests to v2 client and locks in tool/resource structural contracts.
tests/integration/http-server.test.ts Adds modern HTTP handler coverage, header-guard coverage, and capacity bounds validation.
tests/integration/diagnostic-security.test.ts Updates to v2 client and ensures diagnostics remain credential-safe.
tests/e2e/url-reader.e2e.ts Adjusts assertions to handle v2-era tool error vs thrown error shapes.
tests/e2e/timeout.e2e.ts Extends e2e coverage for modern STDIO discovery and updated admission behavior.
tests/e2e/http-transport.e2e.ts Updates e2e HTTP transport client to v2 package.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/http-server.ts
Comment on lines 431 to +434

if (stateless.enabled) {
if (rejectInvalidStatelessHeaders(req, res)) {
if (rejectInvalidStatelessHeaders(req, res)) return;

if (!req.is("application/json")) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in bfe2e2a — verified valid. Renamed the helper to rejectInvalidHostHeader so its name matches its actual Host-only responsibility.

@ihor-sokoliuk

Copy link
Copy Markdown
Owner Author

Review findings handled in bfe2e2a:

  • Removed the unused logging import reported by code scanning.
  • Renamed the stateless-header helper to state its actual Host-validation responsibility.
  • Extracted the production admission/tool-dispatch, server-registration, and modern-header parsing helpers to reduce complexity without changing protocol behavior.
  • Split the long HTTP, MCP-schema, and built-STDIO tests while preserving their exact assertions.
  • Reworded the two security-documentation sentences to describe the supported exceptions explicitly.
  • Left @hono/node-server 1.19.17 unchanged: the cited GHSA affects 1.x versions below 1.19.15 (and 2.x versions below 2.0.5), while this lockfile resolves 1.19.17 and npm audit reports zero vulnerabilities.

@ihor-sokoliuk
ihor-sokoliuk requested a lite review from Copilot August 20, 2026 20:43

Copilot AI 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.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/index.ts:352

  • logging/setLevel handler accepts request.params.level without runtime validation. Since setLogLevel() stores whatever string is provided, an invalid level will make shouldLog() comparisons use indexOf(...) === -1, which can effectively enable logging for all levels (or otherwise break filtering). Consider validating level against the allowed LOG_LEVELS set before calling setLogLevel, and reject invalid values with a sanitized error response.
  mcpServer.server.setRequestHandler("logging/setLevel", async (request, context) => {
    const callback = async () => {
      logMessage(mcpServer, "info", `Setting log level to: ${request.params.level}`);
      setLogLevel(mcpServer, request.params.level);
      return {};

@ihor-sokoliuk

Copy link
Copy Markdown
Owner Author

Follow-up review findings handled in 91b8b28:

  • Reduced the reported legacy STDIO assertion complexity without changing its response requirements.
  • Split the tool-schema structural snapshot helper into bounded helpers, removing the reported line-count issue while preserving the frozen contract.
  • Verified the suppressed logging concern as inapplicable: SDK protocol validation rejects an invalid logging level before the registered handler runs, and a new integration test proves the previously valid level remains unchanged.
  • Left @hono/node-server 1.19.17 unchanged because the cited 1.x advisory range ends below 1.19.15; the resolved 1.19.17 version is outside that range.

Copilot AI 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.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/http-server.ts:307

  • The Host/DNS-rebinding rejection response reflects the raw Host header value back to the client (Invalid Host header: ${host}). Since Host is attacker-controlled, prefer a fixed/non-reflecting error message (and log the received value via diagnostics instead) to avoid echoing untrusted input in responses and to align with the non-reflecting Origin/auth rejection pattern used elsewhere in this handler.
  function rejectInvalidHostHeader(
    req: express.Request,
    res: express.Response,
  ): boolean {
    if (!security.enableDnsRebindingProtection) {
      return false;
    }
    const host = req.headers.host;
    if (security.allowedHosts.length > 0 && (!host || !security.allowedHosts.includes(host))) {
      res.status(403).json({
        jsonrpc: "2.0",
        error: { code: -32000, message: `Invalid Host header: ${host}` },
        id: null,
      });

@ihor-sokoliuk

Copy link
Copy Markdown
Owner Author

Latest review finding handled in f1b0c4d:

  • Verified the suppressed Host-response concern as valid.
  • Replaced the attacker-controlled Host echo with a fixed 403 message.
  • Added an integration assertion that the rejected Host value never appears in the response.
  • The earlier Codacy dependency annotation remains a verified false positive; the guarded issue-scoped dismissal was attempted once but Codacy did not verify it, so the attempt is preserved privately and will not be repeated.

@ihor-sokoliuk
ihor-sokoliuk requested a lite review from Copilot August 20, 2026 21:03
Comment thread __tests__/integration/http-server.test.ts Fixed

Copilot AI 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.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/http-server.ts:460

  • Modern HTTP requests are sessionless, but when MCP_HTTP_STATELESS is false the shared postRateLimiter earlier in this route still chooses initLimiter whenever there is no live mcp-session-id. That means normal modern traffic will be throttled by MCP_RATE_INIT_MAX (and MCP_RATE_SESSION_MAX becomes effectively unused for modern clients). Consider updating the POST /mcp rate-limiter selection to treat modern requests (e.g., based on MCP-Protocol-Version/MCP-Method headers or a light _meta/protocolVersion check) as sessionLimiter traffic.
    const modern = !(await isLegacyRequest(await toWebRequest(req, req.body), req.body));
    if (modern) {
      const headerError = missingModernProtocolHeaderError(req.headers, req.body);
      if (headerError) {
        res.status(400).json({ jsonrpc: "2.0", ...headerError });

@ihor-sokoliuk

Copy link
Copy Markdown
Owner Author

Review follow-up for this head:

  • Replaced the Host non-reflection test's regular-expression assertion with a literal containment assertion, addressing the missing-anchor finding without weakening the regression.
  • The production rejection remains a fixed Invalid Host header message and does not reflect the attacker-controlled Host value.
  • The Codacy dependency finding for @hono/node-server@1.19.17 remains a false positive: the cited advisory affects 1.x versions below 1.19.15 (and a separate 2.x range), while this PR installs 1.19.17; the package audit reports zero vulnerabilities.

@ihor-sokoliuk
ihor-sokoliuk requested a lite review from Copilot August 20, 2026 21:10
Comment thread __tests__/integration/http-server.test.ts Fixed

Copilot AI 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.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/http-server.ts:460

  • In stateful HTTP mode (MCP_HTTP_STATELESS is false), modern requests are classified only after the POST rate limiter runs. That limiter currently upgrades any request that presents a live legacy mcp-session-id to the more generous session bucket. A modern (2026-07-28) request is sessionless and should not be able to select the session limiter via a legacy session ID; this contradicts the documented behavior that only stateful POSTs with a live session use the session limit (CONFIGURATION.md Rate Limiting section). Consider ensuring modern requests always use the init bucket in stateful mode (e.g., ignore mcp-session-id when the body claims 2026-07-28, or classify before choosing the limiter).
    const modern = !(await isLegacyRequest(await toWebRequest(req, req.body), req.body));
    if (modern) {
      const headerError = missingModernProtocolHeaderError(req.headers, req.body);
      if (headerError) {
        res.status(400).json({ jsonrpc: "2.0", ...headerError });

@ihor-sokoliuk

Copy link
Copy Markdown
Owner Author

Review follow-up for this head:

  • The Host-rejection regression now asserts the complete fixed JSON-RPC response object instead of performing a URL-like substring check.
  • This preserves the non-reflection guarantee and removes the incomplete-substring-sanitization pattern reported against the test assertion.
  • The Codacy dependency finding for @hono/node-server@1.19.17 remains a version-range false positive: the cited advisory affects 1.x versions below 1.19.15 (and a separate 2.x range), while this PR installs 1.19.17; the package audit reports zero vulnerabilities.

Copilot AI 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.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (2)

SECURITY.md:183

  • This paragraph states that capacity controls run only with MCP_HTTP_STATELESS=true, but modern HTTP requests are always handled as per-request (sessionless) and the server enforces stateless capacity/timeouts for them regardless of that flag. Consider rewording to reflect that modern requests are always subject to MCP_HTTP_STATELESS_MAX_IN_FLIGHT(_PER_IP) and MCP_HTTP_STATELESS_REQUEST_TIMEOUT_MS, while legacy requests only opt into per-request servers when MCP_HTTP_STATELESS=true.
With `MCP_HTTP_STATELESS=true`, bearer authorization and the hardened Host and Origin checks run before any per-request MCP server is constructed. Rate limiting also runs before construction, followed by the stateless capacity controls. Modern and legacy stateless requests share that capacity, while `/health` and MCP control traffic remain available during tool-admission exhaustion.

HTTP and STDIO support modern `2026-07-28` and legacy `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`, and `2024-10-07`. Modern HTTP is sessionless POST-only; legacy stateful and stateless behavior retains the documented session and 405 boundaries.

CONFIGURATION.md:318

  • The text implies stateless capacity controls only run when MCP_HTTP_STATELESS=true, but modern HTTP requests are always sessionless and the implementation applies admitStatelessRequest() and MCP_HTTP_STATELESS_REQUEST_TIMEOUT_MS to modern requests regardless of that flag (see src/http-server.ts modern branch). Please clarify that the MCP_HTTP_STATELESS_* limits/timeouts also bound modern HTTP requests even when legacy stateless mode is disabled, or adjust the code/docs to match the intended behavior.
By default the server communicates over STDIO. Set `MCP_HTTP_PORT` to enable HTTP mode instead. The SDK v2 server accepts modern MCP requests and retains legacy compatibility; modern HTTP clients should send the negotiated `MCP-Protocol-Version` header.

Both transports support modern `2026-07-28` plus legacy `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`, and `2024-10-07`. Modern HTTP requests are sessionless POSTs; legacy HTTP requests retain the stateful default or the configured legacy stateless mode.

The published server SDK `2.0.0` has a temporary compatibility guard for a 2026-07-28 request that omits that header: it returns the standard HTTP 400 HeaderMismatch response. The guard will be removed only after upgrading to a stable SDK containing upstream PR 2594 and proving that the SDK itself returns the same response.

@ihor-sokoliuk

Copy link
Copy Markdown
Owner Author

Review follow-up for this head:

  • Confirmed the suppressed rate-limiter finding with a failing regression: a modern sessionless request carrying a live legacy session ID selected the session limit (11) instead of the initialization limit (2).
  • Stateful-mode limiter selection now treats an exact modern protocol header or body claim as initialization-limited, regardless of any legacy session ID; retained legacy requests with an exact live session remain session-limited.
  • Added a full HTTP regression proving the modern request consumes and exhausts only the initialization bucket, and updated the operator documentation for the era-specific rule.
  • The Codacy dependency finding for @hono/node-server@1.19.17 remains a version-range false positive: the cited advisory affects 1.x versions below 1.19.15 (and a separate 2.x range), while this PR installs 1.19.17; the package audit reports zero vulnerabilities.

@ihor-sokoliuk

Copy link
Copy Markdown
Owner Author

Review follow-up for this head:

  • Clarified that modern HTTP always uses an isolated per-request server and is always bounded by the stateless in-flight and request-timeout controls, even when retained legacy stateless mode is disabled.
  • Clarified that MCP_HTTP_STATELESS=true extends that same serving and capacity model to retained legacy POST requests.
  • Updated both operator configuration and security guidance, including the individual environment-variable descriptions.
  • The Codacy dependency finding for @hono/node-server@1.19.17 remains a version-range false positive: the cited advisory affects 1.x versions below 1.19.15 (and a separate 2.x range), while this PR installs 1.19.17; the package audit reports zero vulnerabilities.

Copilot AI 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.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

Copilot AI 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.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (1)

scripts/packed-consumer-contracts.mjs:7

  • The file-level eslint-disable security/detect-object-injection suppresses the rule for the entire module, but the dynamic key access is limited to a couple of lookups (REQUIRED_SDK_RUNTIME[name]). To keep lint coverage meaningful, prefer scoping the disable to the exact line(s) that need it (or switch REQUIRED_SDK_RUNTIME to a Map and use .get()/.has() instead).
/* eslint-disable security/detect-object-injection -- all dynamic keys are validated against the fixed SDK runtime allowlist. */
const REQUIRED_SDK_RUNTIME = Object.freeze({
  '@modelcontextprotocol/core': '2.0.0',
  '@modelcontextprotocol/node': '2.0.0',
  '@modelcontextprotocol/server': '2.0.0',
  zod: '4.2.0',
});

@ihor-sokoliuk

Copy link
Copy Markdown
Owner Author

Review follow-up for this head:

  • Replaced the packed-consumer verifier's fixed runtime-version object with a fixed Map.
  • Removed the file-wide security/detect-object-injection suppression; membership and version lookup now use Map.has() and Map.get() directly.
  • The exact v2 runtime allowlist and failure behavior are unchanged.
  • The Codacy dependency finding for @hono/node-server@1.19.17 remains a version-range false positive: the cited advisory affects 1.x versions below 1.19.15 (and a separate 2.x range), while this PR installs 1.19.17; the package audit reports zero vulnerabilities.

@ihor-sokoliuk
ihor-sokoliuk requested a lite review from Copilot August 20, 2026 21:39

Copilot AI 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.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/http-server.ts:492

  • warnDiagnostic pre-sanitizes error via sanitizeDiagnosticValue, but that sanitizer intentionally strips Error fields like message/stack (see diagnostic-sanitizer.ts), so this warning will log almost no useful context about why cleanup failed. Prefer passing the Error through writeDiagnostic (it already sanitizes Errors with sanitizeErrorForTransport) or update warnDiagnostic to treat Error specially so safe message/stack are preserved.
              }),
            ]);
          } catch (error) {
            warnDiagnostic("⚠️  Stateless HTTP cleanup did not complete normally.", error);
          } finally {

@ihor-sokoliuk

Copy link
Copy Markdown
Owner Author

Final review disposition:

  • Rejected the suppressed cleanup-diagnostic comment after checking the actual sanitizer path. sanitizeDiagnosticValue() explicitly preserves sanitized Error name, message, stack, and cause fields before writeDiagnostic() applies the credential-safe chokepoint again.
  • The exercised cleanup-timeout path emits the safe Stateless resource cleanup timed out message and sanitized stack context, so the warning retains useful diagnostics without exposing configured credentials.
  • No source change is warranted for this comment.

@ihor-sokoliuk
ihor-sokoliuk merged commit 1a0f124 into main Aug 20, 2026
6 of 7 checks passed
@ihor-sokoliuk
ihor-sokoliuk deleted the codex/protocol/sdk-v2-modern-serving branch August 20, 2026 21:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants