"""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_mcp.py env: HARBOR_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=). 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: 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()