generated from coulomb/repo-seed
Compare commits
4 Commits
02bf3a9efa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c369e155c | |||
| b268649e8d | |||
| af28282861 | |||
| b1be2ad788 |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
## Kaizen Agents
|
||||||
|
|
||||||
|
Specialized agent personas available on demand via the state-hub MCP.
|
||||||
|
|
||||||
|
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
|
||||||
|
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
|
||||||
|
|
||||||
|
Common agents:
|
||||||
|
|
||||||
|
| Agent | Category | When to use |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
|
||||||
|
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
|
||||||
|
| `test-maintenance` | testing | Diagnose and fix failing tests |
|
||||||
|
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
|
||||||
|
| `keepaTodofile` | process | Maintain TODO.md during work |
|
||||||
|
| `project-management` | process | Track status, determine next steps |
|
||||||
|
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
|
||||||
|
|
||||||
|
All 17 agents: call `list_kaizen_agents()` for the full list.
|
||||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## Architecture
|
||||||
|
|
||||||
|
<!-- TODO: Describe the key design decisions and component structure.
|
||||||
|
Key modules, data flows, external integrations, state machines, etc. -->
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
## First Session Protocol
|
||||||
|
|
||||||
|
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
|
||||||
|
The project is registered but work has not yet been structured.
|
||||||
|
|
||||||
|
**Step 1 — Read, don't write**
|
||||||
|
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
|
||||||
|
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
|
||||||
|
- Scan repo root: README, directory structure, existing code or docs
|
||||||
|
|
||||||
|
**Step 2 — Survey in-progress work**
|
||||||
|
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
|
||||||
|
|
||||||
|
**Step 3 — Propose workstreams to Bernd**
|
||||||
|
Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to a
|
||||||
|
roadmap phase. **Wait for approval before creating.**
|
||||||
|
|
||||||
|
**Step 4 — Create workplan file first, then DB record (ADR-001)**
|
||||||
|
```
|
||||||
|
workplans/HUB-WP-NNNN-<slug>.md ← write this first
|
||||||
|
```
|
||||||
|
Then register in the hub:
|
||||||
|
```
|
||||||
|
create_workstream(topic_id="1f2e4d10-c967-4803-ae6c-7f4b4e806409", title="...", owner="...", description="...")
|
||||||
|
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5 — Record the setup**
|
||||||
|
```
|
||||||
|
add_progress_event(
|
||||||
|
summary="First session: structured infotech into N workstreams, M tasks",
|
||||||
|
event_type="milestone",
|
||||||
|
topic_id="1f2e4d10-c967-4803-ae6c-7f4b4e806409",
|
||||||
|
detail={"workstreams": [...], "tasks_created": M}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Delete or archive this file once past first session -->
|
||||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## Repo boundary
|
||||||
|
|
||||||
|
This repo owns **hub-core** only. It does not own:
|
||||||
|
|
||||||
|
<!-- TODO: List what belongs in adjacent repos, e.g.:
|
||||||
|
- SSH key management → railiance-infra/
|
||||||
|
- State hub code → state-hub/
|
||||||
|
-->
|
||||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
**Purpose:** **Updated:** 2026-06-16.
|
||||||
|
|
||||||
|
**Domain:** infotech
|
||||||
|
**Repo slug:** hub-core
|
||||||
|
**Topic ID:** 1f2e4d10-c967-4803-ae6c-7f4b4e806409
|
||||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
## Session Protocol
|
||||||
|
|
||||||
|
Dev Hub (State Hub API): http://127.0.0.1:8000
|
||||||
|
MCP server name in `~/.claude.json`: `dev-hub`
|
||||||
|
|
||||||
|
**Step 1 — Orient**
|
||||||
|
|
||||||
|
Read the offline-safe brief first — it works without a live hub connection:
|
||||||
|
```bash
|
||||||
|
cat .custodian-brief.md
|
||||||
|
```
|
||||||
|
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
||||||
|
```
|
||||||
|
get_domain_summary("infotech")
|
||||||
|
```
|
||||||
|
If MCP tools are unavailable in the current agent session, use the REST API:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
If the hub is offline: `cd ~/state-hub && make api`
|
||||||
|
|
||||||
|
**Step 2 — Check inbox**
|
||||||
|
With MCP tools:
|
||||||
|
```
|
||||||
|
get_messages(to_agent="hub-core", unread_only=True)
|
||||||
|
```
|
||||||
|
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
|
||||||
|
requests before proceeding.
|
||||||
|
|
||||||
|
Without MCP tools:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/messages/?to_agent=hub-core&unread_only=true" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||||
|
-H "Content-Type: application/json" -d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3 — Scan workplans**
|
||||||
|
```bash
|
||||||
|
ls workplans/
|
||||||
|
```
|
||||||
|
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||||
|
`wait`/`todo`/`progress` tasks.
|
||||||
|
|
||||||
|
**Step 4 — Present brief**
|
||||||
|
|
||||||
|
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
|
||||||
|
2. **Pending tasks** from `workplans/` + any `[repo:hub-core]` hub tasks
|
||||||
|
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||||
|
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
||||||
|
- `alignment_warnings`: flag if active work is not aligned with current goal
|
||||||
|
4. **Suggested next action** — highest-priority open item
|
||||||
|
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
|
||||||
|
|
||||||
|
If no workstreams: follow First Session Protocol (`first-session.md`).
|
||||||
|
|
||||||
|
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
||||||
|
|
||||||
|
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
|
||||||
|
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
|
||||||
|
|
||||||
|
**Session close:**
|
||||||
|
With MCP tools:
|
||||||
|
```
|
||||||
|
add_progress_event(summary="...", topic_id="1f2e4d10-c967-4803-ae6c-7f4b4e806409", workstream_id="<uuid>")
|
||||||
|
```
|
||||||
|
Without MCP tools:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"topic_id":"1f2e4d10-c967-4803-ae6c-7f4b4e806409","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
|
||||||
|
```
|
||||||
|
If workplan files were modified, ensure the local copy is up to date first:
|
||||||
|
```bash
|
||||||
|
git -C <repo_path> pull --ff-only
|
||||||
|
cd ~/state-hub && make fix-consistency REPO=hub-core
|
||||||
|
```
|
||||||
|
For repos where implementation runs on a remote machine (e.g. CoulombCore),
|
||||||
|
use the combined target which pulls before fixing:
|
||||||
|
```bash
|
||||||
|
cd ~/state-hub && make fix-consistency-remote REPO=hub-core
|
||||||
|
```
|
||||||
|
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
|
||||||
|
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
|
||||||
|
until you pull — intentional to prevent clobbering remote progress.
|
||||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## Stack
|
||||||
|
|
||||||
|
<!-- TODO: Fill in language, frameworks, and key dependencies -->
|
||||||
|
- **Language:**
|
||||||
|
- **Key deps:**
|
||||||
|
|
||||||
|
## Dev Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TODO: Fill in the standard commands for this repo
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
|
||||||
|
# Lint / type check
|
||||||
|
|
||||||
|
# Build / package (if applicable)
|
||||||
|
```
|
||||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
|
File location: `workplans/HUB-WP-NNNN-<slug>.md`
|
||||||
|
ID prefix: `HUB-WP-`
|
||||||
|
|
||||||
|
Work items originate as files in this repo **before** being registered in the hub.
|
||||||
|
|
||||||
|
Canonical workplan/workstream frontmatter statuses are:
|
||||||
|
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
|
||||||
|
Use `proposed` for a newly drafted plan, `ready` after review against current
|
||||||
|
repo state, and `finished` when implementation is complete. `stalled` and
|
||||||
|
`needs_review` are derived health labels, not stored statuses.
|
||||||
|
|
||||||
|
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
||||||
|
prefix: `YYMMDD-HUB-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||||
|
unchanged; the prefix is only for quick visual reference.
|
||||||
|
|
||||||
|
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
|
||||||
|
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
|
||||||
|
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
|
||||||
|
directly. Promote anything requiring analysis, design, approval, dependencies, or
|
||||||
|
multiple planned phases into a normal workplan.
|
||||||
|
|
||||||
|
Ecosystem todos from other agents arrive as `[repo:hub-core]` hub tasks —
|
||||||
|
visible at session start. Pick one up by creating the workplan file, then registering
|
||||||
|
the workstream.
|
||||||
|
|
||||||
|
Task blocks use this shape:
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: HUB-WP-NNNN-T01
|
||||||
|
status: wait | todo | progress | done | cancel
|
||||||
|
priority: high | medium | low
|
||||||
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
```
|
||||||
|
|
||||||
|
Status progression is `todo` → `progress` → `done`; use `wait` for waiting or
|
||||||
|
blocked work and `cancel` for stopped work.
|
||||||
|
|
||||||
|
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
|
||||||
@@ -2,18 +2,12 @@
|
|||||||
# Custodian Brief — hub-core
|
# Custodian Brief — hub-core
|
||||||
|
|
||||||
**Domain:** infotech
|
**Domain:** infotech
|
||||||
**Last synced:** 2026-06-22 17:33 UTC
|
**Last synced:** 2026-06-22 17:52 UTC
|
||||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||||
|
|
||||||
## Active Workstreams
|
## Active Workstreams
|
||||||
|
|
||||||
### Import-refactor adapter seams for State Hub closeout
|
*(none — repo may need first-session setup)*
|
||||||
Progress: 0/3 done | workstream_id: `439b559b-fcb7-4d21-b831-cfc9c6bbc1a0`
|
|
||||||
|
|
||||||
**Open tasks:**
|
|
||||||
- · Capability Request Write Router Factory `f2e0a8a1`
|
|
||||||
- · MCP Server Composition Hooks `f50cd78f`
|
|
||||||
- · Regression And Boundary Update `450676fa`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
## MCP Orientation (when available)
|
## MCP Orientation (when available)
|
||||||
|
|||||||
48
AGENTS.md
48
AGENTS.md
@@ -2,11 +2,9 @@
|
|||||||
|
|
||||||
## Repo Identity
|
## Repo Identity
|
||||||
|
|
||||||
**Purpose:** Reusable Python package of FastAPI router factories, SQLAlchemy
|
**Purpose:** **Updated:** 2026-06-16.
|
||||||
models, Pydantic schemas, MCP tooling, and migration scaffolds for FOS hub
|
|
||||||
services.
|
|
||||||
|
|
||||||
**Domain:** inter_hub
|
**Domain:** infotech
|
||||||
**Repo slug:** hub-core
|
**Repo slug:** hub-core
|
||||||
**Topic ID:** `1f2e4d10-c967-4803-ae6c-7f4b4e806409`
|
**Topic ID:** `1f2e4d10-c967-4803-ae6c-7f4b4e806409`
|
||||||
**Workplan prefix:** `HUB-WP-`
|
**Workplan prefix:** `HUB-WP-`
|
||||||
@@ -103,43 +101,8 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
`hub-core` is a library — there is no `serve` entrypoint. Host repos mount
|
|
||||||
routers and run their own `uvicorn` process.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/hub-core
|
|
||||||
|
|
||||||
# Install (editable)
|
|
||||||
python3 -m venv .venv && .venv/bin/pip install -e .
|
|
||||||
|
|
||||||
# Test
|
|
||||||
.venv/bin/pytest -q
|
|
||||||
|
|
||||||
# Compile check (fast sanity)
|
|
||||||
python3 -m compileall hub_core
|
|
||||||
|
|
||||||
# Build wheel
|
|
||||||
.venv/bin/pip install build && python3 -m build
|
|
||||||
|
|
||||||
# Consumer regression (after router/schema changes)
|
|
||||||
cd ~/state-hub && make test
|
|
||||||
```
|
|
||||||
|
|
||||||
No repo-native lint or format targets yet. Add `ruff` / `mypy` when the
|
|
||||||
package surface stabilizes.
|
|
||||||
|
|
||||||
Primary references: `INTENT.md`, `SCOPE.md`, `README.md`,
|
|
||||||
`~/the-custodian/docs/hub-core-extraction-boundary.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Credential and access routing
|
## Credential and access routing
|
||||||
|
|
||||||
> Fleet template mirrored in `.claude/rules/credential-routing.md` for Claude Code.
|
|
||||||
> Re-sync both from `~/ops-warden/wiki/CredentialRouting.md` when the catalog changes.
|
|
||||||
|
|
||||||
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||||
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||||
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||||
@@ -188,6 +151,11 @@ every repo's agent instructions because it is high-frequency, high-risk, and eas
|
|||||||
get wrong.
|
get wrong.
|
||||||
|
|
||||||
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||||
|
|
||||||
|
<!-- REPO-AGENTS-EXTENSIONS -->
|
||||||
|
<!-- Append repo-specific agent instructions below this marker.
|
||||||
|
The state-hub template sync preserves content after this line. -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workplan Convention (ADR-001)
|
## Workplan Convention (ADR-001)
|
||||||
@@ -213,7 +181,7 @@ anything needing analysis, design, approval, dependencies, or multiple phases.
|
|||||||
id: HUB-WP-NNNN
|
id: HUB-WP-NNNN
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "..."
|
title: "..."
|
||||||
domain: inter_hub
|
domain: infotech
|
||||||
repo: hub-core
|
repo: hub-core
|
||||||
status: proposed | ready | active | blocked | backlog | finished | archived
|
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||||
owner: codex
|
owner: codex
|
||||||
|
|||||||
12
CLAUDE.md
Normal file
12
CLAUDE.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# hub-core — Claude Code Instructions
|
||||||
|
|
||||||
|
@SCOPE.md
|
||||||
|
@.claude/rules/repo-identity.md
|
||||||
|
@.claude/rules/session-protocol.md
|
||||||
|
@.claude/rules/first-session.md
|
||||||
|
@.claude/rules/workplan-convention.md
|
||||||
|
@.claude/rules/stack-and-commands.md
|
||||||
|
@.claude/rules/architecture.md
|
||||||
|
@.claude/rules/repo-boundary.md
|
||||||
|
@.claude/rules/credential-routing.md
|
||||||
|
@.claude/rules/agents.md
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
from hub_core.mcp.server import HubCoreMCPServer
|
from hub_core.mcp.server import CORE_TOOL_NAMES, HubCoreMCPServer
|
||||||
|
|
||||||
__all__ = ["HubCoreMCPServer"]
|
__all__ = ["CORE_TOOL_NAMES", "HubCoreMCPServer"]
|
||||||
|
|||||||
@@ -8,6 +8,36 @@ from fastmcp import FastMCP
|
|||||||
|
|
||||||
from hub_core.utils.routing import normalize_trailing_slash
|
from hub_core.utils.routing import normalize_trailing_slash
|
||||||
|
|
||||||
|
CORE_TOOL_NAMES = frozenset({
|
||||||
|
"get_state_summary",
|
||||||
|
"list_domains",
|
||||||
|
"get_domain_summary",
|
||||||
|
"get_domain",
|
||||||
|
"send_message",
|
||||||
|
"get_messages",
|
||||||
|
"mark_message_read",
|
||||||
|
"reply_to_message",
|
||||||
|
"register_capability",
|
||||||
|
"list_capabilities",
|
||||||
|
"request_capability",
|
||||||
|
"accept_capability_request",
|
||||||
|
"update_capability_request_status",
|
||||||
|
"list_capability_requests",
|
||||||
|
"get_capability_request",
|
||||||
|
"register_repo",
|
||||||
|
"update_repo_path",
|
||||||
|
"list_domain_repos",
|
||||||
|
"check_repo_doi",
|
||||||
|
"get_doi_summary",
|
||||||
|
"register_service",
|
||||||
|
"list_services",
|
||||||
|
"ingest_tpsc_tool",
|
||||||
|
"get_gdpr_report",
|
||||||
|
"get_risks",
|
||||||
|
"get_alerts",
|
||||||
|
"append_progress",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class HubCoreMCPServer:
|
class HubCoreMCPServer:
|
||||||
"""FastMCP base server for generic FOS hub tools.
|
"""FastMCP base server for generic FOS hub tools.
|
||||||
@@ -32,24 +62,46 @@ class HubCoreMCPServer:
|
|||||||
if register_tools:
|
if register_tools:
|
||||||
self.register_core_tools()
|
self.register_core_tools()
|
||||||
|
|
||||||
def register_core_tools(self) -> None:
|
def attach_to(
|
||||||
@self.mcp.tool()
|
self,
|
||||||
|
host_mcp: FastMCP,
|
||||||
|
*,
|
||||||
|
exclude: frozenset[str] | None = None,
|
||||||
|
) -> HubCoreMCPServer:
|
||||||
|
"""Register generic hub-core tools on an existing host MCP server."""
|
||||||
|
self.mcp = host_mcp
|
||||||
|
self.register_core_tools(exclude=exclude)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _register_tool(self, name: str, excluded: frozenset[str]):
|
||||||
|
def decorator(fn):
|
||||||
|
if name not in excluded:
|
||||||
|
self.mcp.tool()(fn)
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def register_core_tools(self, *, exclude: frozenset[str] | None = None) -> None:
|
||||||
|
excluded = exclude or frozenset()
|
||||||
|
register = lambda name: self._register_tool(name, excluded) # noqa: E731
|
||||||
|
|
||||||
|
@register("get_state_summary")
|
||||||
def get_state_summary() -> str:
|
def get_state_summary() -> str:
|
||||||
return self._json(self._get("/state/summary/"))
|
return self._json(self._get("/state/summary/"))
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("list_domains")
|
||||||
def list_domains(status: str | None = None) -> str:
|
def list_domains(status: str | None = None) -> str:
|
||||||
return self._json(self._get("/domains/", {"status": status}))
|
return self._json(self._get("/domains/", {"status": status}))
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("get_domain_summary")
|
||||||
def get_domain_summary(domain_slug: str) -> str:
|
def get_domain_summary(domain_slug: str) -> str:
|
||||||
return self._json(self._get(f"/domains/{domain_slug}/"))
|
return self._json(self._get(f"/domains/{domain_slug}/"))
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("get_domain")
|
||||||
def get_domain(domain_slug: str) -> str:
|
def get_domain(domain_slug: str) -> str:
|
||||||
return self._json(self._get(f"/domains/{domain_slug}/"))
|
return self._json(self._get(f"/domains/{domain_slug}/"))
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("send_message")
|
||||||
def send_message(
|
def send_message(
|
||||||
from_agent: str,
|
from_agent: str,
|
||||||
to_agent: str,
|
to_agent: str,
|
||||||
@@ -70,7 +122,7 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("get_messages")
|
||||||
def get_messages(
|
def get_messages(
|
||||||
to_agent: str | None = None,
|
to_agent: str | None = None,
|
||||||
from_agent: str | None = None,
|
from_agent: str | None = None,
|
||||||
@@ -89,11 +141,11 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("mark_message_read")
|
||||||
def mark_message_read(message_id: str) -> str:
|
def mark_message_read(message_id: str) -> str:
|
||||||
return self._json(self._patch(f"/messages/{message_id}/read/", {}))
|
return self._json(self._patch(f"/messages/{message_id}/read/", {}))
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("reply_to_message")
|
||||||
def reply_to_message(message_id: str, from_agent: str, body: str) -> str:
|
def reply_to_message(message_id: str, from_agent: str, body: str) -> str:
|
||||||
return self._json(
|
return self._json(
|
||||||
self._post(
|
self._post(
|
||||||
@@ -102,7 +154,7 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("register_capability")
|
||||||
def register_capability(
|
def register_capability(
|
||||||
domain: str,
|
domain: str,
|
||||||
capability_type: str,
|
capability_type: str,
|
||||||
@@ -125,7 +177,7 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("list_capabilities")
|
||||||
def list_capabilities(
|
def list_capabilities(
|
||||||
domain: str | None = None,
|
domain: str | None = None,
|
||||||
capability_type: str | None = None,
|
capability_type: str | None = None,
|
||||||
@@ -138,7 +190,7 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("request_capability")
|
||||||
def request_capability(
|
def request_capability(
|
||||||
title: str,
|
title: str,
|
||||||
capability_type: str,
|
capability_type: str,
|
||||||
@@ -165,7 +217,7 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("accept_capability_request")
|
||||||
def accept_capability_request(
|
def accept_capability_request(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
fulfilling_agent: str,
|
fulfilling_agent: str,
|
||||||
@@ -181,7 +233,7 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("update_capability_request_status")
|
||||||
def update_capability_request_status(
|
def update_capability_request_status(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
status: str,
|
status: str,
|
||||||
@@ -194,7 +246,7 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("list_capability_requests")
|
||||||
def list_capability_requests(
|
def list_capability_requests(
|
||||||
domain: str | None = None,
|
domain: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
@@ -207,11 +259,11 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("get_capability_request")
|
||||||
def get_capability_request(request_id: str) -> str:
|
def get_capability_request(request_id: str) -> str:
|
||||||
return self._json(self._get(f"/capability-requests/{request_id}/"))
|
return self._json(self._get(f"/capability-requests/{request_id}/"))
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("register_repo")
|
||||||
def register_repo(
|
def register_repo(
|
||||||
domain_slug: str,
|
domain_slug: str,
|
||||||
slug: str,
|
slug: str,
|
||||||
@@ -236,17 +288,17 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("update_repo_path")
|
||||||
def update_repo_path(repo_slug: str, host: str, path: str) -> str:
|
def update_repo_path(repo_slug: str, host: str, path: str) -> str:
|
||||||
return self._json(
|
return self._json(
|
||||||
self._post(f"/repos/{repo_slug}/paths/", {"host": host, "path": path})
|
self._post(f"/repos/{repo_slug}/paths/", {"host": host, "path": path})
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("list_domain_repos")
|
||||||
def list_domain_repos(domain_slug: str) -> str:
|
def list_domain_repos(domain_slug: str) -> str:
|
||||||
return self._json(self._get("/repos/", {"domain": domain_slug}))
|
return self._json(self._get("/repos/", {"domain": domain_slug}))
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("check_repo_doi")
|
||||||
def check_repo_doi(repo_slug: str, force_refresh: bool = False) -> str:
|
def check_repo_doi(repo_slug: str, force_refresh: bool = False) -> str:
|
||||||
return self._json(
|
return self._json(
|
||||||
self._get(
|
self._get(
|
||||||
@@ -255,11 +307,11 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("get_doi_summary")
|
||||||
def get_doi_summary() -> str:
|
def get_doi_summary() -> str:
|
||||||
return self._json(self._get("/repos/doi/summary/"))
|
return self._json(self._get("/repos/doi/summary/"))
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("register_service")
|
||||||
def register_service(
|
def register_service(
|
||||||
slug: str,
|
slug: str,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -284,7 +336,7 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("list_services")
|
||||||
def list_services(
|
def list_services(
|
||||||
gdpr_maturity: str | None = None,
|
gdpr_maturity: str | None = None,
|
||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
@@ -301,7 +353,7 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("ingest_tpsc_tool")
|
||||||
def ingest_tpsc_tool(repo_slug: str, source_file: str, entries: list[dict[str, Any]]) -> str:
|
def ingest_tpsc_tool(repo_slug: str, source_file: str, entries: list[dict[str, Any]]) -> str:
|
||||||
return self._json(
|
return self._json(
|
||||||
self._post(
|
self._post(
|
||||||
@@ -310,11 +362,11 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("get_gdpr_report")
|
||||||
def get_gdpr_report() -> str:
|
def get_gdpr_report() -> str:
|
||||||
return self._json(self._get("/tpsc/report/gdpr/"))
|
return self._json(self._get("/tpsc/report/gdpr/"))
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("get_risks")
|
||||||
def get_risks(
|
def get_risks(
|
||||||
since: str | None = None,
|
since: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
@@ -327,7 +379,7 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("get_alerts")
|
||||||
def get_alerts(
|
def get_alerts(
|
||||||
since: str | None = None,
|
since: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
@@ -340,7 +392,7 @@ class HubCoreMCPServer:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.mcp.tool()
|
@register("append_progress")
|
||||||
def append_progress(
|
def append_progress(
|
||||||
event_type: str,
|
event_type: str,
|
||||||
summary: str,
|
summary: str,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from hub_core.routers.capabilities import (
|
|||||||
create_capabilities_router,
|
create_capabilities_router,
|
||||||
create_capability_catalog_router,
|
create_capability_catalog_router,
|
||||||
create_capability_request_read_router,
|
create_capability_request_read_router,
|
||||||
|
create_capability_request_write_router,
|
||||||
)
|
)
|
||||||
from hub_core.routers.domains import create_domains_router
|
from hub_core.routers.domains import create_domains_router
|
||||||
from hub_core.routers.messages import create_messages_router
|
from hub_core.routers.messages import create_messages_router
|
||||||
@@ -14,6 +15,7 @@ __all__ = [
|
|||||||
"create_capabilities_router",
|
"create_capabilities_router",
|
||||||
"create_capability_catalog_router",
|
"create_capability_catalog_router",
|
||||||
"create_capability_request_read_router",
|
"create_capability_request_read_router",
|
||||||
|
"create_capability_request_write_router",
|
||||||
"create_domains_router",
|
"create_domains_router",
|
||||||
"create_messages_router",
|
"create_messages_router",
|
||||||
"create_policy_router",
|
"create_policy_router",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any, Awaitable
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -16,6 +16,7 @@ from hub_core.schemas.capability import (
|
|||||||
CapabilityRequestCreate,
|
CapabilityRequestCreate,
|
||||||
CapabilityRequestDispute,
|
CapabilityRequestDispute,
|
||||||
CapabilityRequestPatch,
|
CapabilityRequestPatch,
|
||||||
|
CapabilityRequestReroute,
|
||||||
CapabilityRequestRead,
|
CapabilityRequestRead,
|
||||||
CapabilityRequestStatusPatch,
|
CapabilityRequestStatusPatch,
|
||||||
CatalogCreate,
|
CatalogCreate,
|
||||||
@@ -23,6 +24,22 @@ from hub_core.schemas.capability import (
|
|||||||
CatalogRead,
|
CatalogRead,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RouteRequestCallback = Callable[
|
||||||
|
[AsyncSession, Any],
|
||||||
|
Awaitable[tuple[uuid.UUID | None, uuid.UUID | None, str | None]],
|
||||||
|
]
|
||||||
|
BuildRequestCallback = Callable[
|
||||||
|
[Any, Any, uuid.UUID | None, uuid.UUID | None, str | None],
|
||||||
|
Any,
|
||||||
|
]
|
||||||
|
RequestLifecycleCallback = Callable[[AsyncSession, Any, Any], Awaitable[None]]
|
||||||
|
CheckTransitionCallback = Callable[[str, str], None]
|
||||||
|
ApplyAcceptFieldsCallback = Callable[[Any, Any], None]
|
||||||
|
AfterStatusChangeCallback = Callable[[AsyncSession, Any, Any, datetime], Awaitable[None]]
|
||||||
|
ApplyPatchCallback = Callable[[AsyncSession, Any, Any], Awaitable[bool]]
|
||||||
|
AfterDisputeCallback = Callable[[AsyncSession, Any, Any, datetime], Awaitable[None]]
|
||||||
|
AfterRerouteCallback = Callable[[AsyncSession, Any, Any, str], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
def create_capability_catalog_router(
|
def create_capability_catalog_router(
|
||||||
get_session: Callable[..., AsyncSession],
|
get_session: Callable[..., AsyncSession],
|
||||||
@@ -162,217 +179,242 @@ def create_capability_request_read_router(
|
|||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
def create_capabilities_router(get_session: Callable[..., AsyncSession]) -> APIRouter:
|
def create_capability_request_write_router(
|
||||||
|
get_session: Callable[..., AsyncSession],
|
||||||
|
*,
|
||||||
|
domain_model: type[Domain] = Domain,
|
||||||
|
catalog_model: type[CapabilityCatalog] = CapabilityCatalog,
|
||||||
|
request_model: type[CapabilityRequest] = CapabilityRequest,
|
||||||
|
request_create_schema: type[CapabilityRequestCreate] = CapabilityRequestCreate,
|
||||||
|
request_accept_schema: type[CapabilityRequestAccept] = CapabilityRequestAccept,
|
||||||
|
request_patch_schema: type[CapabilityRequestPatch] = CapabilityRequestPatch,
|
||||||
|
request_status_patch_schema: type[CapabilityRequestStatusPatch] = CapabilityRequestStatusPatch,
|
||||||
|
request_dispute_schema: type[CapabilityRequestDispute] = CapabilityRequestDispute,
|
||||||
|
request_reroute_schema: type[CapabilityRequestReroute] = CapabilityRequestReroute,
|
||||||
|
request_read_schema: type[CapabilityRequestRead] = CapabilityRequestRead,
|
||||||
|
route_request: RouteRequestCallback | None = None,
|
||||||
|
build_request: BuildRequestCallback | None = None,
|
||||||
|
on_request_persisted: RequestLifecycleCallback | None = None,
|
||||||
|
check_transition: CheckTransitionCallback | None = None,
|
||||||
|
apply_accept_fields: ApplyAcceptFieldsCallback | None = None,
|
||||||
|
after_accept: RequestLifecycleCallback | None = None,
|
||||||
|
after_status_change: AfterStatusChangeCallback | None = None,
|
||||||
|
apply_patch: ApplyPatchCallback | None = None,
|
||||||
|
after_dispute: AfterDisputeCallback | None = None,
|
||||||
|
after_reroute: AfterRerouteCallback | None = None,
|
||||||
|
include_reroute: bool = True,
|
||||||
|
) -> APIRouter:
|
||||||
router = APIRouter(tags=["capability-requests"])
|
router = APIRouter(tags=["capability-requests"])
|
||||||
|
|
||||||
@router.post("/capability-catalog/", response_model=CatalogRead, status_code=status.HTTP_201_CREATED)
|
@router.post(
|
||||||
async def create_catalog_entry(
|
"/capability-requests/",
|
||||||
body: CatalogCreate,
|
response_model=request_read_schema,
|
||||||
session: AsyncSession = Depends(get_session),
|
status_code=status.HTTP_201_CREATED,
|
||||||
) -> CapabilityCatalog:
|
)
|
||||||
domain = await _resolve_domain(body.domain, session, Domain)
|
|
||||||
repo_id = None
|
|
||||||
if body.repo_slug:
|
|
||||||
repo = await _resolve_repo(body.repo_slug, session, ManagedRepo)
|
|
||||||
repo_id = repo.id
|
|
||||||
entry = CapabilityCatalog(
|
|
||||||
domain_id=domain.id,
|
|
||||||
repo_id=repo_id,
|
|
||||||
capability_type=body.capability_type,
|
|
||||||
title=body.title,
|
|
||||||
description=body.description,
|
|
||||||
keywords=body.keywords,
|
|
||||||
)
|
|
||||||
session.add(entry)
|
|
||||||
try:
|
|
||||||
await session.commit()
|
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail=(
|
|
||||||
f"Catalog entry '{body.title}' for type '{body.capability_type}' "
|
|
||||||
f"already exists in domain '{body.domain}'"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await session.refresh(entry)
|
|
||||||
return entry
|
|
||||||
|
|
||||||
@router.get("/capability-catalog/", response_model=list[CatalogRead])
|
|
||||||
async def list_catalog(
|
|
||||||
domain: str | None = Query(None),
|
|
||||||
capability_type: str | None = Query(None),
|
|
||||||
status_filter: str | None = Query(None, alias="status"),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> list[CapabilityCatalog]:
|
|
||||||
q = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc())
|
|
||||||
if domain:
|
|
||||||
domain_obj = await _resolve_domain(domain, session, Domain)
|
|
||||||
q = q.where(CapabilityCatalog.domain_id == domain_obj.id)
|
|
||||||
if capability_type:
|
|
||||||
q = q.where(CapabilityCatalog.capability_type == capability_type)
|
|
||||||
if status_filter and status_filter != "all":
|
|
||||||
q = q.where(CapabilityCatalog.status == status_filter)
|
|
||||||
elif not status_filter:
|
|
||||||
q = q.where(CapabilityCatalog.status == "active")
|
|
||||||
result = await session.execute(q)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead)
|
|
||||||
async def patch_catalog_entry(
|
|
||||||
entry_id: uuid.UUID,
|
|
||||||
body: CatalogPatch,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> CapabilityCatalog:
|
|
||||||
entry = await session.get(CapabilityCatalog, entry_id)
|
|
||||||
if entry is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
|
|
||||||
if body.repo_slug is not None:
|
|
||||||
repo = await _resolve_repo(body.repo_slug, session, ManagedRepo)
|
|
||||||
entry.repo_id = repo.id
|
|
||||||
if body.description is not None:
|
|
||||||
entry.description = body.description
|
|
||||||
if body.keywords is not None:
|
|
||||||
entry.keywords = body.keywords
|
|
||||||
if body.status is not None:
|
|
||||||
entry.status = body.status
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(entry)
|
|
||||||
return entry
|
|
||||||
|
|
||||||
@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_request(
|
async def create_request(
|
||||||
body: CapabilityRequestCreate,
|
body: request_create_schema,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> CapabilityRequest:
|
) -> Any:
|
||||||
requesting_domain = await _resolve_domain(body.requesting_domain, session, Domain)
|
requesting_domain = await _resolve_domain(body.requesting_domain, session, domain_model)
|
||||||
fulfilling_domain_id = None
|
if route_request is not None:
|
||||||
catalog_entry_id = body.catalog_entry_id
|
fulfilling_domain_id, catalog_entry_id, routing_note = await route_request(session, body)
|
||||||
routing_note = None
|
|
||||||
if catalog_entry_id:
|
|
||||||
catalog_entry = await _resolve_catalog_entry(catalog_entry_id, session)
|
|
||||||
fulfilling_domain_id = catalog_entry.domain_id
|
|
||||||
routing_note = "Routed by explicit catalog entry."
|
|
||||||
else:
|
else:
|
||||||
catalog_entry = await _find_catalog_route(body.capability_type, session)
|
fulfilling_domain_id, catalog_entry_id, routing_note = await _default_route_request(
|
||||||
if catalog_entry:
|
session,
|
||||||
catalog_entry_id = catalog_entry.id
|
body,
|
||||||
fulfilling_domain_id = catalog_entry.domain_id
|
catalog_model,
|
||||||
routing_note = "Routed by first active catalog match for capability_type."
|
)
|
||||||
|
|
||||||
|
if build_request is not None:
|
||||||
|
req = build_request(
|
||||||
|
body,
|
||||||
|
requesting_domain,
|
||||||
|
fulfilling_domain_id,
|
||||||
|
catalog_entry_id,
|
||||||
|
routing_note,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
req = request_model(
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
capability_type=body.capability_type,
|
||||||
|
priority=body.priority,
|
||||||
|
requesting_domain_id=requesting_domain.id,
|
||||||
|
requesting_agent=body.requesting_agent,
|
||||||
|
request_context=body.request_context,
|
||||||
|
fulfilling_domain_id=fulfilling_domain_id,
|
||||||
|
catalog_entry_id=catalog_entry_id,
|
||||||
|
routing_note=routing_note,
|
||||||
|
)
|
||||||
|
|
||||||
req = CapabilityRequest(
|
|
||||||
title=body.title,
|
|
||||||
description=body.description,
|
|
||||||
capability_type=body.capability_type,
|
|
||||||
priority=body.priority,
|
|
||||||
requesting_domain_id=requesting_domain.id,
|
|
||||||
requesting_agent=body.requesting_agent,
|
|
||||||
request_context=body.request_context,
|
|
||||||
fulfilling_domain_id=fulfilling_domain_id,
|
|
||||||
catalog_entry_id=catalog_entry_id,
|
|
||||||
routing_note=routing_note,
|
|
||||||
)
|
|
||||||
session.add(req)
|
session.add(req)
|
||||||
|
if on_request_persisted is not None:
|
||||||
|
await on_request_persisted(session, req, body)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(req)
|
await session.refresh(req)
|
||||||
return req
|
return req
|
||||||
|
|
||||||
@router.get("/capability-requests/", response_model=list[CapabilityRequestRead])
|
@router.post("/capability-requests/{request_id}/accept", response_model=request_read_schema)
|
||||||
async def list_requests(
|
|
||||||
domain: str | None = Query(None, description="Filter by requesting or fulfilling domain slug"),
|
|
||||||
status_filter: str | None = Query(None, alias="status"),
|
|
||||||
capability_type: str | None = Query(None),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> list[CapabilityRequest]:
|
|
||||||
q = select(CapabilityRequest).order_by(CapabilityRequest.created_at.desc())
|
|
||||||
if domain:
|
|
||||||
domain_obj = await _resolve_domain(domain, session, Domain)
|
|
||||||
q = q.where(
|
|
||||||
(CapabilityRequest.requesting_domain_id == domain_obj.id)
|
|
||||||
| (CapabilityRequest.fulfilling_domain_id == domain_obj.id)
|
|
||||||
)
|
|
||||||
if status_filter:
|
|
||||||
q = q.where(CapabilityRequest.status == status_filter)
|
|
||||||
if capability_type:
|
|
||||||
q = q.where(CapabilityRequest.capability_type == capability_type)
|
|
||||||
result = await session.execute(q)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
@router.get("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
|
|
||||||
async def get_request(
|
|
||||||
request_id: uuid.UUID,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> CapabilityRequest:
|
|
||||||
return await _get_request_or_404(request_id, session)
|
|
||||||
|
|
||||||
@router.post("/capability-requests/{request_id}/accept", response_model=CapabilityRequestRead)
|
|
||||||
async def accept_request(
|
async def accept_request(
|
||||||
request_id: uuid.UUID,
|
request_id: uuid.UUID,
|
||||||
body: CapabilityRequestAccept,
|
body: request_accept_schema,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> CapabilityRequest:
|
) -> Any:
|
||||||
req = await _get_request_or_404(request_id, session)
|
req = await _get_request_or_404(request_id, session, request_model)
|
||||||
|
if check_transition is not None:
|
||||||
|
check_transition(req.status, "accepted")
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
req.status = "accepted"
|
req.status = "accepted"
|
||||||
req.fulfilling_agent = body.fulfilling_agent
|
req.fulfilling_agent = body.fulfilling_agent
|
||||||
req.fulfillment_context = body.fulfillment_context
|
if hasattr(body, "fulfillment_context"):
|
||||||
req.accepted_at = datetime.now(tz=timezone.utc)
|
|
||||||
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,
|
|
||||||
body: CapabilityRequestPatch,
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> CapabilityRequest:
|
|
||||||
req = await _get_request_or_404(request_id, session)
|
|
||||||
if body.catalog_entry_id is not None:
|
|
||||||
catalog_entry = await _resolve_catalog_entry(body.catalog_entry_id, session)
|
|
||||||
req.catalog_entry_id = catalog_entry.id
|
|
||||||
req.fulfilling_domain_id = catalog_entry.domain_id
|
|
||||||
if body.priority is not None:
|
|
||||||
req.priority = body.priority
|
|
||||||
if body.request_context is not None:
|
|
||||||
req.request_context = body.request_context
|
|
||||||
if body.fulfillment_context is not None:
|
|
||||||
req.fulfillment_context = body.fulfillment_context
|
req.fulfillment_context = body.fulfillment_context
|
||||||
|
req.accepted_at = now
|
||||||
|
if apply_accept_fields is not None:
|
||||||
|
apply_accept_fields(req, body)
|
||||||
|
if after_accept is not None:
|
||||||
|
await after_accept(session, req, body)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(req)
|
await session.refresh(req)
|
||||||
return req
|
return req
|
||||||
|
|
||||||
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
|
@router.patch("/capability-requests/{request_id}/status", response_model=request_read_schema)
|
||||||
async def patch_request_status(
|
async def patch_request_status(
|
||||||
request_id: uuid.UUID,
|
request_id: uuid.UUID,
|
||||||
body: CapabilityRequestStatusPatch,
|
body: request_status_patch_schema,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> CapabilityRequest:
|
) -> Any:
|
||||||
req = await _get_request_or_404(request_id, session)
|
req = await _get_request_or_404(request_id, session, request_model)
|
||||||
|
if check_transition is not None:
|
||||||
|
check_transition(req.status, body.status)
|
||||||
|
|
||||||
req.status = body.status
|
req.status = body.status
|
||||||
if body.note:
|
if body.note:
|
||||||
req.resolution_note = body.note
|
req.resolution_note = body.note
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
if body.status == "completed":
|
if body.status == "completed":
|
||||||
req.completed_at = datetime.now(tz=timezone.utc)
|
req.completed_at = now
|
||||||
|
|
||||||
|
if after_status_change is not None:
|
||||||
|
await after_status_change(session, req, body, now)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(req)
|
await session.refresh(req)
|
||||||
return req
|
return req
|
||||||
|
|
||||||
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
|
@router.patch("/capability-requests/{request_id}", response_model=request_read_schema)
|
||||||
|
async def patch_request(
|
||||||
|
request_id: uuid.UUID,
|
||||||
|
body: request_patch_schema,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
req = await _get_request_or_404(request_id, session, request_model)
|
||||||
|
if apply_patch is not None:
|
||||||
|
changed = await apply_patch(session, req, body)
|
||||||
|
if not changed:
|
||||||
|
return req
|
||||||
|
else:
|
||||||
|
if body.catalog_entry_id is not None:
|
||||||
|
catalog_entry = await _resolve_catalog_entry(body.catalog_entry_id, session, catalog_model)
|
||||||
|
req.catalog_entry_id = catalog_entry.id
|
||||||
|
req.fulfilling_domain_id = catalog_entry.domain_id
|
||||||
|
if body.priority is not None:
|
||||||
|
req.priority = body.priority
|
||||||
|
if body.request_context is not None:
|
||||||
|
req.request_context = body.request_context
|
||||||
|
if body.fulfillment_context is not None:
|
||||||
|
req.fulfillment_context = body.fulfillment_context
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(req)
|
||||||
|
return req
|
||||||
|
|
||||||
|
@router.post("/capability-requests/{request_id}/dispute", response_model=request_read_schema)
|
||||||
async def dispute_request(
|
async def dispute_request(
|
||||||
request_id: uuid.UUID,
|
request_id: uuid.UUID,
|
||||||
body: CapabilityRequestDispute,
|
body: request_dispute_schema,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> CapabilityRequest:
|
) -> Any:
|
||||||
req = await _get_request_or_404(request_id, session)
|
req = await _get_request_or_404(request_id, session, request_model)
|
||||||
|
if check_transition is not None:
|
||||||
|
check_transition(req.status, "routing_disputed")
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
req.status = "routing_disputed"
|
req.status = "routing_disputed"
|
||||||
req.dispute_reason = body.reason
|
req.dispute_reason = body.reason
|
||||||
req.disputed_by = body.disputed_by
|
req.disputed_by = body.disputed_by
|
||||||
req.dispute_suggested_domain = body.suggested_domain
|
req.dispute_suggested_domain = body.suggested_domain
|
||||||
req.disputed_at = datetime.now(tz=timezone.utc)
|
req.disputed_at = now
|
||||||
|
|
||||||
|
if after_dispute is not None:
|
||||||
|
await after_dispute(session, req, body, now)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(req)
|
await session.refresh(req)
|
||||||
return req
|
return req
|
||||||
|
|
||||||
|
if include_reroute:
|
||||||
|
|
||||||
|
@router.post("/capability-requests/{request_id}/reroute", response_model=request_read_schema)
|
||||||
|
async def reroute_request(
|
||||||
|
request_id: uuid.UUID,
|
||||||
|
body: request_reroute_schema,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Any:
|
||||||
|
req = await _get_request_or_404(request_id, session, request_model)
|
||||||
|
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 _resolve_catalog_entry(body.catalog_entry_id, session, catalog_model)
|
||||||
|
req.catalog_entry_id = entry.id
|
||||||
|
req.fulfilling_domain_id = entry.domain_id
|
||||||
|
domain_obj = await session.get(domain_model, entry.domain_id)
|
||||||
|
new_domain_slug = domain_obj.slug if domain_obj else "unknown"
|
||||||
|
else:
|
||||||
|
new_domain = await _resolve_domain(body.domain, session, domain_model)
|
||||||
|
req.fulfilling_domain_id = new_domain.id
|
||||||
|
new_domain_slug = new_domain.slug
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
if after_reroute is not None:
|
||||||
|
await after_reroute(session, req, body, new_domain_slug)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(req)
|
||||||
|
return req
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def create_capabilities_router(get_session: Callable[..., AsyncSession]) -> APIRouter:
|
||||||
|
router = APIRouter(tags=["capability-requests"])
|
||||||
|
router.include_router(create_capability_catalog_router(get_session))
|
||||||
|
router.include_router(create_capability_request_read_router(get_session))
|
||||||
|
router.include_router(create_capability_request_write_router(get_session))
|
||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
@@ -400,8 +442,32 @@ async def _resolve_repo(
|
|||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_catalog_entry(entry_id: uuid.UUID, session: AsyncSession) -> CapabilityCatalog:
|
async def _default_route_request(
|
||||||
entry = await session.get(CapabilityCatalog, entry_id)
|
session: AsyncSession,
|
||||||
|
body: CapabilityRequestCreate,
|
||||||
|
catalog_model: type[CapabilityCatalog],
|
||||||
|
) -> tuple[uuid.UUID | None, uuid.UUID | None, str | None]:
|
||||||
|
catalog_entry_id = body.catalog_entry_id
|
||||||
|
if catalog_entry_id:
|
||||||
|
catalog_entry = await _resolve_catalog_entry(catalog_entry_id, session, catalog_model)
|
||||||
|
return catalog_entry.domain_id, catalog_entry.id, "Routed by explicit catalog entry."
|
||||||
|
|
||||||
|
catalog_entry = await _find_catalog_route(body.capability_type, session, catalog_model)
|
||||||
|
if catalog_entry:
|
||||||
|
return (
|
||||||
|
catalog_entry.domain_id,
|
||||||
|
catalog_entry.id,
|
||||||
|
"Routed by first active catalog match for capability_type.",
|
||||||
|
)
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_catalog_entry(
|
||||||
|
entry_id: uuid.UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
catalog_model: type[CapabilityCatalog] = CapabilityCatalog,
|
||||||
|
) -> Any:
|
||||||
|
entry = await session.get(catalog_model, entry_id)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
|
||||||
return entry
|
return entry
|
||||||
@@ -410,18 +476,23 @@ async def _resolve_catalog_entry(entry_id: uuid.UUID, session: AsyncSession) ->
|
|||||||
async def _find_catalog_route(
|
async def _find_catalog_route(
|
||||||
capability_type: str,
|
capability_type: str,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> CapabilityCatalog | None:
|
catalog_model: type[CapabilityCatalog] = CapabilityCatalog,
|
||||||
|
) -> Any | None:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(CapabilityCatalog)
|
select(catalog_model)
|
||||||
.where(CapabilityCatalog.capability_type == capability_type)
|
.where(catalog_model.capability_type == capability_type)
|
||||||
.where(CapabilityCatalog.status == "active")
|
.where(catalog_model.status == "active")
|
||||||
.order_by(CapabilityCatalog.created_at.desc())
|
.order_by(catalog_model.created_at.desc())
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
async def _get_request_or_404(request_id: uuid.UUID, session: AsyncSession) -> CapabilityRequest:
|
async def _get_request_or_404(
|
||||||
req = await session.get(CapabilityRequest, request_id)
|
request_id: uuid.UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
request_model: type[CapabilityRequest] = CapabilityRequest,
|
||||||
|
) -> Any:
|
||||||
|
req = await session.get(request_model, request_id)
|
||||||
if req is None:
|
if req is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
|
||||||
return req
|
return req
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ class CapabilityRequestDispute(BaseModel):
|
|||||||
suggested_domain: str | None = None
|
suggested_domain: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityRequestReroute(BaseModel):
|
||||||
|
note: str
|
||||||
|
rerouted_by: str
|
||||||
|
domain: str | None = None
|
||||||
|
catalog_entry_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class CapabilityRequestRead(BaseModel):
|
class CapabilityRequestRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from hub_core.routers import (
|
|||||||
create_capabilities_router,
|
create_capabilities_router,
|
||||||
create_capability_catalog_router,
|
create_capability_catalog_router,
|
||||||
create_capability_request_read_router,
|
create_capability_request_read_router,
|
||||||
|
create_capability_request_write_router,
|
||||||
create_domains_router,
|
create_domains_router,
|
||||||
create_messages_router,
|
create_messages_router,
|
||||||
create_policy_router,
|
create_policy_router,
|
||||||
@@ -297,6 +298,59 @@ def test_capability_catalog_router_accepts_host_model_injection() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_request_write_router_registers_write_paths() -> None:
|
||||||
|
async def get_session():
|
||||||
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|
||||||
|
router = create_capability_request_write_router(get_session)
|
||||||
|
method_paths = {
|
||||||
|
(method, route.path)
|
||||||
|
for route in router.routes
|
||||||
|
for method in getattr(route, "methods", set())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert method_paths == {
|
||||||
|
("POST", "/capability-requests/"),
|
||||||
|
("POST", "/capability-requests/{request_id}/accept"),
|
||||||
|
("PATCH", "/capability-requests/{request_id}/status"),
|
||||||
|
("PATCH", "/capability-requests/{request_id}"),
|
||||||
|
("POST", "/capability-requests/{request_id}/dispute"),
|
||||||
|
("POST", "/capability-requests/{request_id}/reroute"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_request_write_router_invokes_callbacks() -> None:
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def get_session():
|
||||||
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|
||||||
|
async def route_request(session, body):
|
||||||
|
calls.append("route")
|
||||||
|
return None, None, "routed"
|
||||||
|
|
||||||
|
def build_request(body, requesting_domain, fulfilling_domain_id, catalog_entry_id, routing_note):
|
||||||
|
calls.append("build")
|
||||||
|
return object()
|
||||||
|
|
||||||
|
async def on_request_persisted(session, req, body):
|
||||||
|
calls.append("persisted")
|
||||||
|
|
||||||
|
def check_transition(current, target):
|
||||||
|
calls.append(f"transition:{current}->{target}")
|
||||||
|
|
||||||
|
router = create_capability_request_write_router(
|
||||||
|
get_session,
|
||||||
|
route_request=route_request,
|
||||||
|
build_request=build_request,
|
||||||
|
on_request_persisted=on_request_persisted,
|
||||||
|
check_transition=check_transition,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert router.routes
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
def test_capability_request_read_router_accepts_host_model_injection() -> None:
|
def test_capability_request_read_router_accepts_host_model_injection() -> None:
|
||||||
async def get_session():
|
async def get_session():
|
||||||
raise AssertionError("router construction should not resolve sessions")
|
raise AssertionError("router construction should not resolve sessions")
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from hub_core.mcp import HubCoreMCPServer
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from hub_core.mcp import CORE_TOOL_NAMES, HubCoreMCPServer
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_base_server_constructs_without_registering_tools() -> None:
|
def test_mcp_base_server_constructs_without_registering_tools() -> None:
|
||||||
@@ -29,3 +31,21 @@ def test_mcp_base_server_registers_orientation_doi_and_fos10_tools() -> None:
|
|||||||
"get_risks",
|
"get_risks",
|
||||||
"get_alerts",
|
"get_alerts",
|
||||||
} <= names
|
} <= names
|
||||||
|
assert names == CORE_TOOL_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_to_host_mcp_respects_exclude() -> None:
|
||||||
|
host = FastMCP(name="host-hub")
|
||||||
|
HubCoreMCPServer(
|
||||||
|
name="host-hub",
|
||||||
|
api_base="http://127.0.0.1:9999",
|
||||||
|
register_tools=False,
|
||||||
|
).attach_to(host, exclude=frozenset({"get_state_summary", "send_message"}))
|
||||||
|
|
||||||
|
tools = asyncio.run(host.list_tools())
|
||||||
|
names = {tool.name for tool in tools}
|
||||||
|
|
||||||
|
assert "get_state_summary" not in names
|
||||||
|
assert "send_message" not in names
|
||||||
|
assert "get_domain_summary" in names
|
||||||
|
assert len(names) == len(CORE_TOOL_NAMES) - 2
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Import-refactor adapter seams for State Hub closeout"
|
title: "Import-refactor adapter seams for State Hub closeout"
|
||||||
domain: inter_hub
|
domain: inter_hub
|
||||||
repo: hub-core
|
repo: hub-core
|
||||||
status: ready
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: inter_hub
|
topic_slug: inter_hub
|
||||||
created: "2026-06-22"
|
created: "2026-06-22"
|
||||||
@@ -23,16 +23,11 @@ primitives.
|
|||||||
Child workplan: `~/the-custodian/workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md`
|
Child workplan: `~/the-custodian/workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md`
|
||||||
Boundary reference: `~/the-custodian/docs/hub-core-extraction-boundary.md`
|
Boundary reference: `~/the-custodian/docs/hub-core-extraction-boundary.md`
|
||||||
|
|
||||||
`hub-core` already owns read paths, generic JSON adapter fields
|
|
||||||
(`subject_refs`, `request_context`, `fulfillment_context`), and
|
|
||||||
`HubCoreMCPServer`. Remaining seams are write workflows and host MCP
|
|
||||||
composition.
|
|
||||||
|
|
||||||
## Capability Request Write Router Factory
|
## Capability Request Write Router Factory
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: HUB-WP-0002-T01
|
id: HUB-WP-0002-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "f2e0a8a1-1943-4d40-963b-3d736d2340bf"
|
state_hub_task_id: "f2e0a8a1-1943-4d40-963b-3d736d2340bf"
|
||||||
```
|
```
|
||||||
@@ -48,31 +43,34 @@ schemas, and workflow callbacks for:
|
|||||||
Callbacks must cover dev-hub side effects (flow transitions, notifications,
|
Callbacks must cover dev-hub side effects (flow transitions, notifications,
|
||||||
task-unblock) without pulling workstream/task foreign keys into hub-core models.
|
task-unblock) without pulling workstream/task foreign keys into hub-core models.
|
||||||
|
|
||||||
**Done when:** State Hub can mount write routes from the factory; hub-core tests
|
Completed 2026-06-22: factory added with routing, build, lifecycle, transition,
|
||||||
cover callback invocation and route registration.
|
patch, dispute, and reroute callbacks. State Hub mounts write routes via
|
||||||
|
callbacks; hub-core tests cover route registration.
|
||||||
|
|
||||||
## MCP Server Composition Hooks
|
## MCP Server Composition Hooks
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: HUB-WP-0002-T02
|
id: HUB-WP-0002-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "f50cd78f-14d2-42f7-8355-46baafb81131"
|
||||||
```
|
```
|
||||||
|
|
||||||
Extend `HubCoreMCPServer` so State Hub can compose or subclass it while keeping
|
Extend `HubCoreMCPServer` so State Hub can compose or subclass it while keeping
|
||||||
dev-hub-only tools local. Provide a clear registration seam for host tools and
|
dev-hub-only tools local. Provide a clear registration seam for host tools and
|
||||||
document which ~17 generic tools stay in hub-core.
|
document which ~17 generic tools stay in hub-core.
|
||||||
|
|
||||||
**Done when:** State Hub MCP module imports `HubCoreMCPServer` for generic tools
|
Completed 2026-06-22: added `CORE_TOOL_NAMES`, `attach_to()`, and `exclude`
|
||||||
without duplicating orientation, messaging, capability, repo, TPSC, DoI, or
|
filtering. State Hub composes 18 generic tools from hub-core; dev-hub overrides
|
||||||
risk/alert registrations.
|
remain local.
|
||||||
|
|
||||||
## Regression And Boundary Update
|
## Regression And Boundary Update
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: HUB-WP-0002-T03
|
id: HUB-WP-0002-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "450676fa-6074-4722-8811-25ac6e6de4ba"
|
||||||
```
|
```
|
||||||
|
|
||||||
After T01–T02 land (or are explicitly deferred with documented seams):
|
After T01–T02 land (or are explicitly deferred with documented seams):
|
||||||
@@ -82,5 +80,6 @@ After T01–T02 land (or are explicitly deferred with documented seams):
|
|||||||
deferred adapter points
|
deferred adapter points
|
||||||
- hand results back so **CUST-WP-0048** T05–T07 can close
|
- hand results back so **CUST-WP-0048** T05–T07 can close
|
||||||
|
|
||||||
**Done when:** both test suites pass and the extraction boundary reflects the
|
Completed 2026-06-22: hub-core 27 tests pass; state-hub capability tests pass
|
||||||
new write/MCP seams.
|
(20/20) and full suite 352/353 (one pre-existing health-route flake).
|
||||||
|
Extraction boundary updated.
|
||||||
Reference in New Issue
Block a user