129 lines
5.5 KiB
Python
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()
|