Codex does not follow MCP `tools/list` pagination via `nextCursor`

Open 💬 3 comments Opened Jun 18, 2026 by YotaroMatsui

What version of Codex CLI is running?

codex-cli 0.140.0

What subscription do you have?

ChatGPT Pro

Which model were you using?

gpt-5.5

What platform is your computer?

Darwin 25.5.0 arm64 arm

What terminal emulator and version are you using (if applicable)?

zsh (/bin/zsh)

Codex doctor report

overallStatus: fail
codexVersion: 0.140.0
config.load: ok
mcp.config: ok
auth.credentials: ok
network.provider_reachability: ok
network.websocket_reachability: ok
terminal.env: fail because TERM=dumb
updates.status: ok, latest version: 0.140.0

What issue are you seeing?

Codex CLI appears not to follow MCP tools/list pagination when building the callable MCP tool set.

When an MCP server returns a tools/list response with nextCursor, Codex exposes only the first page of tools. Tools returned on later pages are not available as callable MCP tools.

This makes paginated MCP tool discovery incomplete.

I first observed this with an MCP server where MCP Inspector can load additional tools via “more tools” and execute a tool from a later page, but Codex only exposes the first page.

What steps can reproduce the bug?

I reproduced this with a minimal local stdio MCP server.

The server behaves as follows:

  • First tools/list response:
  • returns 30 tools
  • includes nextCursor: "page-2"
  • Second tools/list response with cursor: "page-2":
  • returns an additional tool named github-mcp-server___search_repositories

When connecting Codex CLI 0.140.0 to this server, Codex only exposed the first 30 tools. The tool from the second page was not callable.

Server-side received requests:

{"method":"initialize","id":0}
{"method":"tools/list","id":1,"params":{"_meta":{"progressToken":0}}}

There was no second tools/list request with cursor: "page-2".
Codex output:

probe exposes 30 callable tools.

FAIL:
github-mcp-server___search_repositories is not available
only mcp__probe.page1_tool_* tools were callable

Source investigation:
In Codex rust-v0.140.0, the callable tool discovery path appears to call tools/list once with params = None:
https://github.com/openai/codex/blob/rust-v0.140.0/codex-rs/codex-mcp/src/rmcp_client.rs#L345-L353

The lower-level client already carries next_cursor:
https://github.com/openai/codex/blob/rust-v0.140.0/codex-rs/rmcp-client/src/rmcp_client.rs#L487-L520

Codex also already follows cursor pagination for resources/list / resource_templates/list:
https://github.com/openai/codex/blob/rust-v0.140.0/codex-rs/codex-mcp/src/connection_manager.rs#L541-L568

What is the expected behavior?

If tools/list returns nextCursor, Codex should continue requesting subsequent pages by sending another tools/list request with that cursor.

Example:

{
  "method": "tools/list",
  "params": {
    "cursor": "page-2"
  }
}

Codex should expose tools from all pages as callable MCP tools.
This would make Codex’s MCP tool discovery consistent with MCP pagination and with Codex’s existing resource pagination behavior.

Additional information

MCP spec references:

  • tools/list supports pagination:

https://modelcontextprotocol.io/specification/2025-06-18/server/tools

  • MCP pagination:

https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/pagination

I searched for existing Codex issues/PRs using:

  • tools/list nextCursor
  • MCP pagination tools/list
  • list_tools_with_connector_ids

I did not find an existing matching issue.

View original on GitHub ↗

3 Comments

YotaroMatsui · 2 months ago

Here is the minimal repro server I used.

Save this as /tmp/mcp_pagination_probe.py:

#!/usr/bin/env python3
import json
import sys


def send(obj):
    sys.stdout.write(json.dumps(obj, separators=(",", ":")) + "\n")
    sys.stdout.flush()


def tool(name):
    return {
        "name": name,
        "description": f"Probe tool {name}",
        "inputSchema": {
            "type": "object",
            "properties": {},
            "additionalProperties": False,
        },
    }


PAGE1 = [tool(f"page1_tool_{i:02d}") for i in range(1, 31)]
PAGE2 = [tool("github-mcp-server___search_repositories")]


for line in sys.stdin:
    line = line.strip()
    if not line:
        continue

    req = json.loads(line)
    print(
        json.dumps(
            {
                "received": {
                    "method": req.get("method"),
                    "id": req.get("id"),
                    "params": req.get("params"),
                }
            }
        ),
        file=sys.stderr,
        flush=True,
    )

    method = req.get("method")
    req_id = req.get("id")

    if method == "initialize":
        send(
            {
                "jsonrpc": "2.0",
                "id": req_id,
                "result": {
                    "protocolVersion": "2025-06-18",
                    "capabilities": {"tools": {}},
                    "serverInfo": {"name": "pagination-probe", "version": "0.1.0"},
                },
            }
        )
    elif method == "notifications/initialized":
        continue
    elif method == "tools/list":
        cursor = (req.get("params") or {}).get("cursor")
        if cursor is None:
            send(
                {
                    "jsonrpc": "2.0",
                    "id": req_id,
                    "result": {"tools": PAGE1, "nextCursor": "page-2"},
                }
            )
        elif cursor == "page-2":
            send(
                {
                    "jsonrpc": "2.0",
                    "id": req_id,
                    "result": {"tools": PAGE2},
                }
            )
        else:
            send(
                {
                    "jsonrpc": "2.0",
                    "id": req_id,
                    "error": {"code": -32602, "message": f"invalid cursor: {cursor}"},
                }
            )
    elif method == "tools/call":
        name = (req.get("params") or {}).get("name")
        send(
            {
                "jsonrpc": "2.0",
                "id": req_id,
                "result": {
                    "content": [{"type": "text", "text": f"called {name}"}],
                    "isError": False,
                },
            }
        )
    elif req_id is not None:
        send(
            {
                "jsonrpc": "2.0",
                "id": req_id,
                "error": {"code": -32601, "message": f"method not found: {method}"},
            }
        )

Then run:

codex exec --json --ignore-user-config --sandbox read-only \
  -c 'mcp_servers.probe={command="python3",args=["/tmp/mcp_pagination_probe.py"],startup_timeout_sec=10,default_tools_approval_mode="approve"}' \
  '
Use only the MCP server named probe.
Do not use shell commands.
First report how many callable tools are available from probe.
Then call github-mcp-server___search_repositories if it is available.
Return OK only if that page-2 tool call succeeds. Otherwise return FAIL with the exact reason.
'

Observed result:

probe exposes 30 callable tools.

FAIL:
github-mcp-server___search_repositories is not available
only mcp__probe.page1_tool_* tools were callable

The server logs show Codex sends only the first tools/list request:

{"method":"initialize","id":0}
{"method":"tools/list","id":1,"params":{"_meta":{"progressToken":0}}}

It never sends a second tools/list request with:

{"cursor":"page-2"}

So the reproduction does not depend on a specific gateway or a specific page size limit. It only depends on a valid paginated tools/list response with nextCursor.

timunrau · 1 month ago

Independent real-world reproduction using an HTTP MCP server built with Laravel MCP v0.5.9:

  • The server registers 23 tools.
  • The initial authenticated tools/list response returns exactly 15 tools and a nextCursor.
  • Codex exposes only those first 15 tools.
  • Requesting tools/list again with the returned cursor provides the remaining eight tools.
  • Cursor discovers the second-page tools against the same server.

Laravel MCP defaults the page size to 15:

https://github.com/laravel/mcp/blob/39e8da60eb7bce4737c5d868d35a3fe78938c129/src/Server.php#L102-L104

Its tools/list implementation applies cursor pagination here:

https://github.com/laravel/mcp/blob/39e8da60eb7bce4737c5d868d35a3fe78938c129/src/Server/Methods/ListTools.php#L15-L21

Increasing the server’s defaultPaginationLength to 50 works as a compatibility workaround, but Codex should follow nextCursor so paginated MCP servers work without client-specific configuration.

erictheng93 · 1 month ago

Root cause, minimal repro, and reference fix

Root cause. list_tools_for_client_uncached in codex-rs/codex-mcp/src/rmcp_client.rs issues a single list_tools_with_connector_ids(/*params*/ None, timeout) call and drops the next_cursor field of the response. The lower-level client already surfaces the cursor (ListToolsWithConnectorIdResult.next_cursor in codex-rs/rmcp-client/src/rmcp_client.rs), and Codex already follows cursors for resources/list and resources/templates/list (see the pagination loops in codex-mcp/src/connection_manager.rs, incl. a duplicate-cursor guard). tools/list is the only list endpoint missing that loop.

Real-world repro. codebase-memory-mcp 0.9.0 (stdio) exposes 14 tools, paginated 8 per page:

printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1.0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"cursor":"8"}}' \
| codebase-memory-mcp 2>/dev/null \
| jq -c 'select(.id>=2) | {id, count:(.result.tools|length), nextCursor:.result.nextCursor}'

Output:

{"id":2,"count":8,"nextCursor":"8"}
{"id":3,"count":6,"nextCursor":null}

Codex registers only the first 8 tools from this server. As a cross-client control, the same server binary registers all 14 tools in an MCP client that follows nextCursor (Claude Code), so the server side is spec-compliant.

Reference implementation. I applied the same loop pattern Codex already uses for resources (accumulate pages until next_cursor is None, bail on a repeated cursor) to list_tools_for_client_uncached: https://github.com/erictheng93/codex/commit/a27af5f52c280a09ec4dee23b5cf5d465e65c031. cargo check -p codex-mcp passes. Happy to open a PR with test coverage if the team would like to invite one per the contributing policy.