Skip to content

Commit da61504

Browse files
committed
Harden MCP HTTP non-loopback binding and add expected-content check to conformance
- Refuse to serve MCP HTTP on non-loopback hosts without --auth-token or OAuth configured - Add --expect flag to model conformance; fail provider if response content does not match expected value - Replace bare try/except and isinstance-or chains with contextlib.suppress and isinstance(..., (list, tuple)) across code_mode, oauth21, mcp_http
1 parent bdcec05 commit da61504

14 files changed

Lines changed: 267 additions & 146 deletions

docs/p2-scope.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
33
## Included
44

55
- In-memory Graph RAG primitives for entity/relation traversal and document retrieval.
6-
- Restricted Code Mode for deterministic local data manipulation with AST allow-list validation.
6+
- Restricted Code Mode for deterministic local data manipulation with AST allow-list validation and child-process timeout/resource guardrails.
77
- Stateless MCP request/response envelopes that carry capabilities and shared state per request.
8-
- Streamable HTTP transport for the MCP server with `Mcp-Session-Id` sessions, optional bearer-token auth, and Origin allowlist (POST/GET/DELETE on `/mcp`).
8+
- Streamable HTTP transport for the MCP server with `Mcp-Session-Id` sessions, bearer-token/OAuth guardrails for non-loopback binds, and Origin allowlist (POST/GET/DELETE on `/mcp`).
99
- Managed-agent readiness checks for tool metadata, audit, budget, external state, and HITL gaps.
1010
- Provider portability checks for tool calling, structured output, system prompt support, prompt caching, and context limits.
11+
- Cross-provider live model conformance smoke checks with per-provider pass/fail/skip reporting.
1112

1213
## Still Deferred
1314

1415
- Production GraphQLite deployment, migrations, and Cypher query tuning.
15-
- Production sandboxing for Code Mode using containers, V8 isolates, or a managed execution service.
16-
- OAuth 2.1 / DPoP enforcement on top of the Streamable HTTP transport.
16+
- Strong production sandboxing for Code Mode using containers, V8 isolates, or a managed execution service.
17+
- Production hardening for OAuth 2.1 / DPoP deployments, including key rotation and external client storage.
1718
- Actual managed runtime integration with Anthropic, OpenAI, Google ADK, or Vertex Agent Engine.
18-
- Cross-provider live conformance tests.
19+
- Deeper cross-provider conformance for streaming, structured output, tool calling, and provider-specific safety blocks.
1920

2021
## P2 Extension Rules
2122

setup.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,21 @@
33
from setuptools import find_packages, setup
44

55
GRAPHQLITE_DEPS = ['graphqlite>=0.4.4', 'pysqlite3>=0.6.0']
6-
DEV_DEPS = [*GRAPHQLITE_DEPS, 'pytest>=7', 'pytest-cov>=4', 'ruff>=0.4', 'mypy>=1']
6+
OAUTH_DEPS = ['cryptography>=3.4']
7+
TELEMETRY_DEPS = [
8+
'opentelemetry-api>=1.20',
9+
'opentelemetry-sdk>=1.20',
10+
'opentelemetry-exporter-otlp-proto-http>=1.20',
11+
]
12+
DEV_DEPS = [
13+
*GRAPHQLITE_DEPS,
14+
*OAUTH_DEPS,
15+
*TELEMETRY_DEPS,
16+
'pytest>=7',
17+
'pytest-cov>=4',
18+
'ruff>=0.4',
19+
'mypy>=1',
20+
]
721

822

923
setup(
@@ -13,6 +27,11 @@
1327
packages=find_packages(),
1428
python_requires='>=3.9',
1529
install_requires=[],
16-
extras_require={'graphqlite': GRAPHQLITE_DEPS, 'dev': DEV_DEPS},
30+
extras_require={
31+
'graphqlite': GRAPHQLITE_DEPS,
32+
'oauth': OAUTH_DEPS,
33+
'telemetry': TELEMETRY_DEPS,
34+
'dev': DEV_DEPS,
35+
},
1736
entry_points={'console_scripts': ['teaagent=teaagent.cli:main']},
1837
)

teaagent/cli.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import argparse
4+
import ipaddress
45
import json
56
import sys
67
from typing import Any, Optional
@@ -35,6 +36,17 @@
3536
from teaagent.workspace_tools import build_workspace_tool_registry
3637

3738

39+
def is_loopback_host(host: str) -> bool:
40+
if host == 'localhost':
41+
return True
42+
if host in {'', '0.0.0.0', '::'}:
43+
return False
44+
try:
45+
return ipaddress.ip_address(host).is_loopback
46+
except ValueError:
47+
return False
48+
49+
3850
def main(argv: Optional[list[str]] = None) -> int:
3951
parser = build_parser()
4052
args = parser.parse_args(argv)
@@ -405,6 +417,11 @@ def build_parser() -> argparse.ArgumentParser:
405417
conformance_model.add_argument(
406418
'--prompt', default='Reply with exactly: ok', help='Prompt to send.'
407419
)
420+
conformance_model.add_argument(
421+
'--expect',
422+
default='ok',
423+
help='Exact response content required for pass. Use an empty string to only require non-empty output.',
424+
)
408425
conformance_model.add_argument(
409426
'--max-tokens', type=int, default=32, help='Maximum output tokens.'
410427
)
@@ -681,7 +698,9 @@ def _execute_agent_task(
681698

682699
# --- OpenTelemetry wiring ---
683700
_telemetry_sink = None
684-
if getattr(args, 'telemetry_otlp_endpoint', None) or getattr(args, 'telemetry_console', False):
701+
if getattr(args, 'telemetry_otlp_endpoint', None) or getattr(
702+
args, 'telemetry_console', False
703+
):
685704
try:
686705
from teaagent.telemetry import (
687706
TelemetryConfig,
@@ -730,6 +749,7 @@ def _execute_agent_task(
730749
# Shut down OTel to flush any pending spans.
731750
if _telemetry_sink is not None:
732751
from contextlib import suppress
752+
733753
with suppress(Exception):
734754
_telemetry_sink.force_flush()
735755
payload = run_result_payload(result, routing=routing.to_dict() if routing else None)
@@ -850,6 +870,7 @@ def model_conformance(args: argparse.Namespace) -> int:
850870
report = run_model_conformance(
851871
args.provider,
852872
prompt=args.prompt,
873+
expected_content=args.expect if args.expect else None,
853874
max_tokens=args.max_tokens,
854875
model=args.model,
855876
)
@@ -964,6 +985,16 @@ def mcp_serve_command(args: argparse.Namespace) -> int:
964985
file=sys.stderr,
965986
)
966987
return 1
988+
if (
989+
not is_loopback_host(args.host)
990+
and not args.auth_token
991+
and oauth_server is None
992+
):
993+
print(
994+
'Refusing to serve MCP HTTP on a non-loopback host without --auth-token or OAuth.',
995+
file=sys.stderr,
996+
)
997+
return 1
967998
return serve_mcp_http(
968999
registry,
9691000
host=args.host,

teaagent/code_mode.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import ast
44
import multiprocessing
55
import traceback
6+
from contextlib import suppress
67
from dataclasses import dataclass
78
from typing import Any
89

@@ -168,18 +169,16 @@ def _apply_resource_limits(sandbox: CodeModeSandbox) -> None:
168169
soft = sandbox.memory_bytes
169170
if hard != resource.RLIM_INFINITY:
170171
soft = min(soft, hard)
171-
try:
172+
# macOS reports RLIMIT_AS but rejects lowering it; wall/CPU limits still
173+
# keep Code Mode isolated from the parent process.
174+
with suppress(ValueError):
172175
resource.setrlimit(resource.RLIMIT_AS, (soft, hard))
173-
except ValueError:
174-
# macOS reports RLIMIT_AS but rejects lowering it; wall/CPU limits still
175-
# keep Code Mode isolated from the parent process.
176-
pass
177176

178177

179178
def _validate_plain_data(value: Any, label: str) -> None:
180179
if value is None or isinstance(value, (bool, int, float, str)):
181180
return
182-
if isinstance(value, list) or isinstance(value, tuple):
181+
if isinstance(value, (list, tuple)):
183182
for index, item in enumerate(value):
184183
_validate_plain_data(item, f'{label}[{index}]')
185184
return

teaagent/llm_conformance.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def run_model_conformance(
7979
providers: Iterable[str] | None = None,
8080
*,
8181
prompt: str = 'Reply with exactly: ok',
82+
expected_content: str | None = 'ok',
8283
max_tokens: int = 32,
8384
model: str | None = None,
8485
adapter_factory: AdapterFactory = create_llm_adapter,
@@ -89,6 +90,7 @@ def run_model_conformance(
8990
_run_provider_conformance(
9091
provider,
9192
prompt=prompt,
93+
expected_content=expected_content,
9294
max_tokens=max_tokens,
9395
model=model,
9496
adapter_factory=adapter_factory,
@@ -103,6 +105,7 @@ def _run_provider_conformance(
103105
provider: str,
104106
*,
105107
prompt: str,
108+
expected_content: str | None,
106109
max_tokens: int,
107110
model: str | None,
108111
adapter_factory: AdapterFactory,
@@ -137,6 +140,14 @@ def _run_provider_conformance(
137140
model=response.model,
138141
error='provider returned empty content',
139142
)
143+
if expected_content is not None and content != expected_content:
144+
return ModelConformanceResult(
145+
provider=provider,
146+
status='failed',
147+
model=response.model,
148+
content=content,
149+
error=f'provider returned {content!r}; expected {expected_content!r}',
150+
)
140151
return ModelConformanceResult(
141152
provider=provider,
142153
status='passed',

teaagent/mcp_http.py

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,7 @@ def build_mcp_http_server(
6666
) -> tuple[ThreadingHTTPServer, MCPSessionStore]:
6767
sessions = MCPSessionStore()
6868
origins = frozenset(allowed_origins) if allowed_origins else None
69-
handler_cls = _make_handler(
70-
registry, sessions, auth_token, origins, oauth_server
71-
)
69+
handler_cls = _make_handler(registry, sessions, auth_token, origins, oauth_server)
7270
server = ThreadingHTTPServer((host, port), handler_cls)
7371
return server, sessions
7472

@@ -128,17 +126,13 @@ def _check_auth(self) -> bool:
128126
header = self.headers.get(_AUTHORIZATION_HEADER, '')
129127
if not header.startswith('Bearer '):
130128
return False
131-
return secrets.compare_digest(
132-
header[len('Bearer '):], auth_token
133-
)
129+
return secrets.compare_digest(header[len('Bearer ') :], auth_token)
134130

135131
# OAuth 2.1 / DPoP token validation.
136132
if resource_server is not None:
137133
try:
138134
claims = resource_server.validate_request(
139-
authorization_header=self.headers.get(
140-
_AUTHORIZATION_HEADER
141-
),
135+
authorization_header=self.headers.get(_AUTHORIZATION_HEADER),
142136
dpop_header=self.headers.get(_DPOP_HEADER),
143137
method=self.command,
144138
url=self._request_url(),
@@ -189,9 +183,7 @@ def _send_json(
189183
self.end_headers()
190184
self.wfile.write(body)
191185

192-
def _send_status(
193-
self, status: int, message: Optional[str] = None
194-
) -> None:
186+
def _send_status(self, status: int, message: Optional[str] = None) -> None:
195187
body = (message or '').encode('utf-8')
196188
self.send_response(status)
197189
self.send_header('Content-Type', 'text/plain')
@@ -235,8 +227,10 @@ def _build_dpop_error(self) -> Optional[str]:
235227
auth_header = self.headers.get(_AUTHORIZATION_HEADER, '')
236228
dpop_header = self.headers.get(_DPOP_HEADER)
237229
# Issue a nonce if a DPoP token is presented or DPoP header exists.
238-
if (_TOKEN_TYPE_DPOP in auth_header or dpop_header is not None) and oauth_server is not None:
239-
return oauth_server.generate_dpop_nonce()
230+
if (
231+
_TOKEN_TYPE_DPOP in auth_header or dpop_header is not None
232+
) and oauth_server is not None:
233+
return oauth_server.generate_dpop_nonce()
240234
return None
241235

242236
def _read_body(self) -> tuple[Optional[Any], Optional[str]]:
@@ -276,7 +270,9 @@ def _handle_oauth_authorize(self) -> None:
276270
client_id = _first_param(params, 'client_id')
277271
redirect_uri = _first_param(params, 'redirect_uri')
278272
code_challenge = _first_param(params, 'code_challenge')
279-
code_challenge_method = _first_param(params, 'code_challenge_method') or 'S256'
273+
code_challenge_method = (
274+
_first_param(params, 'code_challenge_method') or 'S256'
275+
)
280276
scope = _first_param(params, 'scope') or 'mcp'
281277
state = _first_param(params, 'state')
282278

@@ -330,7 +326,10 @@ def _handle_oauth_token(self) -> None:
330326
except UnicodeDecodeError:
331327
self._send_json(
332328
400,
333-
{'error': 'invalid_request', 'error_description': 'invalid encoding'},
329+
{
330+
'error': 'invalid_request',
331+
'error_description': 'invalid encoding',
332+
},
334333
)
335334
return
336335
params = parse_qs(body)
@@ -354,7 +353,10 @@ def _handle_oauth_token(self) -> None:
354353
if not code:
355354
self._send_json(
356355
400,
357-
{'error': 'invalid_request', 'error_description': 'code is required'},
356+
{
357+
'error': 'invalid_request',
358+
'error_description': 'code is required',
359+
},
358360
)
359361
return
360362
if not code_verifier:
@@ -526,9 +528,7 @@ def _add_dpop_nonce(self, extra: dict[str, str]) -> None:
526528

527529
def _add_dpop_nonce_to_response(self) -> None:
528530
if oauth_server is not None:
529-
self.send_header(
530-
_DPOP_NONCE_HEADER, oauth_server.generate_dpop_nonce()
531-
)
531+
self.send_header(_DPOP_NONCE_HEADER, oauth_server.generate_dpop_nonce())
532532

533533
return MCPHandler
534534

0 commit comments

Comments
 (0)