Skip to content

Commit bdfba24

Browse files
committed
Add MCP HTTP client, A2A federation acceptance tests, and agent card refinements
New MCPHTTPClient for Streamable HTTP MCP transport with bearer auth, session lifecycle, tools/list, tools/call, and session close New acceptance tests for MCP client auth/session flow and A2A federation discovery/routing/delegation Refactor _make_a2a_handler to accept dict, inject agent name into delegation responses, auto-populate endpoint in discovery server Export MCPClientError/MCPHTTPClient from package; update acceptance docs
1 parent 0bb81d7 commit bdfba24

6 files changed

Lines changed: 239 additions & 7 deletions

File tree

docs/acceptance.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ TeaAgent acceptance tests live under `tests/acceptance/` and verify user-facing
88
- CLI prompt approval workflow: destructive call pauses, `agent resume` auto-approves the pending call id, writes the file, and reports audit summary.
99
- Daily TUI workflow: chat mode, memory injection, progress streaming, agent answer persistence in session history.
1010
- TUI prompt approval workflow: approval prompt, destructive write, final run payload, and audit summary.
11+
- MCP client compatibility flow: bearer auth, session lifecycle, `tools/list`, `tools/call`, and session close.
12+
- A2A federation flow: remote discovery, partial endpoint failure, capability routing, delegation, context forwarding, and agent trace metadata.
1113

1214
## Next User Stories
1315

1416
- Live provider conformance gated by environment variables.
15-
- MCP client compatibility flow with auth, session lifecycle, `tools/list`, and `tools/call`.
16-
- A2A federation flow with remote discovery, capability routing, delegation, failure fallback, and audit correlation.
1717
- Managed runtime flow that forwards task context/tools and records managed runtime audit events.
1818
- Long-running worker flow covering `ultrawork start`, heartbeat/status, logs, and stop.
1919
- Workspace edit flow covering hash reads, patch application, git status, test execution, and final diff summary.

teaagent/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
ModelConformanceResult,
6565
run_model_conformance,
6666
)
67+
from teaagent.mcp_client import MCPClientError, MCPHTTPClient
6768
from teaagent.mcp_http import build_mcp_http_server, serve_mcp_http
6869
from teaagent.mcp_server import handle_mcp_request, serve_mcp_stdio
6970
from teaagent.memory import MemoryCatalog, MemoryEntry
@@ -182,6 +183,8 @@
182183
'LLMRequest',
183184
'LLMResponse',
184185
'LLMResponseFormatError',
186+
'MCPClientError',
187+
'MCPHTTPClient',
185188
'MemoryCatalog',
186189
'MemoryEntry',
187190
'MetricSnapshot',

teaagent/agentcard.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -206,13 +206,13 @@ def dispatch_by_name(
206206

207207

208208
def _make_a2a_handler(
209-
card_json: str,
209+
card_data: dict[str, Any],
210210
task_handler: Optional[Callable[[str, dict[str, Any]], str]],
211211
) -> type:
212212
class _Handler(http.server.BaseHTTPRequestHandler):
213213
def do_GET(self) -> None:
214214
if self.path == '/.well-known/agent.json':
215-
body = card_json.encode('utf-8')
215+
body = json.dumps(card_data).encode('utf-8')
216216
self.send_response(200)
217217
self.send_header('Connection', 'close')
218218
self.send_header('Content-Type', 'application/json')
@@ -233,7 +233,9 @@ def do_POST(self) -> None:
233233
output = task_handler(
234234
req_data.get('task', ''), req_data.get('context') or {}
235235
)
236-
resp_body = json.dumps({'output': output}).encode('utf-8')
236+
resp_body = json.dumps(
237+
{'agent_name': card_data.get('name', ''), 'output': output}
238+
).encode('utf-8')
237239
self.send_response(200)
238240
self.send_header('Connection', 'close')
239241
self.send_header('Content-Type', 'application/json')
@@ -296,9 +298,11 @@ def base_url(self) -> str:
296298
return f'http://{self._host}:{self.port}'
297299

298300
def start(self) -> None:
299-
card_json = json.dumps(self._card.to_dict())
300-
handler_cls = _make_a2a_handler(card_json, self._task_handler)
301+
card_data = self._card.to_dict()
302+
handler_cls = _make_a2a_handler(card_data, self._task_handler)
301303
self._server = http.server.HTTPServer((self._host, self._port), handler_cls)
304+
if not card_data.get('endpoint'):
305+
card_data['endpoint'] = self.base_url
302306
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
303307
self._thread.start()
304308

teaagent/mcp_client.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from __future__ import annotations
2+
3+
import http.client
4+
import json
5+
from typing import Any, Optional
6+
from urllib.parse import urlparse
7+
8+
from teaagent.mcp_http import MCP_PATH, SESSION_HEADER
9+
10+
11+
class MCPClientError(RuntimeError):
12+
pass
13+
14+
15+
class MCPHTTPClient:
16+
"""Small stdlib client for TeaAgent's Streamable HTTP MCP transport."""
17+
18+
def __init__(self, endpoint: str, *, auth_token: Optional[str] = None) -> None:
19+
parsed = urlparse(endpoint)
20+
if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
21+
raise ValueError('endpoint must be an http(s) URL')
22+
self.endpoint = endpoint.rstrip('/')
23+
self.auth_token = auth_token
24+
self.session_id: Optional[str] = None
25+
self._scheme = parsed.scheme
26+
self._host = parsed.hostname
27+
self._port = parsed.port or (443 if parsed.scheme == 'https' else 80)
28+
self._path = parsed.path or MCP_PATH
29+
30+
def initialize(self) -> dict[str, Any]:
31+
payload = self._post({'jsonrpc': '2.0', 'id': 1, 'method': 'initialize'})
32+
if self.session_id is None:
33+
raise MCPClientError('initialize response missing session id')
34+
return payload['result']
35+
36+
def list_tools(self) -> list[dict[str, Any]]:
37+
payload = self._post({'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'})
38+
return list(payload['result']['tools'])
39+
40+
def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
41+
payload = self._post(
42+
{
43+
'jsonrpc': '2.0',
44+
'id': 3,
45+
'method': 'tools/call',
46+
'params': {'name': name, 'arguments': arguments},
47+
}
48+
)
49+
result = payload['result']
50+
if result.get('isError'):
51+
content = result.get('content', [])
52+
message = (
53+
content[0].get('text')
54+
if content and isinstance(content[0], dict)
55+
else 'tool call failed'
56+
)
57+
raise MCPClientError(str(message))
58+
return result
59+
60+
def close(self) -> None:
61+
if self.session_id is None:
62+
return
63+
status, _headers, body = self._request('DELETE', None)
64+
self.session_id = None
65+
if status not in {204, 404}:
66+
raise MCPClientError(f'MCP close failed with HTTP {status}: {body!r}')
67+
68+
def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
69+
body = json.dumps(payload).encode('utf-8')
70+
status, headers, data = self._request('POST', body)
71+
if status != 200:
72+
raise MCPClientError(f'MCP request failed with HTTP {status}: {data!r}')
73+
if SESSION_HEADER in headers:
74+
self.session_id = headers[SESSION_HEADER]
75+
try:
76+
response = json.loads(data.decode('utf-8'))
77+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
78+
raise MCPClientError('MCP response was not valid JSON') from exc
79+
if 'error' in response:
80+
error = response['error']
81+
message = error.get('message') if isinstance(error, dict) else str(error)
82+
raise MCPClientError(str(message))
83+
return response
84+
85+
def _request(
86+
self, method: str, body: Optional[bytes]
87+
) -> tuple[int, dict[str, str], bytes]:
88+
connection_cls = (
89+
http.client.HTTPSConnection
90+
if self._scheme == 'https'
91+
else http.client.HTTPConnection
92+
)
93+
headers = self._headers(body)
94+
conn = connection_cls(self._host, self._port, timeout=10)
95+
try:
96+
conn.request(method, self._path, body=body, headers=headers)
97+
response = conn.getresponse()
98+
return response.status, dict(response.getheaders()), response.read()
99+
finally:
100+
conn.close()
101+
102+
def _headers(self, body: Optional[bytes]) -> dict[str, str]:
103+
headers: dict[str, str] = {}
104+
if body is not None:
105+
headers['Content-Type'] = 'application/json'
106+
headers['Content-Length'] = str(len(body))
107+
if self.auth_token is not None:
108+
headers['Authorization'] = f'Bearer {self.auth_token}'
109+
if self.session_id is not None:
110+
headers[SESSION_HEADER] = self.session_id
111+
return headers
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
import unittest
4+
5+
from teaagent.agentcard import (
6+
A2AClient,
7+
A2ADiscoveryServer,
8+
A2ADispatcher,
9+
AgentCard,
10+
FederatedAgentRegistry,
11+
)
12+
13+
14+
class A2AFederationFlowAcceptanceTests(unittest.TestCase):
15+
def test_federated_discovery_routes_by_capability_and_delegates(self) -> None:
16+
calls: list[tuple[str, dict]] = []
17+
18+
def handler(task: str, context: dict) -> str:
19+
calls.append((task, context))
20+
return f'searched:{context["query"]}'
21+
22+
card = AgentCard(
23+
name='search-agent',
24+
version='1.0.0',
25+
description='Search specialist',
26+
capabilities=frozenset({'search'}),
27+
tools=('workspace_search_text',),
28+
)
29+
with A2ADiscoveryServer(card, port=0, task_handler=handler) as server:
30+
base_url = server.base_url
31+
registry = FederatedAgentRegistry(
32+
['http://127.0.0.1:1', base_url], ttl_seconds=60, timeout=1
33+
)
34+
errors = registry.refresh()
35+
dispatcher = A2ADispatcher(registry)
36+
37+
result = dispatcher.dispatch_by_capability(
38+
'find docs',
39+
'search',
40+
runner=lambda task, routed_card: (
41+
A2AClient.from_card(routed_card, timeout=5)
42+
.delegate(task, context={'query': 'docs'})
43+
.output
44+
),
45+
)
46+
fetched = registry.get('search-agent')
47+
48+
self.assertEqual(len(errors), 1)
49+
self.assertIsNotNone(fetched)
50+
assert fetched is not None
51+
self.assertEqual(fetched.endpoint, base_url)
52+
self.assertEqual(result.agent_name, 'search-agent')
53+
self.assertEqual(result.routed_by_capability, 'search')
54+
self.assertEqual(result.output, 'searched:docs')
55+
self.assertEqual(calls, [('find docs', {'query': 'docs'})])
56+
57+
58+
if __name__ == '__main__':
59+
unittest.main()
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from __future__ import annotations
2+
3+
import tempfile
4+
import threading
5+
import unittest
6+
from pathlib import Path
7+
8+
from teaagent.mcp_client import MCPClientError, MCPHTTPClient
9+
from teaagent.mcp_http import build_mcp_http_server
10+
from teaagent.workspace_tools import build_workspace_tool_registry
11+
12+
13+
class MCPClientFlowAcceptanceTests(unittest.TestCase):
14+
def test_mcp_client_auth_session_list_call_and_close_flow(self) -> None:
15+
with tempfile.TemporaryDirectory() as tmp:
16+
root = Path(tmp)
17+
(root / 'hello.txt').write_text('hello mcp', encoding='utf-8')
18+
server, sessions = build_mcp_http_server(
19+
build_workspace_tool_registry(root),
20+
host='127.0.0.1',
21+
port=0,
22+
auth_token='secret-token',
23+
)
24+
thread = threading.Thread(target=server.serve_forever, daemon=True)
25+
thread.start()
26+
host, port = server.server_address[:2]
27+
try:
28+
unauthenticated = MCPHTTPClient(f'http://{host}:{port}/mcp')
29+
with self.assertRaises(MCPClientError):
30+
unauthenticated.initialize()
31+
32+
client = MCPHTTPClient(
33+
f'http://{host}:{port}/mcp', auth_token='secret-token'
34+
)
35+
server_info = client.initialize()['serverInfo']
36+
tools = client.list_tools()
37+
result = client.call_tool('workspace_read_file', {'path': 'hello.txt'})
38+
session_id = client.session_id
39+
client.close()
40+
finally:
41+
server.shutdown()
42+
server.server_close()
43+
thread.join(timeout=2)
44+
45+
self.assertEqual(server_info['name'], 'teaagent')
46+
self.assertIn('workspace_read_file', {tool['name'] for tool in tools})
47+
self.assertFalse(result['isError'])
48+
self.assertIn('hello mcp', result['content'][0]['text'])
49+
self.assertIsNotNone(session_id)
50+
assert session_id is not None
51+
self.assertFalse(sessions.has(session_id))
52+
53+
54+
if __name__ == '__main__':
55+
unittest.main()

0 commit comments

Comments
 (0)