Files
HarborFork/harbor/mcp_server.py
T

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()