feat: local MCP harness (tools/resources/prompts) + Harness UI page
Streamable-HTTP MCP server on POST /mcp: device.state, attack.deploy/stop/ status/deauth/capture/export_hc22000, loot.handshakes/enterprise_creds, recon.aps/isearch/devices, pineap.kick_client/set_filter tools; recon DB + bundled opencode skills resources; attack playbook prompts. Cookie or Bearer auth. Harness page shows endpoint, token, curl snippet, capability explorer and a copy-paste pi.dev prompt. scripts/harness_stdio.py for stdio-only agents.
This commit is contained in:
@@ -572,7 +572,7 @@ class PagerHandler:
|
||||
return
|
||||
self._fail(404, 'not found')
|
||||
return
|
||||
if method != 'POST' or path != '/api/login':
|
||||
if method != 'POST' or path not in ('/api/login', '/mcp'):
|
||||
if not check_auth(self.headers.get('Cookie', '') or ''):
|
||||
self._fail(401, 'unauthorized')
|
||||
return
|
||||
@@ -3418,6 +3418,366 @@ def h_attacks_clients(ctx):
|
||||
return 200, result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Local Harness: MCP (Model Context Protocol) Streamable-HTTP server.
|
||||
# Lets any agent (opencode, Claude, Cursor, pi.dev, ...) discover and drive
|
||||
# the Pineapple's features with tools, resources and prompts.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
MCP_PROTOCOL = '2025-06-18'
|
||||
|
||||
RECON_URI = 'file:/root/recon/recon.db?mode=ro'
|
||||
|
||||
|
||||
def _sql_table(table, limit=50, where=''):
|
||||
clause = ('WHERE ' + where) if where else ''
|
||||
rc, out, err = device_run(
|
||||
['sqlite3', '-json', RECON_URI,
|
||||
'SELECT * FROM %s %s ORDER BY time DESC LIMIT %d' % (table, clause, int(limit))],
|
||||
timeout=25)
|
||||
if rc != 0:
|
||||
return []
|
||||
try:
|
||||
return json.loads(out or '[]')
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
|
||||
def _mcp_auth(ctx):
|
||||
if check_auth(ctx.h.headers.get('Cookie', '') or ''):
|
||||
return True
|
||||
auth = ctx.h.headers.get('Authorization', '') or ''
|
||||
if auth.startswith('Bearer '):
|
||||
token = auth[7:].strip()
|
||||
try:
|
||||
with open(SESSION_FILE) as f:
|
||||
session = json.load(f)
|
||||
return session.get('token') == token
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _mcp_tool(name, description, schema, fn):
|
||||
return {'name': name, 'description': description,
|
||||
'inputSchema': {'type': 'object', 'properties': schema}, 'fn': fn}
|
||||
|
||||
|
||||
def _mcp_tools():
|
||||
def deploy(args):
|
||||
kind = (args.get('kind') or '').strip().lower()
|
||||
if kind not in ('wpa', 'open', 'enterprise'):
|
||||
return {'error': 'kind must be wpa, open or enterprise'}
|
||||
try:
|
||||
if kind == 'enterprise':
|
||||
result = _deploy_enterprise(args)
|
||||
else:
|
||||
result = _deploy_wpa_open(kind, args)
|
||||
except ValueError as exc:
|
||||
return {'error': str(exc)}
|
||||
except RuntimeError as exc:
|
||||
return {'error': str(exc)}
|
||||
update_pineap_state(mode='advanced', enabled=True, karma=True, collect=True)
|
||||
result['ok'] = True
|
||||
return result
|
||||
|
||||
def stop(args):
|
||||
status, payload = h_attacks_stop(_Ctx_args(args))
|
||||
return payload if status == 200 else {'error': payload.get('error', 'stop failed')}
|
||||
|
||||
def status(args):
|
||||
_, payload = h_attacks_status(None)
|
||||
return payload
|
||||
|
||||
def deauth(args):
|
||||
status, payload = h_attacks_deauth(_Ctx_args(args))
|
||||
return payload if status == 200 else {'error': payload.get('error', 'deauth failed')}
|
||||
|
||||
def capture(args):
|
||||
status, payload = h_attacks_capture(_Ctx_args(args))
|
||||
return payload if status == 200 else {'error': payload.get('error', 'capture failed')}
|
||||
|
||||
def export_hc(args):
|
||||
status, payload = h_attacks_export_hc22000(None)
|
||||
return payload if status == 200 else {'error': payload.get('error', 'export failed')}
|
||||
|
||||
def handshakes(args):
|
||||
_, payload = h_handshakes_get(None)
|
||||
return payload
|
||||
|
||||
def enterprise_creds(args):
|
||||
tables = {}
|
||||
for t in ('basic', 'challenge'):
|
||||
tables[t] = _sql_table('hostap_%s' % ('chalresp' if t == 'challenge' else t), 100)
|
||||
return tables
|
||||
|
||||
def recon_aps(args):
|
||||
return {'aps': _sql_table('ssid', args.get('limit', 50))}
|
||||
|
||||
def recon_isearch(args):
|
||||
ssid = (args.get('ssid') or '').strip()
|
||||
if not ssid:
|
||||
return {'error': 'ssid required'}
|
||||
return {'aps': _sql_table('ssid', 50, "CAST(ssid AS TEXT) LIKE '%%%s%%'" %
|
||||
ssid.replace("'", "''"))}
|
||||
|
||||
def recon_devices(args):
|
||||
return {'devices': _sql_table('wifi_device', args.get('limit', 50))}
|
||||
|
||||
def kick(args):
|
||||
mac = (args.get('mac') or '').strip()
|
||||
if not mac:
|
||||
return {'error': 'mac required'}
|
||||
rc, out, err = device_run([HAK5CMD, 'CLIENT_KICK', mac], timeout=20)
|
||||
return {'ok': rc == 0, 'detail': (err or out)[-300:]}
|
||||
|
||||
def set_filter(args):
|
||||
kind = (args.get('kind') or 'ssid').strip()
|
||||
if kind not in ('ssid', 'client'):
|
||||
return {'error': 'kind must be ssid or client'}
|
||||
action = (args.get('action') or '').strip()
|
||||
payload = {'action': action}
|
||||
if action == 'set_mode':
|
||||
payload['mode'] = (args.get('mode') or 'deny').strip()
|
||||
elif action == 'add':
|
||||
payload['mode'] = (args.get('mode') or 'deny').strip()
|
||||
payload['value'] = (args.get('value') or '').strip()
|
||||
if not payload['value']:
|
||||
return {'error': 'value required'}
|
||||
else:
|
||||
return {'error': 'action must be set_mode or add'}
|
||||
status, resp = h_filter_post(_Ctx_args(payload), kind)
|
||||
return resp if status == 200 else {'error': resp.get('error', 'filter failed')}
|
||||
|
||||
def state(args):
|
||||
_, mode = h_pineap_mode_get(None)
|
||||
_, aps = h_pineap_wifi_get_ap(None)
|
||||
_, health = h_health(None)
|
||||
_, atk = h_attacks_status(None)
|
||||
return {'mode': mode, 'aps': aps, 'health': health, 'attacks': atk,
|
||||
'hop': _read_hop()}
|
||||
|
||||
return [
|
||||
_mcp_tool('device.state', 'Full truth snapshot: PineAP mode, AP configs, health, attacks, hop state.',
|
||||
{'detail': {'type': 'string'}}, state),
|
||||
_mcp_tool('attack.deploy', 'Deploy an attack: kind=wpa|open|enterprise with ssid, passphrase (wpa), enctype, channel (band), hidden, bssid/country (open). Stops conflicting attacks, enables karma + handshake capture, verifies on device.',
|
||||
{'kind': {'type': 'string', 'enum': ['wpa', 'open', 'enterprise']},
|
||||
'ssid': {'type': 'string'},
|
||||
'passphrase': {'type': 'string'},
|
||||
'enctype': {'type': 'string', 'enum': ['psk2', 'sae', 'owe', 'wpa2', 'wpa3']},
|
||||
'channel': {'type': 'number'},
|
||||
'hidden': {'type': 'boolean'},
|
||||
'bssid': {'type': 'string'},
|
||||
'country': {'type': 'string'}}, deploy),
|
||||
_mcp_tool('attack.stop', 'Stop an attack by kind (wpa|open|enterprise); disables APs and resumes hopping.',
|
||||
{'kind': {'type': 'string', 'enum': ['wpa', 'open', 'enterprise']}}, stop),
|
||||
_mcp_tool('attack.status', 'Live attack status: APs per band, live flag, handshake + enterprise credential counts.',
|
||||
{}, status),
|
||||
_mcp_tool('attack.deauth', 'Send deauth frames against an AP/client. Band-aware inject (wlan0mon for 2.4, wlan1mon for 5/6). Only against authorized targets.',
|
||||
{'bssid': {'type': 'string'}, 'client': {'type': 'string'},
|
||||
'channel': {'type': 'number'}}, deauth),
|
||||
_mcp_tool('attack.capture', 'Start/stop/status a monitor pcap capture (iface wlan0mon|wlan1mon, action start|stop|status).',
|
||||
{'iface': {'type': 'string', 'enum': ['wlan0mon', 'wlan1mon']},
|
||||
'action': {'type': 'string', 'enum': ['start', 'stop', 'status']}}, capture),
|
||||
_mcp_tool('attack.export_hc22000', 'Convert captured pcaps to a hashcat-ready .hc22000 file and return the hashcat command.',
|
||||
{}, export_hc),
|
||||
_mcp_tool('loot.handshakes', 'List captured WPA handshakes (files + parsed entries).',
|
||||
{}, handshakes),
|
||||
_mcp_tool('loot.enterprise_creds', 'PineAPE captured enterprise credentials (basic identity data + MSCHAPv2 challenge/response).',
|
||||
{}, enterprise_creds),
|
||||
_mcp_tool('recon.aps', 'Recent recon APs from the recon database.', {'limit': {'type': 'number'}}, recon_aps),
|
||||
_mcp_tool('recon.isearch', 'Find APs matching an SSID in the recon database.', {'ssid': {'type': 'string'}}, recon_isearch),
|
||||
_mcp_tool('recon.devices', 'Recent observed client devices from recon.', {'limit': {'type': 'number'}}, recon_devices),
|
||||
_mcp_tool('pineap.kick_client', 'Disconnect a client from a PineAP/evil-twin AP.', {'mac': {'type': 'string'}}, kick),
|
||||
_mcp_tool('pineap.set_filter', 'Set the SSID/client filter: action=set_mode (mode=deny|allow) or add (value).',
|
||||
{'kind': {'type': 'string', 'enum': ['ssid', 'client']},
|
||||
'action': {'type': 'string', 'enum': ['set_mode', 'add']},
|
||||
'mode': {'type': 'string', 'enum': ['allow', 'deny']},
|
||||
'value': {'type': 'string'}}, set_filter),
|
||||
]
|
||||
|
||||
|
||||
def _Ctx_args(body):
|
||||
return type('C', (), {'body': body, 'args': (), 'query': {}})()
|
||||
|
||||
|
||||
def _mcp_resource_list():
|
||||
res = [
|
||||
{'uri': 'device://state', 'name': 'Device state snapshot', 'mimeType': 'application/json'},
|
||||
{'uri': 'recon://aps', 'name': 'Recon access points', 'mimeType': 'application/json'},
|
||||
{'uri': 'recon://devices', 'name': 'Recon client devices', 'mimeType': 'application/json'},
|
||||
{'uri': 'recon://handshakes', 'name': 'Captured handshakes', 'mimeType': 'application/json'},
|
||||
{'uri': 'recon://enterprise/basic', 'name': 'Enterprise basic credentials', 'mimeType': 'application/json'},
|
||||
{'uri': 'recon://enterprise/challenge', 'name': 'Enterprise challenge/response', 'mimeType': 'application/json'},
|
||||
]
|
||||
for name in ('pineapple-control', 'wifi-deauth', 'aircrack-suite'):
|
||||
res.append({'uri': 'skills://%s' % name, 'name': 'Skill: %s' % name,
|
||||
'mimeType': 'text/markdown'})
|
||||
return res
|
||||
|
||||
|
||||
def _mcp_resource_read(uri):
|
||||
if uri == 'device://state':
|
||||
_, mode = h_pineap_mode_get(None)
|
||||
_, aps = h_pineap_wifi_get_ap(None)
|
||||
_, health = h_health(None)
|
||||
_, atk = h_attacks_status(None)
|
||||
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
||||
'text': json.dumps({'mode': mode, 'aps': aps,
|
||||
'health': health, 'attacks': atk})}]}
|
||||
if uri == 'recon://aps':
|
||||
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
||||
'text': json.dumps(_sql_table('ssid', 50))}]}
|
||||
if uri == 'recon://devices':
|
||||
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
||||
'text': json.dumps(_sql_table('wifi_device', 50))}]}
|
||||
if uri == 'recon://handshakes':
|
||||
_, payload = h_handshakes_get(None)
|
||||
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
||||
'text': json.dumps(payload)}]}
|
||||
if uri == 'recon://enterprise/basic':
|
||||
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
||||
'text': json.dumps(_sql_table('hostap_basic', 100))}]}
|
||||
if uri == 'recon://enterprise/challenge':
|
||||
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
||||
'text': json.dumps(_sql_table('hostap_chalresp', 100))}]}
|
||||
if uri.startswith('skills://'):
|
||||
name = uri[len('skills://'):]
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'skills', '%s.md' % name)
|
||||
if os.path.isfile(path):
|
||||
with open(path, 'r') as f:
|
||||
text = f.read()
|
||||
return {'contents': [{'uri': uri, 'mimeType': 'text/markdown', 'text': text}]}
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _mcp_prompt_list():
|
||||
return [
|
||||
{'name': 'evil-wpa-attack', 'description': 'Deploy an Evil WPA (PSK) twin of a target SSID, capture a four-way handshake, and export it for hashcat.'},
|
||||
{'name': 'evil-enterprise-attack', 'description': 'Serve a WPA2-Enterprise twin with PineAPE credential harvesting.'},
|
||||
{'name': 'recon-survey', 'description': 'Survey the environment from the recon database.'},
|
||||
]
|
||||
|
||||
|
||||
def _mcp_prompt_get(name):
|
||||
if name == 'evil-wpa-attack':
|
||||
return {'description': 'Evil WPA (PSK) attack workflow',
|
||||
'messages': [{'role': 'user', 'content': {'type': 'text',
|
||||
'text': 'Plan: 1) recon.isearch for the target SSID to confirm it is authorized. 2) attack.deploy kind=wpa with the target ssid (passphrase is your choice; enctype psk2; 2.4GHz channels 1-11 or 5GHz 36-165). 3) attack.status until live. 4) Optionally attack.capture on the matching monitor to record raw frames, and attack.deauth against the target\'s clients. 5) Once a handshake appears in loot.handshakes, attack.export_hc22000 and run the returned hashcat command locally.'}}]}
|
||||
if name == 'evil-enterprise-attack':
|
||||
return {'description': 'Evil Enterprise (PineAPE) attack workflow',
|
||||
'messages': [{'role': 'user', 'content': {'type': 'text',
|
||||
'text': 'Plan: 1) recon.isearch the target SSID (authorized only). 2) attack.deploy kind=enterprise ssid=<target> enctype=wpa2 channel=36. 3) attack.status until live. 4) Monitor loot.enterprise_creds for captured identities and MSCHAPv2 challenge/response pairs; crack offline with hashcat -m 5500 (or -m 5800 for netntlmv2-style) once captured. 5) attack.stop when done.'}}]}
|
||||
if name == 'recon-survey':
|
||||
return {'description': 'Recon survey',
|
||||
'messages': [{'role': 'user', 'content': {'type': 'text',
|
||||
'text': 'Plan: 1) device.state for the truth snapshot. 2) recon.aps to list recent networks. 3) recon.devices for observed clients. 4) Summarize: networks, bands, encryption, signal, and any target SSID the operator asked about.'}}]}
|
||||
return None
|
||||
|
||||
|
||||
def _mcp_dispatch(msg):
|
||||
"""Handle one JSON-RPC MCP message. Returns (status, body)."""
|
||||
if not isinstance(msg, dict) or msg.get('jsonrpc') != '2.0':
|
||||
return 400, {'jsonrpc': '2.0', 'error': {'code': -32600, 'message': 'invalid JSON-RPC'}}
|
||||
mid = msg.get('id')
|
||||
method = msg.get('method') or ''
|
||||
params = msg.get('params') or {}
|
||||
if not method:
|
||||
return 400, {'jsonrpc': '2.0', 'id': mid, 'error': {'code': -32600, 'message': 'method required'}}
|
||||
|
||||
def respond(result):
|
||||
if mid is None:
|
||||
return 202, None
|
||||
return 200, {'jsonrpc': '2.0', 'id': mid, 'result': result}
|
||||
|
||||
def respond_error(code, message):
|
||||
if mid is None:
|
||||
return 202, None
|
||||
return 200, {'jsonrpc': '2.0', 'id': mid, 'error': {'code': code, 'message': message}}
|
||||
|
||||
if method == 'initialize':
|
||||
version = (params or {}).get('protocolVersion') or '2025-03-26'
|
||||
return 200, {'jsonrpc': '2.0', 'id': mid, 'result': {
|
||||
'protocolVersion': MCP_PROTOCOL,
|
||||
'capabilities': {
|
||||
'tools': {'listChanged': False},
|
||||
'resources': {'listChanged': False, 'subscribe': False},
|
||||
'prompts': {'listChanged': False},
|
||||
},
|
||||
'serverInfo': {'name': 'mark-viii', 'version': '1.1'}}}
|
||||
if method == 'notifications/initialized':
|
||||
return 202, None
|
||||
if method == 'ping':
|
||||
return respond({})
|
||||
if method == 'tools/list':
|
||||
return respond({'tools': [{'name': t['name'], 'description': t['description'],
|
||||
'inputSchema': t['inputSchema']} for t in _mcp_tools()]})
|
||||
if method == 'tools/call':
|
||||
name = (params or {}).get('name') or ''
|
||||
args = (params or {}).get('arguments') or {}
|
||||
for t in _mcp_tools():
|
||||
if t['name'] == name:
|
||||
try:
|
||||
result = t['fn'](args)
|
||||
except Exception as exc:
|
||||
return respond_error(-32603, 'tool error: %s' % exc)
|
||||
if isinstance(result, dict) and 'error' in result:
|
||||
return respond_error(-32602, result['error'])
|
||||
return respond({'content': [{'type': 'text', 'text': json.dumps(result, indent=2)}]})
|
||||
return respond_error(-32602, 'unknown tool: %s' % name)
|
||||
if method == 'resources/list':
|
||||
return respond({'resources': _mcp_resource_list()})
|
||||
if method == 'resources/read':
|
||||
uri = (params or {}).get('uri') or ''
|
||||
content = _mcp_resource_read(uri)
|
||||
if content is None:
|
||||
return respond_error(-32602, 'unknown resource: %s' % uri)
|
||||
return respond(content)
|
||||
if method == 'prompts/list':
|
||||
return respond({'prompts': _mcp_prompt_list()})
|
||||
if method == 'prompts/get':
|
||||
name = (params or {}).get('name') or ''
|
||||
content = _mcp_prompt_get(name)
|
||||
if content is None:
|
||||
return respond_error(-32602, 'unknown prompt: %s' % name)
|
||||
return respond(content)
|
||||
return respond_error(-32601, 'method not found: %s' % method)
|
||||
|
||||
|
||||
def h_mcp(ctx):
|
||||
if not _mcp_auth(ctx):
|
||||
return 401, {'jsonrpc': '2.0', 'error': {'code': -32001, 'message': 'unauthorized'}}
|
||||
body = ctx.body
|
||||
if body is None:
|
||||
return 400, {'jsonrpc': '2.0', 'error': {'code': -32700, 'message': 'parse error'}}
|
||||
return _mcp_dispatch(body)
|
||||
|
||||
|
||||
def h_harness_capabilities(ctx):
|
||||
tools = [{'name': t['name'], 'description': t['description'],
|
||||
'inputSchema': t['inputSchema']} for t in _mcp_tools()]
|
||||
return 200, {
|
||||
'endpoint': '/mcp',
|
||||
'protocol': MCP_PROTOCOL,
|
||||
'transport': 'Streamable HTTP (POST application/json)',
|
||||
'auth': 'session cookie (browser) or Authorization: Bearer <daemon token>',
|
||||
'tools': tools,
|
||||
'resources': _mcp_resource_list(),
|
||||
'prompts': _mcp_prompt_list(),
|
||||
'skills': ['pineapple-control', 'wifi-deauth', 'aircrack-suite'],
|
||||
}
|
||||
|
||||
|
||||
def h_harness_token(ctx):
|
||||
try:
|
||||
with open(SESSION_FILE) as f:
|
||||
session = json.load(f)
|
||||
return 200, {'token': session.get('token', ''), 'serverid': session.get('serverid', '')}
|
||||
except (OSError, ValueError):
|
||||
return 200, {'token': '', 'serverid': ''}
|
||||
|
||||
|
||||
def hak5(*args, timeout=30):
|
||||
rc, out, err = device_run([HAK5CMD] + list(args), timeout=timeout)
|
||||
return out
|
||||
@@ -4429,6 +4789,9 @@ ROUTER.add('GET', r'/api/attacks/export/hc22000/([^/]+)', h_attacks_download_hc2
|
||||
ROUTER.add('POST', r'/api/attacks/deauth', h_attacks_deauth)
|
||||
ROUTER.add('GET', r'/api/attacks/clients', h_attacks_clients)
|
||||
ROUTER.add('GET', r'/api/health', h_health)
|
||||
ROUTER.add('POST', r'/mcp', h_mcp)
|
||||
ROUTER.add('GET', r'/api/harness/capabilities', h_harness_capabilities)
|
||||
ROUTER.add('GET', r'/api/harness/token', h_harness_token)
|
||||
ROUTER.add('POST', r'/api/recon/start', h_recon_start)
|
||||
ROUTER.add('POST', r'/api/recon/stop', h_recon_stop)
|
||||
ROUTER.add('GET', r'/api/recon/status', h_recon_status)
|
||||
|
||||
Reference in New Issue
Block a user