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