jackson 2 nedēļas atpakaļ
revīzija
f0ff40881c

+ 34 - 0
.env.example

@@ -0,0 +1,34 @@
+# 如果 auth 与 tools 在同一个域名,可只保留 FMS_API_BASE
+# FMS_API_BASE=http://chenjiacheng.base.dahuo.fudingri.com
+
+# 当前本地开发推荐:同一个 IP,不同域名由 hosts + Web 服务器 Host 分发到不同项目。
+# base 项目:负责 auth_code / mcp_token 授权换票
+FMS_AUTH_BASE=http://chenjiacheng.base.dahuo.fudingri.com
+
+# fmsoperate 项目:负责 query_order 等工具接口
+FMS_TOOLS_BASE=http://chenjiacheng.fmsoperate.dahuo.fudingri.com
+
+FMS_CLIENT_TYPE=workbuddy
+FMS_TIMEOUT_SECONDS=10
+FMS_REFRESH_SKEW_SECONDS=120
+FMS_LOG_LEVEL=info
+
+# 推荐:共享 app.py 场景下使用 Redis 保存 token,不要把 token 文件放到 \192.168.1.241\mcp\ 共享目录。
+FMS_TOKEN_STORE=redis
+FMS_REDIS_HOST=192.168.1.241
+FMS_REDIS_PORT=6379
+FMS_REDIS_DB=20
+FMS_REDIS_PASSWORD=
+FMS_REDIS_PREFIX=fms:mcp:workbuddy:
+
+# 通常不要在共享 .env 里写死 FMS_SESSION_KEY。
+# Gateway 会自动用员工本机 COMPUTERNAME/USERNAME 生成隔离 key。
+# 只有在单机排障时才手动指定,例如:
+# FMS_SESSION_KEY=pc-name:windows-user
+
+# 如果临时退回文件存储,再改为:
+# FMS_TOKEN_STORE=file
+# FMS_TOKEN_STORE_PATH=C:/fms-mcp/.mcp_token.json
+
+# Workbuddy 启动脚本路径使用网络 UNC:\\192.168.1.241\mcp\app.py
+# 不要使用开发机映射盘路径 Y:\mcp\app.py。

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+__pycache__
+/project-docs
+.env

+ 196 - 0
README.md

@@ -0,0 +1,196 @@
+# Python MCP Gateway
+
+这是一个轻量的 Python MCP Gateway,用来把 Workbuddy 的 MCP 工具调用转发到现有 ThinkPHP 授权接口和工具接口。
+
+## 当前状态
+
+当前仓库已经具备一个可运行的 stdio MCP 入口,以及一套经过测试的核心模块,覆盖:
+- `.env` 优先的配置加载
+- Redis token 存储,按员工本机 `session_key` 隔离
+- 文件型 token 存储兜底
+- `auth_code` 换票、续期、失效的认证客户端
+- `bind_auth_code` 授权绑定工具
+- 工具 HTTP 转发客户端
+- `query_order` 工具注册
+- Gateway 运行时会话管理
+- MCP `initialize` / `tools/list` / `tools/call` 请求处理
+- 本地命令模式下的 stdio MCP 启动入口
+
+当前实现仍然保持“薄网关”原则:
+- 业务认证和权限判断仍然留在 ThinkPHP 项目中
+- Gateway 只负责协议适配、会话管理和 HTTP 转发
+- 第一阶段只开放查询型工具,当前从 `query_order` 开始
+
+## Workbuddy 配置
+
+员工侧 Workbuddy 使用网络 UNC 路径启动 Gateway:
+
+```json
+{
+  "mcpServers": {
+    "fms": {
+      "command": "python",
+      "args": [
+        "\\\\192.168.1.241\\mcp\\app.py",
+        "serve-stdio"
+      ]
+    }
+  }
+}
+```
+
+不要把 `Y:\mcp\app.py` 写进 Workbuddy 配置;那只是开发机映射盘视角。
+
+## 常用命令
+
+以下命令在 `mcp/` 目录下执行。
+
+### 查看工具列表
+
+```powershell
+python app.py list-tools
+```
+
+### 绑定授权码(开发/排障)
+
+```powershell
+python app.py bind --auth-code AUTH123
+```
+
+员工正式使用时不需要执行这条命令。Workbuddy 拉起 `serve-stdio` 后,员工应在 Workbuddy 中调用 `bind_auth_code` 工具完成绑定。
+
+### 直接调用 query_order(开发/排障)
+
+```powershell
+python app.py call --tool query_order --keyword SO20260706001 --page 1 --limit 20
+```
+
+### 启动 MCP stdio 服务
+
+```powershell
+python \\192.168.1.241\chenjiacheng\mcp\app.py serve-stdio
+```
+
+这个命令可作为 Workbuddy 本地命令模式下的 MCP 服务入口,由 Workbuddy 自动拉起,不要求员工手动执行。
+
+## 环境变量
+
+当前代码直接支持以下文档口径的配置键:
+- `FMS_API_BASE`
+- `FMS_AUTH_BASE`
+- `FMS_TOOLS_BASE`
+- `FMS_CLIENT_TYPE`
+- `FMS_TIMEOUT_MS`
+- `FMS_TIMEOUT_SECONDS`
+- `FMS_REFRESH_SKEW_SECONDS`
+- `FMS_LOG_LEVEL`
+- `FMS_TOKEN_STORE`
+- `FMS_TOKEN_STORE_PATH`
+- `FMS_REDIS_HOST`
+- `FMS_REDIS_PORT`
+- `FMS_REDIS_DB`
+- `FMS_REDIS_PASSWORD`
+- `FMS_REDIS_PREFIX`
+- `FMS_SESSION_KEY`
+
+同时兼容早期实现中的这些键名:
+- `MCP_AUTH_BASE_URL`
+- `MCP_TOOLS_BASE_URL`
+- `MCP_CLIENT_TYPE`
+- `MCP_TIMEOUT_SECONDS`
+- `MCP_REFRESH_SKEW_SECONDS`
+- `MCP_LOG_LEVEL`
+- `MCP_TOKEN_STORE`
+- `MCP_TOKEN_STORE_PATH`
+- `MCP_REDIS_HOST`
+- `MCP_REDIS_PORT`
+- `MCP_REDIS_DB`
+- `MCP_REDIS_PASSWORD`
+- `MCP_REDIS_PREFIX`
+- `MCP_SESSION_KEY`
+
+说明:
+- `config.py` 会自动尝试读取 `mcp/.env`。
+- 同名配置以 `.env` 优先,系统环境变量只在 `.env` 缺少该配置时兜底。
+- 如果只提供 `FMS_API_BASE`,当前实现会同时把它作为 auth 和 tools 的基础地址。
+- 如果 `base` 与 `fmsoperate` 部署在不同域名,应分别提供 `FMS_AUTH_BASE` 与 `FMS_TOOLS_BASE`。
+
+## .env 文件
+
+当前 `mcp/` 目录建议使用 `.env` 保存共享 Gateway 配置。
+
+推荐配置如下:
+
+```dotenv
+FMS_AUTH_BASE=http://chenjiacheng.base.dahuo.fudingri.com
+FMS_TOOLS_BASE=http://chenjiacheng.fmsoperate.dahuo.fudingri.com
+FMS_CLIENT_TYPE=workbuddy
+FMS_TIMEOUT_SECONDS=10
+FMS_REFRESH_SKEW_SECONDS=120
+FMS_LOG_LEVEL=info
+
+FMS_TOKEN_STORE=redis
+FMS_REDIS_HOST=192.168.1.241
+FMS_REDIS_PORT=6379
+FMS_REDIS_DB=20
+FMS_REDIS_PASSWORD=
+FMS_REDIS_PREFIX=fms:mcp:workbuddy:
+```
+
+不要在共享 `.env` 中写死 `FMS_SESSION_KEY`。默认情况下 Gateway 会用员工本机的 `COMPUTERNAME` / `USERNAME` 自动生成稳定 `session_key`,Redis key 形如:
+
+```text
+fms:mcp:workbuddy:{session_key}
+```
+
+这样多个员工通过同一个 `\\192.168.1.241\chenjiacheng\mcp\app.py` 启动时,也不会共用同一个 token。
+
+## Token 存储
+
+当前推荐使用 Redis:
+
+```dotenv
+FMS_TOKEN_STORE=redis
+```
+
+Redis 存储行为:
+- `bind_auth_code` 成功后把 `mcp_token` 写入 Redis
+- Redis key 使用 `FMS_REDIS_PREFIX + session_key`
+- `session_key` 优先读取 `FMS_SESSION_KEY`,未配置时自动从员工本机信息生成
+- token 过期时间会同步设置为 Redis TTL
+- `revoke` 成功后会删除当前 session 的 Redis key
+
+文件存储仍保留为兜底方式:
+
+```dotenv
+FMS_TOKEN_STORE=file
+FMS_TOKEN_STORE_PATH=C:/fms-mcp/.mcp_token.json
+```
+
+如果使用网络共享 `app.py`,不要把 `FMS_TOKEN_STORE_PATH` 指向 `\\192.168.1.241\chenjiacheng\mcp\` 共享目录。
+
+## 本地接入建议
+
+当前员工正式使用路径是:
+1. 员工在后台获取一次性 `auth_code`
+2. Workbuddy 自动通过 `python \\192.168.1.241\chenjiacheng\mcp\app.py serve-stdio` 拉起 Gateway
+3. 员工在 Workbuddy 中调用 `bind_auth_code`,输入授权码完成绑定
+4. Gateway 调用 `exchange` 时带上 `session_key`
+5. token 保存到 Redis 当前员工对应的 key
+6. 后续直接使用 `query_order` 等工具
+
+`python app.py bind --auth-code ...` 仅保留给开发、联调和排障使用。
+
+## 测试命令
+
+```powershell
+python -m unittest discover -s tests -p "test_*.py"
+```
+
+## 当前边界
+
+当前 Gateway 已可作为本地 stdio MCP 进程使用,但仍保持最小边界:
+- 不在 Python 中沉淀额外业务规则
+- 不直接连接业务数据库
+- 不做动态插件加载
+- 第一阶段不开放写入型工具

+ 158 - 0
app.py

@@ -0,0 +1,158 @@
+import argparse
+import json
+import sys
+import uuid
+
+from config import GatewayConfig
+from mcp_protocol import McpProtocolHandler
+from services.api_client import ApiClient
+from services.auth_client import AuthClient
+from services.token_store import FileTokenStore, RedisSocketClient, RedisTokenStore
+from tools.bind_auth_code import BindAuthCodeTool
+from tools.query_order import QueryOrderTool
+
+
+BIND_HINT = '请先登录后台获取 Workbuddy 授权码,然后在 Workbuddy 中输入“绑定授权码 xxx”完成绑定。'
+
+
+class GatewayApp:
+    def __init__(self, auth_client=None, api_client=None, token_store=None):
+        self.auth_client = auth_client
+        self.api_client = api_client
+        self.token_store = token_store
+        self._tools = {
+            'bind_auth_code': BindAuthCodeTool(auth_client=auth_client),
+            'query_order': QueryOrderTool(api_client=api_client),
+        }
+
+    @classmethod
+    def from_config(cls, config, redis_client=None):
+        if config.token_store_type == 'redis':
+            redis = redis_client or RedisSocketClient(
+                host=config.redis_host,
+                port=config.redis_port,
+                db=config.redis_db,
+                password=config.redis_password,
+                timeout=config.timeout_seconds,
+            )
+            token_store = RedisTokenStore(
+                redis,
+                prefix=config.redis_prefix,
+                session_key=config.session_key,
+                refresh_skew_seconds=config.refresh_skew_seconds,
+            )
+        elif config.token_store_type == 'file':
+            token_store = FileTokenStore(
+                config.token_store_path,
+                refresh_skew_seconds=config.refresh_skew_seconds,
+            )
+        else:
+            raise ValueError('unsupported token store type: {0}'.format(config.token_store_type))
+
+        auth_client = AuthClient(
+            base_url=config.auth_base_url,
+            client_type=config.client_type,
+            token_store=token_store,
+            timeout=config.timeout_seconds,
+            session_key=config.session_key,
+        )
+        api_client = ApiClient(
+            base_url=config.tools_base_url,
+            token_store=token_store,
+            timeout=config.timeout_seconds,
+        )
+        return cls(auth_client=auth_client, api_client=api_client, token_store=token_store)
+
+    def list_tools(self):
+        return [tool.metadata() for tool in self._tools.values()]
+
+    def bind(self, auth_code):
+        if self.auth_client is None:
+            raise RuntimeError('auth client is required for bind')
+        auth_code = str(auth_code).strip()
+        if not auth_code:
+            raise ValueError('auth_code is required')
+        return self.auth_client.exchange(auth_code)
+
+    def build_request_id(self, request_id=''):
+        request_id = str(request_id or '').strip()
+        if request_id:
+            return request_id
+        return 'rq_{0}'.format(uuid.uuid4().hex[:16])
+
+    def ensure_session(self):
+        if self.token_store is None:
+            return
+        session = self.token_store.get()
+        if not session or not session.get('token'):
+            raise RuntimeError(BIND_HINT)
+        if self.token_store.is_expiring():
+            if self.auth_client is None:
+                raise RuntimeError('mcp token expiring but auth client missing')
+            self.auth_client.refresh(session['token'])
+
+    def call_tool(self, name, arguments=None, request_id=''):
+        if name not in self._tools:
+            raise KeyError('tool not registered: {0}'.format(name))
+        tool = self._tools[name]
+        if getattr(tool, 'requires_session', True):
+            self.ensure_session()
+        arguments = arguments or {}
+        request_id = self.build_request_id(request_id)
+        return tool.call(request_id=request_id, **arguments)
+
+    def create_protocol_handler(self):
+        return McpProtocolHandler(self)
+
+    def run_cli(self, argv=None, stdin=None, stdout=None):
+        stdin = stdin or sys.stdin
+        stdout = stdout or sys.stdout
+        parser = argparse.ArgumentParser(prog='mcp-gateway')
+        subparsers = parser.add_subparsers(dest='command', required=True)
+
+        subparsers.add_parser('list-tools')
+        subparsers.add_parser('serve-stdio')
+
+        bind_parser = subparsers.add_parser('bind')
+        bind_parser.add_argument('--auth-code', required=True)
+
+        call_parser = subparsers.add_parser('call')
+        call_parser.add_argument('--tool', required=True)
+        call_parser.add_argument('--keyword', required=True)
+        call_parser.add_argument('--page', type=int, default=1)
+        call_parser.add_argument('--limit', type=int, default=20)
+        call_parser.add_argument('--request-id', default='')
+
+        args = parser.parse_args(argv or [])
+
+        if args.command == 'list-tools':
+            payload = self.list_tools()
+        elif args.command == 'serve-stdio':
+            return self.create_protocol_handler().run_stdio(stdin=stdin, stdout=stdout)
+        elif args.command == 'bind':
+            payload = self.bind(args.auth_code)
+        elif args.command == 'call':
+            payload = self.call_tool(
+                args.tool,
+                {
+                    'keyword': args.keyword,
+                    'page': args.page,
+                    'limit': args.limit,
+                },
+                request_id=args.request_id,
+            )
+        else:
+            raise RuntimeError('unsupported command')
+
+        stdout.write(json.dumps(payload, ensure_ascii=False))
+        return 0
+
+
+def main(argv=None):
+    config = GatewayConfig.from_env()
+    app = GatewayApp.from_config(config)
+    return app.run_cli(argv=argv)
+
+
+if __name__ == '__main__':
+    raise SystemExit(main(sys.argv[1:]))

+ 129 - 0
config.py

@@ -0,0 +1,129 @@
+from dataclasses import dataclass
+import os
+
+
+@dataclass
+class GatewayConfig:
+    auth_base_url: str
+    tools_base_url: str
+    client_type: str = 'workbuddy'
+    timeout_seconds: int = 10
+    refresh_skew_seconds: int = 120
+    log_level: str = 'info'
+    token_store_path: str = '.mcp_token.json'
+    token_store_type: str = 'file'
+    redis_host: str = '127.0.0.1'
+    redis_port: int = 6379
+    redis_db: int = 0
+    redis_password: str = ''
+    redis_prefix: str = 'fms:mcp:workbuddy:'
+    session_key: str = ''
+
+    @classmethod
+    def from_env(cls, env=None, dotenv_path=''):
+        primary_env = os.environ if env is None else env
+        dotenv_path = str(dotenv_path or '').strip() or os.path.join(os.path.dirname(__file__), '.env')
+        dotenv_env = cls._load_dotenv(dotenv_path)
+        dotenv_shared_base = (dotenv_env.get('FMS_API_BASE') or '').rstrip('/')
+        env_shared_base = (primary_env.get('FMS_API_BASE') or '').rstrip('/')
+        auth_base_url = cls._resolve_base_url(
+            dotenv_env,
+            primary_env,
+            dotenv_shared_base,
+            env_shared_base,
+            'MCP_AUTH_BASE_URL',
+            'FMS_AUTH_BASE',
+        )
+        tools_base_url = cls._resolve_base_url(
+            dotenv_env,
+            primary_env,
+            dotenv_shared_base,
+            env_shared_base,
+            'MCP_TOOLS_BASE_URL',
+            'FMS_TOOLS_BASE',
+        )
+        timeout_seconds = cls._resolve_timeout_seconds(dotenv_env, primary_env)
+        session_key = cls._pick(dotenv_env, primary_env, 'MCP_SESSION_KEY', 'FMS_SESSION_KEY')
+        return cls(
+            auth_base_url=auth_base_url,
+            tools_base_url=tools_base_url,
+            client_type=cls._pick(dotenv_env, primary_env, 'MCP_CLIENT_TYPE', 'FMS_CLIENT_TYPE') or 'workbuddy',
+            timeout_seconds=timeout_seconds,
+            refresh_skew_seconds=int(cls._pick(dotenv_env, primary_env, 'MCP_REFRESH_SKEW_SECONDS', 'FMS_REFRESH_SKEW_SECONDS') or '120'),
+            log_level=(cls._pick(dotenv_env, primary_env, 'MCP_LOG_LEVEL', 'FMS_LOG_LEVEL') or 'info').lower(),
+            token_store_path=cls._pick(dotenv_env, primary_env, 'MCP_TOKEN_STORE_PATH', 'FMS_TOKEN_STORE_PATH') or '.mcp_token.json',
+            token_store_type=(cls._pick(dotenv_env, primary_env, 'MCP_TOKEN_STORE', 'FMS_TOKEN_STORE') or 'file').lower(),
+            redis_host=cls._pick(dotenv_env, primary_env, 'MCP_REDIS_HOST', 'FMS_REDIS_HOST') or '127.0.0.1',
+            redis_port=int(cls._pick(dotenv_env, primary_env, 'MCP_REDIS_PORT', 'FMS_REDIS_PORT') or '6379'),
+            redis_db=int(cls._pick(dotenv_env, primary_env, 'MCP_REDIS_DB', 'FMS_REDIS_DB') or '0'),
+            redis_password=cls._pick(dotenv_env, primary_env, 'MCP_REDIS_PASSWORD', 'FMS_REDIS_PASSWORD') or '',
+            redis_prefix=cls._pick(dotenv_env, primary_env, 'MCP_REDIS_PREFIX', 'FMS_REDIS_PREFIX') or 'fms:mcp:workbuddy:',
+            session_key=session_key or cls._build_default_session_key(primary_env),
+        )
+
+    @staticmethod
+    def _resolve_base_url(preferred_env, fallback_env, preferred_shared_base, fallback_shared_base, *specific_keys):
+        for key in specific_keys:
+            value = preferred_env.get(key)
+            if value:
+                return value.rstrip('/')
+        if preferred_shared_base:
+            return preferred_shared_base
+        for key in specific_keys:
+            value = fallback_env.get(key)
+            if value:
+                return value.rstrip('/')
+        return fallback_shared_base
+
+    @staticmethod
+    def _resolve_timeout_seconds(preferred_env, fallback_env):
+        if preferred_env.get('MCP_TIMEOUT_SECONDS'):
+            return int(preferred_env['MCP_TIMEOUT_SECONDS'])
+        if preferred_env.get('FMS_TIMEOUT_SECONDS'):
+            return int(preferred_env['FMS_TIMEOUT_SECONDS'])
+        if preferred_env.get('FMS_TIMEOUT_MS'):
+            return max(1, int(preferred_env['FMS_TIMEOUT_MS']) // 1000)
+        if fallback_env.get('MCP_TIMEOUT_SECONDS'):
+            return int(fallback_env['MCP_TIMEOUT_SECONDS'])
+        if fallback_env.get('FMS_TIMEOUT_SECONDS'):
+            return int(fallback_env['FMS_TIMEOUT_SECONDS'])
+        if fallback_env.get('FMS_TIMEOUT_MS'):
+            return max(1, int(fallback_env['FMS_TIMEOUT_MS']) // 1000)
+        return 10
+
+    @staticmethod
+    def _pick(preferred_env, fallback_env, *keys):
+        for key in keys:
+            value = preferred_env.get(key)
+            if value:
+                return value
+        for key in keys:
+            value = fallback_env.get(key)
+            if value:
+                return value
+        return ''
+
+    @staticmethod
+    def _build_default_session_key(env):
+        computer = env.get('COMPUTERNAME') or env.get('HOSTNAME') or os.environ.get('COMPUTERNAME') or os.environ.get('HOSTNAME') or 'unknown-computer'
+        user = env.get('USERNAME') or env.get('USER') or os.environ.get('USERNAME') or os.environ.get('USER') or 'unknown-user'
+        domain = env.get('USERDOMAIN') or os.environ.get('USERDOMAIN') or ''
+        parts = [domain, computer, user] if domain else [computer, user]
+        return ':'.join(str(part).strip().replace(' ', '_') for part in parts if str(part).strip())
+
+    @staticmethod
+    def _load_dotenv(dotenv_path):
+        data = {}
+        if not dotenv_path or not os.path.exists(dotenv_path):
+            return data
+        with open(dotenv_path, 'r', encoding='utf-8') as file:
+            for raw_line in file:
+                line = raw_line.strip()
+                if not line or line.startswith('#') or '=' not in line:
+                    continue
+                key, value = line.split('=', 1)
+                key = key.strip()
+                value = value.strip().strip('"').strip("'")
+                if key:
+                    data[key] = value
+        return data

+ 134 - 0
mcp_protocol.py

@@ -0,0 +1,134 @@
+import json
+import sys
+
+
+class McpProtocolHandler:
+    protocol_version = '2025-06-18'
+    server_name = 'fms-mcp-gateway'
+    server_version = '0.1.0'
+
+    def __init__(self, gateway_app):
+        self.gateway_app = gateway_app
+        self.initialized = False
+
+    def handle_message(self, message):
+        if not isinstance(message, dict):
+            return self._error_response(None, -32600, 'Invalid Request')
+        if 'id' in message:
+            return self.handle_request(message)
+        method = str(message.get('method') or '').strip()
+        if method == 'notifications/initialized':
+            self.initialized = True
+            return None
+        return None
+
+    def handle_request(self, request):
+        request_id = request.get('id')
+        method = str(request.get('method') or '').strip()
+        try:
+            if method == 'initialize':
+                self.initialized = True
+                return self._success_response(
+                    request_id,
+                    {
+                        'protocolVersion': self.protocol_version,
+                        'capabilities': {
+                            'tools': {
+                                'listChanged': False,
+                            }
+                        },
+                        'serverInfo': {
+                            'name': self.server_name,
+                            'version': self.server_version,
+                        },
+                    },
+                )
+            if method == 'tools/list':
+                tools = [self._normalize_tool(tool) for tool in self.gateway_app.list_tools()]
+                return self._success_response(request_id, {'tools': tools})
+            if method == 'tools/call':
+                params = request.get('params') or {}
+                tool_name = params.get('name')
+                arguments = params.get('arguments') or {}
+                tool_result = self.gateway_app.call_tool(tool_name, arguments)
+                structured_content = tool_result.get('data') or {}
+                return self._success_response(
+                    request_id,
+                    {
+                        'content': [
+                            {
+                                'type': 'text',
+                                'text': self._render_text(structured_content),
+                            }
+                        ],
+                        'structuredContent': structured_content,
+                        'isError': False,
+                    },
+                )
+            return self._error_response(request_id, -32601, 'Method not found: {0}'.format(method or '<empty>'))
+        except Exception as exc:
+            if method == 'tools/call':
+                return self._success_response(
+                    request_id,
+                    {
+                        'content': [
+                            {
+                                'type': 'text',
+                                'text': str(exc),
+                            }
+                        ],
+                        'isError': True,
+                    },
+                )
+            return self._error_response(request_id, -32000, str(exc))
+
+    def run_stdio(self, stdin=None, stdout=None):
+        stdin = stdin or sys.stdin
+        stdout = stdout or sys.stdout
+        for raw_line in stdin:
+            line = str(raw_line).strip()
+            if not line:
+                continue
+            try:
+                message = json.loads(line)
+            except ValueError:
+                response = self._error_response(None, -32700, 'Parse error')
+            else:
+                response = self.handle_message(message)
+            if response is None:
+                continue
+            stdout.write(json.dumps(response, ensure_ascii=False) + '\n')
+            if hasattr(stdout, 'flush'):
+                stdout.flush()
+        return 0
+
+    def _normalize_tool(self, tool):
+        normalized = dict(tool)
+        if 'input_schema' in normalized:
+            normalized['inputSchema'] = normalized.pop('input_schema')
+        return normalized
+
+    @staticmethod
+    def _render_text(structured_content):
+        if not structured_content:
+            return 'ok'
+        return json.dumps(structured_content, ensure_ascii=False)
+
+    @staticmethod
+    def _success_response(request_id, result):
+        return {
+            'jsonrpc': '2.0',
+            'id': request_id,
+            'result': result,
+        }
+
+    @staticmethod
+    def _error_response(request_id, code, message):
+        return {
+            'jsonrpc': '2.0',
+            'id': request_id,
+            'error': {
+                'code': code,
+                'message': message,
+            },
+        }

+ 1 - 0
requirements.txt

@@ -0,0 +1 @@
+# Core gateway modules currently use only the Python standard library.

+ 1 - 0
services/__init__.py

@@ -0,0 +1 @@
+

+ 32 - 0
services/api_client.py

@@ -0,0 +1,32 @@
+import json
+import urllib.request
+
+
+class JsonTransport:
+    def post_json(self, url, payload, headers, timeout):
+        request = urllib.request.Request(
+            url=url,
+            data=json.dumps(payload).encode('utf-8'),
+            headers=dict({'Content-Type': 'application/json'}, **headers),
+            method='POST',
+        )
+        with urllib.request.urlopen(request, timeout=timeout) as response:
+            return json.loads(response.read().decode('utf-8'))
+
+
+class ApiClient:
+    def __init__(self, base_url, token_store, transport=None, timeout=10):
+        self.base_url = (base_url or '').rstrip('/')
+        self.token_store = token_store
+        self.transport = transport or JsonTransport()
+        self.timeout = int(timeout)
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        token = self.token_store.require_token()
+        url = self.base_url + '/' + route_path.lstrip('/')
+        headers = {
+            'Authorization': 'Bearer {0}'.format(token),
+            'X-MCP-Tool-Code': tool_code,
+            'X-Request-Id': request_id,
+        }
+        return self.transport.post_json(url, payload, headers, self.timeout)

+ 52 - 0
services/auth_client.py

@@ -0,0 +1,52 @@
+from services.api_client import JsonTransport
+
+
+class AuthClient:
+    def __init__(self, base_url, client_type, token_store, transport=None, timeout=10, session_key=''):
+        self.base_url = (base_url or '').rstrip('/')
+        self.client_type = client_type
+        self.token_store = token_store
+        self.transport = transport or JsonTransport()
+        self.timeout = int(timeout)
+        self.session_key = str(session_key or '').strip()
+
+    def exchange(self, auth_code):
+        payload = {'auth_code': auth_code, 'client_type': self.client_type}
+        if self.session_key:
+            payload['session_key'] = self.session_key
+        response = self.transport.post_json(
+            self.base_url + '/admin/mcp/auth/exchange',
+            payload,
+            {},
+            self.timeout,
+        )
+        self._persist_token(response)
+        return response
+
+    def refresh(self, mcp_token):
+        response = self.transport.post_json(
+            self.base_url + '/admin/mcp/auth/refresh',
+            {'mcp_token': mcp_token},
+            {'Authorization': 'Bearer {0}'.format(mcp_token)},
+            self.timeout,
+        )
+        self._persist_token(response)
+        return response
+
+    def revoke(self, mcp_token):
+        response = self.transport.post_json(
+            self.base_url + '/admin/mcp/auth/revoke',
+            {'mcp_token': mcp_token},
+            {'Authorization': 'Bearer {0}'.format(mcp_token)},
+            self.timeout,
+        )
+        if (response.get('code') or '') == 'MCP_0000':
+            self.token_store.clear()
+        return response
+
+    def _persist_token(self, response):
+        data = response.get('data') or {}
+        token = data.get('mcp_token')
+        expire_time = data.get('expire_time')
+        if token and expire_time:
+            self.token_store.save(token, expire_time)

+ 191 - 0
services/token_store.py

@@ -0,0 +1,191 @@
+from datetime import datetime, timedelta
+import json
+import os
+import socket
+
+
+class InMemoryTokenStore:
+    def __init__(self, refresh_skew_seconds=120):
+        self.refresh_skew_seconds = int(refresh_skew_seconds)
+        self._session = None
+
+    def save(self, token, expire_time):
+        self._session = {
+            'token': token,
+            'expire_time': expire_time,
+        }
+        return self._session
+
+    def get(self):
+        return self._session
+
+    def clear(self):
+        self._session = None
+
+    def is_expiring(self):
+        if not self._session:
+            return True
+        expire_time = self._session.get('expire_time')
+        if not expire_time:
+            return True
+        if isinstance(expire_time, str) and expire_time.endswith('Z'):
+            expire_time = expire_time[:-1] + '+00:00'
+        expires_at = datetime.fromisoformat(str(expire_time))
+        return expires_at <= datetime.now(expires_at.tzinfo) + timedelta(seconds=self.refresh_skew_seconds)
+
+    def require_token(self):
+        if not self._session or not self._session.get('token'):
+            raise RuntimeError('mcp token missing')
+        return self._session['token']
+
+
+class FileTokenStore(InMemoryTokenStore):
+    def __init__(self, path, refresh_skew_seconds=120):
+        super().__init__(refresh_skew_seconds=refresh_skew_seconds)
+        self.path = path
+        self._session = self._read_session()
+
+    def save(self, token, expire_time):
+        session = super().save(token, expire_time)
+        directory = os.path.dirname(self.path)
+        if directory:
+            os.makedirs(directory, exist_ok=True)
+        with open(self.path, 'w', encoding='utf-8') as handle:
+            json.dump(session, handle, ensure_ascii=False)
+        return session
+
+    def get(self):
+        if self._session is None and os.path.exists(self.path):
+            self._session = self._read_session()
+        return self._session
+
+    def clear(self):
+        super().clear()
+        if os.path.exists(self.path):
+            os.remove(self.path)
+
+    def _read_session(self):
+        if not os.path.exists(self.path):
+            return None
+        with open(self.path, 'r', encoding='utf-8') as handle:
+            return json.load(handle)
+
+
+class RedisTokenStore(InMemoryTokenStore):
+    def __init__(self, client, prefix='fms:mcp:workbuddy:', session_key='', refresh_skew_seconds=120):
+        super().__init__(refresh_skew_seconds=refresh_skew_seconds)
+        session_key = str(session_key or '').strip()
+        if not session_key:
+            raise ValueError('session_key is required for RedisTokenStore')
+        self.client = client
+        self.prefix = str(prefix or 'fms:mcp:workbuddy:')
+        self.session_key = session_key
+        self.key = self.prefix + self.session_key
+
+    def save(self, token, expire_time):
+        session = super().save(token, expire_time)
+        payload = json.dumps(session, ensure_ascii=False)
+        ttl = self._ttl_seconds(expire_time)
+        self.client.set(self.key, payload, ex=ttl if ttl and ttl > 0 else None)
+        return session
+
+    def get(self):
+        raw = self.client.get(self.key)
+        if raw is None or raw == '':
+            self._session = None
+            return None
+        if isinstance(raw, bytes):
+            raw = raw.decode('utf-8')
+        self._session = json.loads(raw)
+        return self._session
+
+    def clear(self):
+        super().clear()
+        self.client.delete(self.key)
+
+    def is_expiring(self):
+        self.get()
+        return super().is_expiring()
+
+    @staticmethod
+    def _ttl_seconds(expire_time):
+        if not expire_time:
+            return None
+        value = expire_time[:-1] + '+00:00' if isinstance(expire_time, str) and expire_time.endswith('Z') else expire_time
+        expires_at = datetime.fromisoformat(str(value))
+        now = datetime.now(expires_at.tzinfo) if expires_at.tzinfo else datetime.now()
+        return max(1, int((expires_at - now).total_seconds()))
+
+
+class RedisSocketClient:
+    def __init__(self, host='127.0.0.1', port=6379, db=0, password='', timeout=5):
+        self.host = host
+        self.port = int(port)
+        self.db = int(db)
+        self.password = password or ''
+        self.timeout = int(timeout)
+
+    def set(self, key, value, ex=None):
+        command = ['SET', key, value]
+        if ex is not None:
+            command.extend(['EX', int(ex)])
+        return self._execute(*command) == 'OK'
+
+    def get(self, key):
+        return self._execute('GET', key)
+
+    def delete(self, key):
+        return self._execute('DEL', key)
+
+    def _execute(self, *parts):
+        with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
+            stream = sock.makefile('rb')
+            if self.password:
+                self._send(sock, 'AUTH', self.password)
+                self._read_response(stream)
+            if self.db:
+                self._send(sock, 'SELECT', self.db)
+                self._read_response(stream)
+            self._send(sock, *parts)
+            return self._read_response(stream)
+
+    @staticmethod
+    def _send(sock, *parts):
+        payload = ['*{0}\r\n'.format(len(parts)).encode('utf-8')]
+        for part in parts:
+            if isinstance(part, bytes):
+                data = part
+            else:
+                data = str(part).encode('utf-8')
+            payload.append('${0}\r\n'.format(len(data)).encode('utf-8'))
+            payload.append(data + b'\r\n')
+        sock.sendall(b''.join(payload))
+
+    def _read_response(self, stream):
+        prefix = stream.read(1)
+        if not prefix:
+            raise RuntimeError('empty redis response')
+        if prefix == b'+':
+            return self._read_line(stream).decode('utf-8')
+        if prefix == b'-':
+            raise RuntimeError(self._read_line(stream).decode('utf-8'))
+        if prefix == b':':
+            return int(self._read_line(stream))
+        if prefix == b'$':
+            length = int(self._read_line(stream))
+            if length == -1:
+                return None
+            data = stream.read(length)
+            stream.read(2)
+            return data.decode('utf-8')
+        if prefix == b'*':
+            length = int(self._read_line(stream))
+            return [self._read_response(stream) for _ in range(length)]
+        raise RuntimeError('unsupported redis response: {0}'.format(prefix))
+
+    @staticmethod
+    def _read_line(stream):
+        line = stream.readline()
+        if not line.endswith(b'\r\n'):
+            raise RuntimeError('invalid redis line')
+        return line[:-2]

+ 115 - 0
tests/test_auth_client.py

@@ -0,0 +1,115 @@
+import os
+import os
+import tempfile
+import unittest
+
+from config import GatewayConfig
+from services.auth_client import AuthClient
+from services.token_store import InMemoryTokenStore
+
+
+class DummyTransport:
+    def __init__(self):
+        self.calls = []
+
+    def post_json(self, url, payload, headers, timeout):
+        self.calls.append(
+            {
+                'url': url,
+                'payload': payload,
+                'headers': headers,
+                'timeout': timeout,
+            }
+        )
+        if url.endswith('/admin/mcp/auth/exchange'):
+            return {
+                'code': 'MCP_0000',
+                'msg': 'success',
+                'data': {
+                    'mcp_token': 'MT_exchange',
+                    'expire_time': '2099-01-01T00:00:00',
+                },
+            }
+        if url.endswith('/admin/mcp/auth/refresh'):
+            return {
+                'code': 'MCP_0000',
+                'msg': 'success',
+                'data': {
+                    'mcp_token': 'MT_refresh',
+                    'expire_time': '2099-01-02T00:00:00',
+                },
+            }
+        return {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {},
+        }
+
+
+class AuthClientTest(unittest.TestCase):
+    def test_gateway_config_reads_expected_env_keys(self):
+        env = {
+            'MCP_AUTH_BASE_URL': 'http://base.example.test',
+            'MCP_TOOLS_BASE_URL': 'http://tools.example.test',
+            'MCP_CLIENT_TYPE': 'workbuddy',
+            'MCP_TIMEOUT_SECONDS': '9',
+            'MCP_REFRESH_SKEW_SECONDS': '120',
+        }
+
+        config = GatewayConfig.from_env(env, dotenv_path=os.path.join(tempfile.gettempdir(), 'missing-auth-client.env'))
+
+        self.assertEqual('http://base.example.test', config.auth_base_url)
+        self.assertEqual('http://tools.example.test', config.tools_base_url)
+        self.assertEqual('workbuddy', config.client_type)
+        self.assertEqual(9, config.timeout_seconds)
+        self.assertEqual(120, config.refresh_skew_seconds)
+
+    def test_auth_client_uses_auth_routes_and_updates_token_store(self):
+        transport = DummyTransport()
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        client = AuthClient(
+            base_url='http://base.example.test',
+            client_type='workbuddy',
+            token_store=store,
+            transport=transport,
+            timeout=9,
+        )
+
+        exchange = client.exchange('AUTH123')
+        refresh = client.refresh('MT_exchange')
+        self.assertEqual('MT_refresh', store.get()['token'])
+        revoke = client.revoke('MT_refresh')
+
+        self.assertEqual('MT_exchange', exchange['data']['mcp_token'])
+        self.assertEqual('MT_refresh', refresh['data']['mcp_token'])
+        self.assertIsNone(store.get())
+        self.assertEqual({}, revoke['data'])
+        self.assertEqual('http://base.example.test/admin/mcp/auth/exchange', transport.calls[0]['url'])
+        self.assertEqual('http://base.example.test/admin/mcp/auth/refresh', transport.calls[1]['url'])
+        self.assertEqual('http://base.example.test/admin/mcp/auth/revoke', transport.calls[2]['url'])
+        self.assertEqual({'auth_code': 'AUTH123', 'client_type': 'workbuddy'}, transport.calls[0]['payload'])
+        self.assertEqual({'mcp_token': 'MT_exchange'}, transport.calls[1]['payload'])
+        self.assertEqual({'mcp_token': 'MT_refresh'}, transport.calls[2]['payload'])
+
+    def test_refresh_and_revoke_send_bearer_header_and_revoke_clears_token(self):
+        transport = DummyTransport()
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        store.save('MT_exchange', '2099-01-01T00:00:00')
+        client = AuthClient(
+            base_url='http://base.example.test',
+            client_type='workbuddy',
+            token_store=store,
+            transport=transport,
+            timeout=9,
+        )
+
+        client.refresh('MT_exchange')
+        client.revoke('MT_refresh')
+
+        self.assertEqual('Bearer MT_exchange', transport.calls[0]['headers']['Authorization'])
+        self.assertEqual('Bearer MT_refresh', transport.calls[1]['headers']['Authorization'])
+        self.assertIsNone(store.get())
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 121 - 0
tests/test_bind_auth_code_tool.py

@@ -0,0 +1,121 @@
+import unittest
+
+from app import GatewayApp
+from mcp_protocol import McpProtocolHandler
+from services.token_store import InMemoryTokenStore
+
+
+class DummyAuthClient:
+    def __init__(self, token_store):
+        self.token_store = token_store
+        self.exchange_calls = []
+
+    def exchange(self, auth_code):
+        self.exchange_calls.append(auth_code)
+        self.token_store.save('MT_bound_secret', '2099-01-01T00:00:00')
+        return {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'mcp_token': 'MT_bound_secret',
+                'expire_time': '2099-01-01T00:00:00',
+            },
+        }
+
+
+class DummyApiClient:
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        return {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'summary': 'ok',
+                'records': [],
+                'tips': [],
+            },
+            'meta': {
+                'request_id': request_id,
+            },
+        }
+
+
+class BindAuthCodeToolTest(unittest.TestCase):
+    def build_handler(self, with_token=False):
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        if with_token:
+            store.save('MT_ready', '2099-01-01T00:00:00')
+        auth_client = DummyAuthClient(store)
+        app = GatewayApp(
+            auth_client=auth_client,
+            api_client=DummyApiClient(),
+            token_store=store,
+        )
+        return McpProtocolHandler(app), auth_client, store
+
+    def test_tools_list_includes_bind_auth_code(self):
+        handler, _, _ = self.build_handler()
+
+        response = handler.handle_request(
+            {
+                'jsonrpc': '2.0',
+                'id': 1,
+                'method': 'tools/list',
+                'params': {},
+            }
+        )
+
+        names = [tool['name'] for tool in response['result']['tools']]
+        self.assertIn('bind_auth_code', names)
+        bind_tool = [tool for tool in response['result']['tools'] if tool['name'] == 'bind_auth_code'][0]
+        self.assertEqual(['auth_code'], bind_tool['inputSchema']['required'])
+
+    def test_bind_auth_code_exchanges_code_and_hides_token(self):
+        handler, auth_client, store = self.build_handler()
+
+        response = handler.handle_request(
+            {
+                'jsonrpc': '2.0',
+                'id': 2,
+                'method': 'tools/call',
+                'params': {
+                    'name': 'bind_auth_code',
+                    'arguments': {
+                        'auth_code': ' AUTH123 ',
+                    },
+                },
+            }
+        )
+
+        self.assertFalse(response['result']['isError'])
+        self.assertEqual(['AUTH123'], auth_client.exchange_calls)
+        self.assertEqual('MT_bound_secret', store.get()['token'])
+        text = response['result']['content'][0]['text']
+        structured = response['result']['structuredContent']
+        self.assertIn('绑定成功', text)
+        self.assertNotIn('MT_bound_secret', text)
+        self.assertNotIn('mcp_token', structured)
+        self.assertEqual('bound', structured['status'])
+
+    def test_query_order_without_token_returns_employee_binding_hint(self):
+        handler, _, _ = self.build_handler(with_token=False)
+
+        response = handler.handle_request(
+            {
+                'jsonrpc': '2.0',
+                'id': 3,
+                'method': 'tools/call',
+                'params': {
+                    'name': 'query_order',
+                    'arguments': {
+                        'keyword': 'SO20260706001',
+                    },
+                },
+            }
+        )
+
+        self.assertTrue(response['result']['isError'])
+        self.assertIn('请先登录后台获取 Workbuddy 授权码', response['result']['content'][0]['text'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 140 - 0
tests/test_cli_and_file_store.py

@@ -0,0 +1,140 @@
+import io
+import json
+import os
+import tempfile
+import unittest
+
+from app import GatewayApp
+from services.token_store import FileTokenStore
+
+
+class DummyAuthClient:
+    def __init__(self, token_store):
+        self.token_store = token_store
+        self.exchange_calls = []
+
+    def exchange(self, auth_code):
+        self.exchange_calls.append(auth_code)
+        self.token_store.save('MT_bound', '2099-01-01T00:00:00')
+        return {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'mcp_token': 'MT_bound',
+                'expire_time': '2099-01-01T00:00:00',
+            },
+        }
+
+
+class DummyApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.calls.append(
+            {
+                'tool_code': tool_code,
+                'route_path': route_path,
+                'payload': payload,
+                'request_id': request_id,
+            }
+        )
+        return {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'summary': 'ok',
+                'records': [],
+                'tips': [],
+            },
+            'meta': {
+                'request_id': request_id,
+            },
+        }
+
+
+class CliAndFileStoreTest(unittest.TestCase):
+    def test_file_token_store_persists_session_across_instances(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            path = os.path.join(tmp_dir, 'token.json')
+            first = FileTokenStore(path, refresh_skew_seconds=60)
+            first.save('MT_file', '2099-01-01T00:00:00')
+
+            second = FileTokenStore(path, refresh_skew_seconds=60)
+
+            self.assertEqual('MT_file', second.get()['token'])
+            second.clear()
+            self.assertIsNone(second.get())
+            self.assertFalse(os.path.exists(path))
+
+    def test_run_cli_bind_and_call_emit_json(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            path = os.path.join(tmp_dir, 'token.json')
+            token_store = FileTokenStore(path, refresh_skew_seconds=60)
+            auth_client = DummyAuthClient(token_store)
+            api_client = DummyApiClient()
+            app = GatewayApp(
+                auth_client=auth_client,
+                api_client=api_client,
+                token_store=token_store,
+            )
+
+            bind_stdout = io.StringIO()
+            bind_code = app.run_cli(['bind', '--auth-code', 'AUTH123'], stdout=bind_stdout)
+            bind_payload = json.loads(bind_stdout.getvalue())
+
+            call_stdout = io.StringIO()
+            call_code = app.run_cli([
+                'call',
+                '--tool', 'query_order',
+                '--keyword', 'SO20260706001',
+                '--page', '2',
+                '--limit', '15',
+            ], stdout=call_stdout)
+            call_payload = json.loads(call_stdout.getvalue())
+
+            self.assertEqual(0, bind_code)
+            self.assertEqual('MCP_0000', bind_payload['code'])
+            self.assertEqual(0, call_code)
+            self.assertEqual('MCP_0000', call_payload['code'])
+            self.assertEqual(['AUTH123'], auth_client.exchange_calls)
+            self.assertEqual('query_order', api_client.calls[0]['tool_code'])
+            self.assertEqual({'keyword': 'SO20260706001', 'page': 2, 'limit': 15}, api_client.calls[0]['payload'])
+
+    def test_run_cli_serve_stdio_handles_initialize_request(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            path = os.path.join(tmp_dir, 'token.json')
+            token_store = FileTokenStore(path, refresh_skew_seconds=60)
+            token_store.save('MT_bound', '2099-01-01T00:00:00')
+            app = GatewayApp(
+                auth_client=DummyAuthClient(token_store),
+                api_client=DummyApiClient(),
+                token_store=token_store,
+            )
+            stdin = io.StringIO(
+                json.dumps(
+                    {
+                        'jsonrpc': '2.0',
+                        'id': 1,
+                        'method': 'initialize',
+                        'params': {
+                            'protocolVersion': '2025-06-18',
+                            'capabilities': {},
+                            'clientInfo': {'name': 'workbuddy', 'version': '1.0.0'},
+                        },
+                    }
+                )
+                + '\n'
+            )
+            stdout = io.StringIO()
+
+            exit_code = app.run_cli(['serve-stdio'], stdin=stdin, stdout=stdout)
+            response = json.loads(stdout.getvalue().strip())
+
+            self.assertEqual(0, exit_code)
+            self.assertEqual(1, response['id'])
+            self.assertEqual('2025-06-18', response['result']['protocolVersion'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 76 - 0
tests/test_config_compat.py

@@ -0,0 +1,76 @@
+import os
+import tempfile
+import unittest
+
+from config import GatewayConfig
+
+
+class GatewayConfigCompatTest(unittest.TestCase):
+    def test_gateway_config_accepts_documented_fms_env_names_when_dotenv_missing(self):
+        env = {
+            'FMS_API_BASE': 'http://gateway.example.test',
+            'FMS_CLIENT_TYPE': 'workbuddy',
+            'FMS_TIMEOUT_MS': '15000',
+            'FMS_LOG_LEVEL': 'debug',
+        }
+
+        config = GatewayConfig.from_env(env, dotenv_path=os.path.join(tempfile.gettempdir(), 'missing-fms-mcp.env'))
+
+        self.assertEqual('http://gateway.example.test', config.auth_base_url)
+        self.assertEqual('http://gateway.example.test', config.tools_base_url)
+        self.assertEqual('workbuddy', config.client_type)
+        self.assertEqual(15, config.timeout_seconds)
+        self.assertEqual('debug', config.log_level)
+
+    def test_gateway_config_reads_dotenv_file_when_env_missing(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            dotenv_path = os.path.join(tmp_dir, '.env')
+            with open(dotenv_path, 'w', encoding='utf-8') as file:
+                file.write('FMS_API_BASE=http://dotenv.example.test\n')
+                file.write('FMS_CLIENT_TYPE=workbuddy\n')
+                file.write('FMS_TIMEOUT_SECONDS=12\n')
+                file.write('FMS_LOG_LEVEL=warning\n')
+                file.write('FMS_TOKEN_STORE_PATH=.runtime-token.json\n')
+
+            config = GatewayConfig.from_env(env={}, dotenv_path=dotenv_path)
+
+            self.assertEqual('http://dotenv.example.test', config.auth_base_url)
+            self.assertEqual('http://dotenv.example.test', config.tools_base_url)
+            self.assertEqual('workbuddy', config.client_type)
+            self.assertEqual(12, config.timeout_seconds)
+            self.assertEqual('warning', config.log_level)
+            self.assertEqual('.runtime-token.json', config.token_store_path)
+
+    def test_gateway_config_prefers_dotenv_over_environment(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            dotenv_path = os.path.join(tmp_dir, '.env')
+            with open(dotenv_path, 'w', encoding='utf-8') as file:
+                file.write('FMS_AUTH_BASE=http://dotenv-auth.example.test\n')
+                file.write('FMS_TOOLS_BASE=http://dotenv-tools.example.test\n')
+                file.write('FMS_CLIENT_TYPE=workbuddy-dotenv\n')
+                file.write('FMS_TIMEOUT_SECONDS=12\n')
+                file.write('FMS_LOG_LEVEL=warning\n')
+                file.write('FMS_TOKEN_STORE_PATH=.dotenv-token.json\n')
+
+            config = GatewayConfig.from_env(
+                env={
+                    'FMS_AUTH_BASE': 'http://env-auth.example.test',
+                    'FMS_TOOLS_BASE': 'http://env-tools.example.test',
+                    'FMS_CLIENT_TYPE': 'workbuddy-env',
+                    'FMS_TIMEOUT_SECONDS': '30',
+                    'FMS_LOG_LEVEL': 'debug',
+                    'FMS_TOKEN_STORE_PATH': '.env-token.json',
+                },
+                dotenv_path=dotenv_path,
+            )
+
+            self.assertEqual('http://dotenv-auth.example.test', config.auth_base_url)
+            self.assertEqual('http://dotenv-tools.example.test', config.tools_base_url)
+            self.assertEqual('workbuddy-dotenv', config.client_type)
+            self.assertEqual(12, config.timeout_seconds)
+            self.assertEqual('warning', config.log_level)
+            self.assertEqual('.dotenv-token.json', config.token_store_path)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 96 - 0
tests/test_gateway_query_order.py

@@ -0,0 +1,96 @@
+import unittest
+
+from app import GatewayApp
+from services.api_client import ApiClient
+from services.token_store import InMemoryTokenStore
+from tools.query_order import QueryOrderTool
+
+
+class DummyTransport:
+    def __init__(self):
+        self.calls = []
+
+    def post_json(self, url, payload, headers, timeout):
+        self.calls.append(
+            {
+                'url': url,
+                'payload': payload,
+                'headers': headers,
+                'timeout': timeout,
+            }
+        )
+        return {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'summary': 'ok',
+                'records': [],
+                'tips': [],
+            },
+            'meta': {
+                'request_id': headers['X-Request-Id'],
+            },
+        }
+
+
+class GatewayQueryOrderTest(unittest.TestCase):
+    def test_gateway_lists_query_order_tool(self):
+        app = GatewayApp()
+
+        tools = app.list_tools()
+
+        by_name = {tool['name']: tool for tool in tools}
+        self.assertIn('query_order', by_name)
+        self.assertIn('keyword', by_name['query_order']['input_schema']['required'])
+
+    def test_api_client_adds_mcp_headers_when_calling_tool(self):
+        transport = DummyTransport()
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        store.save('MT_demo', '2099-01-01T00:00:00')
+        client = ApiClient(
+            base_url='http://tools.example.test',
+            token_store=store,
+            transport=transport,
+            timeout=8,
+        )
+
+        response = client.call_tool(
+            tool_code='query_order',
+            route_path='/admin/mcp/tools/queryOrder',
+            payload={'keyword': 'SO20260706001', 'page': 1, 'limit': 20},
+            request_id='rq_demo',
+        )
+
+        self.assertEqual('MCP_0000', response['code'])
+        self.assertEqual(1, len(transport.calls))
+        self.assertEqual('Bearer MT_demo', transport.calls[0]['headers']['Authorization'])
+        self.assertEqual('query_order', transport.calls[0]['headers']['X-MCP-Tool-Code'])
+        self.assertEqual('rq_demo', transport.calls[0]['headers']['X-Request-Id'])
+        self.assertEqual('http://tools.example.test/admin/mcp/tools/queryOrder', transport.calls[0]['url'])
+
+    def test_query_order_tool_normalizes_input_before_forwarding(self):
+        transport = DummyTransport()
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        store.save('MT_demo', '2099-01-01T00:00:00')
+        client = ApiClient(
+            base_url='http://tools.example.test',
+            token_store=store,
+            transport=transport,
+            timeout=8,
+        )
+        tool = QueryOrderTool(api_client=client)
+
+        tool.call(keyword=' SO20260706001 ', page=2, limit=15, request_id='rq_tool')
+
+        self.assertEqual(
+            {
+                'keyword': 'SO20260706001',
+                'page': 2,
+                'limit': 15,
+            },
+            transport.calls[0]['payload'],
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 102 - 0
tests/test_gateway_runtime.py

@@ -0,0 +1,102 @@
+import unittest
+from datetime import datetime, timedelta
+
+from app import GatewayApp
+from services.token_store import InMemoryTokenStore
+
+
+class DummyAuthClient:
+    def __init__(self, token_store):
+        self.token_store = token_store
+        self.exchange_calls = []
+        self.refresh_calls = []
+
+    def exchange(self, auth_code):
+        self.exchange_calls.append(auth_code)
+        self.token_store.save('MT_exchange', '2099-01-01T00:00:00')
+        return {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'mcp_token': 'MT_exchange',
+                'expire_time': '2099-01-01T00:00:00',
+            },
+        }
+
+    def refresh(self, mcp_token):
+        self.refresh_calls.append(mcp_token)
+        self.token_store.save('MT_refresh', '2099-01-02T00:00:00')
+        return {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'mcp_token': 'MT_refresh',
+                'expire_time': '2099-01-02T00:00:00',
+            },
+        }
+
+
+class DummyApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.calls.append(
+            {
+                'tool_code': tool_code,
+                'route_path': route_path,
+                'payload': payload,
+                'request_id': request_id,
+            }
+        )
+        return {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'summary': 'ok',
+                'records': [],
+                'tips': [],
+            },
+            'meta': {
+                'request_id': request_id,
+            },
+        }
+
+
+class GatewayRuntimeTest(unittest.TestCase):
+    def test_bind_exchanges_auth_code_and_persists_token(self):
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        app = GatewayApp(
+            auth_client=DummyAuthClient(store),
+            api_client=DummyApiClient(),
+            token_store=store,
+        )
+
+        response = app.bind('AUTH123')
+
+        self.assertEqual('MCP_0000', response['code'])
+        self.assertEqual('MT_exchange', store.get()['token'])
+
+    def test_call_tool_refreshes_expiring_token_and_generates_request_id(self):
+        store = InMemoryTokenStore(refresh_skew_seconds=60)
+        expiring_time = (datetime.now() + timedelta(seconds=10)).isoformat(timespec='seconds')
+        store.save('MT_old', expiring_time)
+        auth_client = DummyAuthClient(store)
+        api_client = DummyApiClient()
+        app = GatewayApp(
+            auth_client=auth_client,
+            api_client=api_client,
+            token_store=store,
+        )
+
+        response = app.call_tool('query_order', {'keyword': 'SO20260706001'})
+
+        self.assertEqual(['MT_old'], auth_client.refresh_calls)
+        self.assertEqual('MT_refresh', store.get()['token'])
+        self.assertEqual('query_order', api_client.calls[0]['tool_code'])
+        self.assertTrue(api_client.calls[0]['request_id'].startswith('rq_'))
+        self.assertEqual(api_client.calls[0]['request_id'], response['meta']['request_id'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 168 - 0
tests/test_mcp_protocol.py

@@ -0,0 +1,168 @@
+import io
+import json
+import unittest
+
+from app import GatewayApp
+from services.token_store import InMemoryTokenStore
+from mcp_protocol import McpProtocolHandler
+
+
+class DummyApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.calls.append(
+            {
+                'tool_code': tool_code,
+                'route_path': route_path,
+                'payload': payload,
+                'request_id': request_id,
+            }
+        )
+        return {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'summary': 'matched 1 order',
+                'records': [
+                    {
+                        'order_no': 'SO20260706001',
+                    }
+                ],
+                'tips': ['scoped by employee permissions'],
+            },
+            'meta': {
+                'request_id': request_id,
+            },
+        }
+
+
+class McpProtocolTest(unittest.TestCase):
+    def build_handler(self):
+        token_store = InMemoryTokenStore(refresh_skew_seconds=60)
+        token_store.save('MT_demo', '2099-01-01T00:00:00')
+        app = GatewayApp(
+            auth_client=None,
+            api_client=DummyApiClient(),
+            token_store=token_store,
+        )
+        return McpProtocolHandler(app)
+
+    def test_initialize_returns_server_capabilities(self):
+        handler = self.build_handler()
+
+        response = handler.handle_request(
+            {
+                'jsonrpc': '2.0',
+                'id': 1,
+                'method': 'initialize',
+                'params': {
+                    'protocolVersion': '2025-06-18',
+                    'capabilities': {},
+                    'clientInfo': {'name': 'workbuddy', 'version': '1.0.0'},
+                },
+            }
+        )
+
+        self.assertEqual('2.0', response['jsonrpc'])
+        self.assertEqual(1, response['id'])
+        self.assertEqual('2025-06-18', response['result']['protocolVersion'])
+        self.assertIn('tools', response['result']['capabilities'])
+        self.assertEqual('fms-mcp-gateway', response['result']['serverInfo']['name'])
+
+    def test_initialized_notification_does_not_emit_response(self):
+        handler = self.build_handler()
+
+        response = handler.handle_message(
+            {
+                'jsonrpc': '2.0',
+                'method': 'notifications/initialized',
+            }
+        )
+
+        self.assertIsNone(response)
+
+    def test_tools_list_returns_registered_tools(self):
+        handler = self.build_handler()
+
+        response = handler.handle_request(
+            {
+                'jsonrpc': '2.0',
+                'id': 2,
+                'method': 'tools/list',
+                'params': {},
+            }
+        )
+
+        self.assertEqual('2.0', response['jsonrpc'])
+        self.assertEqual(2, response['id'])
+        by_name = {tool['name']: tool for tool in response['result']['tools']}
+        self.assertIn('query_order', by_name)
+        self.assertIn('bind_auth_code', by_name)
+        self.assertIn('inputSchema', by_name['query_order'])
+
+    def test_tools_call_wraps_gateway_result_as_structured_content(self):
+        handler = self.build_handler()
+
+        response = handler.handle_request(
+            {
+                'jsonrpc': '2.0',
+                'id': 3,
+                'method': 'tools/call',
+                'params': {
+                    'name': 'query_order',
+                    'arguments': {
+                        'keyword': 'SO20260706001',
+                        'page': 1,
+                        'limit': 20,
+                    },
+                },
+            }
+        )
+
+        self.assertEqual('2.0', response['jsonrpc'])
+        self.assertEqual(3, response['id'])
+        self.assertFalse(response['result']['isError'])
+        self.assertEqual('matched 1 order', response['result']['structuredContent']['summary'])
+        self.assertEqual('SO20260706001', response['result']['structuredContent']['records'][0]['order_no'])
+        self.assertEqual('text', response['result']['content'][0]['type'])
+        self.assertIn('matched 1 order', response['result']['content'][0]['text'])
+
+    def test_run_stdio_writes_only_request_responses(self):
+        handler = self.build_handler()
+        stdin = io.StringIO(
+            json.dumps(
+                {
+                    'jsonrpc': '2.0',
+                    'id': 1,
+                    'method': 'initialize',
+                    'params': {
+                        'protocolVersion': '2025-06-18',
+                        'capabilities': {},
+                        'clientInfo': {'name': 'workbuddy', 'version': '1.0.0'},
+                    },
+                }
+            )
+            + '\n'
+            + json.dumps(
+                {
+                    'jsonrpc': '2.0',
+                    'method': 'notifications/initialized',
+                }
+            )
+            + '\n'
+        )
+        stdout = io.StringIO()
+
+        handler.run_stdio(stdin=stdin, stdout=stdout)
+
+        lines = [line for line in stdout.getvalue().splitlines() if line.strip()]
+        self.assertEqual(1, len(lines))
+        response = json.loads(lines[0])
+        self.assertEqual(1, response['id'])
+        self.assertEqual('2025-06-18', response['result']['protocolVersion'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 146 - 0
tests/test_redis_token_store.py

@@ -0,0 +1,146 @@
+import json
+import os
+import tempfile
+import unittest
+
+from app import GatewayApp
+from config import GatewayConfig
+from services.auth_client import AuthClient
+from services.token_store import RedisTokenStore
+
+
+class FakeRedisClient:
+    def __init__(self):
+        self.values = {}
+        self.expiries = {}
+        self.deleted = []
+
+    def set(self, key, value, ex=None):
+        self.values[key] = value
+        self.expiries[key] = ex
+        return True
+
+    def get(self, key):
+        return self.values.get(key)
+
+    def delete(self, key):
+        self.deleted.append(key)
+        self.values.pop(key, None)
+        return 1
+
+
+class DummyTransport:
+    def __init__(self):
+        self.calls = []
+
+    def post_json(self, url, payload, headers, timeout):
+        self.calls.append(
+            {
+                'url': url,
+                'payload': payload,
+                'headers': headers,
+                'timeout': timeout,
+            }
+        )
+        return {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'mcp_token': 'MT_session',
+                'expire_time': '2099-01-01T00:00:00',
+            },
+        }
+
+
+class RedisTokenStoreTest(unittest.TestCase):
+    def test_gateway_config_reads_redis_store_settings_from_dotenv(self):
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            dotenv_path = os.path.join(tmp_dir, '.env')
+            with open(dotenv_path, 'w', encoding='utf-8') as file:
+                file.write('FMS_AUTH_BASE=http://auth.example.test\n')
+                file.write('FMS_TOOLS_BASE=http://tools.example.test\n')
+                file.write('FMS_TOKEN_STORE=redis\n')
+                file.write('FMS_REDIS_HOST=192.168.1.241\n')
+                file.write('FMS_REDIS_PORT=6380\n')
+                file.write('FMS_REDIS_DB=2\n')
+                file.write('FMS_REDIS_PASSWORD=secret\n')
+                file.write('FMS_REDIS_PREFIX=fms:mcp:workbuddy:\n')
+                file.write('FMS_SESSION_KEY=pc-a:user-a\n')
+
+            config = GatewayConfig.from_env(env={}, dotenv_path=dotenv_path)
+
+            self.assertEqual('redis', config.token_store_type)
+            self.assertEqual('192.168.1.241', config.redis_host)
+            self.assertEqual(6380, config.redis_port)
+            self.assertEqual(2, config.redis_db)
+            self.assertEqual('secret', config.redis_password)
+            self.assertEqual('fms:mcp:workbuddy:', config.redis_prefix)
+            self.assertEqual('pc-a:user-a', config.session_key)
+
+    def test_redis_token_store_isolates_tokens_by_session_key(self):
+        redis = FakeRedisClient()
+        first = RedisTokenStore(redis, prefix='fms:mcp:workbuddy:', session_key='pc-a:user-a')
+        second = RedisTokenStore(redis, prefix='fms:mcp:workbuddy:', session_key='pc-b:user-b')
+
+        first.save('MT_first', '2099-01-01T00:00:00')
+        second.save('MT_second', '2099-01-01T00:00:00')
+
+        self.assertEqual('MT_first', first.get()['token'])
+        self.assertEqual('MT_second', second.get()['token'])
+        self.assertIn('fms:mcp:workbuddy:pc-a:user-a', redis.values)
+        self.assertIn('fms:mcp:workbuddy:pc-b:user-b', redis.values)
+        self.assertNotEqual(redis.values['fms:mcp:workbuddy:pc-a:user-a'], redis.values['fms:mcp:workbuddy:pc-b:user-b'])
+
+        first.clear()
+
+        self.assertIsNone(first.get())
+        self.assertEqual('MT_second', second.get()['token'])
+        self.assertEqual(['fms:mcp:workbuddy:pc-a:user-a'], redis.deleted)
+
+    def test_auth_client_exchange_sends_session_key(self):
+        transport = DummyTransport()
+        redis = FakeRedisClient()
+        store = RedisTokenStore(redis, prefix='fms:mcp:workbuddy:', session_key='pc-a:user-a')
+        client = AuthClient(
+            base_url='http://auth.example.test',
+            client_type='workbuddy',
+            token_store=store,
+            transport=transport,
+            timeout=9,
+            session_key='pc-a:user-a',
+        )
+
+        client.exchange('AUTH123')
+
+        self.assertEqual(
+            {
+                'auth_code': 'AUTH123',
+                'client_type': 'workbuddy',
+                'session_key': 'pc-a:user-a',
+            },
+            transport.calls[0]['payload'],
+        )
+        self.assertEqual('MT_session', store.get()['token'])
+
+    def test_gateway_from_config_uses_redis_token_store_when_configured(self):
+        config = GatewayConfig(
+            auth_base_url='http://auth.example.test',
+            tools_base_url='http://tools.example.test',
+            token_store_type='redis',
+            redis_host='127.0.0.1',
+            redis_port=6379,
+            redis_db=0,
+            redis_password='',
+            redis_prefix='fms:mcp:workbuddy:',
+            session_key='pc-a:user-a',
+        )
+
+        app = GatewayApp.from_config(config, redis_client=FakeRedisClient())
+
+        self.assertIsInstance(app.token_store, RedisTokenStore)
+        self.assertEqual('fms:mcp:workbuddy:pc-a:user-a', app.token_store.key)
+        self.assertEqual('pc-a:user-a', app.auth_client.session_key)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 1 - 0
tools/__init__.py

@@ -0,0 +1 @@
+

+ 49 - 0
tools/bind_auth_code.py

@@ -0,0 +1,49 @@
+class BindAuthCodeTool:
+    name = 'bind_auth_code'
+    requires_session = False
+
+    def __init__(self, auth_client=None):
+        self.auth_client = auth_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': 'Bind a one-time Workbuddy auth code and create the local MCP session.',
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'auth_code': {
+                        'type': 'string',
+                        'description': 'One-time auth code copied from the FMS backend.',
+                    },
+                },
+                'required': ['auth_code'],
+            },
+        }
+
+    def call(self, auth_code, request_id=''):
+        if self.auth_client is None:
+            raise RuntimeError('auth client is required for bind_auth_code')
+        auth_code = str(auth_code or '').strip()
+        if not auth_code:
+            raise ValueError('auth_code is required')
+        response = self.auth_client.exchange(auth_code)
+        if (response.get('code') or '') != 'MCP_0000':
+            raise RuntimeError(response.get('msg') or 'auth_code bind failed')
+        data = response.get('data') or {}
+        return {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'status': 'bound',
+                'summary': '绑定成功,可以继续使用 query_order 查询订单。',
+                'expire_time': data.get('expire_time'),
+                'tips': [
+                    '后续工具调用会自动使用本机已保存的 MCP 会话。',
+                    '如果授权失效,请回后台重新获取 Workbuddy 授权码。',
+                ],
+            },
+            'meta': {
+                'request_id': request_id,
+            },
+        }

+ 36 - 0
tools/query_order.py

@@ -0,0 +1,36 @@
+class QueryOrderTool:
+    name = 'query_order'
+    route_path = '/admin/mcp/tools/queryOrder'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': 'Query orders with the current employee permissions.',
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'keyword': {'type': 'string'},
+                    'page': {'type': 'integer', 'minimum': 1},
+                    'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100},
+                },
+                'required': ['keyword'],
+            },
+        }
+
+    def call(self, keyword, page=1, limit=20, request_id='rq_query_order'):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_order')
+        keyword = str(keyword).strip()
+        if not keyword:
+            raise ValueError('keyword is required')
+        page = max(1, int(page))
+        limit = max(1, min(100, int(limit)))
+        payload = {
+            'keyword': keyword,
+            'page': page,
+            'limit': limit,
+        }
+        return self.api_client.call_tool(self.name, self.route_path, payload, request_id)