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:
2026-08-18 20:00:13 -05:00
parent b1836ee40e
commit a7ea910231
8 changed files with 917 additions and 2 deletions
@@ -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)