# Testing an MCP server with curl: initialize, the initialized notification and tools/list

> Use curl as an MCP server tester: the commands that open a Streamable HTTP session and list tools, the 405, 400 and 401 answers, and curl against MCP Inspector.

- Source: /learn/test-mcp-server-curl
- Updated: 2026-10-27
- Reading time: 7 minutes
- Tags: MCP, Streamable HTTP, curl, MCP Inspector, testing

---
An MCP server tester has to send 3 requests, and curl can send all of them. They are `initialize`, the `notifications/initialized` notification and `tools/list`, each a POST to the server's endpoint. Each POST carries `Accept: application/json, text/event-stream`. Each one after the first also carries `MCP-Protocol-Version`, and the `Mcp-Session-Id` the server issued, if it issued one. That is the handshake of MCP revision 2025-11-25, and a later section shows the request a 2026-07-28 server expects instead.

## Which MCP revision you are testing

The current revision is 2026-07-28, according to the MCP [versioning page](https://modelcontextprotocol.io/specification/versioning). Its [changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog) removes the `initialize` handshake and protocol-level sessions. Every request now states its version in `_meta`, and every server must answer `server/discover`.

The 2 MCP servers in AgenticWebScore's scan records still speak the older revision. Cloudflare's documentation server answered `initialize` with `2025-11-25` in its [scan record of 20 October 2026](/aws/cloudflare.com). This site's own server did the same on 25 October 2026.

The 2026-07-28 [compatibility rules](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning) let one server speak both revisions. They ask a server that speaks only 2026-07-28 to answer `initialize` with an error that names its versions. So start with the handshake, and read what comes back.

## Step 1: send initialize

The client opens the session with `initialize`: the version it wants, its capabilities, and its name. Save the response headers to a file, because a session id arrives in a header.

```sh
MCP=https://example.com/mcp
curl -sS -D headers.txt -X POST "$MCP" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}'
```

This site's server at `https://agenticwebscore.com/mcp` answers with one JSON object, trimmed here and broken into lines:

```json
{"jsonrpc":"2.0","id":1,"result":{
  "protocolVersion":"2025-11-25",
  "capabilities":{"tools":{"listChanged":false}},
  "serverInfo":{"name":"com.agenticwebscore/scan","title":"AgenticWebScore","version":"0.3.0"},
  "instructions":"Call scan_site with a domain to measure it, …"}}
```

`protocolVersion` is the version the server chose. The [lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) has it repeat yours when it supports it, and name another one when it does not. A `tools` key under `capabilities` means the server has tools to list. Then read the session id, if there is one:

```sh
SID=$(grep -i '^mcp-session-id:' headers.txt | cut -d' ' -f2 | tr -d '\r')
```

## When a server issues an Mcp-Session-Id

Under the [2025-11-25 transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports), a server may assign a session id in an `Mcp-Session-Id` header on its answer to `initialize`. When it does, the client must send that header on every later request. A server that requires it should answer `400` to a request without it. Once the server ends the session, it answers `404`, and the client starts over with a new `initialize`.

A stateless server issues no id. This site's server is one, so `headers.txt` has no such line and `$SID` stays empty. curl sends no header at all for `-H "Mcp-Session-Id: $SID"` when `$SID` is empty, so the commands below work for both kinds of server.

## Step 2: send the initialized notification

After a successful `initialize`, the client must send `notifications/initialized`. It is a notification, so it has no `id` and gets no answer body.

```sh
curl -sS -i -X POST "$MCP" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-11-25' \
  -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
```

```http
HTTP/2 202
cache-control: no-store
```

The transport requires `202 Accepted` with no body for a notification the server accepts. Send the version the server chose in step 1, not the one you asked for. A server that receives a version it does not support must answer `400 Bad Request`.

## Step 3: list the tools

```sh
curl -sS -X POST "$MCP" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-11-25' \
  -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
  | jq -r '.result.tools[].name'
```

```text
scan_site
get_score
list_checks
get_report
```

Each tool in the full answer has a `name`, a `description` and an `inputSchema`. A `nextCursor` in the result means the server has another page of tools. Stop here when you only want to test the server: `tools/call` runs a tool, and a tool can change something.

## A JSON answer or an event stream

For a request, the transport lets the server answer with `Content-Type: application/json` or with `Content-Type: text/event-stream`, and a client must accept both. This site's server always answers JSON.

A server that streams sends Server-Sent Events, and the answer is the `data:` line whose JSON carries your request's `id`. The 2025-11-25 transport also asks the server to open with an event that has an id and an empty `data` field. A stream has this shape:

```text
id: 1
data:

data: {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}
```

Add `-N` so curl prints each event as it arrives, and cut the JSON out with `sed`:

```sh
curl -sS -N --max-time 15 -X POST "$MCP" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-11-25' \
  -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
  | sed -n 's/^data: //p' | jq .
```

A stream may stay open after the answer, which is why the command has `--max-time`.

## Common errors and what they mean

| Answer | What it usually means |
| --- | --- |
| `405` to a GET | Normal: the server offers no stream on GET. |
| `405` to a POST | The wrong URL, or a server on the old HTTP+SSE transport. |
| `400` | An unsupported `MCP-Protocol-Version`, a missing session id, or a 2026-07-28 server refusing `initialize`. |
| `401` with `resource_metadata` | The server needs OAuth, and the URL names its metadata. |
| `404` with a session id | The session ended. Send `initialize` again. |
| `403` | The server rejected your `Origin` header. |
| `200` with a JSON-RPC `error` | The transport worked. The method or its params did not. |

**A 405 to a GET.** The 2025-11-25 transport has a server answer a GET with an event stream or with `405 Method Not Allowed`. A browser visit is a GET, so a 405 shows only that something answers. This site's endpoint:

```sh
curl -sS -i https://agenticwebscore.com/mcp
```

```http
HTTP/2 405
content-type: application/json; charset=utf-8
allow: POST
cache-control: no-store

{"error":"method_not_allowed","message":"Use POST with one JSON-RPC 2.0 message as application/json. This server offers no stream on GET."}
```

**A 400 for a version.** Sent `MCP-Protocol-Version: 2024-01-01`, this site's server answers `400` with the versions it speaks:

```json
{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Unsupported MCP-Protocol-Version. This server speaks 2025-11-25, 2025-06-18."}}
```

**A 401 with resource_metadata.** MCP [authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) is optional. A server that uses it must tell clients where its OAuth protected resource metadata is, for example in a `resource_metadata` parameter on the `WWW-Authenticate` header of its 401 ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728.html) §5.1). The specification's example:

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
                         scope="files:read"
```

Fetch that URL to find the authorization server. A 401 with no challenge only says the server refused you.

**A JSON-RPC error inside a 200.** Asked for `server/discover`, the 2026-07-28 method, this site's 2025-11-25 server answers HTTP 200 with error `-32601`, “Method not found”. The request reached the server, and the server does not know the method.

## The same test on a 2026-07-28 server

A 2026-07-28 server needs no handshake. Each POST carries the version twice, in the `MCP-Protocol-Version` header and in `_meta`. It also names its method in an `Mcp-Method` header, as the [2026-07-28 transport](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) requires:

```sh
curl -sS -X POST "$MCP" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/list' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl-test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'
```

A header that does not match the body gets `400` with error `-32020`. A version the server does not speak gets `400` with `-32022`, and the error lists the versions it supports. A `400` with neither error in its body comes from a legacy server: go back to step 1. Both servers in AgenticWebScore's stored scan records chose 2025-11-25, so no output is shown for this command.

## curl, MCP Inspector or an online tester

All 3 send the same requests, and each shows something the others hide.

| | curl | MCP Inspector | Online tester |
|---|---|---|---|
| Shows | The status, headers and body of each request, raw | The JSON-RPC transcript, the HTTP layer, and a form for each tool | What the vendor's page prints |
| Reaches | Anything your machine reaches | Anything your machine reaches | Public URLs, if its servers send the requests |
| Use it when | You need to read a 400, 401 or 405, or run a check in CI | You want to call a tool, or test a server on your laptop | You want a quick look at a public endpoint |

**curl** follows no protocol for you. You write each request and read each answer, and that is why it explains a bare `405` or `400` better than the other two. It runs in CI or over SSH with nothing to install.

The [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is the project's reference tester. `npx @modelcontextprotocol/inspector` starts a web client and needs Node 22.19.0 or newer. Its Tools tab builds a form from each input schema, its [Protocol tab](https://modelcontextprotocol.io/docs/2026-07-28/tools/inspector/web.md) shows the JSON-RPC transcript, and its Network tab shows status, headers and bodies. With `--cli`, `--method tools/list` prints the tool list from a script. A Protocol Era setting picks the `initialize` handshake (the default), `auto`, or `modern` for 2026-07-28. A call from the Tools tab runs the tool, so point it at a test server.

An **online tester** is a hosted page. If its servers send the requests, it cannot reach a server on your own machine or behind a VPN, and its operator sees your endpoint and any token you paste. If your browser sends them, your server needs CORS headers. Check which revision the tester speaks before you trust a failure: one that sends only the 2025-11-25 handshake, as the scan's check does, reports a 2026-07-28 server as failing.

## What AgenticWebScore's MCP server tester sends

To run it on your own site, use the [MCP server tester](/tools/mcp-server-tester).

The [MCP server check](/checks/mcp-server) is the scanner's MCP server tester. It runs steps 1 to 3 and nothing else. It finds the endpoint in a server card, in the AI catalog at `/.well-known/ai-catalog.json`, or in a homepage link whose relation is `mcp` or `mcp-server`. Failing those, it tries `/mcp`, which counts when a GET gets 405 and `Allow: POST`. It opens an endpoint only on the scanned site's host or one of its subdomains, on the same port, and never one on another site.

The session is at most 3 POSTs within 12 seconds. It offers `2025-11-25`, accepts the version the server chooses, and echoes `Mcp-Session-Id` when one is issued. It reads up to 64 KB of each answer, JSON or event stream. It never sends `tools/call`, `prompts/get` or `resources/read`, so the scan calls no tool, and it sends no credentials.

The check passes when `tools/list` names at least 1 tool. A 401 with a challenge, or a server with no tools, is a partial pass, and neither holds [level 4](/methodology#readiness-levels). One limit: the check sends only the 2025-11-25 handshake, so a server that speaks only 2026-07-28 shows up as a failed handshake. Cloudflare's record names 2 tools, and this site's record names 4.

How an agent learns the endpoint before step 1 is the subject of [MCP server cards](/learn/mcp-server-card). [Tools, not pages](/learn/agent-tools-not-pages) covers what to put in a tool declaration. To see the 3 requests and their status codes for your own server, [scan your site](/scan).
