Skip to main content
Glama

mcp-stateless-recon

CI License: MIT Node Runtime dependencies: 0 MCP 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.


Related MCP server: spectral

Quickstart

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:

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

Or over stdio, for a desktop client:

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:

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.


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

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.

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

POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: dns.resolve
{
  "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": {} }
      }
    }
  }
}
{
  "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

POST /mcp    Mcp-Method: tools/call    Mcp-Name: port.scan
{
  "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.

{
  "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.

POST /mcp    Mcp-Method: tasks/get    Mcp-Name: ba424d57-edee-4c72-b890-6d5979884e49
{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "tasks/get",
  "params": { "taskId": "ba424d57-edee-4c72-b890-6d5979884e49", "_meta": { "...": "..." } }
}
{
  "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

POST /mcp    Mcp-Method: tools/call    Mcp-Name: recon.sweep
{
  "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:

{
  "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.

{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "recon.sweep",
    "continuationToken": "eyJ0b29sIjoicmVjb24uc3dlZXAiLCJyb3VuZCI6MSwi...",
    "inputResponses": {
      "scan-authorisation": { "authorised": true, "reference": "example-programme" }
    },
    "_meta": { "...": "..." }
  }
}
{
  "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:

{
  "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.

POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: port.scan
{ "jsonrpc": "2.0", "id": 9, "method": "tools/list", "params": { "_meta": { "...": "..." } } }
HTTP/1.1 400 Bad Request
{
  "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

$ 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.


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 for responsible disclosure.

References

License

MIT (c) 2026 Abdulsalam A. See LICENSE.

Related MCP Connectors

Related MCP Servers