Shared app servers: the PreToolUse hook leaves a ticket naming the calling session, the MCP server takes it per call (middleware), so two sessions of one app on one Harbor server act as themselves; i_am checks instead of binding and is allowed under a stop
This commit is contained in:
1 parent
e283e4cc54
commit
fdaa3b0806
4 files changed
+131
-18
No files matched your search
+5
-1
@@ -63,7 +63,7 @@ ALLOWED_COMMANDS = {"brief", "inbox", "ack", "thread", "threads", "search", "tai
|
|||||||
"tasks", "pins", "decisions", "handoffs", "handoff", "item", "pending-acks", "wait", "doing", "send",
|
"tasks", "pins", "decisions", "handoffs", "handoff", "item", "pending-acks", "wait", "doing", "send",
|
||||||
"reply", "answer", "questions", "context", "notes", "note", "digest", "attention", "stop", "who",
|
"reply", "answer", "questions", "context", "notes", "note", "digest", "attention", "stop", "who",
|
||||||
"feed", "summarize", "git", "templates", "pulse", "metrics", "wins"}
|
"feed", "summarize", "git", "templates", "pulse", "metrics", "wins"}
|
||||||
ALLOWED_TOOLS = {"brief", "inbox", "ack", "thread", "threads", "search", "status", "queue", "claims", "tasks", "handoff",
|
ALLOWED_TOOLS = {"brief", "i_am", "mark_read", "inbox", "ack", "thread", "threads", "search", "status", "queue", "claims", "tasks", "handoff",
|
||||||
"send", "reply", "wait_for_messages", "doing", "questions", "answer", "context", "notes", "note",
|
"send", "reply", "wait_for_messages", "doing", "questions", "answer", "context", "notes", "note",
|
||||||
"digest", "pin", "close_thread", "stop", "summarize", "who_knows", "git_status"}
|
"digest", "pin", "close_thread", "stop", "summarize", "who_knows", "git_status"}
|
||||||
TOOL_NAME = re.compile(r"^(?:mcp__harbor__|harbor[_.])([a-z_]+)$")
|
TOOL_NAME = re.compile(r"^(?:mcp__harbor__|harbor[_.])([a-z_]+)$")
|
||||||
@@ -276,6 +276,10 @@ def _run(event, harbor, agent, host, payload):
|
|||||||
try: # bookkeeping is best effort
|
try: # bookkeeping is best effort
|
||||||
from .launch import describe_target
|
from .launch import describe_target
|
||||||
tool_input = payload.get("tool_input") or payload.get("input") or {}
|
tool_input = payload.get("tool_input") or payload.get("input") or {}
|
||||||
|
harbor_tool = _harbor_tool(str(tool))
|
||||||
|
if harbor_tool is not None: # tell the app's shared Harbor server which session is calling
|
||||||
|
from . import sessions
|
||||||
|
sessions.leave(harbor, host, harbor_tool, tool_input if isinstance(tool_input, dict) else {}, agent)
|
||||||
paths = harbor.edited_paths(tool, tool_input)
|
paths = harbor.edited_paths(tool, tool_input)
|
||||||
harbor.activity(agent, tool, host, target=describe_target(tool, tool_input, paths))
|
harbor.activity(agent, tool, host, target=describe_target(tool, tool_input, paths))
|
||||||
if not stops:
|
if not stops:
|
||||||
|
|||||||
+40
-17
@@ -5,6 +5,7 @@ Run with the agent's identity fixed in its environment (it cannot be changed fro
|
|||||||
The project comes from $HARBOR_PROJECT or the .harbor.toml above the working directory. The human is refused.
|
The project comes from $HARBOR_PROJECT or the .harbor.toml above the working directory. The human is refused.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextvars
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -14,8 +15,9 @@ os.environ.setdefault("FASTMCP_CHECK_FOR_UPDATES", "off") # no network call
|
|||||||
os.environ.setdefault("FASTMCP_SHOW_SERVER_BANNER", "false")
|
os.environ.setdefault("FASTMCP_SHOW_SERVER_BANNER", "false")
|
||||||
from fastmcp import FastMCP # noqa: E402
|
from fastmcp import FastMCP # noqa: E402
|
||||||
from fastmcp.exceptions import ToolError # noqa: E402
|
from fastmcp.exceptions import ToolError # noqa: E402
|
||||||
|
from fastmcp.server.middleware import Middleware # noqa: E402
|
||||||
|
|
||||||
from . import projects # noqa: E402
|
from . import projects, sessions # noqa: E402
|
||||||
from .core import OPEN_ITEM_STATES, Harbor, HarborError # 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
|
INSTRUCTIONS = """Harbor is the team's shared coordination store: messages in threads, acknowledgements, claims, a
|
||||||
@@ -55,6 +57,30 @@ ClaimKind = Literal["draft", "naming", "edit", "review", "band"]
|
|||||||
mcp = FastMCP("harbor", instructions=INSTRUCTIONS)
|
mcp = FastMCP("harbor", instructions=INSTRUCTIONS)
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
_state = {"seen": 0.0}
|
_state = {"seen": 0.0}
|
||||||
|
_caller = contextvars.ContextVar("harbor_caller", default=None) # the session behind the current tool call
|
||||||
|
|
||||||
|
|
||||||
|
class SessionIdentity(Middleware):
|
||||||
|
"""One app may share this server between several sessions (Codex and Codex2). The app's PreToolUse hook names
|
||||||
|
the calling session in a ticket; take it so this call runs as that agent (harbor.sessions)."""
|
||||||
|
|
||||||
|
async def on_call_tool(self, context, call_next):
|
||||||
|
agent = None
|
||||||
|
try:
|
||||||
|
message = context.message
|
||||||
|
with _lock:
|
||||||
|
agent = sessions.take(harbor(), os.environ.get("HARBOR_HOST", ""), message.name,
|
||||||
|
dict(message.arguments or {}))
|
||||||
|
except Exception: # noqa: BLE001 - identity falls back to the server's own
|
||||||
|
agent = None
|
||||||
|
token = _caller.set(agent)
|
||||||
|
try:
|
||||||
|
return await call_next(context)
|
||||||
|
finally:
|
||||||
|
_caller.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
mcp.add_middleware(SessionIdentity())
|
||||||
|
|
||||||
|
|
||||||
def harbor():
|
def harbor():
|
||||||
@@ -67,9 +93,9 @@ def harbor():
|
|||||||
|
|
||||||
def me():
|
def me():
|
||||||
h = harbor()
|
h = harbor()
|
||||||
# a session bound with i_am, else the agent its working folder names (when it runs in the same app as the
|
# the session its app's hook named for this call (several sessions can share this server), else the agent the
|
||||||
# installed one: Codex and Codex2 share the Codex app), else the installed one
|
# server's working folder names when it runs in the same app as the installed one, else the installed one
|
||||||
agent = _state.get("bound") or h.project.session_identity(os.environ.get("HARBOR_AGENT"), os.getcwd()) or ""
|
agent = (_caller.get() or h.project.session_identity(os.environ.get("HARBOR_AGENT"), os.getcwd()) or "")
|
||||||
if agent not in h.project.ai_names:
|
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 "
|
raise HarborError(f"HARBOR_AGENT must be one of {', '.join(h.project.ai_names)} (set it in this server's MCP "
|
||||||
"config)")
|
"config)")
|
||||||
@@ -125,22 +151,19 @@ def run(fn):
|
|||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
def i_am(agent: str) -> dict:
|
def i_am(agent: str) -> dict:
|
||||||
"""Say which of the project's agents this session is, when one app runs several (Codex and Codex2). Call it once
|
"""Check which of the project's agents Harbor takes this session to be, when one app runs several (Codex and
|
||||||
at the start of the session if your instructions or your working folder name you; only an agent of the same
|
Codex2). Call it at the start of the session with the name your instructions give you. Harbor knows the session
|
||||||
app is accepted, and a server already bound to another agent refuses."""
|
from its working folder (through the app's hooks); if the answer is not you, stop and tell the user."""
|
||||||
def do(h):
|
def do(h):
|
||||||
pinned = os.environ.get("HARBOR_AGENT") or ""
|
|
||||||
if agent not in h.project.ai_names:
|
if agent not in h.project.ai_names:
|
||||||
raise HarborError(f"{agent} is not one of {', '.join(h.project.ai_names)}")
|
raise HarborError(f"{agent} is not one of {', '.join(h.project.ai_names)}")
|
||||||
if pinned and h.project.host_of(agent) != h.project.host_of(pinned):
|
seen = me()
|
||||||
raise HarborError(f"{agent} runs in another app ({h.project.host_of(agent) or 'unknown'}), not this one")
|
out = {"you_are": seen, "matches": seen == agent}
|
||||||
bound = _state.get("bound")
|
if seen != agent:
|
||||||
if bound and bound != agent:
|
out["fix"] = (f"Harbor sees this session as {seen}, not {agent}. Check that this session works in "
|
||||||
raise HarborError(f"this Harbor server is already {bound}: the app shares one server between sessions, "
|
f"{agent}'s own worktree and that the app's Harbor hooks are installed and trusted; do no "
|
||||||
"so give each session its own app profile instead")
|
"work until it matches.")
|
||||||
_state["bound"] = agent
|
return out
|
||||||
return {"you_are": agent, "working_folder": os.getcwd(),
|
|
||||||
"folder_says": h.project.identity_for(os.getcwd())}
|
|
||||||
return run(do)
|
return run(do)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Which agent made this MCP call, when one app runs several sessions (Codex and Codex2) on one shared Harbor server.
|
||||||
|
|
||||||
|
An app starts one Harbor MCP server and shares it between its sessions, so the server cannot tell them apart. The
|
||||||
|
app's PreToolUse hook can: it runs before every tool call with the session's working folder. So the hook leaves a
|
||||||
|
ticket (app, tool, arguments, agent) and the server takes the matching ticket when the call arrives - oldest first,
|
||||||
|
within two minutes. Without a ticket (hooks not installed or not trusted) the server falls back to its own identity."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS session_tickets(id INTEGER PRIMARY KEY, host TEXT NOT NULL, tool TEXT NOT NULL,
|
||||||
|
args TEXT NOT NULL, agent TEXT NOT NULL, at REAL NOT NULL, used INTEGER NOT NULL DEFAULT 0);
|
||||||
|
CREATE INDEX IF NOT EXISTS session_tickets_open ON session_tickets(host, tool, used, at);
|
||||||
|
"""
|
||||||
|
MAX_AGE = 120.0
|
||||||
|
|
||||||
|
|
||||||
|
def args_key(args):
|
||||||
|
return json.dumps(args or {}, sort_keys=True, separators=(",", ":"), default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def leave(harbor, host, tool, args, agent, now=None):
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
|
||||||
|
def do(c):
|
||||||
|
c.execute("DELETE FROM session_tickets WHERE at < ?", (now - 10 * MAX_AGE,))
|
||||||
|
c.execute("INSERT INTO session_tickets(host, tool, args, agent, at) VALUES (?,?,?,?,?)",
|
||||||
|
(host or "", tool, args_key(args), agent, now))
|
||||||
|
ensure(harbor)
|
||||||
|
harbor._write(do)
|
||||||
|
|
||||||
|
|
||||||
|
def take(harbor, host, tool, args, now=None):
|
||||||
|
"""The agent whose hook announced this exact call, or None."""
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
ensure(harbor)
|
||||||
|
|
||||||
|
def do(c):
|
||||||
|
row = c.execute("SELECT id, agent FROM session_tickets WHERE host=? AND tool=? AND args=? AND used=0 AND at>=? "
|
||||||
|
"ORDER BY at, id LIMIT 1", (host or "", tool, args_key(args), now - MAX_AGE)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
c.execute("UPDATE session_tickets SET used=1 WHERE id=?", (row[0],))
|
||||||
|
return row[1]
|
||||||
|
return harbor._write(do)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure(harbor):
|
||||||
|
if not getattr(harbor, "_session_tickets_ready", False):
|
||||||
|
harbor.conn.executescript(SCHEMA)
|
||||||
|
harbor._session_tickets_ready = True
|
||||||
@@ -288,6 +288,41 @@ class ServerWork(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class Mcp(unittest.TestCase):
|
class Mcp(unittest.TestCase):
|
||||||
|
def test_shared_server_takes_the_calling_session_from_the_hook_ticket(self):
|
||||||
|
"""One app's server shared by two sessions: each call runs as the agent its hook named in a ticket."""
|
||||||
|
from fastmcp import Client
|
||||||
|
from harbor import mcp_server, sessions
|
||||||
|
d = temp_dir()
|
||||||
|
project = make_home(d)["test"]
|
||||||
|
mcp_server._state.clear()
|
||||||
|
mcp_server._state["project"] = project
|
||||||
|
saved = {k: os.environ.get(k) for k in ("HARBOR_AGENT", "HARBOR_HOST")}
|
||||||
|
os.environ.update(HARBOR_AGENT="Codex", HARBOR_HOST="codex")
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with Client(mcp_server.mcp) as client:
|
||||||
|
sessions.leave(mcp_server.harbor(), "codex", "doing", {"text": "second session"}, "DeepSeek")
|
||||||
|
await client.call_tool("doing", {"text": "second session"}) # the ticket names DeepSeek
|
||||||
|
await client.call_tool("doing", {"text": "first session"}) # no ticket: the server's own
|
||||||
|
status = (await client.call_tool("status", {})).data
|
||||||
|
doing = {a["name"]: a["doing"] for a in status["agents"]}
|
||||||
|
self.assertEqual(doing["DeepSeek"], "second session")
|
||||||
|
self.assertEqual(doing["Codex"], "first session")
|
||||||
|
sessions.leave(mcp_server.harbor(), "codex", "i_am", {"agent": "DeepSeek"}, "DeepSeek")
|
||||||
|
self.assertTrue((await client.call_tool("i_am", {"agent": "DeepSeek"})).data["matches"])
|
||||||
|
self.assertFalse((await client.call_tool("i_am", {"agent": "DeepSeek"})).data["matches"]) # used up
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(scenario())
|
||||||
|
finally:
|
||||||
|
for k, v in saved.items():
|
||||||
|
if v is None:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
else:
|
||||||
|
os.environ[k] = v
|
||||||
|
mcp_server._state.pop("h").close()
|
||||||
|
mcp_server._state.clear()
|
||||||
|
|
||||||
def test_tools_bind_identity_and_round_trip(self):
|
def test_tools_bind_identity_and_round_trip(self):
|
||||||
from fastmcp import Client
|
from fastmcp import Client
|
||||||
from fastmcp.exceptions import ToolError
|
from fastmcp.exceptions import ToolError
|
||||||
|
|||||||
Reference in new issue
Block a user