agentic-community / agentic-community/mcp-gateway-registry
feat: version pinning and rollback for agent skills
- Dominant language
- Python
- Stars
- 911
- Forks
- 234
- Avg merge
- 1d 11h
- Merged PRs (30d)
- 62
Description
## Problem
The registry's agent-skill model today has one "version" knob that
actually controls behavior (the `skill_md_url` itself) and three that
look like they should:
- `skill_md_url` — the source URL. If it contains a commit SHA, the
skill is effectively pinned. If it points at `main`, it isn't.
- `content_version` — a SHA-256 of the fetched content. Used for
**drift detection**, not version selection.
- `metadata.version` — an informational semver string parsed from
frontmatter. Nothing consumes it.
- `status` (`active` / `deprecated` / `draft` / `beta`) — lifecycle
state, not a version selector.
There is no version history. Updating a skill overwrites the record;
the previous URL is gone. When drift detection occurs, the skill auto-disables
and the operator has to manually re-register.
For skills that execute code inside a coding assistant, this is too
loose. A compromised upstream branch is a supply-chain incident; we
need pinning as a first-class concept so the registry can tell
operators exactly *which* revision each tenant is running, and flip
back without re-registration.
**Recommendation: add a small, explicit versioning model — `source_ref`
separate from `source_url`, an append-only version history, and a
distinction between *published* (what clients see) and *available*
(what's in history).**
## What the registry has today
From `registry/schemas/skill_models.py`:
- `SkillCard.skill_md_url` (line 160) / `skill_md_raw_url` (line 163) —
the immutable source. "Immutable after creation" is the docstring
promise; in practice `PUT /api/skills/{path}` overwrites it.
- `SkillCard.metadata.version` (line 57) — informational only.
- `SkillCard.content_version` (line 251) — a SHA-256 digest used for
cache validation.
- `SkillCard.content_integrity` (line 257) — `composite_hash` +
per-file hashes. Drives drift detection
(`content_integrity.drift_detected`).
- `SkillCard.status` (line 266) — `active` / `deprecated` / `draft` /
`beta`. Governs visibility, not which version is served.
- `SkillCard.updated_at` — single timestamp; previous values not
retained.
Update path: `PUT /api/skills/{skill_path}` at
`registry/api/skill_routes.py:889` replaces the record via
`SkillRegistrationRequest`. No CLI; Dashboard UI posts the same.
Fetch path: pull-on-request via `/api/skills/{id}/content`
(`registry/api/skill_routes.py:456-579`). No background poller.
Net: **one mutable URL slot, no history, no published-vs-available
split.**
## Design
### D1. Data model
Two changes on `SkillCard` and one new embedded collection of
versions. No new top-level collection — keep the record intact so the
existing lookup/listing paths don't change.
```python
class SkillSourceRef(BaseModel):
"""What the skill content was pulled from, at the moment it was pinned."""
source_url: HttpUrl # was skill_md_url
source_raw_url: HttpUrl | None = None # was skill_md_raw_url
source_ref: str | None = None # git ref: SHA (40 hex), tag, or branch
source_ref_kind: Literal["sha", "tag", "branch", "inline", "unknown"] = "unknown"
class SkillVersion(BaseModel):
"""One historical version of a skill."""
version_id: UUID = Field(default_factory=uuid4)
source: SkillSourceRef
content_integrity: ContentIntegrity # reuse existing model
skill_md_content: str | None = None # inline path snapshot
metadata_version: str | None = None # metadata.version at capture time
published_by: str | None = None # user who created this version
published_at: datetime = Field(default_factory=_utc_now)
notes: str | None = None # optional human note
```
Added to `SkillCard`:
```python
versions: list[SkillVersion] = Field(default_factory=list) # append-only
published_version_id: UUID | None = None # pointer
```
Migration: for every existing `SkillCard`, synthesize a single
`SkillVersion` from the current `skill_md_url` / `content_integrity`
/ `metadata.version` and set `published_version_id` to it. Existing
readers don't break because the current fields (`skill_md_url`,
`content_version`, `content_integrity`) keep working — they are
**derived** from the published version going forward.
### D2. `source_ref_kind` validation and mutable-ref policy
On registration / new-version create:
- Parse `source_url`. If it's a known Git host (github.com,
github.mycompany.com, gitlab.*), pull out the ref segment
(`/blob//...`) and classify:
- 40 hex chars → `sha`
- matches a tag pattern → `tag` (best effort; store as-is)
- otherwise → `branch`
- If non-git source (raw HTTPS URL, S3) → `unknown`.
- If inline (`skill_md_content`) → `inline`.
Add a **registry-level policy flag** (settings, not per-skill):
`skill_require_immutable_ref` (default `false` today, `true` for
prod). When true, reject `source_ref_kind in {"branch", "unknown"}`
at registration — forcing operators to pin to SHAs or tags.
### D3. Endpoints
All under existing `registry/api/skill_routes.py`:
| Verb | Path | Purpose |
|---|---|---|
| `POST` | `/api/skills/{skill_path}/versions` | Append a new version (fetches content, computes integrity). Does **not** publish by default. |
| `POST` | `/api/skills/{skill_path}/publish` | `{ "version_id": "..." }` — flip the `published_version_id` pointer. |
| `GET` | `/api/skills/{skill_path}/versions` | List versions (paginated, newest first). |
| `GET` | `/api/skills/{skill_path}/versions/{version_id}` | Fetch one version's metadata (and content). |
| `DELETE` | `/api/skills/{skill_path}/versions/{version_id}` | Admin-only; refuses if published. |
Keep `PUT /api/skills/{skill_path}` working for the legacy "replace
everything" flow: internally it becomes "create a new version +
publish it" so old clients stay functional.
Keep `GET /api/skills/{id}/content` unchanged — it always serves the
*published* version's content. Add `?version_id=` to fetch a
non-published version for diff/preview.
### D4. Rollback = publish an older version
Rollback is a one-call operation:
```
POST /api/skills/{path}/publish
{ "version_id": "" }
```
No refetch, no re-registration. The content is already in the version
record (either via cached `skill_md_content` snapshot, or by
re-fetching from the pinned `source_ref` if SHA-pinned). Guarantee:
any version that was ever published once must remain serveable
without a live upstream call. In practice that means **snapshotting
content into the `SkillVersion` at publish time** for non-inline
sources too.
### D5. Drift detection, re-scoped
Today drift auto-disables the skill. Change this to:
- Drift check runs against `published_version`'s
`content_integrity.composite_hash`.
- On drift, set `content_integrity.drift_detected = true` on the
published version record (not the skill-level state), raise an
audit event, and — based on a policy flag
`skill_on_drift` in `{ "flag", "disable", "repin" }` — either:
- `flag` (default): record drift, keep serving the snapshot
already held in the version record.
- `disable`: today's behavior, preserved as an opt-in.
- `repin`: automatically publish a synthesized "drift-pinned"
version that captures whatever the current upstream looks like
and annotates it.
This makes drift informational (for branch-tracking skills) or a
break-glass signal (for SHA-pinned skills) rather than a guaranteed
outage.
### D6. UI affordances (Dashboard)
On the skill detail page:
- "Versions" tab: table of versions (ref, published-at, published-by,
notes, diff link vs. current published).
- "Publish this version" button next to each row (admin/owner only).
- "Add new version" button: opens the existing registration form
pre-filled with the current source URL; writes a new version
without publishing unless `publish=true` is checked.
- On the main tab, show a badge next to `source_url` indicating
`source_ref_kind` — a red one for `branch` / `unknown` when the
policy is strict.
### D7. Audit
Every version append and publish flip writes an audit row with:
- action (`skill.version.append`, `skill.publish`)
- skill path
- version_id
- previous published_version_id (on publish)
- source_ref / source_ref_kind
- actor
This is the incident story: the audit log can reconstruct "which
version was published at time T," which today it cannot.
Contributor guide
Assessment
This issue has not been assessed yet.