Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
019725c
docs: add Approach D design spec + implementation plan for private MC…
theyashl May 25, 2026
6de55f1
plan: switch to D2 (Helm-config discovery), add access=read-only + mc…
theyashl May 25, 2026
377defc
scaffold mcp-tool-proxy codebundle directory
theyashl May 25, 2026
5cba1b2
test: add stub MCP server fixture for mcp-tool-proxy tests
theyashl May 25, 2026
61d6136
feat(mcp-tool-proxy): _rpc returns envelopes verbatim (callers handle…
theyashl May 25, 2026
c0f3430
feat(mcp-tool-proxy): _notify helper for JSON-RPC notifications
theyashl May 25, 2026
016b125
feat(mcp-tool-proxy): render_tool_output collapses MCP content parts
theyashl May 25, 2026
142b797
feat(mcp-tool-proxy): invoke_tool splits init-error (raise) vs tool-e…
theyashl May 25, 2026
a27d6c6
feat(mcp-tool-proxy): main() entrypoint reading env vars, non-zero ex…
theyashl May 25, 2026
3203d13
feat(mcp-tool-proxy): Robot wrapper with dynamic schema-driven runtim…
theyashl May 25, 2026
02b05f3
test(mcp-tool-proxy): local dry-run script for the Robot wrapper
theyashl May 25, 2026
b068d51
chore(mcp-tool-proxy): gitignore venv/test output/pycache
theyashl May 25, 2026
0b7fa4e
feat(mcp-tool-proxy): generation rule matching mcp_tool resources
theyashl May 25, 2026
0fe0d88
feat(mcp-tool-proxy): SLX template with per-tool config + mcp tags
theyashl May 25, 2026
d17005a
feat(mcp-tool-proxy): Runbook template with dynamic runtimeVarsProvided
theyashl May 25, 2026
c020d9a
docs(mcp-tool-proxy): README with architecture, contract, dev guide
theyashl May 25, 2026
6d40ac1
fix(mcp-tool-proxy): SLX has no configProvided; hierarchy is tag-key …
theyashl May 25, 2026
fdcdf98
fix(mcp-tool-proxy): drop required/type from runtimeVarsProvided
theyashl May 25, 2026
598b241
feat(mcp-tool-proxy): always emit validation on runtimeVarsProvided
theyashl May 25, 2026
ed46753
fix(mcp-tool-proxy): tojson-quote all values that may carry special c…
theyashl May 26, 2026
b33abeb
fix(mcp-tool-proxy): put common-labels include on its own line
theyashl May 26, 2026
6985ad9
docs(mcp-tool-proxy): add Configuring MCP servers section to README
theyashl May 26, 2026
c71b353
fix(mcp-tool-proxy): add mcp_tool to additionalContext.hierarchy
theyashl May 26, 2026
3141251
feat(mcp-tool-proxy): make codeBundle.ref configurable + harden templ…
theyashl May 26, 2026
b327e6a
refactor(mcp-tool-proxy): runbook ref reads {{ ref }} std template var
theyashl May 26, 2026
da1ffa8
refactor(mcp-tool-proxy): rename source tag to platform + set explici…
theyashl May 26, 2026
b3ff04f
refactor(mcp-tool-proxy): shorten qualified_name + dynamic task name …
theyashl May 27, 2026
9fcad80
refactor(mcp-tool-proxy): add resource_name/resource_type tags; hiera…
theyashl May 27, 2026
8739dce
refactor(mcp-tool-proxy): restore 3-level hierarchy (platform → mcp_s…
theyashl May 27, 2026
91a4ee3
feat(mcp-tool-proxy): plumb verify_tls through to the runner proxy
theyashl May 27, 2026
7d7a2c1
fix(mcp-tool-proxy): coerce string args to schema-declared types befo…
theyashl May 27, 2026
17ecc79
feat(mcp-tool-proxy): make access tag per-tool — read spec.access, de…
theyashl May 28, 2026
8ac5413
refactor(mcp-tool-proxy): alias uses '-' separator, resourcePath in s…
theyashl May 28, 2026
e4f1a6a
fix(mcp-tool-proxy): coerce runtimeVar defaults to YAML strings
theyashl May 29, 2026
d9d3b1f
feat(mcp-tool-proxy): mark required params with '(required)' suffix i…
theyashl Jun 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions codebundles/mcp-tool-proxy/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.venv/
.test/_output/
__pycache__/
*.pyc
.pytest_cache/
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
apiVersion: runwhen.com/v1
kind: GenerationRules
spec:
platform: mcp
generationRules:
- resourceTypes:
- mcp:mcp_tool
matchRules:
- type: pattern
pattern: ".+"
properties: [tool_name]
mode: substring
slxs:
- baseName: mcp-tool
levelOfDetail: basic
qualifiers: [server_display_name, tool_name]
baseTemplateName: mcp-tool-proxy
outputItems:
- type: slx
templateName: mcp-tool-proxy-slx.yaml
- type: runbook
templateName: mcp-tool-proxy-runbook.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
apiVersion: runwhen.com/v1
kind: Runbook
metadata:
name: {{slx_name}}
spec:
location: {{default_location}}
codeBundle:
repoUrl: https://github.com/runwhen-contrib/rw-generic-codecollection.git
# Pin the runbook's clone ref to the codecollection ref this template
# was loaded from — `ref` is populated by the workspace-builder
# generation-rules engine (see generation_rules.py:643).
ref: {{ ref | default("main") }}
pathToRobot: codebundles/mcp-tool-proxy/runbook.robot
# Static — same for every invocation of this SLX.
configProvided:
- name: MCP_SERVER_URL
value: {{match_resource.spec.server_url}}
- name: MCP_SERVER_DISPLAY_NAME
value: {{match_resource.spec.server_display_name}}
- name: MCP_TOOL_NAME
value: {{match_resource.spec.tool_name}}
- name: MCP_INPUT_SCHEMA
value: '{{match_resource.spec.input_schema | tojson}}'
- name: MCP_VERIFY_TLS
value: "{{match_resource.spec.verify_tls | default(true) | string | lower}}"
secretsProvided:
- name: mcp_auth
workspaceKey: k8s:file@secret/{{match_resource.spec.secret_ref}}:token
# Dynamic per-invocation. Rendered from the MCP tool's input_schema.properties.
# RuntimeVarEntry shape: name/default/description/validation
# (corestate-operator api/v1/common_types.go). Validation.type is constrained
# to "enum" or "regex" — when the MCP property has neither, we emit a
# permissive `.*` regex so every var carries a validation block.
{# Required parameters are listed at the schema's top level (JSON Schema
convention), not as per-property flags. Surface them by appending a
"(required)" hint to each parameter's description so the runtime UI/agent
knows which vars are mandatory. #}
{%- set required_params = match_resource.spec.input_schema.required | default([]) %}
runtimeVarsProvided:
{% for pname, pschema in (match_resource.spec.input_schema.properties | default({})).items() %}
{%- set raw_default = pschema.default | default("") %}
{%- if raw_default is none %}{%- set raw_default = "" %}{%- endif %}
{# Robot Framework runtime vars are always strings. Cast non-string defaults
(numbers, bools, lists, dicts) via tojson first so they survive YAML
parsing as strings instead of being interpreted as their native type. #}
{%- if raw_default is not string %}{%- set raw_default = raw_default | tojson %}{%- endif %}
{%- set raw_desc = pschema.description | default("") %}
{%- if pname in required_params %}
{%- if raw_desc %}{%- set raw_desc = raw_desc ~ " (required)" %}
{%- else %}{%- set raw_desc = "Required parameter." %}{%- endif %}
{%- endif %}
- name: {{pname}}
description: {{ raw_desc | tojson }}
default: {{ raw_default | tojson }}
validation:
{% if pschema.enum is defined %}
type: enum
values:
{% for v in pschema.enum %}
- {{ v | tojson }}
{% endfor %}
{% elif pschema.pattern is defined %}
type: regex
pattern: {{ pschema.pattern | tojson }}
{% else %}
type: regex
pattern: ".*"
{% endif %}
{% endfor %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
apiVersion: runwhen.com/v1
kind: ServiceLevelX
metadata:
name: {{slx_name}}
labels:
{% include "common-labels.yaml" %}
spec:
alias: {{ (match_resource.spec.server_display_name ~ " - " ~ match_resource.spec.tool_name) | tojson }}
statement: {{ (match_resource.spec.description | default("") or ("Proxy for " ~ match_resource.spec.tool_name)) | tojson }}
additionalContext:
hierarchy:
- platform
- mcp_server
- mcp_tool
qualified_name: "mcp/{{match_resource.spec.server_display_name}}"
resourcePath: "mcp/{{match_resource.spec.server_display_name}}"
tags:
- name: platform
value: mcp
- name: resource_name
value: "{{match_resource.spec.server_display_name}}"
- name: resource_type
value: mcp_server
- name: mcp_server
value: "{{match_resource.spec.server_display_name}}"
- name: mcp_tool
value: "{{match_resource.spec.tool_name}}"
- name: access
value: "{{ match_resource.spec.access | default('read-write') }}"
31 changes: 31 additions & 0 deletions codebundles/mcp-tool-proxy/.test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Local dry-run for mcp-tool-proxy

`dry-run.sh` boots `stub_server.py` (a minimal MCP HTTP server) and invokes
`mcp_tool_proxy.py` directly with the env contract the Robot wrapper would
build at runtime:

| Env var | Source in production | Value here |
|---|---|---|
| `MCP_SERVER_URL` | Runbook `configProvided` | `http://127.0.0.1:18080` |
| `MCP_TOOL_NAME` | Runbook `configProvided` | `echo` |
| `MCP_TOOL_ARGS_JSON` | Built by `runbook.robot` from `RW.Core.Import User Variable` calls | `{"msg":"hello-from-dryrun"}` |
| `MCP_AUTH` | Built by `runbook.robot` from `RW.Core.Import Secret` | `stub-token` |

## Why this bypasses `runbook.robot`

The Robot wrapper imports `RW.Core` (Import User Variable, Import Secret).
That library ships in the private RunWhen runner image, not on PyPI, so it
can't be exercised locally without that image. The script under test
(`mcp_tool_proxy.py`) is itself the part that needs end-to-end coverage with
a live HTTP server — the Robot wrapper just builds the env contract above.

Robot-level validation happens inside the runner via the standard
codecollection CI path.

## Running

```bash
./dry-run.sh
```

Exit 0 + `dry-run OK` printed = stub round-trip works.
36 changes: 36 additions & 0 deletions codebundles/mcp-tool-proxy/.test/dry-run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# End-to-end local validation: boots the stub MCP server and invokes
# mcp_tool_proxy.py directly with the env contract the Robot wrapper builds.
#
# Why not invoke runbook.robot directly here: the wrapper uses RW.Core (Import
# User Variable, Import Secret) which ships in the private runner image, not
# on PyPI. Full Robot-level testing happens inside the runner — see README.md.
set -euo pipefail

HERE="$(cd "$(dirname "$0")" && pwd)"
CB="$(cd "$HERE/.." && pwd)"
PORT=18080

python3 "$HERE/stub_server.py" &
STUB_PID=$!
trap 'kill $STUB_PID 2>/dev/null || true' EXIT
sleep 0.3

PYBIN="$CB/.venv/bin/python"
[ -x "$PYBIN" ] || PYBIN="python3"

OUT="$(
MCP_SERVER_URL="http://127.0.0.1:$PORT" \
MCP_TOOL_NAME="echo" \
MCP_TOOL_ARGS_JSON='{"msg":"hello-from-dryrun"}' \
MCP_AUTH="stub-token" \
"$PYBIN" "$CB/mcp_tool_proxy.py"
)"

echo "--- stdout ---"
echo "$OUT"
echo "--------------"

echo "$OUT" | grep -q 'stub-ok name=echo args={"msg": "hello-from-dryrun"}' \
|| { echo "FAIL: expected stub response not found"; exit 1; }
echo "dry-run OK"
47 changes: 47 additions & 0 deletions codebundles/mcp-tool-proxy/.test/stub_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Minimal HTTP MCP stub for local dry-runs of the Robot wrapper.
Listens on $PORT (default 18080), accepts initialize/notifications/tools/call,
echoes back a canned text response."""
import json
import os
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer


class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
body = json.loads(self.rfile.read(length) or b"{}")
method = body.get("method", "")
if method == "initialize":
resp = {"jsonrpc": "2.0", "id": body["id"],
"result": {"protocolVersion": "2025-03-26",
"capabilities": {},
"serverInfo": {"name": "stub", "version": "0"}}}
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Mcp-Session-Id", "stub-session")
self.end_headers()
self.wfile.write(json.dumps(resp).encode())
elif method == "notifications/initialized":
self.send_response(200)
self.end_headers()
elif method == "tools/call":
args = body["params"].get("arguments", {})
text = f"stub-ok name={body['params']['name']} args={json.dumps(args)}"
resp = {"jsonrpc": "2.0", "id": body["id"],
"result": {"content": [{"type": "text", "text": text}]}}
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(resp).encode())
else:
self.send_response(400)
self.end_headers()

def log_message(self, *_args, **_kw):
return


if __name__ == "__main__":
port = int(os.environ.get("PORT", "18080"))
HTTPServer(("127.0.0.1", port), Handler).serve_forever()
107 changes: 107 additions & 0 deletions codebundles/mcp-tool-proxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# mcp-tool-proxy

Generic codebundle that proxies a single tool call on a private MCP server.
One SLX is auto-generated **per MCP tool** by the `mcp_tools` indexer in
`runwhen-local` (workspace-builder); this codebundle holds the constant
execution path that every generated SLX runs.

## Architecture

```
Helm values (mcpConfig) ──> workspace-builder mcp_tools indexer ──> mcp_tool resources
v
.runwhen/generation-rules/mcp-tool-proxy.yaml
v
one SLX + Runbook per discovered tool
v
(per invocation) agentfarm → papi → runner → robot.run → mcp_tool_proxy.py
v
HTTP/JSON-RPC → MCP server (in-VPC)
```

Design rationale: `docs/superpowers/specs/2026-05-20-private-mcp-integration-design.md`.

## Configuring MCP servers

MCP servers are declared in the workspace builder's `workspaceInfo.yaml`
under an `mcpConfig.servers` list. With the standard Helm chart this lives
under `runwhenLocal.workspaceBuilder.workspaceInfo.configMap.data` (when
the chart manages the ConfigMap) or directly in the `workspaceinfo`
ConfigMap data (when `useExistingConfigMap: true`).

```yaml
runwhenLocal:
workspaceBuilder:
workspaceInfo:
useExistingConfigMap: false
configMap:
create: true
name: workspaceinfo
data:
workspaceName: my-workspace
# ... other workspaceInfo fields ...
mcpConfig:
servers:
- display_name: jira # used in generated SLX names + tags
url: https://jira-mcp.internal:443/mcp
secret_ref: jira-mcp-token # k8s Secret with data.token = bearer
- display_name: linear
url: https://linear-mcp.internal:443/mcp
secret_ref: linear-mcp-token
```

Required fields per server: `display_name`, `url` (full HTTPS endpoint
including path, typically `/mcp`), `secret_ref` (name of a k8s Secret
with the bearer token under `data.token`). Optional: `verify_tls: false`
to skip TLS verification for a server whose issuer isn't yet trusted by
the pod's CA bundle — emits a warning per cycle while in use.

The same secret must be available to runner pods at execution time
(generated Runbooks reference it via `secretsProvided.workspaceKey`).

Full reference: see [WorkspaceInfo customization → mcpConfig](https://github.com/runwhen-contrib/runwhen-local/blob/main/docs/configuration/workspaceinfo-customization.md#mcp-server-discovery-mcpconfig)
in runwhen-local.

## Runtime contract

Generated Runbooks pass:

| Variable | Source | Purpose |
|---|---|---|
| `MCP_SERVER_URL` | Static `configProvided` from template | In-VPC URL of the MCP server |
| `MCP_TOOL_NAME` | Static `configProvided` from template | Tool name to invoke |
| `MCP_INPUT_SCHEMA` | Static `configProvided` from template | JSON of the tool's input_schema |
| `mcp_auth` | `secretsProvided` (workspaceKey from server's secret_ref) | Bearer token |
| `<param>` (per tool) | `runtimeVarsProvided` (rendered from input_schema.properties) | Per-invocation tool arg |

Per-invocation values are supplied by agentfarm in the RunRequest's
`runtime_var_values` map. Papi's `assemble_runbook_env`
(`backend-services-v2/papi/routers/explorer.py:496-547`) merges them into
`configProvided` with no allowlist — they appear in the runner env under
their declared names.

## Error handling

| What happened | Task outcome | Where it shows up |
|---|---|---|
| Tool returned successfully | Succeeds (rc=0) | Tool output in report |
| Tool returned a JSON-RPC error | **Succeeds** (rc=0) | Error message in report (so agentfarm can read and react) |
| Tool returned `result.isError: true` | **Succeeds** (rc=0) | `isError` message + content in report |
| `initialize` returned an error envelope | **Fails** (rc=1) | stderr — we couldn't even start an MCP session |
| Transport failure (connection refused, timeout, TLS, HTTP 5xx) | **Fails** (rc=1) | stderr |

Rationale: agentfarm needs the tool's error message to do something useful — retry, ask the user, route to a different tool. Surfacing tool errors as successful task output (with the error message in the report) preserves that signal. Reserve "failed task" for cases where we have no useful response to surface.

## Local dev

```bash
cd codebundles/mcp-tool-proxy
python3 -m venv .venv
.venv/bin/pip install -r dev-requirements.txt requests
PYTHONPATH=. .venv/bin/pytest tests/ -v
.test/dry-run.sh # end-to-end Robot + stub MCP server
```
2 changes: 2 additions & 0 deletions codebundles/mcp-tool-proxy/dev-requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pytest>=7.4
responses>=0.24
Loading
Loading