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()