generated from coulomb/repo-seed
refactor(hub-core): mount capability write router and compose MCP tools
Use create_capability_request_write_router with dev-hub callbacks and attach generic HubCoreMCPServer tools while keeping enriched local overrides.
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -26,47 +25,40 @@ from api.schemas.capability_request import (
|
||||
from hub_core.routers.capabilities import (
|
||||
create_capability_catalog_router,
|
||||
create_capability_request_read_router,
|
||||
)
|
||||
|
||||
|
||||
router = create_capability_catalog_router(
|
||||
get_session,
|
||||
domain_model=Domain,
|
||||
repo_model=ManagedRepo,
|
||||
catalog_model=CapabilityCatalog,
|
||||
)
|
||||
router.include_router(
|
||||
create_capability_request_read_router(
|
||||
get_session,
|
||||
domain_model=Domain,
|
||||
request_model=CapabilityRequest,
|
||||
request_read_schema=CapabilityRequestRead,
|
||||
)
|
||||
create_capability_request_write_router,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Request endpoints
|
||||
# Write-router callbacks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_request(
|
||||
async def _route_capability(
|
||||
session: AsyncSession,
|
||||
body: CapabilityRequestCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
req_domain = await _resolve_domain(body.requesting_domain, session)
|
||||
|
||||
# Route to provider
|
||||
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability(
|
||||
session, body.capability_type, body.title, body.description or ""
|
||||
) -> tuple[uuid.UUID | None, uuid.UUID | None, str | None]:
|
||||
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability_match(
|
||||
session,
|
||||
body.capability_type,
|
||||
body.title,
|
||||
body.description or "",
|
||||
)
|
||||
return fulfilling_domain_id, catalog_entry_id, routing_note
|
||||
|
||||
req = CapabilityRequest(
|
||||
|
||||
def _build_capability_request(
|
||||
body: CapabilityRequestCreate,
|
||||
requesting_domain: Domain,
|
||||
fulfilling_domain_id: uuid.UUID | None,
|
||||
catalog_entry_id: uuid.UUID | None,
|
||||
routing_note: str | None,
|
||||
) -> CapabilityRequest:
|
||||
return CapabilityRequest(
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
capability_type=body.capability_type,
|
||||
priority=body.priority,
|
||||
requesting_domain_id=req_domain.id,
|
||||
requesting_domain_id=requesting_domain.id,
|
||||
requesting_agent=body.requesting_agent,
|
||||
requesting_workplan_id=body.requesting_workplan_id,
|
||||
blocking_task_id=body.blocking_task_id,
|
||||
@@ -74,12 +66,17 @@ async def create_request(
|
||||
catalog_entry_id=catalog_entry_id,
|
||||
routing_note=routing_note,
|
||||
)
|
||||
session.add(req)
|
||||
await session.flush() # get req.id before creating notification
|
||||
|
||||
# Auto-notify
|
||||
if fulfilling_domain_id:
|
||||
ful_domain = await session.get(Domain, fulfilling_domain_id)
|
||||
|
||||
async def _notify_on_create(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestCreate,
|
||||
) -> None:
|
||||
await session.flush()
|
||||
|
||||
if req.fulfilling_domain_id:
|
||||
ful_domain = await session.get(Domain, req.fulfilling_domain_id)
|
||||
to_agent = ful_domain.slug if ful_domain else "broadcast"
|
||||
else:
|
||||
to_agent = "broadcast"
|
||||
@@ -98,29 +95,16 @@ async def create_request(
|
||||
),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.post("/capability-requests/{request_id}/accept", response_model=CapabilityRequestRead)
|
||||
async def accept_request(
|
||||
request_id: uuid.UUID,
|
||||
body: CapabilityRequestAccept,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
_check_transition(req.status, "accepted")
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
req.status = "accepted"
|
||||
req.fulfilling_agent = body.fulfilling_agent
|
||||
def _apply_accept_fields(req: CapabilityRequest, body: CapabilityRequestAccept) -> None:
|
||||
req.fulfilling_workplan_id = body.fulfilling_workplan_id
|
||||
req.accepted_at = now
|
||||
|
||||
# If no fulfilling domain was set by routing, infer from the accepting agent's context
|
||||
# (The agent can also PATCH it later if needed)
|
||||
|
||||
async def _notify_on_accept(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestAccept,
|
||||
) -> None:
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.fulfilling_agent,
|
||||
@@ -129,30 +113,14 @@ async def accept_request(
|
||||
body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
|
||||
async def patch_request_status(
|
||||
request_id: uuid.UUID,
|
||||
async def _on_status_change(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestStatusPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
_check_transition(req.status, body.status)
|
||||
|
||||
req.status = body.status
|
||||
if body.note:
|
||||
req.resolution_note = body.note
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
# Status-specific side effects
|
||||
now: datetime,
|
||||
) -> None:
|
||||
if body.status == "completed":
|
||||
req.completed_at = now
|
||||
# Auto-unblock the blocking task
|
||||
if req.blocking_task_id:
|
||||
task = await session.get(Task, req.blocking_task_id)
|
||||
if task and task.status == "wait":
|
||||
@@ -200,23 +168,12 @@ async def patch_request_status(
|
||||
body=f"Work on capability **{req.title}** is now in progress.",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.patch("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
|
||||
async def patch_request(
|
||||
request_id: uuid.UUID,
|
||||
async def _apply_capability_patch(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
"""Correct mutable metadata: catalog_entry_id (re-derives fulfilling domain),
|
||||
priority, blocking_task_id, fulfilling_workplan_id.
|
||||
Only fields present in the request body (non-None) are updated.
|
||||
"""
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
|
||||
) -> bool:
|
||||
corrections: list[str] = []
|
||||
|
||||
if body.catalog_entry_id is not None:
|
||||
@@ -225,8 +182,6 @@ async def patch_request(
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
|
||||
req.catalog_entry_id = entry.id
|
||||
# Re-derive fulfilling domain from catalog entry
|
||||
old_domain_id = req.fulfilling_domain_id
|
||||
req.fulfilling_domain_id = entry.domain_id
|
||||
corrections.append(
|
||||
f"catalog_entry: {old_entry_id} → {entry.id} ({entry.title}); "
|
||||
@@ -246,44 +201,25 @@ async def patch_request(
|
||||
corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}")
|
||||
|
||||
if not corrections:
|
||||
return req # no-op
|
||||
return False
|
||||
|
||||
correction_note = "hub correction: " + "; ".join(corrections)
|
||||
req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispute endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
|
||||
async def dispute_request(
|
||||
request_id: uuid.UUID,
|
||||
async def _notify_on_dispute(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestDispute,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
"""Flag a routing decision as incorrect. Transitions to routing_disputed."""
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
_check_transition(req.status, "routing_disputed")
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
req.status = "routing_disputed"
|
||||
req.dispute_reason = body.reason
|
||||
req.disputed_by = body.disputed_by
|
||||
req.dispute_suggested_domain = body.suggested_domain
|
||||
req.disputed_at = now
|
||||
|
||||
now: datetime,
|
||||
) -> None:
|
||||
dispute_entry = (
|
||||
f"disputed by {body.disputed_by}: {body.reason}"
|
||||
+ (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "")
|
||||
)
|
||||
req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry
|
||||
|
||||
# Notify custodian
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.disputed_by,
|
||||
@@ -297,7 +233,6 @@ async def dispute_request(
|
||||
+ f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}"
|
||||
),
|
||||
)
|
||||
# Notify current fulfilling domain
|
||||
if req.fulfilling_domain_slug:
|
||||
_add_notification(
|
||||
session,
|
||||
@@ -312,52 +247,13 @@ async def dispute_request(
|
||||
),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.post("/capability-requests/{request_id}/reroute", response_model=CapabilityRequestRead)
|
||||
async def reroute_request(
|
||||
request_id: uuid.UUID,
|
||||
async def _notify_on_reroute(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestReroute,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
"""Re-route a disputed request to a new domain. Resets to requested."""
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
if req.status != "routing_disputed":
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Cannot reroute from status '{req.status}'. Only 'routing_disputed' requests can be rerouted.",
|
||||
)
|
||||
if body.catalog_entry_id is None and body.domain is None:
|
||||
raise HTTPException(status_code=422, detail="Either catalog_entry_id or domain must be provided.")
|
||||
|
||||
if body.catalog_entry_id is not None:
|
||||
entry = await session.get(CapabilityCatalog, body.catalog_entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
|
||||
req.catalog_entry_id = entry.id
|
||||
req.fulfilling_domain_id = entry.domain_id
|
||||
new_domain_slug = (await session.get(Domain, entry.domain_id)).slug if entry.domain_id else "unknown"
|
||||
else:
|
||||
new_domain = await _resolve_domain(body.domain, session)
|
||||
req.fulfilling_domain_id = new_domain.id
|
||||
new_domain_slug = new_domain.slug
|
||||
|
||||
old_domain = req.dispute_suggested_domain or "unknown"
|
||||
|
||||
# Clear dispute fields
|
||||
req.dispute_reason = None
|
||||
req.disputed_by = None
|
||||
req.dispute_suggested_domain = None
|
||||
req.disputed_at = None
|
||||
req.status = "requested"
|
||||
|
||||
reroute_entry = f"re-routed by {body.rerouted_by} → {new_domain_slug}: {body.note}"
|
||||
req.routing_note = (req.routing_note + "\n" + reroute_entry) if req.routing_note else reroute_entry
|
||||
|
||||
# Notify requester
|
||||
new_domain_slug: str,
|
||||
) -> None:
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.rerouted_by,
|
||||
@@ -368,7 +264,6 @@ async def reroute_request(
|
||||
f"**Note:** {body.note}"
|
||||
),
|
||||
)
|
||||
# Notify new fulfilling domain
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.rerouted_by,
|
||||
@@ -383,24 +278,20 @@ async def reroute_request(
|
||||
),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing algorithm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _route_capability(
|
||||
session: AsyncSession, capability_type: str, title: str, description: str
|
||||
async def _route_capability_match(
|
||||
session: AsyncSession,
|
||||
capability_type: str,
|
||||
title: str,
|
||||
description: str,
|
||||
) -> tuple[uuid.UUID | None, uuid.UUID | None, str]:
|
||||
"""Find the best-matching catalog entry for a capability request.
|
||||
|
||||
Returns (domain_id, catalog_entry_id, routing_note).
|
||||
Uses word-boundary matching on (title + description) combined to avoid
|
||||
false positives from substring matches (e.g. 'postgres' inside 'postgresql',
|
||||
'ha' inside 'has').
|
||||
"""
|
||||
q = select(CapabilityCatalog).where(
|
||||
CapabilityCatalog.capability_type == capability_type,
|
||||
@@ -412,20 +303,19 @@ async def _route_capability(
|
||||
return None, None, f"no active catalog entries for type '{capability_type}' — broadcast"
|
||||
|
||||
if len(entries) == 1:
|
||||
e = entries[0]
|
||||
return e.domain_id, e.id, f"single match: '{e.title}' (domain={e.domain_id})"
|
||||
entry = entries[0]
|
||||
return entry.domain_id, entry.id, f"single match: '{entry.title}' (domain={entry.domain_id})"
|
||||
|
||||
# Score by word-boundary keyword overlap against title + description combined
|
||||
combined = f"{title} {description or ''}".lower()
|
||||
scored: list[tuple[int, CapabilityCatalog]] = []
|
||||
for entry in entries:
|
||||
keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3]
|
||||
score = sum(
|
||||
1 for kw in keywords
|
||||
if re.search(r'\b' + re.escape(kw.lower()) + r'\b', combined)
|
||||
if re.search(r"\b" + re.escape(kw.lower()) + r"\b", combined)
|
||||
)
|
||||
scored.append((score, entry))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
scored.sort(key=lambda item: -item[0])
|
||||
|
||||
best_score, best = scored[0]
|
||||
if best_score == 0:
|
||||
@@ -456,7 +346,6 @@ def _add_notification(
|
||||
subject: str,
|
||||
body: str,
|
||||
) -> None:
|
||||
"""Create an AgentMessage notification in the current session (no commit)."""
|
||||
msg = AgentMessage(
|
||||
from_agent=from_agent,
|
||||
to_agent=to_agent,
|
||||
@@ -466,21 +355,6 @@ def _add_notification(
|
||||
session.add(msg)
|
||||
|
||||
|
||||
async def _resolve_domain(slug: str, session: AsyncSession) -> Domain:
|
||||
result = await session.execute(select(Domain).where(Domain.slug == slug))
|
||||
domain = result.scalar_one_or_none()
|
||||
if domain is None:
|
||||
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
|
||||
return domain
|
||||
|
||||
|
||||
async def _get_request_or_404(request_id: uuid.UUID, session: AsyncSession) -> CapabilityRequest:
|
||||
req = await session.get(CapabilityRequest, request_id)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
|
||||
return req
|
||||
|
||||
|
||||
def _check_transition(current: str, target: str) -> None:
|
||||
can_reach, failures, flow_result = evaluate_transition(
|
||||
"capability_request",
|
||||
@@ -500,3 +374,44 @@ def _check_transition(current: str, target: str) -> None:
|
||||
"flow_result": flow_result_to_dict(flow_result),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
router = create_capability_catalog_router(
|
||||
get_session,
|
||||
domain_model=Domain,
|
||||
repo_model=ManagedRepo,
|
||||
catalog_model=CapabilityCatalog,
|
||||
)
|
||||
router.include_router(
|
||||
create_capability_request_read_router(
|
||||
get_session,
|
||||
domain_model=Domain,
|
||||
request_model=CapabilityRequest,
|
||||
request_read_schema=CapabilityRequestRead,
|
||||
)
|
||||
)
|
||||
router.include_router(
|
||||
create_capability_request_write_router(
|
||||
get_session,
|
||||
domain_model=Domain,
|
||||
catalog_model=CapabilityCatalog,
|
||||
request_model=CapabilityRequest,
|
||||
request_create_schema=CapabilityRequestCreate,
|
||||
request_accept_schema=CapabilityRequestAccept,
|
||||
request_patch_schema=CapabilityRequestPatch,
|
||||
request_status_patch_schema=CapabilityRequestStatusPatch,
|
||||
request_dispute_schema=CapabilityRequestDispute,
|
||||
request_reroute_schema=CapabilityRequestReroute,
|
||||
request_read_schema=CapabilityRequestRead,
|
||||
route_request=_route_capability,
|
||||
build_request=_build_capability_request,
|
||||
on_request_persisted=_notify_on_create,
|
||||
check_transition=_check_transition,
|
||||
apply_accept_fields=_apply_accept_fields,
|
||||
after_accept=_notify_on_accept,
|
||||
after_status_change=_on_status_change,
|
||||
apply_patch=_apply_capability_patch,
|
||||
after_dispute=_notify_on_dispute,
|
||||
after_reroute=_notify_on_reroute,
|
||||
)
|
||||
)
|
||||
@@ -17,6 +17,7 @@ from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
from hub_core.mcp import HubCoreMCPServer
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
@@ -31,6 +32,24 @@ mcp = FastMCP(
|
||||
),
|
||||
)
|
||||
|
||||
# Generic hub tools from hub-core; exclude dev-hub overrides with richer contracts.
|
||||
_HUB_CORE_MCP_EXCLUDE = frozenset({
|
||||
"get_state_summary",
|
||||
"get_domain_summary",
|
||||
"list_domains",
|
||||
"list_domain_repos",
|
||||
"register_repo",
|
||||
"update_repo_path",
|
||||
"request_capability",
|
||||
"register_service",
|
||||
"ingest_tpsc_tool",
|
||||
})
|
||||
HubCoreMCPServer(
|
||||
name="state-hub",
|
||||
api_base=API_BASE,
|
||||
register_tools=False,
|
||||
).attach_to(mcp, exclude=_HUB_CORE_MCP_EXCLUDE)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2224,124 +2243,10 @@ def get_repo_dispatch(repo_slug: str) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent Inbox (inter-agent message passing)
|
||||
# Capability Catalog & Requests (dev-hub extensions)
|
||||
# Messaging and catalog CRUD/list tools come from hub_core.mcp.HubCoreMCPServer.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def send_message(from_agent: str, to_agent: str, subject: str, body: str, thread_id: str | None = None) -> str:
|
||||
"""Send a message from one agent to another (or 'broadcast' for all).
|
||||
|
||||
Use this to coordinate with other Claude instances — e.g. a worker agent
|
||||
reporting status back to the orchestrator, or the hub agent dispatching
|
||||
instructions to a domain agent.
|
||||
|
||||
Args:
|
||||
from_agent: Sender identifier (e.g. 'hub', 'marki-docx', 'railiance')
|
||||
to_agent: Recipient identifier or 'broadcast' for all agents
|
||||
subject: Short subject line (max 500 chars)
|
||||
body: Full message body (markdown supported)
|
||||
thread_id: UUID of the root message to create a thread (optional)
|
||||
"""
|
||||
payload: dict = {"from_agent": from_agent, "to_agent": to_agent, "subject": subject, "body": body}
|
||||
if thread_id:
|
||||
payload["thread_id"] = thread_id
|
||||
msg = _post("/messages/", payload)
|
||||
return json.dumps(msg, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_messages(to_agent: str | None = None, from_agent: str | None = None, unread_only: bool = False, limit: int = 20) -> str:
|
||||
"""List messages in the agent inbox.
|
||||
|
||||
Call this at session start to check for pending coordination messages.
|
||||
|
||||
Args:
|
||||
to_agent: Filter by recipient (your agent name, or omit for all)
|
||||
from_agent: Filter by sender (optional)
|
||||
unread_only: Return only unread messages (default: False)
|
||||
limit: Maximum number of messages to return (default: 20)
|
||||
"""
|
||||
params: dict = {"limit": limit, "unread_only": unread_only}
|
||||
if to_agent:
|
||||
params["to_agent"] = to_agent
|
||||
if from_agent:
|
||||
params["from_agent"] = from_agent
|
||||
return json.dumps(_get("/messages/", params), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mark_message_read(message_id: str) -> str:
|
||||
"""Mark an inbox message as read.
|
||||
|
||||
Args:
|
||||
message_id: UUID of the message to mark as read
|
||||
"""
|
||||
return json.dumps(_patch(f"/messages/{message_id}/read", {}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def reply_to_message(message_id: str, from_agent: str, body: str) -> str:
|
||||
"""Reply to a message. Marks the original as read and creates a reply in the same thread.
|
||||
|
||||
Args:
|
||||
message_id: UUID of the message to reply to
|
||||
from_agent: Your agent identifier
|
||||
body: Reply body (markdown supported)
|
||||
"""
|
||||
return json.dumps(_post(f"/messages/{message_id}/reply", {"from_agent": from_agent, "body": body}), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Catalog & Requests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def register_capability(
|
||||
domain: str,
|
||||
capability_type: str,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
keywords: list[str] | None = None,
|
||||
repo_slug: str | None = None,
|
||||
) -> str:
|
||||
"""Register a capability that a domain can provide. Used for routing requests.
|
||||
|
||||
Args:
|
||||
domain: Domain slug (e.g. 'railiance', 'markitect')
|
||||
capability_type: Category (e.g. 'infrastructure', 'api', 'data', 'security', 'documentation')
|
||||
title: Short title for this capability
|
||||
description: Longer description (optional)
|
||||
keywords: List of keywords for routing (e.g. ['cluster', 'k8s', 'privacy'])
|
||||
repo_slug: Optional repo slug to attribute this capability to a specific repo
|
||||
"""
|
||||
entry = _post("/capability-catalog", {
|
||||
"domain": domain,
|
||||
"capability_type": capability_type,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"keywords": keywords or [],
|
||||
"repo_slug": repo_slug,
|
||||
})
|
||||
return json.dumps(entry, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_capabilities(
|
||||
domain: str | None = None,
|
||||
capability_type: str | None = None,
|
||||
) -> str:
|
||||
"""Browse the capability catalog — what domains can provide.
|
||||
|
||||
Args:
|
||||
domain: Filter by domain slug (optional)
|
||||
capability_type: Filter by type (optional)
|
||||
"""
|
||||
return json.dumps(_get("/capability-catalog", {
|
||||
"domain": domain,
|
||||
"capability_type": capability_type,
|
||||
}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_capability_profile(domain_slug: str | None = None) -> str:
|
||||
"""Full capability registry: domain → repos (with description) → capabilities.
|
||||
@@ -2470,48 +2375,6 @@ def request_capability(
|
||||
return json.dumps(req, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def accept_capability_request(
|
||||
request_id: str,
|
||||
fulfilling_agent: str,
|
||||
fulfilling_workstream_id: str | None = None,
|
||||
) -> str:
|
||||
"""Accept a capability request. Assigns yourself as the fulfilling agent.
|
||||
|
||||
Args:
|
||||
request_id: UUID of the capability request
|
||||
fulfilling_agent: Your agent identifier (e.g. 'railiance-worker')
|
||||
fulfilling_workstream_id: UUID of your workstream for this work (optional)
|
||||
"""
|
||||
result = _post(f"/capability-requests/{request_id}/accept", {
|
||||
"fulfilling_agent": fulfilling_agent,
|
||||
"fulfilling_workstream_id": fulfilling_workstream_id,
|
||||
})
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_capability_request_status(
|
||||
request_id: str,
|
||||
status: str,
|
||||
note: str | None = None,
|
||||
) -> str:
|
||||
"""Advance a capability request through its lifecycle.
|
||||
|
||||
On 'completed': auto-unblocks the blocking task if one was set.
|
||||
|
||||
Args:
|
||||
request_id: UUID of the capability request
|
||||
status: in_progress | ready_for_review | completed | rejected | withdrawn
|
||||
note: Optional note (required for rejection, recommended for completion)
|
||||
"""
|
||||
result = _patch(f"/capability-requests/{request_id}/status", {
|
||||
"status": status,
|
||||
"note": note,
|
||||
})
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def patch_capability_request(
|
||||
request_id: str,
|
||||
@@ -2552,36 +2415,6 @@ def patch_capability_request(
|
||||
return json.dumps(_patch(f"/capability-requests/{request_id}", body), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_capability_requests(
|
||||
domain: str | None = None,
|
||||
status: str | None = None,
|
||||
capability_type: str | None = None,
|
||||
) -> str:
|
||||
"""List capability requests with optional filters.
|
||||
|
||||
Args:
|
||||
domain: Filter by requesting OR fulfilling domain slug
|
||||
status: Filter by status (requested/accepted/in_progress/ready_for_review/completed/rejected/withdrawn)
|
||||
capability_type: Filter by capability type
|
||||
"""
|
||||
return json.dumps(_get("/capability-requests", {
|
||||
"domain": domain,
|
||||
"status": status,
|
||||
"capability_type": capability_type,
|
||||
}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_capability_request(request_id: str) -> str:
|
||||
"""Get a single capability request by ID.
|
||||
|
||||
Args:
|
||||
request_id: UUID of the capability request
|
||||
"""
|
||||
return json.dumps(_get(f"/capability-requests/{request_id}"), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def dispute_capability_routing(
|
||||
request_id: str,
|
||||
@@ -2688,30 +2521,6 @@ def register_service(
|
||||
}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_services(
|
||||
gdpr_maturity: str | None = None,
|
||||
category: str | None = None,
|
||||
pricing_model: str | None = None,
|
||||
) -> str:
|
||||
"""Browse the Third-Party Services Catalog (TPSC).
|
||||
|
||||
Returns services with their GDPR maturity level and gdpr_warning flag
|
||||
(True when maturity is unknown, non_compliant, or initial — may limit
|
||||
use in corporate/GDPR-regulated environments).
|
||||
|
||||
Args:
|
||||
gdpr_maturity: Filter by maturity level (unknown/non_compliant/initial/developing/defined/managed/certified)
|
||||
category: Filter by category (e.g. 'llm_inference', 'storage')
|
||||
pricing_model: Filter by pricing model (free/paid/freemium/usage_based/unknown)
|
||||
"""
|
||||
return json.dumps(_get("/tpsc/catalog", {
|
||||
"gdpr_maturity": gdpr_maturity,
|
||||
"category": category,
|
||||
"pricing_model": pricing_model,
|
||||
}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ingest_tpsc_tool(repo_slug: str) -> str:
|
||||
"""Ingest tpsc.yaml service dependency declarations for a repo.
|
||||
@@ -2750,53 +2559,6 @@ def ingest_tpsc_tool(repo_slug: str) -> str:
|
||||
return output.strip()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_gdpr_report() -> str:
|
||||
"""Get an aggregated GDPR compliance report across all repos' latest TPSC snapshots.
|
||||
|
||||
Returns a warning summary for services with gdpr_maturity in:
|
||||
unknown | non_compliant | initial
|
||||
|
||||
These may limit usability in GDPR-regulated / corporate environments.
|
||||
Services at 'developing' or above have at least a DPA available.
|
||||
"""
|
||||
return json.dumps(_get("/tpsc/report/gdpr"), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repository Definition of Integrated (DoI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def check_repo_doi(repo_slug: str) -> str:
|
||||
"""Evaluate the 14 DoI criteria for a repo and return a full report.
|
||||
|
||||
Criteria are grouped into three tiers:
|
||||
Core (C1–C4): registered, domain, path, remote URL
|
||||
Standard (C5–C9): SCOPE.md, CLAUDE.md, workplan, SBOM, TPSC
|
||||
Full (C10–C14): repo goal, capabilities, agents, clean consistency, host paths
|
||||
|
||||
Status values: pass | fail | warn | skip
|
||||
|
||||
The 'tier' field shows the highest tier where ALL criteria pass or warn:
|
||||
none | core | standard | full
|
||||
|
||||
Args:
|
||||
repo_slug: Registered repo slug (e.g. 'llm-connect', 'the-custodian')
|
||||
"""
|
||||
return json.dumps(_get(f"/repos/{repo_slug}/doi"), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_doi_summary() -> str:
|
||||
"""Return DoI tier for all active repos, sorted worst-first.
|
||||
|
||||
Useful at session start to spot repos that need integration work.
|
||||
Tiers: none (red) → core → standard → full (green).
|
||||
"""
|
||||
return json.dumps(_get("/repos/doi/summary"), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive / ad-hoc task recording
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user