Hi Eric,

This problem seems like a major breakage in ChatGPT Apps behavior. I think it is worth escalating ASAP.

Per @tangweigangsir’s suggestion, I created a minimal reproducible example of a Python MCP server that demonstrates a description-driven dependent tool chain. It exposes three tools: first_call, second_call, and third_call. Each tool’s description tells the model which tool to call next after success, and each response returns the exact next
tool arguments. The final step requires finish_success=“finish_success”.

It fails with the same “toolchain” error that our actual app fails with:

You can easily reproduce the issue, simply run this server.py and ask ChatGPT to use it in Instant mode (without Auto thinking):

from __future__ import annotations

import argparse
import os
import secrets
import time
from typing import Literal

from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import Field


LOCAL_ALLOWED_HOSTS = ["127.0.0.1:*", "localhost:*", "[::1]:*"]
LOCAL_ALLOWED_ORIGINS = ["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"]

mcp = FastMCP(
    "dependent-tool-sequence-mrp",
    instructions=(
        "This server demonstrates a description-driven three-tool sequence. "
        "Call first_call first, then second_call, then third_call."
    ),
    stateless_http=True,
    json_response=True,
)

_runs: dict[str, dict[str, object]] = {}


def _csv_values(values: list[str]) -> list[str]:
    items: list[str] = []
    for value in values:
        items.extend(part.strip() for part in value.split(",") if part.strip())
    return items


def _dedupe(values: list[str]) -> list[str]:
    seen: set[str] = set()
    result: list[str] = []
    for value in values:
        if value not in seen:
            seen.add(value)
            result.append(value)
    return result


def _expanded_hosts(hosts: list[str]) -> list[str]:
    expanded: list[str] = []
    for host in hosts:
        expanded.append(host)
        if ":" not in host and not host.endswith(":*"):
            expanded.append(f"{host}:*")
    return _dedupe(expanded)


def _origins_for_hosts(hosts: list[str]) -> list[str]:
    origins: list[str] = []
    for host in hosts:
        if host in {"0.0.0.0", "::"}:
            continue
        if host.startswith(("http://", "https://")):
            origins.append(host)
            continue
        origins.extend([f"http://{host}", f"https://{host}"])
    return _dedupe(origins)


def _new_token(prefix: str) -> str:
    return f"{prefix}_{secrets.token_urlsafe(8)}"


@mcp.tool(
    name="first_call",
    description=(
        "Step 1 of 3. Call this tool first. If this tool returns status='success', "
        "the next action is to call the MCP tool named second_call with the run_id "
        "and first_call_token returned by this tool."
    ),
)
def first_call() -> dict[str, object]:
    """Start the dependent tool-call sequence."""
    run_id = _new_token("run")
    first_call_token = _new_token("first")
    _runs[run_id] = {
        "created_at": time.time(),
        "first_call_token": first_call_token,
        "second_call_token": None,
        "complete": False,
    }

    return {
        "status": "success",
        "run_id": run_id,
        "first_call_token": first_call_token,
        "next_tool": "second_call",
        "next_arguments": {
            "run_id": run_id,
            "first_call_token": first_call_token,
        },
    }


@mcp.tool(
    name="second_call",
    description=(
        "Step 2 of 3. Call this tool only after first_call returns status='success'. "
        "Use the exact run_id and first_call_token returned by first_call. If this "
        "tool returns status='success', the next action is to call the MCP tool named "
        "third_call with the run_id, second_call_token, and finish_success='finish_success'."
    ),
)
def second_call(
    run_id: str = Field(description="The run_id returned by first_call."),
    first_call_token: str = Field(description="The first_call_token returned by first_call."),
) -> dict[str, object]:
    """Continue the sequence after first_call."""
    run = _runs.get(run_id)
    if run is None:
        return {
            "status": "error",
            "message": "Unknown run_id. Call first_call before second_call.",
        }

    if run["first_call_token"] != first_call_token:
        return {
            "status": "error",
            "message": "Invalid first_call_token. Use the exact token returned by first_call.",
        }

    second_call_token = _new_token("second")
    run["second_call_token"] = second_call_token

    return {
        "status": "success",
        "run_id": run_id,
        "second_call_token": second_call_token,
        "next_tool": "third_call",
        "next_arguments": {
            "run_id": run_id,
            "second_call_token": second_call_token,
            "finish_success": "finish_success",
        },
    }


@mcp.tool(
    name="third_call",
    description=(
        "Step 3 of 3. Call this tool only after second_call returns status='success'. "
        "Use the exact run_id and second_call_token returned by second_call, and set "
        "finish_success exactly to 'finish_success'. If this tool returns status='success', "
        "finish the user interaction with a successful final response."
    ),
)
def third_call(
    run_id: str = Field(description="The run_id originally returned by first_call."),
    second_call_token: str = Field(description="The second_call_token returned by second_call."),
    finish_success: Literal["finish_success"] = Field(
        description="Must be exactly the string 'finish_success'."
    ),
) -> dict[str, object]:
    """Finish the sequence after second_call."""
    run = _runs.get(run_id)
    if run is None:
        return {
            "status": "error",
            "message": "Unknown run_id. Call first_call before third_call.",
        }

    if run["second_call_token"] != second_call_token:
        return {
            "status": "error",
            "message": "Invalid second_call_token. Use the exact token returned by second_call.",
        }

    run["complete"] = True
    return {
        "status": "success",
        "run_id": run_id,
        "finish_success": finish_success,
        "message": "Dependent MCP tool sequence completed successfully.",
    }


def main() -> None:
    parser = argparse.ArgumentParser(description="Minimal dependent-tool MCP server.")
    parser.add_argument(
        "--transport",
        choices=["http", "stdio"],
        default="http",
        help="Run as Streamable HTTP for EC2 or stdio for local MCP clients.",
    )
    parser.add_argument("--host", default="0.0.0.0", help="HTTP host bind address.")
    parser.add_argument("--port", type=int, default=8000, help="HTTP port.")
    parser.add_argument(
        "--allowed-host",
        action="append",
        default=[],
        help=(
            "Allowed HTTP Host header for Streamable HTTP. Repeat or comma-separate. "
            "Example: --allowed-host aleena-unilobed-karma.ngrok-free.dev"
        ),
    )
    parser.add_argument(
        "--allowed-origin",
        action="append",
        default=[],
        help=(
            "Allowed Origin header. Repeat or comma-separate. If omitted, origins are "
            "derived from allowed hosts."
        ),
    )
    parser.add_argument(
        "--disable-dns-rebinding-protection",
        action="store_true",
        help="Disable Host/Origin validation. Useful only for local experiments.",
    )
    args = parser.parse_args()

    if args.transport == "stdio":
        mcp.run(transport="stdio")
        return

    env_allowed_hosts = os.getenv("MCP_ALLOWED_HOSTS", "")
    env_allowed_origins = os.getenv("MCP_ALLOWED_ORIGINS", "")
    configured_hosts = _csv_values(args.allowed_host + [env_allowed_hosts])
    configured_origins = _csv_values(args.allowed_origin + [env_allowed_origins])

    allowed_hosts = _dedupe(LOCAL_ALLOWED_HOSTS + _expanded_hosts(configured_hosts))
    allowed_origins = _dedupe(
        LOCAL_ALLOWED_ORIGINS + configured_origins + _origins_for_hosts(configured_hosts)
    )
    mcp.settings.host = args.host
    mcp.settings.port = args.port
    mcp.settings.transport_security = TransportSecuritySettings(
        enable_dns_rebinding_protection=not args.disable_dns_rebinding_protection,
        allowed_hosts=allowed_hosts,
        allowed_origins=allowed_origins,
    )

    import uvicorn

    uvicorn.run(mcp.streamable_http_app(), host=args.host, port=args.port)


if __name__ == "__main__":
    main()