forked from Keelrleek/Harbor
703 lines
33 KiB
Python
703 lines
33 KiB
Python
"""Harbor as MCP tools (stdio), so an agent can coordinate without composing shell commands.
|
|
|
|
Run with the agent's identity fixed in its environment (it cannot be changed from a tool call):
|
|
command: py -3.12 -B <Harbor>\\harbor_mcp.py env: HARBOR_AGENT=<Agent> (HARBOR_PROJECT optional)
|
|
The project comes from $HARBOR_PROJECT or the .harbor.toml above the working directory. The human is refused.
|
|
"""
|
|
import asyncio
|
|
import os
|
|
import threading
|
|
import time
|
|
from typing import Literal
|
|
|
|
os.environ.setdefault("FASTMCP_CHECK_FOR_UPDATES", "off") # no network calls from a local tool
|
|
os.environ.setdefault("FASTMCP_SHOW_SERVER_BANNER", "false")
|
|
from fastmcp import FastMCP # noqa: E402
|
|
from fastmcp.exceptions import ToolError # noqa: E402
|
|
|
|
from . import projects # noqa: E402
|
|
from .core import OPEN_ITEM_STATES, Harbor, HarborError # noqa: E402
|
|
|
|
INSTRUCTIONS = """Harbor is the team's shared coordination store: messages in threads, acknowledgements, claims, a
|
|
review queue, presence and stop flags. Protocol:
|
|
1. Call brief() at the start of every session and after long work. If it reports a STOP, do not start or continue
|
|
work; only the human can resume. If it shows your last handoff, continue from it.
|
|
2. Take work with next_task() instead of waiting to be told; submit(task=...) or update_task(..., "done") finishes it.
|
|
Keep the team informed with doing("what you are working on") instead of status messages to everyone.
|
|
3. Put each conversation in a thread named after its subject: fn:0x529350, commit:b4b44f0, item:12, or a short
|
|
topic. Answer with reply() so the thread and the people stay together.
|
|
4. Write plain sentences. There is no length limit to squeeze into (20,000 characters); never run words together.
|
|
5. Ask for an acknowledgement (ack=True) only when you need to know the message was acted on; acknowledge with
|
|
ack() and a short note instead of sending a "received" message.
|
|
6. Before working on a function or file, claim it. A refused claim names the holder: ask them in a thread.
|
|
7. Put finished work in the review queue with submit(); do not announce commits in chat. A coordinator lands it.
|
|
8. When you are waiting on someone, use wait_for_messages() instead of polling or sleeping.
|
|
9. A correction is send(..., corrects=<id of your earlier message>).
|
|
10. Before your session ends or your allowance runs out, write handoff(): finished, in flight, next.
|
|
11. To read the original machine code use re_disassemble, re_xrefs, re_bytes and re_lookup: they read the project's
|
|
own image with the team's current names, so do not write your own disassembler or offset maths.
|
|
For Ghidra's view (decompiler, p-code, stack frames, struct field references, types) use ghidra_tools() once,
|
|
then ghidra(tool, arguments): one shared read-only Ghidra session holding the team's annotations.
|
|
To check a value in the running game (x87 control word, `this`, struct fields), use debugger_tools() and
|
|
debugger(tool, arguments): your own x32dbg session; always end it with debugger('terminate_session').
|
|
12. Pick functions with next_functions() (easiest first in your bands). Before writing, read re_lookup (it states the
|
|
calling convention read from the bytes) and re_similar (a readable near-duplicate is a head start). Before
|
|
submitting, run re_match on your worktree: 'match' or 'arguments in registers' is byte-identical, otherwise
|
|
similarity shows how close you are; re_match_program (slow, once before submitting) builds the whole
|
|
program the way the game was built, so register conventions reproduce. After two failed attempts, defer(address, reason, evidence) instead of
|
|
looping. Use re_struct_audit before changing a struct in types.h."""
|
|
|
|
KindName = Literal["note", "question", "answer", "request", "finding", "retraction", "decision", "status", "handoff"]
|
|
Importance = Literal["normal", "high", "urgent"]
|
|
ReviewState = Literal["in_review", "changes_requested", "approved", "held", "rejected", "submitted"]
|
|
ClaimKind = Literal["draft", "naming", "edit", "review", "band"]
|
|
|
|
mcp = FastMCP("harbor", instructions=INSTRUCTIONS)
|
|
_lock = threading.Lock()
|
|
_state = {"seen": 0.0}
|
|
|
|
|
|
def harbor():
|
|
if "h" not in _state:
|
|
project = _state.get("project") or projects.resolve()
|
|
_state["h"] = Harbor(project, shared=True)
|
|
_state["h"].refresh_project() # project-file edits apply without restarting the app
|
|
return _state["h"]
|
|
|
|
|
|
def me():
|
|
h = harbor()
|
|
agent = os.environ.get("HARBOR_AGENT") or h.project.identity_for(os.getcwd()) or ""
|
|
if agent not in h.project.ai_names:
|
|
raise HarborError(f"HARBOR_AGENT must be one of {', '.join(h.project.ai_names)} (set it in this server's MCP "
|
|
"config)")
|
|
if time.monotonic() - _state.get("active", 0.0) > 20: # a tool call is activity (apps without hooks too)
|
|
_state["active"] = time.monotonic()
|
|
try:
|
|
import sys as _sys
|
|
h.activity(agent, "harbor " + _sys._getframe(1).f_code.co_name, os.environ.get("HARBOR_HOST", ""),
|
|
channel="mcp")
|
|
except Exception: # noqa: BLE001 - bookkeeping is best effort
|
|
pass
|
|
return agent
|
|
|
|
|
|
def compact(m):
|
|
out = {"id": m["id"], "at": m["created_at"], "from": m["sender"], "to": m["recipients"],
|
|
"thread": m.get("topic"), "text": m["body"]}
|
|
for key in ("kind", "importance"):
|
|
if m[key] not in ("note", "normal"):
|
|
out[key] = m[key]
|
|
if m.get("ack_required"):
|
|
out["ack_requested"] = True
|
|
if m.get("reply_to"):
|
|
out["reply_to"] = m["reply_to"]
|
|
if m.get("refs"):
|
|
out["refs"] = m["refs"]
|
|
return out
|
|
|
|
|
|
def recipients(to):
|
|
if to is None or to == "All" or to == ["All"]:
|
|
return "All"
|
|
if isinstance(to, str):
|
|
return [name.strip() for name in to.split(",") if name.strip()]
|
|
return list(to)
|
|
|
|
|
|
def run(fn):
|
|
"""Harbor's refusals go back to the agent as a plain tool error (the message says what to do instead)."""
|
|
try:
|
|
with _lock:
|
|
h = harbor()
|
|
if time.monotonic() - _state.get("seen", 0.0) > 60: # record the live MCP connection, once a minute
|
|
_state["seen"] = time.monotonic()
|
|
try:
|
|
h.connect(me(), "mcp", os.environ.get("HARBOR_HOST", ""))
|
|
except HarborError:
|
|
pass
|
|
return fn(h)
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise ToolError(str(error)) from None
|
|
|
|
|
|
@mcp.tool
|
|
def brief(mark_read: bool = False) -> str:
|
|
"""What you need at the start of a session: stop flags, unread messages (urgent first), acknowledgements you
|
|
owe, your review items and claims, what the rest of the team is doing."""
|
|
return run(lambda h: h.brief(me(), mark_read=mark_read))
|
|
|
|
|
|
@mcp.tool
|
|
def doing(text: str = "") -> dict:
|
|
"""Set the one line the whole team sees about what you are doing now (empty clears it)."""
|
|
return run(lambda h: h.doing(me(), text))
|
|
|
|
|
|
@mcp.tool
|
|
def inbox(limit: int = 20, include_read: bool = False, peek: bool = False) -> list[dict]:
|
|
"""Your unread messages, urgent first (marked read unless peek=True). include_read=True lists recent ones."""
|
|
return run(lambda h: [compact(m) for m in h.inbox(me(), unread_only=not include_read, limit=limit,
|
|
mark_read=not peek)])
|
|
|
|
|
|
@mcp.tool
|
|
async def wait_for_messages(timeout_seconds: int = 300) -> dict:
|
|
"""Wait (up to timeout_seconds, max 1800) until a message for you arrives or a stop applies, then return the
|
|
new messages. Use this instead of sleeping or polling when you are waiting on someone."""
|
|
agent = run(lambda h: me())
|
|
timeout = max(1, min(int(timeout_seconds), 1800))
|
|
deadline = asyncio.get_running_loop().time() + timeout
|
|
while True:
|
|
state = run(lambda h: (h.unread_count(agent)[0], [f["key"] for f in h.stopped(agent)]))
|
|
if state[0] or state[1] or asyncio.get_running_loop().time() >= deadline:
|
|
messages = run(lambda h: [compact(m) for m in h.inbox(agent, limit=30)]) if state[0] else []
|
|
run(lambda h: h.touch(agent))
|
|
return {"messages": messages, "stopped": state[1], "timed_out": not state[0] and not state[1]}
|
|
await asyncio.sleep(2)
|
|
|
|
|
|
@mcp.tool
|
|
def send(to: str | list[str], text: str, thread: str | None = None, subject: str | None = None,
|
|
kind: KindName = "note", importance: Importance = "normal", ack: bool = False,
|
|
refs: dict[str, str] | None = None, attachments: list[str] | None = None, corrects: int | None = None,
|
|
due: str | None = None) -> dict:
|
|
"""Send a message. to: an agent, a list of agents, or "All". thread: a topic such as fn:0x529350,
|
|
commit:b4b44f0, task:5 or item:12 (default "general"); subject is the thread's title when it is new.
|
|
refs: what the message is about, e.g. {"commit": "b4b44f0", "fn": "0x529350"}.
|
|
attachments: local file paths to copy into Harbor (reports, logs, diffs).
|
|
corrects: the id of your earlier message this corrects (goes to the same thread and people).
|
|
due: when you need an answer ("30m", "2h", or an ISO time); missing it raises an alert."""
|
|
return run(lambda h: h.send(me(), recipients(to), text, subject=subject, thread=thread, kind=kind,
|
|
importance=importance, ack_required=ack, refs=refs, origin="mcp",
|
|
attachments=attachments, corrects=corrects, due=due))
|
|
|
|
|
|
@mcp.tool
|
|
def reply(message_id: int, text: str, kind: KindName = "answer", importance: Importance = "normal",
|
|
ack: bool = False, to: str | list[str] | None = None) -> dict:
|
|
"""Reply in the same thread to the same people (or to= someone else)."""
|
|
return run(lambda h: h.send(me(), None if to is None else recipients(to), text, reply_to=message_id, kind=kind,
|
|
importance=importance, ack_required=ack, origin="mcp"))
|
|
|
|
|
|
@mcp.tool
|
|
def ack(message_ids: list[int], note: str | None = None) -> list[int]:
|
|
"""Acknowledge messages addressed to you (use instead of a "received" message); note says what you did."""
|
|
return run(lambda h: h.ack(me(), message_ids, note))
|
|
|
|
|
|
@mcp.tool
|
|
def thread(thread: str) -> dict:
|
|
"""A whole thread by id or topic (e.g. "fn:0x529350")."""
|
|
def get(h):
|
|
t, messages = h.thread(thread)
|
|
return {"thread": t, "messages": [compact(m) for m in messages]}
|
|
return run(get)
|
|
|
|
|
|
@mcp.tool
|
|
def threads(limit: int = 25, include_closed: bool = False) -> list[dict]:
|
|
"""Recent threads with your unread count in each."""
|
|
return run(lambda h: h.threads(limit=limit, agent=me(), include_closed=include_closed))
|
|
|
|
|
|
@mcp.tool
|
|
def close_thread(thread: str, reopen: bool = False) -> dict:
|
|
"""Close a thread whose question is settled (or reopen it). A new message reopens it automatically."""
|
|
return run(lambda h: h.set_thread_state(me(), thread, "open" if reopen else "closed"))
|
|
|
|
|
|
@mcp.tool
|
|
def search(query: str, limit: int = 15) -> list[dict]:
|
|
"""Full-text search over every message, including imported chat history."""
|
|
return run(lambda h: [compact(m) for m in h.search(query, limit=limit)])
|
|
|
|
|
|
@mcp.tool
|
|
def claim(scope: str, kind: ClaimKind = "draft", ttl_hours: float | None = None, note: str | None = None) -> dict:
|
|
"""Claim fn:0xADDR, range:0xLO-0xHI, name:Symbol, task:text or file:path before working on it. Claiming the same
|
|
scope again renews it. Refused (with the holder named) when someone holds an overlapping exclusive claim."""
|
|
return run(lambda h: h.claim(me(), scope, kind=kind, ttl_hours=ttl_hours, note=note))
|
|
|
|
|
|
@mcp.tool
|
|
def release(claim_id: int | None = None, scope: str | None = None) -> list[int]:
|
|
"""Release your claim by id or scope when the work is submitted or abandoned."""
|
|
if claim_id is None and not scope:
|
|
raise ToolError("give claim_id or scope")
|
|
return run(lambda h: h.release(me(), claim_id, scope))
|
|
|
|
|
|
@mcp.tool
|
|
def claims(agent: str | None = None) -> list[dict]:
|
|
"""Active claims (everyone's, or one agent's)."""
|
|
return run(lambda h: h.active_claims(agent=agent))
|
|
|
|
|
|
@mcp.tool
|
|
def submit(title: str, scope: str | None = None, commit: str | None = None, branch: str | None = None,
|
|
note: str | None = None, task: int | None = None) -> dict:
|
|
"""Put finished work in the review queue (opens thread item:<id> with the reviewers). task: the task this
|
|
completes; landing the item closes the task, a change request reopens it for you."""
|
|
return run(lambda h: h.submit(me(), title, scope=scope, commit=commit, branch=branch, note=note, task=task))
|
|
|
|
|
|
TaskPriority = Literal["urgent", "high", "normal", "low"]
|
|
TaskMove = Literal["done", "blocked", "open", "cancelled", "in_progress"]
|
|
|
|
|
|
@mcp.tool
|
|
def next_task(take: bool = True) -> dict:
|
|
"""Take the best open task you may do now: highest priority first, dependencies finished, scope not claimed by
|
|
someone else. Taking it claims its scope and sets your `doing` line; the result includes what the team already
|
|
knows about that scope (notes, past threads, review items). take=False only shows it."""
|
|
def get(h):
|
|
task = h.next_task(me(), take=take)
|
|
if not task:
|
|
return {"task": None, "reason": "no open task you can take right now (or you are stopped)"}
|
|
return {"task": task, "context": h.context_text(task["scope"]) if task["scope"] else None}
|
|
return run(get)
|
|
|
|
|
|
@mcp.tool
|
|
def ask(question: str, options: list[str] | None = None, to: str | None = None, due: str | None = None,
|
|
fallback: str | None = None) -> dict:
|
|
"""Ask the human (default) or an agent a question. options become answer buttons on the chat page (keep them
|
|
short: "Yes", "No", "Later"). fallback: a coordinator who may answer while the human is away. Then use
|
|
wait_for_messages(): the answer arrives as a reply."""
|
|
return run(lambda h: h.ask(me(), question, options or (), to=to, due=due, fallback=fallback, origin="mcp"))
|
|
|
|
|
|
@mcp.tool
|
|
def answer(question_id: int, answer: str) -> dict:
|
|
"""Answer a question addressed to you (one of its options, or 'other: ...')."""
|
|
return run(lambda h: h.answer(me(), question_id, answer, origin="mcp"))
|
|
|
|
|
|
@mcp.tool
|
|
def questions(include_answered: bool = False) -> list[dict]:
|
|
"""Questions to you and from you."""
|
|
def get(h):
|
|
agent = me()
|
|
return [q for q in h.questions(open_only=not include_answered, limit=100) if agent in (q["to_name"], q["asker"])]
|
|
return run(get)
|
|
|
|
|
|
@mcp.tool
|
|
def note(scope: str, text: str, replaces: int | None = None) -> dict:
|
|
"""Add to the team's memory about a scope (fn:0x..., range:..., file:..., name:...): an ABI detail, a trap, a
|
|
decision. Everyone who later takes work on that scope sees it."""
|
|
return run(lambda h: h.note(me(), scope, text, replaces=replaces))
|
|
|
|
|
|
@mcp.tool
|
|
def context(scope: str) -> str:
|
|
"""Everything the team knows about a scope: notes, threads, messages that mention it, tasks, review items and
|
|
who claimed it before. Read it before you start on a function."""
|
|
return run(lambda h: h.context_text(scope, limit=15))
|
|
|
|
|
|
@mcp.tool
|
|
def metric(name: str, value: float, note: str | None = None) -> dict:
|
|
"""Report a number the board charts over time (e.g. readable=779 after a landing, tests_passing=58)."""
|
|
return run(lambda h: h.metric(me(), name, value, note))
|
|
|
|
|
|
@mcp.tool
|
|
def tasks(mine: bool = False, include_closed: bool = False) -> list[dict]:
|
|
"""The task list: open, in progress, blocked and in review (include_closed adds done and cancelled)."""
|
|
from .core import TASK_STATES
|
|
states = TASK_STATES if include_closed else ("open", "in_progress", "blocked", "review")
|
|
return run(lambda h: h.tasks(states=states, assignee=me() if mine else None))
|
|
|
|
|
|
@mcp.tool
|
|
def add_task(title: str, body: str | None = None, scope: str | None = None, assignee: str | None = None,
|
|
priority: TaskPriority = "normal", after: list[int] | None = None, due: str | None = None) -> dict:
|
|
"""Add a task (split your work, or record follow-up work). scope: fn:0x..., range:..., file:..., task:...;
|
|
after: task ids that must be done first; assignee: yourself, or anyone if you are a coordinator."""
|
|
return run(lambda h: h.add_task(me(), title, body=body, scope=scope, assignee=assignee, priority=priority,
|
|
after=after or [], due=due, origin="mcp"))
|
|
|
|
|
|
@mcp.tool
|
|
def take_task(task_id: int) -> dict:
|
|
"""Take a specific open task (claims its scope)."""
|
|
return run(lambda h: h.take_task(me(), task_id))
|
|
|
|
|
|
@mcp.tool
|
|
def update_task(task_id: int, state: TaskMove, note: str | None = None) -> dict:
|
|
"""Move a task: done (with a note on what you did), blocked (note says by what), open (hand it back),
|
|
cancelled, in_progress (resume a blocked task)."""
|
|
return run(lambda h: h.update_task(me(), task_id, state, note=note))
|
|
|
|
|
|
@mcp.tool
|
|
def handoff(text: str) -> dict:
|
|
"""Record where you left off: what is finished, what is in flight (branch, files, half-done steps), what comes
|
|
next. Shown at the start of your next session and to the team. Write one before your session ends."""
|
|
return run(lambda h: h.handoff(me(), text))
|
|
|
|
|
|
@mcp.tool
|
|
def pin(message_id: int, note: str | None = None, unpin: bool = False) -> list[dict]:
|
|
"""Pin a message the whole team should keep seeing (a rule, a decision, an address list), or unpin it."""
|
|
return run(lambda h: [compact(m) for m in (h.unpin(me(), message_id) if unpin else h.pin(me(), message_id, note))])
|
|
|
|
|
|
@mcp.tool
|
|
def review(item_id: int, state: ReviewState, note: str | None = None, commit: str | None = None) -> dict:
|
|
"""Move a review item: reviewers use in_review / changes_requested / approved / held / rejected; the author
|
|
re-submits with state="submitted" (and the new commit)."""
|
|
return run(lambda h: h.transition(me(), item_id, state, note=note, commit=commit))
|
|
|
|
|
|
@mcp.tool
|
|
def withdraw(item_id: int, note: str | None = None) -> dict:
|
|
"""The author withdraws their own review item."""
|
|
return run(lambda h: h.transition(me(), item_id, "withdrawn", note=note))
|
|
|
|
|
|
@mcp.tool
|
|
def land(item_id: int, commit: str, note: str | None = None) -> dict:
|
|
"""Coordinator only: the approved item is on main in this commit."""
|
|
return run(lambda h: h.transition(me(), item_id, "landed", note=note, commit=commit))
|
|
|
|
|
|
@mcp.tool
|
|
def queue(include_closed: bool = False) -> list[dict]:
|
|
"""The review queue (open items; include_closed adds landed, withdrawn and rejected)."""
|
|
states = OPEN_ITEM_STATES + (("landed", "withdrawn", "rejected") if include_closed else ())
|
|
return run(lambda h: h.items(states=states))
|
|
|
|
|
|
@mcp.tool
|
|
def status() -> dict:
|
|
"""Stop flags, everyone's presence (what they are doing, when last seen), unread counts, queue size and anything
|
|
that needs attention."""
|
|
def get(h):
|
|
agents = []
|
|
for a in h.agents():
|
|
unread, urgent = h.unread_count(a["name"])
|
|
agents.append({"name": a["name"], "doing": a.get("doing"), "last_seen": a["last_seen"], "unread": unread,
|
|
"urgent": urgent})
|
|
return {"stopped_for_me": [f["key"] for f in h.stopped(me())], "flags": list(h.flags().values()),
|
|
"agents": agents, "open_review_items": len(h.items()), "active_claims": len(h.active_claims()),
|
|
"attention": [a["text"] for a in h.attention()]}
|
|
return run(get)
|
|
|
|
|
|
@mcp.tool
|
|
def digest(since: str = "8h") -> dict:
|
|
"""What happened since a time ("8h", "90m", "2d" or an ISO time): per-agent activity, review and claim changes,
|
|
stops, and messages to the human."""
|
|
def get(h):
|
|
d = h.digest(since)
|
|
d["to_human"] = [compact(m) for m in d["to_human"]]
|
|
return d
|
|
return run(get)
|
|
|
|
|
|
@mcp.tool
|
|
def stop(target: str = "self", reason: str | None = None, relay_of: int | None = None) -> dict:
|
|
"""Stop yourself (target="self"). A coordinator may stop anyone. To stop or resume someone on the human's
|
|
request, relay their chat-page message: relay_of = that message id."""
|
|
def do(h):
|
|
agent = me()
|
|
who = agent if target == "self" else target
|
|
key = "stop:all" if who.lower() == "all" else f"stop:{who}"
|
|
return h.set_flag(key, "on", agent, "mcp", reason=reason, relay_of=relay_of)
|
|
return run(do)
|
|
|
|
|
|
@mcp.tool
|
|
def summarize(thread: str, text: str) -> dict:
|
|
"""Pin a short summary to a long thread: what was decided, what is open, who does what next. It is shown on top
|
|
of the thread and in `context` for its scope."""
|
|
return run(lambda h: h.summarize(me(), thread, text))
|
|
|
|
|
|
@mcp.tool
|
|
def who_knows(scope: str) -> list[dict]:
|
|
"""Agents ranked by how much they have worked on a scope - ask them first."""
|
|
return run(lambda h: h.experts(scope))
|
|
|
|
|
|
@mcp.tool
|
|
def git_status() -> dict:
|
|
"""Your worktree as Harbor sees it: branch, last commit, uncommitted files, commits behind the main branch, and
|
|
commits made since your last submit (work not yet in the review queue)."""
|
|
return run(lambda h: h.git_status(me(), max_age=5) or {"error": "no workdir for you in the project file"})
|
|
|
|
|
|
@mcp.tool
|
|
def add_tasks(lines: str, template: str | None = None, priority: TaskPriority = "normal") -> dict:
|
|
"""Add many tasks at once, one per line ("0x445310 RouteCellCost" or a plain title). Addresses go to their band
|
|
owner. template: run a template from the project file for every line."""
|
|
return run(lambda h: h.bulk_tasks(me(), lines, template=template, priority=priority, origin="mcp"))
|
|
|
|
|
|
@mcp.tool
|
|
def resume(target: str, relay_of: int | None = None, reason: str | None = None) -> dict:
|
|
"""Only the human resumes. A coordinator may lift a stop it set itself (not one it relayed for the human)."""
|
|
def do(h):
|
|
key = "stop:all" if target.lower() == "all" else f"stop:{target}"
|
|
return h.set_flag(key, "off", me(), "mcp", reason=reason, relay_of=relay_of)
|
|
return run(do)
|
|
|
|
|
|
_reverse = {}
|
|
|
|
|
|
def _re(fn):
|
|
"""Run a reverse-engineering lookup against the project's [reverse] files (read-only, no Harbor lock needed)."""
|
|
try:
|
|
h = harbor()
|
|
r = _reverse.get(h.project.name)
|
|
if r is None:
|
|
from .reverse import Reverse
|
|
r = _reverse[h.project.name] = Reverse(h.project.reverse)
|
|
return fn(r)
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise ToolError(str(error)) from None
|
|
|
|
|
|
@mcp.tool
|
|
def re_disassemble(target: str, max_bytes: int | None = None) -> str:
|
|
"""Disassemble the whole function at an address or name (or max_bytes from the address), from the project's own
|
|
image of the executable, with call targets, imports and known globals named from the team's annotations. Use
|
|
this instead of writing your own disassembler script."""
|
|
return _re(lambda r: r.disassemble(target, max_bytes))
|
|
|
|
|
|
@mcp.tool
|
|
def re_xrefs(target: str) -> dict:
|
|
"""Callers and callees of the function at an address or name, plus the globals, imports and strings it uses,
|
|
each with its current name."""
|
|
return _re(lambda r: r.xrefs(target))
|
|
|
|
|
|
@mcp.tool
|
|
def re_bytes(target: str, count: int = 16, as_type: Literal["hex", "int32", "uint32", "float", "double",
|
|
"string"] = "hex") -> dict:
|
|
"""Bytes at an address or name, read as hex, int32, uint32, float, double or a C string (constants, tables,
|
|
initial values of globals)."""
|
|
return _re(lambda r: r.bytes_at(target, count, as_type))
|
|
|
|
|
|
@mcp.tool
|
|
def re_lookup(target: str) -> dict:
|
|
"""What the team has recorded about an address or name: the names.csv, functions.csv and globals.csv rows, the
|
|
prototype, the containing function, and the decompiled file with its status."""
|
|
return _re(lambda r: r.lookup(target))
|
|
|
|
|
|
_ghidra = {}
|
|
|
|
|
|
def _gh(fn):
|
|
try:
|
|
h = harbor()
|
|
g = _ghidra.get(h.project.name)
|
|
if g is None:
|
|
from .reverse import Ghidra
|
|
g = _ghidra[h.project.name] = Ghidra(h.project.reverse)
|
|
return fn(g)
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise ToolError(str(error)) from None
|
|
|
|
|
|
@mcp.tool()
|
|
def re_match(targets: str, decomp: str | None = None) -> dict:
|
|
"""Compile functions with the game's own compiler (Visual C++ 2003, cl 13.10) and compare them with the
|
|
original bytes. targets: up to 50 addresses, e.g. '0x412F70, 0x4ADC70'; decomp: your worktree (default main).
|
|
status 'match' means byte-identical apart from linker-filled addresses; 'arguments in registers' means the
|
|
game passed them in registers (link-time code generation) and the rest is identical. similarity (0-1)
|
|
compares instructions, so you can see whether a rewrite moved closer. Matching is a bonus, not a gate:
|
|
readable, correct C comes first."""
|
|
try:
|
|
from .reverse import match_functions
|
|
return match_functions(harbor().project.reverse, targets, decomp)
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise ToolError(str(error)) from None
|
|
|
|
|
|
@mcp.tool()
|
|
def re_match_program(targets: str = "", decomp: str | None = None) -> dict:
|
|
"""The exact-match check the way the game was built: every readable function of your worktree (decomp)
|
|
goes into one /GL + /LTCG image with the game's compiler, so register-argument conventions and inlining
|
|
reproduce (re_match compiles one file at a time and cannot). Slow (1.5 to 9 minutes): run it once before
|
|
submitting, with targets = the functions you changed. Statuses: exact, exact-relaxed (identical after
|
|
dropping leading stack-argument loads), exact-but-targets-differ, differs (with similarity and the
|
|
register inputs on both sides)."""
|
|
try:
|
|
from .reverse import match_program
|
|
return match_program(harbor().project.reverse, targets, decomp)
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise ToolError(str(error)) from None
|
|
|
|
|
|
@mcp.tool()
|
|
def re_behaviour(targets: str, decomp: str | None = None) -> list:
|
|
"""The emulator check: run the game's own machine code and your compiled C of each function (up to 30
|
|
addresses; decomp = your worktree) from the same registers, stack and memory, 12 trials each, and compare the
|
|
result, every memory write, the calls made and their arguments. verdict 'same' means it behaves like the game;
|
|
'differs' gives the first divergence in words (e.g. 'returns 1 vs 0', 'original writes 0x86BAC0=5, ours 4');
|
|
'weak' means no trial ran to the end in both. Use it before submitting anything that does not byte-match."""
|
|
try:
|
|
from .reverse import behaviour_check
|
|
return behaviour_check(harbor().project.reverse, targets, decomp)
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise ToolError(str(error)) from None
|
|
|
|
|
|
@mcp.tool()
|
|
def re_similar(target: str, k: int = 10, min_score: float = 0.7) -> dict:
|
|
"""Functions whose instructions look like this one (normalized: addresses blanked, registers and small
|
|
constants kept), best first, with each one's status. A readable near-duplicate is a head start: read it
|
|
first. 0.85+ is usually the same code (template copies); 0.75-0.85 the same idiom; check before reusing."""
|
|
try:
|
|
from .reverse import run_toolkit
|
|
return run_toolkit(harbor().project.reverse, "similar",
|
|
[str(target), "--k", str(max(1, min(int(k), 50))), "--min", str(float(min_score))])
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise ToolError(str(error)) from None
|
|
|
|
|
|
@mcp.tool()
|
|
def re_struct_audit(struct: str) -> list:
|
|
"""Check a struct's field types against how the game's code accesses it through its globals: width,
|
|
signedness (movsx/movzx), float vs int, and unnamed bytes the code uses. Use it before changing types.h."""
|
|
try:
|
|
from .reverse import run_toolkit
|
|
return run_toolkit(harbor().project.reverse, "struct_audit", ["--struct", str(struct)], json_to_file=True)
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise ToolError(str(error)) from None
|
|
|
|
|
|
def _queue():
|
|
from .queue import FunctionQueue
|
|
return FunctionQueue(harbor())
|
|
|
|
|
|
@mcp.tool()
|
|
def next_functions(n: int = 10) -> list:
|
|
"""The functions in your bands to decompile next, easiest first: not readable yet, not deferred, not claimed
|
|
by someone else, rejected fewer than the attempt cap. Each row says why it ranks where it does (size, how
|
|
many callees already have names, the convention from the bytes, earlier rejections)."""
|
|
try:
|
|
q = _queue()
|
|
return q.next_functions(me(), max(1, min(int(n), 50)), similar_readable=q.similar_readable())
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise ToolError(str(error)) from None
|
|
|
|
|
|
@mcp.tool()
|
|
def defer(address: str, reason: str, evidence: str) -> dict:
|
|
"""Set a function aside instead of trying it again: reason is one of register-args, needs-type,
|
|
callee-unnamed, inline, x87, layout, too-large, engine-template, data-not-code, other; evidence says what you
|
|
found so the next person starts from it. It becomes a DEFERRED note on the function; next_functions skips it
|
|
until the note is replaced. Defer after two failed attempts rather than looping."""
|
|
try:
|
|
return _queue().defer(me(), address, reason, evidence)
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise ToolError(str(error)) from None
|
|
|
|
|
|
@mcp.tool()
|
|
def decomp_progress() -> dict:
|
|
"""Every function in a tier: exact (byte match with the game's compiler), verified (same behaviour in the
|
|
emulator), readable, deferred, raw; library and engine counted apart. Functions and bytes per tier."""
|
|
try:
|
|
return _queue().progress()
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise ToolError(str(error)) from None
|
|
|
|
|
|
@mcp.tool()
|
|
def ghidra_tools() -> list:
|
|
"""The tools of the team's shared Ghidra session (GhidrAssistMCP, read-only, the curated project with every
|
|
applied annotation): name, description and arguments. Call them with ghidra(tool, arguments)."""
|
|
return _gh(lambda g: g.tools())
|
|
|
|
|
|
@mcp.tool()
|
|
def ghidra(tool: str, arguments: dict | None = None) -> str:
|
|
"""Run one tool in the shared read-only Ghidra session, e.g. ghidra('get_code', {'function': 'GetFogLevel',
|
|
'format': 'decompiler'}), ghidra('get_function_stack_layout', {'function_name_or_address': '0x4ADC70'}),
|
|
ghidra('struct', {'action': 'field_xrefs', ...}). Long decompiles are waited for. Renames, retypes and other
|
|
changes are refused: names and types change through annotations/ and land on main."""
|
|
return _gh(lambda g: g.call(tool, arguments or {}))
|
|
|
|
|
|
_debugger = {}
|
|
|
|
|
|
def _dbg(fn):
|
|
try:
|
|
h = harbor()
|
|
d = _debugger.get(h.project.name)
|
|
if d is None:
|
|
from .reverse import Debugger
|
|
d = _debugger[h.project.name] = Debugger(h.project.reverse)
|
|
return fn(d)
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise ToolError(str(error)) from None
|
|
|
|
|
|
@mcp.tool()
|
|
def debugger_tools() -> list:
|
|
"""The tools of your own x64dbg session (x64dbg-automate on x32dbg): start_session, set_breakpoint, go,
|
|
get_all_registers, read_memory, eval_expression, ... Call them with debugger(tool, arguments)."""
|
|
return _dbg(lambda d: d.tools())
|
|
|
|
|
|
@mcp.tool()
|
|
def debugger(tool: str, arguments: dict | None = None) -> str:
|
|
"""Run one x64dbg tool in your own debugger session, e.g. debugger('start_session', {'target_exe':
|
|
"R:/SteamLibrary/steamapps/common/Sid Meier's Pirates!/Pirates!.exe"}), debugger('set_breakpoint', ...),
|
|
debugger('go'), debugger('get_all_registers'). The session lasts until debugger('terminate_session') or
|
|
until your app closes. Use it to read values, not to patch the game."""
|
|
return _dbg(lambda d: d.call(tool, arguments or {}))
|
|
|
|
|
|
def _report_in():
|
|
"""Tell Harbor this agent's app has its tools running: at start, then every 5 minutes while the app keeps the
|
|
server open (updates the connection only, not the agent's last-seen time)."""
|
|
def loop():
|
|
while True:
|
|
time.sleep(300)
|
|
try:
|
|
with _lock:
|
|
h = harbor()
|
|
h._write(lambda c: c.execute(
|
|
"UPDATE connections SET last_at=? WHERE agent=? AND channel='mcp' AND host=?",
|
|
(h.now(), me(), os.environ.get("HARBOR_HOST", ""))))
|
|
except Exception: # noqa: BLE001 - reporting in is best effort
|
|
pass
|
|
try:
|
|
with _lock:
|
|
h = harbor()
|
|
h.connect(me(), "mcp", os.environ.get("HARBOR_HOST", ""))
|
|
_state["seen"] = time.monotonic()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
threading.Thread(target=loop, daemon=True).start()
|
|
|
|
|
|
def main(project=None):
|
|
if project is not None:
|
|
_state["project"] = project
|
|
try:
|
|
me() # refuse to start without a valid identity
|
|
except (HarborError, projects.ProjectError) as error:
|
|
raise SystemExit(f"harbor mcp: {error}")
|
|
_report_in()
|
|
mcp.run(show_banner=False)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|