CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 28s
Claude Code injects only the first ~2,048 chars of an MCP server's instructions and silently cuts the rest mid-word (#2562, observed live): _INSTRUCTIONS was 20,002 chars, so ~90% — including all Systems tagging guidance — never reached any session. Rearchitect delivery around what each surface actually delivers: - _INSTRUCTIONS becomes a 1,997-char purpose-sorted map, with a header comment stating the budget and where detail belongs instead. - Tool docstrings keep the per-tool HOW (audit: nearly all displaced topics were already duplicated there); backfill the four gaps — enter_project session scoping + project bootstrap, create_rule entity-vs-rule test, create_design_system not-a-rulebook, create_system two-records test. - The plugin static context (the delivery floor) gains the tag-to-Systems reflex and a surfaces-layering statement; plugin 0.1.25 -> 0.1.26 so the executing cache refreshes (#2209). - create_task / create_note / create_snippet return a systems_hint when a record is created untagged in a project that has Systems — in-band at the exact write it applies to, fail-open like the dedup gate. - Guards: _INSTRUCTIONS length budget, floor-states-the-reflex, and a displaced-topics sweep asserting every cut topic still lives on a delivered surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
408 lines
16 KiB
Python
408 lines
16 KiB
Python
"""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
|
|
from scribe.services.design_starter_roles import (
|
|
ALL_GROUPS,
|
|
DEFAULT_TOKEN_PREFIX,
|
|
describe_groups,
|
|
)
|
|
|
|
|
|
async def create_design_system(
|
|
title: str,
|
|
description: str = "",
|
|
guidance: str = "",
|
|
parent_id: int = 0,
|
|
starter_role_groups: list[str] | None = None,
|
|
token_prefix: str = "",
|
|
) -> dict:
|
|
"""Create a design system, optionally inheriting from another.
|
|
|
|
This — not a rulebook — is where visual standards live: a token can be
|
|
inherited, resolved per mode, rendered to a stylesheet and checked against
|
|
code, and none of that survives being written as rule prose. Once a
|
|
project points at one (set_project_design_system), treat it as binding for
|
|
that project's UI work.
|
|
|
|
Args:
|
|
title: What this system is — a house style, or one app within it
|
|
(required).
|
|
description: What it covers and when it applies.
|
|
guidance: The narrative a token table cannot hold — aesthetic, voice and
|
|
tone, what is deliberately out of scope. Markdown, free-form.
|
|
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.
|
|
starter_role_groups: Seed the system with named but VALUELESS token
|
|
roles, so there is something to reach for before a literal gets
|
|
written instead. Call list_starter_role_groups() for the catalogue.
|
|
Pass ["all"] for every group. Omit for none — a system with three
|
|
hand-written tokens is a legitimate design system.
|
|
token_prefix: Naming convention for the seeded roles, e.g. "--fs-".
|
|
Defaults to a neutral "--ds-"; pass the install's own if it has one.
|
|
Ignored when no starter groups are requested.
|
|
"""
|
|
uid = current_user_id()
|
|
groups = starter_role_groups
|
|
if groups and len(groups) == 1 and groups[0] == "all":
|
|
groups = list(ALL_GROUPS)
|
|
system = await ds_svc.create_design_system(
|
|
uid,
|
|
title=title,
|
|
description=description or None,
|
|
guidance=guidance or None,
|
|
parent_id=parent_id or None,
|
|
starter_role_groups=groups,
|
|
token_prefix=token_prefix or DEFAULT_TOKEN_PREFIX,
|
|
)
|
|
if system is None:
|
|
raise ValueError(f"parent design system {parent_id} not found or not writable")
|
|
return system.to_dict()
|
|
|
|
|
|
async def list_starter_role_groups() -> dict:
|
|
"""The starter token ROLES offered at design-system creation.
|
|
|
|
Roles, not values. Every group is a set of named questions — "page
|
|
background, the deepest surface" — that the operator answers with their own
|
|
palette. Nothing here carries a colour, because a default palette would be
|
|
one install's taste shipped as product.
|
|
|
|
Reach for this before create_design_system so the choice is informed, and
|
|
pass the group names you want as `starter_role_groups`.
|
|
"""
|
|
return {"groups": describe_groups(), "default_prefix": DEFAULT_TOKEN_PREFIX}
|
|
|
|
|
|
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 = "",
|
|
guidance: 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.
|
|
guidance: New guidance prose, 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 guidance:
|
|
fields["guidance"] = guidance
|
|
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."}
|
|
|
|
|
|
async def get_design_system_stylesheet(
|
|
design_system_id: int,
|
|
root_selector: str = ":root",
|
|
) -> dict:
|
|
"""The master CSS sheet a design system generates.
|
|
|
|
Purpose tokens only — this sheet declares what values MEAN and styles no
|
|
elements. Components (buttons, tables, input schemes) are SNIPPETS that
|
|
reference these names, so a value is stated once and reused rather than
|
|
restated per element. Reach for this when you need the tokens a snippet is
|
|
allowed to use.
|
|
|
|
Also returns `valueless` (tokens the system names but has no value for) and
|
|
`duplicates` (values declared under more than one name — a deliberate alias,
|
|
or one idea recorded twice).
|
|
|
|
Args:
|
|
design_system_id: The system to render.
|
|
root_selector: Selector for the base layer. Defaults to `:root`; pass a
|
|
container selector to scope the sheet to a preview region.
|
|
"""
|
|
uid = current_user_id()
|
|
result = await ds_svc.stylesheet_for_system(uid, design_system_id, root_selector)
|
|
if result is None:
|
|
raise ValueError(f"design system {design_system_id} not found")
|
|
return result
|
|
|
|
|
|
async def check_snippets_against_design_system(
|
|
design_system_id: int,
|
|
project_id: int = 0,
|
|
) -> dict:
|
|
"""Which recorded snippets disagree with a design system's sheet.
|
|
|
|
Snippets are the component layer — buttons, tables, input schemes — and they
|
|
are supposed to use the tags the sheet declares. Three findings per snippet,
|
|
each currently silent in the codebase:
|
|
|
|
unknown `var(--x)` where the system has no `--x`. Renders as
|
|
NOTHING: no error, no failing test, just an element
|
|
that quietly isn't styled.
|
|
superseded_literals a literal the sheet says to stop writing, paired with
|
|
the token to write instead.
|
|
local_definitions custom properties the snippet mints for itself rather
|
|
than using shared ones — the bloat a shared sheet
|
|
exists to prevent.
|
|
|
|
Snippets with nothing to report are omitted. Reach for this before writing
|
|
or reviewing component CSS.
|
|
|
|
Args:
|
|
design_system_id: The system whose sheet is authoritative.
|
|
project_id: Narrow to one project, or 0 for every project.
|
|
"""
|
|
uid = current_user_id()
|
|
result = await ds_svc.check_snippets_against_system(
|
|
uid, design_system_id, project_id
|
|
)
|
|
if result is None:
|
|
raise ValueError(f"design system {design_system_id} not found")
|
|
return result
|
|
|
|
|
|
# ── Tokens ──────────────────────────────────────────────────────────────
|
|
|
|
async def create_design_token(
|
|
design_system_id: int,
|
|
name: str,
|
|
value_by_mode: dict | None = None,
|
|
group_name: str = "",
|
|
purpose: str = "",
|
|
rationale: str = "",
|
|
supersedes: list | None = None,
|
|
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. "--surface-page" (required).
|
|
Name it for its PURPOSE, not its value: a name like "--obsidian"
|
|
or "--button-bg" stops being true the moment the value or the
|
|
element changes.
|
|
value_by_mode: Values keyed by mode, e.g.
|
|
{"base": "#14171a", "light": "#f7f5ef"}. 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 background, deepest
|
|
surface".
|
|
rationale: WHY it is this value — a different question from purpose.
|
|
"Deliberately the same value as the primary action colour" is a
|
|
rationale; "page background, deepest surface" is a purpose.
|
|
supersedes: Literal values this token should be used INSTEAD OF, e.g.
|
|
["#fff", "#ffffff"]. This is how a design system records what a
|
|
prohibition was trying to say — not "white is banned" but "write
|
|
this token instead". Declare it rather than expecting it to be
|
|
inferred: a superseded literal and the token's own value are
|
|
usually different values, so nothing can connect them by matching.
|
|
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,
|
|
rationale=rationale or None,
|
|
supersedes=supersedes,
|
|
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 = "",
|
|
rationale: str = "",
|
|
supersedes: list | None = None,
|
|
order_index: int = -1,
|
|
) -> dict:
|
|
"""Update a token. Empty/None args leave a field unchanged.
|
|
|
|
`value_by_mode` and `supersedes` REPLACE their whole value rather than
|
|
merging into it, so send every entry you want the token to keep. Pass `[]`
|
|
to clear `supersedes` entirely.
|
|
"""
|
|
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 rationale:
|
|
fields["rationale"] = rationale
|
|
# `is not None`, not truthiness: `[]` is a meaningful edit (drop every
|
|
# superseded literal) and would otherwise be unreachable.
|
|
if supersedes is not None:
|
|
fields["supersedes"] = supersedes
|
|
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_starter_role_groups,
|
|
list_design_systems,
|
|
get_design_system,
|
|
resolve_design_system,
|
|
update_design_system,
|
|
delete_design_system,
|
|
get_design_system_stylesheet,
|
|
check_snippets_against_design_system,
|
|
create_design_token,
|
|
list_design_tokens,
|
|
update_design_token,
|
|
delete_design_token,
|
|
set_project_design_system,
|
|
):
|
|
mcp.tool(name=fn.__name__)(fn)
|