Keep HTTP MCP startup independent of ambient WebSocket packages
This commit is contained in:
1 parent
e84362b999
commit
b0340869a7
12 files changed
+207
-38
No files matched your search
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.2~compat.20261027.2",
|
||||
"version": "0.3.2~compat.20261027.3",
|
||||
"guide_schema": 1,
|
||||
"topics": {
|
||||
"start": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.2~compat.20261027.2",
|
||||
"version": "0.3.2~compat.20261027.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "start",
|
||||
"title": "Start with FrameBridge",
|
||||
@@ -24,7 +24,7 @@
|
||||
"heading": "Install and connect",
|
||||
"body": "Install the .deb built for your exact Debian 13, Ubuntu 24.04/26.04, Linux Mint 22.x or LMDE 7 amd64 profile. These compatibility candidates still require installation and lifecycle validation on each target. Select the artifact from the matching profile folder and keep the distribution system Python. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal; advanced setup is optional for central/node roles and public settings.",
|
||||
"commands": [
|
||||
"sudo apt install ./framebridge-expanded_0.3.2~compat.20261027.2_amd64.deb",
|
||||
"sudo apt install ./framebridge-expanded_0.3.2~compat.20261027.3_amd64.deb",
|
||||
"fb connect"
|
||||
]
|
||||
},
|
||||
@@ -64,12 +64,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "4a85d9721ac3f758837a57126484b6cca56a598088f90365de2796f0b8d13766"
|
||||
"guide_sha256": "b5be618bfb62d102ebec222370882da129c37e92044ebe86cd9203def5c0b272"
|
||||
},
|
||||
"commands": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.2~compat.20261027.2",
|
||||
"version": "0.3.2~compat.20261027.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "commands",
|
||||
"title": "Command reference",
|
||||
@@ -136,12 +136,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "e8680e094c647dc991d7bc9e4800d3978502dad81cf101f10d072be88dc4e9a1"
|
||||
"guide_sha256": "043a13ee822b03113ca877351986ee4d25628fc31bf623a9f23134c1980545c0"
|
||||
},
|
||||
"connection": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.2~compat.20261027.2",
|
||||
"version": "0.3.2~compat.20261027.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "connection",
|
||||
"title": "Connect and disconnect ChatGPT",
|
||||
@@ -190,12 +190,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "0c8c16baf4d28e9b37154dad049193aeae252813b18eba3159cf437918eb3416"
|
||||
"guide_sha256": "3a6218c30256375c32bc80b34609f75b8465e901a2c7012389e6b5cdff995af3"
|
||||
},
|
||||
"lifecycle": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.2~compat.20261027.2",
|
||||
"version": "0.3.2~compat.20261027.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "lifecycle",
|
||||
"title": "Services, connection intent and boot policy",
|
||||
@@ -256,12 +256,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "0e3d6c96084e40b07d7373f3f7dbe22aa7659e59ec7cff57383026918dd0948c"
|
||||
"guide_sha256": "a7dd26f8acc2ffbadb47f01a9fe24b1b547853f576b7b8805e01ab9d40087cd4"
|
||||
},
|
||||
"nodes": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.2~compat.20261027.2",
|
||||
"version": "0.3.2~compat.20261027.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "nodes",
|
||||
"title": "Central and node operation",
|
||||
@@ -310,12 +310,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "a0a317d230abb52bd673bb82453c0c17d73d31e30b1766e4247ef80ada02aa2a"
|
||||
"guide_sha256": "a535fb417b3dff1c6ad022a83576ea9e001a2f2f78e99a2ca0d146747fe1300c"
|
||||
},
|
||||
"terminals": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.2~compat.20261027.2",
|
||||
"version": "0.3.2~compat.20261027.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "terminals",
|
||||
"title": "Persistent Bash terminals",
|
||||
@@ -376,12 +376,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "e9ab7535894f07a7aa309d0f4f02d36d37c65143006f8bc67b9f12a1e00f0f5a"
|
||||
"guide_sha256": "d6854b43a419e32ba16a3707d1481607d0073017daa971597bbeeee885e22f8c"
|
||||
},
|
||||
"screens": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.2~compat.20261027.2",
|
||||
"version": "0.3.2~compat.20261027.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "screens",
|
||||
"title": "Authentic screen observation and input",
|
||||
@@ -426,12 +426,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "3d37bc0fac316fa50275f1a9f38f15e87aef435593c694aebaacf4666734e1fd"
|
||||
"guide_sha256": "0b43cec434d78c75e277487cd28346c689e6f8111e0d99daf67e03904380651b"
|
||||
},
|
||||
"diagnostics": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.2~compat.20261027.2",
|
||||
"version": "0.3.2~compat.20261027.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "diagnostics",
|
||||
"title": "Status and diagnosis",
|
||||
@@ -476,12 +476,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "9cdab418287a3fcf283258782d117813bb45d23183101e5b7ee8eeb162cd38b4"
|
||||
"guide_sha256": "67ddeb5dc3314452f3378099b5427bed6e3a426547c322294b29f8935302bc5e"
|
||||
},
|
||||
"security": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.2~compat.20261027.2",
|
||||
"version": "0.3.2~compat.20261027.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "security",
|
||||
"title": "Authorization, distribution and safe operation",
|
||||
@@ -516,12 +516,12 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "64ecf0b4e25dec7faffae90ef3d48ce732f5ec2c7fb815815a625ae124230c9e"
|
||||
"guide_sha256": "995ee955506bd5ac91b27363c6833d2f6872555d7b6fbb03471b5af18bbf9661"
|
||||
},
|
||||
"compatibility": {
|
||||
"ok": true,
|
||||
"product": "FrameBridge",
|
||||
"version": "0.3.2~compat.20261027.2",
|
||||
"version": "0.3.2~compat.20261027.3",
|
||||
"guide_schema": 1,
|
||||
"topic": "compatibility",
|
||||
"title": "Compatibility with earlier commands",
|
||||
@@ -564,8 +564,8 @@
|
||||
],
|
||||
"read_only": true,
|
||||
"capabilities_verified": false,
|
||||
"guide_sha256": "f622466edc45196d42390129d8a32424e1b0e64b7751688790061b1e8b5941d2"
|
||||
"guide_sha256": "1a31c25cf96ca3f02f74984eba740dade767816537e3fac9edee9d7cebf86717"
|
||||
}
|
||||
},
|
||||
"guide_sha256": "f83c632796c7cdb3489683b1c4584f0fedec11f4aeb9321430f1a709888d6965"
|
||||
"guide_sha256": "05db1724919b95443b00670ef3365512c0ac30bbd15462819c3a2236e7df7a25"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# FrameBridge — command guide
|
||||
|
||||
Version: 0.3.2~compat.20261027.2 | Guide schema: 1
|
||||
Version: 0.3.2~compat.20261027.3 | Guide schema: 1
|
||||
|
||||
This reference is generated from the shared CLI/MCP guide. Use the matching installed version.
|
||||
|
||||
@@ -36,7 +36,7 @@ fb --json status
|
||||
Install the .deb built for your exact Debian 13, Ubuntu 24.04/26.04, Linux Mint 22.x or LMDE 7 amd64 profile. These compatibility candidates still require installation and lifecycle validation on each target. Select the artifact from the matching profile folder and keep the distribution system Python. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal; advanced setup is optional for central/node roles and public settings.
|
||||
|
||||
~~~sh
|
||||
sudo apt install ./framebridge-expanded_0.3.2~compat.20261027.2_amd64.deb
|
||||
sudo apt install ./framebridge-expanded_0.3.2~compat.20261027.3_amd64.deb
|
||||
fb connect
|
||||
~~~
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FrameBridge — Debian, Ubuntu and Linux Mint installation and first use
|
||||
|
||||
Version: 0.3.2~compat.20261027.2 | Guide schema: 1
|
||||
Version: 0.3.2~compat.20261027.3 | Guide schema: 1
|
||||
|
||||
This reference is generated from the shared CLI/MCP guide. Use the matching installed version.
|
||||
|
||||
@@ -36,7 +36,7 @@ fb --json status
|
||||
Install the .deb built for your exact Debian 13, Ubuntu 24.04/26.04, Linux Mint 22.x or LMDE 7 amd64 profile. These compatibility candidates still require installation and lifecycle validation on each target. Select the artifact from the matching profile folder and keep the distribution system Python. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal; advanced setup is optional for central/node roles and public settings.
|
||||
|
||||
~~~sh
|
||||
sudo apt install ./framebridge-expanded_0.3.2~compat.20261027.2_amd64.deb
|
||||
sudo apt install ./framebridge-expanded_0.3.2~compat.20261027.3_amd64.deb
|
||||
fb connect
|
||||
~~~
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.TH FRAMEBRIDGE 1 "2026-10-27" "FrameBridge 0.3.2~compat.20261027.2" "User Commands"
|
||||
.TH FRAMEBRIDGE 1 "2026-10-27" "FrameBridge 0.3.2~compat.20261027.3" "User Commands"
|
||||
.SH NAME
|
||||
framebridge, fb \- use local Linux tools and enrolled nodes with ChatGPT
|
||||
.SH SYNOPSIS
|
||||
@@ -21,7 +21,7 @@ fb \-\-json status
|
||||
Install the .deb built for your exact Debian 13, Ubuntu 24.04/26.04, Linux Mint 22.x or LMDE 7 amd64 profile. These compatibility candidates still require installation and lifecycle validation on each target. Select the artifact from the matching profile folder and keep the distribution system Python. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal; advanced setup is optional for central/node roles and public settings.
|
||||
.PP
|
||||
.nf
|
||||
sudo apt install ./framebridge\-expanded_0.3.2~compat.20261027.2_amd64.deb
|
||||
sudo apt install ./framebridge\-expanded_0.3.2~compat.20261027.3_amd64.deb
|
||||
fb connect
|
||||
.fi
|
||||
.SS Updating and removal
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.2~compat.20261027.2"
|
||||
__version__ = "0.3.2~compat.20261027.3"
|
||||
@@ -333,7 +333,8 @@ def main() -> None:
|
||||
streamable_http_path="/mcp")
|
||||
hub = FleetHub(discover_nodes, node_read)
|
||||
register_fleet_read_tools(server, hub)
|
||||
server.run(transport="streamable-http")
|
||||
from .http_server import run_streamable_mcp
|
||||
run_streamable_mcp(server)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Run FrameBridge's HTTP MCP endpoints without optional WebSocket detection.
|
||||
|
||||
The FastMCP app owns its routes, middleware and session-manager lifespan.
|
||||
Other FrameBridge transports do not use this helper.
|
||||
"""
|
||||
|
||||
|
||||
def run_streamable_mcp(server):
|
||||
import anyio
|
||||
import uvicorn
|
||||
|
||||
async def serve():
|
||||
config = uvicorn.Config(
|
||||
server.streamable_http_app(),
|
||||
host=server.settings.host,
|
||||
port=server.settings.port,
|
||||
log_level=server.settings.log_level.lower(),
|
||||
ws="none",
|
||||
)
|
||||
await uvicorn.Server(config).serve()
|
||||
|
||||
anyio.run(serve)
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
CANDIDATE_VERSION = "0.3.2~compat.20261027.2"
|
||||
CANDIDATE_VERSION = "0.3.2~compat.20261027.3"
|
||||
VENDOR_RELATIVE = Path("original/geranas/FrameBridge-Fleet-Hub/vendor")
|
||||
SOURCE_VENDOR = Path("sources/original-geranas/FrameBridge-Fleet-Hub/vendor")
|
||||
GLIBC_BASELINE = (2, 34)
|
||||
|
||||
@@ -838,4 +838,5 @@ from framebridge_expanded.tty_mcp import register_tools as register_tty_tools
|
||||
register_tty_tools(mcp, framebridge_exec)
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http")
|
||||
from framebridge_expanded.http_server import run_streamable_mcp
|
||||
run_streamable_mcp(mcp)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Actual HTTP MCP startup is independent of ambient WebSocket libraries."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from framebridge_expanded import __version__
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class HttpMcpTransportTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix='fb-http-transport-')
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.directory = Path(self.temporary.name)
|
||||
self.ambient = self.directory/'ambient'
|
||||
package = self.ambient/'websockets'
|
||||
package.mkdir(parents=True)
|
||||
self.marker = self.directory/'unwanted-websocket-import'
|
||||
(package/'__init__.py').write_text(
|
||||
"import os\nfrom pathlib import Path\n"
|
||||
"Path(os.environ['FB_TEST_WS_MARKER']).write_text('imported')\n"
|
||||
"raise RuntimeError('INCOMPATIBLE_AMBIENT_WEBSOCKETS')\n")
|
||||
home = self.directory/'home'
|
||||
home.mkdir(mode=0o700)
|
||||
self.env = dict(os.environ, HOME=str(home), PYTHONNOUSERSITE='1',
|
||||
PYTHONDONTWRITEBYTECODE='1',
|
||||
FB_TEST_WS_MARKER=str(self.marker),
|
||||
FRAMEBRIDGE_EXPANDED_STATE=str(home/'state'),
|
||||
FRAMEBRIDGE_STATE_DIR=str(home/'state'),
|
||||
FRAMEBRIDGE_TOOL_ROOT=str(home/'adapter'),
|
||||
FRAMEBRIDGE_BROKER_SOURCE=str(SOURCE/'sources/original-geranas/FrameBridge-Broker-RC1/src'),
|
||||
FRAMEBRIDGE_FLEET_SOURCE=str(SOURCE/'sources/original-geranas/FrameBridge-Fleet-Hub/app'))
|
||||
# Match the child search path exported by runtime.environment(), with an
|
||||
# intentionally incompatible optional library taking precedence.
|
||||
self.env['PYTHONPATH'] = os.pathsep.join(map(str, [
|
||||
self.ambient, SOURCE/'expanded', SOURCE/'portable',
|
||||
SOURCE/'sources/original-geranas/FrameBridge-Fleet-Hub/vendor']))
|
||||
|
||||
def test_default_websocket_detection_would_load_the_incompatible_library(self):
|
||||
code = "from mcp.server.fastmcp import FastMCP; import uvicorn; uvicorn.Config(FastMCP('negative-control').streamable_http_app()).load()"
|
||||
result = subprocess.run([sys.executable, '-c', code], env=self.env,
|
||||
capture_output=True, text=True, timeout=15)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn('INCOMPATIBLE_AMBIENT_WEBSOCKETS', result.stderr)
|
||||
self.assertTrue(self.marker.exists())
|
||||
|
||||
def start(self, name):
|
||||
with socket.socket() as reservation:
|
||||
reservation.bind(('127.0.0.1', 0))
|
||||
self.port = reservation.getsockname()[1]
|
||||
if name == 'fleet':
|
||||
command = [sys.executable, '-m', 'framebridge_expanded.fleet']
|
||||
self.env['FRAMEBRIDGE_FLEET_HUB_PORT'] = str(self.port)
|
||||
else:
|
||||
command = [sys.executable, str(SOURCE/'portable/front_door_mcp.py')]
|
||||
self.env['FRAMEBRIDGE_FRONT_DOOR_PORT'] = str(self.port)
|
||||
log = (self.directory/(name+'.log')).open('w')
|
||||
self.process = subprocess.Popen(command, env=self.env, cwd=self.directory,
|
||||
stdin=subprocess.DEVNULL, stdout=log,
|
||||
stderr=log, start_new_session=True)
|
||||
log.close()
|
||||
self.addCleanup(self.stop)
|
||||
self.session = None
|
||||
self.protocol = None
|
||||
deadline = time.monotonic()+12
|
||||
while time.monotonic() < deadline:
|
||||
if self.process.poll() is not None:
|
||||
self.fail('HTTP component exited: '+(self.directory/(name+'.log')).read_text()[-2000:])
|
||||
try:
|
||||
reply, headers, _ = self.rpc({'jsonrpc':'2.0', 'id':1,
|
||||
'method':'initialize', 'params':{'protocolVersion':'2025-11-25',
|
||||
'capabilities':{}, 'clientInfo':{'name':'framebridge-http-qa','version':'1'}}})
|
||||
break
|
||||
except urllib.error.URLError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
self.fail('HTTP component did not become ready')
|
||||
self.assertIn('result', reply)
|
||||
self.assertIn('serverInfo', reply['result'])
|
||||
self.assertTrue(headers.get('Content-Type', '').startswith('text/event-stream'))
|
||||
self.session = headers.get('Mcp-Session-Id')
|
||||
self.assertTrue(self.session)
|
||||
self.protocol = reply['result']['protocolVersion']
|
||||
_, _, code = self.rpc({'jsonrpc':'2.0', 'method':'notifications/initialized'})
|
||||
self.assertEqual(202, code)
|
||||
|
||||
def stop(self):
|
||||
if self.process.poll() is None:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait(timeout=5)
|
||||
|
||||
def rpc(self, value):
|
||||
headers = {'Content-Type':'application/json',
|
||||
'Accept':'application/json, text/event-stream'}
|
||||
if self.session:
|
||||
headers['Mcp-Session-Id'] = self.session
|
||||
if self.protocol:
|
||||
headers['Mcp-Protocol-Version'] = self.protocol
|
||||
request = urllib.request.Request('http://127.0.0.1:'+str(self.port)+'/mcp',
|
||||
data=json.dumps(value).encode(), headers=headers, method='POST')
|
||||
with urllib.request.urlopen(request, timeout=3) as response:
|
||||
raw = response.read().decode()
|
||||
if raw and response.headers.get('Content-Type','').startswith('text/event-stream'):
|
||||
raw = next(line[5:].strip() for line in raw.splitlines() if line.startswith('data:'))
|
||||
return json.loads(raw) if raw else None, response.headers, response.status
|
||||
|
||||
def test_fleet_initializes_and_lists_tools_without_websocket_import(self):
|
||||
self.start('fleet')
|
||||
result, _, status = self.rpc({'jsonrpc':'2.0','id':2,'method':'tools/list'})
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual(6, len(result['result']['tools']))
|
||||
self.assertFalse(self.marker.exists())
|
||||
|
||||
def test_actual_frontdoor_help_and_execution_denial_over_streamable_http(self):
|
||||
self.start('frontdoor')
|
||||
result, _, status = self.rpc({'jsonrpc':'2.0','id':2,'method':'tools/list'})
|
||||
self.assertEqual(200, status)
|
||||
names = {tool['name'] for tool in result['result']['tools']}
|
||||
self.assertTrue({'framebridge_help','framebridge_exec'}.issubset(names))
|
||||
reply, _, _ = self.rpc({'jsonrpc':'2.0','id':3,'method':'tools/call',
|
||||
'params':{'name':'framebridge_help','arguments':{'topic':'start'}}})
|
||||
self.assertIn(__version__, json.dumps(reply['result']))
|
||||
denied, _, _ = self.rpc({'jsonrpc':'2.0','id':4,'method':'tools/call',
|
||||
'params':{'name':'framebridge_exec',
|
||||
'arguments':{'script':'true','authorized':False}}})
|
||||
self.assertIn('AUTHORIZED_TRUE_REQUIRED', json.dumps(denied['result']))
|
||||
self.assertFalse((Path(self.env['FRAMEBRIDGE_TOOL_ROOT'])/'adapter.sock').exists())
|
||||
self.assertFalse(self.marker.exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user