diff --git a/.github/workflows/publish-mcp-server.yml b/.github/workflows/publish-mcp-server.yml new file mode 100644 index 000000000..6b8350b2a --- /dev/null +++ b/.github/workflows/publish-mcp-server.yml @@ -0,0 +1,177 @@ +name: Validate and publish FunASR MCP server + +on: + pull_request: + paths: + - "examples/mcp_server/**" + - ".github/workflows/publish-mcp-server.yml" + push: + branches: ["main"] + tags: ["mcp-v*"] + paths: + - "examples/mcp_server/**" + - ".github/workflows/publish-mcp-server.yml" + workflow_dispatch: + +env: + IMAGE_NAME: ghcr.io/modelscope/funasr-mcp + MCP_PUBLISHER_VERSION: v1.8.0 + MCP_PUBLISHER_SHA256: 1370446bbe74d562608e8005a6ccce02d146a661fbd78674e11cc70b9618d6cf + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + + - name: Run MCP unit and metadata tests + run: python -m unittest discover -s examples/mcp_server -p "test_*.py" -v + + - name: Validate server.json against the official schema + run: | + python -m pip install --disable-pip-version-check "jsonschema==4.25.1" + schema_url="$(jq -r '."$schema"' examples/mcp_server/server.json)" + curl --fail --show-error --silent --location --retry 3 \ + "$schema_url" -o "$RUNNER_TEMP/server.schema.json" + python - <<'PY' + import json + import os + from pathlib import Path + + from jsonschema import Draft7Validator + + metadata = json.loads(Path("examples/mcp_server/server.json").read_text()) + schema = json.loads( + (Path(os.environ["RUNNER_TEMP"]) / "server.schema.json").read_text() + ) + Draft7Validator(schema).validate(metadata) + PY + + - name: Install verified MCP publisher + run: | + archive="$RUNNER_TEMP/mcp-publisher.tar.gz" + curl --fail --show-error --silent --location --retry 3 \ + "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" \ + -o "$archive" + echo "${MCP_PUBLISHER_SHA256} $archive" | sha256sum --check + tar -xzf "$archive" -C "$RUNNER_TEMP" mcp-publisher + + - name: Validate metadata with the official Registry + run: | + "$RUNNER_TEMP/mcp-publisher" validate examples/mcp_server/server.json + + - name: Check release tag matches server version + id: metadata + shell: bash + run: | + version="$(jq -r '.version' examples/mcp_server/server.json)" + echo "version=$version" >> "$GITHUB_OUTPUT" + if [[ "$GITHUB_REF" == refs/tags/mcp-v* ]]; then + tag_version="${GITHUB_REF_NAME#mcp-v}" + test "$tag_version" = "$version" || { + echo "Tag version $tag_version does not match server.json $version" >&2 + exit 1 + } + fi + + - uses: docker/setup-buildx-action@v4 + + - name: Build local MCP image + uses: docker/build-push-action@v7 + with: + context: examples/mcp_server + load: true + push: false + tags: funasr-mcp:test + build-args: | + VERSION=${{ steps.metadata.outputs.version }} + VCS_REF=${{ github.sha }} + cache-from: type=gha,scope=funasr-mcp + cache-to: type=gha,mode=max,scope=funasr-mcp + + - name: Smoke-test MCP protocol inside the image + run: python examples/mcp_server/smoke_test.py funasr-mcp:test + + publish: + if: startsWith(github.ref, 'refs/tags/mcp-v') + needs: validate + runs-on: ubuntu-latest + environment: mcp-registry-publish + permissions: + contents: read + id-token: write + packages: write + + steps: + - uses: actions/checkout@v7 + + - name: Read server version + id: metadata + run: echo "version=$(jq -r '.version' examples/mcp_server/server.json)" >> "$GITHUB_OUTPUT" + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/setup-buildx-action@v4 + + - name: Publish versioned MCP image + uses: docker/build-push-action@v7 + with: + context: examples/mcp_server + push: true + tags: | + ${{ env.IMAGE_NAME }}:${{ steps.metadata.outputs.version }} + ${{ env.IMAGE_NAME }}:latest + build-args: | + VERSION=${{ steps.metadata.outputs.version }} + VCS_REF=${{ github.sha }} + cache-from: type=gha,scope=funasr-mcp + cache-to: type=gha,mode=max,scope=funasr-mcp + + - name: Make the GHCR package public + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api --method PATCH \ + "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/funasr-mcp" \ + -f visibility=public + + - name: Install verified MCP publisher + run: | + archive="$RUNNER_TEMP/mcp-publisher.tar.gz" + curl --fail --show-error --silent --location --retry 3 \ + "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" \ + -o "$archive" + echo "${MCP_PUBLISHER_SHA256} $archive" | sha256sum --check + tar -xzf "$archive" -C "$RUNNER_TEMP" mcp-publisher + + - name: Publish metadata to the official MCP Registry + working-directory: examples/mcp_server + run: | + "$RUNNER_TEMP/mcp-publisher" login github-oidc + "$RUNNER_TEMP/mcp-publisher" publish + + - name: Verify the Registry listing + run: | + for attempt in {1..6}; do + if curl --fail --show-error --silent --get \ + --data-urlencode "search=io.github.modelscope/funasr-mcp" \ + https://registry.modelcontextprotocol.io/v0.1/servers \ + | jq -e '.servers[] | select(.server.name == "io.github.modelscope/funasr-mcp")' \ + >/dev/null; then + exit 0 + fi + sleep 10 + done + echo "Published server did not appear in the Registry within 60 seconds" >&2 + exit 1 diff --git a/examples/mcp_server/Dockerfile b/examples/mcp_server/Dockerfile index 8e71be551..669584cc1 100644 --- a/examples/mcp_server/Dockerfile +++ b/examples/mcp_server/Dockerfile @@ -1,6 +1,16 @@ FROM python:3.10-slim -LABEL io.modelcontextprotocol.server.name="io.github.modelscope/funasr-mcp" +ARG FUNASR_VERSION=1.3.14 +ARG VERSION=0.1.0 +ARG VCS_REF=unknown + +LABEL io.modelcontextprotocol.server.name="io.github.modelscope/funasr-mcp" \ + org.opencontainers.image.title="FunASR MCP Server" \ + org.opencontainers.image.description="Local speech transcription over the Model Context Protocol" \ + org.opencontainers.image.source="https://github.com/modelscope/FunASR" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.licenses="MIT" ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ @@ -15,8 +25,7 @@ RUN apt-get update \ libsndfile1 \ && rm -rf /var/lib/apt/lists/* -RUN python -m pip install --upgrade pip \ - && pip install funasr +RUN pip install "funasr==${FUNASR_VERSION}" COPY funasr_mcp.py /app/funasr_mcp.py diff --git a/examples/mcp_server/README.md b/examples/mcp_server/README.md index 03f9d0746..dc8c2fc38 100644 --- a/examples/mcp_server/README.md +++ b/examples/mcp_server/README.md @@ -19,29 +19,62 @@ checks that initialize the server and call `tools/list`. docker build -t funasr-mcp examples/mcp_server docker run --rm -i \ -e FUNASR_DEVICE=cpu \ - -v /path/to/audio:/audio:ro \ + --mount type=bind,src=/path/to/audio,dst=/audio,readonly \ + --mount type=volume,src=funasr-mcp-cache,dst=/root/.cache/modelscope \ funasr-mcp ``` +Verify the image entrypoint and MCP handshake without downloading a model: + +```bash +python examples/mcp_server/smoke_test.py funasr-mcp +``` + When submitting this server to MCP directories such as Glama, use this folder as the Docker build context so the container entrypoint runs `funasr_mcp.py`. The repository root `glama.json` declares GitHub maintainer ownership for Glama, while the `glama.json` file in this directory declares the container command and metadata for directory scanners. -### Official MCP Registry checklist +### Official MCP Registry -The Dockerfile includes the OCI ownership label expected by the official MCP -Registry: +The versioned Registry metadata is in [`server.json`](server.json). It points to +the public GHCR image and asks clients to mount one host audio directory at +`/audio` read-only. The Dockerfile carries the matching OCI ownership label: ```dockerfile LABEL io.modelcontextprotocol.server.name="io.github.modelscope/funasr-mcp" ``` -Before publishing, push a public OCI image (for example to GHCR) and create a -matching `server.json` whose `name` is `io.github.modelscope/funasr-mcp` and -whose package identifier points at that image tag. The Registry verifies that -the Docker/OCI label and `server.json` name match. +The release workflow validates the metadata against the official schema, builds +the image, performs an MCP `initialize` and `tools/list` handshake, pushes the +versioned image to GHCR, and publishes `server.json` through the official +`mcp-publisher` CLI. + +To release a new MCP server version: + +1. Update `version` and the OCI image tag in `server.json` together. +2. Merge the change after the MCP validation workflow passes. +3. Have a `modelscope` organization Owner push the matching `mcp-v` + tag, for example `mcp-v0.1.0`. +4. Approve the protected `mcp-registry-publish` environment deployment. + +The official Registry only grants the `io.github.modelscope/*` namespace to a +GitHub organization Owner. Keep the publish environment restricted to release +tags and require a maintainer approval because its OIDC token can publish the +organization namespace. + +After publication, clients can run the pinned image directly: + +```bash +docker run --rm -i \ + --mount type=bind,src=/path/to/audio,dst=/audio,readonly \ + --mount type=volume,src=funasr-mcp-cache,dst=/root/.cache/modelscope \ + ghcr.io/modelscope/funasr-mcp:0.1.0 +``` + +When using the container, pass tool paths under `/audio`, such as +`/audio/meeting.wav`. ### Glama submission checklist diff --git a/examples/mcp_server/server.json b/examples/mcp_server/server.json new file mode 100644 index 000000000..c487127bc --- /dev/null +++ b/examples/mcp_server/server.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.modelscope/funasr-mcp", + "title": "FunASR", + "description": "Transcribe local audio with FunASR and SenseVoice using private, on-device inference.", + "version": "0.1.0", + "websiteUrl": "https://github.com/modelscope/FunASR/tree/main/examples/mcp_server", + "repository": { + "url": "https://github.com/modelscope/FunASR", + "source": "github", + "id": "569959091", + "subfolder": "examples/mcp_server" + }, + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/modelscope/funasr-mcp:0.1.0", + "runtimeHint": "docker", + "runtimeArguments": [ + { + "type": "named", + "name": "--mount", + "description": "Mount the host audio directory read-only at /audio in the container.", + "value": "type=bind,src={audio_directory},dst=/audio,readonly", + "variables": { + "audio_directory": { + "description": "Host directory containing audio files. Use /audio/ when calling the tool.", + "format": "filepath", + "isRequired": true, + "placeholder": "/path/to/audio" + } + } + }, + { + "type": "named", + "name": "--mount", + "description": "Persist downloaded ModelScope model files between container runs.", + "value": "type=volume,src=funasr-mcp-cache,dst=/root/.cache/modelscope" + } + ], + "transport": { + "type": "stdio" + } + } + ] +} diff --git a/examples/mcp_server/smoke_test.py b/examples/mcp_server/smoke_test.py new file mode 100644 index 000000000..c6ec80629 --- /dev/null +++ b/examples/mcp_server/smoke_test.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +import argparse +import json +import subprocess + + +REQUESTS = [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "container-smoke-test", "version": "1.0"}, + }, + }, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, +] + + +def validate_responses(responses): + if not all(isinstance(response, dict) for response in responses): + raise ValueError("expected each stdout payload to be a JSON object") + + response_ids = [response.get("id") for response in responses] + if len(responses) != 2 or set(response_ids) != {1, 2}: + raise ValueError(f"expected response IDs 1 and 2 only, got {response_ids}") + by_id = {response["id"]: response for response in responses} + + server_info = by_id[1].get("result", {}).get("serverInfo", {}) + if server_info.get("name") != "funasr": + raise ValueError(f"unexpected serverInfo: {server_info}") + + tools = by_id[2].get("result", {}).get("tools", []) + tool_names = {tool.get("name") for tool in tools} + if "transcribe_audio" not in tool_names: + raise ValueError(f"transcribe_audio missing from tools/list: {tool_names}") + + +def parse_responses(stdout): + responses = [] + for line_number, raw_line in enumerate(stdout.splitlines(), start=1): + line = raw_line.strip() + if not line: + continue + try: + responses.append(json.loads(line)) + except json.JSONDecodeError as error: + raise ValueError( + f"non-JSON stdout on line {line_number}: {line[:120]}" + ) from error + return responses + + +def run_smoke_test(image, timeout): + payload = "".join(f"{json.dumps(request)}\n" for request in REQUESTS) + completed = subprocess.run( + ["docker", "run", "--rm", "-i", image], + input=payload, + text=True, + capture_output=True, + check=False, + timeout=timeout, + ) + if completed.returncode != 0: + raise RuntimeError( + f"container exited with {completed.returncode}: {completed.stderr.strip()}" + ) + + responses = parse_responses(completed.stdout) + validate_responses(responses) + + +def main(): + parser = argparse.ArgumentParser(description="Smoke-test the FunASR MCP image") + parser.add_argument("image", help="Local or remote container image reference") + parser.add_argument("--timeout", type=int, default=60) + args = parser.parse_args() + + run_smoke_test(args.image, args.timeout) + print(f"MCP container smoke test passed: {args.image}") + + +if __name__ == "__main__": + main() diff --git a/examples/mcp_server/test_registry_metadata.py b/examples/mcp_server/test_registry_metadata.py new file mode 100644 index 000000000..b795cde44 --- /dev/null +++ b/examples/mcp_server/test_registry_metadata.py @@ -0,0 +1,162 @@ +import importlib.util +import json +import re +import unittest +from pathlib import Path + + +MCP_DIR = Path(__file__).resolve().parent +REPO_ROOT = MCP_DIR.parents[1] +SERVER_JSON = MCP_DIR / "server.json" +DOCKERFILE = MCP_DIR / "Dockerfile" +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "publish-mcp-server.yml" +SMOKE_TEST = MCP_DIR / "smoke_test.py" + + +def load_smoke_test_module(): + spec = importlib.util.spec_from_file_location("funasr_mcp_smoke_test", SMOKE_TEST) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load smoke test module from {SMOKE_TEST}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class MCPRegistryMetadataTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.metadata = json.loads(SERVER_JSON.read_text()) + + def test_server_metadata_uses_canonical_namespace(self): + self.assertEqual( + self.metadata["$schema"], + "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + ) + self.assertEqual(self.metadata["name"], "io.github.modelscope/funasr-mcp") + self.assertLessEqual(len(self.metadata["description"]), 100) + self.assertEqual( + self.metadata["repository"], + { + "url": "https://github.com/modelscope/FunASR", + "source": "github", + "id": "569959091", + "subfolder": "examples/mcp_server", + }, + ) + + def test_oci_package_version_matches_server_version(self): + packages = self.metadata["packages"] + self.assertEqual(len(packages), 1) + package = packages[0] + self.assertEqual(package["registryType"], "oci") + self.assertEqual(package["runtimeHint"], "docker") + self.assertEqual(package["transport"], {"type": "stdio"}) + self.assertEqual( + package["identifier"], + f"ghcr.io/modelscope/funasr-mcp:{self.metadata['version']}", + ) + + def test_oci_package_mounts_audio_read_only(self): + package = self.metadata["packages"][0] + mounts = [ + argument + for argument in package["runtimeArguments"] + if argument.get("name") == "--mount" + and "audio_directory" in argument.get("value", "") + ] + self.assertEqual(len(mounts), 1) + mount = mounts[0] + self.assertIn("dst=/audio", mount["value"]) + self.assertIn("readonly", mount["value"]) + self.assertEqual(mount["variables"]["audio_directory"]["format"], "filepath") + self.assertTrue(mount["variables"]["audio_directory"]["isRequired"]) + + def test_docker_ownership_label_matches_server_name(self): + dockerfile = DOCKERFILE.read_text() + match = re.search( + r'io\.modelcontextprotocol\.server\.name="([^"]+)"', dockerfile + ) + self.assertIsNotNone(match) + self.assertEqual(match.group(1), self.metadata["name"]) + self.assertIn("ARG FUNASR_VERSION=1.3.14", dockerfile) + self.assertIn("funasr==${FUNASR_VERSION}", dockerfile) + + def test_release_workflow_is_versioned_and_oidc_authenticated(self): + workflow = WORKFLOW.read_text() + self.assertIn('"mcp-v*"', workflow) + self.assertIn("MCP_PUBLISHER_VERSION: v1.8.0", workflow) + self.assertIn( + "MCP_PUBLISHER_SHA256: 1370446bbe74d562608e8005a6ccce02d146a661fbd78674e11cc70b9618d6cf", + workflow, + ) + self.assertIn("id-token: write", workflow) + self.assertIn("login github-oidc", workflow) + self.assertIn("python examples/mcp_server/smoke_test.py", workflow) + self.assertIn("actions/checkout@v7", workflow) + self.assertIn("actions/setup-python@v6", workflow) + self.assertIn("docker/build-push-action@v7", workflow) + self.assertIn("docker/setup-buildx-action@v4", workflow) + self.assertIn("docker/login-action@v4", workflow) + self.assertNotIn("actions/checkout@v4", workflow) + self.assertNotIn("actions/setup-python@v5", workflow) + self.assertNotIn("docker/build-push-action@v6", workflow) + self.assertNotIn("docker/setup-buildx-action@v3", workflow) + + +class MCPContainerSmokeContractTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.smoke_test = load_smoke_test_module() + + def test_validate_responses_accepts_initialize_and_tools_list(self): + responses = [ + { + "jsonrpc": "2.0", + "id": 1, + "result": {"serverInfo": {"name": "funasr", "version": "1.3.14"}}, + }, + { + "jsonrpc": "2.0", + "id": 2, + "result": {"tools": [{"name": "transcribe_audio"}]}, + }, + ] + + self.smoke_test.validate_responses(responses) + + def test_validate_responses_rejects_missing_tool(self): + responses = [ + { + "jsonrpc": "2.0", + "id": 1, + "result": {"serverInfo": {"name": "funasr", "version": "1.3.14"}}, + }, + {"jsonrpc": "2.0", "id": 2, "result": {"tools": []}}, + ] + + with self.assertRaisesRegex(ValueError, "transcribe_audio"): + self.smoke_test.validate_responses(responses) + + def test_validate_responses_rejects_non_object_payload(self): + with self.assertRaisesRegex(ValueError, "JSON object"): + self.smoke_test.validate_responses([{"id": 1}, ["unexpected"]]) + + def test_validate_responses_reports_unexpected_ids_without_sorting(self): + responses = [ + {"jsonrpc": "2.0", "id": 1, "result": {}}, + {"jsonrpc": "2.0", "id": 2, "result": {"tools": []}}, + {"jsonrpc": "2.0", "method": "notifications/progress"}, + ] + + with self.assertRaisesRegex(ValueError, "expected response IDs 1 and 2"): + self.smoke_test.validate_responses(responses) + + def test_parse_responses_rejects_non_json_stdout(self): + stdout = 'library log on stdout\n{"jsonrpc":"2.0","id":1,"result":{}}\n' + + with self.assertRaisesRegex(ValueError, "non-JSON stdout"): + self.smoke_test.parse_responses(stdout) + + +if __name__ == "__main__": + unittest.main()