feat(mcpd): ResourceRevision + ResourceProposal services + Prompt revision integration
Phase 2 of the Skills + Revisions + Proposals work. Stands up the generic
revision/proposal layer and wires Prompt into it. Skills will plug into the
same infrastructure in PR-3 with no further service changes required.
This PR is intentionally additive: PromptRequest table and routes are
unchanged. The /api/v1/proposals API runs side-by-side with the legacy
/api/v1/promptrequests API. The PromptRequest cutover (rename + backfill +
mcplocal rewire) is deferred to a later PR so this one stays reviewable.
## What's added
### Repositories (src/mcpd/src/repositories/)
- resource-revision.repository.ts — append-only revision log keyed by
(resourceType, resourceId). Soft FK; no relations declared. Supports
history listing, semver lookup, and contentHash cross-resource search.
- resource-proposal.repository.ts — generic propose queue. Status lifecycle
pending → approved | rejected. Mirrors Prompt's `?? ''` workaround for
nullable-FK compound lookups.
### Services (src/mcpd/src/services/)
- resource-revision.service.ts — record() inserts a revision with a stable
sha256 contentHash computed from canonicalised JSON (key-sorted at every
level so reordered objects produce the same hash). Caller passes a
pre-computed semver; service does NOT decide bump policy.
- resource-proposal.service.ts — propose / approve / reject / list, with a
per-resourceType handler registry. PromptService registers the 'prompt'
handler at construction; the SkillService will register 'skill' in PR-3.
approve() runs in a Prisma $transaction so the resource update + revision
insert + proposal status flip are atomic.
### Pure utility (src/mcpd/src/utils/semver.ts)
- bumpSemver(current, kind) for major / minor / patch
- compareSemver(a, b) — numeric, not lex (10 > 9)
- isValidSemver(s)
- Invalid input falls back to '0.1.0' rather than throwing — keeps the
audit-write path from blowing up the prompt update if a row's semver
ever drifts out of MAJOR.MINOR.PATCH shape.
### Routes (src/mcpd/src/routes/)
- revisions.ts — GET /api/v1/revisions?resourceType=&resourceId=,
GET /api/v1/revisions/:id, GET /api/v1/revisions/:id/diff?against=<id|live>
(unified-format diff via the `diff` package), and POST
/api/v1/prompts/:id/restore-revision { revisionId, note? }.
- proposals.ts — GET / POST /api/v1/proposals,
GET /api/v1/proposals/:id, PUT for body updates, POST .../approve and
POST .../reject, plus DELETE.
## What's changed
- PromptService.create / update now record a ResourceRevision when the
revision service is wired. Update auto-bumps patch on content change;
authors can override via `--bump major|minor|patch` or `--semver X.Y.Z`
on the CLI (forwarded into the PUT body). Best-effort: revision write
failures are swallowed so the prompt save still succeeds (revision is
audit, not source of truth).
- PromptService.setProposalService registers a 'prompt' approval handler
with the proposal service. Approval runs in a Prisma transaction:
upsert prompt → record revision → update currentRevisionId → flip
proposal status. semver bumps to 0.1.0 on first approval, patch
thereafter.
- New CLI flags on `mcpctl edit prompt`: --bump, --semver, --note. They're
prompt-only (validated client-side); other resources reject them.
- Aliases in shared.ts: `proposal`/`prop` → proposals,
`revision`/`rev` → revisions.
- diff dependency added to mcpd.
## Tests
- src/mcpd/tests/utils/semver.test.ts — covers bump/compare/validate
including numeric (not lex) semver compare and invalid-input fallback.
- prompt-service.test.ts updated: makePrompt fixture now sets semver +
agentId + currentRevisionId; updatePrompt assertion expects the
auto-bumped patch in the same update call.
- prompt-routes.test.ts updated symmetrically.
## RBAC
`proposals` and `revisions` URL segments map to the existing `prompts`
permission for now. PR-7 may split if a "reviewer" role becomes useful.
## Verification
Full suite: 158 test files / 2127 tests green.
`pnpm build` clean across all 6 workspace packages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,10 @@ export function createEditCommand(deps: EditCommandDeps): Command {
|
||||
.description('Edit a resource in your default editor (server, project)')
|
||||
.argument('<resource>', 'Resource type (server, project)')
|
||||
.argument('<name-or-id>', 'Resource name or ID')
|
||||
.action(async (resourceArg: string, nameOrId: string) => {
|
||||
.option('--bump <kind>', 'Bump prompt semver after edit: major | minor | patch')
|
||||
.option('--semver <version>', 'Set prompt semver explicitly (X.Y.Z)')
|
||||
.option('--note <message>', 'Note attached to the resulting revision')
|
||||
.action(async (resourceArg: string, nameOrId: string, opts: { bump?: string; semver?: string; note?: string }) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
|
||||
// Instances are immutable
|
||||
@@ -55,6 +58,23 @@ export function createEditCommand(deps: EditCommandDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation for prompt-only revision flags
|
||||
if ((opts.bump !== undefined || opts.semver !== undefined || opts.note !== undefined) && resource !== 'prompts') {
|
||||
log('Error: --bump, --semver, and --note are only valid for prompts');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.bump !== undefined && opts.semver !== undefined) {
|
||||
log('Error: pass --bump or --semver, not both');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.bump !== undefined && !['major', 'minor', 'patch'].includes(opts.bump)) {
|
||||
log("Error: --bump must be 'major', 'minor', or 'patch'");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve name → ID
|
||||
const id = await resolveNameOrId(client, resource, nameOrId);
|
||||
|
||||
@@ -102,6 +122,12 @@ export function createEditCommand(deps: EditCommandDeps): Command {
|
||||
|
||||
// Parse and apply
|
||||
const updates = yaml.load(modifiedClean) as Record<string, unknown>;
|
||||
// Append semver-related flags for prompts (server-side bumps + records revision).
|
||||
if (resource === 'prompts') {
|
||||
if (opts.bump !== undefined) updates.bump = opts.bump;
|
||||
if (opts.semver !== undefined) updates.semver = opts.semver;
|
||||
if (opts.note !== undefined) updates.note = opts.note;
|
||||
}
|
||||
await client.put(`/api/v1/${resource}/${id}`, updates);
|
||||
log(`${singular} '${nameOrId}' updated.`);
|
||||
} finally {
|
||||
|
||||
@@ -21,6 +21,14 @@ export const RESOURCE_ALIASES: Record<string, string> = {
|
||||
promptrequest: 'promptrequests',
|
||||
promptrequests: 'promptrequests',
|
||||
pr: 'promptrequests',
|
||||
// PR-2: shared revision + proposal queue (replaces promptrequests in
|
||||
// PR-7). Lookup goes through /api/v1/proposals and /api/v1/revisions.
|
||||
proposal: 'proposals',
|
||||
proposals: 'proposals',
|
||||
prop: 'proposals',
|
||||
revision: 'revisions',
|
||||
revisions: 'revisions',
|
||||
rev: 'revisions',
|
||||
serverattachment: 'serverattachments',
|
||||
serverattachments: 'serverattachments',
|
||||
sa: 'serverattachments',
|
||||
|
||||
Reference in New Issue
Block a user