Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
contents: write # write CHANGELOG, create GitHub releases
issues: write # comment on released issues
pull-requests: write # comment on released PRs
id-token: write # npm Trusted Publishing via OIDC
id-token: write # npm Trusted Publishing and MCP Registry login via OIDC
steps:
- name: Checkout
uses: actions/checkout@v7
Expand Down Expand Up @@ -43,6 +43,30 @@ jobs:
- name: Build
run: pnpm build

# The MCP Registry's CLI, verified against its release signature. The
# release step runs it (as semantic-release's successCmd) once npm has
# the new version and the .mcpb is on the GitHub release.
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

- name: Install mcp-publisher
env:
MCP_PUBLISHER_VERSION: v1.8.8
working-directory: ${{ runner.temp }}
run: |
set -euo pipefail
archive="mcp-publisher_linux_amd64.tar.gz"
base_url="https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}"
curl --fail --location --silent --show-error --output "$archive" "$base_url/$archive"
curl --fail --location --silent --show-error --output "$archive.sigstore.json" "$base_url/$archive.sigstore.json"
cosign verify-blob \
--bundle "$archive.sigstore.json" \
--certificate-identity "https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/${MCP_PUBLISHER_VERSION}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"$archive"
tar xzf "$archive" mcp-publisher
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"

- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
5 changes: 3 additions & 2 deletions .releaserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
[
"@semantic-release/exec",
{
"prepareCmd": "pnpm build:mcpb",
"publishCmd": "npm publish --provenance --access public"
"prepareCmd": "pnpm build:mcpb && pnpm registry:prepare",
"publishCmd": "npm publish --provenance --access public",
"successCmd": "pnpm registry:publish"
}
],
[
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Put your videos in the extension's **Media folder** (by default `Documents/MakeM
}
```

The Claude Code skill auto-discovers triggers and shells out via `npx -y` on demand. The MCP server exposes the timeline tools (show / edit / check / transcribe / undo / redo / export) and a `doctor` setup check over stdio; media stays confined to `MAKEMYCLIP_WORKSPACE`, and `MAKEMYCLIP_WHISPER_MODEL` is only needed for transcription and captions. Everything runs locally and offline — no API key.
It is listed in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.MakeMyClip/editor) as `io.github.MakeMyClip/editor`, so MCP clients that browse the registry can install it from there. The Claude Code skill auto-discovers triggers and shells out via `npx -y` on demand. The MCP server exposes the timeline tools (show / edit / check / transcribe / undo / redo / export) and a `doctor` setup check over stdio; media stays confined to `MAKEMYCLIP_WORKSPACE`, and `MAKEMYCLIP_WHISPER_MODEL` is only needed for transcription and captions. Everything runs locally and offline — no API key.

**Optional: local transcription** (for editing by transcript, removing retakes, and captions):

Expand Down
9 changes: 8 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,18 @@ When the workflow publishes, it:
1. Reads commits since the last tag, decides the next version
2. Updates `CHANGELOG.md` for the release notes
3. Publishes to npm at `@makemyclip/editor` with provenance attestation
4. Creates a GitHub Release with auto-generated notes
4. Creates a GitHub Release with auto-generated notes, with the Claude Desktop extension `makemyclip-editor.mcpb` attached (built by `pnpm build:mcpb`)
5. Tags the commit as `v<version>`
6. Lists the version in the official MCP Registry as `io.github.MakeMyClip/editor` (`pnpm registry:publish`, after npm has it and the `.mcpb` is on the release)

The package shows a verified publisher badge on npmjs.com because of provenance.

### MCP Registry

`server.json` holds the listing. At release, `pnpm registry:prepare` stamps it with the version and adds the `.mcpb` (its release URL and SHA-256); that change stays in the release job's checkout. The registry checks that the npm version's `package.json` has `"mcpName": "io.github.MakeMyClip/editor"`, case included, and that the `.mcpb` URL is reachable. The release job logs in with GitHub OIDC (`mcp-publisher login github-oidc`), which grants the `io.github.MakeMyClip/*` namespace; `mcp-publisher` is downloaded and checked against its sigstore signature.

If the registry step fails, npm and the GitHub release are already out, and re-running the job does nothing (semantic-release sees no new commits). List the version by hand from a checkout of the tag (its `package.json` still says `0.0.0-development`, so pass the version): `gh release download v<version> --pattern makemyclip-editor.mcpb --dir release` (the registry entry must match the released file's SHA-256), `pnpm registry:prepare <version>`, then `mcp-publisher login github --token <PAT>` (a token with `read:org`, from an owner of the MakeMyClip organization; the interactive login can't publish to an organization namespace) and `mcp-publisher publish`.

## First-time setup

These steps run **once**, before the first automatic release.
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "@makemyclip/editor",
"version": "0.0.0-development",
"mcpName": "io.github.MakeMyClip/editor",
"description": "Open-source AI video editor for Claude Code and MCP clients: remove silence, edit by transcript with local whisper.cpp, add captions, and export to Final Cut Pro, DaVinci Resolve or Premiere. Runs locally on FFmpeg.",
"keywords": [
"video-editing",
Expand Down Expand Up @@ -59,6 +60,8 @@
"build": "tsup && pnpm build:ui",
"build:ui": "vite build --config src/ui/web/vite.config.ts",
"build:mcpb": "tsx scripts/build-mcpb.ts",
"registry:prepare": "tsx scripts/mcp-registry.ts prepare",
"registry:publish": "tsx scripts/mcp-registry.ts publish",
"dev": "tsx src/cli.ts",
"dev:ui": "vite --config src/ui/web/vite.config.ts",
"start": "node dist/cli.js",
Expand Down
92 changes: 92 additions & 0 deletions scripts/mcp-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* List a release in the official MCP Registry (registry.modelcontextprotocol.io).
*
* prepare [version]
* Stamp server.json for `version`, by default the one in
* package.json (which semantic-release has just written), and
* add the Claude Desktop extension as an `mcpb` package: its
* release URL and the SHA-256 of release/makemyclip-editor.mcpb.
* publish `mcp-publisher login github-oidc`, then `mcp-publisher publish`.
* Retried, since the registry reads the new npm version, which
* can take a minute to appear. Needs `mcp-publisher` on PATH and
* a GitHub Actions OIDC token (`id-token: write`).
*
* server.json is changed in the release job's checkout only; nothing is
* committed back.
*/
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { execa } from 'execa';

const root = resolve(import.meta.dirname, '..');
const serverJsonPath = join(root, 'server.json');
const bundlePath = join(root, 'release', 'makemyclip-editor.mcpb');
const REPO = 'https://github.com/MakeMyClip/editor';

interface RegistryPackage {
registryType: string;
identifier: string;
version?: string;
fileSha256?: string;
transport: { type: string };
[key: string]: unknown;
}

interface ServerJson {
version: string;
packages: RegistryPackage[];
[key: string]: unknown;
}

async function prepare(versionArg?: string): Promise<void> {
const version =
versionArg ??
(JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { version: string }).version;
const server = JSON.parse(await readFile(serverJsonPath, 'utf8')) as ServerJson;
const sha256 = createHash('sha256')
.update(await readFile(bundlePath))
.digest('hex');

server.version = version;
const packages = server.packages.filter((p) => p.registryType !== 'mcpb');
for (const p of packages) if (p.registryType === 'npm') p.version = version;
packages.push({
registryType: 'mcpb',
identifier: `${REPO}/releases/download/v${version}/makemyclip-editor.mcpb`,
version,
fileSha256: sha256,
transport: { type: 'stdio' },
});
server.packages = packages;
await writeFile(serverJsonPath, `${JSON.stringify(server, null, 2)}\n`);
process.stdout.write(`server.json: ${version}, makemyclip-editor.mcpb sha256 ${sha256}\n`);
}

async function publish(): Promise<void> {
await execa('mcp-publisher', ['login', 'github-oidc'], { cwd: root, stdio: 'inherit' });
const attempts = 6;
for (let attempt = 1; ; attempt++) {
try {
await execa('mcp-publisher', ['publish'], { cwd: root, stdio: 'inherit' });
return;
} catch (err) {
if (attempt === attempts) throw err;
process.stderr.write(`mcp-publisher publish failed (attempt ${attempt}); retrying in 20 s\n`);
await sleep(20_000);
}
}
}

const [command, version] = process.argv.slice(2);
const run =
command === 'prepare' ? () => prepare(version) : command === 'publish' ? publish : undefined;
if (!run) {
process.stderr.write('Usage: tsx scripts/mcp-registry.ts prepare [version] | publish\n');
process.exit(1);
}
run().catch((err: unknown) => {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});
61 changes: 61 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.MakeMyClip/editor",
"title": "MakeMyClip Editor",
"description": "Local video editor: cut silence, edit by transcript, add captions, export to Final Cut or Premiere.",
"websiteUrl": "https://github.com/MakeMyClip/editor#readme",
"repository": {
"url": "https://github.com/MakeMyClip/editor",
"source": "github",
"id": "1250722349"
},
"icons": [
{
"src": "https://raw.githubusercontent.com/MakeMyClip/editor/main/logo.png",
"mimeType": "image/png",
"sizes": ["1024x1024"]
}
],
"version": "0.0.0-development",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "@makemyclip/editor",
"version": "0.0.0-development",
"runtimeHint": "npx",
"packageArguments": [{ "type": "positional", "value": "mcp" }],
"transport": { "type": "stdio" },
"environmentVariables": [
{
"name": "MAKEMYCLIP_WORKSPACE",
"description": "Folder the editor may read and write; put your videos here. Defaults to a folder in the temp directory.",
"format": "filepath",
"isRequired": false,
"isSecret": false
},
{
"name": "MAKEMYCLIP_WHISPER_MODEL",
"description": "Optional: a whisper.cpp model file (e.g. ggml-base.en.bin) for transcripts and captions.",
"format": "filepath",
"isRequired": false,
"isSecret": false
},
{
"name": "MAKEMYCLIP_FFMPEG_PATH",
"description": "Optional: an FFmpeg to use instead of the one ffmpeg-static downloads.",
"format": "filepath",
"isRequired": false,
"isSecret": false
},
{
"name": "MAKEMYCLIP_WHISPER_PATH",
"description": "Optional: the whisper-cli binary, when it isn't on PATH.",
"format": "filepath",
"isRequired": false,
"isSecret": false
}
]
}
]
}
43 changes: 43 additions & 0 deletions tests/server-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { FFMPEG_PATH_ENV } from '../src/ffmpeg/run.js';
import { WHISPER_MODEL_ENV, WHISPER_PATH_ENV } from '../src/transcribe/whisper.js';

const read = (path: string): Record<string, unknown> =>
JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')) as Record<string, unknown>;

const pkg = read('../package.json') as { name: string; mcpName: string; homepage: string };
const server = read('../server.json') as {
name: string;
description: string;
repository: { url: string };
packages: {
registryType: string;
identifier: string;
packageArguments: { value: string }[];
environmentVariables: { name: string }[];
}[];
};

describe('MCP Registry server.json', () => {
it('matches the mcpName the registry checks on npm, case included', () => {
expect(server.name).toBe('io.github.MakeMyClip/editor');
expect(pkg.mcpName).toBe(server.name);
});

it('fits the registry limits and points at this repo', () => {
expect(server.description.length).toBeLessThanOrEqual(100);
expect(server.repository.url).toBe(pkg.homepage);
});

it('runs `npx @makemyclip/editor mcp` with the env the server reads', () => {
const [npm, ...rest] = server.packages;
expect(rest).toEqual([]);
expect(npm?.registryType).toBe('npm');
expect(npm?.identifier).toBe(pkg.name);
expect(npm?.packageArguments.map((a) => a.value)).toEqual(['mcp']);
expect(npm?.environmentVariables.map((e) => e.name).sort()).toEqual(
['MAKEMYCLIP_WORKSPACE', FFMPEG_PATH_ENV, WHISPER_MODEL_ENV, WHISPER_PATH_ENV].sort(),
);
});
});
Loading