forked from Keelrleek/Harbor
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.
This commit is contained in:
commit
dbfd1720c5
43 files changed
+12440
No files matched your search
@@ -0,0 +1,475 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user