Skip to content

Commit 3187456

Browse files
committed
fix(app-server): restore typed MCP status inventory
1 parent bf18463 commit 3187456

6 files changed

Lines changed: 211 additions & 9 deletions

File tree

codex/app_server/models.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, Literal
3+
from typing import Literal
44

55
from pydantic import BaseModel, Field
66
from pydantic import ConfigDict as PydanticConfigDict
@@ -217,11 +217,11 @@ class ConfigRequirementsReadResult(AppServerResultModel):
217217

218218

219219
class McpServerStatus(AppServerResultModel):
220-
auth_status: str
220+
auth_status: protocol.McpAuthStatus
221221
name: str
222-
resource_templates: list[dict[str, Any]]
223-
resources: list[dict[str, Any]]
224-
tools: dict[str, dict[str, Any]]
222+
resource_templates: list[protocol.ResourceTemplate]
223+
resources: list[protocol.Resource]
224+
tools: dict[str, protocol.Tool]
225225

226226

227227
class McpServerStatusListResult(AppServerResultModel):

codex/protocol/types.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# generated by datamodel-codegen:
22
# ruff: noqa: F821
33
# filename: codex_app_server_protocol.schemas.json
4-
# timestamp: 2026-04-22T13:29:29+00:00
4+
# timestamp: 2026-04-22T18:36:41+00:00
55

66
from __future__ import annotations
77

@@ -6543,6 +6543,60 @@ class ServerNotification(RootModel):
65436543
]
65446544

65456545

6546+
class McpAuthStatus(RootModel[Literal["unsupported", "notLoggedIn", "bearerToken", "oAuth"]]):
6547+
root: Literal["unsupported", "notLoggedIn", "bearerToken", "oAuth"]
6548+
6549+
6550+
class Resource(BaseModel):
6551+
field_meta: Annotated[Any | None, Field(alias="_meta")] = None
6552+
annotations: Any | None = None
6553+
description: str | None = None
6554+
icons: list[Any] | None = None
6555+
mimeType: str | None = None
6556+
name: str
6557+
size: int | None = None
6558+
title: str | None = None
6559+
uri: str
6560+
6561+
6562+
class ResourceTemplate(BaseModel):
6563+
annotations: Any | None = None
6564+
description: str | None = None
6565+
mimeType: str | None = None
6566+
name: str
6567+
title: str | None = None
6568+
uriTemplate: str
6569+
6570+
6571+
class Tool(BaseModel):
6572+
field_meta: Annotated[Any | None, Field(alias="_meta")] = None
6573+
annotations: Any | None = None
6574+
description: str | None = None
6575+
icons: list[Any] | None = None
6576+
inputSchema: Any
6577+
name: str
6578+
outputSchema: Any | None = None
6579+
title: str | None = None
6580+
6581+
6582+
class McpServerStatus(BaseModel):
6583+
authStatus: McpAuthStatus
6584+
name: str
6585+
resourceTemplates: list[ResourceTemplate]
6586+
resources: list[Resource]
6587+
tools: dict[str, Tool]
6588+
6589+
6590+
class ListMcpServerStatusResponse(BaseModel):
6591+
data: list[McpServerStatus]
6592+
nextCursor: Annotated[
6593+
str | None,
6594+
Field(
6595+
description="Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return."
6596+
),
6597+
] = None
6598+
6599+
65466600
ClientRequest.model_rebuild()
65476601
ServerRequest.model_rebuild()
65486602
ServerNotification.model_rebuild()

scripts/generate_protocol_types.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@
44
from __future__ import annotations
55

66
import argparse
7+
import re
78
import subprocess # nosec B404
89
import sys
910
import tempfile
1011
from pathlib import Path
1112

1213
SUBPROCESS_TIMEOUT_SECONDS = 300
14+
EXTRA_PROTOCOL_SCHEMA_FILES = (
15+
# Response-only schemas are not reachable from the ClientRequest/
16+
# ServerRequest/ServerNotification envelope, but they are part of the
17+
# app-server protocol and are used by the SDK's typed RPC result models.
18+
"v2/ListMcpServerStatusResponse.json",
19+
)
1320

1421

1522
def parse_args() -> argparse.Namespace:
@@ -100,6 +107,52 @@ def generate_protocol_models(*, schema_path: Path, output_path: Path) -> None:
100107
)
101108

102109

110+
def extract_generated_model_definitions(text: str) -> str:
111+
lines = text.splitlines()
112+
for index, line in enumerate(lines):
113+
if line.startswith("class "):
114+
return "\n".join(lines[index:]).strip() + "\n"
115+
raise ValueError("generated model file does not contain any class definitions")
116+
117+
118+
def append_generated_model_definitions(*, target_path: Path, generated_path: Path) -> None:
119+
target = target_path.read_text(encoding="utf-8")
120+
definitions = extract_generated_model_definitions(
121+
generated_path.read_text(encoding="utf-8")
122+
).rstrip()
123+
target_lines = target.splitlines()
124+
insert_at = next(
125+
(
126+
index
127+
for index, line in enumerate(target_lines)
128+
if re.match(r"^\w+\.model_rebuild\(\)\s*$", line)
129+
),
130+
len(target_lines),
131+
)
132+
133+
updated = (
134+
"\n".join(target_lines[:insert_at]).rstrip()
135+
+ "\n\n"
136+
+ definitions
137+
+ "\n\n"
138+
+ "\n".join(target_lines[insert_at:]).lstrip()
139+
).rstrip()
140+
target_path.write_text(updated + "\n", encoding="utf-8")
141+
142+
143+
def append_extra_protocol_models(*, schema_dir: Path, output_path: Path) -> None:
144+
with tempfile.TemporaryDirectory(prefix="codex-extra-protocol-models-") as temp_dir:
145+
temp_path = Path(temp_dir)
146+
for relative_schema_path in EXTRA_PROTOCOL_SCHEMA_FILES:
147+
schema_path = schema_dir / relative_schema_path
148+
generated_path = temp_path / f"{schema_path.stem}.py"
149+
generate_protocol_models(schema_path=schema_path, output_path=generated_path)
150+
append_generated_model_definitions(
151+
target_path=output_path,
152+
generated_path=generated_path,
153+
)
154+
155+
103156
def build_postprocess_command(*, output_path: Path) -> list[str]:
104157
return [sys.executable, "scripts/postprocess_protocol_types.py", str(output_path)]
105158

@@ -124,6 +177,7 @@ def main() -> int:
124177
experimental=args.experimental,
125178
)
126179
generate_protocol_models(schema_path=schema_path, output_path=output_path)
180+
append_extra_protocol_models(schema_dir=schema_dir, output_path=output_path)
127181

128182
postprocess_protocol_models(output_path)
129183
return 0

tests/test_app_server_client.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,9 +1523,37 @@ def list_mcp_status(message: JsonObject) -> JsonObject:
15231523
{
15241524
"name": "github",
15251525
"authStatus": "oAuth",
1526-
"tools": {},
1527-
"resources": [],
1528-
"resourceTemplates": [],
1526+
"tools": {
1527+
"repo_status": {
1528+
"_meta": {"origin": "pytest"},
1529+
"annotations": {"readOnlyHint": True},
1530+
"description": "Read repository status",
1531+
"inputSchema": {"type": "object", "properties": {}},
1532+
"name": "repo_status",
1533+
"outputSchema": {"type": "object"},
1534+
"title": "Repo status",
1535+
}
1536+
},
1537+
"resources": [
1538+
{
1539+
"_meta": {"origin": "pytest"},
1540+
"description": "Repository README",
1541+
"mimeType": "text/markdown",
1542+
"name": "readme",
1543+
"size": 12,
1544+
"title": "README",
1545+
"uri": "file:///repo/README.md",
1546+
}
1547+
],
1548+
"resourceTemplates": [
1549+
{
1550+
"description": "Repository files",
1551+
"mimeType": "text/plain",
1552+
"name": "repo_files",
1553+
"title": "Repository files",
1554+
"uriTemplate": "file:///repo/{path}",
1555+
}
1556+
],
15291557
}
15301558
],
15311559
"nextCursor": None,
@@ -1747,6 +1775,21 @@ def windows_sandbox_setup_start(message: JsonObject) -> JsonObject:
17471775
assert reload_result == EmptyResult()
17481776
assert oauth_result.authorization_url == "https://example.com/oauth"
17491777
assert mcp_status[0].name == "github"
1778+
assert isinstance(mcp_status[0].auth_status, protocol.McpAuthStatus)
1779+
assert mcp_status[0].auth_status.root == "oAuth"
1780+
assert isinstance(mcp_status[0].tools["repo_status"], protocol.Tool)
1781+
assert mcp_status[0].tools["repo_status"].field_meta == {"origin": "pytest"}
1782+
assert mcp_status[0].tools["repo_status"].inputSchema == {
1783+
"type": "object",
1784+
"properties": {},
1785+
}
1786+
assert mcp_status[0].tools["repo_status"].outputSchema == {"type": "object"}
1787+
assert isinstance(mcp_status[0].resources[0], protocol.Resource)
1788+
assert mcp_status[0].resources[0].field_meta == {"origin": "pytest"}
1789+
assert mcp_status[0].resources[0].mimeType == "text/markdown"
1790+
assert mcp_status[0].resources[0].uri == "file:///repo/README.md"
1791+
assert isinstance(mcp_status[0].resource_templates[0], protocol.ResourceTemplate)
1792+
assert mcp_status[0].resource_templates[0].uriTemplate == "file:///repo/{path}"
17501793
assert mcp_status_page.data[0].name == "github"
17511794
assert mcp_status_alias[0].name == "github"
17521795
assert mcp_status_page_alias.data[0].name == "github"

tests/test_protocol_codegen.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,14 @@ def test_generated_protocol_types_do_not_use_legacy_conint() -> None:
88

99
assert " conint" not in content
1010
assert "conint(" not in content
11+
12+
13+
def test_generated_protocol_types_include_mcp_status_response_models() -> None:
14+
content = Path("codex/protocol/types.py").read_text(encoding="utf-8")
15+
16+
assert "class McpAuthStatus" in content
17+
assert "class Resource(BaseModel)" in content
18+
assert "class ResourceTemplate(BaseModel)" in content
19+
assert "class Tool(BaseModel)" in content
20+
assert "class McpServerStatus(BaseModel)" in content
21+
assert "class ListMcpServerStatusResponse(BaseModel)" in content

tests/test_protocol_generation_scripts.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,46 @@ def fake_run_stage(name: str, command: list[str]) -> None:
109109
}
110110

111111

112+
def test_generate_protocol_types_inserts_extra_response_models_before_rebuilds(
113+
tmp_path: Path,
114+
) -> None:
115+
module = _load_script_module(
116+
"generate_protocol_types_extra", "scripts/generate_protocol_types.py"
117+
)
118+
target_path = tmp_path / "types.py"
119+
generated_path = tmp_path / "ListMcpServerStatusResponse.py"
120+
target_path.write_text(
121+
"# generated by datamodel-codegen:\n"
122+
"from pydantic import BaseModel\n\n"
123+
"class Existing(BaseModel):\n"
124+
" pass\n\n"
125+
"ClientRequest.model_rebuild()\n",
126+
encoding="utf-8",
127+
)
128+
generated_path.write_text(
129+
"# generated by datamodel-codegen:\n"
130+
"from pydantic import BaseModel\n\n"
131+
"class ListMcpServerStatusResponse(BaseModel):\n"
132+
" data: list[str]\n",
133+
encoding="utf-8",
134+
)
135+
136+
module.append_generated_model_definitions(
137+
target_path=target_path,
138+
generated_path=generated_path,
139+
)
140+
141+
assert target_path.read_text(encoding="utf-8") == (
142+
"# generated by datamodel-codegen:\n"
143+
"from pydantic import BaseModel\n\n"
144+
"class Existing(BaseModel):\n"
145+
" pass\n\n"
146+
"class ListMcpServerStatusResponse(BaseModel):\n"
147+
" data: list[str]\n\n"
148+
"ClientRequest.model_rebuild()\n"
149+
)
150+
151+
112152
def test_postprocess_protocol_types_main_uses_explicit_path(
113153
monkeypatch: pytest.MonkeyPatch,
114154
tmp_path: Path,

0 commit comments

Comments
 (0)