"""
用途:
扫描 MCP 服务器目录,为每个子目录生成 RPM spec 文件,并在需要时生成
mcp-rpm.yaml;同时打包源码为 tar.gz 并清理目录,只保留 tar.gz 与 .spec。
使用方法:
1) 配置可选的 GitCode Token(用于补全仓库描述、许可证、话题等信息):
export GITCODE_TOKEN=xxxx
2) 执行当前代码
注意事项:
- 该脚本会删除每个服务器目录中的 .git/.venv/.env 等文件,并在最后清理
目录,仅保留生成的 .spec 与 .tar.gz;请先备份或在副本目录上运行。
- 处理目录由 run() 内的 servers_dir 变量指定。
"""
import argparse
import os
import glob
import yaml
from datetime import datetime
import subprocess
import requests
from typing import Dict, Optional
from dotenv import load_dotenv
import logging
from string import Template
import tarfile
import shutil
from pathlib import Path
SPEC_TEMPLATE = Template(r"""Name: $pkg_name
Version: $version
Release: 1
Summary: $summary
License: $license
URL: $url
Source0: %{name}-%{version}.tar.gz
BuildArch: noarch
BuildRequires: python3-devel
BuildRequires: python3-setuptools
Requires: python3
Requires: uv
Requires: python3-mcp
$extra_requires
%description
$description
%prep
%setup -q -n %{name}-%{version}
%build
%install
mkdir -p %{buildroot}/opt/mcp-servers/servers/$server_name
mkdir -p %{buildroot}/opt/mcp-servers/servers/$server_name/src
cp -r src/* %{buildroot}/opt/mcp-servers/servers/$server_name/src/ || :
cp README.md %{buildroot}/opt/mcp-servers/servers/$server_name/README.md || :
cp mcp_config.json %{buildroot}/opt/mcp-servers/servers/$server_name/ || :
cp pyproject.toml %{buildroot}/opt/mcp-servers/servers/$server_name/ || :
cp requirements.txt %{buildroot}/opt/mcp-servers/servers/$server_name/ || :
%post
SERVER_DIR="/opt/mcp-servers/servers/$server_name"
VENV_DIR="$${SERVER_DIR}/.venv"
PYPROJECT_FILE="$${SERVER_DIR}/pyproject.toml"
REQUIREMENTS_FILE="$${SERVER_DIR}/requirements.txt"
PYPI_MIRROR="https://mirrors.aliyun.com/pypi/simple/"
CREATE_VENV=0
if [ -f "$${PYPROJECT_FILE}" ]; then
cd "$${SERVER_DIR}"
CREATE_VENV=1
uv venv "$${VENV_DIR}" --python /bin/python3 --system-site-packages || {
echo "ERROR: 虚拟环境创建失败(pyproject.toml 路径:$${PYPROJECT_FILE})" >&2
exit 1
}
. "$${VENV_DIR}/bin/activate"
uv sync --index "$${PYPI_MIRROR}" --frozen || {
echo "WARNING: UV 依赖同步失败,尝试用 pip 降级安装" >&2
"$${VENV_DIR}/bin/python" -m pip install . -i "$${PYPI_MIRROR}" || exit 1
}
elif [ -f "$${REQUIREMENTS_FILE}" ]; then
cd "$${SERVER_DIR}"
CREATE_VENV=1
uv venv "$${VENV_DIR}" --python /bin/python3 --system-site-packages || {
echo "ERROR: 虚拟环境创建失败(requirements.txt 路径:$${REQUIREMENTS_FILE})" >&2
exit 1
}
. "$${VENV_DIR}/bin/activate"
"$${VENV_DIR}/bin/python" -m pip install --upgrade pip -i "$${PYPI_MIRROR}" || exit 1
"$${VENV_DIR}/bin/python" -m pip install -r "$${REQUIREMENTS_FILE}" -i "$${PYPI_MIRROR}" || {
echo "ERROR: requirements.txt 依赖安装失败" >&2
exit 1
}
fi
if [ "$${CREATE_VENV}" -eq 1 ]; then
find "$${VENV_DIR}" -type d -exec chmod 755 {} \;
find "$${VENV_DIR}" -type f ! -path "$${VENV_DIR}/bin/*" -exec chmod 644 {} \;
find "$${VENV_DIR}/bin" -type f -exec chmod 755 {} \;
chown -R root:root "$${VENV_DIR}"
fi
if [ "$${CREATE_VENV}" -eq 0 ]; then
echo "INFO: 未检测到 pyproject.toml 或 requirements.txt,跳过虚拟环境创建" >&2
fi
%postun
rm -rf "/opt/mcp-servers/servers/$server_name/.venv"
%files
%defattr(-,root,root,-)
/opt/mcp-servers/servers/$server_name/
%changelog
* $date openEuler MCP Team <mcp@openeuler.org> - $version-1
- Initial package for $pkg_name
""")
def _fmt_requires(config: dict) -> str:
deps = config.get("dependencies", {}) or {}
system_deps = deps.get("system", []) or []
package_deps = deps.get("packages", []) or []
all_deps = [d for d in (system_deps + package_deps) if d]
if not all_deps:
return []
return all_deps
def process_folder(server_dir, pkg_version, pkg_name=None):
server_dir = Path(server_dir)
server_dir = server_dir.resolve()
server_name = server_dir.name
package_name = pkg_name or server_name
tarball = server_dir / f"{package_name}-{pkg_version}.tar.gz"
inner_top_dir = f"{package_name}-{pkg_version}"
print("=" * 40)
print(f"开始处理服务器:{server_name}")
print("-" * 40)
print("1. 清理临时文件...")
for temp_file in ["uv.lock", ".venv", ".env", ".git", ".gitignore", ".gitattributes", "mcp-test-results"]:
temp_path = server_dir / temp_file
try:
if temp_path.is_file():
temp_path.unlink()
elif temp_path.is_dir():
shutil.rmtree(temp_path)
except FileNotFoundError:
pass
print(" ✅ 已清理 uv.lock/.venv/.env(无则忽略)")
print(f"2. 开始打包(压缩包内顶层目录:{inner_top_dir})...")
final_pack_files = []
for item in server_dir.iterdir():
if item == tarball or item.suffix == ".spec":
continue
final_pack_files.append(item)
if not final_pack_files:
print(" ⚠️ 该目录下无可用文件,创建空压缩包...")
with tarfile.open(tarball, "w:gz") as tar:
pass
else:
with tarfile.open(tarball, "w:gz") as tar:
for file_path in final_pack_files:
arcname = os.path.join(inner_top_dir, file_path.name)
tar.add(str(file_path), arcname=arcname)
print(f" ✅ 压缩包生成完成:{tarball}")
print(" 📌 压缩包内路径示例:")
try:
with tarfile.open(tarball, "r:gz") as tar:
for idx, member in enumerate(tar.getmembers()):
if idx >= 5:
break
print(f" {member.name}")
except:
print(" (空压缩包)")
print("3. 清理冗余文件,仅保留tar包和spec文件...")
keep_files = [tarball]
keep_files.extend(server_dir.glob("*.spec"))
for item in server_dir.iterdir():
if item in keep_files:
continue
try:
if item.is_file():
item.unlink()
elif item.is_dir():
shutil.rmtree(item)
except:
pass
print(" ✅ 清理完成,当前目录仅保留:")
has_files = False
for item in server_dir.iterdir():
has_files = True
print(f" {item.name}")
if not has_files:
print(" (目录仅保留空tar包)")
def generate_specs(server_dir,process_flag=False):
server_name = os.path.basename(server_dir)
logging.info(f"Processing server: {server_name} (dir: {server_dir})")
yaml_file = os.path.join(server_dir, "mcp-rpm.yaml")
if not os.path.exists(yaml_file):
logging.warning(f"mcp-rpm.yaml not found in {server_dir}, use default config")
config = {}
else:
with open(yaml_file, "r", encoding="utf-8") as f:
config = yaml.safe_load(f) or {}
pkg_name = config.get("name", server_name)
version = config.get("version", "1.0.0")
summary = config.get("summary", f"MCP server: {server_name}")
description = config.get("description", summary)
license_ = config.get("license", "MIT")
url = config.get("url", "https://gitcode.com/mcp-servers/%s"%(server_name))
extra_requires_pkgs = [pk for pk in _fmt_requires(config) if pk not in ["python3-devel", "python3-setuptools", "python3", "uv", "python3-mcp"]]
if extra_requires_pkgs:
extra_requires = "\n".join([f"Requires: {d}" for d in extra_requires_pkgs])
spec_text = SPEC_TEMPLATE.substitute(
pkg_name=pkg_name,
version=version,
summary=summary,
description=description,
license=license_,
url=url,
server_name=server_name,
extra_requires=extra_requires,
date=datetime.now().strftime("%a %b %d %Y"),
)
out_path = os.path.join(server_dir, f"{pkg_name}.spec")
with open(out_path, "w", encoding="utf-8") as outf:
outf.write(spec_text)
logging.info(f"Generated spec file: {out_path}")
if process_flag:
process_folder(server_dir, pkg_version=version, pkg_name=pkg_name)
def get_github_repo_api_url(server_dir: str) -> str:
"""
从 Git 仓库目录解析 GitHub API 地址
:param server_dir: Git 仓库本地目录路径
:return: GitHub Repo API 地址(如 https://api.github.com/repos/user/repo)
:raises: subprocess.CalledProcessError (git命令执行失败)、ValueError (URL格式不支持)
"""
try:
cmd = ['git', '-C', server_dir, 'remote', 'get-url', 'origin']
remote_url = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT).strip()
except subprocess.CalledProcessError as e:
raise RuntimeError(f"执行git命令失败: {e.output.strip()}") from e
part: str = ""
if remote_url.startswith('git@github.com:'):
part = remote_url[len('git@github.com:'):]
elif remote_url.startswith('https://github.com/'):
part = remote_url[len('https://github.com/'):]
else:
raise ValueError(f"不支持的 GitHub URL 格式: {remote_url}(仅支持 git@github.com: 或 https://github.com/ 开头)")
if part.endswith('.git'):
part = part[:-4]
api_url = f'https://api.github.com/repos/{part}'
return api_url
def get_repo_info(server_dir: str) -> Dict[str, any]:
"""
获取 GitHub 仓库的详细信息(描述、地址、许可证、星数、话题等)
:param server_dir: Git 仓库本地目录路径
:return: 仓库信息字典
:raises: RuntimeError (API 请求失败)
"""
api_url = get_github_repo_api_url(server_dir)
token: Optional[str] = os.getenv('GITCODE_TOKEN')
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28"
}
if token:
headers['Authorization'] = f'token {token}'
try:
resp = requests.get(api_url, headers=headers, timeout=15)
resp.raise_for_status()
data = resp.json()
except requests.exceptions.RequestException as e:
raise RuntimeError(f"获取仓库基础信息失败: {str(e)}") from e
topics = []
topics_api_url = f"{api_url}/topics"
try:
resp_topics = requests.get(topics_api_url, headers=headers, timeout=15)
if resp_topics.status_code == 200:
topics = resp_topics.json().get('names', [])
else:
print(f"警告:获取话题失败(状态码 {resp_topics.status_code}),跳过")
except requests.exceptions.RequestException as e:
print(f"警告:获取话题时网络异常: {str(e)},跳过")
info = {
'description': data.get('description', 'No description provided'),
'html_url': data.get('html_url', ''),
'license': (data.get('license') or {}).get('name', 'MIT'),
'stargazers_count': data.get('stargazers_count', 0),
'topics': topics,
}
return info
def create_mcp_yaml(server_dir):
dir_name = os.path.basename(os.path.abspath(server_dir))
mcp_yaml = {
'name': dir_name,
'summary': f'MCP server for {dir_name}',
'version': '1.0.0',
'release': '1',
'dependencies': {
'system': ['python3', 'uv', 'python3-mcp', 'jq'],
'packages': [],
},
'files': {
'required': ['mcp_config.json'],
'optional': [],
},
'install_scripts': {
'pre': [],
'post': [],
'preun': [],
'postun': [],
}
}
gitdir = os.path.join(server_dir, '.git')
if os.path.isdir(gitdir):
repo_info = get_repo_info(server_dir)
mcp_yaml['description'] = f"{repo_info['description']}\nGitHub: {repo_info['html_url']}"
mcp_yaml['license'] = repo_info.get('license', 'MIT')
else:
mcp_yaml['description'] = f"MCP tools for {dir_name}"
mcp_yaml['license'] = 'MIT'
with open(os.path.join(server_dir, 'mcp-rpm.yaml'), 'w') as f:
yaml.safe_dump(mcp_yaml, f, sort_keys=False, allow_unicode=True)
def build_parser():
parser = argparse.ArgumentParser(
description="Generate MCP RPM spec files and tarballs"
)
parser.add_argument(
"--servers-dir",
default=None,
help="Directory containing MCP server subdirectories.",
)
parser.add_argument(
"--server-dir",
default=None,
help="Single MCP server directory to process.",
)
parser.add_argument(
"--work-dir",
required=True,
help="Working directory to copy servers into before processing.",
)
return parser
def run(servers_dir: str | None, server_dir: str | None, work_dir: str):
load_dotenv()
if bool(servers_dir) == bool(server_dir):
raise ValueError("Provide exactly one of --servers-dir or --server-dir")
source_dirs = []
if servers_dir:
if not os.path.exists(servers_dir):
raise FileNotFoundError(f"Target directory not found at: {servers_dir}")
if os.path.abspath(servers_dir) == os.path.abspath(work_dir):
raise ValueError("work-dir must be different from servers-dir")
source_dirs = [
d for d in glob.glob(os.path.join(servers_dir, "*"))
if os.path.isdir(d)
]
if not source_dirs:
raise FileNotFoundError(f"No subdirectories found in {servers_dir}")
logging.info(f"Found {len(source_dirs)} subdirectories to process")
else:
if not os.path.exists(server_dir):
raise FileNotFoundError(f"Target directory not found at: {server_dir}")
if os.path.abspath(server_dir) == os.path.abspath(work_dir):
raise ValueError("work-dir must be different from server-dir")
source_dirs = [server_dir]
logging.info("Processing single server dir: %s", server_dir)
os.makedirs(work_dir, exist_ok=True)
server_subdirs = []
for src_dir in source_dirs:
dst_dir = os.path.join(work_dir, os.path.basename(src_dir))
if os.path.exists(dst_dir):
raise FileExistsError(f"work-dir already has: {dst_dir}")
shutil.copytree(src_dir, dst_dir)
server_subdirs.append(dst_dir)
for server_dir in server_subdirs:
if not os.path.exists(os.path.join(server_dir,"mcp-rpm.yaml")):
create_mcp_yaml(server_dir)
generate_specs(server_dir, process_flag=True)
if __name__ == "__main__":
try:
parser = build_parser()
args = parser.parse_args()
run(args.servers_dir, args.server_dir, args.work_dir)
except Exception as e:
logging.error(f"Script failed: {str(e)}", exc_info=True)
exit(1)