Skip to content

Commit dcd0922

Browse files
committed
feat: publish FunASR MCP server to registry
1 parent 970eec7 commit dcd0922

6 files changed

Lines changed: 486 additions & 11 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
name: Validate and publish FunASR MCP server
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "examples/mcp_server/**"
7+
- ".github/workflows/publish-mcp-server.yml"
8+
push:
9+
branches: ["main"]
10+
tags: ["mcp-v*"]
11+
paths:
12+
- "examples/mcp_server/**"
13+
- ".github/workflows/publish-mcp-server.yml"
14+
workflow_dispatch:
15+
16+
env:
17+
IMAGE_NAME: ghcr.io/modelscope/funasr-mcp
18+
MCP_PUBLISHER_VERSION: v1.8.0
19+
MCP_PUBLISHER_SHA256: 1370446bbe74d562608e8005a6ccce02d146a661fbd78674e11cc70b9618d6cf
20+
21+
jobs:
22+
validate:
23+
runs-on: ubuntu-latest
24+
permissions:
25+
contents: read
26+
27+
steps:
28+
- uses: actions/checkout@v7
29+
with:
30+
persist-credentials: false
31+
32+
- uses: actions/setup-python@v6
33+
with:
34+
python-version: "3.10"
35+
36+
- name: Run MCP unit and metadata tests
37+
run: python -m unittest discover -s examples/mcp_server -p "test_*.py" -v
38+
39+
- name: Validate server.json against the official schema
40+
run: |
41+
python -m pip install --disable-pip-version-check "jsonschema==4.25.1"
42+
schema_url="$(jq -r '."$schema"' examples/mcp_server/server.json)"
43+
curl --fail --show-error --silent --location --retry 3 \
44+
"$schema_url" -o "$RUNNER_TEMP/server.schema.json"
45+
python - <<'PY'
46+
import json
47+
import os
48+
from pathlib import Path
49+
50+
from jsonschema import Draft7Validator
51+
52+
metadata = json.loads(Path("examples/mcp_server/server.json").read_text())
53+
schema = json.loads(
54+
(Path(os.environ["RUNNER_TEMP"]) / "server.schema.json").read_text()
55+
)
56+
Draft7Validator(schema).validate(metadata)
57+
PY
58+
59+
- name: Install verified MCP publisher
60+
run: |
61+
archive="$RUNNER_TEMP/mcp-publisher.tar.gz"
62+
curl --fail --show-error --silent --location --retry 3 \
63+
"https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" \
64+
-o "$archive"
65+
echo "${MCP_PUBLISHER_SHA256} $archive" | sha256sum --check
66+
tar -xzf "$archive" -C "$RUNNER_TEMP" mcp-publisher
67+
68+
- name: Validate metadata with the official Registry
69+
run: |
70+
"$RUNNER_TEMP/mcp-publisher" validate examples/mcp_server/server.json
71+
72+
- name: Check release tag matches server version
73+
id: metadata
74+
shell: bash
75+
run: |
76+
version="$(jq -r '.version' examples/mcp_server/server.json)"
77+
echo "version=$version" >> "$GITHUB_OUTPUT"
78+
if [[ "$GITHUB_REF" == refs/tags/mcp-v* ]]; then
79+
tag_version="${GITHUB_REF_NAME#mcp-v}"
80+
test "$tag_version" = "$version" || {
81+
echo "Tag version $tag_version does not match server.json $version" >&2
82+
exit 1
83+
}
84+
fi
85+
86+
- uses: docker/setup-buildx-action@v4
87+
88+
- name: Build local MCP image
89+
uses: docker/build-push-action@v7
90+
with:
91+
context: examples/mcp_server
92+
load: true
93+
push: false
94+
tags: funasr-mcp:test
95+
build-args: |
96+
VERSION=${{ steps.metadata.outputs.version }}
97+
VCS_REF=${{ github.sha }}
98+
cache-from: type=gha,scope=funasr-mcp
99+
cache-to: type=gha,mode=max,scope=funasr-mcp
100+
101+
- name: Smoke-test MCP protocol inside the image
102+
run: python examples/mcp_server/smoke_test.py funasr-mcp:test
103+
104+
publish:
105+
if: startsWith(github.ref, 'refs/tags/mcp-v')
106+
needs: validate
107+
runs-on: ubuntu-latest
108+
environment: mcp-registry-publish
109+
permissions:
110+
contents: read
111+
id-token: write
112+
packages: write
113+
114+
steps:
115+
- uses: actions/checkout@v7
116+
with:
117+
persist-credentials: false
118+
119+
- name: Read server version
120+
id: metadata
121+
run: echo "version=$(jq -r '.version' examples/mcp_server/server.json)" >> "$GITHUB_OUTPUT"
122+
123+
- uses: docker/login-action@v4
124+
with:
125+
registry: ghcr.io
126+
username: ${{ github.actor }}
127+
password: ${{ secrets.GITHUB_TOKEN }}
128+
129+
- uses: docker/setup-buildx-action@v4
130+
131+
- name: Publish versioned MCP image
132+
uses: docker/build-push-action@v7
133+
with:
134+
context: examples/mcp_server
135+
push: true
136+
tags: |
137+
${{ env.IMAGE_NAME }}:${{ steps.metadata.outputs.version }}
138+
${{ env.IMAGE_NAME }}:latest
139+
build-args: |
140+
VERSION=${{ steps.metadata.outputs.version }}
141+
VCS_REF=${{ github.sha }}
142+
cache-from: type=gha,scope=funasr-mcp
143+
cache-to: type=gha,mode=max,scope=funasr-mcp
144+
145+
- name: Make the GHCR package public
146+
env:
147+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
148+
run: |
149+
gh api --method PATCH \
150+
"/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/funasr-mcp" \
151+
-f visibility=public
152+
153+
- name: Install verified MCP publisher
154+
run: |
155+
archive="$RUNNER_TEMP/mcp-publisher.tar.gz"
156+
curl --fail --show-error --silent --location --retry 3 \
157+
"https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" \
158+
-o "$archive"
159+
echo "${MCP_PUBLISHER_SHA256} $archive" | sha256sum --check
160+
tar -xzf "$archive" -C "$RUNNER_TEMP" mcp-publisher
161+
162+
- name: Publish metadata to the official MCP Registry
163+
working-directory: examples/mcp_server
164+
run: |
165+
"$RUNNER_TEMP/mcp-publisher" login github-oidc
166+
"$RUNNER_TEMP/mcp-publisher" publish
167+
168+
- name: Verify the Registry listing
169+
run: |
170+
for attempt in {1..6}; do
171+
if curl --fail --show-error --silent --get \
172+
--data-urlencode "search=io.github.modelscope/funasr-mcp" \
173+
https://registry.modelcontextprotocol.io/v0.1/servers \
174+
| jq -e '.servers[] | select(.server.name == "io.github.modelscope/funasr-mcp")' \
175+
>/dev/null; then
176+
exit 0
177+
fi
178+
sleep 10
179+
done
180+
echo "Published server did not appear in the Registry within 60 seconds" >&2
181+
exit 1

examples/mcp_server/Dockerfile

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
FROM python:3.10-slim
22

3-
LABEL io.modelcontextprotocol.server.name="io.github.modelscope/funasr-mcp"
3+
ARG FUNASR_VERSION=1.3.14
4+
ARG VERSION=0.1.0
5+
ARG VCS_REF=unknown
6+
7+
LABEL io.modelcontextprotocol.server.name="io.github.modelscope/funasr-mcp" \
8+
org.opencontainers.image.title="FunASR MCP Server" \
9+
org.opencontainers.image.description="Local speech transcription over the Model Context Protocol" \
10+
org.opencontainers.image.source="https://github.com/modelscope/FunASR" \
11+
org.opencontainers.image.version="${VERSION}" \
12+
org.opencontainers.image.revision="${VCS_REF}" \
13+
org.opencontainers.image.licenses="MIT"
414

515
ENV PYTHONDONTWRITEBYTECODE=1 \
616
PYTHONUNBUFFERED=1 \
@@ -15,8 +25,7 @@ RUN apt-get update \
1525
libsndfile1 \
1626
&& rm -rf /var/lib/apt/lists/*
1727

18-
RUN python -m pip install --upgrade pip \
19-
&& pip install funasr
28+
RUN pip install "funasr==${FUNASR_VERSION}"
2029

2130
COPY funasr_mcp.py /app/funasr_mcp.py
2231

examples/mcp_server/README.md

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,62 @@ checks that initialize the server and call `tools/list`.
1919
docker build -t funasr-mcp examples/mcp_server
2020
docker run --rm -i \
2121
-e FUNASR_DEVICE=cpu \
22-
-v /path/to/audio:/audio:ro \
22+
--mount type=bind,src=/path/to/audio,dst=/audio,readonly \
23+
--mount type=volume,src=funasr-mcp-cache,dst=/root/.cache/modelscope \
2324
funasr-mcp
2425
```
2526

27+
Verify the image entrypoint and MCP handshake without downloading a model:
28+
29+
```bash
30+
python examples/mcp_server/smoke_test.py funasr-mcp
31+
```
32+
2633
When submitting this server to MCP directories such as Glama, use this folder as
2734
the Docker build context so the container entrypoint runs `funasr_mcp.py`.
2835
The repository root `glama.json` declares GitHub maintainer ownership for Glama,
2936
while the `glama.json` file in this directory declares the container command and
3037
metadata for directory scanners.
3138

32-
### Official MCP Registry checklist
39+
### Official MCP Registry
3340

34-
The Dockerfile includes the OCI ownership label expected by the official MCP
35-
Registry:
41+
The versioned Registry metadata is in [`server.json`](server.json). It points to
42+
the public GHCR image and asks clients to mount one host audio directory at
43+
`/audio` read-only. The Dockerfile carries the matching OCI ownership label:
3644

3745
```dockerfile
3846
LABEL io.modelcontextprotocol.server.name="io.github.modelscope/funasr-mcp"
3947
```
4048

41-
Before publishing, push a public OCI image (for example to GHCR) and create a
42-
matching `server.json` whose `name` is `io.github.modelscope/funasr-mcp` and
43-
whose package identifier points at that image tag. The Registry verifies that
44-
the Docker/OCI label and `server.json` name match.
49+
The release workflow validates the metadata against the official schema, builds
50+
the image, performs an MCP `initialize` and `tools/list` handshake, pushes the
51+
versioned image to GHCR, and publishes `server.json` through the official
52+
`mcp-publisher` CLI.
53+
54+
To release a new MCP server version:
55+
56+
1. Update `version` and the OCI image tag in `server.json` together.
57+
2. Merge the change after the MCP validation workflow passes.
58+
3. Have a `modelscope` organization Owner push the matching `mcp-v<version>`
59+
tag, for example `mcp-v0.1.0`.
60+
4. Approve the protected `mcp-registry-publish` environment deployment.
61+
62+
The official Registry only grants the `io.github.modelscope/*` namespace to a
63+
GitHub organization Owner. Keep the publish environment restricted to release
64+
tags and require a maintainer approval because its OIDC token can publish the
65+
organization namespace.
66+
67+
After publication, clients can run the pinned image directly:
68+
69+
```bash
70+
docker run --rm -i \
71+
--mount type=bind,src=/path/to/audio,dst=/audio,readonly \
72+
--mount type=volume,src=funasr-mcp-cache,dst=/root/.cache/modelscope \
73+
ghcr.io/modelscope/funasr-mcp:0.1.0
74+
```
75+
76+
When using the container, pass tool paths under `/audio`, such as
77+
`/audio/meeting.wav`.
4578

4679
### Glama submission checklist
4780

examples/mcp_server/server.json

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3+
"name": "io.github.modelscope/funasr-mcp",
4+
"title": "FunASR",
5+
"description": "Transcribe local audio with FunASR and SenseVoice using private, on-device inference.",
6+
"version": "0.1.0",
7+
"websiteUrl": "https://github.com/modelscope/FunASR/tree/main/examples/mcp_server",
8+
"repository": {
9+
"url": "https://github.com/modelscope/FunASR",
10+
"source": "github",
11+
"id": "569959091",
12+
"subfolder": "examples/mcp_server"
13+
},
14+
"packages": [
15+
{
16+
"registryType": "oci",
17+
"identifier": "ghcr.io/modelscope/funasr-mcp:0.1.0",
18+
"runtimeHint": "docker",
19+
"runtimeArguments": [
20+
{
21+
"type": "named",
22+
"name": "--mount",
23+
"description": "Mount the host audio directory read-only at /audio in the container.",
24+
"value": "type=bind,src={audio_directory},dst=/audio,readonly",
25+
"variables": {
26+
"audio_directory": {
27+
"description": "Host directory containing audio files. Use /audio/<file> when calling the tool.",
28+
"format": "filepath",
29+
"isRequired": true,
30+
"placeholder": "/path/to/audio"
31+
}
32+
}
33+
},
34+
{
35+
"type": "named",
36+
"name": "--mount",
37+
"description": "Persist downloaded ModelScope model files between container runs.",
38+
"value": "type=volume,src=funasr-mcp-cache,dst=/root/.cache/modelscope"
39+
}
40+
],
41+
"transport": {
42+
"type": "stdio"
43+
}
44+
}
45+
]
46+
}

examples/mcp_server/smoke_test.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import json
4+
import subprocess
5+
6+
7+
REQUESTS = [
8+
{
9+
"jsonrpc": "2.0",
10+
"id": 1,
11+
"method": "initialize",
12+
"params": {
13+
"protocolVersion": "2024-11-05",
14+
"capabilities": {},
15+
"clientInfo": {"name": "container-smoke-test", "version": "1.0"},
16+
},
17+
},
18+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
19+
]
20+
21+
22+
def validate_responses(responses):
23+
by_id = {response.get("id"): response for response in responses}
24+
if set(by_id) != {1, 2}:
25+
raise ValueError(f"expected response IDs 1 and 2, got {sorted(by_id)}")
26+
27+
server_info = by_id[1].get("result", {}).get("serverInfo", {})
28+
if server_info.get("name") != "funasr":
29+
raise ValueError(f"unexpected serverInfo: {server_info}")
30+
31+
tools = by_id[2].get("result", {}).get("tools", [])
32+
tool_names = {tool.get("name") for tool in tools}
33+
if "transcribe_audio" not in tool_names:
34+
raise ValueError(f"transcribe_audio missing from tools/list: {tool_names}")
35+
36+
37+
def run_smoke_test(image, timeout):
38+
payload = "".join(f"{json.dumps(request)}\n" for request in REQUESTS)
39+
completed = subprocess.run(
40+
["docker", "run", "--rm", "-i", image],
41+
input=payload,
42+
text=True,
43+
capture_output=True,
44+
check=False,
45+
timeout=timeout,
46+
)
47+
if completed.returncode != 0:
48+
raise RuntimeError(
49+
f"container exited with {completed.returncode}: {completed.stderr.strip()}"
50+
)
51+
52+
responses = [
53+
json.loads(line) for line in completed.stdout.splitlines() if line.strip()
54+
]
55+
validate_responses(responses)
56+
57+
58+
def main():
59+
parser = argparse.ArgumentParser(description="Smoke-test the FunASR MCP image")
60+
parser.add_argument("image", help="Local or remote container image reference")
61+
parser.add_argument("--timeout", type=int, default=60)
62+
args = parser.parse_args()
63+
64+
run_smoke_test(args.image, args.timeout)
65+
print(f"MCP container smoke test passed: {args.image}")
66+
67+
68+
if __name__ == "__main__":
69+
main()

0 commit comments

Comments
 (0)