Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions frontend/src/hooks/useWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,21 @@ export function useWebSocket() {
}
break;
}

case 'library:update': {
// A Library space changed from outside this browser — an MCP client,
// or anything else that is not the UI. These queries do not poll, so
// without this the change is invisible until a reload, which is the
// one thing a background agent guarantees you will not think to do.
const space = event.data.space as string | undefined;
if (space) {
queryClient.invalidateQueries({ queryKey: ['library-tasks', space] });
queryClient.invalidateQueries({ queryKey: ['library-task-columns', space] });
}
// The tree carries notebooks and notes and is not keyed by space.
queryClient.invalidateQueries({ queryKey: ['library'] });
break;
}
}
});

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ export type WebSocketEventType =
| 'agent:typing'
| 'task:update'
| 'task:new'
// A Library space changed from outside this browser (e.g. an MCP client).
| 'library:update'
| 'project:update'
| 'channel:new'
| 'error'
Expand Down
41 changes: 33 additions & 8 deletions src/teamwork/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ def __init__(self, message: str, code: int = -32000):
"post_comment": CAP_MESSAGE_POST,
}

# Tools that change something. A caller watching the UI should see the result
# without reloading, so these announce themselves; reads say nothing.
MUTATING_TOOLS = frozenset({
"create_task", "update_task", "comment_on_task",
"create_notebook", "create_note", "update_note",
})

# Tools that take no ``space`` argument, and so cannot be checked against a
# space-scoped key the ordinary way. Each needs its own answer in `authorize`;
# adding a tool here without one is how a scope check gets silently skipped.
Expand Down Expand Up @@ -447,13 +454,17 @@ async def _dispatch_library(tool: str, arguments: dict[str, Any],


async def call_tool(client: AgentClient, tool: str, arguments: dict[str, Any],
*, post_comment: Any = None) -> Any:
*, post_comment: Any = None, on_change: Any = None) -> Any:
"""Authorise and run one tool call.

``post_comment`` is injected by the route because posting to a channel
needs the request-scoped database session and signature header that only
the HTTP layer has. Everything else goes to the Library over HTTP and needs
neither, which is why this module stays testable without a running app.
``post_comment`` and ``on_change`` are injected by the route because both
need things only the HTTP layer has — a request-scoped database session, and
the websocket manager. Everything else goes to the Library over HTTP and
needs neither, which is why this module stays testable without a running app.

``on_change`` fires only after a mutation actually succeeded. Announcing the
attempt would put the UI a step ahead of the data: it would refetch, see the
old state, and quietly disagree with what the agent was told.
"""
authorize(client, tool, arguments)

Expand All @@ -465,11 +476,24 @@ async def call_tool(client: AgentClient, tool: str, arguments: dict[str, Any],
return await post_comment(project_id=project_id, channel_id=channel_id,
content=content)

return await _dispatch_library(tool, arguments, client)
result = await _dispatch_library(tool, arguments, client)

if on_change is not None and tool in MUTATING_TOOLS:
try:
await on_change(space=arguments.get("space"), tool=tool)
except Exception: # noqa: BLE001
# The write already happened. Failing the call now would tell the
# agent its change was rejected when it was not — a reload still
# shows the truth, so a missed notification is the smaller harm.
logger.warning("could not announce an MCP change to the UI",
exc_info=True)

return result


async def handle_request(client: AgentClient, payload: dict[str, Any],
*, post_comment: Any = None) -> dict[str, Any] | None:
*, post_comment: Any = None,
on_change: Any = None) -> dict[str, Any] | None:
"""Handle one JSON-RPC request. Returns ``None`` for a notification."""
request_id = payload.get("id")
method = payload.get("method") or ""
Expand All @@ -490,7 +514,8 @@ async def handle_request(client: AgentClient, payload: dict[str, Any],
tool = params.get("name") or ""
arguments = params.get("arguments") or {}
try:
result = await call_tool(client, tool, arguments, post_comment=post_comment)
result = await call_tool(client, tool, arguments,
post_comment=post_comment, on_change=on_change)
except McpError as exc:
# A refused or failed tool call is reported as a tool result with
# isError, not a protocol error: the agent should read it, correct
Expand Down
25 changes: 23 additions & 2 deletions src/teamwork/routers/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from teamwork.agent_auth import AgentClient
from teamwork.models import get_db
from teamwork.routers.external import _verify_api_key
from teamwork.websocket import EventType, WebSocketEvent, manager

router = APIRouter(tags=["mcp"])
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -48,6 +49,24 @@ async def post(*, project_id: str, channel_id: str, content: str) -> Any:
return post


def _change_announcer():
"""Tell every open browser that a Library space changed.

The Kanban and note queries do not poll, so a write that did not come from
the UI was invisible until someone reloaded — which is exactly what an agent
working in the background produces. Broadcast to all: a Library space is not
scoped to a project on the TeamWork side, and a client that does not care
ignores an event it has no query for.
"""
async def announce(*, space: str | None, tool: str) -> None:
await manager.broadcast_all(WebSocketEvent(
type=EventType.LIBRARY_UPDATE,
data={"space": space, "tool": tool, "source": "mcp"},
))

return announce


@router.post("/mcp")
async def mcp_endpoint(
request: Request,
Expand All @@ -58,12 +77,14 @@ async def mcp_endpoint(
"""One JSON-RPC request or a batch of them."""
if isinstance(payload, list):
responses = [await mcp_server.handle_request(
client, item, post_comment=_comment_poster(request, db, client))
client, item, post_comment=_comment_poster(request, db, client),
on_change=_change_announcer())
for item in payload]
return [r for r in responses if r is not None]

result = await mcp_server.handle_request(
client, payload, post_comment=_comment_poster(request, db, client))
client, payload, post_comment=_comment_poster(request, db, client),
on_change=_change_announcer())
if result is None:
return {} # notification: acknowledged, no body
return result
4 changes: 4 additions & 0 deletions src/teamwork/websocket/connection_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ class EventType(str, Enum):
AGENT_TYPING = "agent:typing" # Typing indicator
TASK_UPDATE = "task:update"
TASK_NEW = "task:new"
# A Library space changed from outside this browser — an MCP client, or any
# writer that is not the UI itself. The Kanban and note queries have no
# polling, so without this the only way to see the change is a reload.
LIBRARY_UPDATE = "library:update"
PROJECT_UPDATE = "project:update"
CHANNEL_NEW = "channel:new"
ERROR = "error"
Expand Down
66 changes: 66 additions & 0 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,69 @@ def test_every_spaceless_tool_is_answered_in_authorize():

assert SPACELESS_TOOLS == {"list_spaces", "post_comment"}, (
"a new spaceless tool needs an explicit scope decision in authorize()")


# ── Telling the UI something changed ─────────────────────────────────────────

@pytest.mark.asyncio
async def test_a_write_announces_itself(prax):
"""A card filed by an agent should appear without a reload.

The Kanban query does not poll, so a write that did not come from the UI was
invisible until someone refreshed — which is the one thing you will not
think to do while a background agent is working.
"""
seen = []

async def on_change(**kw):
seen.append(kw)

await _call(client(allow=frozenset({CAP_TASK_WRITE})), "create_task",
{"space": "proj-a", "title": "Ship it"}, on_change=on_change)
assert seen == [{"space": "proj-a", "tool": "create_task"}]


@pytest.mark.asyncio
async def test_a_read_announces_nothing(prax):
# Nothing changed, so there is nothing for anyone to refetch.
seen = []

async def on_change(**kw):
seen.append(kw)

await _call(client(), "list_tasks", {"space": "proj-a"}, on_change=on_change)
assert seen == []


@pytest.mark.asyncio
async def test_a_refused_call_announces_nothing(prax):
"""The UI must not be told about a change that did not happen."""
seen = []

async def on_change(**kw):
seen.append(kw)

with pytest.raises(McpError):
await _call(client(spaces=frozenset({"proj-a"}),
allow=frozenset({CAP_TASK_WRITE})),
"create_task", {"space": "other", "title": "x"},
on_change=on_change)
assert seen == []


@pytest.mark.asyncio
async def test_a_failed_announcement_does_not_fail_the_write(prax):
"""The write already happened.

Failing the call now would tell the agent its change was rejected when it
was not. A reload still shows the truth, so a missed notification is the
smaller harm.
"""
async def on_change(**kw):
raise RuntimeError("websocket is down")

result = await _call(client(allow=frozenset({CAP_TASK_WRITE})), "create_task",
{"space": "proj-a", "title": "Ship it"},
on_change=on_change)
assert result == {"ok": True}
assert prax.calls, "the write still went through"
Loading