Problem
Shared-source skills (.dmx/vendor/{name}/skills/) and app-repo-local skills
(.dmx/skills/) are only reachable through the loop runtime's
get_skill_definition tool — a loop step has to ask for them by name. They
never show up as /dmx/* slash commands in the IDE, unlike bundled skills.
This is a real gap, not just a cosmetic one: it means an org can publish a
shared code-review skill via GH-27's shared sources, but nobody can just
type / and find it — they only get it if a loop schedules it for them.
That undercuts the pitch of shared sources as "the same skills, everywhere."
Why this happens
create_app() in server.py registers prompts (slash commands) exactly
once, synchronously, at server startup, from exactly one fixed directory:
def create_app(skills_dir: Path | None = None, rules_dir: Path | None = None) -> FastMCP:
resolved_skills_dir, resolved_rules_dir = resolve_dirs(skills_dir, rules_dir)
app = FastMCP("dmx", instructions=SERVER_INSTRUCTIONS)
skills = load_skills(resolved_skills_dir)
for skill in skills:
_register_skill(app, skill)
...
resolved_skills_dir is the bundled skills/ directory (or an explicit
override). There's no workspace concept here at all — no per-request
resolution, unlike every other workspace-aware dmx tool, which uses
resolve_workspace_root() (an async, per-call lookup via the client's MCP
roots). By the time create_app() runs, there's no request, no ctx,
nothing to resolve a workspace root against.
Proposal
FastMCP (>=3.3, currently pinned via fastmcp>=2.0) ships a Provider
abstraction built for exactly this: a subclass overrides _list_prompts() /
_get_prompt(), and FastMCP calls those fresh on every prompts/list /
prompts/get request — no caching, no static registration.
Traced the request path in fastmcp/server/server.py:
async def list_prompts(self, *, run_middleware: bool = True) -> Sequence[Prompt]:
async with fastmcp.server.context.Context(fastmcp=self) as ctx:
...
prompts = list(await super().list_prompts()) # → Provider._list_prompts()
Every listing/get call runs inside a live Context, so a custom provider's
_list_prompts() can call get_context() / await ctx.list_roots() — the
same mechanism resolve_workspace_root() uses for tools — and get the real
workspace root, per request, live.
A SharedSkillsProvider(Provider) could, on every listing request:
- Resolve the workspace root via
ctx.list_roots().
- Read
.dmx/shared-sources.yaml, then walk .dmx/skills/ (app repo) and
each declared .dmx/vendor/{name}/skills/ (shared sources, in order).
- Apply the same three-tier precedence/collision resolution
_resolve_skill()
already implements in loop_tools.py, so a name has exactly one winner
(a prompt name can't be ambiguous the way a sync-time "warn on collision"
can).
- Register the result as a provider:
FastMCP("dmx", providers=[SharedSkillsProvider()]).
This is additive — bundled skills keep using the existing static
_register_skill path; this is a second, dynamic source of prompts layered
on top.
Why this avoids the problems a naive fix would hit
- No staleness. Recomputed on every listing call, so
/dmx/sync output
and manual edits to .dmx/skills/ show up immediately — no server restart,
no file-watcher needed for this path.
- No duplicate-registration bug. The existing bundled-skill hot-reload
path has a known FastMCP limitation (no deregister/replace API, so reloads
can accumulate duplicates). Providers don't touch that path at all.
- No cwd-guessing. Uses the same
ctx.list_roots() resolution as every
other workspace-aware tool, instead of assuming the server's cwd is the
project root.
Open design questions
- Folder-shaped skills (
{name}/SKILL.md) need their content resolved via
the same logic _resolve_skill() already has, not catalog._parse_skill's
flat-file assumption — the provider should probably delegate to
_resolve_skill() per name rather than reimplement catalog parsing.
- Collision handling changes from "warn" (current
/dmx/sync semantics) to
"must pick exactly one winner."
- Depends on the MCP client supporting
roots (Cursor does — other dmx
tools already rely on it) — worth an explicit smoke test.
- Whether app-repo-local skills (
.dmx/skills/) should go through this same
provider, or whether this is scoped to shared-source skills only for v1.
Scope
Not a small fix — touches server bootstrap (server.py), and needs a design
pass before implementation. Filing this to track the investigation and scope
the work; no code changes yet.
Problem
Shared-source skills (
.dmx/vendor/{name}/skills/) and app-repo-local skills(
.dmx/skills/) are only reachable through the loop runtime'sget_skill_definitiontool — a loop step has to ask for them by name. Theynever show up as
/dmx/*slash commands in the IDE, unlike bundled skills.This is a real gap, not just a cosmetic one: it means an org can publish a
shared
code-reviewskill via GH-27's shared sources, but nobody can justtype
/and find it — they only get it if a loop schedules it for them.That undercuts the pitch of shared sources as "the same skills, everywhere."
Why this happens
create_app()inserver.pyregisters prompts (slash commands) exactlyonce, synchronously, at server startup, from exactly one fixed directory:
resolved_skills_diris the bundledskills/directory (or an explicitoverride). There's no workspace concept here at all — no per-request
resolution, unlike every other workspace-aware dmx tool, which uses
resolve_workspace_root()(an async, per-call lookup via the client's MCProots). By the time
create_app()runs, there's no request, noctx,nothing to resolve a workspace root against.
Proposal
FastMCP (>=3.3, currently pinned via
fastmcp>=2.0) ships aProviderabstraction built for exactly this: a subclass overrides
_list_prompts()/_get_prompt(), and FastMCP calls those fresh on everyprompts/list/prompts/getrequest — no caching, no static registration.Traced the request path in
fastmcp/server/server.py:Every listing/get call runs inside a live
Context, so a custom provider's_list_prompts()can callget_context()/await ctx.list_roots()— thesame mechanism
resolve_workspace_root()uses for tools — and get the realworkspace root, per request, live.
A
SharedSkillsProvider(Provider)could, on every listing request:ctx.list_roots()..dmx/shared-sources.yaml, then walk.dmx/skills/(app repo) andeach declared
.dmx/vendor/{name}/skills/(shared sources, in order)._resolve_skill()already implements in
loop_tools.py, so a name has exactly one winner(a prompt name can't be ambiguous the way a sync-time "warn on collision"
can).
FastMCP("dmx", providers=[SharedSkillsProvider()]).This is additive — bundled skills keep using the existing static
_register_skillpath; this is a second, dynamic source of prompts layeredon top.
Why this avoids the problems a naive fix would hit
/dmx/syncoutputand manual edits to
.dmx/skills/show up immediately — no server restart,no file-watcher needed for this path.
path has a known FastMCP limitation (no deregister/replace API, so reloads
can accumulate duplicates). Providers don't touch that path at all.
ctx.list_roots()resolution as everyother workspace-aware tool, instead of assuming the server's cwd is the
project root.
Open design questions
{name}/SKILL.md) need their content resolved viathe same logic
_resolve_skill()already has, notcatalog._parse_skill'sflat-file assumption — the provider should probably delegate to
_resolve_skill()per name rather than reimplement catalog parsing./dmx/syncsemantics) to"must pick exactly one winner."
roots(Cursor does — other dmxtools already rely on it) — worth an explicit smoke test.
.dmx/skills/) should go through this sameprovider, or whether this is scoped to shared-source skills only for v1.
Scope
Not a small fix — touches server bootstrap (
server.py), and needs a designpass before implementation. Filing this to track the investigation and scope
the work; no code changes yet.