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 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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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 (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
|
# Interactive / ad-hoc task recording
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user