Files
openworker/coworker/teams/mcp_server.py
T
Rohit C Prasad aafb4f4291 Feed model: interest follows the assignment relation; notes are pure appends
Worker delivery is now a read-time feed over its slice (assigned ∪ filed) — send-backs, comment answers, reassignments, cancels, and acceptance all arrive through one relation; per-event recipient addressing retired.
Reassignment delivers before interest ends; a new assignee replays the item's story. Detail pane gains Add a note (never changes state); external pending/consume become space-scoped feed calls.
2026-08-17 16:05:16 -07:00

212 lines
7.6 KiB
Python

"""`team-board` — the board and journal as an MCP server on stdio.
The way an external coding agent joins a team: its MCP config runs
`ocw board mcp --url … --token … --space …` (or `--db …` headless), it sees the
role-scoped board tools, and the user asks it to claim an item and work. Identity
and authority never live here: the dialect is already bound to one actor (token or
local flags), and every write is judged by the store/server — this file is a thin
adapter, safe to hand to any harness.
Tool results are JSON — raw data for the agent, not prose.
"""
from __future__ import annotations
from typing import Any, Optional
from .model import BoardError
def build(dialect, *, space: str):
"""Assemble the FastMCP server for one dialect+space. Split from serve() so
tests can inspect the registered tool set without a transport."""
from mcp.server.fastmcp import FastMCP
who = dialect.whoami()
role = who.get("role", "worker")
mcp = FastMCP(
"team-board",
instructions=(
f"A shared team work board (you are '{who.get('actor')}', role"
f" {role}) plus the team journal. Items carry acceptance criteria —"
" what gets verified before they can be done. Typical worker loop:"
" board_list → board_claim an open item → board_move to in_progress →"
" work, journal_append findings as you go → board_move to review with"
" a hand-off comment and refs. Never mark items done — done is the"
" verdict after review."
),
)
def _safe(func, *args, **kwargs) -> Any:
try:
return func(*args, **kwargs)
except (BoardError, ValueError) as error:
return {"error": str(error)}
@mcp.tool()
def board_list(state: str = "", assignee: str = "") -> Any:
"""List work items on the board, optionally filtered by state
(open/in_progress/blocked/review/done/canceled) or assignee."""
return _safe(
dialect.list_items, space, state=state or None, assignee=assignee or None
)
@mcp.tool()
def board_show(item: int) -> Any:
"""One work item in full: description, acceptance criteria, refs, links,
and every comment."""
return _safe(dialect.get_item, space, item)
@mcp.tool()
def board_create(
title: str,
criteria: str,
description: str = "",
parent: Optional[int] = None,
case: str = "",
) -> Any:
"""File a new work item (open, unassigned — work starts when it is
assigned or claimed). `criteria` is the acceptance criteria — what gets
verified before the item can be done; required."""
return _safe(
dialect.create_item,
space,
title=title,
criteria=criteria,
description=description,
parent=parent,
case=case or None,
)
@mcp.tool()
def board_claim(item: int) -> Any:
"""Claim an open, unassigned item for yourself. First claim wins; the
item becomes your assignment. Only claim work you can start on now."""
return _safe(dialect.claim, space, item)
@mcp.tool()
def board_move(item: int, to: str, comment: str = "", refs: list[str] = []) -> Any:
"""Move a work item: in_progress when you start, blocked with the blocker
as `comment`, review with a hand-off comment and artifact refs (branch,
PR, file:line) when finished."""
return _safe(
dialect.transition, space, item, to, comment=comment, refs=list(refs or [])
)
@mcp.tool()
def board_comment(item: int, body: str, refs: list[str] = []) -> Any:
"""Comment on a work item — durable and attributed; answers that matter
belong here. `refs` attach artifact pointers."""
return _safe(dialect.comment, space, item, body, refs=list(refs or []))
@mcp.tool()
def board_attach(item: int, path: str, caption: str = "") -> Any:
"""Attach a screenshot or image (png/jpg/gif/webp, ≤10MB) from a local
file to a work item — so the lead/reviewer can SEE what you did. Give it
a caption saying what the image shows. Great with review hand-offs."""
from pathlib import Path as _Path
source = _Path(path).expanduser()
if not source.is_file():
return {"error": f"no such file: {path}"}
return _safe(
dialect.attach,
space,
item,
source.read_bytes(),
source.name,
caption=caption,
)
@mcp.tool()
def board_pending() -> Any:
"""Your unconsumed feed: every event on items assigned to you or filed
by you — assignments, send-backs with feedback, comments from the lead
or user, cancellations. Check at the start of a work session and before
finishing; acknowledge with board_consume."""
return _safe(dialect.pending, space)
@mcp.tool()
def board_consume(upto_seq: int) -> Any:
"""Acknowledge feed events up to a sequence number (from board_pending),
so they are not re-delivered."""
return _safe(lambda: (dialect.consume(space, upto_seq), {"ok": True})[1])
if role in ("lead", "user"):
@mcp.tool()
def board_assign(item: int, assignee: str) -> Any:
"""Assign a work item to a worker (or to yourself to reserve it)."""
return _safe(dialect.assign, space, item, assignee)
@mcp.tool()
def board_link(src: int, kind: str, dst: int) -> Any:
"""Link two items: `parent` (dst becomes src's parent) or `blocks`
(src blocks dst)."""
return _safe(dialect.link, space, src, kind, dst)
@mcp.tool()
def board_policy(claims: str = "") -> Any:
"""Show the board's claim policy, or set it: `open` (workers may
self-claim open items) or `lead-only`."""
if claims:
return _safe(dialect.set_policy, space, claims=claims)
return _safe(dialect.policy, space)
@mcp.tool()
def journal_append(
case: str,
body: str,
kind: str = "note",
item: Optional[int] = None,
entities: list[str] = [],
refs: list[str] = [],
) -> Any:
"""Append to a journal case as you work: kind is finding, evidence,
decision, note, or raw (a capture excerpt referencing a file).
`entities` are the concrete things it is about (paths, resources, ids)."""
return _safe(
dialect.journal_append,
case,
body,
kind=kind,
space=space,
item=item,
entities=list(entities or []),
refs=list(refs or []),
)
@mcp.tool()
def journal_read(
case: str,
item: Optional[int] = None,
author: str = "",
kind: str = "",
entity: str = "",
include_raw: bool = False,
limit: int = 50,
) -> Any:
"""Read a journal case, filtered by item, author, entry kind, or entity.
Prefer narrow reads; raw captures are skipped unless asked."""
return _safe(
dialect.journal_read,
case,
item=item,
author=author or None,
kind=kind or None,
entity=entity or None,
include_raw=include_raw,
limit=limit,
)
@mcp.tool()
def journal_cases() -> Any:
"""The journal cases you can read, with entry counts."""
return _safe(dialect.journal_overview)
return mcp
def serve(dialect, *, space: str) -> None:
build(dialect, space=space).run("stdio")