diff --git a/RELEASING.md b/RELEASING.md index 49066209..9a0ccd99 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -146,6 +146,35 @@ installs from a local clone: `plugins/memhub/` holds real files with no symlinks of its own, which is exactly why the PUBLIC entry is safe to pin via `git-subdir`. +## Stable MCP URLs and compatibility versions (ENG-1118) + +Keep the MCP URL identical across releases. Codex derives its CIMD client ID +from the full URL, including its query string. Adding a version query changed +production's registered `YzZcYxKAiT6g` identity to `-ugMLRSp9raH` in 0.76.0, +and Auth0 refused login before any backend compatibility check could run. + +Set `headers.X-MemHub-Plugin-Version` in each MCP config to that package's +manifest version. The header is part of the loaded connection configuration; +updating the installed files still requires restarting/reconnecting the host. +The Python transports independently snapshot the loaded package version. +`tests/version_parity_test.py` checks both the exact canonical URLs and header +parity; do not add install-channel or release query parameters to the URL. + +For the 2026-09-22 release, keep the 0.76.1 candidate unmerged until the release +window: merging its bump publishes to Codex and Cursor. Verify backend #1335 +and the ENG-1118 header regression coverage on the target deployment before +activating the broader compatibility gate. Keep the legacy version query +accepted server-side for older installs, with conflicting values rejected. +Do not raise a floor or change enforcement flags as part of this OAuth repair. + +After release, update/restart the plugin and perform a fresh Codex OAuth login, +then initialize MCP, call `list_orgs`, and verify token refresh. The production +CIMD URL should end in `/codex/YzZcYxKAiT6g/client.json`. Verify supported and +below-floor versions through the header using the staging policy first. Prepare +the staging package with its own version bump, and publish it separately using +the staging procedure above. Claude's production tag and marketplace pin also +remain separate release steps; do not move the existing pin before tagging. + ## Gotchas worth knowing before you hit them - **Version-keyed caches on BOTH Claude and Codex** diff --git a/plugins/memhub-staging/.claude-plugin/plugin.json b/plugins/memhub-staging/.claude-plugin/plugin.json index 673cda76..2d542da4 100644 --- a/plugins/memhub-staging/.claude-plugin/plugin.json +++ b/plugins/memhub-staging/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub-staging", "description": "STAGING build of the MemHub plugin \u2014 identical behavior to `memhub` but pointed at the staging backend (api.staging.memhub.xtrace.ai) for MemHub developers iterating on the service. Skills, hooks, and scripts are shared with `memhub` (symlinked), so the two never drift; only the backend URL and OAuth client differ. Install this instead of `memhub` \u2014 never both (both register an MCP server named `memhub`). Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.76.0", + "version": "0.76.1", "license": "Apache-2.0", "hooks": "./hooks/claude-hooks.json", "author": { diff --git a/plugins/memhub-staging/.codex-plugin/plugin.json b/plugins/memhub-staging/.codex-plugin/plugin.json index fbfedfc8..8ee01c1c 100644 --- a/plugins/memhub-staging/.codex-plugin/plugin.json +++ b/plugins/memhub-staging/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub-staging", "description": "MemHub staging for Codex: team memory and Rulebook hooks. Use the setup skill to verify the active hook route and credential. Repository shell commands in projectless tasks must include an explicit cd prefix when the host omits workdir. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.76.0", + "version": "0.76.1", "license": "Apache-2.0", "author": { "name": "XTrace", diff --git a/plugins/memhub-staging/.mcp.json b/plugins/memhub-staging/.mcp.json index e462e7e1..f65f43f2 100644 --- a/plugins/memhub-staging/.mcp.json +++ b/plugins/memhub-staging/.mcp.json @@ -2,7 +2,10 @@ "mcpServers": { "memhub": { "type": "http", - "url": "https://api.staging.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.76.0", + "url": "https://api.staging.memhub.xtrace.ai/mcp-server/mcp", + "headers": { + "X-MemHub-Plugin-Version": "0.76.1" + }, "auth": { "CLIENT_ID": "mYTjrWldX9ZFGtDES3hQDEHSis9gIjiq" }, diff --git a/plugins/memhub/.claude-plugin/plugin.json b/plugins/memhub/.claude-plugin/plugin.json index ebd0ce06..de310652 100644 --- a/plugins/memhub/.claude-plugin/plugin.json +++ b/plugins/memhub/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub", "description": "Auto-capture Claude Code sessions into MemHub team memory. A Stop hook ships each turn as it happens \u2014 sending only the transcript bytes written since the last successful flush, so a session is captured even if it never reaches a clean exit \u2014 alongside flushes on commit/PR events (PostToolUse hooks) and a SessionEnd backstop. The memhub MCP runs tool-aware (agentic) extraction of facts, episodes, and artifacts with watermark-based delta dedup, batching extraction so per-turn capture doesn't fragment episodes. A PreToolUse hook surfaces situated team directives (lessons/procedures) before each Edit/Write/Bash by firing the memhub recall_directives symbol-tripwire on the in-flight call and injecting any hits as context (a client-side precision gate drops directives whose triggers don't concretely match the call, e.g. an over-broad repo-name trigger). Includes skills for: plugin install/health (setup), plugin authentication with its own hook credential (login), repo brain creation and seeding (onboard), terminal artifact uploads (save-artifact), on-demand session import (import-session), team-memory recall (search-memory), teammate session handoff (handoff-session), git-authored spec development with ownership frontmatter (spec), Rulebook rule authoring (create-rule), rules proposed from CLAUDE.md and past sessions in one run (start-rulebook), and PR babysitting (pr-babysit) \u2014 a hook arms a self-paced loop after `gh pr create` that fixes review-bot findings and saves the fixing process to the repo's agent brain. Rules live in RULEBOOKS \u2014 containers with their own membership, so a rule reaches exactly the people its book binds, and one person can be bound by several (their org's book plus their team's): the authoring skills resolve which book a rule lands in and offer to create one when there is none, the hooks cache every book that binds you, and when two books fire on one call the wider book's rule is the one the per-call cap keeps. A PostToolUse hook reminds authors when an edited file belongs to a git-authored spec. Spec ownership frontmatter replaces the retired artifact map; the backend maintains read-only mirrors and opens bootstrap or audit PRs. Sessions are named with the title their own host shows \u2014 Claude Code's generated title, or on Codex the thread_name Codex generated (from the rollout, else ~/.codex/session_index.jsonl), passed through verbatim so MemHub's sessions list matches the editor's; a session its host never named falls back to a title derived from its first prompt \u2014 and route into the repo's own agent brain via a per-user room cache (~/.config/memhub-plugin/rooms.json, never written into the repo) \u2014 resolved once by /memhub:onboard and read by every writer, including the two automatic capture paths, so team memory lands in the repo's room instead of personal memory. Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (scan this machine's local session history for the sessions that wrote a PR's code, rank the candidates by the evidence that matched, and link the ones you approve). Every rulebook fire is now disclosed to the user in a fixed shape on two channels: the hook's own terminal line leads with `\ud83d\udccf Rule fired: ` (`\u26d4\ufe0f` when a gate actually blocked the call) above the detail line it already showed, and the agent is told to echo that same byte-identical line at the top of its reply so the fire reaches the transcript everything downstream reads. Session start now says what rules ARE \u2014 standing instructions from teammates carrying CLAUDE.md's weight \u2014 before listing them. The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.76.0", + "version": "0.76.1", "license": "Apache-2.0", "hooks": "./hooks/claude-hooks.json", "author": { diff --git a/plugins/memhub/.codex-plugin/plugin.json b/plugins/memhub/.codex-plugin/plugin.json index 3ff929c2..2568500f 100644 --- a/plugins/memhub/.codex-plugin/plugin.json +++ b/plugins/memhub/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub", "description": "XTrace MemHub team memory in Codex: automatic session capture, situated directive recall, artifact sync, plus skills for setup, login, onboarding, artifact upload, session import, search, handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook — tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Run memhub:setup to verify hook routing and authentication. Current desktop builds support bundled hooks; older hosts need the user bridge. Bundled handlers defer to matching installed user bridge handlers, which require trust. Codex's own threads are not captured as your sessions: spawned subagents, guardian action-reviews and memory consolidation are recognised from the rollout's thread_source and skipped by name, while every other kind \u2014 including a product surface we have not seen, since Codex's ThreadSource type is open-ended \u2014 is captured, because refusing an unknown kind would silently and unrecoverably drop real work. Every rule fire is reported under the same namespaced session id capture uploads, so a fire is linked to the Codex session it fired in. Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (scan this machine's local session history for the sessions that wrote a PR's code, rank the candidates by the evidence that matched, and link the ones you approve). The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.76.0", + "version": "0.76.1", "license": "Apache-2.0", "author": { "name": "XTrace", diff --git a/plugins/memhub/.cursor-plugin/plugin.json b/plugins/memhub/.cursor-plugin/plugin.json index 3ca9d03c..1113dd20 100644 --- a/plugins/memhub/.cursor-plugin/plugin.json +++ b/plugins/memhub/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "memhub", "displayName": "MemHub", "description": "XTrace MemHub team memory in Cursor: search_memory / save_artifact / import_conversation tools plus skills for setup, login, onboarding, artifact upload, session import, recall, handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook — tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Sessions are captured automatically. Session \u2194 PR linking ships as skills on Cursor: link-pr and find-contributing-sessions. Cursor's shell hooks are observational \u2014 `afterShellExecution` defines no reply the agent can see \u2014 so the automatic post-`gh pr create` link that Claude Code and Codex get is not available here. The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.76.0", + "version": "0.76.1", "license": "Apache-2.0", "author": { "name": "XTrace" diff --git a/plugins/memhub/.mcp.json b/plugins/memhub/.mcp.json index f924b237..83ae8aed 100644 --- a/plugins/memhub/.mcp.json +++ b/plugins/memhub/.mcp.json @@ -2,7 +2,10 @@ "mcpServers": { "memhub": { "type": "http", - "url": "https://api.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.76.0", + "url": "https://api.memhub.xtrace.ai/mcp-server/mcp", + "headers": { + "X-MemHub-Plugin-Version": "0.76.1" + }, "auth": { "CLIENT_ID": "xHoQkYd7uNUfaNX6Tyv143e1Ev7u3XS0" }, diff --git a/plugins/memhub/mcp.json b/plugins/memhub/mcp.json index 4d333d70..b8db2061 100644 --- a/plugins/memhub/mcp.json +++ b/plugins/memhub/mcp.json @@ -3,7 +3,10 @@ "mcpServers": { "memhub": { "type": "streamable-http", - "url": "https://api.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.76.0" + "url": "https://api.memhub.xtrace.ai/mcp-server/mcp", + "headers": { + "X-MemHub-Plugin-Version": "0.76.1" + } } } } diff --git a/plugins/memhub/plugin.json b/plugins/memhub/plugin.json index 406626eb..7096bb94 100644 --- a/plugins/memhub/plugin.json +++ b/plugins/memhub/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "memhub", "description": "XTrace MemHub team memory: search_memory / save_artifact / import_conversation MCP tools plus skills for setup, login, onboarding, artifact upload, session import, team-memory recall, session handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook — tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Session auto-capture ships through the host-native plugin layer (hooks are outside the Agent Plugins spec). Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (scan this machine's local session history for the sessions that wrote a PR's code, rank the candidates by the evidence that matched, and link the ones you approve). The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.76.0", + "version": "0.76.1", "license": "Apache-2.0", "author": { "name": "XTrace", diff --git a/tests/mcp_oauth_identity_test.py b/tests/mcp_oauth_identity_test.py new file mode 100644 index 00000000..86075119 --- /dev/null +++ b/tests/mcp_oauth_identity_test.py @@ -0,0 +1,58 @@ +"""ENG-1118: package releases must retain Auth0's registered Codex identity.""" +import base64 +import copy +import hashlib +import json +import unittest + +from version_parity_test import ( + MCP_AP, MCP_CLAUDE, MCP_STAGING, PRODUCTION_MCP_URL, STAGING_MCP_URL, + connection_errors, +) + + +def callback_id(url): + # Codex oauth_callback.rs: SHA-256 of the complete URL, first nine bytes, + # URL-safe base64. These values were checked against the production log + # and the existing Auth0 CIMD registration, not inferred from the config. + return base64.urlsafe_b64encode(hashlib.sha256(url.encode()).digest()[:9]).decode() + + +class McpOAuthIdentityTests(unittest.TestCase): + def test_shipped_connections_reuse_canonical_oauth_identity(self): + for path, endpoint, registered in ( + (MCP_AP, PRODUCTION_MCP_URL, "YzZcYxKAiT6g"), + (MCP_CLAUDE, PRODUCTION_MCP_URL, "YzZcYxKAiT6g"), + (MCP_STAGING, STAGING_MCP_URL, "lYfHlhqd4tzK"), + ): + with self.subTest(config=path): + config = json.loads(path.read_text()) + server = config["mcpServers"]["memhub"] + self.assertEqual(server["url"], endpoint) + self.assertEqual(callback_id(server["url"]), registered) + for version in ("0.76.1", "0.77.0"): + candidate = copy.deepcopy(config) + candidate["mcpServers"]["memhub"]["headers"]["X-MemHub-Plugin-Version"] = version + self.assertEqual(connection_errors(candidate, version, endpoint), []) + self.assertEqual(callback_id(candidate["mcpServers"]["memhub"]["url"]), registered) + + def test_release_guard_rejects_the_production_incident(self): + config = json.loads(MCP_AP.read_text()) + server = config["mcpServers"]["memhub"] + version = server["headers"]["X-MemHub-Plugin-Version"] + server["url"] += "?memhub_plugin_version=0.76.0" + self.assertEqual(callback_id(server["url"]), "-ugMLRSp9raH") + self.assertTrue(connection_errors(config, version, PRODUCTION_MCP_URL)) + + def test_release_guard_rejects_missing_stale_and_ambiguous_version_headers(self): + for headers in ({}, {"X-MemHub-Plugin-Version": "0.76.0"}, + {"X-MemHub-Plugin-Version": "0.76.1", + "x-memhub-plugin-version": "0.76.0"}): + with self.subTest(headers=headers): + config = json.loads(MCP_AP.read_text()) + config["mcpServers"]["memhub"]["headers"] = headers + self.assertTrue(connection_errors(config, "0.76.1", PRODUCTION_MCP_URL)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/version_parity_test.py b/tests/version_parity_test.py index daa5bdca..96cb4327 100644 --- a/tests/version_parity_test.py +++ b/tests/version_parity_test.py @@ -21,7 +21,6 @@ import json import sys -from urllib.parse import urlsplit, parse_qs from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -36,6 +35,23 @@ MCP_AP = MEMHUB / "mcp.json" # Agent Plugins format (Codex, Cursor, …) MCP_CLAUDE = MEMHUB / ".mcp.json" # Claude Code format (carries oauth) MCP_STAGING = ROOT / "plugins" / "memhub-staging" / ".mcp.json" +PRODUCTION_MCP_URL = "https://api.memhub.xtrace.ai/mcp-server/mcp" +STAGING_MCP_URL = "https://api.staging.memhub.xtrace.ai/mcp-server/mcp" + + +def connection_errors(config: dict, version: str, canonical_url: str) -> list[str]: + """Keep release metadata out of the URL Codex hashes for its OAuth client ID.""" + server = config.get("mcpServers", {}).get("memhub", {}) + errors = [] + if server.get("url") != canonical_url: + errors.append(f"MCP URL must stay {canonical_url!r} across releases; " + "query parameters change the registered OAuth identity") + reported = [value for name, value in server.get("headers", {}).items() + if name.lower() == "x-memhub-plugin-version"] + if reported != [version]: + errors.append(f"loaded MCP connection must send one version header " + f"matching package {version}, got {reported}") + return errors def _reject_dupes(pairs: list[tuple[str, object]]) -> dict: @@ -124,19 +140,16 @@ def server_url(cfg: dict) -> str | None: ap_url, claude_url = server_url(ap_mcp), server_url(claude_mcp) print(f" mcp.json → {ap_url}") print(f" .mcp.json → {claude_url}") - # Compare ignoring query string: the AP entry may carry an install-channel - # tag (?client=…) without pointing anywhere different. - strip = lambda u: (u or "").split("?")[0] - if not ap_url or strip(ap_url) != strip(claude_url): + if not ap_url or ap_url != claude_url: print("\nFAIL MCP endpoints disagree — AP-installed hosts (Codex, Cursor)\n" " would talk to a different backend than Claude installs.") return 1 - for config, expected in ((ap_mcp, versions["memhub (AP root)"]), - (claude_mcp, versions["memhub (claude)"]), - (staging_mcp, staging_manifest["version"])): - reported = parse_qs(urlsplit(server_url(config)).query).get("memhub_plugin_version") - if reported != [expected]: - print(f"FAIL loaded MCP connection must report package version {expected}, got {reported}") + for config, expected, url in ( + (ap_mcp, versions["memhub (AP root)"], PRODUCTION_MCP_URL), + (claude_mcp, versions["memhub (claude)"], PRODUCTION_MCP_URL), + (staging_mcp, staging_manifest["version"], STAGING_MCP_URL)): + for error in connection_errors(config, expected, url): + print(f"FAIL {error}") failures += 1 print("ok both MCP configs point at the same server") return 0 if not failures else 1