diff --git a/docs/config.md b/docs/config.md index f2d211642..752dcc0dc 100644 --- a/docs/config.md +++ b/docs/config.md @@ -827,6 +827,13 @@ Per Decision S5 of the design spike, backend-agnostic high-level sections only the Llama-Stack-specific synthesis controls: which baseline to start from, an optional profile file, and a raw native_override escape hatch. +During synthesis from the default baseline or a profile, LCORE ensures the +Llama Stack MCP tool_runtime provider (`provider_id: model-context-protocol`, +`provider_type: remote::model-context-protocol`) is present so static +`mcp_servers` and dynamic MCP registration work. That ensure is skipped when +`baseline: empty` (migration / blank-slate); supply MCP via `native_override` +in that case. + Attributes: baseline: Synthesis starting point. "default" begins from LCORE's built-in baseline (src/data/default_run.yaml); "empty" begins from diff --git a/docs/deployment_guide.md b/docs/deployment_guide.md index a38735e0f..bd8b1849a 100644 --- a/docs/deployment_guide.md +++ b/docs/deployment_guide.md @@ -105,8 +105,11 @@ starts from a *baseline*. By default that is LCORE's built-in baseline; a A profile is an ordinary `run.yaml`-shaped YAML file — the same schema Llama Stack reads natively. Everything else in the unified pipeline (enrichment, -the high-level `inference.providers` section, `native_override`) is applied -*on top* of the profile, in that order. +the high-level `inference.providers` section, ensuring the MCP tool_runtime +provider, then `native_override`) is applied *on top* of the profile, in that +order. The MCP ensure adds `provider_id: model-context-protocol` when missing +so static `mcp_servers` and dynamic MCP registration work; it is skipped only +for `baseline: empty` (use `native_override` there if you need MCP). **Authoring a profile.** Start from one of the reference profiles shipped in [`examples/profiles/`](https://github.com/lightspeed-core/lightspeed-stack/tree/main/examples/profiles): diff --git a/src/constants.py b/src/constants.py index a94bb350a..a5053178d 100644 --- a/src/constants.py +++ b/src/constants.py @@ -160,6 +160,10 @@ MCP_AUTH_CLIENT: Final[str] = "client" MCP_AUTH_OAUTH: Final[str] = "oauth" +# MCP tool_runtime provider (Llama Stack run.yaml / unified synthesis) +MCP_TOOL_RUNTIME_PROVIDER_ID: Final[str] = "model-context-protocol" +MCP_TOOL_RUNTIME_PROVIDER_TYPE: Final[str] = "remote::model-context-protocol" + # Media type constants for streaming responses MEDIA_TYPE_JSON: Final[str] = "application/json" MEDIA_TYPE_TEXT: Final[str] = "text/plain" diff --git a/src/data/default_run.yaml b/src/data/default_run.yaml index cb8b35bb7..206ee8718 100644 --- a/src/data/default_run.yaml +++ b/src/data/default_run.yaml @@ -9,7 +9,9 @@ # It is intentionally thinner than the repo-root run.yaml: it carries only the # APIs and providers needed to boot a minimal, queryable stack (inference, # safety, vector_io, agents, tool_runtime, files) plus the storage backends -# those providers reference. Operators extend it via high-level sections or +# those providers reference. tool_runtime includes rag-runtime (file_search / +# RAG) and model-context-protocol (MCP) so those capabilities work when +# operators enable them. Operators extend it via high-level sections or # `native_override`. # # NOTE: `external_providers_dir` carries a default (`:=~/.llama/providers.d`) @@ -54,6 +56,9 @@ providers: - config: {} provider_id: rag-runtime provider_type: inline::rag-runtime + - config: {} + provider_id: model-context-protocol + provider_type: remote::model-context-protocol vector_io: - config: persistence: diff --git a/src/llama_stack_configuration.py b/src/llama_stack_configuration.py index 1df975e48..4e82bfbf8 100644 --- a/src/llama_stack_configuration.py +++ b/src/llama_stack_configuration.py @@ -782,6 +782,47 @@ def apply_high_level_inference( ) +def ensure_mcp_tool_runtime(ls_config: dict[str, Any]) -> None: + """Ensure the default MCP tool_runtime provider exists in ``ls_config``. + + Adds ``tool_runtime`` to ``apis`` when missing, then appends the default + ``model-context-protocol`` provider under ``providers.tool_runtime`` when + no entry with that ``provider_id`` is already present. Existing entries + (including ``rag-runtime``) are left untouched. + + Parameters: + ls_config: The Llama Stack configuration being synthesized (modified + in place). + + Returns: + None: ``ls_config`` is modified in place. + """ + apis = ls_config.setdefault("apis", []) + if "tool_runtime" not in apis: + apis.append("tool_runtime") + + providers_section = ls_config.setdefault("providers", {}) + tool_runtime = providers_section.setdefault("tool_runtime", []) + for existing in tool_runtime: + if ( + isinstance(existing, dict) + and existing.get("provider_id") == constants.MCP_TOOL_RUNTIME_PROVIDER_ID + ): + return + + tool_runtime.append( + { + "provider_id": constants.MCP_TOOL_RUNTIME_PROVIDER_ID, + "provider_type": constants.MCP_TOOL_RUNTIME_PROVIDER_TYPE, + "config": {}, + } + ) + logger.info( + "Added MCP tool_runtime provider provider_id=%r", + constants.MCP_TOOL_RUNTIME_PROVIDER_ID, + ) + + def _resolve_profile_path(profile: str, config_file_dir: Optional[str]) -> Path: """Resolve a ``profile:`` path against the loaded config's directory (R8). @@ -812,8 +853,9 @@ def synthesize_configuration( Implements the unified-mode synthesis pipeline: select a baseline (profile file, empty, or the built-in default), apply the existing enrichment (Azure Entra ID, BYOK RAG, Solr/OKP) for parity with legacy mode (R7), - expand the high-level ``inference.providers`` section, and deep-merge the - raw ``native_override`` last (R5). + expand the high-level ``inference.providers`` section, ensure the default + MCP tool_runtime provider when the baseline was not empty, and deep-merge + the raw ``native_override`` last (R5). Parameters: lcs_config: The full ``lightspeed-stack.yaml`` parsed into a dict. @@ -829,6 +871,7 @@ def synthesize_configuration( unified = llama_stack.get("config") # None when only top-level inputs are set # 1-2. Select the baseline. + baseline_was_empty = False if unified and unified.get("profile"): profile_path = _resolve_profile_path(unified["profile"], config_file_dir) logger.info("Loading synthesis baseline from profile %s", profile_path) @@ -836,6 +879,7 @@ def synthesize_configuration( baseline = yaml.safe_load(file) or {} elif unified and unified.get("baseline") == "empty": logger.info("Synthesizing from an empty baseline") + baseline_was_empty = True baseline = {} else: baseline = ( @@ -860,11 +904,16 @@ def synthesize_configuration( if inference.get("providers"): apply_high_level_inference(ls_config, inference) - # 6. Raw escape hatch, deep-merged last with list replacement (R5). + # 6. Ensure MCP tool_runtime for default/profile baselines (skipped for + # baseline: empty so migrate round-trips stay lossless). + if not baseline_was_empty: + ensure_mcp_tool_runtime(ls_config) + + # 7. Raw escape hatch, deep-merged last with list replacement (R5). if unified and unified.get("native_override"): ls_config = deep_merge_list_replace(ls_config, unified["native_override"]) - # 7. Dedupe again in case native_override or enrichment reintroduced dupes. + # 8. Dedupe again in case native_override or enrichment reintroduced dupes. dedupe_providers_vector_io(ls_config) return ls_config diff --git a/tests/e2e/configuration/README.md b/tests/e2e/configuration/README.md index 1a8d700ca..3edc5d852 100644 --- a/tests/e2e/configuration/README.md +++ b/tests/e2e/configuration/README.md @@ -25,7 +25,10 @@ and the active `lightspeed-stack.yaml` is also copied to the repo root — so th relative `profile:` path resolves to that materialized file, which the unified synthesizer consumes as its baseline. LS behavior is identical to the legacy two-file path (requirement R7 in the config-merge design doc); the wiring that -selects a provider config stays unchanged. +selects a provider config stays unchanged. The synthesizer also ensures the +MCP `tool_runtime` provider (`model-context-protocol`) is present on the +profile baseline when missing; many `run-*.yaml` fixtures already include it, +so the ensure is typically a no-op. The `tests/e2e/configs/run-*.yaml` files therefore serve a dual role: in server mode they are the run configuration of the standalone Llama Stack diff --git a/tests/unit/test_llama_stack_synthesize.py b/tests/unit/test_llama_stack_synthesize.py index ad1ffbc3c..eb2998e45 100644 --- a/tests/unit/test_llama_stack_synthesize.py +++ b/tests/unit/test_llama_stack_synthesize.py @@ -9,7 +9,7 @@ import os import stat from pathlib import Path -from typing import Any, get_args +from typing import Any, Optional, get_args import pytest import yaml @@ -18,6 +18,7 @@ PROVIDER_TYPE_MAP, apply_high_level_inference, deep_merge_list_replace, + ensure_mcp_tool_runtime, load_default_baseline, migrate_config_dumb, synthesize_configuration, @@ -25,6 +26,79 @@ ) from models.config import UnifiedInferenceProvider +# --------------------------------------------------------------------------- +# ensure_mcp_tool_runtime +# --------------------------------------------------------------------------- + + +def _tool_runtime_ids(ls_config: dict[str, Any]) -> list[Optional[str]]: + """Return provider_id values from providers.tool_runtime.""" + providers = ls_config.get("providers") or {} + return [ + entry.get("provider_id") + for entry in providers.get("tool_runtime") or [] + if isinstance(entry, dict) + ] + + +def test_ensure_mcp_tool_runtime_appends_and_preserves_rag() -> None: + """MCP is appended; existing rag-runtime is untouched.""" + ls_config: dict[str, Any] = { + "apis": ["tool_runtime"], + "providers": { + "tool_runtime": [ + { + "provider_id": "rag-runtime", + "provider_type": "inline::rag-runtime", + "config": {}, + } + ] + }, + } + ensure_mcp_tool_runtime(ls_config) + assert _tool_runtime_ids(ls_config) == [ + "rag-runtime", + "model-context-protocol", + ] + + found = False + found_entry = {} + for entry in ls_config["providers"]["tool_runtime"]: + if ( + isinstance(entry, dict) + and entry.get("provider_id") == "model-context-protocol" + ): + found = True + found_entry = entry + break + assert found + assert found_entry["provider_type"] == "remote::model-context-protocol" + assert found_entry["config"] == {} + + +def test_ensure_mcp_tool_runtime_idempotent() -> None: + """If MCP already exists, do not duplicate or rewrite it.""" + existing = { + "provider_id": "model-context-protocol", + "provider_type": "remote::model-context-protocol", + "config": {"keep": True}, + } + ls_config: dict[str, Any] = { + "apis": ["tool_runtime"], + "providers": {"tool_runtime": [existing]}, + } + ensure_mcp_tool_runtime(ls_config) + assert ls_config["providers"]["tool_runtime"] == [existing] + + +def test_ensure_mcp_tool_runtime_adds_api_when_missing() -> None: + """Thin baselines get tool_runtime in apis and the MCP provider.""" + ls_config: dict[str, Any] = {} + ensure_mcp_tool_runtime(ls_config) + assert "tool_runtime" in ls_config["apis"] + assert _tool_runtime_ids(ls_config) == ["model-context-protocol"] + + # --------------------------------------------------------------------------- # load_default_baseline # --------------------------------------------------------------------------- @@ -41,6 +115,14 @@ def test_load_default_baseline_returns_usable_dict() -> None: assert ":=" in baseline["external_providers_dir"] +def test_load_default_baseline_includes_mcp_tool_runtime() -> None: + """Default stack ships MCP beside rag-runtime (same rationale as RAG).""" + baseline = load_default_baseline() + ids = _tool_runtime_ids(baseline) + assert "rag-runtime" in ids + assert "model-context-protocol" in ids + + # --------------------------------------------------------------------------- # deep_merge_list_replace # --------------------------------------------------------------------------- @@ -347,6 +429,135 @@ def test_provider_type_map_covers_every_literal_value() -> None: # --------------------------------------------------------------------------- +def test_synthesize_default_baseline_ensures_mcp() -> None: + """Non-empty default baseline path always gets MCP (even with no mcp_servers).""" + default_baseline: dict[str, Any] = { + "apis": ["inference", "tool_runtime"], + "providers": { + "inference": [], + "tool_runtime": [ + { + "provider_id": "rag-runtime", + "provider_type": "inline::rag-runtime", + "config": {}, + } + ], + }, + } + lcs_config = { + "llama_stack": {"config": {"baseline": "default"}}, + "inference": {"providers": []}, + } + result = synthesize_configuration(lcs_config, default_baseline=default_baseline) + assert "model-context-protocol" in _tool_runtime_ids(result) + assert "rag-runtime" in _tool_runtime_ids(result) + + +def test_synthesize_empty_baseline_skips_mcp_ensure() -> None: + """baseline: empty must not assume MCP.""" + lcs_config = { + "llama_stack": { + "config": { + "baseline": "empty", + "native_override": { + "version": 2, + "apis": ["inference"], + "providers": {"inference": []}, + }, + } + } + } + result = synthesize_configuration(lcs_config) + assert "model-context-protocol" not in _tool_runtime_ids(result) + assert result["apis"] == ["inference"] + + +def test_synthesize_empty_baseline_keeps_mcp_from_override() -> None: + """MCP already in native_override is preserved when ensure is skipped.""" + lcs_config = { + "llama_stack": { + "config": { + "baseline": "empty", + "native_override": { + "providers": { + "tool_runtime": [ + { + "provider_id": "model-context-protocol", + "provider_type": "remote::model-context-protocol", + "config": {}, + } + ] + } + }, + } + } + } + result = synthesize_configuration(lcs_config) + assert _tool_runtime_ids(result) == ["model-context-protocol"] + + +def test_synthesize_native_override_can_opt_out_of_mcp() -> None: + """List-replace of tool_runtime after ensure removes MCP (opt-out).""" + default_baseline: dict[str, Any] = { + "apis": ["tool_runtime"], + "providers": {"tool_runtime": []}, + } + lcs_config = { + "llama_stack": { + "config": { + "baseline": "default", + "native_override": { + "providers": { + "tool_runtime": [ + { + "provider_id": "rag-runtime", + "provider_type": "inline::rag-runtime", + "config": {}, + } + ] + } + }, + } + } + } + result = synthesize_configuration(lcs_config, default_baseline=default_baseline) + assert _tool_runtime_ids(result) == ["rag-runtime"] + + +def test_synthesize_profile_missing_mcp_gets_ensure( + tmp_path: Path, +) -> None: + """A thin profile without MCP still receives the provider via ensure.""" + profile = tmp_path / "thin.yaml" + profile.write_text( + yaml.dump( + { + "apis": ["inference"], + "providers": {"inference": []}, + } + ), + encoding="utf-8", + ) + lcs_config = { + "llama_stack": {"config": {"profile": str(profile)}}, + } + result = synthesize_configuration(lcs_config) + assert "tool_runtime" in result["apis"] + assert "model-context-protocol" in _tool_runtime_ids(result) + + +def test_synthesize_empty_profile_still_ensures_mcp(tmp_path: Path) -> None: + """A profile that loads {} still gets ensure; only baseline: empty skips it.""" + profile = tmp_path / "empty-profile.yaml" + profile.write_text("{}\n", encoding="utf-8") + lcs_config = { + "llama_stack": {"config": {"profile": str(profile)}}, + } + result = synthesize_configuration(lcs_config) + assert "tool_runtime" in result["apis"] + assert "model-context-protocol" in _tool_runtime_ids(result) + + def test_synthesize_from_empty_baseline_only_native_override() -> None: """baseline: empty starts from {} so native_override is the whole output.""" lcs = {