Skip to content

Commit fc378c3

Browse files
feat: add project-level Codex MCP configuration (#1398)
Add project MCP configuration, launchers, local configuration templates, and offline regression tests for TAPD, Playwright, and DBHub.
1 parent 9a407ea commit fc378c3

14 files changed

Lines changed: 372 additions & 0 deletions

File tree

‎.codex/README.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# SQLBot 开发用 MCP
2+
3+
项目级 MCP 布局,提供 TAPD、Playwright 和 DBHub 启动器。
4+
这里服务于 Codex 开发任务,与 SQLBot 产品对外提供的 MCP 接口无关。
5+
6+
## 配置与启用
7+
8+
[Codex 官方 MCP 文档](https://developers.openai.com/codex/mcp)支持受信任项目使用 `.codex/config.toml`。
9+
本仓库三个服务配置为 `enabled = true`。首次使用前需完成本地配置;暂不使用的服务应设为 `enabled = false`,避免缺少配置时启动失败。
10+
11+
1. 在本机安装需要的 MCP 服务,记录其入口文件位置;启动器不会自动安装依赖。
12+
2. 将相应目录的 `.env.example` 复制为同目录 `.env.local`,填写自己的入口、解释器和项目配置。
13+
3. 在 `.codex/config.toml` 中确认需要的服务为 `enabled = true`,其余设为 `false`;从 SQLBot 仓库根启动新的 Codex 会话,并检查 MCP 连接状态。
14+
15+
配置中的启动脚本路径相对于仓库根。不要从子目录启动这些相对路径命令。
16+
`.env.local` 使用 `KEY="value"` 格式,值必须是 JSON 字符串;不支持 shell 展开、单引号或行尾注释。
17+
未知字段、重复字段和缺失必填值会导致启动失败,错误消息不回显配置值。
18+
本地配置、浏览器资料及输出已被 Git 忽略;建议将 `.env.local` 权限设为 `600`。
19+
不要复制其他项目的凭据、项目 ID 或浏览器会话。
20+
21+
## 服务约定
22+
23+
- **TAPD**:填写 SQLBot 对应的 `TAPD_WORKSPACE_ID`、个人用户名、令牌和入口。配置保留参考项目的 Node/Python 两种实现的读取工具白名单,不开放写入工具。Python `mcp-server-tapd` 调用仍须显式传本项目 `workspace_id`;启动器不会自动注入每次调用。不要使用会另行加载其他个人凭据的包装入口。
24+
- **Playwright**:使用支持 Chrome、`--user-data-dir` 和 `--output-dir` 参数的 `@playwright/mcp` 入口,并准备本机 Chrome。浏览器资料位于 `playwright_mcp/browser-profile/`,截图等产物位于 `playwright_mcp/output/`。`TEST_BASE_URL` 是本机示例,需按实际测试环境调整;`TEST_*` 仅供执行测试时按需使用,启动器不会自动登录。
25+
- **DBHub**:沿用参考项目针对 `@bytebase/dbhub@1.3.1` 的配置格式,入口须支持 `--config`。示例 DSN 已改为 PostgreSQL/sqlbot,仅用于展示格式。DSN 通过环境变量传递,`dbhub.toml` 为 `execute_sql` 设置 `readonly = true` 和最多 1000 行;数据库账号本身也必须只读。只在任务授权的测试环境使用,不用生产或共享业务库做配置验证。
26+
27+
## 运行时
28+
29+
外层启动器需要 PATH 中的 Python 3.9+。`mcp_runtime.py` 统一处理配置和入口解析:
30+
31+
- `MCP_RUNTIME="auto"` 根据 `.js/.cjs/.mjs`、`.py` 或 Node/Python shebang 识别运行时,也可明确指定 `node` 或 `python`。
32+
- MCP 入口须为已存在的绝对路径;`NODE_BIN` / `PYTHON_BIN` 可为绝对路径或 PATH 命令名。桌面应用 PATH 可能与终端不同,推荐在本地配置中填写解释器绝对路径。
33+
- Python MCP 使用其安装环境的解释器。Python TAPD 将 `TAPD_API_TOKEN` 映射为 `TAPD_ACCESS_TOKEN`,将用户名映射为 `CURRENT_USER_NICK`,并使用 stdio。
34+
- 运行时识别不保证不同 MCP 实现的命令行参数和工具 API 兼容。Playwright、DBHub 模板面向上述 Node 实现。
35+
36+
## 验证
37+
38+
从仓库根运行:
39+
40+
```bash
41+
python3 -B backend/tests/test_codex_mcp_runtime.py
42+
```
43+
44+
离线测试覆盖配置校验、解释器选择和启动参数/环境变量传递,不连接外部服务。
45+
配置解析或离线测试通过不代表真实 MCP 握手、工具调用或数据库只读权限已经验证。

‎.codex/config.toml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# 填写对应 .env.local 后,按需将 enabled 改为 true。
2+
[mcp_servers.tapd]
3+
command = "python3"
4+
args = [".codex/tapd_mcp/launch.py"]
5+
enabled = true
6+
startup_timeout_sec = 20
7+
tool_timeout_sec = 60
8+
enabled_tools = ["tapd_get_current_user", "tapd_parse_url", "tapd_get_story", "tapd_list_stories", "tapd_get_task", "tapd_list_tasks", "tapd_get_bug", "tapd_list_bugs", "tapd_get_iteration", "tapd_list_iterations", "tapd_list_comments", "tapd_get_story_related_bugs", "tapd_get_story_changes", "tapd_list_workspace_users", "get_stories_or_tasks", "get_story_or_task_count", "get_bug", "get_bug_count", "get_comments", "get_iterations", "get_related_bugs", "get_workspace_users", "get_entity_custom_fields", "get_entity_attachments", "get_image", "get_workflows_status_map", "get_workitem_types"]
9+
10+
[mcp_servers.playwright]
11+
command = "python3"
12+
args = [".codex/playwright_mcp/launch.py"]
13+
enabled = true
14+
startup_timeout_sec = 30
15+
tool_timeout_sec = 120
16+
17+
[mcp_servers.dbhub]
18+
command = "python3"
19+
args = [".codex/dbhub_mcp/launch.py"]
20+
enabled = true
21+
startup_timeout_sec = 30
22+
tool_timeout_sec = 120

‎.codex/dbhub_mcp/.env.example‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
DBHUB_DSN="postgres://readonly_user:password@127.0.0.1:5432/sqlbot"
2+
DBHUB_MCP_ENTRY="/absolute/path/to/@bytebase/dbhub/dist/index.js"
3+
NODE_BIN="node"
4+
5+
# auto 根据扩展名或 shebang 判断;也可指定 node / python
6+
MCP_RUNTIME="auto"
7+
# Python 入口请使用安装该 MCP 的虚拟环境解释器
8+
PYTHON_BIN="python3"

‎.codex/dbhub_mcp/dbhub.toml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[[sources]]
2+
id = "sqlbot"
3+
dsn = "${DSN}"
4+
5+
[[tools]]
6+
name = "execute_sql"
7+
source = "sqlbot"
8+
readonly = true
9+
max_rows = 1000
10+
11+
[[tools]]
12+
name = "search_objects"
13+
source = "sqlbot"

‎.codex/dbhub_mcp/launch.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Launch DBHub using the existing read-only configuration."""
2+
import os
3+
import sys
4+
from pathlib import Path
5+
6+
sys.dont_write_bytecode = True
7+
base = Path(__file__).resolve().parent
8+
sys.path.insert(0, str(base.parent))
9+
from mcp_runtime import execute, load_config, resolve_command
10+
11+
try:
12+
local = load_config(base, {'DBHUB_DSN', 'DBHUB_MCP_ENTRY'}, {'DBHUB_DSN', 'DBHUB_MCP_ENTRY'})
13+
_, command = resolve_command(local, 'DBHUB_MCP_ENTRY')
14+
env = os.environ.copy()
15+
env['DSN'] = local['DBHUB_DSN']
16+
command.extend(['--config', str(base / 'dbhub.toml')])
17+
execute(command, env)
18+
except (OSError, ValueError, KeyError, TypeError):
19+
sys.exit('DBHub MCP 启动失败;请检查 .codex/dbhub_mcp/.env.local 的字段、入口及解释器')

‎.codex/mcp_runtime.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Shared local MCP configuration and runtime resolution (Python 3.9+)."""
2+
import json
3+
import os
4+
import shutil
5+
from pathlib import Path
6+
7+
RUNTIME_KEYS = {'MCP_RUNTIME', 'NODE_BIN', 'PYTHON_BIN'}
8+
9+
10+
def load_config(base, allowed, required):
11+
local = {}
12+
for number, raw in enumerate((base / '.env.local').read_text().splitlines(), 1):
13+
line = raw.strip()
14+
if not line or line.startswith('#'):
15+
continue
16+
key, sep, value = line.partition('=')
17+
key = key.strip()
18+
if not sep or key not in allowed | RUNTIME_KEYS or key in local:
19+
raise ValueError(f'配置第 {number} 行存在未知或重复字段')
20+
try:
21+
parsed = json.loads(value.strip())
22+
except ValueError:
23+
raise ValueError(f'配置第 {number} 行必须使用 JSON 字符串') from None
24+
if not isinstance(parsed, str):
25+
raise ValueError(f'配置第 {number} 行必须使用字符串')
26+
local[key] = parsed
27+
for key in required:
28+
if not local.get(key):
29+
raise ValueError(f'缺少配置字段 {key}')
30+
return local
31+
32+
33+
def resolve_command(local, entry_key):
34+
entry = Path(local[entry_key]).expanduser()
35+
if not entry.is_absolute() or not entry.is_file():
36+
raise ValueError(f'{entry_key} 必须是存在的文件绝对路径')
37+
runtime = local.get('MCP_RUNTIME') or 'auto'
38+
if runtime == 'auto':
39+
if entry.suffix in {'.js', '.cjs', '.mjs'}:
40+
runtime = 'node'
41+
elif entry.suffix == '.py':
42+
runtime = 'python'
43+
else:
44+
with entry.open('rb') as stream:
45+
shebang = stream.readline(512)
46+
if shebang.startswith(b'#!') and b'python' in shebang:
47+
runtime = 'python'
48+
elif shebang.startswith(b'#!') and b'node' in shebang:
49+
runtime = 'node'
50+
else:
51+
raise ValueError('无法判断入口类型,请明确设置 MCP_RUNTIME')
52+
if runtime not in {'node', 'python'}:
53+
raise ValueError('MCP_RUNTIME 仅支持 auto、node、python')
54+
key, fallback = ('NODE_BIN', 'node') if runtime == 'node' else ('PYTHON_BIN', 'python3')
55+
binary = shutil.which(str(Path(local.get(key) or fallback).expanduser()))
56+
if not binary:
57+
raise ValueError(f'{key} 不是可执行文件,请指定实际解释器路径')
58+
return runtime, [binary, str(entry)]
59+
60+
61+
def execute(command, env):
62+
os.execvpe(command[0], command, env)

‎.codex/playwright_mcp/.env.example‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
PLAYWRIGHT_MCP_ENTRY="/absolute/path/to/@playwright/mcp/cli.js"
2+
NODE_BIN="node"
3+
TEST_BASE_URL="http://127.0.0.1:8000"
4+
TEST_USERNAME=""
5+
TEST_PASSWORD=""
6+
7+
# auto 根据扩展名或 shebang 判断;也可指定 node / python
8+
MCP_RUNTIME="auto"
9+
# Python 入口请使用安装该 MCP 的虚拟环境解释器
10+
PYTHON_BIN="python3"

‎.codex/playwright_mcp/launch.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Launch Playwright MCP with project-local browser storage."""
2+
import os
3+
import sys
4+
from pathlib import Path
5+
6+
sys.dont_write_bytecode = True
7+
base = Path(__file__).resolve().parent
8+
sys.path.insert(0, str(base.parent))
9+
from mcp_runtime import execute, load_config, resolve_command
10+
11+
try:
12+
local = load_config(base, {'PLAYWRIGHT_MCP_ENTRY', 'TEST_BASE_URL', 'TEST_USERNAME', 'TEST_PASSWORD'},
13+
{'PLAYWRIGHT_MCP_ENTRY'})
14+
_, command = resolve_command(local, 'PLAYWRIGHT_MCP_ENTRY')
15+
profile = base / 'browser-profile'
16+
output = base / 'output'
17+
profile.mkdir(exist_ok=True, mode=0o700)
18+
output.mkdir(exist_ok=True, mode=0o700)
19+
command.extend(['--browser', 'chrome', '--user-data-dir', str(profile), '--output-dir', str(output)])
20+
execute(command, os.environ.copy())
21+
except (OSError, ValueError, KeyError, TypeError):
22+
sys.exit('Playwright MCP 启动失败;请检查 .codex/playwright_mcp/.env.local 的字段、入口及解释器')

‎.codex/tapd_mcp/.env.example‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
TAPD_WORKSPACE_ID=""
2+
TAPD_CURRENT_USER=""
3+
TAPD_API_TOKEN=""
4+
TAPD_MCP_ENTRY="/absolute/path/to/tapd-mcp/dist/index.js"
5+
NODE_BIN="node"
6+
7+
# auto 根据扩展名或 shebang 判断;也可指定 node / python
8+
MCP_RUNTIME="auto"
9+
# Python 入口请使用安装该 MCP 的虚拟环境解释器
10+
PYTHON_BIN="python3"
11+
12+
# Python mcp-server-tapd:入口填写虚拟环境 bin/mcp-server-tapd
13+
# PYTHON_BIN 填写同一虚拟环境 bin/python,令牌仍填写 TAPD_API_TOKEN
14+
TAPD_API_BASE_URL="https://api.tapd.cn"
15+
TAPD_BASE_URL="https://www.tapd.cn"

‎.codex/tapd_mcp/launch.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Launch Node or Python TAPD MCP using only project-local credentials."""
2+
import os
3+
import sys
4+
from pathlib import Path
5+
6+
sys.dont_write_bytecode = True
7+
base = Path(__file__).resolve().parent
8+
sys.path.insert(0, str(base.parent))
9+
from mcp_runtime import execute, load_config, resolve_command
10+
11+
try:
12+
keys = {'TAPD_WORKSPACE_ID', 'TAPD_CURRENT_USER', 'TAPD_API_TOKEN',
13+
'TAPD_MCP_ENTRY', 'TAPD_API_BASE_URL', 'TAPD_BASE_URL'}
14+
local = load_config(base, keys, {'TAPD_WORKSPACE_ID', 'TAPD_CURRENT_USER',
15+
'TAPD_API_TOKEN', 'TAPD_MCP_ENTRY'})
16+
runtime, command = resolve_command(local, 'TAPD_MCP_ENTRY')
17+
env = os.environ.copy()
18+
for key in ('TAPD_API_USER', 'TAPD_API_PASSWORD', 'TAPD_ACCESS_TOKEN',
19+
'TAPD_API_BASE_URL', 'TAPD_BASE_URL', 'CURRENT_USER_NICK'):
20+
env.pop(key, None)
21+
env.update({key: local[key] for key in ('TAPD_WORKSPACE_ID', 'TAPD_CURRENT_USER', 'TAPD_API_TOKEN')})
22+
if runtime == 'python':
23+
env['TAPD_ACCESS_TOKEN'] = local['TAPD_API_TOKEN']
24+
env['CURRENT_USER_NICK'] = local['TAPD_CURRENT_USER']
25+
env['TAPD_API_BASE_URL'] = local.get('TAPD_API_BASE_URL') or 'https://api.tapd.cn'
26+
env['TAPD_BASE_URL'] = local.get('TAPD_BASE_URL') or 'https://www.tapd.cn'
27+
command.extend(['--mode', 'stdio'])
28+
else:
29+
for key in ('TAPD_API_BASE_URL', 'TAPD_BASE_URL'):
30+
if local.get(key):
31+
env[key] = local[key]
32+
execute(command, env)
33+
except (OSError, ValueError, KeyError, TypeError):
34+
sys.exit('TAPD MCP 启动失败;请检查 .codex/tapd_mcp/.env.local 的字段、入口及解释器')

0 commit comments

Comments
 (0)