agentic-community / agentic-community/mcp-gateway-registry

feat: version pinning and rollback for agent skills

Open
#941 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.