From b42f1754dc32fa59dffca5c0987e24c6ff31fc5b Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Mon, 25 May 2026 15:49:26 +0530 Subject: [PATCH 01/28] feat(indexer): scaffold mcp_tools indexer with settings --- src/indexers/mcp_tools.py | 78 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/indexers/mcp_tools.py diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py new file mode 100644 index 00000000..cbd6e59e --- /dev/null +++ b/src/indexers/mcp_tools.py @@ -0,0 +1,78 @@ +"""Indexer that discovers MCP tools by: + 1. Reading the workspace's MCP server list from the MCP_CONFIG setting + (populated from Helm `mcpConfig:` values via the runner's existing + workspaceInfo plumbing — Approach D2). + 2. For each configured server, calling its `tools/list` MCP endpoint + (in-VPC, outbound from the runner). + 3. Emitting one `mcp_tool` resource per tool to the Registry, with the + server's URL/secret-ref/display-name and the tool's input schema. + +A subsequent generation-rule run (enrichers/generation_rules) matches these +resources and renders one SLX + Runbook per tool via the mcp-tool-proxy +templates in rw-generic-codecollection. +""" + +import logging +from typing import Any + +import requests + +from component import Setting, SettingDependency, Context +from resources import Registry, REGISTRY_PROPERTY_NAME + +logger = logging.getLogger(__name__) + +DOCUMENTATION = "Discovers MCP tools from Helm-configured MCP servers (Approach D2)." + +# Same pattern as CLOUD_CONFIG_SETTING in src/indexers/common.py — a DICT +# setting populated from the workspaceInfo YAML's `mcpConfig:` key, which the +# runner Helm chart writes from its values.yaml `mcpConfig:` block: +# +# mcpConfig: +# servers: +# - display_name: jira +# url: https://jira-mcp.internal:443/mcp +# secret_ref: jira-mcp-token +# - display_name: linear +# url: https://linear-mcp.internal:443/mcp +# secret_ref: linear-mcp-token +MCP_CONFIG_SETTING = Setting( + "MCP_CONFIG", + "mcpConfig", + Setting.Type.DICT, + "Configuration for MCP servers to introspect for tool discovery.", + dict(), +) + +SETTINGS = ( + SettingDependency(MCP_CONFIG_SETTING, False), +) + +PLATFORM_NAME = "mcp" +RESOURCE_TYPE = "mcp_tool" +TOOLS_LIST_TIMEOUT = 15 + + +def index(context: Context) -> None: + config = context.get_setting(MCP_CONFIG_SETTING) or {} + servers = _load_servers_from_setting(config, on_warning=context.add_warning) + if not servers: + logger.info("mcp_tools: no MCP servers configured; skipping.") + return + + registry: Registry = context.get_property(REGISTRY_PROPERTY_NAME) + + for server in servers: + try: + tools = _list_tools(server) + except Exception as exc: + # Preserve previous SLXs on failure (per design §7.9). We simply + # don't emit fresh resources for this server; the existing SLXs + # from the previous successful cycle stay in place upstream. + logger.warning("mcp_tools: tools/list failed for %s: %s", + server.get("display_name"), exc) + context.add_warning( + f"MCP tools/list failed for {server.get('display_name')}: {exc}") + continue + for tool in tools: + _emit_tool_resource(registry, server, tool) From 728e2b9db2a499b1deab794f92ce72f2fcf3f204 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Mon, 25 May 2026 15:51:16 +0530 Subject: [PATCH 02/28] feat(indexer): _load_servers_from_setting parses MCP_CONFIG with validation --- src/indexers/mcp_tools.py | 29 +++++++++++++++++++++++ src/tests.py | 48 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py index cbd6e59e..948ebd47 100644 --- a/src/indexers/mcp_tools.py +++ b/src/indexers/mcp_tools.py @@ -76,3 +76,32 @@ def index(context: Context) -> None: continue for tool in tools: _emit_tool_resource(registry, server, tool) + + +REQUIRED_SERVER_FIELDS = ("display_name", "url", "secret_ref") + + +def _load_servers_from_setting(config, on_warning=None) -> list[dict[str, Any]]: + """Parse the MCP_CONFIG setting (a DICT mirroring the Helm values block) + into a list of validated server entries. Skips malformed entries with a + warning so a single bad config row doesn't prevent the rest from working. + """ + if not config: + return [] + raw = config.get("servers") or [] + valid: list[dict[str, Any]] = [] + for entry in raw: + if not isinstance(entry, dict): + if on_warning: + on_warning(f"mcpConfig.servers entry is not a dict: {entry!r}") + continue + missing = [f for f in REQUIRED_SERVER_FIELDS if not entry.get(f)] + if missing: + label = entry.get("display_name") or entry.get("url") or "" + if on_warning: + on_warning( + f"mcpConfig.servers[{label}] missing required field(s) " + f"{missing}; skipping") + continue + valid.append(entry) + return valid diff --git a/src/tests.py b/src/tests.py index d67c5812..29b6d3d9 100644 --- a/src/tests.py +++ b/src/tests.py @@ -464,4 +464,50 @@ def test_code_bundle_black_list(self): # return local_repo_url # # def test_no_code_bundle_config(self): -# pass \ No newline at end of file +# pass + + +# --------------------------------------------------------------------------- +# mcp_tools indexer +# --------------------------------------------------------------------------- + +import json +import responses +from unittest import mock + +from indexers import mcp_tools + + +class LoadServersFromSettingTest(TestCase): + def test_returns_servers_list_from_well_formed_config(self): + config = {"servers": [ + {"display_name": "jira", + "url": "https://jira-mcp.internal/mcp", + "secret_ref": "jira-mcp-token"}, + {"display_name": "linear", + "url": "https://linear-mcp.internal/mcp", + "secret_ref": "linear-mcp-token"}, + ]} + servers = mcp_tools._load_servers_from_setting(config) + self.assertEqual(len(servers), 2) + self.assertEqual(servers[0]["display_name"], "jira") + + def test_returns_empty_when_no_servers_key(self): + self.assertEqual(mcp_tools._load_servers_from_setting({}), []) + self.assertEqual(mcp_tools._load_servers_from_setting(None), []) + + def test_skips_entries_missing_required_fields_and_warns(self): + warnings = [] + config = {"servers": [ + {"display_name": "ok", + "url": "https://ok.internal/mcp", + "secret_ref": "ok-token"}, + {"display_name": "broken"}, # missing url + secret_ref + {"url": "https://anon.internal/mcp", + "secret_ref": "anon-token"}, # missing display_name + ]} + servers = mcp_tools._load_servers_from_setting( + config, on_warning=warnings.append) + self.assertEqual([s["display_name"] for s in servers], ["ok"]) + self.assertEqual(len(warnings), 2) + self.assertTrue(any("broken" in w for w in warnings)) \ No newline at end of file From 283cf27d8d859e31f779d09445f85852e6b9fc4f Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Mon, 25 May 2026 15:51:51 +0530 Subject: [PATCH 03/28] feat(indexer): _list_tools performs MCP handshake and returns tool array --- src/indexers/mcp_tools.py | 51 +++++++++++++++++++++++++++++++++++++++ src/tests.py | 43 ++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py index 948ebd47..d768d609 100644 --- a/src/indexers/mcp_tools.py +++ b/src/indexers/mcp_tools.py @@ -105,3 +105,54 @@ def _load_servers_from_setting(config, on_warning=None) -> list[dict[str, Any]]: continue valid.append(entry) return valid + + +def _resolve_secret(secret_ref: str) -> str: + """Read a workspace secret and return the token value. Resolved here so + tests can monkey-patch this single function rather than threading a + fetcher parameter through every call site.""" + from k8s_utils import get_secret + data = get_secret(secret_ref) + # Secret convention: stored under key "token"; fall back to single-key shape. + return data.get("token") or next(iter(data.values())) + + +def _list_tools(server: dict[str, Any], + fetch_secret=None) -> list[dict[str, Any]]: + """Calls the MCP server's initialize/notifications/tools/list handshake + and returns the `tools` array from the result. + + `fetch_secret` is injected for testability. Defaults to _resolve_secret + which talks to the k8s secret store at runtime. + """ + if fetch_secret is None: + fetch_secret = _resolve_secret + token = fetch_secret(server["secret_ref"]) + + s = requests.Session() + s.headers.update({ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + "Accept": "application/json, text/event-stream", + }) + init = s.post(server["url"], + json={"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "runwhen-builder", "version": "1.0.0"}}}, + timeout=TOOLS_LIST_TIMEOUT) + init.raise_for_status() + sid = init.headers.get("Mcp-Session-Id") + if sid: + s.headers["Mcp-Session-Id"] = sid + s.post(server["url"], + json={"jsonrpc": "2.0", "method": "notifications/initialized"}, + timeout=TOOLS_LIST_TIMEOUT) + resp = s.post(server["url"], + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + timeout=TOOLS_LIST_TIMEOUT) + resp.raise_for_status() + parsed = resp.json() + if "error" in parsed: + raise RuntimeError(f"tools/list error: {parsed['error']}") + return parsed.get("result", {}).get("tools", []) diff --git a/src/tests.py b/src/tests.py index 29b6d3d9..6eba892f 100644 --- a/src/tests.py +++ b/src/tests.py @@ -510,4 +510,45 @@ def test_skips_entries_missing_required_fields_and_warns(self): config, on_warning=warnings.append) self.assertEqual([s["display_name"] for s in servers], ["ok"]) self.assertEqual(len(warnings), 2) - self.assertTrue(any("broken" in w for w in warnings)) \ No newline at end of file + self.assertTrue(any("broken" in w for w in warnings)) + + +class ListToolsTest(TestCase): + @responses.activate + def test_lists_tools_via_initialize_and_tools_list(self): + url = "https://jira-mcp.internal/mcp" + + def init_cb(request): + body = json.loads(request.body) + assert body["method"] == "initialize" + return (200, {"Content-Type": "application/json", + "Mcp-Session-Id": "s1"}, + json.dumps({"jsonrpc": "2.0", "id": body["id"], + "result": {"protocolVersion": "2025-03-26", + "capabilities": {}, + "serverInfo": {"name": "x", "version": "0"}}})) + + def notify_or_list_cb(request): + body = json.loads(request.body) + if body.get("method") == "notifications/initialized": + return (200, {}, "") + assert body["method"] == "tools/list" + return (200, {"Content-Type": "application/json"}, + json.dumps({"jsonrpc": "2.0", "id": body["id"], + "result": {"tools": [ + {"name": "create_issue", + "description": "Create a Jira issue", + "inputSchema": {"type": "object", + "properties": {"project": {"type": "string"}}, + "required": ["project"]}}, + ]}})) + + responses.add_callback(responses.POST, url, callback=init_cb) + responses.add_callback(responses.POST, url, callback=notify_or_list_cb) + responses.add_callback(responses.POST, url, callback=notify_or_list_cb) + + server = {"display_name": "jira", "url": url, "secret_ref": "tok"} + tools = mcp_tools._list_tools(server, fetch_secret=lambda _: "stub-token") + self.assertEqual(len(tools), 1) + self.assertEqual(tools[0]["name"], "create_issue") + self.assertEqual(tools[0]["inputSchema"]["required"], ["project"]) \ No newline at end of file From 1d3e5339d8e055d28da54ec62df4101ba5f538b2 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Mon, 25 May 2026 15:52:49 +0530 Subject: [PATCH 04/28] feat(indexer): _emit_tool_resource + index() drift-preserving on failure --- src/indexers/mcp_tools.py | 27 +++++++++++++++++++ src/tests.py | 55 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py index d768d609..aebc25a7 100644 --- a/src/indexers/mcp_tools.py +++ b/src/indexers/mcp_tools.py @@ -156,3 +156,30 @@ def _list_tools(server: dict[str, Any], if "error" in parsed: raise RuntimeError(f"tools/list error: {parsed['error']}") return parsed.get("result", {}).get("tools", []) + + +def _emit_tool_resource(registry: Registry, + server: dict[str, Any], + tool: dict[str, Any]) -> None: + """Add one `mcp_tool` resource to the registry under platform=mcp.""" + server_name = server["display_name"] + tool_name = tool["name"] + qualified = f"{server_name}__{tool_name}" + spec = { + "server_id": server.get("server_id"), + "server_display_name": server_name, + "server_url": server["url"], + "secret_ref": server["secret_ref"], + "tool_name": tool_name, + "description": tool.get("description", ""), + "input_schema": tool.get("inputSchema") or tool.get("input_schema") or { + "type": "object", "properties": {}, "required": [], + }, + } + registry.add_resource( + platform_name=PLATFORM_NAME, + resource_type_name=RESOURCE_TYPE, + resource_name=qualified, + resource_qualified_name=qualified, + resource_attributes={"spec": spec}, + ) diff --git a/src/tests.py b/src/tests.py index 6eba892f..8069a083 100644 --- a/src/tests.py +++ b/src/tests.py @@ -551,4 +551,57 @@ def notify_or_list_cb(request): tools = mcp_tools._list_tools(server, fetch_secret=lambda _: "stub-token") self.assertEqual(len(tools), 1) self.assertEqual(tools[0]["name"], "create_issue") - self.assertEqual(tools[0]["inputSchema"]["required"], ["project"]) \ No newline at end of file + self.assertEqual(tools[0]["inputSchema"]["required"], ["project"]) + + +from resources import Registry, REGISTRY_PROPERTY_NAME +from component import Context + + +class EmitToolResourceTest(TestCase): + def test_emits_resource_with_expected_shape(self): + reg = Registry() + server = {"server_id": "u1", "display_name": "jira", + "url": "https://jira-mcp.internal/mcp", + "secret_ref": "jira-mcp-token"} + tool = {"name": "create_issue", + "description": "Create a Jira issue", + "inputSchema": {"type": "object", + "properties": {"project": {"type": "string"}}, + "required": ["project"]}} + mcp_tools._emit_tool_resource(reg, server, tool) + + rt = reg.lookup_resource_type("mcp", "mcp_tool") + self.assertIsNotNone(rt) + self.assertEqual(len(rt.instances), 1) + res = next(iter(rt.instances.values())) + self.assertEqual(res.spec["server_display_name"], "jira") + self.assertEqual(res.spec["tool_name"], "create_issue") + self.assertEqual(res.spec["secret_ref"], "jira-mcp-token") + self.assertEqual(res.spec["input_schema"]["required"], ["project"]) + + def test_index_skips_when_config_empty(self): + ctx = Context({}, mock.MagicMock()) + ctx.set_property(REGISTRY_PROPERTY_NAME, Registry()) + mcp_tools.index(ctx) # should not raise + reg = ctx.get_property(REGISTRY_PROPERTY_NAME) + self.assertEqual(reg.platforms, {}) + + def test_index_preserves_on_tools_list_failure(self): + # MCP_CONFIG lists one server; tools/list raises → no resources + # emitted, warning recorded, no exception propagated. + ctx = Context({ + "MCP_CONFIG": {"servers": [ + {"display_name": "jira", + "url": "https://jira-mcp.internal/mcp", + "secret_ref": "tok"}, + ]}, + }, mock.MagicMock()) + ctx.set_property(REGISTRY_PROPERTY_NAME, Registry()) + with mock.patch.object(mcp_tools, "_list_tools", + side_effect=RuntimeError("boom")) as patched: + mcp_tools.index(ctx) + self.assertEqual(patched.call_count, 1) + reg = ctx.get_property(REGISTRY_PROPERTY_NAME) + self.assertEqual(reg.platforms, {}) + self.assertTrue(any("boom" in w for w in ctx.warnings)) \ No newline at end of file From 60cc52e92e462c3ebd881ca21483a656a711e5d1 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Mon, 25 May 2026 15:54:27 +0530 Subject: [PATCH 05/28] feat(workspace-builder): register mcp_tools indexer in component pipeline --- src/component.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/component.py b/src/component.py index 5dd857f1..0a111533 100644 --- a/src/component.py +++ b/src/component.py @@ -248,7 +248,7 @@ def init_components(): # be added here, which is less than ideal, although practically may not be # a huge deal. component_stages_init = ( - (Stage.INDEXER, ["load_resources", "kubeapi", "cloudquery", "azure_devops"]), + (Stage.INDEXER, ["load_resources", "kubeapi", "cloudquery", "azure_devops", "mcp_tools"]), (Stage.ENRICHER, ["generation_rules"]), (Stage.RENDERER, ["render_output_items", "dump_resources"]) ) From 22d4c8c7c7e3a122d889f958096660d8d7c1c1f6 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Mon, 25 May 2026 15:55:04 +0530 Subject: [PATCH 06/28] test(indexer): end-to-end mcp_tools to template render integration test --- src/tests.py | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/src/tests.py b/src/tests.py index 8069a083..735374b8 100644 --- a/src/tests.py +++ b/src/tests.py @@ -604,4 +604,93 @@ def test_index_preserves_on_tools_list_failure(self): self.assertEqual(patched.call_count, 1) reg = ctx.get_property(REGISTRY_PROPERTY_NAME) self.assertEqual(reg.platforms, {}) - self.assertTrue(any("boom" in w for w in ctx.warnings)) \ No newline at end of file + self.assertTrue(any("boom" in w for w in ctx.warnings)) + + +import yaml as _yaml + + +class EndToEndMcpIndexingTest(TestCase): + """Runs the full mcp_tools indexer against an in-memory MCP_CONFIG + + stub MCP server, then renders the rw-generic-codecollection templates + against the resulting registry and asserts the SLX + Runbook YAML + reflect the discovered tool.""" + + @responses.activate + def test_indexer_to_template_render(self): + # 1. Helm-provided MCP_CONFIG (single server) + mcp_config = {"servers": [ + {"display_name": "jira", + "url": "https://jira-mcp.internal/mcp", + "secret_ref": "jira-mcp-token"}, + ]} + + # 2. Stub MCP tools/list + url = "https://jira-mcp.internal/mcp" + + def init_cb(request): + body = json.loads(request.body) + return (200, {"Content-Type": "application/json", + "Mcp-Session-Id": "s1"}, + json.dumps({"jsonrpc": "2.0", "id": body["id"], + "result": {"protocolVersion": "2025-03-26", + "capabilities": {}, + "serverInfo": {"name": "x", "version": "0"}}})) + + def list_cb(request): + body = json.loads(request.body) + if body.get("method") == "notifications/initialized": + return (200, {}, "") + assert body["method"] == "tools/list" + return (200, {"Content-Type": "application/json"}, + json.dumps({"jsonrpc": "2.0", "id": body["id"], + "result": {"tools": [ + {"name": "create_issue", + "description": "Create a Jira issue", + "inputSchema": { + "type": "object", + "properties": { + "project": {"type": "string", "description": "Project key"}, + "summary": {"type": "string"}, + }, + "required": ["project", "summary"]}}, + ]}})) + + responses.add_callback(responses.POST, url, callback=init_cb) + responses.add_callback(responses.POST, url, callback=list_cb) + responses.add_callback(responses.POST, url, callback=list_cb) + + # 3. Run indexer with the secret resolver stubbed out + ctx = Context({"MCP_CONFIG": mcp_config}, mock.MagicMock()) + ctx.set_property(REGISTRY_PROPERTY_NAME, Registry()) + with mock.patch.object(mcp_tools, "_resolve_secret", + return_value="stub-token"): + mcp_tools.index(ctx) + reg = ctx.get_property(REGISTRY_PROPERTY_NAME) + instances = reg.lookup_resource_type("mcp", "mcp_tool").instances + self.assertEqual(len(instances), 1) + match_resource = next(iter(instances.values())) + + # 4. Render Runbook template from the codecollection. + import jinja2 + cb_path = os.environ.get( + "MCP_TOOL_PROXY_PATH", + "/Users/prats/Documents/work/rw-generic-codecollection/codebundles/mcp-tool-proxy", + ) + env = jinja2.Environment( + loader=jinja2.FileSystemLoader(os.path.join(cb_path, ".runwhen/templates")), + undefined=jinja2.StrictUndefined, + ) + t = env.get_template("mcp-tool-proxy-runbook.yaml") + out = t.render(slx_name="mcp-jira-create-issue", + default_location="loc1", + match_resource=match_resource) + parsed = _yaml.safe_load(out) + runtime_vars = parsed["spec"]["runtimeVarsProvided"] + names = {v["name"] for v in runtime_vars} + self.assertEqual(names, {"project", "summary"}) + required_names = {v["name"] for v in runtime_vars if v.get("required") is True} + self.assertEqual(required_names, {"project", "summary"}) + # Static config var carries the input schema as JSON for the codebundle + config_names = {c["name"] for c in parsed["spec"]["configProvided"]} + self.assertEqual(config_names, {"MCP_SERVER_URL", "MCP_TOOL_NAME", "MCP_INPUT_SCHEMA"}) \ No newline at end of file From 142c240a3ec42a8d0b221bd763eefa53f79aae79 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Mon, 25 May 2026 16:29:31 +0530 Subject: [PATCH 07/28] test(indexer): guard runtimeVarsProvided field allowlist Assert no fields beyond name/default/description/validation appear on the rendered Runbook, so future drift in the codecollection template fails this test instead of failing CRD validation at apply time. --- src/tests.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/tests.py b/src/tests.py index 735374b8..79f11dbb 100644 --- a/src/tests.py +++ b/src/tests.py @@ -689,8 +689,13 @@ def list_cb(request): runtime_vars = parsed["spec"]["runtimeVarsProvided"] names = {v["name"] for v in runtime_vars} self.assertEqual(names, {"project", "summary"}) - required_names = {v["name"] for v in runtime_vars if v.get("required") is True} - self.assertEqual(required_names, {"project", "summary"}) + # RuntimeVarEntry only allows name/default/description/validation + # (corestate-operator api/v1/common_types.go) — guard against drift + # that would put unknown fields on the rendered Runbook. + allowed_fields = {"name", "default", "description", "validation"} + for v in runtime_vars: + extra = set(v.keys()) - allowed_fields + self.assertFalse(extra, f"runtimeVarsProvided[{v.get('name')}] has unexpected field(s): {extra}") # Static config var carries the input schema as JSON for the codebundle config_names = {c["name"] for c in parsed["spec"]["configProvided"]} self.assertEqual(config_names, {"MCP_SERVER_URL", "MCP_TOOL_NAME", "MCP_INPUT_SCHEMA"}) \ No newline at end of file From 3a2bc8ed7eaa504a831c5dfed3f4d533027afad1 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Mon, 25 May 2026 16:35:54 +0530 Subject: [PATCH 08/28] test(indexer): assert every runtimeVar has validation with allowed type --- src/tests.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tests.py b/src/tests.py index 79f11dbb..982a96a4 100644 --- a/src/tests.py +++ b/src/tests.py @@ -696,6 +696,12 @@ def list_cb(request): for v in runtime_vars: extra = set(v.keys()) - allowed_fields self.assertFalse(extra, f"runtimeVarsProvided[{v.get('name')}] has unexpected field(s): {extra}") + # Every var must carry a validation block with a CRD-allowed type + # (the CRD enum is {enum, regex}); when the MCP property has neither + # enum nor pattern the template falls back to regex .* + self.assertIn("validation", v, f"runtimeVarsProvided[{v['name']}] missing validation") + self.assertIn(v["validation"]["type"], {"enum", "regex"}, + f"runtimeVarsProvided[{v['name']}] has invalid validation.type") # Static config var carries the input schema as JSON for the codebundle config_names = {c["name"] for c in parsed["spec"]["configProvided"]} self.assertEqual(config_names, {"MCP_SERVER_URL", "MCP_TOOL_NAME", "MCP_INPUT_SCHEMA"}) \ No newline at end of file From 992f5d1a09dfece03b6ec0ff684becc013ca2242 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 14:13:50 +0530 Subject: [PATCH 09/28] feat(mcp): register McpPlatformHandler so gen rules can load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generation rules referencing `platform: mcp` were failing to load with KeyError('mcp') because the platform handler dict in generation_rules.load() didn't include an MCP entry. Without a handler, qualifiers like server_display_name / tool_name (carried in resource.spec) can't be resolved for SLX naming either. McpPlatformHandler reads qualifier and property values straight out of the nested `spec` dict the indexer emits — that's where server_display_name, tool_name, etc. live for mcp_tool resources. Co-Authored-By: Claude Opus 4.7 --- src/enrichers/generation_rules.py | 2 ++ src/enrichers/mcp.py | 35 +++++++++++++++++++++++ src/tests.py | 46 +++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 src/enrichers/mcp.py diff --git a/src/enrichers/generation_rules.py b/src/enrichers/generation_rules.py index 7ad470b1..7642fea8 100644 --- a/src/enrichers/generation_rules.py +++ b/src/enrichers/generation_rules.py @@ -35,6 +35,7 @@ from .gcp import GCPPlatformHandler, GCP_PLATFORM from .aws import AWSPlatformHandler, AWS_PLATFORM from .azure_devops import AzureDevOpsPlatformHandler +from .mcp import McpPlatformHandler, MCP_PLATFORM from renderers.render_output_items import OUTPUT_ITEMS_PROPERTY from renderers.render_output_items import OutputItem as RendererOutputItem @@ -1110,6 +1111,7 @@ def load(context: Context) -> None: GCP_PLATFORM: GCPPlatformHandler(), AWS_PLATFORM: AWSPlatformHandler(), "azure_devops": AzureDevOpsPlatformHandler(), + MCP_PLATFORM: McpPlatformHandler(), } context.set_property(PLATFORM_HANDLERS_PROPERTY_NAME, platform_handlers) request_code_collections = context.get_setting("CODE_COLLECTIONS") diff --git a/src/enrichers/mcp.py b/src/enrichers/mcp.py new file mode 100644 index 00000000..9a06998a --- /dev/null +++ b/src/enrichers/mcp.py @@ -0,0 +1,35 @@ +from typing import Any, Optional + +from resources import Resource + +from .generation_rule_types import PlatformHandler + +# Source of truth for the platform name lives with the indexer; mirror it +# here for the enricher's registration without forcing a cross-package +# dependency at the top level. +from indexers.mcp_tools import PLATFORM_NAME as MCP_PLATFORM + + +class McpPlatformHandler(PlatformHandler): + """Platform handler for MCP-discovered tool resources. + + Resources are emitted by `indexers.mcp_tools._emit_tool_resource` with a + nested `spec` dict carrying the server/tool fields. This handler surfaces + those fields as qualifier values and resource property values so the + generic generation-rules engine can match and name SLXs against them. + """ + + def __init__(self): + super().__init__(MCP_PLATFORM) + + def _spec_value(self, resource: Resource, key: str) -> Optional[str]: + spec = getattr(resource, "spec", None) or {} + value = spec.get(key) + return value if value is not None else None + + def get_resource_qualifier_value(self, resource: Resource, qualifier_name: str) -> Optional[str]: + return self._spec_value(resource, qualifier_name) + + def get_resource_property_values(self, resource: Resource, property_name: str) -> Optional[list[Any]]: + value = self._spec_value(resource, property_name) + return [value] if value is not None else None diff --git a/src/tests.py b/src/tests.py index 982a96a4..ac056af1 100644 --- a/src/tests.py +++ b/src/tests.py @@ -476,6 +476,52 @@ def test_code_bundle_black_list(self): from unittest import mock from indexers import mcp_tools +from enrichers.mcp import McpPlatformHandler, MCP_PLATFORM + + +class McpPlatformHandlerTest(TestCase): + def setUp(self): + self.handler = McpPlatformHandler() + + def test_platform_name_matches_indexer(self): + self.assertEqual(MCP_PLATFORM, mcp_tools.PLATFORM_NAME) + self.assertEqual(self.handler.name, MCP_PLATFORM) + + def test_qualifier_value_pulled_from_spec(self): + class R: # minimal resource stand-in + spec = {"server_display_name": "jira", "tool_name": "create_issue"} + + self.assertEqual( + self.handler.get_resource_qualifier_value(R(), "server_display_name"), + "jira", + ) + self.assertEqual( + self.handler.get_resource_qualifier_value(R(), "tool_name"), + "create_issue", + ) + + def test_qualifier_value_returns_none_when_missing(self): + class R: + spec = {"server_display_name": "jira"} + + self.assertIsNone(self.handler.get_resource_qualifier_value(R(), "tool_name")) + + def test_property_values_wrap_spec_value_in_list(self): + class R: + spec = {"tool_name": "create_issue"} + + self.assertEqual( + self.handler.get_resource_property_values(R(), "tool_name"), + ["create_issue"], + ) + self.assertIsNone(self.handler.get_resource_property_values(R(), "missing")) + + def test_handles_resource_without_spec(self): + class R: + pass + + self.assertIsNone(self.handler.get_resource_qualifier_value(R(), "tool_name")) + self.assertIsNone(self.handler.get_resource_property_values(R(), "tool_name")) class LoadServersFromSettingTest(TestCase): From 74572250742ba9b71a599de78f73e76548432cb4 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 14:51:31 +0530 Subject: [PATCH 10/28] chore(mcp): trace indexer execution end-to-end The indexer was silent on every path except 'no servers' and 'tools/list failed', which makes "indexer ran but emitted nothing" indistinguishable from "indexer never ran" in pod logs. Now it logs: - start of index() - raw server count from MCP_CONFIG - validated server count - per-server tools/list call (display_name + url + secret_ref) - per-server tool count returned - per-tool emission (DEBUG) - summary line on completion (total tools, total servers) Behaviour unchanged; the existing 13 mcp tests still pass. Co-Authored-By: Claude Opus 4.7 --- src/indexers/mcp_tools.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py index aebc25a7..105c1b4d 100644 --- a/src/indexers/mcp_tools.py +++ b/src/indexers/mcp_tools.py @@ -54,28 +54,41 @@ def index(context: Context) -> None: + logger.info("mcp_tools: indexer starting") config = context.get_setting(MCP_CONFIG_SETTING) or {} + raw_count = len((config or {}).get("servers") or []) + logger.info("mcp_tools: MCP_CONFIG has %d raw server entries", raw_count) servers = _load_servers_from_setting(config, on_warning=context.add_warning) + logger.info("mcp_tools: %d server entries passed validation", len(servers)) if not servers: logger.info("mcp_tools: no MCP servers configured; skipping.") return registry: Registry = context.get_property(REGISTRY_PROPERTY_NAME) + total_tools = 0 for server in servers: + name = server.get("display_name") + url = server.get("url") + logger.info("mcp_tools: calling tools/list on %s (url=%s, secret_ref=%s)", + name, url, server.get("secret_ref")) try: tools = _list_tools(server) except Exception as exc: # Preserve previous SLXs on failure (per design §7.9). We simply # don't emit fresh resources for this server; the existing SLXs # from the previous successful cycle stay in place upstream. - logger.warning("mcp_tools: tools/list failed for %s: %s", - server.get("display_name"), exc) + logger.warning("mcp_tools: tools/list failed for %s: %s", name, exc) context.add_warning( - f"MCP tools/list failed for {server.get('display_name')}: {exc}") + f"MCP tools/list failed for {name}: {exc}") continue + logger.info("mcp_tools: %s returned %d tools", name, len(tools)) for tool in tools: + logger.debug("mcp_tools: emitting resource for %s/%s", name, tool.get("name")) _emit_tool_resource(registry, server, tool) + total_tools += 1 + logger.info("mcp_tools: indexer complete; emitted %d mcp_tool resources across %d servers", + total_tools, len(servers)) REQUIRED_SERVER_FIELDS = ("display_name", "url", "secret_ref") From 29b3f82b5be6b5f3d99d0e835d06f74a58860726 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 14:58:54 +0530 Subject: [PATCH 11/28] fix(mcp): add mcp_tools to the run.sh and run.py components allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering an indexer in component.py's Stage.INDEXER list only makes it available; the actual autorun cycle goes through run.sh which passes an explicit --components allowlist to run.py. Without mcp_tools in that allowlist the indexer never executes regardless of registration, which is exactly what was happening in qwark-matrix on the deployed pod (we observed `python3 run.py run --components load_resources,kubeapi,cloudquery,azure_devops,generation_rules, render_output_items,dump_resources …` with no mcp_tools entry). Adds mcp_tools to: - run.sh both COMPONENTS branches (with / without cloudquery) - run.py --components default Co-Authored-By: Claude Opus 4.7 --- src/run.py | 2 +- src/run.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/run.py b/src/run.py index 049e0144..9e9a0c76 100755 --- a/src/run.py +++ b/src/run.py @@ -226,7 +226,7 @@ def main(): help=f'Host/port info for where the {SERVICE_NAME} REST service is running. ' f'Format is :') parser.add_argument('-c', '--components', action='store', - default="load_resources,kubeapi,cloudquery,azure_devops,generation_rules,render_output_items,dump_resources") + default="load_resources,kubeapi,cloudquery,azure_devops,mcp_tools,generation_rules,render_output_items,dump_resources") parser.add_argument('-o', '--output', action='store', dest='output_path', default="output", help="Path to output directory for generated files. " "The path is relative to the base directory.") diff --git a/src/run.sh b/src/run.sh index 1571bab5..e4d86735 100755 --- a/src/run.sh +++ b/src/run.sh @@ -54,9 +54,9 @@ fi touch "$LOCK_FILE" # Construct components string based on whether --disable-cloudquery is set -COMPONENTS="load_resources,kubeapi,azure_devops,generation_rules,render_output_items,dump_resources" +COMPONENTS="load_resources,kubeapi,azure_devops,mcp_tools,generation_rules,render_output_items,dump_resources" if [ $DISABLE_CLOUDQUERY -eq 0 ]; then - COMPONENTS="load_resources,kubeapi,cloudquery,azure_devops,generation_rules,render_output_items,dump_resources" + COMPONENTS="load_resources,kubeapi,cloudquery,azure_devops,mcp_tools,generation_rules,render_output_items,dump_resources" fi # Run the Python script with your specified arguments From d8caa0a8c28026d064c404369c5347d01ec1f4ea Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 15:08:16 +0530 Subject: [PATCH 12/28] fix(mcp): forward mcpConfig from workspaceInfo into the REST request body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI wrapper (run.py) reads workspaceInfo.yaml and POSTs a curated request_data dict to the workspace-builder REST endpoint. The endpoint then reads each registered Setting's json_name from request.data. So a new top-level workspaceInfo key only reaches the indexer if BOTH sides forward it — and run.py was silently dropping mcpConfig despite the indexer's Setting being registered correctly. Symptom in the qwark-matrix pod: [INFO] indexers.mcp_tools: indexer starting [INFO] indexers.mcp_tools: MCP_CONFIG has 0 raw server entries [INFO] indexers.mcp_tools: 0 server entries passed validation [INFO] indexers.mcp_tools: no MCP servers configured; skipping. …even though /shared/workspaceInfo.yaml had a populated mcpConfig block. Adds mcp_config parsing alongside cloud_config/code_collections and the matching `if mcp_config: request_data['mcpConfig'] = mcp_config` guard next to cloudConfig. Same shape as the existing cloudConfig plumbing. Co-Authored-By: Claude Opus 4.7 --- src/run.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/run.py b/src/run.py index 9e9a0c76..0b612ebf 100755 --- a/src/run.py +++ b/src/run.py @@ -433,6 +433,7 @@ def main(): code_collections = workspace_info.get("codeCollections") overrides = workspace_info.get("overrides", {}) task_tag_exclusions = workspace_info.get("taskTagExclusions") + mcp_config = workspace_info.get("mcpConfig") # ------------------------------------------------------------------ 4. validation guards missing = [] @@ -682,6 +683,8 @@ def main(): request_data['codeCollections'] = code_collections if cloud_config: request_data['cloudConfig'] = cloud_config + if mcp_config: + request_data['mcpConfig'] = mcp_config if overrides: request_data['overrides'] = overrides if task_tag_exclusions: From a0f9928bf73b19b1db22f43675f46823818754a2 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 15:46:28 +0530 Subject: [PATCH 13/28] feat(mcp): add per-server verify_tls escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some workspace-builder pods ship a CA bundle that only trusts the platform's runner CA — public CAs are absent. When an MCP server's cert is signed by a public CA (Let's Encrypt etc.) the indexer fails with CERTIFICATE_VERIFY_FAILED. The proper fix is to extend the pod's CA bundle, but that's a Helm/runtime concern that may take time. verify_tls (per server entry, default True) gives an opt-in escape hatch for the affected server. When set to False: - requests.Session.verify is set to False - we log a WARNING once per server so it's visible in the pod log - urllib3 InsecureRequestWarning is silenced so the warning above is the only noise per server Usage in workspaceInfo.yaml (temporary — remove once the pod CA bundle trusts the issuer): mcpConfig: servers: - display_name: rw-mcp url: https://mcp.test.runwhen.com secret_ref: test-mcp-scr verify_tls: false Co-Authored-By: Claude Opus 4.7 --- src/indexers/mcp_tools.py | 23 +++++++++++++++++++++++ src/tests.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py index 105c1b4d..383dc094 100644 --- a/src/indexers/mcp_tools.py +++ b/src/indexers/mcp_tools.py @@ -137,12 +137,35 @@ def _list_tools(server: dict[str, Any], `fetch_secret` is injected for testability. Defaults to _resolve_secret which talks to the k8s secret store at runtime. + + The `verify_tls` field on a server entry (default True) is forwarded to + the underlying `requests` call. Set it to False for a server whose cert + can't be validated by the pod's CA bundle (temporary escape hatch — the + proper fix is to add the issuer to the bundle). """ if fetch_secret is None: fetch_secret = _resolve_secret token = fetch_secret(server["secret_ref"]) + verify_tls = server.get("verify_tls", True) + if verify_tls is False: + logger.warning( + "mcp_tools: TLS verification DISABLED for %s — temporary debug " + "mode. The proper fix is to trust the server's CA in the pod's " + "CA bundle. Do not leave verify_tls=false in production.", + server.get("display_name"), + ) + # Silence requests' per-call InsecureRequestWarning so the warning + # above is the only noise per server (logged once at start). + try: + from urllib3 import disable_warnings + from urllib3.exceptions import InsecureRequestWarning + disable_warnings(InsecureRequestWarning) + except Exception: + pass + s = requests.Session() + s.verify = verify_tls s.headers.update({ "Content-Type": "application/json", "Authorization": f"Bearer {token}", diff --git a/src/tests.py b/src/tests.py index ac056af1..0a51542a 100644 --- a/src/tests.py +++ b/src/tests.py @@ -599,6 +599,43 @@ def notify_or_list_cb(request): self.assertEqual(tools[0]["name"], "create_issue") self.assertEqual(tools[0]["inputSchema"]["required"], ["project"]) + def test_verify_tls_flag_propagates_to_requests(self): + """verify_tls=False on a server entry should disable cert verification + in the underlying requests.Session. Default (omitted) stays True.""" + captured = {} + real_session_post = mcp_tools.requests.Session.post + + def post_spy(self, url, **kwargs): + captured["verify"] = self.verify + # Short-circuit the network call with a synthetic 200 so the rest + # of _list_tools doesn't actually fire over the wire. + class _Resp: + status_code = 200 + headers = {} + def raise_for_status(self): pass + def json(self_inner): return {"jsonrpc": "2.0", "id": 1, + "result": {"tools": []}} + return _Resp() + + mcp_tools.requests.Session.post = post_spy + try: + mcp_tools._list_tools( + {"display_name": "x", "url": "https://x.invalid/mcp", + "secret_ref": "tok", "verify_tls": False}, + fetch_secret=lambda _: "stub", + ) + self.assertEqual(captured["verify"], False) + + captured.clear() + mcp_tools._list_tools( + {"display_name": "x", "url": "https://x.invalid/mcp", + "secret_ref": "tok"}, # verify_tls omitted → default True + fetch_secret=lambda _: "stub", + ) + self.assertEqual(captured["verify"], True) + finally: + mcp_tools.requests.Session.post = real_session_post + from resources import Registry, REGISTRY_PROPERTY_NAME from component import Context From 6f20811fc36245efc65e003e1a00835397ddd7c0 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 15:55:06 +0530 Subject: [PATCH 14/28] fix(mcp): pass verify per-request to defeat REQUESTS_CA_BUNDLE override Setting session.verify=False isn't sufficient when REQUESTS_CA_BUNDLE is exported in the environment. requests.Session.merge_environment_settings reads the env path into the per-request verify when the request-level value is None (the default), and merge_setting then returns that per-request path over session.verify=False because it's non-None. Net effect we saw in qwark-matrix: verify_tls=false was honored at the session level, the WARNING fired, but each post() call still verified against /etc/ssl/certs/ca-certificates.crt and failed. Fix: forward verify=verify_tls as a kwarg to each s.post() call. With an explicit False, the env override path in merge_environment_settings is skipped (guarded by `if verify is True or verify is None`). Test updated to assert both session-level and per-call kwarg verify follow the verify_tls flag. Co-Authored-By: Claude Opus 4.7 --- src/indexers/mcp_tools.py | 15 ++++++++++++--- src/tests.py | 28 +++++++++++++++++++--------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py index 383dc094..93bd341e 100644 --- a/src/indexers/mcp_tools.py +++ b/src/indexers/mcp_tools.py @@ -165,6 +165,12 @@ def _list_tools(server: dict[str, Any], pass s = requests.Session() + # Note: setting s.verify alone isn't enough when REQUESTS_CA_BUNDLE is set + # in the environment — requests.Session.merge_environment_settings reads + # the env path into the per-request verify (when the request-level value + # is None/True) and then merge_setting returns the per-request value over + # the session-level one. We pass verify=verify_tls to each post() call so + # the env override is skipped when verify_tls is False. s.verify = verify_tls s.headers.update({ "Content-Type": "application/json", @@ -176,17 +182,20 @@ def _list_tools(server: dict[str, Any], "params": {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "runwhen-builder", "version": "1.0.0"}}}, - timeout=TOOLS_LIST_TIMEOUT) + timeout=TOOLS_LIST_TIMEOUT, + verify=verify_tls) init.raise_for_status() sid = init.headers.get("Mcp-Session-Id") if sid: s.headers["Mcp-Session-Id"] = sid s.post(server["url"], json={"jsonrpc": "2.0", "method": "notifications/initialized"}, - timeout=TOOLS_LIST_TIMEOUT) + timeout=TOOLS_LIST_TIMEOUT, + verify=verify_tls) resp = s.post(server["url"], json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, - timeout=TOOLS_LIST_TIMEOUT) + timeout=TOOLS_LIST_TIMEOUT, + verify=verify_tls) resp.raise_for_status() parsed = resp.json() if "error" in parsed: diff --git a/src/tests.py b/src/tests.py index 0a51542a..90860cb7 100644 --- a/src/tests.py +++ b/src/tests.py @@ -601,14 +601,17 @@ def notify_or_list_cb(request): def test_verify_tls_flag_propagates_to_requests(self): """verify_tls=False on a server entry should disable cert verification - in the underlying requests.Session. Default (omitted) stays True.""" - captured = {} + on the per-request `verify=` kwarg (not just session-level), because + REQUESTS_CA_BUNDLE in the environment can override session.verify. + Default (omitted) stays True.""" + captured = {"calls": []} real_session_post = mcp_tools.requests.Session.post def post_spy(self, url, **kwargs): - captured["verify"] = self.verify - # Short-circuit the network call with a synthetic 200 so the rest - # of _list_tools doesn't actually fire over the wire. + captured["calls"].append({ + "session_verify": self.verify, + "kwarg_verify": kwargs.get("verify"), + }) class _Resp: status_code = 200 headers = {} @@ -624,15 +627,22 @@ def json(self_inner): return {"jsonrpc": "2.0", "id": 1, "secret_ref": "tok", "verify_tls": False}, fetch_secret=lambda _: "stub", ) - self.assertEqual(captured["verify"], False) - - captured.clear() + # Every post() call must carry verify=False as a kwarg so the + # env-var override path in requests is skipped. + self.assertTrue(captured["calls"]) + for call in captured["calls"]: + self.assertEqual(call["session_verify"], False) + self.assertEqual(call["kwarg_verify"], False) + + captured["calls"] = [] mcp_tools._list_tools( {"display_name": "x", "url": "https://x.invalid/mcp", "secret_ref": "tok"}, # verify_tls omitted → default True fetch_secret=lambda _: "stub", ) - self.assertEqual(captured["verify"], True) + for call in captured["calls"]: + self.assertEqual(call["session_verify"], True) + self.assertEqual(call["kwarg_verify"], True) finally: mcp_tools.requests.Session.post = real_session_post From 7c3b850994fbdd4f195368e1c7d5bf218897a047 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 16:49:27 +0530 Subject: [PATCH 15/28] fix(mcp): base64-decode secret value before using as bearer token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit k8s_utils.get_secret returns secret.data unchanged from the Kubernetes Python client — i.e. values are still base64-encoded strings. Other call sites (azure_utils.py:95) already base64-decode before use, but _resolve_secret was returning the encoded string directly, which the indexer then put on the Authorization header verbatim. Observed in qwark-matrix on a verified-valid test token: - direct curl with base64 -d'd token → HTTP 200 (initialize OK) - indexer with the same secret_ref → HTTP 401 unauthorized Decoding inside _resolve_secret keeps the fix local to the indexer's single secret-fetch path. Tests still pass (the existing test injects fetch_secret directly, bypassing _resolve_secret, so token semantics are unchanged at that layer). Co-Authored-By: Claude Opus 4.7 --- src/indexers/mcp_tools.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py index 93bd341e..d5604f13 100644 --- a/src/indexers/mcp_tools.py +++ b/src/indexers/mcp_tools.py @@ -123,11 +123,18 @@ def _load_servers_from_setting(config, on_warning=None) -> list[dict[str, Any]]: def _resolve_secret(secret_ref: str) -> str: """Read a workspace secret and return the token value. Resolved here so tests can monkey-patch this single function rather than threading a - fetcher parameter through every call site.""" + fetcher parameter through every call site. + + k8s_utils.get_secret returns secret.data from the Kubernetes Python + client unchanged — values are still base64-encoded. Must be decoded + before use as a bearer token (see azure_utils.py:95 for prior art). + """ + import base64 from k8s_utils import get_secret data = get_secret(secret_ref) # Secret convention: stored under key "token"; fall back to single-key shape. - return data.get("token") or next(iter(data.values())) + encoded = data.get("token") or next(iter(data.values())) + return base64.b64decode(encoded).decode("utf-8") def _list_tools(server: dict[str, Any], From dd41f1ef1edbc1c9fbac3b0cc044ae0c3dc6e416 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 16:55:26 +0530 Subject: [PATCH 16/28] chore: re-trigger CI for 40361e3 (gh actions 500'd on the previous push) From 33217c76f1f97d9f2f4b4e981850e34582085c1e Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 17:00:49 +0530 Subject: [PATCH 17/28] fix(mcp): parse SSE (text/event-stream) responses from MCP servers Streamable-HTTP MCP servers may respond to JSON-RPC posts in either application/json or text/event-stream (SSE) form. The RunWhen MCP server returns SSE: event: message data: {"jsonrpc":"2.0","id":1,"result":{...}} resp.json() fails on that with JSONDecodeError. The codecollection's mcp_tool_proxy.py client already handles both content types via a _parse_response helper; this commit ports the same logic to the indexer's _list_tools as _parse_jsonrpc_response. Verified end-to-end against https://mcp.test.runwhen.com/mcp from the qwark-matrix workspace-builder pod (in-place patched + invoked _list_tools directly): with this fix, 37 tools were returned in full. Co-Authored-By: Claude Opus 4.7 --- src/indexers/mcp_tools.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py index d5604f13..0448969f 100644 --- a/src/indexers/mcp_tools.py +++ b/src/indexers/mcp_tools.py @@ -204,12 +204,38 @@ def _list_tools(server: dict[str, Any], timeout=TOOLS_LIST_TIMEOUT, verify=verify_tls) resp.raise_for_status() - parsed = resp.json() + parsed = _parse_jsonrpc_response(resp) + if parsed is None: + raise RuntimeError("tools/list returned no parseable JSON-RPC envelope") if "error" in parsed: raise RuntimeError(f"tools/list error: {parsed['error']}") return parsed.get("result", {}).get("tools", []) +def _parse_jsonrpc_response(resp) -> dict[str, Any] | None: + """Parse a JSON or SSE (text/event-stream) MCP response body. Mirrors + `_parse_response` in the rw-generic-codecollection mcp-tool-proxy client: + streamable HTTP MCP servers may answer in either content type, and the + SSE form is `event: message\\ndata: \\n\\n` per RPC envelope. + """ + import json as _json + ct = resp.headers.get("Content-Type", "") + if "text/event-stream" in ct: + for line in resp.text.split("\n"): + if line.startswith("data: "): + try: + msg = _json.loads(line[len("data: "):]) + if "id" in msg or "result" in msg or "error" in msg: + return msg + except _json.JSONDecodeError: + continue + return None + try: + return resp.json() + except Exception: + return None + + def _emit_tool_resource(registry: Registry, server: dict[str, Any], tool: dict[str, Any]) -> None: From 71d322e065fb581311221fce9e0b22f1d25656f5 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 17:43:10 +0530 Subject: [PATCH 18/28] docs(mcp): document mcpConfig block in workspaceInfo customization guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an mcpConfig section to docs/configuration/workspaceinfo-customization.md describing how to declare private MCP servers for tool discovery — required fields, optional verify_tls escape hatch, expected k8s Secret shape, and a note that the same secret must be reachable from runner pods at execution time. Co-Authored-By: Claude Opus 4.7 --- .../workspaceinfo-customization.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/configuration/workspaceinfo-customization.md b/docs/configuration/workspaceinfo-customization.md index c3ca4f6a..14de1ff6 100644 --- a/docs/configuration/workspaceinfo-customization.md +++ b/docs/configuration/workspaceinfo-customization.md @@ -26,12 +26,37 @@ codeCollections: - # Another code collections to scan +# Information about MCP servers to discover tools from +mcpConfig: + servers: + - display_name: jira + url: https://jira-mcp.internal:443/mcp + secret_ref: jira-mcp-token + + # Custom information about specific code bundles custom: prometheus_provider: gmp # More custom configuration ``` +#### MCP Server Discovery (`mcpConfig`) + +The optional `mcpConfig` block declares one or more private MCP servers to introspect during the indexer phase. For each entry the workspace builder calls `initialize` + `tools/list` on the server, then emits one `mcp_tool` resource per discovered tool. The `mcp-tool-proxy` codebundle in [rw-generic-codecollection](https://github.com/runwhen-contrib/rw-generic-codecollection) renders one SLX + Runbook per `mcp_tool` resource via its generation rule. + +
FieldRequiredDescription
display_nameyesShort identifier used in generated SLX names and tags (e.g. jira, linear). Lowercase, alphanumeric / underscores.
urlyesFull HTTPS URL of the MCP endpoint, including the path (commonly /mcp). Must be reachable from the workspace-builder pod's network at index time, and from runner pods at execution time.
secret_refyesName of a Kubernetes Secret in the same namespace whose data.token is the bearer token to send as Authorization: Bearer <token>. Token is base64-decoded by the indexer before use.
verify_tlsno (default true)Set to false to skip TLS certificate verification for this server. Intended for environments where the pod's CA bundle does not yet trust the server's issuer; a warning is logged for every cycle in which it is disabled. Prefer extending the CA bundle for production use.
+ +A single server's `tools/list` failure logs a warning and skips that server only — other servers and the rest of the cycle continue normally. + +##### Required secret shape + +```bash +kubectl -n create secret generic jira-mcp-token \ + --from-literal=token='' +``` + +The runner uses the same `secret_ref` at execution time (via `secretsProvided.workspaceKey`), so the secret must be reachable from the runner namespace too. + #### Basic Workspace Configuration Info There are several settings that are used to configure information in the workspace that's generated to be uploaded to the RunWhen platform to. The available settings are: From 8bb83fecbf184859403b6b011c5d236e39c2fe48 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 18:33:29 +0530 Subject: [PATCH 19/28] chore: nudge CI now that gh actions is healthy again From f81a5fdd0e79468ef68dcd444fc3b561e96f0e53 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 21:17:20 +0530 Subject: [PATCH 20/28] feat(mcp): thread codecollection_ref from mcpConfig into mcp_tool resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-server `codecollection_ref` field on mcpConfig.servers[] gets written into each emitted mcp_tool resource's spec so the codecollection runbook template can render it into the generated Runbook YAML's codeBundle.ref. Defaults to "main"; override per-server when testing against a branch that hasn't merged yet. Why: PAPI's taskiq worker clones the codecollection at this ref when attaching runbooks to SLXs. With the hardcoded "main" default and the codebundle living only on a feature branch, the worker errored out with PathNotFoundError inside post_sync_hook — SLX rows were committed but runbooks never attached, and the batch task aborted before remaining SLXs were processed. Co-Authored-By: Claude Opus 4.7 --- src/indexers/mcp_tools.py | 5 +++++ src/tests.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py index 0448969f..1306bef5 100644 --- a/src/indexers/mcp_tools.py +++ b/src/indexers/mcp_tools.py @@ -253,6 +253,11 @@ def _emit_tool_resource(registry: Registry, "input_schema": tool.get("inputSchema") or tool.get("input_schema") or { "type": "object", "properties": {}, "required": [], }, + # ref the runner / PAPI taskiq worker will use when cloning the + # mcp-tool-proxy codebundle to attach a runbook to this SLX. + # Defaults to "main"; override per-server in mcpConfig when testing + # against a branch that hasn't merged yet. + "codecollection_ref": server.get("codecollection_ref", "main"), } registry.add_resource( platform_name=PLATFORM_NAME, diff --git a/src/tests.py b/src/tests.py index 90860cb7..b00c13ef 100644 --- a/src/tests.py +++ b/src/tests.py @@ -672,6 +672,22 @@ def test_emits_resource_with_expected_shape(self): self.assertEqual(res.spec["tool_name"], "create_issue") self.assertEqual(res.spec["secret_ref"], "jira-mcp-token") self.assertEqual(res.spec["input_schema"]["required"], ["project"]) + # default codecollection_ref must always be 'main' so generated runbooks + # clone an existing branch (the PAPI taskiq worker errors out otherwise). + self.assertEqual(res.spec["codecollection_ref"], "main") + + def test_emits_resource_with_overridden_codecollection_ref(self): + reg = Registry() + server = {"display_name": "jira", + "url": "https://jira-mcp.internal/mcp", + "secret_ref": "jira-mcp-token", + "codecollection_ref": "feat/some-branch"} + tool = {"name": "create_issue", + "inputSchema": {"type": "object", "properties": {}}} + mcp_tools._emit_tool_resource(reg, server, tool) + rt = reg.lookup_resource_type("mcp", "mcp_tool") + res = next(iter(rt.instances.values())) + self.assertEqual(res.spec["codecollection_ref"], "feat/some-branch") def test_index_skips_when_config_empty(self): ctx = Context({}, mock.MagicMock()) From aa608de341bcf49e22e685f0d94a2d89151278a5 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 21:17:57 +0530 Subject: [PATCH 21/28] docs(mcp): document codecollection_ref field in mcpConfig server entries Co-Authored-By: Claude Opus 4.7 --- docs/configuration/workspaceinfo-customization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/workspaceinfo-customization.md b/docs/configuration/workspaceinfo-customization.md index 14de1ff6..f82f5d58 100644 --- a/docs/configuration/workspaceinfo-customization.md +++ b/docs/configuration/workspaceinfo-customization.md @@ -44,7 +44,7 @@ custom: The optional `mcpConfig` block declares one or more private MCP servers to introspect during the indexer phase. For each entry the workspace builder calls `initialize` + `tools/list` on the server, then emits one `mcp_tool` resource per discovered tool. The `mcp-tool-proxy` codebundle in [rw-generic-codecollection](https://github.com/runwhen-contrib/rw-generic-codecollection) renders one SLX + Runbook per `mcp_tool` resource via its generation rule. -
FieldRequiredDescription
display_nameyesShort identifier used in generated SLX names and tags (e.g. jira, linear). Lowercase, alphanumeric / underscores.
urlyesFull HTTPS URL of the MCP endpoint, including the path (commonly /mcp). Must be reachable from the workspace-builder pod's network at index time, and from runner pods at execution time.
secret_refyesName of a Kubernetes Secret in the same namespace whose data.token is the bearer token to send as Authorization: Bearer <token>. Token is base64-decoded by the indexer before use.
verify_tlsno (default true)Set to false to skip TLS certificate verification for this server. Intended for environments where the pod's CA bundle does not yet trust the server's issuer; a warning is logged for every cycle in which it is disabled. Prefer extending the CA bundle for production use.
+
FieldRequiredDescription
display_nameyesShort identifier used in generated SLX names and tags (e.g. jira, linear). Lowercase, alphanumeric / underscores.
urlyesFull HTTPS URL of the MCP endpoint, including the path (commonly /mcp). Must be reachable from the workspace-builder pod's network at index time, and from runner pods at execution time.
secret_refyesName of a Kubernetes Secret in the same namespace whose data.token is the bearer token to send as Authorization: Bearer <token>. Token is base64-decoded by the indexer before use.
verify_tlsno (default true)Set to false to skip TLS certificate verification for this server. Intended for environments where the pod's CA bundle does not yet trust the server's issuer; a warning is logged for every cycle in which it is disabled. Prefer extending the CA bundle for production use.
codecollection_refno (default main)Git ref (branch/tag/SHA) of rw-generic-codecollection that the generated Runbook will clone at execution time to find the mcp-tool-proxy codebundle. Override when the codebundle lives on a feature branch that has not yet merged to main.
A single server's `tools/list` failure logs a warning and skips that server only — other servers and the rest of the cycle continue normally. From a71dda978c9452a1a52760e334384277cc7eaa19 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Tue, 26 May 2026 21:23:43 +0530 Subject: [PATCH 22/28] =?UTF-8?q?revert(mcp):=20drop=20codecollection=5Fre?= =?UTF-8?q?f=20from=20mcpConfig=20=E2=80=94=20wrong=20abstraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A codecollection ref isn't a property of an MCP server. The workspace-builder generation-rules engine already populates `{{ ref }}` as a standard template variable, pinned to the codecollection ref the template was loaded from (generation_rules.py:643). The Runbook template now uses that directly, which keeps mcpConfig clean and lets the existing codeCollections entry govern the ref end-to-end. Co-Authored-By: Claude Opus 4.7 --- .../configuration/workspaceinfo-customization.md | 4 +++- src/indexers/mcp_tools.py | 5 ----- src/tests.py | 16 ---------------- 3 files changed, 3 insertions(+), 22 deletions(-) diff --git a/docs/configuration/workspaceinfo-customization.md b/docs/configuration/workspaceinfo-customization.md index f82f5d58..cba8f981 100644 --- a/docs/configuration/workspaceinfo-customization.md +++ b/docs/configuration/workspaceinfo-customization.md @@ -44,7 +44,9 @@ custom: The optional `mcpConfig` block declares one or more private MCP servers to introspect during the indexer phase. For each entry the workspace builder calls `initialize` + `tools/list` on the server, then emits one `mcp_tool` resource per discovered tool. The `mcp-tool-proxy` codebundle in [rw-generic-codecollection](https://github.com/runwhen-contrib/rw-generic-codecollection) renders one SLX + Runbook per `mcp_tool` resource via its generation rule. -
FieldRequiredDescription
display_nameyesShort identifier used in generated SLX names and tags (e.g. jira, linear). Lowercase, alphanumeric / underscores.
urlyesFull HTTPS URL of the MCP endpoint, including the path (commonly /mcp). Must be reachable from the workspace-builder pod's network at index time, and from runner pods at execution time.
secret_refyesName of a Kubernetes Secret in the same namespace whose data.token is the bearer token to send as Authorization: Bearer <token>. Token is base64-decoded by the indexer before use.
verify_tlsno (default true)Set to false to skip TLS certificate verification for this server. Intended for environments where the pod's CA bundle does not yet trust the server's issuer; a warning is logged for every cycle in which it is disabled. Prefer extending the CA bundle for production use.
codecollection_refno (default main)Git ref (branch/tag/SHA) of rw-generic-codecollection that the generated Runbook will clone at execution time to find the mcp-tool-proxy codebundle. Override when the codebundle lives on a feature branch that has not yet merged to main.
+
FieldRequiredDescription
display_nameyesShort identifier used in generated SLX names and tags (e.g. jira, linear). Lowercase, alphanumeric / underscores.
urlyesFull HTTPS URL of the MCP endpoint, including the path (commonly /mcp). Must be reachable from the workspace-builder pod's network at index time, and from runner pods at execution time.
secret_refyesName of a Kubernetes Secret in the same namespace whose data.token is the bearer token to send as Authorization: Bearer <token>. Token is base64-decoded by the indexer before use.
verify_tlsno (default true)Set to false to skip TLS certificate verification for this server. Intended for environments where the pod's CA bundle does not yet trust the server's issuer; a warning is logged for every cycle in which it is disabled. Prefer extending the CA bundle for production use.
+ +> The generated Runbook's codeBundle.ref is pinned to the ref of the codecollection the template was loaded from (the ref standard template variable). Point your codeCollections entry for rw-generic-codecollection at the branch/tag you want runners to execute against — no extra knob in mcpConfig. A single server's `tools/list` failure logs a warning and skips that server only — other servers and the rest of the cycle continue normally. diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py index 1306bef5..0448969f 100644 --- a/src/indexers/mcp_tools.py +++ b/src/indexers/mcp_tools.py @@ -253,11 +253,6 @@ def _emit_tool_resource(registry: Registry, "input_schema": tool.get("inputSchema") or tool.get("input_schema") or { "type": "object", "properties": {}, "required": [], }, - # ref the runner / PAPI taskiq worker will use when cloning the - # mcp-tool-proxy codebundle to attach a runbook to this SLX. - # Defaults to "main"; override per-server in mcpConfig when testing - # against a branch that hasn't merged yet. - "codecollection_ref": server.get("codecollection_ref", "main"), } registry.add_resource( platform_name=PLATFORM_NAME, diff --git a/src/tests.py b/src/tests.py index b00c13ef..90860cb7 100644 --- a/src/tests.py +++ b/src/tests.py @@ -672,22 +672,6 @@ def test_emits_resource_with_expected_shape(self): self.assertEqual(res.spec["tool_name"], "create_issue") self.assertEqual(res.spec["secret_ref"], "jira-mcp-token") self.assertEqual(res.spec["input_schema"]["required"], ["project"]) - # default codecollection_ref must always be 'main' so generated runbooks - # clone an existing branch (the PAPI taskiq worker errors out otherwise). - self.assertEqual(res.spec["codecollection_ref"], "main") - - def test_emits_resource_with_overridden_codecollection_ref(self): - reg = Registry() - server = {"display_name": "jira", - "url": "https://jira-mcp.internal/mcp", - "secret_ref": "jira-mcp-token", - "codecollection_ref": "feat/some-branch"} - tool = {"name": "create_issue", - "inputSchema": {"type": "object", "properties": {}}} - mcp_tools._emit_tool_resource(reg, server, tool) - rt = reg.lookup_resource_type("mcp", "mcp_tool") - res = next(iter(rt.instances.values())) - self.assertEqual(res.spec["codecollection_ref"], "feat/some-branch") def test_index_skips_when_config_empty(self): ctx = Context({}, mock.MagicMock()) From d3f5b0d19b67ccf5ccd2b46461bfa8d5c4280850 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Wed, 27 May 2026 16:10:31 +0530 Subject: [PATCH 23/28] feat(indexer): forward mcpConfig verify_tls into mcp_tool resource spec The codecollection's mcp-tool-proxy runbook template now reads verify_tls from the resource spec and emits it as MCP_VERIFY_TLS so the runner-side proxy can mirror the indexer's TLS escape hatch (rw-generic-codecollection PR #43). Co-Authored-By: Claude Opus 4.7 --- src/indexers/mcp_tools.py | 1 + src/tests.py | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py index 0448969f..a9921c75 100644 --- a/src/indexers/mcp_tools.py +++ b/src/indexers/mcp_tools.py @@ -248,6 +248,7 @@ def _emit_tool_resource(registry: Registry, "server_display_name": server_name, "server_url": server["url"], "secret_ref": server["secret_ref"], + "verify_tls": server.get("verify_tls", True), "tool_name": tool_name, "description": tool.get("description", ""), "input_schema": tool.get("inputSchema") or tool.get("input_schema") or { diff --git a/src/tests.py b/src/tests.py index 90860cb7..1ec24af7 100644 --- a/src/tests.py +++ b/src/tests.py @@ -795,6 +795,11 @@ def list_cb(request): self.assertIn("validation", v, f"runtimeVarsProvided[{v['name']}] missing validation") self.assertIn(v["validation"]["type"], {"enum", "regex"}, f"runtimeVarsProvided[{v['name']}] has invalid validation.type") - # Static config var carries the input schema as JSON for the codebundle + # Static config var carries the input schema as JSON for the codebundle. + # MCP_VERIFY_TLS is forwarded from the indexer's per-server verify_tls + # flag — defaults to "true" when the field is omitted from mcpConfig. config_names = {c["name"] for c in parsed["spec"]["configProvided"]} - self.assertEqual(config_names, {"MCP_SERVER_URL", "MCP_TOOL_NAME", "MCP_INPUT_SCHEMA"}) \ No newline at end of file + self.assertEqual( + config_names, + {"MCP_SERVER_URL", "MCP_TOOL_NAME", "MCP_INPUT_SCHEMA", "MCP_VERIFY_TLS"}, + ) \ No newline at end of file From bb8ecda501b6887f9514072dcccb1f84fee3f700 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Thu, 28 May 2026 15:38:03 +0530 Subject: [PATCH 24/28] feat(mcp-tools): classify per-tool access via readOnlyHint + verb heuristic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emits an `access` field on each `mcp_tool` resource so the SLX template can tag tools as read-only or read-write per-tool instead of hard-coding a single value. Logic: - `annotations.readOnlyHint=true` is authoritative → "read-only". - Otherwise (hint false, missing, or annotations absent) fall back on the tool name's leading verb (get/list/search/read/fetch/describe/ find/query/show/view). Many MCP servers leave readOnlyHint unset or default it to false even for clearly read-only tools. - Default "read-write" — safer to over-mark write capability than to silently let a write tool through as read-only. The heuristic only matches whole leading tokens (split on `_` or `-`), so `listen_for_events` and `gettysburg_addresses` don't false-positive on `list` / `get` substrings. Co-Authored-By: Claude Opus 4.7 --- src/indexers/mcp_tools.py | 43 ++++++++++++++++++++++++++++ src/tests.py | 60 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/indexers/mcp_tools.py b/src/indexers/mcp_tools.py index a9921c75..c2aa65fc 100644 --- a/src/indexers/mcp_tools.py +++ b/src/indexers/mcp_tools.py @@ -52,6 +52,48 @@ RESOURCE_TYPE = "mcp_tool" TOOLS_LIST_TIMEOUT = 15 +# Verbs that strongly suggest a tool only reads state. Used as a fallback when +# the MCP server doesn't set `annotations.readOnlyHint=true` — most servers +# don't bother to set the hint, so we fall back on the tool name's leading +# verb to avoid defaulting every tool to read-write. +READ_ONLY_VERBS = ( + "get", "list", "search", "read", "fetch", + "describe", "find", "query", "show", "view", +) + + +def _name_suggests_read_only(name: str) -> bool: + """True if the tool name's leading token (split on `_` or `-`) is one of + READ_ONLY_VERBS. Examples that match: `list_teams`, `get-project`, + `search_documentation`. Examples that don't: `create_issue`, + `update_project`, `delete_attachment`.""" + if not name: + return False + lower = name.lower() + for verb in READ_ONLY_VERBS: + if lower == verb or lower.startswith(verb + "_") or lower.startswith(verb + "-"): + return True + return False + + +def _compute_access(tool: dict[str, Any]) -> str: + """Determine the SLX `access` tag for an MCP tool. + + - `annotations.readOnlyHint=true` is authoritative → "read-only". + - Otherwise (hint is false, missing, or annotations absent) fall back on + the tool name's leading verb. Many MCP servers leave readOnlyHint unset + or default it to false even for clearly read-only tools, so we'd flag + half the catalog as read-write without the heuristic. + - Default is "read-write" — safer to over-mark write capability than to + silently let a write tool through as read-only. + """ + annotations = tool.get("annotations") or {} + if annotations.get("readOnlyHint") is True: + return "read-only" + if _name_suggests_read_only(tool.get("name", "")): + return "read-only" + return "read-write" + def index(context: Context) -> None: logger.info("mcp_tools: indexer starting") @@ -254,6 +296,7 @@ def _emit_tool_resource(registry: Registry, "input_schema": tool.get("inputSchema") or tool.get("input_schema") or { "type": "object", "properties": {}, "required": [], }, + "access": _compute_access(tool), } registry.add_resource( platform_name=PLATFORM_NAME, diff --git a/src/tests.py b/src/tests.py index 1ec24af7..f168a3b1 100644 --- a/src/tests.py +++ b/src/tests.py @@ -651,6 +651,53 @@ def json(self_inner): return {"jsonrpc": "2.0", "id": 1, from component import Context +class ComputeAccessTest(TestCase): + def test_read_only_hint_true_is_authoritative(self): + tool = {"name": "create_issue", "annotations": {"readOnlyHint": True}} + self.assertEqual(mcp_tools._compute_access(tool), "read-only") + + def test_read_only_hint_false_falls_back_to_name_heuristic(self): + # Hint says not read-only, but verb says it is — heuristic wins. + # Some MCP servers leave readOnlyHint at the default `false` even for + # tools that clearly only read; the verb is the more reliable signal. + tool = {"name": "list_teams", "annotations": {"readOnlyHint": False}} + self.assertEqual(mcp_tools._compute_access(tool), "read-only") + + def test_missing_annotations_uses_name_heuristic(self): + for name in ("list_teams", "get_project", "search_documentation", + "read_file", "describe_table", "fetch_user", "find_issue", + "query_db", "show_status", "view_doc"): + self.assertEqual(mcp_tools._compute_access({"name": name}), + "read-only", msg=name) + + def test_write_verbs_default_to_read_write(self): + for name in ("create_issue", "update_project", "delete_attachment", + "save_comment", "send_message", "post_status"): + self.assertEqual(mcp_tools._compute_access({"name": name}), + "read-write", msg=name) + + def test_heuristic_matches_kebab_case_names(self): + self.assertEqual( + mcp_tools._compute_access({"name": "list-teams"}), "read-only") + self.assertEqual( + mcp_tools._compute_access({"name": "create-issue"}), "read-write") + + def test_heuristic_does_not_match_substring(self): + # `listen_for_events` starts with "listen", not "list_" — must not + # be flagged read-only just because "list" is a prefix of "listen". + self.assertEqual( + mcp_tools._compute_access({"name": "listen_for_events"}), + "read-write") + # Same for `gettysburg_addresses` — not "get_". + self.assertEqual( + mcp_tools._compute_access({"name": "gettysburg_addresses"}), + "read-write") + + def test_empty_or_missing_name_defaults_to_read_write(self): + self.assertEqual(mcp_tools._compute_access({}), "read-write") + self.assertEqual(mcp_tools._compute_access({"name": ""}), "read-write") + + class EmitToolResourceTest(TestCase): def test_emits_resource_with_expected_shape(self): reg = Registry() @@ -672,6 +719,19 @@ def test_emits_resource_with_expected_shape(self): self.assertEqual(res.spec["tool_name"], "create_issue") self.assertEqual(res.spec["secret_ref"], "jira-mcp-token") self.assertEqual(res.spec["input_schema"]["required"], ["project"]) + # `create_*` is a write verb → access defaults to read-write. + self.assertEqual(res.spec["access"], "read-write") + + def test_emits_access_read_only_for_list_verb(self): + reg = Registry() + server = {"display_name": "jira", + "url": "https://jira-mcp.internal/mcp", + "secret_ref": "jira-mcp-token"} + mcp_tools._emit_tool_resource( + reg, server, {"name": "list_projects", "inputSchema": {}}) + res = next(iter( + reg.lookup_resource_type("mcp", "mcp_tool").instances.values())) + self.assertEqual(res.spec["access"], "read-only") def test_index_skips_when_config_empty(self): ctx = Context({}, mock.MagicMock()) From 4672265048c0598c8898e688c9087ba80b027fe8 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Thu, 28 May 2026 17:30:26 +0530 Subject: [PATCH 25/28] test(mcp-tools): assert MCP_SERVER_DISPLAY_NAME in rendered configProvided Tracks the codecollection's runbook template change that adds MCP_SERVER_DISPLAY_NAME to configProvided (needed so the Robot task title can render as "_"). Co-Authored-By: Claude Opus 4.7 --- src/tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tests.py b/src/tests.py index f168a3b1..0a77438c 100644 --- a/src/tests.py +++ b/src/tests.py @@ -861,5 +861,6 @@ def list_cb(request): config_names = {c["name"] for c in parsed["spec"]["configProvided"]} self.assertEqual( config_names, - {"MCP_SERVER_URL", "MCP_TOOL_NAME", "MCP_INPUT_SCHEMA", "MCP_VERIFY_TLS"}, + {"MCP_SERVER_URL", "MCP_SERVER_DISPLAY_NAME", "MCP_TOOL_NAME", + "MCP_INPUT_SCHEMA", "MCP_VERIFY_TLS"}, ) \ No newline at end of file From fd34cc98137b4317f3d1e0e4f7d70150ffff686a Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Thu, 28 May 2026 18:29:20 +0530 Subject: [PATCH 26/28] fix(render): honor explicit resourcePath in template, skip auto-compute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compute_resource_path_from_hierarchy` unconditionally overwrote `additionalContext.resourcePath` with the hierarchy tag values joined by `/`. That broke any platform that needs hierarchy and resourcePath at different depths — e.g. mcp wants a 3-key hierarchy (platform/mcp_server/mcp_tool) for UI grouping but a 2-key resourcePath (platform/mcp_server) for the underlying resource identity, so all tools on the same server share one resourcePath. When the template already set a truthy resourcePath, honor it and skip the recompute. Templates that don't set it (the existing kubernetes/aws/azure/gcp templates) keep the auto-compute behavior unchanged. Co-Authored-By: Claude Opus 4.7 --- src/renderers/render_output_items.py | 10 +++++ src/tests.py | 61 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/renderers/render_output_items.py b/src/renderers/render_output_items.py index fb72b0c2..2889a599 100644 --- a/src/renderers/render_output_items.py +++ b/src/renderers/render_output_items.py @@ -88,6 +88,16 @@ def compute_resource_path_from_hierarchy(data: dict) -> None: if name and name not in tag_lookup and value is not None: tag_lookup[name] = str(value) + # If the template already set an explicit resourcePath, honor it — some + # platforms (e.g. mcp) want hierarchy and resourcePath to be different + # depths (3-key hierarchy for UI grouping, 2-key resourcePath for the + # underlying resource identity). For platforms that don't set it, we + # still compute from hierarchy so they get the auto-behavior unchanged. + if additional_context.get('resourcePath'): + logger.debug( + f"Honoring explicit resourcePath from template: {additional_context['resourcePath']}") + return + # Build resourcePath from the hierarchy entries in order. # resource_name always appears in the path even when its value duplicates # a parent entry — the hierarchy is the single source of truth. diff --git a/src/tests.py b/src/tests.py index 0a77438c..9fa6e797 100644 --- a/src/tests.py +++ b/src/tests.py @@ -651,6 +651,67 @@ def json(self_inner): return {"jsonrpc": "2.0", "id": 1, from component import Context +from renderers.render_output_items import compute_resource_path_from_hierarchy + + +class ComputeResourcePathFromHierarchyTest(TestCase): + """The post-render hook in render_output_items.py derives `resourcePath` + from `additionalContext.hierarchy` + `tags` so they stay in sync. But + some platforms (e.g. mcp) need hierarchy and resourcePath at different + depths — 3-key hierarchy for UI grouping, 2-key resourcePath for the + underlying resource identity. Test that an explicit `resourcePath` in + the template is honored, while the auto-compute path stays unchanged + for templates that don't set it. + """ + + def _make_data(self, hierarchy, tags, explicit_resource_path=None): + additional_context = {"hierarchy": hierarchy} + if explicit_resource_path is not None: + additional_context["resourcePath"] = explicit_resource_path + return {"spec": {"additionalContext": additional_context, "tags": tags}} + + def test_auto_computes_when_resource_path_not_set(self): + data = self._make_data( + hierarchy=["platform", "mcp_server", "mcp_tool"], + tags=[{"name": "platform", "value": "mcp"}, + {"name": "mcp_server", "value": "linear-mcp"}, + {"name": "mcp_tool", "value": "list_teams"}], + ) + compute_resource_path_from_hierarchy(data) + self.assertEqual( + data["spec"]["additionalContext"]["resourcePath"], + "mcp/linear-mcp/list_teams") + + def test_honors_explicit_resource_path_from_template(self): + # Same hierarchy as above, but template already set resourcePath to a + # different depth — must not be overwritten. + data = self._make_data( + hierarchy=["platform", "mcp_server", "mcp_tool"], + tags=[{"name": "platform", "value": "mcp"}, + {"name": "mcp_server", "value": "linear-mcp"}, + {"name": "mcp_tool", "value": "list_teams"}], + explicit_resource_path="mcp/linear-mcp", + ) + compute_resource_path_from_hierarchy(data) + self.assertEqual( + data["spec"]["additionalContext"]["resourcePath"], + "mcp/linear-mcp") + + def test_empty_explicit_resource_path_falls_through_to_auto_compute(self): + # `resourcePath: ""` in a template (e.g. when an upstream value was + # missing) should not block auto-compute — we only honor truthy values. + data = self._make_data( + hierarchy=["platform", "cluster"], + tags=[{"name": "platform", "value": "kubernetes"}, + {"name": "cluster", "value": "prod"}], + explicit_resource_path="", + ) + compute_resource_path_from_hierarchy(data) + self.assertEqual( + data["spec"]["additionalContext"]["resourcePath"], + "kubernetes/prod") + + class ComputeAccessTest(TestCase): def test_read_only_hint_true_is_authoritative(self): tool = {"name": "create_issue", "annotations": {"readOnlyHint": True}} From 0d9cb8ed726460fce3d2c765651dfd8847b32453 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Fri, 29 May 2026 18:51:02 +0530 Subject: [PATCH 27/28] test(mcp): lock in runtimeVar default string coercion Adds RunbookDefaultsAreStringsTest covering string/int/bool/list/dict/ null/missing default values. Each must render as a YAML string in the mcp-tool-proxy runbook template so Robot Framework consumes them as strings (paired with the codecollection template fix). Co-Authored-By: Claude Opus 4.7 --- src/tests.py | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/src/tests.py b/src/tests.py index 9fa6e797..6414fe73 100644 --- a/src/tests.py +++ b/src/tests.py @@ -924,4 +924,100 @@ def list_cb(request): config_names, {"MCP_SERVER_URL", "MCP_SERVER_DISPLAY_NAME", "MCP_TOOL_NAME", "MCP_INPUT_SCHEMA", "MCP_VERIFY_TLS"}, - ) \ No newline at end of file + ) + + +class RunbookDefaultsAreStringsTest(TestCase): + """The runbook template must coerce every runtimeVar `default` to a YAML + string. Robot Framework treats runtime vars as strings — if the template + leaves a JSON schema default as a raw number/bool/list/dict, YAML parses + it as that native type and the runner barfs on the type mismatch.""" + + def _render(self, properties: dict) -> dict: + import jinja2 + from indexers import mcp_tools + from resources import Registry, REGISTRY_PROPERTY_NAME + from component import Context + + # Build a fake registry entry by running _emit_tool_resource directly + # — no MCP server needed; we only care about template rendering. + reg = Registry() + mcp_tools._emit_tool_resource( + reg, + {"display_name": "srv", + "url": "https://srv.local/mcp", + "secret_ref": "srv-token"}, + {"name": "do_thing", + "description": "", + "inputSchema": {"type": "object", "properties": properties}}, + ) + match_resource = next(iter( + reg.lookup_resource_type("mcp", "mcp_tool").instances.values())) + + cb_path = os.environ.get( + "MCP_TOOL_PROXY_PATH", + "/Users/prats/Documents/work/rw-generic-codecollection/codebundles/mcp-tool-proxy", + ) + env = jinja2.Environment( + loader=jinja2.FileSystemLoader(os.path.join(cb_path, ".runwhen/templates")), + undefined=jinja2.StrictUndefined, + ) + t = env.get_template("mcp-tool-proxy-runbook.yaml") + out = t.render(slx_name="srv-do-thing", + default_location="loc1", + match_resource=match_resource) + return _yaml.safe_load(out) + + def _defaults_by_name(self, parsed: dict) -> dict: + return {v["name"]: v["default"] for v in parsed["spec"]["runtimeVarsProvided"]} + + def test_string_default_passes_through(self): + defaults = self._defaults_by_name(self._render({ + "project": {"type": "string", "default": "RW"}, + })) + self.assertEqual(defaults["project"], "RW") + self.assertIsInstance(defaults["project"], str) + + def test_int_default_becomes_string(self): + defaults = self._defaults_by_name(self._render({ + "limit": {"type": "integer", "default": 42}, + })) + self.assertEqual(defaults["limit"], "42") + self.assertIsInstance(defaults["limit"], str) + + def test_bool_default_becomes_lowercase_json_string(self): + # tojson on True yields "true" (JSON), not "True" (Python repr) — + # matters because Robot/downstream consumers expect JSON-shaped bools. + defaults = self._defaults_by_name(self._render({ + "dry_run": {"type": "boolean", "default": True}, + })) + self.assertEqual(defaults["dry_run"], "true") + self.assertIsInstance(defaults["dry_run"], str) + + def test_list_default_becomes_json_string(self): + defaults = self._defaults_by_name(self._render({ + "tags": {"type": "array", "default": ["a", "b"]}, + })) + self.assertEqual(defaults["tags"], '["a", "b"]') + self.assertIsInstance(defaults["tags"], str) + + def test_dict_default_becomes_json_string(self): + defaults = self._defaults_by_name(self._render({ + "filters": {"type": "object", "default": {"k": "v"}}, + })) + self.assertEqual(defaults["filters"], '{"k": "v"}') + self.assertIsInstance(defaults["filters"], str) + + def test_missing_default_becomes_empty_string(self): + defaults = self._defaults_by_name(self._render({ + "project": {"type": "string"}, + })) + self.assertEqual(defaults["project"], "") + self.assertIsInstance(defaults["project"], str) + + def test_null_default_becomes_empty_string(self): + defaults = self._defaults_by_name(self._render({ + "project": {"type": "string", "default": None}, + })) + self.assertEqual(defaults["project"], "") + self.assertIsInstance(defaults["project"], str) \ No newline at end of file From 0e6dffa99f9af81419b04a2d7db81fb280255af8 Mon Sep 17 00:00:00 2001 From: Prathamesh Lohakare Date: Mon, 1 Jun 2026 13:37:49 +0530 Subject: [PATCH 28/28] test(mcp): lock in runtimeVar required-param description marking Adds RunbookRequiredParamMarkingTest covering: required+description gets "(required)" suffix; required without description gets a fallback sentence; optional params are untouched; schemas without a required array don't crash; mixed required/optional in one schema. Inherits from the defaults-are-strings test class to keep regression coverage. Co-Authored-By: Claude Opus 4.7 --- src/tests.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/src/tests.py b/src/tests.py index 6414fe73..75c9bcd2 100644 --- a/src/tests.py +++ b/src/tests.py @@ -933,7 +933,7 @@ class RunbookDefaultsAreStringsTest(TestCase): leaves a JSON schema default as a raw number/bool/list/dict, YAML parses it as that native type and the runner barfs on the type mismatch.""" - def _render(self, properties: dict) -> dict: + def _render(self, properties: dict, required: list | None = None) -> dict: import jinja2 from indexers import mcp_tools from resources import Registry, REGISTRY_PROPERTY_NAME @@ -942,6 +942,9 @@ def _render(self, properties: dict) -> dict: # Build a fake registry entry by running _emit_tool_resource directly # — no MCP server needed; we only care about template rendering. reg = Registry() + input_schema = {"type": "object", "properties": properties} + if required is not None: + input_schema["required"] = required mcp_tools._emit_tool_resource( reg, {"display_name": "srv", @@ -949,7 +952,7 @@ def _render(self, properties: dict) -> dict: "secret_ref": "srv-token"}, {"name": "do_thing", "description": "", - "inputSchema": {"type": "object", "properties": properties}}, + "inputSchema": input_schema}, ) match_resource = next(iter( reg.lookup_resource_type("mcp", "mcp_tool").instances.values())) @@ -1020,4 +1023,54 @@ def test_null_default_becomes_empty_string(self): "project": {"type": "string", "default": None}, })) self.assertEqual(defaults["project"], "") - self.assertIsInstance(defaults["project"], str) \ No newline at end of file + self.assertIsInstance(defaults["project"], str) + + +class RunbookRequiredParamMarkingTest(RunbookDefaultsAreStringsTest): + """The runbook template should mark required parameters in the rendered + description so downstream UI/agent surfaces know which inputs are + mandatory. JSON Schema lists required fields at the top level (not as a + per-property flag), so this needs the schema's `required` array.""" + + def _descriptions_by_name(self, parsed: dict) -> dict: + return {v["name"]: v["description"] for v in parsed["spec"]["runtimeVarsProvided"]} + + def test_required_param_with_description_gets_suffix(self): + descs = self._descriptions_by_name(self._render( + {"project": {"type": "string", "description": "Project key"}}, + required=["project"], + )) + self.assertEqual(descs["project"], "Project key (required)") + + def test_required_param_with_empty_description_gets_sentence(self): + descs = self._descriptions_by_name(self._render( + {"project": {"type": "string"}}, + required=["project"], + )) + self.assertEqual(descs["project"], "Required parameter.") + + def test_optional_param_description_is_unchanged(self): + descs = self._descriptions_by_name(self._render( + {"summary": {"type": "string", "description": "Issue title"}}, + required=["project"], # different param required; summary is not + )) + self.assertEqual(descs["summary"], "Issue title") + + def test_no_required_array_in_schema(self): + # Schemas may omit `required` entirely — must not crash, no suffix. + descs = self._descriptions_by_name(self._render( + {"project": {"type": "string", "description": "Project key"}}, + required=None, + )) + self.assertEqual(descs["project"], "Project key") + + def test_mixed_required_and_optional(self): + descs = self._descriptions_by_name(self._render( + {"project": {"type": "string", "description": "Project key"}, + "summary": {"type": "string", "description": "Issue title"}, + "labels": {"type": "string", "description": "CSV labels"}}, + required=["project", "summary"], + )) + self.assertEqual(descs["project"], "Project key (required)") + self.assertEqual(descs["summary"], "Issue title (required)") + self.assertEqual(descs["labels"], "CSV labels") \ No newline at end of file