-
Notifications
You must be signed in to change notification settings - Fork 35
feat(registry): orchestrator + agent + MCP server E2E (#246) #633
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Kalindi-Dev
wants to merge
3
commits into
aws-samples:main
Choose a base branch
from
Kalindi-Dev:feat/246-registry-orchestrator-mcp
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| """Apply registry-resolved assets (#246) to the agent's runtime environment. | ||
|
|
||
| The orchestrator resolves a blueprint's ``registry://`` pins at the create-task | ||
| boundary and threads a ``resolved_assets`` bundle into the invocation payload | ||
| (see docs/design/REGISTRY.md §7/§8). This module is the agent-side consumer: | ||
| it takes that bundle and applies each asset kind with the right mechanism. | ||
|
|
||
| MVP (this PR) wires ``mcp_server`` end-to-end — the resolved server config is | ||
| merged into ``<repo_dir>/.mcp.json`` alongside whatever ``channel_mcp.py`` | ||
| wrote, so the Claude Agent SDK (``setting_sources=["project"]``) picks it up at | ||
| session start. ``cedar_policy_module`` and ``skill`` are stubs here; PR 3 fills | ||
| them in (Cedar text appended to the PolicyEngine set; skill prompt fragments | ||
| into the SDK setting sources). | ||
|
|
||
| Design note — parity with channel_mcp.py: both write ``.mcp.json`` by | ||
| read-merge-write on the ``mcpServers`` map, never clobbering other servers. | ||
| Registry assets and the channel MCP can therefore coexist in one file. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| from typing import Any | ||
|
|
||
| from shell import log | ||
|
|
||
| #: Key under which the resolved MCP server config lives in the descriptor | ||
| #: (REGISTRY.md §3.3). The value is a ready-to-write ``mcpServers`` entry. | ||
| _MCP_SERVER_CONFIG_KEY = "server_config" | ||
|
|
||
|
|
||
| def _read_existing_mcp_config(path: str) -> dict[str, Any]: | ||
| """Return the parsed .mcp.json at ``path``, or an empty dict if absent/invalid. | ||
|
|
||
| Mirrors ``channel_mcp._read_existing_mcp_config`` — malformed JSON is logged | ||
| and treated as absent rather than crashing the agent on a broken file. | ||
| """ | ||
| if not os.path.isfile(path): | ||
| return {} | ||
| try: | ||
| with open(path, encoding="utf-8") as f: | ||
| parsed = json.load(f) | ||
| if isinstance(parsed, dict): | ||
| return parsed | ||
| log("WARN", f"Ignoring non-object .mcp.json at {path} (got {type(parsed).__name__})") | ||
| except (OSError, json.JSONDecodeError) as e: | ||
| log("WARN", f"Failed to read existing .mcp.json at {path}: {type(e).__name__}: {e}") | ||
| return {} | ||
|
|
||
|
|
||
| def _mcp_server_key(asset: dict[str, Any]) -> str: | ||
| """The ``mcpServers`` key an asset registers under. | ||
|
|
||
| Prefer the descriptor's ``tool_prefix`` stripped of the ``mcp__`` scaffolding | ||
| (so tools surface consistently), falling back to ``namespace-name`` which is | ||
| always present and unique per asset. | ||
| """ | ||
| namespace = asset.get("namespace", "") | ||
| name = asset.get("name", "") | ||
| return f"{namespace}-{name}".strip("-") or name or "registry-mcp" | ||
|
|
||
|
|
||
| def apply_mcp_assets(repo_dir: str, resolved_mcp_servers: list[dict[str, Any]]) -> int: | ||
| """Merge resolved MCP server assets into ``<repo_dir>/.mcp.json``. | ||
|
|
||
| Read-merge-write on the ``mcpServers`` map, preserving any channel MCP or | ||
| repo-committed entries. Each asset's descriptor carries a ``server_config`` | ||
| (the ``mcpServers`` entry value); assets missing it are skipped with a warn | ||
| rather than writing a malformed entry. | ||
|
|
||
| Args: | ||
| repo_dir: the cloned-repo working directory the SDK uses as ``cwd``. | ||
| resolved_mcp_servers: the ``mcp_servers`` list from the resolved bundle; | ||
| each item is a resolved-asset dict (kind/namespace/name/version/descriptor). | ||
|
|
||
| Returns: | ||
| The number of MCP entries written (0 when nothing to apply, or on a | ||
| write failure — logged). | ||
| """ | ||
| if not resolved_mcp_servers: | ||
| return 0 | ||
| if not repo_dir or not os.path.isdir(repo_dir): | ||
| log("WARN", f"apply_mcp_assets: repo_dir missing or not a directory: {repo_dir!r}") | ||
| return 0 | ||
|
|
||
| mcp_path = os.path.join(repo_dir, ".mcp.json") | ||
| config = _read_existing_mcp_config(mcp_path) | ||
| servers = config.get("mcpServers") | ||
| if not isinstance(servers, dict): | ||
| servers = {} | ||
|
|
||
| written = 0 | ||
| for asset in resolved_mcp_servers: | ||
| descriptor = asset.get("descriptor") or {} | ||
| server_config = descriptor.get(_MCP_SERVER_CONFIG_KEY) | ||
| if not isinstance(server_config, dict): | ||
| log( | ||
| "WARN", | ||
| f"apply_mcp_assets: asset {asset.get('namespace')}/{asset.get('name')} " | ||
| f"has no '{_MCP_SERVER_CONFIG_KEY}' in its descriptor; skipping", | ||
| ) | ||
| continue | ||
| servers[_mcp_server_key(asset)] = server_config | ||
| written += 1 | ||
|
|
||
| if written == 0: | ||
| return 0 | ||
|
|
||
| config["mcpServers"] = servers | ||
| try: | ||
| with open(mcp_path, "w", encoding="utf-8") as f: | ||
| json.dump(config, f, indent=2) | ||
| f.write("\n") | ||
| except OSError as e: | ||
| log("ERROR", f"apply_mcp_assets: failed to write {mcp_path}: {e}") | ||
| return 0 | ||
|
|
||
| log("TASK", f"registry: merged {written} MCP server asset(s) into {mcp_path}") | ||
| return written | ||
|
|
||
|
|
||
| def apply_cedar_modules(resolved_cedar_modules: list[dict[str, Any]]) -> list[str]: | ||
| """Return Cedar policy text for resolved cedar_policy_module assets. | ||
|
|
||
| STUB (PR 3): the resolver already returns these assets, but wiring the text | ||
| into the PolicyEngine's policy set is deferred. Returns an empty list today | ||
| so callers can unconditionally extend their policy list. | ||
| """ | ||
| if resolved_cedar_modules: | ||
| log( | ||
| "TASK", | ||
| f"registry: {len(resolved_cedar_modules)} cedar_policy_module asset(s) " | ||
| "resolved but not yet applied (PR 3)", | ||
| ) | ||
| return [] | ||
|
|
||
|
|
||
| def apply_skills(repo_dir: str, resolved_skills: list[dict[str, Any]]) -> int: | ||
| """Apply resolved skill assets to the SDK setting sources. | ||
|
|
||
| STUB (PR 3): returns 0 today. The resolver returns these assets; loading the | ||
| prompt fragments into the SDK's setting_sources is deferred to PR 3. | ||
| """ | ||
| if resolved_skills: | ||
| log( | ||
| "TASK", | ||
| f"registry: {len(resolved_skills)} skill asset(s) resolved but not yet applied (PR 3)", | ||
| ) | ||
| return 0 | ||
|
|
||
|
|
||
| def apply_resolved_assets(repo_dir: str, resolved_assets: dict[str, list[dict]]) -> None: | ||
| """Apply an entire resolved-asset bundle to the runtime. | ||
|
|
||
| Dispatches each kind to its loader. MVP applies MCP servers; cedar/skills are | ||
| logged-only stubs (PR 3). Safe to call with an empty bundle (no-op). | ||
| """ | ||
| if not resolved_assets: | ||
| return | ||
| apply_mcp_assets(repo_dir, resolved_assets.get("mcp_servers") or []) | ||
| apply_cedar_modules(resolved_assets.get("cedar_policy_modules") or []) | ||
| apply_skills(repo_dir, resolved_assets.get("skills") or []) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| """Registry URI grammar parity — agent side (#246). | ||
|
|
||
| Loads every ``contracts/registry-resolution/grammar-*.json`` fixture and asserts | ||
| that ``workflow.is_registry_ref(ref)`` agrees with the fixture's ``expected.valid``. | ||
| This is the golden contract that the TypeScript ``parseRef`` | ||
| (cdk/src/handlers/shared/registry-resolver.ts) must also reproduce — mirroring | ||
| the cross-engine pattern in ``test_cedar_parity.py`` and the workflow-validation | ||
| corpus. | ||
|
|
||
| If the regex and a fixture disagree, CI fails before the change ships: either | ||
| the grammar regressed, or the fixture must be updated as a recorded decision. | ||
| See ``contracts/registry-resolution/README.md`` and REGISTRY.md §6. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
| from workflow import is_registry_ref | ||
|
|
||
| _FIXTURE_DIR = ( | ||
| Path(os.path.dirname(__file__)) / ".." / ".." / "contracts" / "registry-resolution" | ||
| ).resolve() | ||
|
|
||
|
|
||
| def _load_grammar_fixtures() -> list[dict]: | ||
| assert _FIXTURE_DIR.is_dir(), ( | ||
| f"expected fixture dir at {_FIXTURE_DIR}; see contracts/registry-resolution/README.md" | ||
| ) | ||
| fixtures = [] | ||
| for path in sorted(_FIXTURE_DIR.glob("grammar-*.json")): | ||
| fixture = json.loads(path.read_text(encoding="utf-8")) | ||
| for required in ("name", "ref", "expected"): | ||
| if required not in fixture: | ||
| raise AssertionError(f"{path.name}: missing required field {required!r}") | ||
| if "valid" not in fixture["expected"]: | ||
| raise AssertionError(f"{path.name}: expected missing 'valid'") | ||
| fixtures.append(fixture) | ||
| assert fixtures, f"no grammar-*.json fixtures under {_FIXTURE_DIR}" | ||
| return fixtures | ||
|
|
||
|
|
||
| _FIXTURES = _load_grammar_fixtures() | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("fixture", _FIXTURES, ids=[f["name"] for f in _FIXTURES]) | ||
| def test_grammar_matches_fixture(fixture: dict) -> None: | ||
| observed = is_registry_ref(fixture["ref"]) | ||
| expected = fixture["expected"]["valid"] | ||
| assert observed == expected, ( | ||
| f"fixture {fixture['name']!r}: grammar drift — is_registry_ref({fixture['ref']!r}) " | ||
| f"returned {observed}, fixture expects {expected}" | ||
| ) | ||
|
|
||
|
|
||
| def test_corpus_covers_valid_and_invalid() -> None: | ||
| """The corpus must exercise both accept and reject paths.""" | ||
| valids = [f for f in _FIXTURES if f["expected"]["valid"]] | ||
| invalids = [f for f in _FIXTURES if not f["expected"]["valid"]] | ||
| assert valids, "grammar corpus must include at least one valid ref" | ||
| assert invalids, "grammar corpus must include at least one invalid ref" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit (comment-analyzer): this docstring says the key 'Prefer[s] the descriptor's
tool_prefixstripped of themcp__scaffolding … falling back tonamespace-name', but the implementation below only ever computesnamespace-name—tool_prefixis never read. Either wire thetool_prefixpreference or drop that sentence so the comment matches the code. Non-blocking.