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:
2026-06-22 19:52:22 +02:00
parent c421a2a60d
commit 262682cdf0
2 changed files with 130 additions and 453 deletions

View File

@@ -1,8 +1,7 @@
import re import re
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import HTTPException
from fastapi import Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,47 +25,40 @@ from api.schemas.capability_request import (
from hub_core.routers.capabilities import ( from hub_core.routers.capabilities import (
create_capability_catalog_router, create_capability_catalog_router,
create_capability_request_read_router, create_capability_request_read_router,
) create_capability_request_write_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,
)
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Capability Request endpoints # Write-router callbacks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED) async def _route_capability(
async def create_request( session: AsyncSession,
body: CapabilityRequestCreate, body: CapabilityRequestCreate,
session: AsyncSession = Depends(get_session), ) -> tuple[uuid.UUID | None, uuid.UUID | None, str | None]:
) -> CapabilityRequest: fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability_match(
req_domain = await _resolve_domain(body.requesting_domain, session) session,
body.capability_type,
# Route to provider body.title,
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability( body.description or "",
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, title=body.title,
description=body.description, description=body.description,
capability_type=body.capability_type, capability_type=body.capability_type,
priority=body.priority, priority=body.priority,
requesting_domain_id=req_domain.id, requesting_domain_id=requesting_domain.id,
requesting_agent=body.requesting_agent, requesting_agent=body.requesting_agent,
requesting_workplan_id=body.requesting_workplan_id, requesting_workplan_id=body.requesting_workplan_id,
blocking_task_id=body.blocking_task_id, blocking_task_id=body.blocking_task_id,
@@ -74,12 +66,17 @@ async def create_request(
catalog_entry_id=catalog_entry_id, catalog_entry_id=catalog_entry_id,
routing_note=routing_note, routing_note=routing_note,
) )
session.add(req)
await session.flush() # get req.id before creating notification
# Auto-notify
if fulfilling_domain_id: async def _notify_on_create(
ful_domain = await session.get(Domain, fulfilling_domain_id) 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" to_agent = ful_domain.slug if ful_domain else "broadcast"
else: else:
to_agent = "broadcast" to_agent = "broadcast"
@@ -98,29 +95,16 @@ async def create_request(
), ),
) )
await session.commit()
await session.refresh(req)
return req
def _apply_accept_fields(req: CapabilityRequest, body: CapabilityRequestAccept) -> None:
@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
req.fulfilling_workplan_id = body.fulfilling_workplan_id 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( _add_notification(
session, session,
from_agent=body.fulfilling_agent, 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}**.", body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
) )
await session.commit()
await session.refresh(req)
return req
async def _on_status_change(
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead) session: AsyncSession,
async def patch_request_status( req: CapabilityRequest,
request_id: uuid.UUID,
body: CapabilityRequestStatusPatch, body: CapabilityRequestStatusPatch,
session: AsyncSession = Depends(get_session), now: datetime,
) -> CapabilityRequest: ) -> None:
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
if body.status == "completed": if body.status == "completed":
req.completed_at = now
# Auto-unblock the blocking task
if req.blocking_task_id: if req.blocking_task_id:
task = await session.get(Task, req.blocking_task_id) task = await session.get(Task, req.blocking_task_id)
if task and task.status == "wait": 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.", body=f"Work on capability **{req.title}** is now in progress.",
) )
await session.commit()
await session.refresh(req)
return req
async def _apply_capability_patch(
@router.patch("/capability-requests/{request_id}", response_model=CapabilityRequestRead) session: AsyncSession,
async def patch_request( req: CapabilityRequest,
request_id: uuid.UUID,
body: CapabilityRequestPatch, body: CapabilityRequestPatch,
session: AsyncSession = Depends(get_session), ) -> bool:
) -> 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)
corrections: list[str] = [] corrections: list[str] = []
if body.catalog_entry_id is not None: if body.catalog_entry_id is not None:
@@ -225,8 +182,6 @@ async def patch_request(
if entry is None: if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found") raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
req.catalog_entry_id = entry.id 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 req.fulfilling_domain_id = entry.domain_id
corrections.append( corrections.append(
f"catalog_entry: {old_entry_id}{entry.id} ({entry.title}); " 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}") corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}")
if not corrections: if not corrections:
return req # no-op return False
correction_note = "hub correction: " + "; ".join(corrections) correction_note = "hub correction: " + "; ".join(corrections)
req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note
return True
await session.commit()
await session.refresh(req)
return req
# --------------------------------------------------------------------------- async def _notify_on_dispute(
# Dispute endpoints session: AsyncSession,
# --------------------------------------------------------------------------- req: CapabilityRequest,
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
async def dispute_request(
request_id: uuid.UUID,
body: CapabilityRequestDispute, body: CapabilityRequestDispute,
session: AsyncSession = Depends(get_session), now: datetime,
) -> CapabilityRequest: ) -> None:
"""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
dispute_entry = ( dispute_entry = (
f"disputed by {body.disputed_by}: {body.reason}" f"disputed by {body.disputed_by}: {body.reason}"
+ (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "") + (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 req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry
# Notify custodian
_add_notification( _add_notification(
session, session,
from_agent=body.disputed_by, from_agent=body.disputed_by,
@@ -297,7 +233,6 @@ async def dispute_request(
+ f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}" + f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}"
), ),
) )
# Notify current fulfilling domain
if req.fulfilling_domain_slug: if req.fulfilling_domain_slug:
_add_notification( _add_notification(
session, session,
@@ -312,52 +247,13 @@ async def dispute_request(
), ),
) )
await session.commit()
await session.refresh(req)
return req
async def _notify_on_reroute(
@router.post("/capability-requests/{request_id}/reroute", response_model=CapabilityRequestRead) session: AsyncSession,
async def reroute_request( req: CapabilityRequest,
request_id: uuid.UUID,
body: CapabilityRequestReroute, body: CapabilityRequestReroute,
session: AsyncSession = Depends(get_session), new_domain_slug: str,
) -> CapabilityRequest: ) -> None:
"""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
_add_notification( _add_notification(
session, session,
from_agent=body.rerouted_by, from_agent=body.rerouted_by,
@@ -368,7 +264,6 @@ async def reroute_request(
f"**Note:** {body.note}" f"**Note:** {body.note}"
), ),
) )
# Notify new fulfilling domain
_add_notification( _add_notification(
session, session,
from_agent=body.rerouted_by, from_agent=body.rerouted_by,
@@ -383,24 +278,20 @@ async def reroute_request(
), ),
) )
await session.commit()
await session.refresh(req)
return req
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routing algorithm # Routing algorithm
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _route_capability( async def _route_capability_match(
session: AsyncSession, capability_type: str, title: str, description: str session: AsyncSession,
capability_type: str,
title: str,
description: str,
) -> tuple[uuid.UUID | None, uuid.UUID | None, str]: ) -> tuple[uuid.UUID | None, uuid.UUID | None, str]:
"""Find the best-matching catalog entry for a capability request. """Find the best-matching catalog entry for a capability request.
Returns (domain_id, catalog_entry_id, routing_note). 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( q = select(CapabilityCatalog).where(
CapabilityCatalog.capability_type == capability_type, 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" return None, None, f"no active catalog entries for type '{capability_type}' — broadcast"
if len(entries) == 1: if len(entries) == 1:
e = entries[0] entry = entries[0]
return e.domain_id, e.id, f"single match: '{e.title}' (domain={e.domain_id})" 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() combined = f"{title} {description or ''}".lower()
scored: list[tuple[int, CapabilityCatalog]] = [] scored: list[tuple[int, CapabilityCatalog]] = []
for entry in entries: for entry in entries:
keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3] keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3]
score = sum( score = sum(
1 for kw in keywords 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.append((score, entry))
scored.sort(key=lambda x: -x[0]) scored.sort(key=lambda item: -item[0])
best_score, best = scored[0] best_score, best = scored[0]
if best_score == 0: if best_score == 0:
@@ -456,7 +346,6 @@ def _add_notification(
subject: str, subject: str,
body: str, body: str,
) -> None: ) -> None:
"""Create an AgentMessage notification in the current session (no commit)."""
msg = AgentMessage( msg = AgentMessage(
from_agent=from_agent, from_agent=from_agent,
to_agent=to_agent, to_agent=to_agent,
@@ -466,21 +355,6 @@ def _add_notification(
session.add(msg) 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: def _check_transition(current: str, target: str) -> None:
can_reach, failures, flow_result = evaluate_transition( can_reach, failures, flow_result = evaluate_transition(
"capability_request", "capability_request",
@@ -500,3 +374,44 @@ def _check_transition(current: str, target: str) -> None:
"flow_result": flow_result_to_dict(flow_result), "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,
)
)

View File

@@ -17,6 +17,7 @@ from uuid import UUID
import httpx import httpx
from fastmcp import FastMCP from fastmcp import FastMCP
from hub_core.mcp import HubCoreMCPServer
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") 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 # 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() @mcp.tool()
def get_capability_profile(domain_slug: str | None = None) -> str: def get_capability_profile(domain_slug: str | None = None) -> str:
"""Full capability registry: domain → repos (with description) → capabilities. """Full capability registry: domain → repos (with description) → capabilities.
@@ -2470,48 +2375,6 @@ def request_capability(
return json.dumps(req, indent=2) 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() @mcp.tool()
def patch_capability_request( def patch_capability_request(
request_id: str, request_id: str,
@@ -2552,36 +2415,6 @@ def patch_capability_request(
return json.dumps(_patch(f"/capability-requests/{request_id}", body), indent=2) 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() @mcp.tool()
def dispute_capability_routing( def dispute_capability_routing(
request_id: str, request_id: str,
@@ -2688,30 +2521,6 @@ def register_service(
}), indent=2) }), 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() @mcp.tool()
def ingest_tpsc_tool(repo_slug: str) -> str: def ingest_tpsc_tool(repo_slug: str) -> str:
"""Ingest tpsc.yaml service dependency declarations for a repo. """Ingest tpsc.yaml service dependency declarations for a repo.
@@ -2750,53 +2559,6 @@ def ingest_tpsc_tool(repo_slug: str) -> str:
return output.strip() 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 (C1C4): registered, domain, path, remote URL
Standard (C5C9): SCOPE.md, CLAUDE.md, workplan, SBOM, TPSC
Full (C10C14): 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 # Interactive / ad-hoc task recording
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------