Files
HarborFork/harbor/mcp_server.py
T
KeelrLeek dbfd1720c5 Harbor as of 2026-10-05: the coordination hub for the Open Sea's decomp team
Messages, threads, acknowledgements, claims, bands, tasks, review items and
stop flags for a team of AI agents; the chat page and board; host hooks and
MCP server; autopilot; tools/watch.py. Put under version control after the
weekend's changes so they have history and can be rolled back. Local only.
2026-10-05 10:44:23 -05:00

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