Skip to content

fix: support Streamable HTTP MCP servers for Codex CLI (closes #1260)#1262

Merged
danielmeppiel merged 12 commits into
microsoft:mainfrom
masaishi:fix/codex-streamable-http-mcp
May 21, 2026
Merged

fix: support Streamable HTTP MCP servers for Codex CLI (closes #1260)#1262
danielmeppiel merged 12 commits into
microsoft:mainfrom
masaishi:fix/codex-streamable-http-mcp

Conversation

@masaishi
Copy link
Copy Markdown
Contributor

@masaishi masaishi commented May 11, 2026

Description

Codex CLI supports Streamable HTTP MCP servers, but apm install was skipping Codex for every remote MCP entry, and README.md documented this as intended behavior.

This PR aligns the Codex adapter with the official Codex MCP spec:

  • Emit Streamable HTTP remote MCPs to ~/.codex/config.toml as mcp_servers.<name> entries with url = "..." (and http_headers when provided).
  • Keep SSE skipped, but update the notice to point users to transport: streamable-http (SSE is deprecated by the MCP spec and absent from the Codex config schema).
  • Extend the conflict detector to recognize flat-key mcp_servers.<name> entries that use url (previously only command / args were matched).
  • Remove the misleading “Codex CLI does not support remote MCP servers” line from README.md.

Fixes #1260

Type of change

  • Bug fix
  • Documentation
  • Maintenance / refactor

Testing

  • Tested locally

  • All existing tests pass

  • Added tests for new functionality (if applicable)

    • Streamable HTTP server config generation and SSE-skip behavior in test_mcp_client_factory.py
    • Codex flat-key remote URL detection in test_conflict_detection.py
    • Streamable-HTTP self-defined transport handling in _build_self_defined_info (test_mcp_integrator.py)

Copilot AI review requested due to automatic review settings May 11, 2026 01:45
@masaishi
Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Codex CLI adapter so apm install can emit Streamable HTTP (remote) MCP server entries into Codex config.toml, while continuing to skip SSE remotes with an updated warning message. It also expands conflict detection to recognize Codex TOML “flat-key” server entries that are configured via url, and removes a README statement claiming Codex cannot use remote MCP servers.

Changes:

  • Codex adapter: generate mcp_servers.<name> TOML entries for remote streamable-http MCP servers (including http_headers), and skip SSE remotes via formatter signaling.
  • Conflict detector: treat mcp_servers.<name> flat-key configs containing url as existing servers (not just command/args).
  • Tests + README: add unit coverage for streamable-http + conflict detection, and remove misleading README wording about Codex remote MCP support.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/apm_cli/adapters/client/codex.py Adds remote Streamable HTTP config emission for Codex and SSE-skip path via _format_server_config() returning None.
src/apm_cli/core/conflict_detector.py Extends Codex flat-key server detection to include url-based configs.
tests/unit/test_mcp_client_factory.py Updates SSE skip test to assert _rich_warning content; adds streamable-http formatting + install tests.
tests/unit/test_conflict_detection.py Adds coverage for detecting Codex flat-key remote url entries.
tests/unit/integration/test_mcp_integrator.py Adds coverage ensuring self-defined streamable-http builds a remotes entry.
README.md Removes the statement that Codex CLI does not support remote MCP servers.

Comment on lines +223 to +227
remote = self._select_remote_with_url(remotes) or remotes[0]
server_name = server_info.get("name", "")
if (remote.get("transport_type") or "").strip() == "sse":
_rich_warning(
f"Skipping MCP server '{server_name}' for Codex CLI: SSE transport "
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APM always builds a single-element remotes list (mcp_integrator.py:216: info["remotes"] = [remote]), so the proposed “first SSE, later streamable-http” scenario cannot occur.

Additionally, the _select_remote_with_url(remotes) or remotes[0] pattern is already identical across the other adapters:

  • copilot.py:541
  • gemini.py:131
  • vscode.py:362

The recommendation to “mirror the other adapters’ validation” is therefore self-defeating, because the referenced implementations use the exact same logic.

Comment on lines +234 to +237
remote_config = {
"url": (remote.get("url") or "").strip(),
"id": server_info.get("id", ""),
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

codex.py:617-627’s _select_remote_with_url contractually never returns a remote with an empty URL.

Writing id as an empty string is intentional and consistent with copilot.py:565. This follows the conflict_detector schema convention (introduced in commit 6a44d74).

Omitting the key would break conflict detection behavior.

Comment on lines 172 to +177
# Generate server configuration with environment variable resolution
server_config = self._format_server_config(server_info, env_overrides, runtime_vars)

# Skip if formatter signaled "unsupported" (e.g. SSE remote on Codex)
if server_config is None:
return False
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix it
1a75308

Comment on lines +297 to +301
"headers": [
{"name": "Authorization", "value": "Bearer ghp_xxx"},
{"name": "X-Figma-Region", "value": "us-east-1"},
],
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub’s PAT format requires:

ghp_ + 36 alphanumeric characters

@danielmeppiel danielmeppiel added panel-review Trigger the apm-review-panel gh-aw workflow and removed panel-review Trigger the apm-review-panel gh-aw workflow labels May 14, 2026
@github-actions
Copy link
Copy Markdown

APM Review Panel: needs_rework

Codex adapter now speaks streamable-HTTP remote MCP -- real capability unlock, one concrete security gap (non-HTTPS URL write) must close before merge.

cc @danielmeppiel -- a fresh advisory pass is ready for your review.

This PR delivers the last adapter in the harness matrix for remote MCP transport: Codex users can now run apm install --mcp <server> --transport http and reach any hosted MCP endpoint (Figma, GitHub MCP, etc.) without manual TOML surgery. The OSS story -- "one command, any harness, any transport" -- is now structurally true. That is a meaningful milestone worth shipping.

The panel converged on one concrete blocker (supply-chain-security) and two reinforcing recommended findings that share a root cause. The blocker is real: the url field from the registry is written verbatim to config.toml with no scheme validation. A compromised or typosquatted registry entry can write (attacker.com/redacted) (redacted) or a localhost SSRF pivot URL to a file Codex reads at runtime. The fix is a single guard (if not url.startswith('https://'): raise ValueError) that closes the persistent write vector before any network request is made. This must land before merge -- the capability is useless if a user can be silently redirected to a plaintext endpoint. Auth-expert and supply-chain-security converged independently on the resolved-token disclosure gap: if the registry supplies a static Authorization: Bearer ghp_xxxx header, the resolved value is written to TOML with no user-facing disclosure. This is not a blocker -- it is a recommended pre-merge hardening -- but given the convergence from two distinct lenses it should be treated as near-blocking and paired with the URL guard in the same diff.

The remaining panel signal decomposes into two tiers. Tier 1 (in-PR): DevX and CLI-logging both flag a silent happy path -- the streamable-HTTP success branch emits a bare print() while the SSE deprecation branch fires _rich_warning. This is a one-line fix that belongs in this PR. The empty-URL fallback (test-coverage + devx convergence) writes url='' to TOML and returns True; a guard here is also a one-liner. Tier 2 (follow-up): doc drift on targets-matrix.md and install-mcp-servers.md, the missing CHANGELOG entry, the remotes+packages silent precedence, and the missing GitHub-server detection parity with Cursor can all ship post-merge as standalone issues.

Dissent. No material disagreement between panelists. Python-architect and devx-ux both flagged the remotes+packages silent fallthrough; python-architect called it recommended, devx called it a nit. CEO weights it as recommended -- a precedence rule with a comment is cheap and the silent ignore of a remote endpoint is surprising. This does not affect the ship stance.

Aligned with: Portable by manifest (apm.yml transport field now routes correctly for all four harnesses; Codex remote MCP is no longer a manual exception); Secure by default (FAILS on this PR as submitted -- non-HTTPS URLs from the registry are written to disk without validation, must be fixed before merge); Governed by policy (conflict detection updated to handle url-keyed entries; id='' empty-string fallback is a minor gap); Multi-harness / multi-host (Codex joins Copilot, Claude, and Cursor on streamable-HTTP, completing the matrix); OSS community driven (disclaimer removal removes friction for Codex-primary contributors; CHANGELOG entry still missing); Pragmatic as npm (SSE deprecation warning with a migration message is exactly the right pattern: clear, actionable, no silent breakage).

Growth signal. The oss-growth-hacker correctly identified the angle: "One command installs Figma MCP into every agent -- Copilot, Claude, Cursor, and now Codex." Once the URL guard and token disclosure warning land, this PR earns a CHANGELOG entry and a short launch post. The SSE deprecation message also positions APM as the authoritative migration guide for the MCP transport transition -- worth mirroring in a docs FAQ so users who hit the warning can find it via search.

Panel summary

Persona B R N Takeaway
Python Architect 0 1 2 Solid feature addition; one correctness gap (remotes+packages silently falls through to package path) and a missing type hint are the main items. None-as-sentinel is acceptable at this scope but worth noting.
CLI Logging Expert 0 1 1 SSE warning migration is correct and well-formed; one recommended follow-up on the adjacent success path, one nit on symbol= consistency.
DevX UX Expert 0 2 2 Silent success on streamable-HTTP install and URL-less fallback are the two real gaps; SSE deprecation message is good; remotes+packages ambiguity is low risk but worth a nit.
Supply Chain Security Expert 1 2 1 No URL scheme validation lets a compromised registry write non-HTTPS/localhost URLs to Codex config; resolved auth tokens in http_headers land on disk without explicit user consent warning.
OSS Growth Hacker 0 2 2 Unblocking Codex remote-MCP is a genuine "works with" expansion worth amplifying, but the README silently removes the disclaimer instead of adding a positive claim -- a missed conversion moment.
Auth Expert 0 2 2 Resolved Authorization headers are written plaintext to config.toml with no user-facing disclosure; static registry tokens pass through verbatim; otherwise no credential-leak vectors found.
Doc Writer 0 2 2 README removal is accurate but purely subtractive; two doc pages have stale or incomplete Codex remote-MCP coverage that will mislead users trying --transport http with Codex.
Test Coverage Expert 0 1 1 TOML write/read round-trip is covered at fixture tier; two unit-tier gaps remain: all-empty-URL fallback silently writes url='' and unknown transport_type silently falls through to streamable-HTTP path.

B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.

Top 5 follow-ups

  1. [Supply Chain Security Expert] (blocking-severity) Enforce HTTPS-only scheme before writing url to Codex TOML; raise a hard error for any other scheme. -- A compromised registry entry can write (redacted) (redacted) or localhost URLs to a config file Codex reads at runtime. This is a persistent write vector with no mitigation today. Must close before merge.
  2. [Auth Expert] Emit an explicit named warning when a resolved Authorization / X-Api-Key header is about to be written to disk in plaintext. -- Auth-expert and supply-chain-security converged independently: static or fully-resolved tokens from the registry land in config.toml silently. Near-blocking given dual-lens convergence.
  3. [DevX UX Expert] Guard the empty-URL fallback: raise ValueError when every remote has an empty URL instead of writing url='' and returning True. -- Test-coverage-expert confirmed no test asserts this path. The silent write of url='' produces an unusable Codex config with a false-success return.
  4. [CLI Logging Expert] Replace bare print() on the streamable-HTTP success path with _rich_success(); add _rich_info() when remotes+packages silently routes to packages. -- The SSE path was just migrated to _rich_warning; the adjacent success branch still uses bare print(), breaking the traffic-light contract on the same code path.
  5. [OSS Growth Hacker] Add a CHANGELOG entry for the Codex + streamable-HTTP unlock and add an affirmative README line where the disclaimer was removed. -- The disclaimer removal is purely subtractive. A CHANGELOG bullet seeds external mentions and upgrade motivation; a positive README claim converts Codex users who scan before trusting --transport http.

Architecture

classDiagram
    direction LR
    class MCPClientAdapter {
        <<Abstract>>
        +target_name: str
        +mcp_servers_key: str
        +get_config_path() str
        +update_config(updates) bool
        +configure_mcp_server(...) bool
    }
    class CodexClientAdapter {
        <<ConcreteAdapter>>
        +configure_mcp_server(...) bool
        +_format_server_config(info, env, vars) dict | None
        +_select_remote_with_url(remotes) dict | None
        +_select_best_package(packages) dict
        +update_config(updates) bool
    }
    class CopilotClientAdapter {
        <<ConcreteAdapter>>
        +_format_server_config(info, env, vars) dict
    }
    class CursorClientAdapter {
        <<ConcreteAdapter>>
        +_format_server_config(info, env, vars) dict
    }
    class MCPConflictDetector {
        <<Collaborator>>
        +get_existing_server_configs() dict
    }
    class SimpleRegistryClient {
        <<Collaborator>>
        +find_server_by_reference(ref) dict
    }
    MCPClientAdapter <|-- CodexClientAdapter : extends
    MCPClientAdapter <|-- CopilotClientAdapter : extends
    MCPClientAdapter <|-- CursorClientAdapter : extends
    CodexClientAdapter *-- SimpleRegistryClient : owns
    CodexClientAdapter ..> MCPConflictDetector : reads via
    class CodexClientAdapter:::touched
    class MCPConflictDetector:::touched
    classDef touched fill:#fff3b0,stroke:#d47600
Loading
flowchart TD
    A(["apm install --transport http"]) --> B["configure_mcp_server()\ncodex.py:120"]
    B --> C{"server_info cached?"}
    C -- no --> D["[NET] registry_client.find_server_by_reference()"]
    C -- yes --> E["use cached server_info"]
    D --> E
    E --> F["resolve config_key from server_name / server_url"]
    F --> G["_format_server_config(server_info, env, vars)\ncodex.py:190"]
    G --> H{"_raw_stdio present?"}
    H -- yes --> I["return stdio config dict"]
    H -- no --> J{"remotes and NOT packages?"}
    J -- no --> K{"packages empty?"}
    K -- yes --> L["raise ValueError (incomplete config)"]
    K -- no --> M["_select_best_package() -> package dict"]
    M --> N["build command/args/env config"]
    N --> I
    J -- yes --> O["_select_remote_with_url(remotes)"]
    O --> P{"transport_type == 'sse'?"}
    P -- yes --> Q["_rich_warning() + return None"]
    P -- no --> R["build {url, id} remote_config"]
    R --> S{"headers present?"}
    S -- yes --> T["_resolve_variable_placeholders() for each header"]
    T --> U["remote_config['http_headers'] = headers"]
    S -- no --> V["return remote_config"]
    U --> V
    G --> W{"result is None?"}
    I --> W
    Q --> W
    V --> W
    W -- yes --> X["return False (skip, warning already emitted)"]
    W -- no --> Y["[FS] update_config({config_key: server_config})\nwrites .codex/config.toml"]
    Y --> Z["[I/O] MCPConflictDetector reads url/command/args keys\nconflict_detector.py:118-120"]
    Z --> AA(["return True"])
Loading

Recommendation

Hold for the HTTPS scheme guard (supply-chain-security blocking finding) and pair it with the auth-disclosure warning (auth-expert + supply-chain-security convergence). Both are small diffs -- likely a single function and a one-line pre-write check. Once those two land, the empty-URL guard and the bare-print fix (each a one-liner) should come along in the same revision since they are already identified and trivially cheap. With those four items in the diff, this PR is clean to merge and the follow-up issue queue (doc updates, CHANGELOG, GitHub-server parity, remotes+packages comment) can be tracked as a post-merge milestone.


Full per-persona findings

Python Architect

  • [recommended] Mixed remotes+packages server silently ignores the streamable-HTTP remote and installs as stdio at src/apm_cli/adapters/client/codex.py:222
    The guard if remotes and not packages only enters the remote-config branch when there are no packages. A server that ships both a remote endpoint and a package falls through to the package path without any warning. If the operator intended a streamable-HTTP connection, they will silently get a local install instead.
    Suggested: Add a precedence rule with a comment, or add _rich_warning when falling through to packages path when remotes also exists.

  • [nit] _select_remote_with_url lacks a type annotation on its return value at src/apm_cli/adapters/client/codex.py:616
    The docstring says dict or None but no -> dict | None hint is present. All other helpers on this class carry return-type hints.
    Suggested: def _select_remote_with_url(remotes: list[dict]) -> dict | None:

  • [nit] None-as-sentinel from _format_server_config diverges from other adapters which return {} for skip
    Copilot and Cursor return {} (empty dict) as the nothing-to-do signal; Codex now returns None for the SSE case. Two conventions for the same concept in one inheritance tree.
    Suggested: Add a one-line docstring note to MCPClientAdapter._format_server_config clarifying the sentinel contract.

CLI Logging Expert

  • [recommended] Streamable-HTTP success path still uses bare print() immediately after the changed block at src/apm_cli/adapters/client/codex.py
    The SSE path was just migrated to _rich_warning, but the success branch directly below it is left with a bare print(). A user installing a streamable-HTTP server sees uncolored, un-symbolled output while a warning fires in yellow with [!]. This breaks the traffic-light contract for the same code path.
    Suggested: Replace print(f"Successfully configured MCP server '{config_key}' for Codex CLI") with _rich_success(...).

  • [nit] symbol="warning" is valid but inconsistent with sibling adapters
    Every other _rich_warning call in vscode.py, copilot.py, and claude.py omits symbol= entirely.
    Suggested: Either adopt symbol="warning" across all _rich_warning call sites or drop it here to match the majority pattern.

DevX UX Expert

  • [recommended] Silent success: no confirmation emitted after streamable-HTTP install at src/apm_cli/adapters/client/codex.py
    After _format_server_config returns remote_config and update_config succeeds, nothing tells the user the server was installed. Running apm install --mcp server --transport http produces no output on the happy path -- indistinguishable from a silent skip.
    Suggested: Emit _rich_success(f"MCP server '{server_name}' installed for Codex CLI (streamable-http).").

  • [recommended] Empty-URL fallback: remotes[0] with no URL silently produces a broken config at src/apm_cli/adapters/client/codex.py
    When every remote lacks a URL, _select_remote_with_url returns None and falls back to remotes[0], writing url='' to Codex TOML. configure_mcp_server returns True but the entry is unusable.
    Suggested: if not (remote.get('url') or '').strip(): raise ValueError(f"MCP server '{server_name}' has no usable URL for streamable-http transport.")

  • [nit] remotes+packages case silently routes to packages-only without acknowledgment
    Suggested: Add _rich_info(f"MCP server '{server_name}' has both remote and package variants; using local package for Codex CLI.").

  • [nit] SSE deprecation warning missing the corrected command
    Suggested: Append Run: apm install --mcp <server> --transport streamable-http to the warning string.

Supply Chain Security Expert

  • [blocking] No URL scheme validation -- registry can inject (redacted) (redacted) or localhost URLs at src/apm_cli/adapters/client/codex.py:235
    The url field is taken directly from the registry response and only stripped before being written to Codex's TOML config. No scheme allow-list, no localhost/loopback block, no HTTPS enforcement. A compromised registry entry can supply (attacker.com/redacted) (MITM), (redacted) or (127.0.0.1/redacted) (SSRF). The attack is persistent -- written to a file Codex reads at runtime. *Suggested:* if not url.startswith('https://'): raise ValueError(f'Remote MCP URL must use HTTPS: {url}')`

  • [recommended] Resolved auth tokens written to disk config without explicit user disclosure at src/apm_cli/adapters/client/codex.py:246-248
    _warn_input_variables only fires for unresolved \$\{input:...} placeholders. Fully-resolved Authorization: Bearer (token) values are written to TOML silently.
    Suggested: Add a check for headers matching Authorization/X-Api-Key and emit an explicit warning naming the config path.

  • [recommended] Empty-string fallback for id silently breaks UUID-based conflict detection at src/apm_cli/adapters/client/codex.py:236
    server_info.get('id', '') falls back to empty string. The conflict detector then matches any other server whose id is also '' -- false-positive conflict blocks.
    Suggested: Skip writing the id key when the value is falsy.

  • [nit] Conflict detector url heuristic could match non-MCP TOML entries at src/apm_cli/core/conflict_detector.py:119

OSS Growth Hacker

  • [recommended] README removes limitation but adds no positive claim about what is now supported at README.md
    Suggested: Add an affirmative line near the Codex install example.

  • [recommended] No CHANGELOG entry for this capability unlock at CHANGELOG.md
    Suggested: "Codex CLI now supports streamable-HTTP remote MCP servers; --transport http no longer skips Codex."

  • [nit] SSE deprecation warning should be mirrored in docs FAQ.

  • [nit] Works-with section could note all clients support both local and hosted MCP transports.

Auth Expert

  • [recommended] No disclosure to user that resolved Authorization headers are persisted in plaintext at src/apm_cli/adapters/client/codex.py
    _warn_input_variables fires only for unresolved placeholders -- silent when the token is fully resolved.

  • [recommended] Static tokens from registry entries are written verbatim with no warning
    _resolve_variable_placeholders passes values through unchanged when they contain no placeholder. A cheap regex check on resolved header values would catch raw tokens (ghp_, github_pat_, etc.).

  • [nit] No GitHub-server detection unlike Cursor adapter
    Cursor injects a live token from GitHubTokenManager and refuses registry-supplied Authorization overrides. Codex has no equivalent.

  • [nit] No credential-leak via logs -- confirmed clean. _log.debug logs only the file path; no resolved token value surfaces in output.

Doc Writer

  • [recommended] install-mcp-servers.md token injection section does not mention Codex adapter at docs/src/content/docs/consumer/install-mcp-servers.md
    The section scopes automatic Authorization: Bearer injection to "Copilot CLI adapter" only. Users installing GitHub MCP against Codex with --transport http have no guidance on whether token injection applies.

  • [recommended] targets-matrix.md Codex section does not list supported MCP transport types at docs/src/content/docs/reference/targets-matrix.md
    With --transport http now working for Codex, the section is materially incomplete.
    Suggested: Add: "MCP transports. stdio and streamable-http (remote). See Install MCP servers for transport selection."

  • [nit] README removes limitation note but adds no positive replacement.

  • [nit] No documentation captures SSE-to-streamable-http migration path.

Test Coverage Expert

  • [recommended] _select_remote_with_url all-empty-URL fallback writes url='' to TOML with no test asserting the outcome at tests/unit/test_mcp_client_factory.py
    When every remote has an empty URL, the fallback path writes url='' to the Codex TOML and returns True. Confirmed missing via grep.
    Proof (missing at unit): tests/unit/test_mcp_client_factory.py::test_configure_mcp_server_all_remotes_have_empty_url -- proves: apm install of a remote-only server with no resolvable URL either fails loudly or writes a non-empty url [devx, secure-by-default]

  • [nit] Unknown transport_type (e.g. "websocket") silently falls through to streamable-HTTP path with no test
    Proof (missing at unit): tests/unit/test_mcp_client_factory.py::test_format_server_config_unknown_transport_treated_as_streamable_http -- proves: unrecognised transport_type produces a known, documented outcome [devx]

This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.

Note

🔒 Integrity filter blocked 2 items

The following items were blocked because they don't meet the GitHub integrity level.

To allow these resources, lower min-integrity in your GitHub frontmatter:

tools:
  github:
    min-integrity: approved  # merged | approved | unapproved | none

Generated by PR Review Panel for issue #1262 · ● 3.3M ·

@github-actions github-actions Bot removed the panel-review Trigger the apm-review-panel gh-aw workflow label May 14, 2026
@danielmeppiel
Copy link
Copy Markdown
Collaborator

Maintainer sweep [2026-05-15]: panel still needs_rework. The single remaining blocker is the non-HTTPS URL write in the Codex Streamable HTTP path -- the supply-chain-security persona flagged this and it must close before merge. @masaishi, your response thread shows engagement; can you confirm whether the latest push enforces HTTPS-only at the URL-validation gate (with a test) so I can re-run the panel? Everything else looks landable.

@masaishi
Copy link
Copy Markdown
Contributor Author

@danielmeppiel
Thanks for checking! You're absolutely right — this was still missing. I addressed it in commit 9caa34c by enforcing HTTPS-only validation at the URL validation gate and adding test coverage for the behavior.

@danielmeppiel
Copy link
Copy Markdown
Collaborator

PR-shepherd review (automated by orchestrator session)

Verdict: ready-to-merge

Root-cause alignment

ALIGNED. The PR cleanly addresses the root cause of #1260.

  • The old single-guard "any non-stdio remote => skip Codex" path
    (codex.py:150-160) is gone.
  • Transport branching now lives in _format_server_config:
    • transport_type == "sse" => _rich_warning(...) + return None
      (rejection message points users at transport: streamable-http).
    • transport_type == "streamable-http" => emits
      mcp_servers.<name> with url, id, and optional http_headers
      to ~/.codex/config.toml, matching the Codex MCP config schema.
    • stdio packages path is unchanged.
  • README L155 ("Codex CLI currently does not support remote MCP
    servers...") is removed, so docs no longer contradict behaviour.
  • Conflict detector (conflict_detector.py:115-120) was extended to
    recognise flat-key mcp_servers.<name> entries that use url,
    preventing false-clean reads on already-installed remote entries.
  • Supply-chain blocker from the prior panel (non-HTTPS URL write) is
    closed: _format_server_config parses the URL and rejects any
    scheme other than https, with a named warning. Confirmed in
    commit 9caa34c.

Specialist panel findings (material only)

The full 8-persona panel ran on 2026-05-14 (see prior comment by
github-actions[bot]). This shepherd pass reconciles that report
against the latest HEAD (5f94adf):

  • supply-chain-security [blocking, prior] => CLOSED. scheme != "https"
    guard at codex.py:218-219; tests
    test_format_server_config_streamable_http_rejects_non_https and
    test_configure_mcp_server_http_remote_rejected both assert the
    warning fires AND no config is written.
  • auth-expert [recommended] => still open: resolved Authorization
    / X-Api-Key header values land in config.toml with no
    user-facing disclosure beyond the generic
    _warn_input_variables (which only fires on unresolved
    ${input:...} placeholders). Not blocking; recommended follow-up.
  • devx-ux + test-coverage [recommended, prior] => empty-URL fallback
    still writes url='' (_select_remote_with_url returns None =>
    code falls back to remotes[0]). Not blocking, low real-world
    hit-rate; flagged as follow-up.
  • cli-logging [recommended, prior] => streamable-HTTP success path
    still emits a bare print() while the SSE warning uses
    _rich_warning. Cosmetic asymmetry; follow-up.
  • python-architect [recommended, prior] => remotes + packages
    silently routes to the package path; no _rich_info to surface
    the precedence choice. Follow-up.
  • oss-growth-hacker [recommended, prior] => no CHANGELOG entry and
    README removed the disclaimer without adding a positive claim.
    Follow-up.
  • doc-writer [recommended, prior] => targets-matrix.md and
    install-mcp-servers.md still scope token-injection guidance to
    Copilot only and do not list Codex's new transport coverage.
    Follow-up.

None of the remaining items are blockers individually or in
aggregate. The single blocking finding has been closed with tests.

E2E coverage

GOOD. The PR adds the right unit-tier scenarios for the changed
contract:

  • test_format_server_config_streamable_http_writes_url_and_id -
    happy-path streamable-HTTP with no headers.
  • test_format_server_config_streamable_http_writes_headers -
    registry-supplied headers land under http_headers.
  • test_format_server_config_streamable_http_self_defined -
    apm.yml-defined streamable-HTTP server flows through.
  • test_format_server_config_streamable_http_rejects_non_https -
    scheme guard fires + no config written.
  • test_configure_mcp_server_http_remote_rejected - end-to-end
    through configure_mcp_server; verifies no mcp_servers block
    appears in the resulting TOML.
  • test_configure_mcp_server_streamable_http_writes_toml_entry -
    end-to-end install of figma/figma round-trips through
    update_config and produces a parseable TOML entry with
    url, id, and http_headers.
  • test_configure_mcp_server_sse_remote_rejected - SSE remote
    warned + returns False + no TOML write.
  • test_codex_flat_keys_detect_remote_url_entries - conflict
    detector picks up url-keyed entries alongside stdio ones.
  • test_streamable_http_transport_builds_remote - integrator path
    for self-defined transport: streamable-http.

Missing-but-not-blocking (test-coverage-expert prior):

  • All-remotes-have-empty-URL fallback.
  • Unknown transport_type (e.g. websocket) silently treated as
    streamable-HTTP.

Empirical repro

YES. Checked out pr-1262-shepherd (HEAD 5f94adf) into a worktree
and ran:

  • uv run --extra dev pytest tests/unit/test_mcp_client_factory.py tests/unit/test_conflict_detection.py tests/unit/integration/test_mcp_integrator.py -q
    => 127 passed, 3 subtests passed in 3.49s.
  • uv run --extra dev ruff check src/ tests/
    => All checks passed.
  • uv run --extra dev ruff format --check src/ tests/
    => 764 files already formatted.
  • Direct probe of _format_server_config against three crafted
    server_info payloads:
    • streamable-https + headers =>
      {'url': 'https://mcp.figma.com/mcp', 'id': 'uuid-1', 'http_headers': {'Authorization': 'Bearer demo'}}
    • sse over https =>
      None + warning naming the SSE deprecation + migration hint.
    • streamable-http over http:// =>
      None + warning naming the HTTPS requirement.

Behaviour matches the PR description and closes #1260 at the root.

Suggested follow-ups

To file as separate issues post-merge (do NOT block this PR):

  1. Emit _rich_warning (or a dedicated _rich_info) when a fully
    resolved Authorization / X-Api-Key value is about to be
    written to ~/.codex/config.toml, naming the path. Covers the
    auth-expert + supply-chain-security convergence on plaintext
    token persistence.
  2. Replace the streamable-HTTP success-path print(...) with
    _rich_success(...) to restore the traffic-light contract that
    the SSE branch already follows.
  3. Guard the empty-URL fallback: when every remote lacks a usable
    URL, raise instead of writing url='' and returning True.
  4. Add a precedence rule (and _rich_info) for servers that ship
    both remotes and packages; today they silently install as
    stdio.
  5. CHANGELOG entry: "Codex CLI now supports streamable-HTTP remote
    MCP servers; --transport http no longer skips Codex."
  6. Doc sync: update docs/.../reference/targets-matrix.md (Codex
    transports section) and docs/.../consumer/install-mcp-servers.md
    (broaden token-injection scope beyond Copilot CLI). Mirror the
    SSE-to-streamable-http migration in a docs FAQ entry.

Docs check

README L155 status: REMOVED in this PR (- line in the diff). The
removal is purely subtractive; replacing it with a one-line positive
claim (e.g. "Codex supports stdio and streamable-HTTP remote MCP;
SSE is deprecated and skipped with a migration warning.") is a
recommended follow-up but not a blocker.

targets-matrix.md and install-mcp-servers.md were NOT updated in
this PR and remain stale on Codex remote-MCP transport coverage and
token-injection scope. These are pre-existing gaps surfaced by the
new capability; tracked as follow-ups, not blockers.


Orchestrator session: danielmeppiel/triage-issues-1363-1364-1366

…oft#1262)

Six non-blocking follow-ups from PR microsoft#1262 shepherd review:

1. Auth-header disclosure (audit): no leak path found. _warn_input_variables
   in base.py logs only the ${input:VAR_ID} identifier, never the header
   value. Header values flow value -> http_headers dict -> toml.dump only.
   No code change required.

2. _rich_success on success: replace bare print() with green/bold
   _rich_success() to mirror Claude adapter pattern. Covers both stdio
   and streamable-http registration paths.

3. Empty-URL guard: reject remote entries whose url is empty / whitespace
   with an explicit 'has an empty url' warning, instead of falling through
   to a misleading 'no scheme' urlparse warning.

4. Hybrid (remotes + packages) precedence: add a code comment + _log.debug
   line documenting that Codex prefers the stdio package when a registry
   server publishes both. Behavior unchanged; the existing
   test_configure_mcp_server_hybrid_accepted test already asserts it.

5. CHANGELOG: add a [Unreleased] / Fixed bullet describing
   Codex streamable-http MCP support, with refs (microsoft#1260, microsoft#1262).

6. Docs sync: audited docs/src/content/docs/. No stale 'Codex does not
   support remote MCP' claim remained; README L155 was already removed in
   commit 4d7b017. No docs edit required.

New tests in tests/unit/test_mcp_client_factory.py:
  - test_format_server_config_streamable_http_rejects_empty_url
    (covers '', '   ', None via subTest)
  - test_configure_mcp_server_streamable_http_emits_rich_success
  - test_configure_mcp_server_stdio_emits_rich_success
  - test_configure_mcp_server_hybrid_logs_precedence

Verification:
  uv run --extra dev ruff check src/ tests/   # silent
  uv run --extra dev ruff format --check src/ tests/   # silent
  uv run --extra dev pytest tests/unit/test_mcp_client_factory.py
    tests/test_codex_empty_string_and_defaults.py
    tests/test_codex_docker_args_fix.py
    tests/unit/test_codex_runtime.py
  -> 45 passed, 6 subtests passed

Closes follow-ups raised in microsoft#1262 (comment)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel
Copy link
Copy Markdown
Collaborator

Polish follow-ups landed -- ready to merge

Pushed b5235d63 directly to this branch (maintainerCanModify=true). All four CI checks green: CI, CodeQL, NOTICE Drift Check, Merge Gate.

Addresses the six follow-ups from the shepherd review at #1262 (comment):

  1. Auth-header disclosure (audit, no code change). Confirmed clean: header values flow value -> remote_config["http_headers"] -> toml.dump only. _warn_input_variables (in base.py) iterates env/header values but emits only the matched ${input:VAR_ID} identifier, never the value. No print, _log, or exception path stringifies the header value.

  2. _rich_success on success. Replaced the bare print(f"Successfully configured...") in configure_mcp_server with _rich_success(f"Configured MCP server '{config_key}' for Codex CLI", symbol="success"), mirroring the Claude adapter pattern. Covers both stdio packages and streamable-http remotes. New tests assert it is called for both paths.

  3. Empty-URL guard. _format_server_config now rejects empty / whitespace-only url values with an explicit warning: Skipping MCP server '<name>' for Codex CLI: remote entry has an empty url. Set 'url:' to the server's streamable-http endpoint. Previously empty URLs fell through to a misleading must use https:// (got no scheme) warning. New subTest covers "", " ", and None.

  4. Hybrid (remotes + packages) precedence. Added an explicit code comment documenting the existing behavior plus a _log.debug line at the packages branch: Codex hybrid server '%s': preferring stdio package over remote endpoint. Behavior unchanged -- the existing test_configure_mcp_server_hybrid_accepted already asserts packages-win; new test_configure_mcp_server_hybrid_logs_precedence pins the debug-log contract so the choice stays auditable. (Self-defined entries cannot reach the adapter with both command and url because MCPDependency.transport gates mutual exclusion upstream in mcp_integrator._build_self_defined_info.)

  5. CHANGELOG. Added an [Unreleased] / Fixed entry referencing ([BUG] Codex CLI skips Streamable HTTP MCP servers; README claims this is unsupported #1260, fix: support Streamable HTTP MCP servers for Codex CLI (closes #1260) #1262) describing streamable-http support, the explicit rejections for SSE / non-https / empty-url, and the hybrid precedence on Codex.

  6. Docs sync. Re-audited docs/src/content/docs/. No remaining "Codex does not support remote MCP" claim -- README L155 was already removed in 4d7b0175, and consumer/install-mcp-servers.md plus reference/manifest-schema.md describe Codex only in terms of placeholder syntax support, which is unchanged. No docs edit needed.

Test deltas

tests/unit/test_mcp_client_factory.py:

  • test_format_server_config_streamable_http_rejects_empty_url (subTests: "", " ", None)
  • test_configure_mcp_server_streamable_http_emits_rich_success
  • test_configure_mcp_server_stdio_emits_rich_success
  • test_configure_mcp_server_hybrid_logs_precedence

Local verification:

uv run --extra dev ruff check src/ tests/                  # silent
uv run --extra dev ruff format --check src/ tests/         # silent
uv run --extra dev pytest tests/unit/test_mcp_client_factory.py \
  tests/test_codex_empty_string_and_defaults.py \
  tests/test_codex_docker_args_fix.py \
  tests/unit/test_codex_runtime.py
-> 45 passed, 6 subtests passed

@masaishi -- thanks for the PR. Handing back to maintainers for merge.

@danielmeppiel danielmeppiel merged commit ab1b8cd into microsoft:main May 21, 2026
5 of 8 checks passed
danielmeppiel pushed a commit that referenced this pull request May 21, 2026
…gates

CI lint was failing the PR with R0801 (pylint duplicate-code). The
duplication was introduced when main was merged into this branch: the
recent MCP-client work on main left an identical
`_select_best_package` method in both CodexClientAdapter and
CopilotClientAdapter, each ~22 lines long. pylint --fail-on=R0801
catches this at the merge commit CI tests, so the PR cannot pass
until the dup is resolved.

Fix: hoist `_select_best_package` to the `MCPClientAdapter` base class
(alongside the existing `_infer_registry_name` it already calls) and
delete the duplicates from codex.py and copilot.py. Both call sites
use `self._select_best_package(packages)`, so inheritance resolves
the lookup transparently. Mirrors the in-flight refactor on
`daf99e0f refactor(adapters): hoist _select_remote_with_url and
_select_best_package to base` (not yet on main).

Also: expand the canonical lint contract at
`.apm/instructions/linting.instructions.md` to cover all seven CI
Lint steps (ruff check, ruff format, YAML I/O guard, file-length
guard, no raw `str(relative_to)`, pylint R0801, auth-signal lint)
instead of only ruff. Adds the full `uv run ... && ...` mirror chain
and calls out the merge-commit semantics (CI tests HEAD merged with
main, so duplication on main can fail a clean PR diff). Apm compile
flows the change into the generated AGENTS.md (gitignored at root)
and into `.github/copilot-instructions.md`.

Validation:

* uv run --extra dev ruff check src/ tests/ -- clean
* uv run --extra dev ruff format --check src/ tests/ -- clean
* uv run --extra dev python -m pylint --disable=all --enable=R0801 \
  --min-similarity-lines=10 --fail-on=R0801 src/apm_cli/ -- 10.00/10, exit 0
* bash scripts/lint-auth-signals.sh -- clean
* uv run --extra dev pytest tests/unit/integration/test_hook_integrator.py \
  tests/integration/test_hook_root_source_drift_e2e.py -- 161 passed

Pre-existing main-side test failures in
`tests/unit/test_codex_adapter_compatibility.py` (introduced by
#1262 / #1277 on main and tracked by the in-flight
`32991fb6 fix(adapters): restore _resolve_env_placeholders shim and
reject SSE remotes in tests` branch) are out of scope for this PR
and will resolve when that follow-up lands on main.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
danielmeppiel added a commit that referenced this pull request May 21, 2026
…ees (supersedes #1330, closes #1329) (#1392)

* fix: stabilize root hook source ids

* test: remove project-specific hook fixture names

* fix(hooks): bound dependency hook source discovery

* fix(hooks): reject symlinked dependency markers

* test(hooks): subprocess-tier coverage + debug logs for #1329

- Add tests/integration/test_hook_root_source_drift_e2e.py: subprocess
  test that seeds stale .claude/settings.json + apm-hooks.json sidecar
  with old source-id, runs apm install, asserts entries heal to
  _local/<manifest-name> and user-owned hook entry survives.
- Add two _log.debug lines in hook_integrator.py:
  - manifest-parse fallback in _get_root_local_package_name
  - heal count when stale same-content merged entries are removed
- Update affected unit tests to read _apm_source from Claude sidecar
  (post-#1359 schema-strict layout).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(hooks): act on review-panel recommendations for #1329 follow-up

Addresses the top recommended-tier follow-ups from the APM review panel
on #1392 (#1392 (comment)).

In-PR:

* CHANGELOG: add [Unreleased] > Fixed entry with user-outcome framing,
  enumerating all five affected harnesses (Claude / Codex / Cursor /
  Gemini / Windsurf) and the explicit parenthetical that Copilot is
  unaffected because its hooks live in per-file namespaces rather than
  a shared merged config. (doc-writer + oss-growth-hacker convergence)

* Integration coverage: parametrize the e2e heal regression test across
  all five merged-hook harnesses instead of Claude only. Uses an
  install-then-corrupt-then-reinstall pattern so the seeded entries
  match each target's on-disk shape (sidecar for Claude; in-file
  _apm_source for Codex / Cursor / Gemini / Windsurf), closing the
  silent-drift gap the test-coverage-expert flagged as highest-signal.

* _safe_source_name hardening: collapse runs of 2+ dots to a single
  dot before stripping edges. No traversal risk today (the marker is
  JSON metadata), but guards any future caller that passes the name
  into a Path join. (supply-chain-security recommendation.)

* Nits: include exception message (not just class name) in the
  manifest-unreadable debug log; replace O(n*m) list membership in
  the heal counter with an id-set; document the project_root parameter
  on _get_package_name; promote _get_root_local_package_name,
  _get_hook_source_marker, and _should_remove_prior_merged_entry to
  @staticmethod (they touch no instance state).

The DependencySourceScanner extraction the python-architect proposed is
explicitly out of scope for this PR per the panel's own guidance
("does not need to land in this PR but should not slip past the next
meaningful touch to hook_integrator.py").

Validation:

* uv run --extra dev ruff check src/ tests/ -- clean
* uv run --extra dev ruff format --check src/ tests/ -- clean
* uv run --extra dev pytest tests/unit/integration/test_hook_integrator.py -- 156 passed
* uv run --extra dev pytest tests/integration/test_hook_root_source_drift_e2e.py -- 5 passed (claude, codex, cursor, gemini, windsurf)
* uv run --extra dev pytest tests/unit/integration/ tests/integration/test_hook_root_source_drift_e2e.py -- 1364 passed

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(ci): resolve R0801 duplication; expand lint contract to all 7 CI gates

CI lint was failing the PR with R0801 (pylint duplicate-code). The
duplication was introduced when main was merged into this branch: the
recent MCP-client work on main left an identical
`_select_best_package` method in both CodexClientAdapter and
CopilotClientAdapter, each ~22 lines long. pylint --fail-on=R0801
catches this at the merge commit CI tests, so the PR cannot pass
until the dup is resolved.

Fix: hoist `_select_best_package` to the `MCPClientAdapter` base class
(alongside the existing `_infer_registry_name` it already calls) and
delete the duplicates from codex.py and copilot.py. Both call sites
use `self._select_best_package(packages)`, so inheritance resolves
the lookup transparently. Mirrors the in-flight refactor on
`daf99e0f refactor(adapters): hoist _select_remote_with_url and
_select_best_package to base` (not yet on main).

Also: expand the canonical lint contract at
`.apm/instructions/linting.instructions.md` to cover all seven CI
Lint steps (ruff check, ruff format, YAML I/O guard, file-length
guard, no raw `str(relative_to)`, pylint R0801, auth-signal lint)
instead of only ruff. Adds the full `uv run ... && ...` mirror chain
and calls out the merge-commit semantics (CI tests HEAD merged with
main, so duplication on main can fail a clean PR diff). Apm compile
flows the change into the generated AGENTS.md (gitignored at root)
and into `.github/copilot-instructions.md`.

Validation:

* uv run --extra dev ruff check src/ tests/ -- clean
* uv run --extra dev ruff format --check src/ tests/ -- clean
* uv run --extra dev python -m pylint --disable=all --enable=R0801 \
  --min-similarity-lines=10 --fail-on=R0801 src/apm_cli/ -- 10.00/10, exit 0
* bash scripts/lint-auth-signals.sh -- clean
* uv run --extra dev pytest tests/unit/integration/test_hook_integrator.py \
  tests/integration/test_hook_root_source_drift_e2e.py -- 161 passed

Pre-existing main-side test failures in
`tests/unit/test_codex_adapter_compatibility.py` (introduced by
#1262 / #1277 on main and tracked by the in-flight
`32991fb6 fix(adapters): restore _resolve_env_placeholders shim and
reject SSE remotes in tests` branch) are out of scope for this PR
and will resolve when that follow-up lands on main.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(adapters): restore _resolve_env_placeholders shim and reject SSE remotes in tests

PR #1277 removed the _resolve_env_placeholders legacy wrapper from
adapters and softened Codex remote-server rejection (https URLs are
now accepted as streamable-http), but did not update the phase-3
and compatibility test suites. The result was 6 pre-existing test
failures on Build & Test that this PR's Windows-targeted changes
inherited.

- Restore _resolve_env_placeholders on MCPClientAdapter as a thin
  delegate to _resolve_variable_placeholders (empty runtime_vars).
  External callers and 4 test cases rely on the legacy name.
- Update test_returns_false_for_remote_only_server in both codex
  test files to use an SSE-transport remote, which is still the
  rejection contract post-#1277 (streamable-http is now accepted).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Koichi Takahashi <tkou15.hi@gmail.com>
Co-authored-by: Koichi Takahashi <work@k1t.space>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Daniel Meppiel <copilot-rework@github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status/triaged Initial agentic triage complete; pending maintainer ratification (silence = approval).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Codex CLI skips Streamable HTTP MCP servers; README claims this is unsupported

4 participants