Skip to content

未正确处理 MCP 服务端返回的“无主响应” #1175

Description

@nhkl12-lab

提交前确认 / Before submitting

  • 我已经搜索过现有 Issue,确认这不是重复问题。
  • 我已经移除配置、日志和截图中的密钥及隐私信息。

类型 / Type:MCP(远程服务)

Operit 版本 / Operit version

1.12.1 (46)

设备与运行环境 / Environment

HONOR EBG-AN10 / Android 12

包或服务名称 / Package or service

hithinkfinance(远程 MCP / httpStream;第三方 A 股行情类服务)

来源与版本 / Source and version

来源:远程 MCP 服务(自行配置的第三方端点,非市场插件包)。
版本:服务端未声明版本号;initialize 协商的协议版本为 2024-11-05。

连接与相关配置 / Transport and configuration

  • transport:httpStream(远程 MCP;HTTP POST + JSON)
  • 涉及方法:initialize / notifications/initialized / tools/list / ping
  • 端点鉴权已隐去(本 issue 不提供任何 Key)

Provider、模型与工具 / Provider, model and tool

不适用:问题发生在插件验证/注册阶段,未进入模型与工具调用环节。

复现步骤 / Steps to reproduce

【最小复现(本地、不依赖第三方服务;Python 3 标准库即可)】

  1. 在本机运行最小 MCP Streamable HTTP 服务端(脚本见下方折叠区):initialize / tools/list / ping 均按规范;唯独对 notifications/initialized 返回 200 + JSON 伪响应(自编 id + result),复刻线上服务端的非合规行为。
  2. 在 Operit 添加该端点:远程 MCP / httpStream,URL:http://127.0.0.1:8765/mcp
  3. 触发插件验证 → 预期与线上一致:不进入“验证成功”名单、不注册工具、无工具缓存。
  4. 对照组:把步骤 1 的伪响应改为合规行为(不响应 / 202 空 body)→ 重新验证 → 通过。
最小复现脚本(repro_min.py + selftest.py)

repro_min.py

#!/usr/bin/env python3
"""
Minimal repro: a Streamable-HTTP MCP server that answers a *notification* with a
pseudo-response, mimicking a third-party remote MCP service, which replies to
`notifications/initialized` with:
    {"id":"<uuid>","result":{"_notification_handled":true}}
Per JSON-RPC 2.0 a notification MUST NOT get any response.
Per MCP Streamable HTTP (2025-03-26..2025-11-25 line) a POST containing only
notifications MUST be answered with HTTP 202, no body.
The initialize response below is kept *minimal but spec-compliant*
(Lifecycle, 2025-11-25): protocolVersion echoed; capabilities {} (all optional);
serverInfo requires only name+version.
Run:  python3 repro_min.py        # listens on http://127.0.0.1:8765/mcp
"""
import json, uuid
from http.server import BaseHTTPRequestHandler, HTTPServer

HOST, PORT = '127.0.0.1', 8765

class Handler(BaseHTTPRequestHandler):
    protocol_version = 'HTTP/1.1'

    def log_message(self, fmt, *args):  # keep quiet
        pass

    def _send_json(self, obj, code=200, headers=None):
        raw = json.dumps(obj).encode('utf-8')
        self.send_response(code)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(raw)))
        for k, v in (headers or {}).items():
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(raw)

    def _send_empty(self, code):
        self.send_response(code)
        self.send_header('Content-Length', '0')
        self.end_headers()

    def do_GET(self):
        # Optional SSE stream not implemented; spec: servers either offer it or reply 405.
        self.send_response(405)
        self.send_header('Content-Length', '0')
        self.end_headers()

    def do_POST(self):
        length = int(self.headers.get('Content-Length') or 0)
        body = self.rfile.read(length) if length else b''
        try:
            msg = json.loads(body)
        except Exception:
            self._send_empty(400)
            return
        mid, method = msg.get('id'), msg.get('method')

        if method == 'initialize':
            req_v = (msg.get('params') or {}).get('protocolVersion', '2025-11-25')
            self._send_json(
                {'jsonrpc': '2.0', 'id': mid, 'result': {
                    'protocolVersion': req_v,
                    'capabilities': {},
                    'serverInfo': {'name': 'repro-bad-server', 'version': '0.1.0'}}},
                headers={'Mcp-Session-Id': 'repro-session-0001'})
        elif method == 'notifications/initialized':
            # ===== the non-compliant bit (mimics the observed remote server) =====
            # A notification must get NO response; here we send a pseudo-response.
            self._send_json({'jsonrpc': '2.0', 'id': str(uuid.uuid4()),
                             'result': {'_notification_handled': True}})
        elif method == 'tools/list':
            self._send_json({'jsonrpc': '2.0', 'id': mid, 'result': {'tools': []}})
        elif method == 'ping':
            self._send_json({'jsonrpc': '2.0', 'id': mid, 'result': {}})
        elif mid is None:
            # any other notification: correct behavior - accept with 202, no body
            self._send_empty(202)
        else:
            self._send_json({'jsonrpc': '2.0', 'id': mid,
                             'error': {'code': -32601, 'message': 'Method not found'}})

if __name__ == '__main__':
    print(f'repro server on http://{HOST}:{PORT}/mcp')
    HTTPServer((HOST, PORT), Handler).serve_forever()

selftest.py

#!/usr/bin/env python3
# Self-test for repro_min.py: verify the two behavioral branches over raw HTTP.
import json, urllib.request

def post(m):
    req = urllib.request.Request(
        'http://127.0.0.1:8765/mcp',
        data=json.dumps(m).encode(),
        headers={'Content-Type': 'application/json'},
        method='POST')
    r = urllib.request.urlopen(req, timeout=5)
    return r.status, r.read().decode()[:200]

st, body = post({'jsonrpc': '2.0', 'id': 1, 'method': 'initialize',
                 'params': {'protocolVersion': '2025-11-25', 'capabilities': {},
                            'clientInfo': {'name': 'selftest', 'version': '0.0'}}})
print('INIT  ->', st, body)
st, body = post({'jsonrpc': '2.0', 'method': 'notifications/initialized'})
print('INITD ->', st, body)
st, body = post({'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'})
print('TOOLS ->', st, body)

主要问题描述 / Main problem description

【实际】远程 MCP(httpStream)插件在“插件验证”阶段失败:不进入“验证成功”名单、不注册工具、无工具缓存,工具面板不可用;用户侧没有任何可定位的协议层提示。
【定位】该服务端对 notifications/initialized(通知;JSON-RPC 2.0 规定不得有任何响应,MCP Streamable HTTP 应为 202 空 body)返回 200 + JSON 伪响应:
{"jsonrpc":"2.0","id":"<服务端自行生成,不指向任何真实请求>","result":{"_notification_handled":true}}
该“无主响应”无法与任何在途请求匹配,严格校验的客户端会视为协议违规——疑似即验证失败的直接原因。
【已排除】网络与配置正常:同一端点 initialize / ping 均正常返回 200。
【期望】1) 对这类非合规响应:即便出于兼容而容忍,建议“忽略并计数”(可观测),不因此判插件失败;或至少在验证失败时给出协议层原因(如 unmatched response),便于用户定位。2) 同步确认:客户端对“无主响应”是否有兼容/容忍计划。
【影响】第三方服务不合规时,用户仅表现为“插件不可用”,排障成本高(需自行取证到协议层才能定位)。

截图或录屏 / Screenshot or recording

(暂未上传;以文字与日志补充说明。)

日志、请求或响应 / Logs, request or response

【日志】operit.log(2026-09-12,节选)
· 06:04:30.892 连接 MCP runtime: hithinkfinance …(连接尝试)
· 06:04:32.176 D/MCPStarter: 开始为 4 个验证成功的插件注册工具: [sinafinance, taiwanmarket, yahoofinance, koreamarketmcp](本插件不在其中)
(检索关键字:验证成功的插件)

【状态文件】server_status.json 中 hithinkfinance:lastStartTime:0 / lastStopTime:0 / toolsCachedTime:0,无 cachedTools(对照:sinafinance 已缓存 75 个工具)。

【服务端行为】对 notifications/initialized 的响应:
HTTP 200
{"jsonrpc":"2.0","id":"<服务端自生成,无对应请求>","result":{"_notification_handled":true}}
(规范要求:不得有响应;Streamable HTTP 应为 202 空 body)

【版本口径】复现固定 2025-11-25(含握手代);实测服务端协商 2024-11-05;2026-07-28 已取消握手,本复现不涉新版语义。

【相关】#1050(MCP 引擎与工具注册域跟踪)可能相关,供判断。

【脱敏确认】全文不含 API Key / Token / Cookie 等敏感信息。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions