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
2 changes: 1 addition & 1 deletion .cursor-plugin/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"command": "npx",
"args": [
"--yes",
"--package=@pascal-app/cli@1.0.0",
"--package=@pascal-app/cli@1.0.4",
"pascal",
"mcp",
"connect"
Expand Down
65 changes: 56 additions & 9 deletions .github/workflows/mcp-registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ on:
pull_request:
paths:
- server.json
- packages/mcp/package.json
- .github/workflows/mcp-registry.yml
push:
branches: [main]
paths:
- server.json
- packages/mcp/package.json
- .github/workflows/mcp-registry.yml
# Run before publishing: the hosted deployment must already serve this version.
workflow_dispatch:

env:
MCP_PUBLISHER_VERSION: 1.8.1
Expand All @@ -36,30 +40,58 @@ jobs:
- name: Validate MCP Registry manifest
run: "$RUNNER_TEMP/mcp-publisher validate"

# The registry verifies npm ownership through the published package's mcpName. A
# prerelease tree keeps the manifest on the last stable version.
- name: Manifest follows the @pascal-app/mcp package
run: |
jq --exit-status --slurpfile pkg packages/mcp/package.json '
(.version | test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))
and (($pkg[0].version | contains("-")) or .version == $pkg[0].version)
and .name == $pkg[0].mcpName
and .packages == [{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": $pkg[0].name,
"version": .version,
"runtimeHint": "npx",
"transport": { "type": "stdio" }
}]
' server.json

# OAuth is discovered from the 401 challenge, so the remote declares no headers.
- name: Verify hosted endpoint contract
run: |
jq --exit-status '
(.version | strings | test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))
and .remotes == [{
.remotes == [{
"type": "streamable-http",
"url": "https://editor.pascal.app/api/mcp",
"headers": [{
"name": "Authorization",
"description": "Bearer API key created in Pascal Settings, formatted as Bearer sk_live_...",
"isRequired": true,
"isSecret": true
}]
"url": "https://editor.pascal.app/api/mcp"
}]
' server.json
curl --fail --location --output /dev/null \
https://editor.pascal.app/docs/developers/mcp
status=$(curl --silent --show-error --output "$RUNNER_TEMP/mcp-response.json" \
--dump-header "$RUNNER_TEMP/mcp-response.headers" \
--write-out '%{http_code}' \
--request POST \
--header 'content-type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"registry-check","version":"1"}}}' \
https://editor.pascal.app/api/mcp)
test "$status" = "401"
grep --ignore-case --fixed-strings \
'resource_metadata="https://editor.pascal.app/.well-known/oauth-protected-resource"' \
"$RUNNER_TEMP/mcp-response.headers"

# The hosted API catalog and server card are served by the hosted deployment, not
# this repository, so they only match after that deployment picks up this version.
hosted-matches-manifest:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
persist-credentials: false

- name: Verify live API catalog matches manifest
run: |
Expand Down Expand Up @@ -103,3 +135,18 @@ jobs:
| tr -d '\r' \
| grep --fixed-strings --ignore-case \
'link: <https://editor.pascal.app/.well-known/api-catalog>; rel="api-catalog"'

- name: Verify live server card matches manifest
run: |
curl --fail \
--connect-timeout 10 \
--max-time 30 \
--retry 3 \
--retry-connrefused \
--retry-delay 2 \
--output "$RUNNER_TEMP/server-card.json" \
https://editor.pascal.app/.well-known/mcp/server-card.json
jq --exit-status --slurpfile manifest server.json '
.serverInfo.version == $manifest[0].version
and .transport.endpoint == $manifest[0].remotes[0].url
' "$RUNNER_TEMP/server-card.json"
18 changes: 18 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,24 @@ jobs:
# Refresh the lockfile so the release commit remains reproducible.
bun install

# The Cursor plugin's CLI pin and server.json quote these versions; a beta
# leaves them on the last stable release.
bun scripts/sync-agent-versions.ts

# Publishing server.json to the MCP Registry stays a manual owner step after
# this release lands; see wiki/npm-release.md.
- name: Validate MCP Registry manifest
env:
MCP_PUBLISHER_VERSION: 1.8.1
MCP_PUBLISHER_LINUX_AMD64_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc
run: |
curl --fail --location \
"https://github.com/modelcontextprotocol/registry/releases/download/v${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" \
--output "$RUNNER_TEMP/mcp-publisher.tar.gz"
echo "${MCP_PUBLISHER_LINUX_AMD64_SHA256} $RUNNER_TEMP/mcp-publisher.tar.gz" | sha256sum --check
tar -xzf "$RUNNER_TEMP/mcp-publisher.tar.gz" -C "$RUNNER_TEMP" mcp-publisher
"$RUNNER_TEMP/mcp-publisher" validate

- id: version
run: |
echo "version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,12 @@ These workflows require a connected Pascal MCP server for their tool-backed acti

### MCP Registry

[`server.json`](server.json) is Pascal's manifest for the official MCP Registry. Its
version tracks the hosted MCP implementation independently of the npm package version.
Pull requests validate the manifest and production endpoint. A Pascal organization
owner publishes an approved version from `main` with the official registry publisher.
[`server.json`](server.json) is Pascal's manifest for the official MCP Registry, named
`app.pascal/editor`. It lists the `@pascal-app/mcp` npm package and the hosted Streamable HTTP
endpoint, which signs in with OAuth. Its version is the `@pascal-app/mcp` package version:
`bun scripts/sync-agent-versions.ts` rewrites it, and the Cursor plugin's CLI pin, from the
package versions, and CI fails when they drift. A Pascal owner publishes from `main` after a
release; see [wiki/npm-release.md](wiki/npm-release.md#mcp-registry).

## Using Published Packages

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"test": "turbo run test",
"ci": "bun run check && bun run skills:validate && bun run check-types && bun run test && bun run build",
"postinstall": "([ -n \"$CI\" ] || [ ! -e .git ] || git config core.hooksPath .githooks)",
"skills:validate": "bun scripts/validate-skills.ts && bun test scripts/cursor-plugin-policy.test.ts scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/path-containment.test.ts scripts/public-skill-discovery-policy.test.ts",
"skills:validate": "bun scripts/validate-skills.ts && bun scripts/sync-agent-versions.ts --check && bun test scripts/sync-agent-versions.test.ts scripts/cursor-plugin-policy.test.ts scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/path-containment.test.ts scripts/public-skill-discovery-policy.test.ts",
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'",
"clean:cache": "rm -rf apps/*/.next apps/*/.swc apps/*/.turbo packages/*/.turbo tooling/*/.turbo .turbo node_modules/.cache",
"restart": "bun kill && bun clean:cache && bun dev",
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "@pascal-app/mcp",
"version": "1.0.4",
"description": "Model Context Protocol server for Pascal 3D editor",
"mcpName": "app.pascal/editor",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
37 changes: 36 additions & 1 deletion scripts/cursor-plugin-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const temporaryRoots: string[] = []
function fixture(): string {
const target = mkdtempSync(join(tmpdir(), 'cursor-plugin-policy-'))
temporaryRoots.push(target)
for (const path of ['skills', '.cursor-plugin', 'assets']) {
for (const path of ['skills', '.cursor-plugin', 'assets', 'packages/cli/package.json']) {
cpSync(join(root, path), join(target, path), { recursive: true })
}
return target
Expand Down Expand Up @@ -58,6 +58,41 @@ describe('Cursor marketplace installs from skills/', () => {
'Cursor local MCP must bootstrap the pinned npm CLI without a global Pascal dependency',
)
})
test('rejects a pin that lags the CLI package version', () => {
const target = fixture()
const path = join(target, 'packages/cli/package.json')
writeFileSync(
path,
JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), version: '99.0.0' }),
)
expect(validateCursorPluginPackage(target)).toContain(
'Cursor local MCP must bootstrap the pinned npm CLI without a global Pascal dependency',
)
})

test('keeps the stable pin while the CLI is on a beta', () => {
const target = fixture()
const path = join(target, 'packages/cli/package.json')
writeFileSync(
path,
JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), version: '99.0.0-beta.1' }),
)
expect(validateCursorPluginPackage(target)).toEqual([])
})

test('rejects a prerelease pin', () => {
const target = fixture()
for (const name of ['.cursor-plugin/mcp.json', 'skills/.cursor-plugin/mcp.json']) {
const path = join(target, name)
const config = JSON.parse(readFileSync(path, 'utf8'))
config.mcpServers.pascal.args[1] = '--package=@pascal-app/cli@99.0.0-beta.1'
writeFileSync(path, JSON.stringify(config))
}
expect(validateCursorPluginPackage(target)).toContain(
'Cursor local MCP must bootstrap the pinned npm CLI without a global Pascal dependency',
)
})

test('rejects a legacy API-key header on the browser OAuth server', () => {
const target = fixture()
for (const name of ['.cursor-plugin/mcp.json', 'skills/.cursor-plugin/mcp.json']) {
Expand Down
17 changes: 14 additions & 3 deletions scripts/cursor-plugin-policy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { stablePackageVersion } from './sync-agent-versions'

export function validateCursorPluginPackage(root: string): string[] {
const failures: string[] = []
Expand Down Expand Up @@ -31,14 +32,24 @@ export function validateCursorPluginPackage(root: string): string[] {
if (JSON.stringify(config) !== JSON.stringify(rootConfig)) {
failures.push('Cursor MCP configs must match at both install roots')
}
const hasCliPackage = existsSync(join(root, 'packages/cli/package.json'))
if (!hasCliPackage) failures.push('Cursor CLI pin needs packages/cli/package.json')
const cliVersion = hasCliPackage ? stablePackageVersion(root, 'cli') : undefined
const localServer = (config.mcpServers as Record<string, unknown> | undefined)?.pascal as
| { args?: unknown[] }
| undefined
const pin = localServer?.args?.[1]
const pinnedVersion =
typeof pin === 'string' ? /^--package=@pascal-app\/cli@(\d+\.\d+\.\d+)$/.exec(pin)?.[1] : null
const expectedLocalServer = {
type: 'stdio',
command: 'npx',
args: ['--yes', '--package=@pascal-app/cli@1.0.0', 'pascal', 'mcp', 'connect'],
args: ['--yes', `--package=@pascal-app/cli@${pinnedVersion}`, 'pascal', 'mcp', 'connect'],
}
if (
JSON.stringify((config.mcpServers as Record<string, unknown> | undefined)?.pascal) !==
JSON.stringify(expectedLocalServer)
!pinnedVersion ||
(cliVersion !== undefined && pinnedVersion !== cliVersion) ||
JSON.stringify(localServer) !== JSON.stringify(expectedLocalServer)
) {
failures.push(
'Cursor local MCP must bootstrap the pinned npm CLI without a global Pascal dependency',
Expand Down
51 changes: 51 additions & 0 deletions scripts/sync-agent-versions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { agentVersionDrift, cliPinFiles } from './sync-agent-versions'

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const temporaryRoots: string[] = []

function fixture(version: string): string {
const target = mkdtempSync(join(tmpdir(), 'sync-agent-versions-'))
temporaryRoots.push(target)
for (const path of [...cliPinFiles, 'server.json']) {
cpSync(join(root, path), join(target, path), { recursive: true })
}
for (const pkg of ['cli', 'mcp']) {
const path = join(target, 'packages', pkg, 'package.json')
cpSync(join(root, 'packages', pkg, 'package.json'), path)
writeFileSync(path, JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), version }))
}
return target
}

afterEach(() => {
for (const path of temporaryRoots.splice(0)) rmSync(path, { recursive: true, force: true })
})

describe('agent integration versions follow the package versions', () => {
test('the repository is in sync', () => {
expect([...agentVersionDrift(root).keys()]).toEqual([])
})

test('a release bump rewrites the CLI pin and the registry manifest', () => {
const drift = agentVersionDrift(fixture('9.8.7'))
expect([...drift.keys()].sort()).toEqual([...cliPinFiles, 'server.json'].sort())

const cursor = JSON.parse(drift.get('.cursor-plugin/mcp.json') ?? '{}')
expect(cursor.mcpServers.pascal.args).toContain('--package=@pascal-app/cli@9.8.7')

const server = JSON.parse(drift.get('server.json') ?? '{}')
expect(server.version).toBe('9.8.7')
expect(server.packages).toEqual([
expect.objectContaining({ identifier: '@pascal-app/mcp', version: '9.8.7' }),
])
})

test('a beta release keeps every integration on the last stable version', () => {
expect([...agentVersionDrift(fixture('9.8.7-beta.1')).keys()]).toEqual([])
})
})
80 changes: 80 additions & 0 deletions scripts/sync-agent-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

// Agent integrations quote published npm versions. The package.json files are the
// source of truth; this rewrites every quote so a release cannot leave one behind.
export const cliPinFiles = [
'.cursor-plugin/mcp.json',
'skills/.cursor-plugin/mcp.json',
'skills/README.md',
'skills/pascal-3d/references/setup.md',
'skills/furniture-fit/references/setup.md',
] as const

export const mcpPackageName = '@pascal-app/mcp'

const cliPin = /@pascal-app\/cli@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g

/**
* The version integrations should quote for a package, or undefined while the tree is on a
* prerelease. Prereleases publish on npm's `beta` tag, so integrations keep the last stable one.
*/
export function stablePackageVersion(root: string, pkg: string): string | undefined {
const { version } = JSON.parse(
readFileSync(join(root, 'packages', pkg, 'package.json'), 'utf8'),
) as { version: string }
return version.includes('-') ? undefined : version
}

function syncServerJson(source: string, version: string): string {
const manifest = JSON.parse(source)
manifest.version = version
for (const entry of manifest.packages ?? []) {
if (entry.identifier === mcpPackageName) entry.version = version
}
return `${JSON.stringify(manifest, null, 2)}\n`
}

/** Returns each managed file whose content differs from what the package versions require. */
export function agentVersionDrift(root: string): Map<string, string> {
const cliVersion = stablePackageVersion(root, 'cli')
const mcpVersion = stablePackageVersion(root, 'mcp')
const expected = new Map<string, string>()
if (cliVersion) {
for (const path of cliPinFiles) {
expected.set(
path,
readFileSync(join(root, path), 'utf8').replace(cliPin, `@pascal-app/cli@${cliVersion}`),
)
}
}
if (mcpVersion) {
expected.set(
'server.json',
syncServerJson(readFileSync(join(root, 'server.json'), 'utf8'), mcpVersion),
)
}
for (const [path, content] of expected) {
if (readFileSync(join(root, path), 'utf8') === content) expected.delete(path)
}
return expected
}

if (import.meta.main) {
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const drift = agentVersionDrift(root)
if (process.argv.includes('--check')) {
if (drift.size > 0) {
console.error(
`Agent integration versions are out of sync with packages/*/package.json:\n${[...drift.keys()].map((path) => ` ${path}`).join('\n')}\nRun: bun scripts/sync-agent-versions.ts`,
)
process.exit(1)
}
} else {
for (const [path, content] of drift) {
writeFileSync(join(root, path), content)
console.log(`Updated ${path}`)
}
}
}
Loading
Loading