forked from Keelrleek/Harbor
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.
476 lines
22 KiB
Python
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()
|