lazyBoy/tests/mcp-http.test.py

129 lines
5.5 KiB
Python

"""Real MCP SDK over loopback HTTP inside a disposable Computer."""
import json
import os
from pathlib import Path
import subprocess
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
TOKEN = 'runner-mcp-secret-canary'
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, *_args):
pass
def reply(self, status, body=None):
data = json.dumps(body).encode() if body is not None else b''
self.send_response(status)
self.send_header('Mcp-Session-Id', 'fixture-session')
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
self.reply(405)
def do_DELETE(self):
self.reply(204)
def do_POST(self):
request = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
if self.headers.get('Authorization') != 'Bearer ' + TOKEN:
self.reply(401, {'error': TOKEN})
return
if 'id' not in request:
self.reply(202)
return
method = request['method']
if method == 'initialize':
result = {'protocolVersion': request['params']['protocolVersion'],
'capabilities': {'tools': {}}, 'serverInfo': {'name': 'fixture', 'version': '1'}}
elif method == 'tools/list':
result = {'tools': [{'name': 'echo', 'description': 'Echo Unicode',
'inputSchema': {'type': 'object', 'properties': {'text': {'type': 'string'}}}}]}
elif method == 'tools/call':
args = request['params']['arguments']
self.server.calls += 1
if args.get('expired'):
self.reply(404)
return
if args.get('disconnect'):
self.close_connection = True
return
result = {'content': [{'type': 'text', 'text': 'x' * (5 * 1024 * 1024) if args.get('large') else args.get('text', '')}],
'isError': args.get('fail', False)}
else:
self.reply(200, {'jsonrpc': '2.0', 'id': request['id'], 'error': {'code': -32601, 'message': 'unknown'}})
return
self.reply(200, {'jsonrpc': '2.0', 'id': request['id'], 'result': result})
class ComputerMcp(unittest.TestCase):
@classmethod
def setUpClass(cls):
if os.environ.get('LAZYBOY_RUNNER_TEST_CONTAINER') != '1' or not Path('/.dockerenv').exists():
raise RuntimeError('requires disposable Computer fixture')
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
cls.server.calls = 0
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
cls.thread.join()
def invoke(self, method, arguments=None, authorized=True):
config = {'url': f'http://127.0.0.1:{self.server.server_port}/mcp', 'method': method,
'headers': {'Authorization': 'Bearer ' + TOKEN} if authorized else {},
'name': 'echo', 'arguments': arguments or {}}
request = {'action': 'mcp', 'identity': {'computer_id': 'fixture', 'bot_id': 'mcp-agent', 'generation': 1},
'input': json.dumps(config)}
return subprocess.run(['python3', '-I', '/var/lib/lazyboy-runner/runner_jobs.py'],
input=json.dumps(request), capture_output=True, text=True, timeout=55)
def test_discovery_and_call_execute_in_computer(self):
listed = self.invoke('tools/list')
self.assertEqual(listed.returncode, 0, listed.stdout + listed.stderr)
tools = json.loads(json.loads(listed.stdout)['stdout'])
self.assertEqual(tools[0]['name'], 'echo')
called = self.invoke('tools/call', {'text': '中文 response'})
self.assertEqual(called.returncode, 0, called.stdout + called.stderr)
result = json.loads(json.loads(called.stdout)['stdout'])
self.assertEqual(result['content'][0]['text'], '中文 response')
def test_auth_error_does_not_leak_remote_secret_response(self):
failed = self.invoke('tools/list', authorized=False)
self.assertNotEqual(failed.returncode, 0)
self.assertNotIn(TOKEN, failed.stdout + failed.stderr)
self.assertIn('EFFECT_UNKNOWN', failed.stdout)
def test_session_expiry_and_lost_response_never_replay_mutation(self):
for failure in ('expired', 'disconnect'):
before = self.server.calls
failed = self.invoke('tools/call', {failure: True})
self.assertNotEqual(failed.returncode, 0)
self.assertEqual(self.server.calls - before, 1)
self.assertIn('EFFECT_UNKNOWN', failed.stdout)
def test_oversized_response_is_bounded_and_not_reported_as_success(self):
failed = self.invoke('tools/call', {'large': True})
self.assertNotEqual(failed.returncode, 0)
self.assertLess(len(failed.stdout), 1024)
self.assertIn('EFFECT_UNKNOWN', failed.stdout)
def test_tool_error_is_preserved_for_api_reconciliation(self):
called = self.invoke('tools/call', {'fail': True})
self.assertEqual(called.returncode, 0, called.stdout)
self.assertTrue(json.loads(json.loads(called.stdout)['stdout'])['isError'])
if __name__ == '__main__':
unittest.main()