Skip to main content
Glama
README.md
# mcp-stateless-recon

[![CI](https://github.com/abdulsalam-create/mcp-stateless-recon/actions/workflows/ci.yml/badge.svg)](https://github.com/abdulsalam-create/mcp-stateless-recon/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://nodejs.org)
[![Runtime dependencies: 0](https://img.shields.io/badge/runtime%20deps-0-success.svg)](#zero-runtime-dependencies)
[![MCP 2026-07-28](https://img.shields.io/badge/MCP-2026--07--28-5b4ee6.svg)](https://modelcontextprotocol.io/specification/2026-07-28)

A dependency-free, spec-accurate implementation of the **stateless MCP
2026-07-28 protocol** — with the Tasks extension, multi round-trip elicitation,
and a protocol conformance suite. Exposed as a safe network-reconnaissance
toolserver.

It does not wrap `@modelcontextprotocol/sdk`. It implements the wire protocol:
the `_meta` key grammar, `resultType` polymorphism, the error-code allocation
rules, the `Mcp-Method`/`Mcp-Name` routing headers and their `-32020`
mismatch rule, the Tasks state machine, and RFC 8707 audience-bound tokens. On
top of that sits a recon toolserver — DNS, TLS, security headers, port scanning
— that exists to exercise the hard parts of the specification with real I/O,
behind an SSRF guard that is tested harder than anything else in the repository.

**708 tests**, of which 140 are conformance tests named after the specification
requirements they assert.

---

## Why this exists

The 2026-07-28 revision of the Model Context Protocol made one change that
touches everything else: **it deleted the handshake.**

There is no `initialize`, no `notifications/initialized`, and no
`Mcp-Session-Id`. Servers MUST NOT infer state from prior requests on the same
connection. Everything a server needs to serve a request now travels with that
request, in `params._meta`: the protocol version, the client's capabilities, and
optionally who the client claims to be.

That sounds like a simplification. In practice it inverts how you write a
server.

- **Capabilities are per request, not per connection.** A client that supports
  the Tasks extension on one call may not declare it on the next, and the server
  must answer each one on its own terms. "Does this client support tasks?" is no
  longer a property you can cache.
- **Anything that must outlive a request needs an explicit name.** Long-running
  work becomes a `taskId` the client carries. A half-finished call awaiting an
  answer becomes a signed continuation token the client carries. Nothing is
  parked in memory under a socket.
- **Any request can land on any instance.** That is the payoff: behind a
  round-robin load balancer, instances are interchangeable, and a restart drops
  nothing. It is also the constraint, because a single `Map` keyed by connection
  quietly destroys it — and it will pass every test you write on a single
  machine with a single client.
- **The failure mode is invisible.** Stateful leakage does not throw. It works
  perfectly in development and produces rare, unreproducible bugs in production.

So this project treats statelessness as something to _prove_, not to assert.
`tests/conformance/multi-round-trip.test.ts` starts two independent server
instances, begins a multi-round-trip exchange on the first, answers it on the
second, and finishes it on the first. They share no memory — only the key used
to sign continuation tokens.

The revision also added things that are easy to implement carelessly and
interesting to implement properly:

- a formal **`_meta` key-name grammar** with a reservation rule that hinges on
  the _second_ label of a prefix (`com.mcp.tools/` is reserved for MCP,
  `com.example.mcp/` is not);
- **`resultType`** on every result, where the legal set is the core values plus
  whatever the extensions _this client declared_ contribute;
- **error-code bands** with two codes forbidden outright;
- **routing headers** that a gateway can act on without parsing the body, which
  only works because disagreement between header and body is now an error;
- the **Tasks extension**, whose terminal statuses must never change and whose
  tasks must never be handed to a client that cannot poll them;
- **RFC 8707 resource indicators**, the defence against a token minted for one
  MCP server being replayed at another.

A fuller write-up, with citations, is in [docs/PROTOCOL.md](docs/PROTOCOL.md).

---

## Quickstart

```bash
git clone https://github.com/abdulsalam-create/mcp-stateless-recon.git
cd mcp-stateless-recon
npm install
npm test
npm run build
```

Serve over HTTP:

```bash
node dist/bin/cli.js --port 8848 --allow '*.example.com'
```

Or over stdio, for a desktop client:

```bash
node dist/bin/cli.js --stdio
```

Watch a full annotated transcript — discovery, a plain call, a task, two
elicitation round trips, and a deliberate `-32020`:

```bash
npm run example:client
```

### CLI options

| Flag                  | Default     | Meaning                                                                                                                                      |
| --------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `--port <n>`          | `8848`      | HTTP port; `0` picks a free one                                                                                                              |
| `--host <addr>`       | `127.0.0.1` | bind address                                                                                                                                 |
| `--stdio`             | off         | serve on stdin/stdout instead of HTTP                                                                                                        |
| `--allow <patterns>`  | none        | comma-separated allowlist: hostname, `*.suffix`, IP literal or IPv4 CIDR                                                                     |
| `--allow-private`     | off         | permit non-routable targets. **Local testing only**                                                                                          |
| `--max-ports <n>`     | `128`       | hard cap on ports in one `port.scan`                                                                                                         |
| `--max-redirects <n>` | `3`         | redirect cap for `http.security_headers`                                                                                                     |
| `--timeout <ms>`      | `8000`      | per-connection timeout                                                                                                                       |
| `--resource <uri>`    | none        | canonical resource URI; enables RFC 8707 bearer-token validation (requires `MCP_RECON_HS256_SECRET`; the server refuses to start without it) |
| `--issuer <uri>`      | none        | trusted authorization server issuer (comma-separated)                                                                                        |

Environment (STDIO takes credentials from here, as the spec directs):
`MCP_RECON_ALLOW`, `MCP_RECON_CONTINUATION_SECRET`, `MCP_RECON_HS256_SECRET`.

### Deploying behind a load balancer

Nothing in the server depends on connection identity, so instances are
interchangeable and need no sticky sessions. Two caveats, both honest:

- Set `MCP_RECON_CONTINUATION_SECRET` to the **same value on every instance**,
  or a multi-round-trip call started on instance A cannot be finished on B.
- The bundled `InMemoryTaskStore` is per-instance, so a `taskId` created on A is
  not visible on B. The `TaskStore` interface exists precisely so a shared
  implementation can be dropped in; see [Not implemented](#known-limitations--not-implemented).

---

## Tools

| Tool                    | Result shape                         | What it does                                                                                                                              | Safety                                                                                                 |
| ----------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `dns.resolve`           | `complete`                           | A, AAAA, CNAME, MX, TXT and NS records, with every returned address classified against the SSRF range table                               | allowlist, per-host rate limit                                                                         |
| `tls.inspect`           | `complete`                           | completes a handshake and reports subject, issuer, SANs, validity window, days to expiry, protocol and cipher, plus findings              | connects to the validated IP with the hostname as SNI; reports bad chains rather than trusting them    |
| `http.security_headers` | `complete`                           | grades HSTS, CSP, framing, nosniff, Referrer-Policy, Permissions-Policy and COOP/COEP/CORP; weighted 0-100 score with per-header findings | full guard on the request and every redirect hop; size, time and redirect caps                         |
| `port.scan`             | **`task`**                           | bounded TCP connect scan; progress via `statusMessage`, honours `tasks/cancel`                                                            | requires the Tasks extension; hard port cap; asks for confirmation above 32 ports; bounded concurrency |
| `recon.sweep`           | **`input_required`** then `complete` | composite DNS -> TLS -> headers                                                                                                           | sends nothing until the operator confirms authorisation, then scope, over two round trips              |

Every tool declares a JSON Schema 2020-12 `inputSchema` and returns structured
output alongside a text rendering.

---

## The Tasks flow

```mermaid
sequenceDiagram
    autonumber
    participant C as Client
    participant LB as Load balancer
    participant S as Any server instance
    participant T as Task store

    C->>LB: tools/call port.scan<br/>_meta.clientCapabilities.extensions<br/>= { "io.modelcontextprotocol/tasks": {} }
    LB->>S: (any instance)
    Note over S: capability gate:<br/>no declaration means -32021,<br/>never a task it cannot poll
    S->>T: create(task) - committed before responding
    S-->>C: result { resultType: "task",<br/>task: { taskId, status: "working", ttlMs, pollIntervalMs } }

    Note over S: work continues after the response

    loop every pollIntervalMs
        C->>LB: tasks/get { taskId }
        LB->>S: (possibly a different instance)
        S->>T: read
        S-->>C: { status: "working", task.statusMessage: "scanned 12/22 ..." }
    end

    opt scan wider than 32 ports
        S->>T: status = input_required + inputRequests
        C->>S: tasks/get { taskId }
        S-->>C: { status: "input_required", inputRequests: { "large-scan-confirmation": ... } }
        C->>S: tasks/update { taskId, inputResponses: { "large-scan-confirmation": { confirmed: true } } }
        S-->>C: { resultType: "complete" }  (empty ack)
        Note over S,T: unknown or already-satisfied keys<br/>are IGNORED, not rejected
    end

    opt operator changes their mind
        C->>S: tasks/cancel { taskId }
        S-->>C: { resultType: "complete" }  (empty ack)
        Note over S: cooperative - the task may still<br/>reach completed or failed
    end

    S->>T: status = completed (terminal, never changes again)
    C->>S: tasks/get { taskId }
    S-->>C: { status: "completed", result: { ... the synchronous answer ... } }
```

## The MRTR confirmation flow

`recon.sweep` will not emit a packet until a human says so — twice.

```mermaid
sequenceDiagram
    autonumber
    participant C as Client
    participant A as Instance A
    participant B as Instance B

    C->>A: tools/call recon.sweep { host }
    Note over A: no traffic sent yet
    A-->>C: resultType: "input_required"<br/>inputRequests: { "scan-authorisation": elicitation/create }<br/>continuationToken: signed(round 1, args, answers)

    Note over C: operator confirms authorisation

    C->>B: tools/call recon.sweep<br/>continuationToken + inputResponses<br/>{ "scan-authorisation": { authorised: true, reference } }
    Note over B: B has never seen this call before.<br/>The token carries everything.
    B-->>C: resultType: "input_required"<br/>inputRequests: { "scan-scope": elicitation/create }<br/>continuationToken: signed(round 2, args, answers so far)

    Note over C: operator picks dns / tls / headers

    C->>A: tools/call recon.sweep<br/>continuationToken + inputResponses<br/>{ "scan-scope": { checks: ["dns","tls","headers"] } }
    A->>A: resolve, then TLS, then headers,<br/>each through the SSRF guard
    A-->>C: resultType: "complete"<br/>structuredContent: { authorisation, scope, dns, tls, headers, errors }
```

The continuation token is `base64url(payload).base64url(HMAC-SHA256)`. It is
signed, not encrypted, and never carries secrets. It exists because the
specification says cross-request state must be referenced by an explicit
identifier the client passes each time — and a self-contained token is the only
form of that which survives a round-robin load balancer with no shared store.

---

## Worked examples

Real request/response pairs, taken from `npm run example:client`. `_meta` is
shown in full the first time and elided afterwards.

### 1. A plain `tools/call`

```http
POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: dns.resolve
```

```json
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "dns.resolve",
    "arguments": { "host": "example.com", "recordTypes": ["A", "AAAA"] },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "example-client", "version": "0.1.0" },
      "io.modelcontextprotocol/clientCapabilities": {
        "elicitation": {},
        "extensions": { "io.modelcontextprotocol/tasks": {} }
      }
    }
  }
}
```

```json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "resultType": "complete",
    "content": [
      {
        "type": "text",
        "text": "DNS records for example.com\nA: [\"104.20.23.154\",\"172.66.147.243\"]\nAAAA: [\"2606:4700:10::6814:179a\",\"2606:4700:10::ac42:93f3\"]"
      }
    ],
    "structuredContent": {
      "host": "example.com",
      "records": {
        "A": ["104.20.23.154", "172.66.147.243"],
        "AAAA": ["2606:4700:10::6814:179a", "2606:4700:10::ac42:93f3"]
      },
      "addressClassification": [
        {
          "address": "104.20.23.154",
          "category": "public",
          "globallyRoutable": true,
          "reason": "globally routable unicast"
        },
        { "...": "one entry per resolved address" }
      ],
      "errors": {}
    },
    "_meta": {
      "io.modelcontextprotocol/serverInfo": {
        "name": "mcp-stateless-recon",
        "title": "Stateless MCP Recon Toolserver",
        "version": "0.1.0"
      },
      "io.modelcontextprotocol/protocolVersion": "2026-07-28"
    }
  }
}
```

### 2. A task-returning call, then a poll

```http
POST /mcp    Mcp-Method: tools/call    Mcp-Name: port.scan
```

```json
{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tools/call",
  "params": {
    "name": "port.scan",
    "arguments": { "host": "127.0.0.1", "preset": "web", "timeoutMs": 400 },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {
        "extensions": { "io.modelcontextprotocol/tasks": {} }
      }
    }
  }
}
```

The response arrives immediately. The task is already committed to the store, so
the `taskId` is pollable with no grace period.

```json
{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "resultType": "task",
    "task": {
      "taskId": "ba424d57-edee-4c72-b890-6d5979884e49",
      "status": "working",
      "createdAt": "2026-09-15T14:38:23.634Z",
      "ttlMs": 600000,
      "pollIntervalMs": 500,
      "statusMessage": "resolving 127.0.0.1"
    },
    "_meta": { "io.modelcontextprotocol/serverInfo": { "...": "..." } }
  }
}
```

Poll it. Note `Mcp-Name` mirrors the `taskId`, so a gateway can meter per task.

```http
POST /mcp    Mcp-Method: tasks/get    Mcp-Name: ba424d57-edee-4c72-b890-6d5979884e49
```

```json
{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "tasks/get",
  "params": { "taskId": "ba424d57-edee-4c72-b890-6d5979884e49", "_meta": { "...": "..." } }
}
```

```json
{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "resultType": "complete",
    "task": {
      "taskId": "ba424d57-edee-4c72-b890-6d5979884e49",
      "status": "completed",
      "createdAt": "2026-09-15T14:38:23.634Z",
      "ttlMs": 600000,
      "pollIntervalMs": 500,
      "statusMessage": "completed"
    },
    "status": "completed",
    "result": {
      "resultType": "complete",
      "content": [
        {
          "type": "text",
          "text": "TCP connect scan of 127.0.0.1 (127.0.0.1)\nports requested: 8, probed: 8\nopen: none"
        }
      ],
      "structuredContent": {
        "host": "127.0.0.1",
        "address": "127.0.0.1",
        "scanned": 8,
        "open": [],
        "results": [{ "port": 80, "state": "closed", "latencyMs": 3, "reason": "ECONNREFUSED" }],
        "cancelled": false
      }
    }
  }
}
```

Two details worth noticing. The `status` is `completed`, which is **terminal**:
a later `tasks/cancel` will be acknowledged but will not change it. And the
nested `result` is exactly what a synchronous call would have returned,
`resultType` and all.

### 3. An `input_required` round trip

```http
POST /mcp    Mcp-Method: tools/call    Mcp-Name: recon.sweep
```

```json
{
  "jsonrpc": "2.0",
  "id": 6,
  "method": "tools/call",
  "params": {
    "name": "recon.sweep",
    "arguments": { "host": "example.com" },
    "_meta": { "...": "..." }
  }
}
```

No packet has been sent to `example.com` at this point:

```json
{
  "jsonrpc": "2.0",
  "id": 6,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "scan-authorisation": {
        "method": "elicitation/create",
        "params": {
          "message": "About to run a reconnaissance sweep against example.com. Confirm you are authorised to test this host - an in-scope bug bounty target, or a system you own or have written permission to assess.",
          "requestedSchema": {
            "type": "object",
            "properties": {
              "authorised": {
                "type": "boolean",
                "description": "I am authorised to test this host."
              },
              "reference": { "type": "string", "maxLength": 200 }
            },
            "required": ["authorised"],
            "additionalProperties": false
          }
        }
      }
    },
    "continuationToken": "eyJ0b29sIjoicmVjb24uc3dlZXAiLCJyb3VuZCI6MSwiYXJncyI6eyJob3N0IjoiZXhhbXBsZS5jb20ifSwiYW5zd2VycyI6e30sImV4cGlyZXNBdCI6MTc4OTQ4MzQwNDE0Miwibm9uY2UiOiJGN0FydFgyY0RiWVAifQ.9EPnWj6WiZsmPxddNg98CDL6vrAIQkxrAZc5i8BeiOo"
  }
}
```

The client replays the token with the answer. Note that it does **not** resend
`arguments` — they travel inside the token.

```json
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "recon.sweep",
    "continuationToken": "eyJ0b29sIjoicmVjb24uc3dlZXAiLCJyb3VuZCI6MSwi...",
    "inputResponses": {
      "scan-authorisation": { "authorised": true, "reference": "example-programme" }
    },
    "_meta": { "...": "..." }
  }
}
```

```json
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "scan-scope": {
        "method": "elicitation/create",
        "params": {
          "message": "Which checks should the sweep of example.com run?",
          "requestedSchema": {
            "type": "object",
            "properties": {
              "checks": {
                "type": "array",
                "items": { "type": "string", "enum": ["dns", "tls", "headers"] }
              }
            },
            "required": ["checks"]
          }
        }
      }
    },
    "continuationToken": "eyJ0b29sIjoicmVjb24uc3dlZXAiLCJyb3VuZCI6Miwi..."
  }
}
```

One more round and the sweep runs, returning `resultType: "complete"` with the
authorisation reference preserved in the report for the audit trail.

Answer `{ "authorised": false }` instead and nothing is scanned:

```json
{
  "jsonrpc": "2.0",
  "id": 7,
  "error": {
    "code": 1004,
    "message": "the operator did not confirm authorisation to test example.com; no traffic was sent",
    "data": { "host": "example.com" }
  }
}
```

`1004` is a positive integer deliberately: new application codes belong
**outside** `-32768..-32000`.

### 4. A `-32020` header mismatch

The body says `tools/list`; the header claims `tools/call`. A gateway routing on
the header would have billed and authorised something the server never ran.

```http
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: port.scan
```

```json
{ "jsonrpc": "2.0", "id": 9, "method": "tools/list", "params": { "_meta": { "...": "..." } } }
```

```http
HTTP/1.1 400 Bad Request
```

```json
{
  "jsonrpc": "2.0",
  "id": 9,
  "error": {
    "code": -32020,
    "message": "HTTP header mcp-method disagrees with the JSON-RPC body",
    "data": { "header": "mcp-method", "headerValue": "tools/call", "bodyValue": "tools/list" }
  }
}
```

---

## Spec conformance

Each row names a requirement and the test that proves it. Run `npm test` to
check the column on the right.

| Requirement (2026-07-28)                                                         | Proven by                                                                                                 |
| -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| No `initialize` handshake; the first request may be a real call                  | `conformance/stateless-requests.test.ts`                                                                  |
| Servers MUST NOT issue or require a session id                                   | `conformance/stateless-requests.test.ts`                                                                  |
| Servers MUST NOT infer state from prior requests on the same connection          | `conformance/stateless-requests.test.ts`                                                                  |
| A request omitting `_meta.protocolVersion` MUST be `-32602` / HTTP 400           | `conformance/stateless-requests.test.ts`                                                                  |
| A request omitting `_meta.clientCapabilities` MUST be `-32602`                   | `conformance/stateless-requests.test.ts`                                                                  |
| Results SHOULD carry `io.modelcontextprotocol/serverInfo` in `_meta`             | `conformance/stateless-requests.test.ts`                                                                  |
| `_meta` key grammar: prefix, labels, name                                        | `protocol/meta-key-grammar.test.ts` (58 cases)                                                            |
| A prefix whose **second label** is `modelcontextprotocol`/`mcp` is reserved      | `protocol/meta-key-grammar.test.ts`                                                                       |
| `traceparent`/`tracestate`/`baggage` are the only prefix-exempt keys             | `protocol/meta-key-grammar.test.ts`                                                                       |
| An unsupported protocol version MUST be `-32022`                                 | `protocol/request-meta.test.ts`, `conformance/stateless-requests.test.ts`                                 |
| `Mcp-Method` disagreeing with the body MUST be `-32020`                          | `conformance/routing-headers.test.ts`                                                                     |
| `Mcp-Name` disagreeing with the body MUST be `-32020`                            | `conformance/routing-headers.test.ts`                                                                     |
| `Mcp-Name` present on a method that addresses nothing MUST be `-32020`           | `conformance/routing-headers.test.ts`                                                                     |
| Header protocol version disagreeing with `_meta` MUST be `-32020`                | `conformance/routing-headers.test.ts`                                                                     |
| Every result MUST carry `resultType`                                             | `conformance/result-types.test.ts`                                                                        |
| `input_required` carries an `InputRequiredResult` the client can answer          | `conformance/result-types.test.ts`, `conformance/multi-round-trip.test.ts`                                |
| Extensions MAY add `resultType` values; the server emits no others               | `conformance/result-types.test.ts`                                                                        |
| `-32021` MUST carry `data.requiredCapabilities`, HTTP 400                        | `conformance/capabilities.test.ts`, `protocol/errors.test.ts`                                             |
| Undefined codes in `-32099..-32020` MUST NOT be emitted                          | `protocol/errors.test.ts`                                                                                 |
| New codes MUST NOT be allocated in `-32019..-32000`                              | `protocol/errors.test.ts`                                                                                 |
| `-32002` and `-32042` MUST NOT be emitted                                        | `protocol/errors.test.ts`, `conformance/error-codes.test.ts`                                              |
| New application codes SHOULD sit outside `-32768..-32000`                        | `conformance/error-codes.test.ts`                                                                         |
| Unknown tool / unknown task use `-32602`, not `-32002`                           | `conformance/error-codes.test.ts`                                                                         |
| `server/discover` reports capabilities, including `capabilities.extensions`      | `conformance/discovery-and-caching.test.ts`                                                               |
| `tools/list` results carry `ttlMs` and `cacheScope`                              | `conformance/discovery-and-caching.test.ts`                                                               |
| A task MUST NOT be returned to a client that did not declare the extension       | `conformance/capabilities.test.ts`, `conformance/result-types.test.ts`                                    |
| A task MUST be durably created before its `taskId` is returned                   | `conformance/tasks-lifecycle.test.ts`                                                                     |
| `tasks/get` returns `result` on `completed`, `error` on `failed`                 | `conformance/tasks-lifecycle.test.ts`                                                                     |
| `tasks/get` returns `inputRequests` on `input_required`                          | `conformance/tasks-lifecycle.test.ts`                                                                     |
| `tasks/update` is acknowledged with an empty result                              | `conformance/tasks-lifecycle.test.ts`                                                                     |
| Responses for unknown keys MUST be ignored, not rejected                         | `conformance/tasks-lifecycle.test.ts`, `extensions/task-store.test.ts`                                    |
| Responses for already-satisfied keys MUST be ignored                             | `conformance/tasks-lifecycle.test.ts`, `extensions/task-store.test.ts`                                    |
| `tasks/cancel` is acknowledged with an empty result; cancellation is cooperative | `conformance/tasks-lifecycle.test.ts`, `extensions/task-runner.test.ts`                                   |
| `completed`/`failed`/`cancelled` are terminal and MUST NOT change                | `extensions/task-state-machine.test.ts` (every illegal transition), `conformance/tasks-lifecycle.test.ts` |
| `subscriptions/listen` streams notifications with `subscriptionId` in `_meta`    | `conformance/subscriptions.test.ts`                                                                       |
| Subscription state is scoped to the request, not the connection                  | `conformance/subscriptions.test.ts`                                                                       |
| A stateless follow-up may be served by a different instance                      | `conformance/multi-round-trip.test.ts`                                                                    |
| RFC 8707: a token minted for another resource server MUST be rejected            | `security/resource-indicator.test.ts`, `conformance/authorization.test.ts`                                |
| RFC 9207: an untrusted `iss` MUST be rejected                                    | `security/resource-indicator.test.ts`                                                                     |
| STDIO takes credentials from the environment, not the wire                       | `transport/stdio.test.ts`                                                                                 |
| JSON Schema `$ref` MUST NOT be dereferenced automatically                        | `schema/validator.test.ts`                                                                                |

---

## Security controls

This is a tool that opens sockets to hosts named by whoever is talking to the
model. That makes it, by construction, an SSRF engine unless it is built not to
be. The guard is the most-tested code in the repository.

### The target guard (`src/security/target-guard.ts`)

1. **Scheme allowlist.** Only `http:` and `https:`. `file:`, `gopher:`, `ftp:`,
   `data:`, `ws:` and URL-embedded credentials are refused before anything else
   happens.
2. **Resolve once, classify everything.** The host is resolved and _every_
   returned address is classified. The target is refused if **any** address is
   non-routable — "any", not "all", because a host that answers with one public
   and one internal address is the multi-A-record rebinding trick.
3. **Connect to the address that was validated.** The socket is pinned through a
   custom `lookup`, so a resolver that changes its answer between the check and
   the connect cannot move the connection to `127.0.0.1`. TLS still uses the
   hostname for SNI and certificate verification, so pinning costs nothing.
   `tests/security/target-guard.test.ts` includes a resolver that answers
   `127.0.0.1` once and something else afterwards, and asserts the connection
   went where it was checked.
4. **Re-validate every redirect hop.** A public URL that 302s to
   `http://169.254.169.254/latest/meta-data/` is the single most common SSRF
   payload in the wild; the scheme, the allowlist and the range table are all
   re-applied on each hop.
5. **Allowlist mode.** `--allow` accepts hostnames, `*.suffix` wildcards, IP
   literals and IPv4 CIDRs, and is enforced before resolution and again after
   every redirect.
6. **Caps.** Redirects, response size, per-connection timeout, request body size.

### The range table (`src/security/ip-classify.ts`)

Both address families, parsed from scratch, classified against an ordered rule
table. Blocked: loopback (`127/8`, `::1`), RFC 1918 (`10/8`, `172.16/12`,
`192.168/16`), CGNAT (`100.64/10`), link-local (`169.254/16`, including
`169.254.169.254`, and `fe80::/10`), unique-local (`fc00::/7`), multicast,
broadcast, unspecified, documentation and benchmark ranges, `240/4`, deprecated
site-local, Teredo and 6to4 (both of which embed an arbitrary IPv4 endpoint),
and the discard prefix.

IPv4-mapped and NAT64-embedded IPv6 addresses are **decomposed and the embedded
IPv4 address is classified**, so `::ffff:127.0.0.1` and
`64:ff9b::169.254.169.254` are refused. The IPv4 parser rejects leading zeros,
because some resolvers read `0177.0.0.1` as octal.

`tests/security/ip-classify.test.ts` is a 101-case table that includes the
address immediately either side of every boundary. It is the highest-value test
file here: a regression in it is a live SSRF.

### Authorization (`src/security/auth/resource-indicator.ts`)

RFC 8707 audience binding, checked against this server's own canonical resource
URI after signature and expiry. A token minted for another MCP server is
rejected — that is the token-passthrough / confused-deputy defence, and it is
tested against exact mismatches, look-alike hosts and path prefixes. RFC 9207
issuer validation on top. `alg: none` and algorithm confusion are rejected
outright rather than "supported".

### Everything else

- **Rate limiting** per target host (token bucket) and **bounded concurrency**
  inside `port.scan`, so a 128-port sweep does not open 128 sockets.
- **A hard port cap** that _refuses_ an over-large request rather than silently
  truncating it — a report that quietly scanned fewer ports than asked is a lie.
- **Confirmation gates.** `recon.sweep` sends nothing without two explicit
  answers; `port.scan` asks again above 32 ports. Both record the authorisation
  reference in the report.
- **Continuation tokens are HMAC-signed**, expire, and are bound to the tool
  that issued them.
- **`$ref` is never dereferenced.** The MCP specification forbids automatic
  dereferencing of schema references, and a network-fetching `$ref` resolver
  inside a tool server is a textbook SSRF sink. The validator reports `$ref` as
  unsupported and does not evaluate behind it.
- **`clientInfo` and `serverInfo` are never used for security decisions.** They
  are self-reported and unauthenticated, and the code says so where it handles
  them.

---

## Project layout

```
src/
  protocol/     types, _meta grammar, error codes, JSON-RPC framing
  server/       dispatcher, tool registry, server/discover, subscriptions
  extensions/
    tasks/      state machine, store, RPC methods, runner
  transport/    streamable HTTP, routing headers, STDIO
  security/     target guard, IP classifier, rate limiting,
                continuation tokens, auth/resource-indicator
  tools/        the five recon tools + the pure header grader
  schema/       JSON Schema 2020-12 subset validator
  bin/          CLI
tests/
  conformance/  spec requirements, named after the requirement
  protocol/ security/ extensions/ tools/ schema/ transport/
docs/PROTOCOL.md     the 2026-07-28 changes, with citations
examples/client.ts   a minimal spec-correct client that prints the transcript
```

Every source file opens with a comment naming what it implements and citing the
spec section or RFC.

## Engineering

- TypeScript with `strict`, `noUncheckedIndexedAccess` and
  `exactOptionalPropertyTypes`; ESM; Node >= 20.
- ESLint flat config on `strictTypeChecked` + `stylisticTypeChecked`, and
  Prettier. Both pass with no suppressions except one documented
  `eslint-disable` for Unicode code-point counting in the schema validator.
- Vitest. `npm run test:coverage` for coverage.
- CI on Node 20 and 22, which also checks formatting, asserts the runtime
  dependency count is zero, and runs the example client end to end.

### Zero runtime dependencies

```console
$ npm ls --omit=dev --depth=0
mcp-stateless-recon@0.1.0 /path/to/mcp-stateless-recon
`-- (empty)
```

Development dependencies are TypeScript, Vitest, ESLint, typescript-eslint,
Prettier and `@types/node`. Nothing ships. The protocol, the JSON Schema subset,
the IP parsers, the JWT verification and the HTTP plumbing are all implemented
against `node:` built-ins. CI fails the build if a runtime dependency appears.

---

## Known limitations / not implemented

Stated plainly, because a portfolio piece that overclaims is worse than one that
does less.

- **No OAuth authorization server.** This validates tokens; it does not mint
  them. There is no `/authorize`, no `/token`, no PKCE flow. RFC 9207 issuer
  validation is enforced on the server side only — the client half, validating
  `iss` before redeeming a code, is documented in `docs/PROTOCOL.md` but is by
  definition a client's job.
- **Only HS256 bearer tokens.** No RS256, no JWKS fetching, no key rotation. A
  real deployment wants asymmetric verification against the authorization
  server's JWKS.
- **No Dynamic Client Registration and no CIMD.** DCR is deprecated in this
  revision and CIMD replaces it; neither is implemented, because neither belongs
  in a resource server.
- **The JSON Schema validator is a documented subset**, not a complete 2020-12
  implementation. It covers `type`, `required`, `properties`,
  `additionalProperties`, `enum`, `const`, `minimum`/`maximum`,
  `exclusiveMinimum`/`exclusiveMaximum`, `minLength`/`maxLength`, `pattern`,
  `items`, `minItems`/`maxItems` and `uniqueItems`. `allOf`, `anyOf`, `oneOf`,
  `not`, `if`/`then`/`else`, `patternProperties`, `dependentSchemas`, `format`
  assertions and `$ref` resolution are **not** implemented. `validate()` returns
  `unsupportedKeywords` so a caller can fail closed, and a conformance test
  asserts the bundled tools stay inside the subset.
- **The task store is in-memory and per-instance.** A `taskId` created on one
  instance is not visible on another. The `TaskStore` interface exists so a
  Redis or Postgres implementation can be dropped in, but none is included.
  Multi-round-trip calls _do_ work across instances, because their state lives
  in the signed token rather than the store.
- **Rate limits are per instance.** Behind a load balancer each instance
  enforces its own share; there is no shared budget.
- **No MCP Apps / UI extension**, no resources, no prompts, no completions. The
  server implements `server/discover`, `tools/*`, `tasks/*` and
  `subscriptions/listen`. `Mcp-Name` handling for `prompts/get` and
  `resources/read` is implemented and tested, but those methods are not served.
- **No JSON-RPC batching.** The revision does not require it and it interacts
  badly with the routing headers, which describe a single method and name.
- **`port.scan` is a TCP connect scan only.** No SYN scanning, no service
  fingerprinting, no banner grabbing, no UDP. It answers "is this port accepting
  connections" and nothing more, on purpose.
- **`tls.inspect` reports on certificates rather than trusting them.** It sets
  `rejectUnauthorized: false` so it can describe expired and self-signed chains,
  and reports `authorized` honestly. Do not reuse that connection for anything.
- **Deprecated capabilities are accepted, not implemented.** Roots, Sampling and
  Logging appear in `ClientCapabilities` for the 12-month support window and are
  otherwise ignored.
- **The 2026-07-28 revision is the only one spoken.** There is no
  backwards-compatibility shim for `2025-06-18` or earlier.

---

## Legal and ethical use

This is reconnaissance tooling. Scanning hosts you neither own nor have
permission to test is a criminal offence in most jurisdictions. The design makes
authorisation explicit — `recon.sweep` will not send a packet until an operator
confirms, `port.scan` asks again before a wide scan, and `--allow` pins the
whole process to an agreed scope — but the tool cannot know what you are
authorised to do. That part is yours.

See [SECURITY.md](SECURITY.md) for responsible disclosure.

## References

- [MCP specification, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28)
- [MCP base protocol, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic)
- [What changed in 2026-07-28](https://blog.modelcontextprotocol.io/posts/2026-07-28/)
- [Tasks extension overview](https://modelcontextprotocol.io/extensions/tasks/overview)
- [modelcontextprotocol/ext-tasks](https://github.com/modelcontextprotocol/ext-tasks)
- [RFC 8707 - Resource Indicators for OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8707)
- [RFC 9207 - OAuth 2.0 Authorization Server Issuer Identification](https://www.rfc-editor.org/rfc/rfc9207)

## License

MIT (c) 2026 Abdulsalam A. See [LICENSE](LICENSE).