Skip to content

Commit 4ee6af9

Browse files
sjarmakclaude
andcommitted
fix: OpenHands MCP integration — config.toml + auth proxy for Sourcegraph
OpenHands ignores .mcp.json (Claude Code format) and hardcodes Authorization: Bearer, but Sourcegraph requires Authorization: token. Changes: - Override _configure_mcp() in OpenHandsHarnessAgent to write config.toml with [mcp] shttp_servers pointing at a local auth proxy - Add sg_auth_proxy.py: lightweight HTTP proxy on localhost:18973 that rewrites Bearer → token auth before forwarding to Sourcegraph - Add Dockerfile.sg_only selection to openhands_2config.sh for MCP runs (copies task to temp dir, swaps Dockerfile like run_selected_tasks.sh) Verified: 13 Sourcegraph MCP tools load, agent makes MCP calls. Prior "MCP" runs had 0 tools loaded — all results were broken baselines. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3a65abd commit 4ee6af9

File tree

3 files changed

+321
-11
lines changed

3 files changed

+321
-11
lines changed

agents/harnesses/openhands/agent.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
"""OpenHands harness agent wired to Harbor's OpenHands CLI with shared baseline tooling."""
22

3+
import json
4+
import logging
35
import os
46

57
from harbor.agents import utils as harbor_utils
8+
from harbor.environments.base import BaseEnvironment
69
from harbor.agents.installed.openhands import OpenHands
710

811
from ..base import BaselineHarnessMixin
912

13+
logger = logging.getLogger(__name__)
14+
1015
# Codex model names (LiteLLM/Harbor don't know these); we map them to OPENAI_API_KEY
1116
# so Harbor's get_api_key_var_names_from_model_name can resolve the key.
1217
_CODEX_MODEL_PREFIXES = ("gpt-5.3-codex", "gpt53codex", "codex")
@@ -56,3 +61,111 @@ def __init__(self, *args, **kwargs):
5661
# or it raises "LLM Provider NOT provided". Normalize Codex models so env LLM_MODEL works.
5762
if self.model_name:
5863
self.model_name = _litellm_codex_model(self.model_name)
64+
65+
# Port for the in-container auth proxy (Sourcegraph needs "token" auth,
66+
# but OpenHands hardcodes "Bearer").
67+
_SG_PROXY_PORT = 18973
68+
69+
async def _configure_mcp(self, environment: BaseEnvironment) -> None:
70+
"""Configure MCP for OpenHands with Sourcegraph auth proxy.
71+
72+
OpenHands hardcodes 'Authorization: Bearer <token>' for SHTTP, but
73+
Sourcegraph requires 'Authorization: token <token>'. We solve this by:
74+
1. Uploading a small HTTP auth proxy (sg_auth_proxy.py) into the container
75+
2. Starting it as a background daemon on localhost
76+
3. Pointing OpenHands' config.toml at http://localhost:<port>
77+
"""
78+
mcp_type = os.environ.get("BASELINE_MCP_TYPE", "none").lower()
79+
if mcp_type == "none":
80+
return
81+
82+
sg_url = (
83+
os.environ.get("SOURCEGRAPH_URL")
84+
or os.environ.get("SRC_ENDPOINT")
85+
or "https://sourcegraph.sourcegraph.com"
86+
)
87+
sg_token = (
88+
os.environ.get("SOURCEGRAPH_ACCESS_TOKEN")
89+
or os.environ.get("SRC_ACCESS_TOKEN")
90+
or ""
91+
)
92+
if not sg_token:
93+
logger.warning("SOURCEGRAPH_ACCESS_TOKEN not set; skipping OpenHands MCP")
94+
return
95+
if not sg_url.startswith(("http://", "https://")):
96+
sg_url = f"https://{sg_url}"
97+
sg_url = sg_url.rstrip("/")
98+
99+
mcp_endpoint = f"{sg_url}/.api/mcp"
100+
workdir = await self._detect_workdir(environment)
101+
102+
# --- Upload and start the auth proxy ---
103+
proxy_src = os.path.join(os.path.dirname(__file__), "sg_auth_proxy.py")
104+
await environment.upload_file(
105+
source_path=proxy_src,
106+
target_path="/tmp/sg_auth_proxy.py",
107+
)
108+
# Start proxy as background daemon
109+
start_cmd = (
110+
f"SG_MCP_URL={mcp_endpoint} "
111+
f"SG_MCP_TOKEN={sg_token} "
112+
f"nohup python3 /tmp/sg_auth_proxy.py --port {self._SG_PROXY_PORT} "
113+
f"> /tmp/sg_proxy.log 2>&1 &"
114+
)
115+
await environment.exec(start_cmd)
116+
# Wait briefly for proxy to start
117+
await environment.exec("sleep 1")
118+
result = await environment.exec(
119+
f"curl -s -o /dev/null -w '%{{http_code}}' http://127.0.0.1:{self._SG_PROXY_PORT}/ || echo 'proxy_down'"
120+
)
121+
proxy_status = (result.stdout or "").strip()
122+
if "proxy_down" in proxy_status:
123+
logger.error("Auth proxy failed to start. Log: %s",
124+
(await environment.exec("cat /tmp/sg_proxy.log")).stdout)
125+
return
126+
logger.info("Auth proxy running on port %d (status: %s)", self._SG_PROXY_PORT, proxy_status)
127+
128+
# --- config.toml pointing at local proxy (no api_key needed) ---
129+
local_url = f"http://127.0.0.1:{self._SG_PROXY_PORT}"
130+
config_toml = (
131+
"[agent]\n"
132+
"enable_mcp = true\n"
133+
"\n"
134+
"[mcp]\n"
135+
"shttp_servers = [\n"
136+
f' {{ url = "{local_url}", timeout = 300 }}\n'
137+
"]\n"
138+
)
139+
config_toml_path = self.logs_dir / "config.toml"
140+
config_toml_path.write_text(config_toml)
141+
await environment.upload_file(
142+
source_path=str(config_toml_path),
143+
target_path=f"{workdir}/config.toml",
144+
)
145+
146+
# Also set env vars as backup
147+
servers = [{"url": local_url, "timeout": 300}]
148+
os.environ["OPENHANDS_MCP_SHTTP_SERVERS"] = repr(servers)
149+
os.environ["OPENHANDS_AGENT_ENABLE_MCP"] = "true"
150+
151+
# Upload CLAUDE.md for instruction context
152+
claude_md = "## Sourcegraph MCP\nUse the provided MCP tools before local edits."
153+
claude_md_path = self.logs_dir / "CLAUDE.md"
154+
claude_md_path.write_text(claude_md)
155+
await environment.upload_file(
156+
source_path=str(claude_md_path), target_path=f"{workdir}/CLAUDE.md"
157+
)
158+
159+
# Save debug artifacts
160+
mcp_json = {
161+
"mcpServers": {
162+
"sourcegraph": {
163+
"type": "http",
164+
"url": f"{sg_url}/.api/mcp/v1",
165+
"headers": {"Authorization": f"token {sg_token}"},
166+
}
167+
}
168+
}
169+
artifact_path = self.logs_dir / ".mcp.json"
170+
artifact_path.write_text(json.dumps(mcp_json, indent=2))
171+
logger.info("OpenHands MCP configured via auth proxy -> %s", mcp_endpoint)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env python3
2+
"""HTTP auth proxy for Sourcegraph MCP.
3+
4+
OpenHands sends 'Authorization: Bearer <token>', but Sourcegraph requires
5+
'Authorization: token <token>'. This proxy listens on localhost and forwards
6+
requests to Sourcegraph with the correct auth header.
7+
8+
Runs as a background daemon inside the container. OpenHands SHTTP config
9+
points at http://localhost:<port> with no api_key; this proxy adds auth.
10+
11+
Usage:
12+
SG_MCP_URL=https://sourcegraph.sourcegraph.com/.api/mcp \
13+
SG_MCP_TOKEN=sgp_... \
14+
python3 sg_auth_proxy.py [--port 18973]
15+
16+
Writes the actual listen port to /tmp/sg_proxy_port on startup.
17+
"""
18+
19+
import argparse
20+
import os
21+
import sys
22+
from http.server import HTTPServer, BaseHTTPRequestHandler
23+
import urllib.request
24+
import urllib.error
25+
26+
SG_URL = os.environ.get("SG_MCP_URL", "https://sourcegraph.sourcegraph.com/.api/mcp")
27+
SG_TOKEN = os.environ.get("SG_MCP_TOKEN", "")
28+
29+
30+
class ProxyHandler(BaseHTTPRequestHandler):
31+
def do_POST(self):
32+
content_length = int(self.headers.get("Content-Length", 0))
33+
body = self.rfile.read(content_length) if content_length else b""
34+
35+
# Forward headers, replacing auth
36+
fwd_headers = {}
37+
for key, val in self.headers.items():
38+
lower = key.lower()
39+
if lower in ("host", "authorization", "s", "x-session-api-key"):
40+
continue
41+
fwd_headers[key] = val
42+
43+
if SG_TOKEN:
44+
fwd_headers["Authorization"] = f"token {SG_TOKEN}"
45+
fwd_headers["Host"] = urllib.request.urlparse(SG_URL).netloc
46+
47+
req = urllib.request.Request(
48+
SG_URL, data=body, headers=fwd_headers, method="POST"
49+
)
50+
51+
try:
52+
with urllib.request.urlopen(req, timeout=300) as resp:
53+
resp_body = resp.read()
54+
self.send_response(resp.status)
55+
for key, val in resp.getheaders():
56+
if key.lower() not in ("transfer-encoding", "connection"):
57+
self.send_header(key, val)
58+
self.end_headers()
59+
self.wfile.write(resp_body)
60+
except urllib.error.HTTPError as e:
61+
self.send_response(e.code)
62+
self.send_header("Content-Type", "application/json")
63+
self.end_headers()
64+
err_body = e.read() if e.fp else b""
65+
self.wfile.write(err_body)
66+
except Exception as e:
67+
self.send_response(502)
68+
self.send_header("Content-Type", "text/plain")
69+
self.end_headers()
70+
self.wfile.write(str(e).encode())
71+
72+
def do_GET(self):
73+
# MCP streamable HTTP also uses GET for SSE streams
74+
fwd_headers = {}
75+
for key, val in self.headers.items():
76+
lower = key.lower()
77+
if lower in ("host", "authorization", "s", "x-session-api-key"):
78+
continue
79+
fwd_headers[key] = val
80+
81+
if SG_TOKEN:
82+
fwd_headers["Authorization"] = f"token {SG_TOKEN}"
83+
fwd_headers["Host"] = urllib.request.urlparse(SG_URL).netloc
84+
85+
req = urllib.request.Request(SG_URL, headers=fwd_headers, method="GET")
86+
87+
try:
88+
with urllib.request.urlopen(req, timeout=300) as resp:
89+
resp_body = resp.read()
90+
self.send_response(resp.status)
91+
for key, val in resp.getheaders():
92+
if key.lower() not in ("transfer-encoding", "connection"):
93+
self.send_header(key, val)
94+
self.end_headers()
95+
self.wfile.write(resp_body)
96+
except urllib.error.HTTPError as e:
97+
self.send_response(e.code)
98+
self.end_headers()
99+
self.wfile.write(e.read() if e.fp else b"")
100+
except Exception as e:
101+
self.send_response(502)
102+
self.end_headers()
103+
self.wfile.write(str(e).encode())
104+
105+
def log_message(self, format, *args):
106+
# Suppress request logging to keep container logs clean
107+
pass
108+
109+
110+
def main():
111+
parser = argparse.ArgumentParser()
112+
parser.add_argument("--port", type=int, default=18973)
113+
args = parser.parse_args()
114+
115+
server = HTTPServer(("127.0.0.1", args.port), ProxyHandler)
116+
port = server.server_address[1]
117+
118+
# Write port for config discovery
119+
with open("/tmp/sg_proxy_port", "w") as f:
120+
f.write(str(port))
121+
122+
print(f"SG auth proxy listening on 127.0.0.1:{port} -> {SG_URL}", flush=True)
123+
server.serve_forever()
124+
125+
126+
if __name__ == "__main__":
127+
main()

0 commit comments

Comments
 (0)