feat(design-systems): REST + MCP surfaces, at parity
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 15s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 26s

Milestone #254 step 4 (#2290). Eleven capabilities, both surfaces, one service.

Design systems are owner-scoped top-level records rather than project-scoped
ones, so these do not nest under /api/projects/ the way systems do —
routes/rulebooks.py was the closer shape. The one exception is the project
pointer, which is genuinely about a project: PUT /api/projects/<id>/design-system,
PUT rather than PATCH because clearing it is a first-class outcome and not an
omission.

`/resolved` and `/tokens` are deliberately separate endpoints. One answers "what
does this system CHANGE", the other "what does it end up BEING", and a system
that overrides nothing has an empty token list and a full resolved set. Shipping
only one would have made the other a client-side computation of exactly the kind
the record model exists to remove.

ResolvedToken.to_dict carries the SHADOWED contributions, not just the winner.
Dropping them at the serialisation boundary would have discarded the one thing
step 2 was built to preserve, and it would have been invisible — the payload
still looks complete.

Three sentinel translations on the MCP side, each tested, because an agent
cannot omit an argument and a wrong mapping here is silent:

  - parent_id: 0 = unchanged, -1 = clear (become a family system), positive =
    set. Renaming a system must not silently re-root it.
  - order_index: -1 = unchanged, since 0 is a valid position.
  - value_by_mode: guarded on `is not None`, not truthiness, so `{}` can strip
    every mode from a token instead of being unreachable.

DesignSystemCycle maps to 400 on REST and to a ValueError carrying the message
on MCP — kept apart from 404 throughout. An agent told "not found" retries the
same call; one told what the loop is can fix it.

Two structural guards beyond the parity list: every endpoint must be reachable
on the app (catching a decorator copied without its path, where the second
handler silently never runs), and every public coroutine in the tools module
must be registered (a tool written but never registered is invisible to an
agent, and nothing else would notice).
This commit is contained in:
2026-07-30 17:09:25 -04:00
parent 839d6902ad
commit 143b968c5d
7 changed files with 781 additions and 1 deletions
+2
View File
@@ -27,6 +27,7 @@ from scribe.routes.knowledge import knowledge_bp
from scribe.routes.rulebooks import rulebooks_bp
from scribe.routes.plugin import plugin_bp
from scribe.routes.design import design_bp
from scribe.routes.design_systems import design_systems_bp
from scribe.routes.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp
from scribe.routes.systems import systems_bp
@@ -91,6 +92,7 @@ def create_app() -> Quart:
app.register_blueprint(rulebooks_bp)
app.register_blueprint(plugin_bp)
app.register_blueprint(design_bp)
app.register_blueprint(design_systems_bp)
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(systems_bp)
+3 -1
View File
@@ -5,7 +5,8 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets, systems, tags, tasks, trash,
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets,
systems, tags, tasks, trash,
)
@@ -17,6 +18,7 @@ def register_all(mcp) -> None:
projects.register(mcp)
milestones.register(mcp)
systems.register(mcp)
design_systems.register(mcp)
tags.register(mcp)
recent.register(mcp)
repos.register(mcp)
+266
View File
@@ -0,0 +1,266 @@
"""Design system + token MCP tools — wrappers over services/design_systems.py.
A design system is a stylesheet held as records: a named set of tokens with an
optional parent, so a family system carries the house style and an app system
carries only what it changes. Precedence by name along the parent chain IS the
CSS cascade, which is why "what does this app alter?" is a plain list rather
than a diff.
Parity with the REST surface is a rule, not a nicety (see
`tests/test_routes_design_systems.py`): an agent and a browser are two callers
of one service.
Sentinels, matching the milestone/task tool conventions:
- title="" / description="" / etc. -> "leave unchanged" on update
- parent_id / design_system_id: 0 = leave unchanged, -1 = clear, positive = set
(three states, because clearing a parent is a real operation and not the
same as omitting the argument)
- order_index=-1 -> "leave unchanged" (0 is a valid order_index)
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import design_systems as ds_svc
from scribe.services.design_systems import DesignSystemCycle
async def create_design_system(
title: str,
description: str = "",
parent_id: int = 0,
) -> dict:
"""Create a design system, optionally inheriting from another.
Args:
title: What this system is, e.g. "FabledSword" or "Scribe" (required).
description: What it covers and when it applies.
parent_id: Inherit from this system — it holds the defaults this one
overrides. Omit (0) for a top-level "family" system, which is what
a first design system usually is.
"""
uid = current_user_id()
system = await ds_svc.create_design_system(
uid,
title=title,
description=description or None,
parent_id=parent_id or None,
)
if system is None:
raise ValueError(f"parent design system {parent_id} not found or not writable")
return system.to_dict()
async def list_design_systems() -> dict:
"""List your design systems. An empty list is normal — most installs have none."""
uid = current_user_id()
rows = await ds_svc.list_design_systems(uid)
return {"design_systems": [s.to_dict() for s in rows]}
async def get_design_system(design_system_id: int) -> dict:
"""Fetch a design system plus its OWN tokens — i.e. what it changes.
For what it actually resolves to once inheritance is applied, use
`resolve_design_system`. The two answer different questions and a system
that overrides nothing has an empty token list but a full resolved set.
"""
uid = current_user_id()
system = await ds_svc.get_design_system(uid, design_system_id)
if system is None:
raise ValueError(f"design system {design_system_id} not found")
tokens = await ds_svc.list_tokens(uid, design_system_id)
return {
"design_system": system.to_dict(),
"tokens": [t.to_dict() for t in tokens],
}
async def resolve_design_system(design_system_id: int) -> dict:
"""The EFFECTIVE token set — everything inherited, with this system's on top.
Each token carries `origin_by_mode` (which system supplied each mode's
value) and `contributions` (every system that offered one, deepest first —
so entry 0 won and the rest were shadowed). Reach for this when you need to
know what a value actually IS; reach for `get_design_system` when you need
to know what this system CHANGES.
"""
uid = current_user_id()
resolved = await ds_svc.resolve_design_system(uid, design_system_id)
if resolved is None:
raise ValueError(f"design system {design_system_id} not found")
return {
"design_system_id": design_system_id,
"tokens": [t.to_dict() for t in resolved],
}
async def update_design_system(
design_system_id: int,
title: str = "",
description: str = "",
parent_id: int = 0,
) -> dict:
"""Update a design system.
Args:
design_system_id: The system to update.
title: New title, or "" to leave unchanged.
description: New description, or "" to leave unchanged.
parent_id: 0 = leave unchanged, -1 = clear (make this a top-level
family system), positive = inherit from that system. A parent that
already inherits from this system is refused — that would be a loop.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if description:
fields["description"] = description
if parent_id:
fields["parent_id"] = None if parent_id == -1 else parent_id
try:
system = await ds_svc.update_design_system(uid, design_system_id, **fields)
except DesignSystemCycle as exc:
raise ValueError(str(exc)) from exc
if system is None:
raise ValueError(f"design system {design_system_id} not found or not writable")
return system.to_dict()
async def delete_design_system(design_system_id: int) -> dict:
"""Soft-delete a design system (recoverable).
Systems that inherited from it become top-level systems keeping their own
tokens — deleting a family does not delete the apps under it.
"""
uid = current_user_id()
if not await ds_svc.delete_design_system(uid, design_system_id):
raise ValueError(f"design system {design_system_id} not found or not writable")
return {"message": f"Design system {design_system_id} deleted."}
# ── Tokens ──────────────────────────────────────────────────────────────
async def create_design_token(
design_system_id: int,
name: str,
value_by_mode: dict | None = None,
group_name: str = "",
purpose: str = "",
order_index: int = 0,
) -> dict:
"""Add a token to a design system.
Args:
design_system_id: The system that owns this token.
name: The custom-property name, e.g. "--fs-obsidian" (required).
value_by_mode: Values keyed by mode, e.g.
{"base": "#f7f5ef", "dark": "#14171a"}. Use "base" for the value
that applies when no mode is more specific; a token that is not
mode-dependent needs only "base". In a system WITH a parent, an
omitted mode is inherited rather than blanked.
group_name: Free-text grouping — "surface", "text", "radius", whatever
this system's own vocabulary is.
purpose: What the token is for, e.g. "page bg, deepest surface".
order_index: Display position within its group.
"""
uid = current_user_id()
token = await ds_svc.create_token(
uid,
design_system_id=design_system_id,
name=name,
value_by_mode=value_by_mode,
group_name=group_name or None,
purpose=purpose or None,
order_index=order_index,
)
if token is None:
raise ValueError(f"design system {design_system_id} not found or not writable")
return token.to_dict()
async def list_design_tokens(design_system_id: int) -> dict:
"""A design system's OWN tokens — its override set, not its effective set."""
uid = current_user_id()
rows = await ds_svc.list_tokens(uid, design_system_id)
return {"tokens": [t.to_dict() for t in rows]}
async def update_design_token(
token_id: int,
name: str = "",
value_by_mode: dict | None = None,
group_name: str = "",
purpose: str = "",
order_index: int = -1,
) -> dict:
"""Update a token. Empty/None args leave a field unchanged.
`value_by_mode` REPLACES the whole map rather than merging into it, so send
every mode you want the token to keep.
"""
uid = current_user_id()
fields: dict = {}
if name:
fields["name"] = name
if value_by_mode is not None:
fields["value_by_mode"] = value_by_mode
if group_name:
fields["group_name"] = group_name
if purpose:
fields["purpose"] = purpose
if order_index >= 0:
fields["order_index"] = order_index
token = await ds_svc.update_token(uid, token_id, **fields)
if token is None:
raise ValueError(f"design token {token_id} not found or not writable")
return token.to_dict()
async def delete_design_token(token_id: int) -> dict:
"""Soft-delete a token (recoverable).
In a system with a parent this restores inheritance: the token stops being
overridden here and resolves to the parent's value again.
"""
uid = current_user_id()
if not await ds_svc.delete_token(uid, token_id):
raise ValueError(f"design token {token_id} not found or not writable")
return {"message": f"Design token {token_id} deleted."}
async def set_project_design_system(project_id: int, design_system_id: int = 0) -> dict:
"""Point a project at a design system.
Args:
project_id: The project to style.
design_system_id: The system it uses, or -1 to clear it. Pointing at a
system only requires READ access to it — consuming a design system
is not changing it.
"""
uid = current_user_id()
target = None if design_system_id == -1 else design_system_id
ok = await ds_svc.set_project_design_system(uid, project_id, target)
if not ok:
raise ValueError(
f"project {project_id} not writable, or design system "
f"{design_system_id} not found"
)
return {"project_id": project_id, "design_system_id": target}
def register(mcp) -> None:
for fn in (
create_design_system,
list_design_systems,
get_design_system,
resolve_design_system,
update_design_system,
delete_design_system,
create_design_token,
list_design_tokens,
update_design_token,
delete_design_token,
set_project_design_system,
):
mcp.tool(name=fn.__name__)(fn)
+189
View File
@@ -0,0 +1,189 @@
"""Design system + token REST endpoints (milestone #254 step 4).
Wraps `services/design_systems.py`, which owns the ACL and the cycle guard.
Design systems are owner-scoped top-level records rather than project-scoped
ones, so these do NOT nest under `/api/projects/` — `routes/rulebooks.py` is the
closer shape.
Two failures this layer has to keep apart, which is why the service raises for
one and returns None for the other:
- `DesignSystemCycle` -> 400 with its message. "That parent already inherits
from this system" is a correctable mistake and the caller needs to be told
which one they made.
- None -> 404, covering both "no such system" and "not yours". Conflating
those two IS the intent: distinguishing them would confirm the existence of
records the caller may not see.
"""
from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required
from scribe.services import design_systems as ds_svc
from scribe.services.design_systems import DesignSystemCycle
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
def _not_found(what: str = "design system"):
return jsonify({"error": f"{what} not found"}), 404
# ── Design systems ──────────────────────────────────────────────────────
@design_systems_bp.get("/design-systems")
@login_required
async def list_design_systems():
"""The caller's design systems. An empty list is the ordinary state for an
install that has never made one, not an error."""
rows = await ds_svc.list_design_systems(_uid())
return jsonify({"design_systems": [s.to_dict() for s in rows]})
@design_systems_bp.post("/design-systems")
@login_required
async def create_design_system():
data = await request.get_json() or {}
title = (data.get("title") or "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
system = await ds_svc.create_design_system(
user_id=_uid(),
title=title,
description=data.get("description") or None,
parent_id=data.get("parent_id"),
)
if system is None:
return jsonify({"error": "parent design system not found"}), 404
return jsonify(system.to_dict()), 201
@design_systems_bp.get("/design-systems/<int:design_system_id>")
@login_required
async def get_design_system(design_system_id: int):
system = await ds_svc.get_design_system(_uid(), design_system_id)
if system is None:
return _not_found()
return jsonify(system.to_dict())
@design_systems_bp.patch("/design-systems/<int:design_system_id>")
@login_required
async def update_design_system(design_system_id: int):
data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in ("title", "description")}
# Presence, not truthiness: `{"parent_id": null}` means "make this a root",
# which a `if data.get("parent_id")` filter would silently drop.
if "parent_id" in data:
fields["parent_id"] = data["parent_id"]
try:
system = await ds_svc.update_design_system(_uid(), design_system_id, **fields)
except DesignSystemCycle as exc:
return jsonify({"error": str(exc)}), 400
if system is None:
return _not_found()
return jsonify(system.to_dict())
@design_systems_bp.delete("/design-systems/<int:design_system_id>")
@login_required
async def delete_design_system(design_system_id: int):
if not await ds_svc.delete_design_system(_uid(), design_system_id):
return _not_found()
return "", 204
@design_systems_bp.get("/design-systems/<int:design_system_id>/resolved")
@login_required
async def resolve_design_system(design_system_id: int):
"""The EFFECTIVE token set — everything inherited, with this system's on top.
Distinct from `/tokens` on purpose: that returns what this system CHANGES,
this returns what it ends up being. Both are real questions and answering
only one would make the other a client-side computation.
"""
resolved = await ds_svc.resolve_design_system(_uid(), design_system_id)
if resolved is None:
return _not_found()
return jsonify({
"design_system_id": design_system_id,
"tokens": [t.to_dict() for t in resolved],
})
# ── Tokens ──────────────────────────────────────────────────────────────
@design_systems_bp.get("/design-systems/<int:design_system_id>/tokens")
@login_required
async def list_design_tokens(design_system_id: int):
"""This system's OWN tokens — its override set, not its effective set."""
if await ds_svc.get_design_system(_uid(), design_system_id) is None:
return _not_found()
rows = await ds_svc.list_tokens(_uid(), design_system_id)
return jsonify({"tokens": [t.to_dict() for t in rows]})
@design_systems_bp.post("/design-systems/<int:design_system_id>/tokens")
@login_required
async def create_design_token(design_system_id: int):
data = await request.get_json() or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
token = await ds_svc.create_token(
user_id=_uid(),
design_system_id=design_system_id,
name=name,
value_by_mode=data.get("value_by_mode"),
group_name=data.get("group_name") or None,
purpose=data.get("purpose") or None,
order_index=data.get("order_index") or 0,
)
if token is None:
return _not_found()
return jsonify(token.to_dict()), 201
@design_systems_bp.patch("/design-tokens/<int:token_id>")
@login_required
async def update_design_token(token_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items()
if k in ("name", "value_by_mode", "group_name", "purpose", "order_index")
}
token = await ds_svc.update_token(_uid(), token_id, **fields)
if token is None:
return _not_found("design token")
return jsonify(token.to_dict())
@design_systems_bp.delete("/design-tokens/<int:token_id>")
@login_required
async def delete_design_token(token_id: int):
if not await ds_svc.delete_token(_uid(), token_id):
return _not_found("design token")
return "", 204
# ── The project pointer ─────────────────────────────────────────────────
@design_systems_bp.put("/projects/<int:project_id>/design-system")
@login_required
async def set_project_design_system(project_id: int):
"""Point a project at a design system. `{"design_system_id": null}` clears it.
PUT rather than PATCH: this sets one field to exactly what is sent, and
clearing it is a first-class outcome rather than an omission.
"""
data = await request.get_json() or {}
ok = await ds_svc.set_project_design_system(
_uid(), project_id, data.get("design_system_id")
)
if not ok:
return _not_found("project or design system")
return jsonify({"project_id": project_id,
"design_system_id": data.get("design_system_id")})
+24
View File
@@ -119,6 +119,30 @@ class ResolvedToken:
entries = self.contributions.get(mode) or self.contributions.get(BASE_MODE)
return entries[0].value if entries else None
def to_dict(self) -> dict:
"""Payload shape for both surfaces — and it carries the SHADOWED entries.
Serialising only the winner would throw away the provenance at the last
step, which is the one thing this type exists to preserve. `contributions`
is the audit trail; `value_by_mode` / `origin_by_mode` are alongside it so
a client renders without re-deriving anything, and cannot derive it
differently.
"""
return {
"name": self.name,
"group_name": self.group_name,
"purpose": self.purpose,
"order_index": self.order_index,
"value_by_mode": self.value_by_mode,
"origin_by_mode": self.origin_by_mode,
"contributions": {
mode: [
{"system_id": c.system_id, "value": c.value} for c in entries
]
for mode, entries in self.contributions.items()
},
}
def is_overridden_in(self, system_id: int) -> bool:
"""Does `system_id` win any mode of this token AND shadow something?