Skip to content

Feature: expose shared-source (and app-repo-local) skills as slash commands #35

Description

@hpieris-dm

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:

  1. Resolve the workspace root via ctx.list_roots().
  2. Read .dmx/shared-sources.yaml, then walk .dmx/skills/ (app repo) and
    each declared .dmx/vendor/{name}/skills/ (shared sources, in order).
  3. 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).
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions