# MCP Server Diff

[![GitHub Marketplace](https://img.shields.io/badge/Marketplace-MCP%20Server%20Diff-blue?logo=github)](https://github.com/marketplace/actions/mcp-server-diff)
[![npm version](https://img.shields.io/npm/v/mcp-server-diff)](https://www.npmjs.com/package/mcp-server-diff)
[![GitHub release](https://img.shields.io/github/v/release/SamMorrowDrums/mcp-server-diff)](https://github.com/SamMorrowDrums/mcp-server-diff/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

A GitHub Action for diffing [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server **public interfaces** between versions. Compares the current branch against a baseline to surface any changes to your server's exposed tools, resources, prompts, and capabilities.

> **Also available as a standalone CLI** — see [CLI Documentation](#cli-tool) or install with `npx mcp-server-diff`

## Overview

MCP servers expose a **public interface** to AI assistants: tools (with their input schemas), resources, prompts, and server capabilities. As your server evolves, changes to this interface are worth tracking. This action automates public interface comparison by:

1. Building your MCP server from both the current branch and a baseline (merge-base, tag, or specified ref)
2. Querying both versions for their complete public interface (tools, resources, prompts, capabilities)
3. Generating a diff report showing exactly what changed
4. Surfacing results directly in GitHub's Job Summary

This is **not** about testing internal logic or correctness—it's about visibility into what your server _advertises_ to clients.

## Quick Start

Create `.github/workflows/mcp-diff.yml` in your repository:

```yaml
name: MCP Server Diff

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
    tags: ['v*']

permissions:
  contents: read

jobs:
  mcp-diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: SamMorrowDrums/mcp-server-diff@v2
        with:
          setup_node: true
          install_command: npm ci
          build_command: npm run build
          start_command: node dist/stdio.js
```

## Language Examples

### Node.js / TypeScript

```yaml
- uses: SamMorrowDrums/mcp-server-diff@v2
  with:
    setup_node: true
    node_version: '22'
    install_command: npm ci
    build_command: npm run build
    start_command: node dist/stdio.js
```

### Python

```yaml
- uses: SamMorrowDrums/mcp-server-diff@v2
  with:
    setup_python: true
    python_version: '3.12'
    install_command: pip install -e .
    start_command: python -m my_mcp_server
```

### Go

```yaml
- uses: SamMorrowDrums/mcp-server-diff@v2
  with:
    setup_go: true
    install_command: go mod download
    build_command: go build -o bin/server ./cmd/stdio
    start_command: ./bin/server
```

### Rust

```yaml
- uses: SamMorrowDrums/mcp-server-diff@v2
  with:
    setup_rust: true
    install_command: cargo fetch
    build_command: cargo build --release
    start_command: ./target/release/my-mcp-server
```

### C# / .NET

```yaml
- uses: SamMorrowDrums/mcp-server-diff@v2
  with:
    setup_dotnet: true
    dotnet_version: '9.0.x'
    install_command: dotnet restore
    build_command: dotnet build -c Release
    start_command: dotnet run --no-build -c Release
```

### Custom Setup

If you need more control over environment setup (caching, specific registries, etc.), do your own setup before calling the action:

```yaml
steps:
  - uses: actions/checkout@v4
    with:
      fetch-depth: 0

  - uses: actions/setup-node@v4
    with:
      node-version: '22'
      cache: 'npm'
      registry-url: 'https://npm.pkg.github.com'

  - uses: SamMorrowDrums/mcp-server-diff@v2
    with:
      install_command: npm ci
      build_command: npm run build
      start_command: node dist/stdio.js
```

## Testing Multiple Transports

Test both stdio and HTTP transports in a single run using the `configurations` input:

```yaml
- uses: SamMorrowDrums/mcp-server-diff@v2
  with:
    setup_node: true
    install_command: npm ci
    build_command: npm run build
    configurations: |
      [
        {
          "name": "stdio",
          "transport": "stdio",
          "start_command": "node dist/stdio.js"
        },
        {
          "name": "streamable-http",
          "transport": "streamable-http",
          "start_command": "node dist/http.js",
          "server_url": "http://localhost:3000/mcp"
        }
      ]
```

## Inputs Reference

### Language Setup (Optional)

| Input | Description | Default |
|-------|-------------|---------|
| `setup_node` | Set up Node.js environment | `false` |
| `node_version` | Node.js version | `20` |
| `setup_python` | Set up Python environment | `false` |
| `python_version` | Python version | `3.11` |
| `setup_go` | Set up Go environment | `false` |
| `go_version` | Go version (reads from go.mod if empty) | `""` |
| `setup_rust` | Set up Rust environment | `false` |
| `rust_toolchain` | Rust toolchain | `stable` |
| `setup_dotnet` | Set up .NET environment | `false` |
| `dotnet_version` | .NET version | `8.0.x` |

### Required Inputs

| Input | Description |
|-------|-------------|
| `install_command` | Command to install dependencies (e.g., `npm ci`, `pip install -e .`, `go mod download`) |

### Server Configuration

| Input | Description | Default |
|-------|-------------|---------|
| `build_command` | Command to build the server. Optional for interpreted languages. | `""` |
| `start_command` | Command to start the server for stdio transport | `""` |
| `transport` | Transport type: `stdio` or `streamable-http` | `stdio` |
| `server_url` | Server URL for HTTP transport (e.g., `http://localhost:3000/mcp`) | `""` |
| `configurations` | JSON array of test configurations for testing multiple transports | `""` |
| `server_timeout` | Timeout in seconds to wait for server response | `10` |
| `env_vars` | Environment variables as newline-separated `KEY=VALUE` pairs | `""` |

Either `start_command` (for stdio) or `server_url` (for HTTP) must be provided, unless using `configurations`.

### Comparison Configuration

| Input | Description | Default |
|-------|-------------|---------|
| `compare_ref` | Git ref to compare against. Auto-detects merge-base on PRs or previous tag on tag pushes if not specified. | `""` |
| `fail_on_diff` | Fail the action if API changes are detected. Useful for release validation workflows. | `false` |
| `fail_on_error` | Fail the action if a genuine probe error occurs (e.g. the server fails to start on **both** sides). A configuration that starts on only one side is reported as "missing on one side" and is **not** treated as a probe error — see [One-Sided Startup Failures](#one-sided-startup-failures). | `true` |

### Configuration Object Schema

When using `configurations`, each object supports:

| Field | Description | Required |
|-------|-------------|----------|
| `name` | Identifier for this configuration (appears in report) | Yes |
| `transport` | `stdio` or `streamable-http` | No (default: `stdio`) |
| `start_command` | Server start command (stdio: spawns process, HTTP: starts server in background) | Yes for stdio, optional for HTTP |
| `server_url` | URL for HTTP transport | Required for `streamable-http` |
| `startup_wait_ms` | Milliseconds to wait for HTTP server to start (when using `start_command`) | No (default: 2000) |
| `pre_test_command` | Command to run before probing (alternative to `start_command` for HTTP) | No |
| `pre_test_wait_ms` | Milliseconds to wait after `pre_test_command` | No |
| `post_test_command` | Command to run after probing (cleanup, used with `pre_test_command`) | No |
| `headers` | HTTP headers for this configuration | No |
| `env_vars` | Additional environment variables | No |
| `custom_messages` | Config-specific custom messages | No |
| `base_start_command` | Command for baseline comparison (skips git checkout for this config) | No |
| `base_server_url` | URL for baseline HTTP server (used with `base_start_command`) | No |

### Comparing Against External Servers

When comparing against external servers (e.g., Docker images, remote services), use `base_start_command` to specify a different command for the baseline. This skips git checkout for that configuration and probes the specified server directly:

```yaml
configurations: |
  [
    {
      "name": "compare-versions",
      "transport": "stdio",
      "start_command": "docker run -i ghcr.io/example/mcp-server:v2.0.0",
      "base_start_command": "docker run -i ghcr.io/example/mcp-server:v1.0.0"
    }
  ]
```

This is useful for:
- **Version comparison**: Compare a new release against the previous version
- **Golden reference testing**: Compare your local code against a known-good reference
- **Cross-implementation testing**: Compare different implementations of the same server
- **Self-testing CI**: Verify the action detects diffs by comparing two known-different servers

For HTTP transport, use `base_server_url` alongside `base_start_command`:

```yaml
configurations: |
  [
    {
      "name": "http-comparison",
      "transport": "streamable-http",
      "start_command": "docker run -p 3000:3000 myserver:latest",
      "server_url": "http://localhost:3000/mcp",
      "base_start_command": "docker run -p 3001:3000 myserver:v1.0.0",
      "base_server_url": "http://localhost:3001/mcp"
    }
  ]
```

## How It Works

### Execution Flow

1. **Baseline Detection**: Determines the comparison ref:
   - For pull requests: merge-base with target branch
   - For tag pushes: previous tag (e.g., `v1.1.0` compares against `v1.0.0`)
   - Explicit: uses `compare_ref` if provided
2. **Build Baseline**: Creates a git worktree at the baseline ref and builds the server
3. **Build Current**: Builds the server from the current branch
4. **Conformance Testing**: Sends MCP protocol requests to both servers:
   - `initialize` - Server capabilities and metadata
   - `tools/list` - Available tools and their schemas
   - `resources/list` - Available resources
   - `prompts/list` - Available prompts
5. **Report Generation**: Produces a Markdown report with diffs, uploaded as an artifact and displayed in Job Summary

### What Gets Compared

The action queries the **public interface** of both server versions and compares the responses:

| Method | What It Reveals |
|--------|----------------|
| `initialize` | Server name, version, capabilities |
| `tools/list` | Available tools and their JSON schemas |
| `resources/list` | Exposed resources |
| `prompts/list` | Available prompts |

Differences appear as unified diffs in the report. Common changes include:

- New tools, resources, or prompts added
- Schema changes (new parameters, updated descriptions)
- Capability changes (new features enabled)
- Version string updates

### One-Sided Startup Failures

Sometimes a configuration starts on one side of the comparison but fails to start on
the other. The most common cause is a **brand-new configuration** that depends on a CLI
flag or option that doesn't exist on the compare ref yet — for example a PR that
introduces a new scoped server mode. On the compare ref the unknown flag is rejected and
the server exits with something like `MCP error -32000: Connection closed`.

When **exactly one** side fails to start (and the other probes successfully), the action:

1. **Reports a fail on the side that could not start**, naming the version (current
   branch or compare ref) in a dedicated "🚫 missing on one side" section and a per-config
   callout, instead of an opaque probe error.
2. **Diffs the working side against an empty baseline**, so the entire surface of the
   new (or removed) configuration renders as added/removed — giving you a complete
   picture of what it exposes.
3. **Does not trip `fail_on_error`.** A one-sided startup failure is treated as a
   surface difference (`config-missing`), not a genuine probe error, so an introducing PR
   isn't failed for the expected "this config doesn't exist on the base ref yet" case. It
   still counts as a difference, so `fail_on_diff` will flag it if enabled.

When **both** sides fail to start, that is a genuine probe failure: it is reported as an
error and trips `fail_on_error` (when enabled).

## Transport Support

### stdio Transport

The default transport communicates with your server via stdin/stdout using JSON-RPC. For stdio, each configuration spawns a fresh server process:

```yaml
- uses: SamMorrowDrums/mcp-server-diff@v2
  with:
    setup_node: true
    install_command: npm ci
    build_command: npm run build
    start_command: node dist/stdio.js
```

### Streamable HTTP Transport

For HTTP servers, you typically want to **start the server once** and test multiple configurations against it. Use `start_command` at the configuration level—the action spawns the server, waits for startup, probes it, then terminates it after that configuration completes:

```yaml
configurations: |
  [{
    "name": "http-server",
    "transport": "streamable-http",
    "start_command": "node dist/http.js",
    "server_url": "http://localhost:3000/mcp",
    "startup_wait_ms": 2000
  }]
```

**Per-configuration server lifecycle**: If your use case requires a fresh server instance per configuration (e.g., testing different flags or environment variables), include `start_command` in each configuration—each will get its own server process started and stopped.

**Shared server for multiple configurations**: If you want one HTTP server to handle multiple test configurations, use `pre_test_command`/`post_test_command` on the first/last configuration, or start the server in a prior workflow step:

```yaml
configurations: |
  [
    {
      "name": "config-a",
      "transport": "streamable-http",
      "server_url": "http://localhost:3000/mcp",
      "pre_test_command": "node dist/http.js &",
      "pre_test_wait_ms": 2000
    },
    {
      "name": "config-b",
      "transport": "streamable-http",
      "server_url": "http://localhost:3000/mcp"
    },
    {
      "name": "config-c",
      "transport": "streamable-http",
      "server_url": "http://localhost:3000/mcp",
      "post_test_command": "pkill -f 'node dist/http.js' || true"
    }
  ]
```

**Pre-deployed servers**: For already-running servers (staging, production), omit lifecycle commands entirely:

```yaml
- uses: SamMorrowDrums/mcp-server-diff@v2
  with:
    install_command: 'true'
    transport: streamable-http
    server_url: https://mcp.example.com/api
```

## Version Comparison Strategies

### Pull Requests

On pull requests, the action automatically compares against the merge-base with the target branch. This shows exactly what changes the PR introduces.

### Tag Releases

When triggered by a tag push matching `v*`, the action finds the previous tag and compares against it:

```yaml
on:
  push:
    tags: ['v*']

# v1.2.0 will automatically compare against v1.1.0
```

### Explicit Baseline

Specify any git ref to compare against:

```yaml
- uses: SamMorrowDrums/mcp-server-diff@v2
  with:
    setup_node: true
    install_command: npm ci
    build_command: npm run build
    start_command: node dist/stdio.js
    compare_ref: v1.0.0
```

### Failing on Changes (Release Validation)

For release workflows where you want to ensure no API changes, use `fail_on_diff`:

```yaml
- uses: SamMorrowDrums/mcp-server-diff@v2
  with:
    setup_node: true
    install_command: npm ci
    build_command: npm run build
    start_command: node dist/stdio.js
    compare_ref: v1.0.0
    fail_on_diff: true  # Action fails if any API changes are detected
```

## Artifacts and Reports

The action produces:

1. **Job Summary**: Inline Markdown report in the GitHub Actions UI showing test results and diffs
2. **Artifact**: `mcp-diff-report` artifact containing `MCP_DIFF_REPORT.md` for download or further processing

## Example Output

### No Changes Detected

When the MCP server's public interface hasn't changed between branches:

```
📊 Comparison:
  Current: HEAD
  Compare: abc1234 (v1.0.0)

🧪 Running diff...

📊 Phase 3: Comparing results...
📋 Configuration stdio: ✅ No changes

✅ No API Changes - All configurations match the baseline.
```

### Changes Detected

When changes are detected, the action shows a semantic diff with clear paths to each change:

```
📋 Configuration stdio: 3 change(s) found
```

The generated report shows exactly what changed using path notation:

```diff
--- base/tools.json
+++ branch/tools.json

+ tools[new_tool]: {"name": "new_tool", "description": "A newly added tool", ...}
- tools[old_tool].inputSchema.properties.name.description: "Old description"
+ tools[old_tool].inputSchema.properties.name.description: "Updated description"
- tools[calculator].inputSchema.properties.precision.type: "string"  
+ tools[calculator].inputSchema.properties.precision.type: "number"
```

```diff
--- base/resources.json
+++ branch/resources.json

+ resources[config://settings]: {"uri": "config://settings", "name": "Settings", ...}
```

Each line shows:
- `+` for additions (new tools, resources, or changed values)
- `-` for removals (deleted items or previous values)
- Full path to the change: `tools[tool_name].inputSchema.properties.param.type`

This makes it easy to see exactly what changed without wading through entire JSON dumps

## Recommended Workflow

```yaml
name: MCP Server Diff

on:
  workflow_dispatch:
  pull_request:
    branches: [main]
  push:
    branches: [main]
    tags: ['v*']

permissions:
  contents: read

jobs:
  mcp-diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: SamMorrowDrums/mcp-server-diff@v2
        with:
          setup_node: true
          install_command: npm ci
          build_command: npm run build
          configurations: |
            [
              {
                "name": "stdio",
                "transport": "stdio",
                "start_command": "node dist/stdio.js"
              },
              {
                "name": "streamable-http",
                "transport": "streamable-http",
                "start_command": "node dist/http.js",
                "server_url": "http://localhost:3000/mcp"
              }
            ]
```

## Troubleshooting

### Server fails to start

- Check that `start_command` works locally
- Increase `server_timeout` for slow-starting servers
- Verify all dependencies are installed by `install_command`

### Missing baseline

- Ensure `fetch-depth: 0` in your checkout step
- For new repositories, the first run may fail (no baseline exists)

### HTTP transport connection refused

- Verify `server_url` matches your server's listen address
- Ensure the server binds to `0.0.0.0` or `127.0.0.1`, not just `localhost` on some systems
- Check firewall or container networking if running in Docker

---

## CLI Tool

The CLI lets you diff any two MCP servers directly from your terminal—useful for local development, CI pipelines, or comparing servers across different implementations.

### Installation

```bash
# Run directly with npx (no install required)
npx mcp-server-diff --help

# Or install globally
npm install -g mcp-server-diff
```

### Basic Usage

```bash
# Compare two local stdio servers
npx mcp-server-diff -b "python -m mcp_server" -t "node dist/stdio.js"

# Compare local server vs remote HTTP endpoint
npx mcp-server-diff -b "go run ./cmd/server stdio" -t "https://mcp.example.com/api"

# Output formats
npx mcp-server-diff -b "..." -t "..." -o diff      # Raw diff hunks only
npx mcp-server-diff -b "..." -t "..." -o json      # Full JSON with details
npx mcp-server-diff -b "..." -t "..." -o markdown  # Formatted report
npx mcp-server-diff -b "..." -t "..." -o summary   # One-line summary (default)
```

### HTTP Headers & Authentication

For authenticated HTTP endpoints, pass headers with `-H` (target) or `--base-header`:

```bash
# Direct header value for target
npx mcp-server-diff -b "./server" -t "https://api.example.com/mcp" \
  -H "Authorization: Bearer your-token-here"

# Read from environment variable (keeps secrets out of shell history)
export MCP_TOKEN="your-secret-token"
npx mcp-server-diff -b "./server" -t "https://api.example.com/mcp" \
  -H "Authorization: Bearer env:MCP_TOKEN"

# Prompt for secret interactively (hidden input, named "token")
npx mcp-server-diff -b "./server" -t "https://api.example.com/mcp" \
  -H "Authorization: Bearer secret:token"

# Headers for both sides (e.g., comparing two authenticated servers)
npx mcp-server-diff \
  -b "https://api.example.com/v1/mcp" --base-header "Authorization: Bearer secret:v1token" \
  -t "https://api.example.com/v2/mcp" -H "Authorization: Bearer secret:v2token"
```

### Config File

For complex comparisons or multiple targets, use a config file:

```bash
npx mcp-server-diff -c servers.json -o diff
```

```json
{
  "base": {
    "name": "python-server",
    "transport": "stdio",
    "start_command": "python -m mcp_server"
  },
  "targets": [
    {
      "name": "typescript-server",
      "transport": "stdio",
      "start_command": "node dist/stdio.js"
    },
    {
      "name": "remote-server",
      "transport": "streamable-http",
      "server_url": "https://mcp.example.com/api",
      "headers": {
        "Authorization": "Bearer token"
      }
    }
  ]
}
```

### CLI Options Reference

| Option | Description |
|--------|-------------|
| `-b, --base <cmd\|url>` | Base server command (stdio) or URL (http) |
| `-t, --target <cmd\|url>` | Target server command (stdio) or URL (http) |
| `-H, --header <header>` | HTTP header for target (repeatable) |
| `-B, --base-header <header>` | HTTP header for base server (repeatable) |
| `-T, --target-header <header>` | HTTP header for target (same as `-H`) |
| `-c, --config <file>` | Config file with base and targets |
| `-o, --output <format>` | Output: `diff`, `json`, `markdown`, `summary` (default) |
| `-v, --verbose` | Verbose output |
| `-q, --quiet` | Quiet mode (only output result) |
| `-h, --help` | Show help |
| `--version` | Show version |

**Header value patterns:**
- `Bearer your-token` — literal value
- `Bearer env:VAR_NAME` — read from environment variable  
- `Bearer secret:name` — prompt once for "name", reuse if used multiple times

---

## Migration to 3.0

Version 3.0 is a maintenance release that refreshes the dependency tree and prepares the probe for the upcoming MCP draft spec.

**Dependency upgrades (breaking only at the install layer)**

- `zod` v3 → v4 (signature of `z.record` now requires explicit key + value schemas)
- `undici` v6 → v8 (also updates the `overrides` block, resolving the v6 WebSocket advisories)
- `diff` v8 → v9
- `@actions/core`, `@actions/exec`, `@actions/io` v1 → v3
- `eslint` / `@eslint/js` v9 → v10, `typescript` v5 → v6, `jest` / `@types/jest` v29 → v30
- `@types/node` v22 → v24 (the action and CLI are tested on Node 22 + 24)
- `@vercel/ncc` 0.38 → 0.44

**Dropped support: Node.js 20.** Node 20 reached end-of-life on 2026-04-30 and `undici` v8 requires Node 22+. The action and CLI now require Node.js 22 or newer; the action defaults to Node 22 when `setup_node: true` is supplied without an explicit `node_version`.

If you consume `mcp-server-diff` as a GitHub Action or via `npx`, nothing in your workflow should change.

**MCP draft-spec forward-compat**

The probe now strips the `ttlMs` and `cacheScope` cache hints (CacheableResult, [SEP-2461](https://modelcontextprotocol.io/specification/draft/changelog)) from the top level of `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list` results before snapshotting. These freshness hints are intended to vary between runs, so stripping them keeps diffs focused on real interface changes. Nested `ttlMs` / `cacheScope` fields (e.g. inside a tool description) are preserved.

The `initialize` snapshot is captured from whichever handshake path the server actually supports — see [Cross-spec-version diffing](#cross-spec-version-diffing) for details. Servers on the draft spec ([SEP-2575](https://modelcontextprotocol.io/specification/draft/changelog)) are probed statelessly via `server/discover`; pre-draft servers continue to use the legacy `initialize` handshake via `@modelcontextprotocol/sdk`. Both flow into the same canonical `initialize` snapshot file.

### Cross-spec-version diffing

A common situation during the draft-spec rollout is that the base ref runs against MCP spec `2025-06-18` or `2025-11-25` while the branch runs against the draft. The tool is designed so that a server upgrading its SDK without changing its public surface produces a **clean diff**.

What gets normalized away before snapshotting:

- **CacheableResult hints** — top-level `ttlMs` / `cacheScope` on `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list` ([SEP-2461](https://modelcontextprotocol.io/specification/draft/changelog)).
- **`_meta` protocol plumbing** — a specific, exact-key denylist is stripped from every `_meta` object, at any depth: `io.modelcontextprotocol/protocolVersion`, `io.modelcontextprotocol/clientInfo`, `io.modelcontextprotocol/clientCapabilities`, `io.modelcontextprotocol/subscriptionId`, `io.modelcontextprotocol/logLevel`. An emptied `_meta` is dropped entirely. **We do not strip by prefix**: the `io.modelcontextprotocol/*` namespace is reserved by the spec but is also where official extensions live (MCP Apps' `_meta.ui`, Tasks' `io.modelcontextprotocol/related-task`, etc.) — those surfaces are exactly what this tool exists to diff, so they round-trip untouched.
- **W3C trace context inside `_meta`** — `traceparent`, `tracestate`, and `baggage` (transport-injected for OTel propagation) are stripped from `_meta`.
- **Tool annotation default hints** — `ToolAnnotations` fields are dropped when they equal their spec defaults (`readOnlyHint=false`, `destructiveHint=true`, `idempotentHint=false`, `openWorldHint=true`), so a server that emits explicit defaults compares equal to one that omits them. Important when an SDK toggles `omitempty` between versions (e.g. go-sdk v1.6→v1.7 dropped it on `ReadOnlyHint`/`IdempotentHint` and would otherwise show every tool as "changed"). An emptied `annotations` object is dropped entirely.
- **`initialize` envelope churn** — `protocolVersion` and `capabilities.experimental` are excluded from the `initialize` snapshot body. The negotiated protocol version is captured separately and surfaced by the reporter (see below).
- **Endpoint names are canonicalized** — see `CANONICAL_SNAPSHOT_NAMES` in `src/probe.ts`. The probe tries the stateless `server/discover` path first (SEP-2575, SEP-2243) so a server on the draft spec is probed at its own newest spec version; on any failure it falls back to the legacy `initialize` handshake via `@modelcontextprotocol/sdk`. Both flow into the same `initialize` snapshot file, so a spec upgrade shows up as a content diff on one file, not a "removed + added" pair. The reporter still surfaces "Protocol version changed from X to Y" via a banner. Note that fields like `instructions` and `capabilities` are NOT normalized away even though they may differ between paths — those are real public-interface signals. For example, go-sdk v1.7.0-pre.1 emits `instructions` on `initialize` but omits it from `server/discover`; this is spec-conformant (`instructions` is optional on both result shapes) but is still a public-interface difference clients adopting discover observe, so it surfaces in the diff.

What is **not** normalized (intentionally):

- `serverInfo.version` (the SDK version is a legitimate signal worth tracking).
- Nested `ttlMs` / `cacheScope` that live inside a tool/prompt/resource definition (those would be part of the public surface, not envelope hints).
- Any `_meta` key not on the exact denylist above — including the entire MCP Apps surface (`_meta.ui` and friends, [SEP-1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865)) and vendor extensions under non-reserved namespaces (`x.acme/*`, etc.).

When the base and branch probes negotiate different MCP protocol versions, the report (and the PR summary) emit a banner like:

> ℹ️ **MCP protocol version changed:** `2025-11-25` → `draft`. Protocol-level plumbing is normalized away; any diff below reflects real public-surface changes.

If the public surface is identical the diff is empty even when the protocol version moved — the banner is the only signal.

## License

MIT License. See [LICENSE](LICENSE) for details.

## Contributing

Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

## Related Resources

- [Model Context Protocol Specification](https://modelcontextprotocol.io/)
- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
- [MCP Go SDK](https://github.com/modelcontextprotocol/go-sdk)

### Example Configurations

Working examples of this action in various languages:

| Language | Repository | Workflow |
|----------|------------|----------|
| TypeScript | [mcp-typescript-starter](https://github.com/SamMorrowDrums/mcp-typescript-starter) | [mcp-diff.yml](https://github.com/SamMorrowDrums/mcp-typescript-starter/blob/main/.github/workflows/mcp-diff.yml) |
| Python | [mcp-python-starter](https://github.com/SamMorrowDrums/mcp-python-starter) | [mcp-diff.yml](https://github.com/SamMorrowDrums/mcp-python-starter/blob/main/.github/workflows/mcp-diff.yml) |
| Go | [mcp-go-starter](https://github.com/SamMorrowDrums/mcp-go-starter) | [mcp-diff.yml](https://github.com/SamMorrowDrums/mcp-go-starter/blob/main/.github/workflows/mcp-diff.yml) |
| Rust | [mcp-rust-starter](https://github.com/SamMorrowDrums/mcp-rust-starter) | [mcp-diff.yml](https://github.com/SamMorrowDrums/mcp-rust-starter/blob/main/.github/workflows/mcp-diff.yml) |
| C# | [mcp-csharp-starter](https://github.com/SamMorrowDrums/mcp-csharp-starter) | [mcp-diff.yml](https://github.com/SamMorrowDrums/mcp-csharp-starter/blob/main/.github/workflows/mcp-diff.yml) |

For a production example, see [github-mcp-server](https://github.com/github/github-mcp-server).
