diff --git a/.github/scripts/server-json-validator/package-lock.json b/.github/scripts/server-json-validator/package-lock.json new file mode 100644 index 000000000..3b345d852 --- /dev/null +++ b/.github/scripts/server-json-validator/package-lock.json @@ -0,0 +1,84 @@ +{ + "name": "cudly-mcp-server-json-validator", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cudly-mcp-server-json-validator", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/.github/scripts/server-json-validator/package.json b/.github/scripts/server-json-validator/package.json new file mode 100644 index 000000000..5e759c0fa --- /dev/null +++ b/.github/scripts/server-json-validator/package.json @@ -0,0 +1,8 @@ +{ + "name": "cudly-mcp-server-json-validator", + "private": true, + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + } +} diff --git a/.github/scripts/server-json-validator/validate.cjs b/.github/scripts/server-json-validator/validate.cjs new file mode 100644 index 000000000..3a182e6ce --- /dev/null +++ b/.github/scripts/server-json-validator/validate.cjs @@ -0,0 +1,22 @@ +const fs = require("fs"); +const Ajv = require("ajv"); +const addFormats = require("ajv-formats"); + +const [schemaPath, dataPath] = process.argv.slice(2); +if (!schemaPath || !dataPath) { + console.error("usage: node .github/scripts/server-json-validator/validate.cjs "); + process.exit(2); +} + +const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); +const data = JSON.parse(fs.readFileSync(dataPath, "utf8")); +const ajv = new Ajv({ strict: false, allErrors: true }); +addFormats(ajv); + +const valid = ajv.validate(schema, data); +if (!valid) { + console.error(JSON.stringify(ajv.errors, null, 2)); + process.exit(1); +} + +console.log(`${dataPath} valid`); diff --git a/.github/workflows/mcp-server-json.yml b/.github/workflows/mcp-server-json.yml new file mode 100644 index 000000000..efc464a15 --- /dev/null +++ b/.github/workflows/mcp-server-json.yml @@ -0,0 +1,80 @@ +name: MCP server.json + +# Validates server.json (the MCP Registry listing manifest) on every PR that +# touches it, and gates tag pushes on server.json's version matching the tag. +# The registry rejects republishing a version, so a mismatch here must fail +# loud rather than let release automation publish the wrong metadata. + +on: + pull_request: + paths: + - "server.json" + - ".github/workflows/mcp-server-json.yml" + - ".github/scripts/server-json-validator/**" + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate-schema: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Fetch the MCP Registry server.json schema + run: | + set -euo pipefail + expected_schema_url="https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json" + expected_registry_name="io.github.LeanerCloud/cudly-mcp" + if ! jq -e --arg expected "$expected_schema_url" \ + '.["$schema"] | type == "string" and . == $expected' server.json >/dev/null; then + echo "::error::server.json must declare the official MCP Registry \$schema URL" + exit 1 + fi + if ! jq -e --arg expected "$expected_registry_name" \ + '.name | type == "string" and . == $expected' server.json >/dev/null; then + echo "::error::server.json name must be $expected_registry_name" + exit 1 + fi + curl -sSfL --connect-timeout 10 --max-time 30 \ + "$expected_schema_url" -o /tmp/server.schema.json + + - name: Install server.json validator dependencies + working-directory: .github/scripts/server-json-validator + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Validate server.json against the schema + run: node .github/scripts/server-json-validator/validate.cjs /tmp/server.schema.json server.json + + assert-version-matches-tag: + if: startsWith(github.ref, 'refs/tags/v') + needs: validate-schema + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Assert server.json version matches the pushed tag + run: | + set -euo pipefail + tag="${GITHUB_REF#refs/tags/v}" + server_version=$(jq -r '.version' server.json) + if [[ "$tag" != "$server_version" ]]; then + echo "::error::server.json version ($server_version) does not match tag v$tag." \ + "Correct server.json.version in the release commit before creating or recreating the tag;" \ + "this workflow never rewrites the file." + exit 1 + fi + echo "server.json version ($server_version) matches tag v$tag" diff --git a/server.json b/server.json new file mode 100644 index 000000000..ed972ca42 --- /dev/null +++ b/server.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.LeanerCloud/cudly-mcp", + "description": "Search and buy AWS/Azure/GCP reserved capacity (RIs, Savings Plans, CUDs) via any MCP client.", + "repository": { + "url": "https://github.com/LeanerCloud/CUDly", + "source": "github" + }, + "version": "0.1.0", + "websiteUrl": "https://cudly.io/mcp/", + "packages": [ + { + "registryType": "mcpb", + "identifier": "https://github.com/LeanerCloud/CUDly/releases/download/v0.1.0/cudly-mcp-full.mcpb", + "fileSha256": "0000000000000000000000000000000000000000000000000000000000000000", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "name": "CUDLY_MCP_ENABLE_REAL_PURCHASES", + "description": "Set to 1 (or true) to let purchase tools execute real, money-spending purchases. Unset (the default) refuses every dry_run=false call before any provider or credential is touched -- see mcp/README.md 'Safety model'.", + "isRequired": false, + "isSecret": false, + "format": "string" + } + ] + } + ] +}