test_headhaul_document_tools.py 51 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280
  1. import importlib
  2. import json
  3. import os
  4. import unittest
  5. from io import BytesIO
  6. from unittest.mock import patch
  7. import socket
  8. import threading
  9. from http.server import HTTPServer
  10. from app import GatewayApp
  11. from constants import DEVICE_INVALID_MESSAGE
  12. from public_gateway import PublicGatewayApp
  13. from public_server import (
  14. PublicMcpHttpHandler,
  15. create_http_handler,
  16. discard_request_body,
  17. UPLOAD_BODY_LIMIT,
  18. )
  19. from utils.rate_limiter import SimpleRateLimiter
  20. from services.api_client import ApiClient, JsonTransport
  21. from services.headhaul_document_app import (
  22. HeadhaulSlotMemory,
  23. public_upload_url,
  24. )
  25. from services.output_presenter import OutputPresenter
  26. from services.scoped_api_client import ScopedApiClient
  27. from tools.headhaul_document_common import bounded_integer, number_list
  28. from tools.delete_headhaul_document import DeleteHeadhaulDocumentTool
  29. from tools.list_headhaul_document_filter_options import (
  30. ListHeadhaulDocumentFilterOptionsTool,
  31. )
  32. from tools.prepare_headhaul_document_upload import PrepareHeadhaulDocumentUploadTool
  33. from tools.query_headhaul_document_list import QueryHeadhaulDocumentListTool
  34. from tools.save_headhaul_document import SaveHeadhaulDocumentTool
  35. COLUMNS = [
  36. ('document_type_name', '文档类型'),
  37. ('entry_no_text', '录入单号快照'),
  38. ('original_name', '文件名'),
  39. ('safe_url', '安全链接'),
  40. ('operator_name', '操作人'),
  41. ('operate_time', '操作时间'),
  42. ]
  43. class RecordingApiClient:
  44. def __init__(self):
  45. self.calls = []
  46. def list_enabled_tools(self, request_id=''):
  47. return {
  48. 'code': 'MCP_0000',
  49. 'data': {
  50. 'tool_codes': [
  51. 'query_headhaul_document_list',
  52. 'list_headhaul_document_filter_options',
  53. 'prepare_headhaul_document_upload',
  54. 'save_headhaul_document',
  55. 'delete_headhaul_document',
  56. ],
  57. },
  58. }
  59. def call_tool(self, tool_code, route_path, payload, request_id):
  60. self.calls.append((tool_code, route_path, payload, request_id))
  61. return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
  62. class HeadhaulDocumentToolContractTest(unittest.TestCase):
  63. def test_query_schema_forbids_identity_and_mixed_locator(self):
  64. metadata = QueryHeadhaulDocumentListTool().metadata()
  65. schema = metadata['input_schema']
  66. self.assertEqual('query_headhaul_document_list', metadata['name'])
  67. self.assertEqual(
  68. '/mcp/tools/queryHeadhaulDocumentList',
  69. QueryHeadhaulDocumentListTool.route_path,
  70. )
  71. self.assertFalse(schema['additionalProperties'])
  72. self.assertNotIn('required', schema)
  73. for forbidden in ('company_id', 'admin_id', 'is_super', 'target_no'):
  74. self.assertNotIn(forbidden, schema['properties'])
  75. self.assertIn('使用场景:', metadata['description'])
  76. self.assertIn('禁止使用:', metadata['description'])
  77. self.assertIn('admin/document/index', metadata['description'])
  78. def test_query_requires_locator_and_forwards_typed_numbers(self):
  79. client = RecordingApiClient()
  80. with self.assertRaises(ValueError):
  81. QueryHeadhaulDocumentListTool(client).call()
  82. QueryHeadhaulDocumentListTool(client).call(order_numbers=['FMS-1'])
  83. self.assertEqual(
  84. (
  85. 'query_headhaul_document_list',
  86. '/mcp/tools/queryHeadhaulDocumentList',
  87. {
  88. 'order_numbers': ['FMS-1'],
  89. 'page': 1,
  90. 'limit': 20,
  91. },
  92. 'rq_query_headhaul_document_list',
  93. ),
  94. client.calls[0],
  95. )
  96. def test_filter_schema_has_three_types(self):
  97. metadata = ListHeadhaulDocumentFilterOptionsTool().metadata()
  98. self.assertEqual(
  99. ['查询文档类型', '添加文档类型', '录入单号种类'],
  100. metadata['input_schema']['properties']['filter_type']['enum'],
  101. )
  102. self.assertIn('使用场景:', metadata['description'])
  103. self.assertIn('禁止使用:', metadata['description'])
  104. self.assertIn('文档类型名称 - 文件维度', metadata['description'])
  105. self.assertIn('完整显示名', metadata['description'])
  106. def test_prepare_does_not_declare_upload_widget(self):
  107. metadata = PrepareHeadhaulDocumentUploadTool().metadata()
  108. self.assertNotIn('_meta', metadata)
  109. self.assertEqual(['document_type_id'], metadata['input_schema']['required'])
  110. self.assertIn('对话窗口', metadata['description'])
  111. self.assertIn('选文件框', metadata['description'])
  112. client = RecordingApiClient()
  113. PrepareHeadhaulDocumentUploadTool(client).call(3)
  114. self.assertEqual(
  115. {'document_type_id': 3},
  116. client.calls[0][2],
  117. )
  118. with self.assertRaises(ValueError):
  119. PrepareHeadhaulDocumentUploadTool(client).call(True)
  120. def test_save_rejects_ticket_and_link_together(self):
  121. client = RecordingApiClient()
  122. with self.assertRaises(ValueError):
  123. SaveHeadhaulDocumentTool(client).call(
  124. 3,
  125. [{'entry_no_type': 7, 'numbers': ['FMS-1']}],
  126. ticket_ref='mhdt_a',
  127. link_url='https://example.com/a',
  128. )
  129. SaveHeadhaulDocumentTool(client).call(
  130. 3,
  131. [{'entry_no_type': 7, 'numbers': ['FMS-1']}],
  132. link_url='https://example.com/a',
  133. )
  134. self.assertEqual(
  135. 'https://example.com/a',
  136. client.calls[0][2]['link_url'],
  137. )
  138. def test_delete_requires_document_ref(self):
  139. client = RecordingApiClient()
  140. with self.assertRaises(ValueError):
  141. DeleteHeadhaulDocumentTool(client).call(' ')
  142. DeleteHeadhaulDocumentTool(client).call('mhdd_opaque')
  143. self.assertEqual({'document_ref': 'mhdd_opaque'}, client.calls[0][2])
  144. def test_local_and_public_registries_include_five_tools(self):
  145. local = GatewayApp().registered_tool_names()
  146. public = PublicGatewayApp(None, None).registered_tool_names()
  147. self.assertEqual(local, public)
  148. self.assertEqual(39, len(local))
  149. for name in (
  150. 'query_headhaul_document_list',
  151. 'list_headhaul_document_filter_options',
  152. 'prepare_headhaul_document_upload',
  153. 'save_headhaul_document',
  154. 'delete_headhaul_document',
  155. ):
  156. self.assertIn(name, local)
  157. self.assertEqual(38, len(OutputPresenter.SAFE_TOOLS))
  158. def test_presenter_list_keeps_six_columns_and_document_ref(self):
  159. presenter = OutputPresenter()
  160. columns = [{'key': key, 'name': name} for key, name in COLUMNS]
  161. result = presenter.present('query_headhaul_document_list', {
  162. 'code': 'MCP_0000',
  163. 'data': {
  164. 'columns': columns,
  165. 'records': [{
  166. 'document_type_name': '报关资料',
  167. 'entry_no_text': 'FMS-1',
  168. 'original_name': 'a.pdf',
  169. 'safe_url': 'https://files.example/a.pdf',
  170. 'operator_name': '测试员',
  171. 'operate_time': '2026-09-18 01:00:00',
  172. 'document_ref': 'mhdd_abc',
  173. }],
  174. },
  175. 'meta': {
  176. 'page': 1, 'limit': 20, 'has_more': False,
  177. 'request_id': 'rq_doc',
  178. },
  179. })
  180. self.assertFalse(result['is_error'])
  181. self.assertEqual('文档身份', result['structured_content']['headers'][-1]['label'])
  182. self.assertEqual('mhdd_abc', result['structured_content']['rows'][0][-1])
  183. bad = dict(result)
  184. extra = {
  185. 'code': 'MCP_0000',
  186. 'data': {
  187. 'columns': columns,
  188. 'records': [{
  189. 'document_type_name': '报关资料',
  190. 'entry_no_text': 'FMS-1',
  191. 'original_name': 'a.pdf',
  192. 'safe_url': 'javascript:alert(1)',
  193. 'operator_name': '测试员',
  194. 'operate_time': '2026-09-18 01:00:00',
  195. 'document_ref': 'mhdd_abc',
  196. }],
  197. },
  198. 'meta': {
  199. 'page': 1, 'limit': 20, 'has_more': False,
  200. 'request_id': 'rq_doc',
  201. },
  202. }
  203. self.assertTrue(presenter.present('query_headhaul_document_list', extra)['is_error'])
  204. def test_presenter_prepare_save_delete(self):
  205. presenter = OutputPresenter()
  206. prepare = presenter.present('prepare_headhaul_document_upload', {
  207. 'code': 'MCP_0000',
  208. 'data': {
  209. 'upload_token': 'tok',
  210. 'upload_url': '/mcp/upload-headhaul-document',
  211. 'expires_in': 1800,
  212. },
  213. 'meta': {'request_id': 'rq_p'},
  214. })
  215. self.assertFalse(prepare['is_error'])
  216. self.assertIn('对话窗口', prepare['text'])
  217. self.assertIn('选文件框', prepare['text'])
  218. self.assertNotIn('app_resource', prepare)
  219. self.assertNotIn('ui', prepare['meta'])
  220. from mcp_protocol import McpProtocolHandler
  221. embedded = McpProtocolHandler._presented_response('1', prepare)
  222. self.assertEqual(1, len(embedded['result']['content']))
  223. self.assertEqual('text', embedded['result']['content'][0]['type'])
  224. self.assertEqual('tok', prepare['structured_content']['upload_token'])
  225. self.assertEqual(
  226. public_upload_url(),
  227. prepare['structured_content']['upload_url'],
  228. )
  229. self.assertEqual('tok', prepare['meta']['upload_token'])
  230. self.assertEqual('rq_p', prepare['meta']['request_id'])
  231. with patch.dict(os.environ, {'FMS_GATEWAY_PUBLIC_BASE': 'http://gateway.test'}):
  232. linked = presenter.present('prepare_headhaul_document_upload', {
  233. 'code': 'MCP_0000',
  234. 'data': {
  235. 'upload_token': 'tok',
  236. 'upload_url': '/mcp/upload-headhaul-document',
  237. 'expires_in': 1800,
  238. },
  239. 'meta': {'request_id': 'rq_p'},
  240. })
  241. self.assertEqual(
  242. 'http://gateway.test/mcp/upload-headhaul-document',
  243. linked['structured_content']['upload_url'],
  244. )
  245. self.assertIn(
  246. 'http://gateway.test/mcp/upload-headhaul-document',
  247. linked['text'],
  248. )
  249. save = presenter.present('save_headhaul_document', {
  250. 'code': 'MCP_0000',
  251. 'data': {'document_ref': 'mhdd_x'},
  252. 'meta': {'request_id': 'rq_s'},
  253. })
  254. self.assertEqual('已保存', save['structured_content']['结果'])
  255. delete = presenter.present('delete_headhaul_document', {
  256. 'code': 'MCP_0000',
  257. 'data': {'result': '已删除'},
  258. 'meta': {'request_id': 'rq_d'},
  259. })
  260. self.assertFalse(delete['is_error'])
  261. self.assertTrue(presenter.present('save_headhaul_document', {
  262. 'code': 'MCP_0000',
  263. 'data': {'document_ref': 'id-1'},
  264. 'meta': {'request_id': 'rq_bad'},
  265. })['is_error'])
  266. def test_slot_memory_and_public_upload_url(self):
  267. memory = HeadhaulSlotMemory()
  268. memory.remember('', 'x')
  269. memory.remember('s', '')
  270. memory.remember('s', 'a')
  271. memory.remember('s', 'b')
  272. self.assertEqual('', memory.session_for_token('a'))
  273. self.assertEqual('s', memory.session_for_token('b'))
  274. self.assertEqual('b', memory.token_for_session('s'))
  275. with patch.dict(os.environ, {'FMS_GATEWAY_PUBLIC_BASE': 'https://mcp.example.com/'}):
  276. self.assertEqual(
  277. 'https://mcp.example.com/mcp/upload-headhaul-document',
  278. public_upload_url(),
  279. )
  280. with patch.dict(os.environ, {'FMS_GATEWAY_PUBLIC_BASE': ''}):
  281. self.assertEqual('/mcp/upload-headhaul-document', public_upload_url())
  282. def test_upload_http_accepts_remembered_token_without_cookie(self):
  283. class Gateway:
  284. def __init__(self):
  285. self.calls = []
  286. def registered_tool_names(self):
  287. return ('prepare_headhaul_document_upload',)
  288. def session_for_headhaul_token(self, token):
  289. return 'GWS_test' if token == 'slot-token' else ''
  290. def upload_headhaul_document(self, *args, **kwargs):
  291. self.calls.append((args, kwargs))
  292. return {
  293. 'code': 'MCP_0000',
  294. 'data': {'ticket_ref': 'mhdt_x'},
  295. 'meta': {},
  296. }
  297. class Parser:
  298. def parse(self, headers):
  299. return type('C', (), {
  300. 'has_session': lambda self: False,
  301. 'gateway_session_id': '',
  302. })()
  303. gateway = Gateway()
  304. handler = PublicMcpHttpHandler(gateway, context_parser=Parser())
  305. body = (
  306. b'------Bound\r\n'
  307. b'Content-Disposition: form-data; name="upload_token"\r\n\r\n'
  308. b'slot-token\r\n'
  309. b'------Bound\r\n'
  310. b'Content-Disposition: form-data; name="file"; filename="a.pdf"\r\n'
  311. b'Content-Type: application/pdf\r\n\r\n'
  312. b'%PDF\r\n'
  313. b'------Bound--\r\n'
  314. )
  315. status, payload = handler.handle_upload(
  316. {'Content-Type': 'multipart/form-data; boundary=----Bound'},
  317. body,
  318. trace_request_id='rq_slot',
  319. )
  320. self.assertEqual(200, status)
  321. self.assertEqual('MCP_0000', payload['code'])
  322. self.assertEqual('GWS_test', gateway.calls[0][0][0])
  323. def test_public_gateway_prepare_remembers_upload_token(self):
  324. class Store:
  325. def get(self, gateway_session_id):
  326. return {
  327. 'mcp_token': 'MT_test',
  328. 'admin_id': 1,
  329. 'company_id': 1,
  330. } if gateway_session_id == 'GWS_test' else None
  331. def touch_session(self, gateway_session_id):
  332. return self.get(gateway_session_id)
  333. class Client:
  334. def list_enabled_tools(self, token='', request_id=''):
  335. return {
  336. 'code': 'MCP_0000',
  337. 'data': {
  338. 'tool_codes': ['prepare_headhaul_document_upload'],
  339. },
  340. }
  341. def call_tool(self, **kwargs):
  342. return {
  343. 'code': 'MCP_0000',
  344. 'data': {
  345. 'upload_token': 'prepared-token',
  346. 'upload_url': '/mcp/upload-headhaul-document',
  347. 'expires_in': 1800,
  348. },
  349. 'meta': {'request_id': 'rq_p'},
  350. }
  351. app = PublicGatewayApp(Store(), Client())
  352. app.call_tool('GWS_test', 'prepare_headhaul_document_upload', {'document_type_id': 3})
  353. self.assertEqual('GWS_test', app.session_for_headhaul_token('prepared-token'))
  354. class EmptyDataClient:
  355. def list_enabled_tools(self, token='', request_id=''):
  356. return {
  357. 'code': 'MCP_0000',
  358. 'data': {
  359. 'tool_codes': ['prepare_headhaul_document_upload'],
  360. },
  361. }
  362. def call_tool(self, **kwargs):
  363. return {'code': 'MCP_0000', 'data': []}
  364. empty_app = PublicGatewayApp(Store(), EmptyDataClient())
  365. empty_app.call_tool(
  366. 'GWS_test',
  367. 'prepare_headhaul_document_upload',
  368. {'document_type_id': 3},
  369. )
  370. self.assertEqual('', empty_app.session_for_headhaul_token('prepared-token'))
  371. class BlankTokenClient(EmptyDataClient):
  372. def call_tool(self, **kwargs):
  373. return {
  374. 'code': 'MCP_0000',
  375. 'data': {'upload_token': ' '},
  376. }
  377. blank_app = PublicGatewayApp(Store(), BlankTokenClient())
  378. blank_app.call_tool(
  379. 'GWS_test',
  380. 'prepare_headhaul_document_upload',
  381. {'document_type_id': 3},
  382. )
  383. self.assertEqual('', blank_app.session_for_headhaul_token(' '))
  384. def test_local_gateway_remembers_prepare_slot(self):
  385. class Client(RecordingApiClient):
  386. def list_enabled_tools(self, request_id=''):
  387. return {
  388. 'code': 'MCP_0000',
  389. 'data': {
  390. 'tool_codes': ['prepare_headhaul_document_upload'],
  391. },
  392. }
  393. def call_tool(self, tool_code, route_path, payload, request_id):
  394. super().call_tool(tool_code, route_path, payload, request_id)
  395. return {
  396. 'code': 'MCP_0000',
  397. 'data': {'upload_token': 'local-tok'},
  398. }
  399. app = GatewayApp(api_client=Client(), token_store=None)
  400. app.call_tool('prepare_headhaul_document_upload', {'document_type_id': 3})
  401. self.assertEqual('local', app.session_for_headhaul_token('local-tok'))
  402. class EmptyClient(RecordingApiClient):
  403. def list_enabled_tools(self, request_id=''):
  404. return {
  405. 'code': 'MCP_0000',
  406. 'data': {
  407. 'tool_codes': ['prepare_headhaul_document_upload'],
  408. },
  409. }
  410. def call_tool(self, tool_code, route_path, payload, request_id):
  411. return {'code': 'MCP_0000', 'data': None}
  412. empty = GatewayApp(api_client=EmptyClient(), token_store=None)
  413. empty.call_tool('prepare_headhaul_document_upload', {'document_type_id': 3})
  414. self.assertEqual('', empty.session_for_headhaul_token('local-tok'))
  415. def test_upload_http_parses_multipart_and_forwards(self):
  416. class Gateway:
  417. def __init__(self):
  418. self.calls = []
  419. def registered_tool_names(self):
  420. return ('prepare_headhaul_document_upload',)
  421. def upload_headhaul_document(self, *args, **kwargs):
  422. self.calls.append((args, kwargs))
  423. return {
  424. 'code': 'MCP_0000',
  425. 'data': {'ticket_ref': 'mhdt_x'},
  426. 'meta': {},
  427. }
  428. class Parser:
  429. def parse(self, headers):
  430. return type('C', (), {
  431. 'has_session': lambda self: True,
  432. 'gateway_session_id': 'GWS_test',
  433. })()
  434. gateway = Gateway()
  435. handler = PublicMcpHttpHandler(gateway, context_parser=Parser())
  436. body = (
  437. b'------Bound\r\n'
  438. b'Content-Disposition: form-data; name="upload_token"\r\n\r\n'
  439. b'tok123\r\n'
  440. b'------Bound\r\n'
  441. b'Content-Disposition: form-data; name="file"; filename="a.pdf"\r\n'
  442. b'Content-Type: application/pdf\r\n\r\n'
  443. b'%PDF\r\n'
  444. b'------Bound--\r\n'
  445. )
  446. status, payload = handler.handle_upload(
  447. {'Content-Type': 'multipart/form-data; boundary=----Bound'},
  448. body,
  449. client_ip='127.0.0.1',
  450. trace_request_id='rq_up',
  451. )
  452. self.assertEqual(200, status)
  453. self.assertEqual('MCP_0000', payload['code'])
  454. self.assertEqual(1, len(gateway.calls))
  455. def test_json_transport_builds_multipart(self):
  456. captured = {}
  457. class Response:
  458. def read(self):
  459. return b'{"code":"MCP_0000"}'
  460. def __enter__(self):
  461. return self
  462. def __exit__(self, exc_type, exc, tb):
  463. return False
  464. def fake_urlopen(request, timeout=0):
  465. captured['url'] = request.full_url
  466. captured['timeout'] = timeout
  467. captured['content_type'] = request.headers.get('Content-type')
  468. captured['data'] = request.data
  469. return Response()
  470. with patch('services.api_client.urllib.request.urlopen', fake_urlopen):
  471. result = JsonTransport().post_multipart(
  472. 'http://tools.example/mcp/tools/uploadHeadhaulDocument',
  473. {'upload_token': 'tok'},
  474. {'file': ('a.pdf', b'%PDF', 'application/pdf')},
  475. {'Authorization': 'Bearer t'},
  476. 120,
  477. )
  478. self.assertEqual({'code': 'MCP_0000'}, result)
  479. self.assertIn('multipart/form-data', captured['content_type'])
  480. self.assertIn(b'tok', captured['data'])
  481. self.assertIn(b'a.pdf', captured['data'])
  482. def test_protocol_resources_and_filter_call(self):
  483. from mcp_protocol import McpProtocolHandler
  484. class App:
  485. def list_resources(self):
  486. from services.headhaul_document_app import resource_list
  487. return resource_list()
  488. def read_resource(self, uri, gateway_session_id=''):
  489. from services.headhaul_document_app import read_resource
  490. return read_resource(uri)
  491. handler = McpProtocolHandler(App())
  492. listed = handler.handle_request({
  493. 'jsonrpc': '2.0',
  494. 'id': 8,
  495. 'method': 'resources/list',
  496. 'params': {},
  497. })
  498. self.assertEqual([], listed['result']['resources'])
  499. missing = handler.handle_request({
  500. 'jsonrpc': '2.0',
  501. 'id': 10,
  502. 'method': 'resources/read',
  503. 'params': {'uri': 'ui://missing'},
  504. })
  505. self.assertEqual(-32602, missing['error']['code'])
  506. class FoundApp:
  507. def read_resource(self, uri, gateway_session_id=''):
  508. return {'uri': uri, 'mimeType': 'text/plain', 'text': 'ok'}
  509. found = McpProtocolHandler(FoundApp()).handle_request({
  510. 'jsonrpc': '2.0',
  511. 'id': 13,
  512. 'method': 'resources/read',
  513. 'params': {'uri': 'ui://example'},
  514. })
  515. self.assertEqual(
  516. [{'uri': 'ui://example', 'mimeType': 'text/plain', 'text': 'ok'}],
  517. found['result']['contents'],
  518. )
  519. client = RecordingApiClient()
  520. ListHeadhaulDocumentFilterOptionsTool(client).call('查询文档类型')
  521. self.assertEqual('查询文档类型', client.calls[0][2]['filter_type'])
  522. with self.assertRaises(RuntimeError):
  523. QueryHeadhaulDocumentListTool().call(order_numbers=['X'])
  524. with self.assertRaises(ValueError):
  525. ListHeadhaulDocumentFilterOptionsTool(client).call('客户名称')
  526. def test_upload_http_rejects_missing_session_and_file(self):
  527. class Parser:
  528. def parse(self, headers):
  529. return type('C', (), {
  530. 'has_session': lambda self: False,
  531. 'gateway_session_id': '',
  532. })()
  533. handler = PublicMcpHttpHandler(
  534. type('G', (), {'registered_tool_names': lambda self: ()})(),
  535. context_parser=Parser(),
  536. )
  537. status, payload = handler.handle_upload({}, b'', trace_request_id='rq_no')
  538. self.assertEqual(401, status)
  539. self.assertEqual('MCP_1101', payload['code'])
  540. class OkParser:
  541. def parse(self, headers):
  542. return type('C', (), {
  543. 'has_session': lambda self: True,
  544. 'gateway_session_id': 'GWS_test',
  545. })()
  546. ok_handler = PublicMcpHttpHandler(
  547. type('G', (), {'registered_tool_names': lambda self: ()})(),
  548. context_parser=OkParser(),
  549. )
  550. status, payload = ok_handler.handle_upload(
  551. {'Content-Type': 'text/plain'},
  552. b'nope',
  553. trace_request_id='rq_bad',
  554. )
  555. self.assertEqual(400, status)
  556. def test_public_gateway_upload_requires_enabled_prepare_tool(self):
  557. class Store:
  558. def get(self, gateway_session_id):
  559. return {'mcp_token': 'MT_test'} if gateway_session_id == 'GWS_test' else None
  560. class Client:
  561. def __init__(self):
  562. self.uploads = []
  563. def list_enabled_tools(self, token, request_id=''):
  564. return {
  565. 'code': 'MCP_0000',
  566. 'data': {'tool_codes': ['prepare_headhaul_document_upload']},
  567. }
  568. def upload_headhaul_document(self, **kwargs):
  569. self.uploads.append(kwargs)
  570. return {'code': 'MCP_0000', 'data': {'ticket_ref': 'mhdt_z'}}
  571. client = Client()
  572. app = PublicGatewayApp(Store(), client)
  573. result = app.upload_headhaul_document(
  574. 'GWS_test', 'tok', 'a.pdf', b'%PDF', 'application/pdf', 'rq_u', '1.1.1.1'
  575. )
  576. self.assertEqual('MCP_0000', result['code'])
  577. self.assertEqual('tok', client.uploads[0]['upload_token'])
  578. self.assertEqual([], GatewayApp().list_resources())
  579. self.assertIsNone(GatewayApp().read_resource('ui://widget/headhaul-document-upload.html'))
  580. self.assertEqual([], PublicGatewayApp(Store(), client).list_resources())
  581. self.assertIsNone(PublicGatewayApp(Store(), client).read_resource('ui://missing'))
  582. with self.assertRaisesRegex(RuntimeError, 'tool disabled'):
  583. PublicGatewayApp(
  584. Store(),
  585. type('C', (), {
  586. 'list_enabled_tools': lambda self, token, request_id='': {
  587. 'code': 'MCP_0000',
  588. 'data': {'tool_codes': []},
  589. },
  590. })(),
  591. ).upload_headhaul_document(
  592. 'GWS_test', 'tok', 'a.pdf', b'%PDF', 'application/pdf', 'rq_u',
  593. )
  594. with self.assertRaisesRegex(RuntimeError, 'upload client unavailable'):
  595. PublicGatewayApp(
  596. Store(),
  597. type('C', (), {
  598. 'list_enabled_tools': lambda self, token, request_id='': {
  599. 'code': 'MCP_0000',
  600. 'data': {
  601. 'tool_codes': ['prepare_headhaul_document_upload'],
  602. },
  603. },
  604. })(),
  605. ).upload_headhaul_document(
  606. 'GWS_test', 'tok', 'a.pdf', b'%PDF', 'application/pdf', 'rq_u',
  607. )
  608. def test_query_and_common_validators_reject_invalid_values(self):
  609. client = RecordingApiClient()
  610. query = QueryHeadhaulDocumentListTool(client)
  611. with self.assertRaises(ValueError):
  612. query.call(document_type_id=True)
  613. with self.assertRaises(ValueError):
  614. query.call(document_type_id=0)
  615. with self.assertRaises(ValueError):
  616. query.call(original_name=' ')
  617. with self.assertRaises(ValueError):
  618. query.call(original_name='x' * 256)
  619. query.call(original_name='a.pdf', document_type_id=3, page=2, limit=10)
  620. with self.assertRaises(ValueError):
  621. query.call(order_numbers=['ok', 1])
  622. with self.assertRaises(ValueError):
  623. query.call(order_numbers=[''])
  624. with self.assertRaises(ValueError):
  625. query.call(order_numbers=['x' * 101])
  626. with self.assertRaises(ValueError):
  627. number_list([], 'order_numbers')
  628. self.assertEqual(['A'], number_list(['A', 'A'], 'order_numbers'))
  629. with self.assertRaises(ValueError):
  630. number_list(['N{0}'.format(i) for i in range(201)], 'order_numbers')
  631. with self.assertRaises(ValueError):
  632. bounded_integer(True, 'page')
  633. with self.assertRaises(ValueError):
  634. bounded_integer(0, 'page')
  635. with self.assertRaises(ValueError):
  636. bounded_integer(101, 'limit')
  637. def test_save_entries_and_payload_branches(self):
  638. with self.assertRaises(RuntimeError):
  639. SaveHeadhaulDocumentTool().call(
  640. 3, [{'entry_no_type': 7, 'numbers': ['FMS-1']}],
  641. link_url='https://example.com/a',
  642. )
  643. client = RecordingApiClient()
  644. tool = SaveHeadhaulDocumentTool(client)
  645. with self.assertRaises(ValueError):
  646. tool.call(0, [{'entry_no_type': 7, 'numbers': ['FMS-1']}],
  647. link_url='https://example.com/a')
  648. with self.assertRaises(ValueError):
  649. tool.call(3, [{'entry_no_type': 7, 'numbers': ['FMS-1']}],
  650. link_url='ftp://example.com/a')
  651. with self.assertRaises(ValueError):
  652. tool.call(3, [], link_url='https://example.com/a')
  653. with self.assertRaises(ValueError):
  654. tool.call(3, ['bad'], link_url='https://example.com/a')
  655. with self.assertRaises(ValueError):
  656. tool.call(3, [{'entry_no_type': True, 'numbers': ['A']}],
  657. link_url='https://example.com/a')
  658. with self.assertRaises(ValueError):
  659. tool.call(3, [{'entry_no_type': 7, 'numbers': []}],
  660. link_url='https://example.com/a')
  661. with self.assertRaises(ValueError):
  662. tool.call(3, [{'entry_no_type': 7, 'numbers': [1]}],
  663. link_url='https://example.com/a')
  664. with self.assertRaises(ValueError):
  665. tool.call(3, [{'entry_no_type': 7, 'numbers': [' ']}],
  666. link_url='https://example.com/a')
  667. tool.call(
  668. 3,
  669. [{'entry_no_type': 7, 'numbers': ['FMS-1', 'FMS-1']}],
  670. ticket_ref=' mhdt_a ',
  671. )
  672. self.assertEqual('mhdt_a', client.calls[-1][2]['ticket_ref'])
  673. self.assertEqual(['FMS-1'], client.calls[-1][2]['entries'][0]['numbers'])
  674. with self.assertRaises(RuntimeError):
  675. PrepareHeadhaulDocumentUploadTool().call(3)
  676. with self.assertRaises(RuntimeError):
  677. DeleteHeadhaulDocumentTool().call('mhdd_x')
  678. with self.assertRaises(RuntimeError):
  679. ListHeadhaulDocumentFilterOptionsTool().call('查询文档类型')
  680. ListHeadhaulDocumentFilterOptionsTool(client).call(
  681. '添加文档类型', keyword='报关',
  682. )
  683. with self.assertRaises(ValueError):
  684. ListHeadhaulDocumentFilterOptionsTool(client).call(
  685. '查询文档类型', keyword='k' * 101,
  686. )
  687. with self.assertRaises(ValueError):
  688. DeleteHeadhaulDocumentTool(client).call('x' * 501)
  689. def test_presenter_fail_closed_on_malformed_headhaul_payloads(self):
  690. presenter = OutputPresenter()
  691. columns = [{'key': key, 'name': name} for key, name in COLUMNS]
  692. meta = {'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq'}
  693. record = {
  694. 'document_type_name': '报关资料',
  695. 'entry_no_text': 'FMS-1',
  696. 'original_name': 'a.pdf',
  697. 'safe_url': '',
  698. 'operator_name': '测试员',
  699. 'operate_time': '2026-09-18 01:00:00',
  700. 'document_ref': 'mhdd_abc',
  701. }
  702. self.assertFalse(presenter.present('query_headhaul_document_list', {
  703. 'code': 'MCP_0000',
  704. 'data': {'columns': columns, 'records': [record]},
  705. 'meta': meta,
  706. })['is_error'])
  707. self.assertTrue(presenter.present('query_headhaul_document_list', {
  708. 'code': 'MCP_0000', 'data': {'columns': columns}, 'meta': meta,
  709. })['is_error'])
  710. self.assertTrue(presenter.present('query_headhaul_document_list', {
  711. 'code': 'MCP_0000',
  712. 'data': {'columns': columns[:1], 'records': []},
  713. 'meta': meta,
  714. })['is_error'])
  715. bad_columns = list(columns)
  716. bad_columns[0] = {'key': 'x', 'name': '文档类型'}
  717. self.assertTrue(presenter.present('query_headhaul_document_list', {
  718. 'code': 'MCP_0000',
  719. 'data': {'columns': bad_columns, 'records': []},
  720. 'meta': meta,
  721. })['is_error'])
  722. self.assertTrue(presenter.present('query_headhaul_document_list', {
  723. 'code': 'MCP_0000',
  724. 'data': {'columns': columns, 'records': ['nope']},
  725. 'meta': meta,
  726. })['is_error'])
  727. missing_ref = dict(record)
  728. missing_ref.pop('document_ref')
  729. self.assertTrue(presenter.present('query_headhaul_document_list', {
  730. 'code': 'MCP_0000',
  731. 'data': {'columns': columns, 'records': [missing_ref]},
  732. 'meta': meta,
  733. })['is_error'])
  734. blank_ref = dict(record)
  735. blank_ref['document_ref'] = ' '
  736. self.assertTrue(presenter.present('query_headhaul_document_list', {
  737. 'code': 'MCP_0000',
  738. 'data': {'columns': columns, 'records': [blank_ref]},
  739. 'meta': meta,
  740. })['is_error'])
  741. not_prefixed = dict(record)
  742. not_prefixed['document_ref'] = 'other'
  743. self.assertTrue(presenter.present('query_headhaul_document_list', {
  744. 'code': 'MCP_0000',
  745. 'data': {'columns': columns, 'records': [not_prefixed]},
  746. 'meta': meta,
  747. })['is_error'])
  748. not_string = dict(record)
  749. not_string['original_name'] = 1
  750. self.assertTrue(presenter.present('query_headhaul_document_list', {
  751. 'code': 'MCP_0000',
  752. 'data': {'columns': columns, 'records': [not_string]},
  753. 'meta': meta,
  754. })['is_error'])
  755. self.assertTrue(presenter.present('query_headhaul_document_list', {
  756. 'code': 'MCP_0000',
  757. 'data': {'columns': columns, 'records': []},
  758. 'meta': {'request_id': 'rq'},
  759. })['is_error'])
  760. self.assertTrue(presenter.present('prepare_headhaul_document_upload', {
  761. 'code': 'MCP_0000',
  762. 'data': {'upload_token': 'tok'},
  763. 'meta': {'request_id': 'rq'},
  764. })['is_error'])
  765. self.assertTrue(presenter.present('prepare_headhaul_document_upload', {
  766. 'code': 'MCP_0000',
  767. 'data': {
  768. 'upload_token': 'tok',
  769. 'upload_url': '/other',
  770. 'expires_in': 1800,
  771. },
  772. 'meta': {'request_id': 'rq'},
  773. })['is_error'])
  774. self.assertTrue(presenter.present('prepare_headhaul_document_upload', {
  775. 'code': 'MCP_0000',
  776. 'data': {
  777. 'upload_token': 'tok',
  778. 'upload_url': '/mcp/upload-headhaul-document',
  779. 'expires_in': 1800,
  780. 'resource_uri': 'ui://widget/headhaul-document-upload.html',
  781. },
  782. 'meta': {'request_id': 'rq'},
  783. })['is_error'])
  784. self.assertTrue(presenter.present('save_headhaul_document', {
  785. 'code': 'MCP_0000',
  786. 'data': {'document_ref': 'mhdd_x', 'extra': 1},
  787. 'meta': {'request_id': 'rq'},
  788. })['is_error'])
  789. self.assertTrue(presenter.present('delete_headhaul_document', {
  790. 'code': 'MCP_0000',
  791. 'data': {'result': '已删除', 'extra': 1},
  792. 'meta': {'request_id': 'rq'},
  793. })['is_error'])
  794. self.assertTrue(presenter.present('delete_headhaul_document', {
  795. 'code': 'MCP_0000',
  796. 'data': {'result': 'ok'},
  797. 'meta': {'request_id': 'rq'},
  798. })['is_error'])
  799. def test_upload_clients_and_http_error_paths(self):
  800. transport = type('T', (), {
  801. 'calls': [],
  802. 'post_multipart': lambda self, *args: self.calls.append(args) or {'code': 'MCP_0000'},
  803. })()
  804. api = ApiClient(
  805. 'http://tools.example',
  806. type('S', (), {'require_token': lambda self: 'MT_x'})(),
  807. transport=transport,
  808. timeout=5,
  809. )
  810. api.upload_headhaul_document('tok', 'a.pdf', b'%PDF', '', 'rq_a')
  811. self.assertEqual(
  812. 'http://tools.example/mcp/tools/uploadHeadhaulDocument',
  813. transport.calls[0][0],
  814. )
  815. scoped = ScopedApiClient('http://tools.example/', transport=transport, timeout=3)
  816. with self.assertRaises(RuntimeError):
  817. scoped.upload_headhaul_document('', 'tok', 'a.pdf', b'%PDF')
  818. scoped.upload_headhaul_document(
  819. 'MT_y', 'tok', 'a.pdf', b'%PDF', '', 'rq_b', '1.2.3.4',
  820. )
  821. self.assertEqual('1.2.3.4', transport.calls[-1][3]['X-MCP-Client-IP'])
  822. scoped.upload_headhaul_document('MT_y', 'tok', 'a.pdf', b'%PDF')
  823. self.assertNotIn('X-MCP-Client-IP', transport.calls[-1][3])
  824. class OkParser:
  825. def parse(self, headers):
  826. return type('C', (), {
  827. 'has_session': lambda self: True,
  828. 'gateway_session_id': 'GWS_test',
  829. })()
  830. class RaisingGateway:
  831. def __init__(self, exc):
  832. self.exc = exc
  833. def registered_tool_names(self):
  834. return ('prepare_headhaul_document_upload',)
  835. def upload_headhaul_document(self, *args, **kwargs):
  836. raise self.exc
  837. body = (
  838. b'------Bound\r\n'
  839. b'Content-Disposition: form-data; name="upload_token"\r\n\r\n'
  840. b'tok123\r\n'
  841. b'------Bound\r\n'
  842. b'Content-Disposition: form-data; name="file"; filename="a.pdf"\r\n'
  843. b'Content-Type: application/pdf\r\n\r\n'
  844. b'%PDF\r\n'
  845. b'------Bound--\r\n'
  846. )
  847. headers = {'Content-Type': 'multipart/form-data; boundary="----Bound"'}
  848. too_big = PublicMcpHttpHandler(RaisingGateway(RuntimeError('x')), OkParser())
  849. status, payload = too_big.handle_upload(
  850. headers, b'x' * (102 * 1024 * 1024 + 1), trace_request_id='rq_big',
  851. )
  852. self.assertEqual(413, status)
  853. for exc, expected in (
  854. (RuntimeError(DEVICE_INVALID_MESSAGE), 401),
  855. (RuntimeError('tool disabled: prepare_headhaul_document_upload'), 403),
  856. (RuntimeError('boom'), 500),
  857. ):
  858. handler = PublicMcpHttpHandler(RaisingGateway(exc), OkParser())
  859. status, payload = handler.handle_upload(headers, body, trace_request_id='rq_e')
  860. self.assertEqual(expected, status)
  861. class BadPayloadGateway:
  862. def registered_tool_names(self):
  863. return ('prepare_headhaul_document_upload',)
  864. def upload_headhaul_document(self, *args, **kwargs):
  865. return 'nope'
  866. status, payload = PublicMcpHttpHandler(BadPayloadGateway(), OkParser()).handle_upload(
  867. headers, body, trace_request_id='rq_p',
  868. )
  869. self.assertEqual(500, status)
  870. fields, files = PublicMcpHttpHandler._parse_multipart(
  871. {'CONTENT-TYPE': 'multipart/form-data; boundary=----Bound'},
  872. b'------Bound\r\nno-separator\r\n------Bound\r\n'
  873. b'Content-Disposition: form-data; filename="x.pdf"\r\n\r\n'
  874. b'data\r\n------Bound--\r\n',
  875. )
  876. self.assertEqual({}, fields)
  877. self.assertEqual({}, files)
  878. class NoResourceApp:
  879. def registered_tool_names(self):
  880. return ()
  881. handler = PublicMcpHttpHandler(NoResourceApp(), OkParser())
  882. listed = handler.handle_json_rpc(
  883. headers={'X-Gateway-Session': 'GWS_test'},
  884. message={'jsonrpc': '2.0', 'id': 1, 'method': 'resources/list'},
  885. trace_request_id='rq_rl',
  886. )
  887. self.assertEqual([], listed['result']['resources'])
  888. missing = handler.handle_json_rpc(
  889. headers={'X-Gateway-Session': 'GWS_test'},
  890. message={'jsonrpc': '2.0', 'id': 2, 'method': 'resources/read', 'params': {}},
  891. trace_request_id='rq_rr',
  892. )
  893. self.assertEqual(-32602, missing['error']['code'])
  894. from services.request_context import RequestContextParser
  895. no_session = PublicMcpHttpHandler(
  896. NoResourceApp(), RequestContextParser(),
  897. ).handle_json_rpc(
  898. headers={},
  899. message={'jsonrpc': '2.0', 'id': 3, 'method': 'resources/list'},
  900. trace_request_id='rq_ns',
  901. )
  902. self.assertEqual(-32001, no_session['error']['code'])
  903. class ResourceApp:
  904. def registered_tool_names(self):
  905. return ()
  906. def list_resources(self):
  907. from services.headhaul_document_app import resource_list
  908. return resource_list()
  909. def read_resource(self, uri, gateway_session_id=''):
  910. from services.headhaul_document_app import read_resource
  911. return read_resource(uri)
  912. ok_handler = PublicMcpHttpHandler(ResourceApp(), OkParser())
  913. listed = ok_handler.handle_json_rpc(
  914. headers={'X-Gateway-Session': 'GWS_test'},
  915. message={'jsonrpc': '2.0', 'id': 4, 'method': 'resources/list'},
  916. )
  917. self.assertEqual([], listed['result']['resources'])
  918. read = ok_handler.handle_json_rpc(
  919. headers={'X-Gateway-Session': 'GWS_test'},
  920. message={
  921. 'jsonrpc': '2.0', 'id': 5,
  922. 'method': 'resources/read',
  923. 'params': {'uri': 'ui://widget/headhaul-document-upload.html'},
  924. },
  925. )
  926. self.assertEqual(-32602, read['error']['code'])
  927. from mcp_protocol import McpProtocolHandler
  928. empty = McpProtocolHandler(type('A', (), {})())
  929. listed = empty.handle_request({
  930. 'jsonrpc': '2.0', 'id': 11, 'method': 'resources/list', 'params': {},
  931. })
  932. self.assertEqual([], listed['result']['resources'])
  933. missing = empty.handle_request({
  934. 'jsonrpc': '2.0', 'id': 12,
  935. 'method': 'resources/read',
  936. 'params': 'ui://widget/headhaul-document-upload.html',
  937. })
  938. self.assertEqual(-32602, missing['error']['code'])
  939. PublicMcpHttpHandler._parse_multipart({'Accept': 'text/plain'}, b'')
  940. PublicMcpHttpHandler._parse_multipart(
  941. {'Content-Type': 'multipart/form-data; boundary=----Bound'},
  942. b'------Bound\r\nContent-Disposition: form-data; name="x"\r\n\r\nvalue------Bound--\r\n',
  943. )
  944. not_dict = PublicMcpHttpHandler(
  945. ResourceApp(), OkParser(),
  946. ).handle_json_rpc(
  947. headers={'X-Gateway-Session': 'GWS_test'},
  948. message={
  949. 'jsonrpc': '2.0', 'id': 6,
  950. 'method': 'resources/read',
  951. 'params': 'ui://widget/headhaul-document-upload.html',
  952. },
  953. )
  954. self.assertEqual(-32602, not_dict['error']['code'])
  955. class FoundPublicApp:
  956. def registered_tool_names(self):
  957. return ()
  958. def read_resource(self, uri, gateway_session_id=''):
  959. return {'uri': uri, 'mimeType': 'text/plain', 'text': 'ok'}
  960. found = PublicMcpHttpHandler(FoundPublicApp(), OkParser()).handle_json_rpc(
  961. headers={'X-Gateway-Session': 'GWS_test'},
  962. message={
  963. 'jsonrpc': '2.0', 'id': 7,
  964. 'method': 'resources/read',
  965. 'params': {'uri': 'ui://example'},
  966. },
  967. )
  968. self.assertEqual(
  969. [{'uri': 'ui://example', 'mimeType': 'text/plain', 'text': 'ok'}],
  970. found['result']['contents'],
  971. )
  972. class UploadGateway:
  973. def registered_tool_names(self):
  974. return ('prepare_headhaul_document_upload',)
  975. def upload_headhaul_document(self, *args, **kwargs):
  976. return {'code': 'MCP_0000', 'data': {'ticket_ref': 'mhdt_http'}}
  977. handler_class = create_http_handler(UploadGateway())
  978. sock = socket.socket()
  979. sock.bind(('127.0.0.1', 0))
  980. port = sock.getsockname()[1]
  981. sock.close()
  982. server = HTTPServer(('127.0.0.1', port), handler_class)
  983. thread = threading.Thread(target=server.serve_forever, daemon=True)
  984. thread.start()
  985. try:
  986. import urllib.request
  987. req = urllib.request.Request(
  988. 'http://127.0.0.1:{0}/mcp/upload-headhaul-document'.format(port),
  989. data=body,
  990. headers={
  991. 'Content-Type': 'multipart/form-data; boundary=----Bound',
  992. 'X-Gateway-Session': 'GWS_test',
  993. },
  994. method='POST',
  995. )
  996. with urllib.request.urlopen(req, timeout=5) as resp:
  997. payload = json.loads(resp.read())
  998. self.assertEqual('MCP_0000', payload['code'])
  999. empty_req = urllib.request.Request(
  1000. 'http://127.0.0.1:{0}/mcp/upload-headhaul-document'.format(port),
  1001. data=b'',
  1002. headers={
  1003. 'Content-Type': 'multipart/form-data; boundary=----Bound',
  1004. 'X-Gateway-Session': 'GWS_test',
  1005. },
  1006. method='POST',
  1007. )
  1008. try:
  1009. urllib.request.urlopen(empty_req, timeout=5)
  1010. self.fail('expected 400')
  1011. except Exception as exc:
  1012. self.assertEqual(400, exc.code)
  1013. finally:
  1014. server.shutdown()
  1015. server.server_close()
  1016. def test_upload_gate_rejects_oversize_rate_and_concurrency(self):
  1017. from http.client import HTTPConnection
  1018. discard_request_body(BytesIO(b'abcd'), None, 0)
  1019. discard_request_body(None, None, 10)
  1020. buf = BytesIO(b'x' * 200)
  1021. discard_request_body(buf, None, 200, chunk=32)
  1022. self.assertEqual(200, buf.tell())
  1023. short = BytesIO(b'xy')
  1024. discard_request_body(short, None, 50, chunk=32)
  1025. self.assertEqual(2, short.tell())
  1026. class BoomConn:
  1027. def gettimeout(self):
  1028. raise OSError('no timeout')
  1029. discard_request_body(BytesIO(b'ab'), BoomConn(), 2)
  1030. class RestoreBoom:
  1031. def __init__(self):
  1032. self.calls = 0
  1033. def gettimeout(self):
  1034. return 9
  1035. def settimeout(self, value):
  1036. self.calls += 1
  1037. if self.calls > 1:
  1038. raise OSError('restore failed')
  1039. discard_request_body(BytesIO(b'ab'), RestoreBoom(), 2)
  1040. class ReadBoom:
  1041. def read(self, size):
  1042. raise OSError('read failed')
  1043. class OkConn:
  1044. def gettimeout(self):
  1045. return 1
  1046. def settimeout(self, value):
  1047. return None
  1048. discard_request_body(ReadBoom(), OkConn(), 8)
  1049. handler = PublicMcpHttpHandler(
  1050. type('G', (), {'registered_tool_names': lambda self: ()})(),
  1051. )
  1052. bad, payload, key = handler.gate_upload({}, -1, '1.1.1.1', 'rq')
  1053. self.assertEqual(400, bad)
  1054. self.assertEqual('MCP_1401', payload['code'])
  1055. self.assertIsNone(key)
  1056. too_big, payload, key = handler.gate_upload(
  1057. {}, UPLOAD_BODY_LIMIT + 1, '1.1.1.1', 'rq',
  1058. )
  1059. self.assertEqual(413, too_big)
  1060. self.assertIsNone(key)
  1061. self.assertEqual(400, handler.gate_upload({}, True, '1.1.1.1', 'rq')[0])
  1062. limiter = SimpleRateLimiter(max_requests=1, window_seconds=60, max_in_flight=1)
  1063. limited = PublicMcpHttpHandler(
  1064. type('G', (), {'registered_tool_names': lambda self: ()})(),
  1065. rate_limiter=limiter,
  1066. )
  1067. first = limited.gate_upload(
  1068. {'X-Gateway-Session': 'GWS_test'}, 10, '1.1.1.1', 'rq',
  1069. )
  1070. self.assertIsNone(first[0])
  1071. second = limited.gate_upload(
  1072. {'X-Gateway-Session': 'GWS_test'}, 10, '1.1.1.1', 'rq',
  1073. )
  1074. self.assertEqual(429, second[0])
  1075. self.assertEqual('MCP_9001', second[1]['code'])
  1076. limiter.release(first[2])
  1077. busy = SimpleRateLimiter(max_requests=10, window_seconds=60, max_in_flight=1)
  1078. concurrent = PublicMcpHttpHandler(
  1079. type('G', (), {'registered_tool_names': lambda self: ()})(),
  1080. rate_limiter=busy,
  1081. )
  1082. held = concurrent.gate_upload(
  1083. {'X-Gateway-Session': 'GWS_a'}, 10, '1.1.1.1', 'rq',
  1084. )
  1085. self.assertIsNone(held[0])
  1086. blocked = concurrent.gate_upload(
  1087. {'X-Gateway-Session': 'GWS_a'}, 10, '1.1.1.1', 'rq',
  1088. )
  1089. self.assertEqual(429, blocked[0])
  1090. busy.release(held[2])
  1091. ip_only = concurrent.gate_upload({}, 10, '8.8.8.8', 'rq')
  1092. self.assertIsNone(ip_only[0])
  1093. busy.release(ip_only[2])
  1094. class RejectGateway:
  1095. def registered_tool_names(self):
  1096. return ()
  1097. def upload_headhaul_document(self, *args, **kwargs):
  1098. raise AssertionError('must not read oversize body')
  1099. handler_class = create_http_handler(RejectGateway())
  1100. sock = socket.socket()
  1101. sock.bind(('127.0.0.1', 0))
  1102. port = sock.getsockname()[1]
  1103. sock.close()
  1104. server = HTTPServer(('127.0.0.1', port), handler_class)
  1105. thread = threading.Thread(target=server.serve_forever, daemon=True)
  1106. thread.start()
  1107. try:
  1108. conn = HTTPConnection('127.0.0.1', port, timeout=5)
  1109. conn.putrequest('POST', '/mcp/upload-headhaul-document')
  1110. conn.putheader('Content-Type', 'multipart/form-data; boundary=----Bound')
  1111. conn.putheader('Content-Length', str(UPLOAD_BODY_LIMIT + 1))
  1112. conn.putheader('X-Gateway-Session', 'GWS_test')
  1113. conn.endheaders()
  1114. resp = conn.getresponse()
  1115. self.assertEqual(413, resp.status)
  1116. oversize = json.loads(resp.read())
  1117. self.assertEqual('MCP_1401', oversize['code'])
  1118. self.assertIn('100M', oversize['msg'])
  1119. conn.close()
  1120. conn = HTTPConnection('127.0.0.1', port, timeout=5)
  1121. conn.putrequest('POST', '/mcp/upload-headhaul-document')
  1122. conn.putheader('Content-Type', 'multipart/form-data; boundary=----Bound')
  1123. conn.putheader('Content-Length', 'nope')
  1124. conn.putheader('X-Gateway-Session', 'GWS_test')
  1125. conn.endheaders()
  1126. resp = conn.getresponse()
  1127. self.assertEqual(400, resp.status)
  1128. conn.close()
  1129. finally:
  1130. server.shutdown()
  1131. server.server_close()
  1132. class OkGateway:
  1133. def registered_tool_names(self):
  1134. return ('prepare_headhaul_document_upload',)
  1135. def upload_headhaul_document(self, *args, **kwargs):
  1136. return {'code': 'MCP_0000', 'data': {'ticket_ref': 'mhdt_ok'}}
  1137. ok_limiter = SimpleRateLimiter(
  1138. max_requests=10, window_seconds=60, max_in_flight=2,
  1139. )
  1140. ok_handler = create_http_handler(OkGateway(), ok_limiter)
  1141. sock = socket.socket()
  1142. sock.bind(('127.0.0.1', 0))
  1143. port = sock.getsockname()[1]
  1144. sock.close()
  1145. server = HTTPServer(('127.0.0.1', port), ok_handler)
  1146. thread = threading.Thread(target=server.serve_forever, daemon=True)
  1147. thread.start()
  1148. try:
  1149. upload_body = (
  1150. b'------Bound\r\n'
  1151. b'Content-Disposition: form-data; name="upload_token"\r\n\r\n'
  1152. b'tok123\r\n'
  1153. b'------Bound\r\n'
  1154. b'Content-Disposition: form-data; name="file"; filename="a.pdf"\r\n'
  1155. b'Content-Type: application/pdf\r\n\r\n'
  1156. b'%PDF\r\n'
  1157. b'------Bound--\r\n'
  1158. )
  1159. conn = HTTPConnection('127.0.0.1', port, timeout=5)
  1160. conn.putrequest('POST', '/mcp/upload-headhaul-document')
  1161. conn.putheader('Content-Type', 'multipart/form-data; boundary=----Bound')
  1162. conn.putheader('Content-Length', str(len(upload_body)))
  1163. conn.putheader('X-Gateway-Session', 'GWS_test')
  1164. conn.endheaders()
  1165. conn.send(upload_body)
  1166. resp = conn.getresponse()
  1167. self.assertEqual(200, resp.status)
  1168. payload = json.loads(resp.read())
  1169. self.assertEqual('MCP_0000', payload['code'])
  1170. conn.close()
  1171. finally:
  1172. server.shutdown()
  1173. server.server_close()