feat(outlook): 添加 Outlook MCP 集成支持并优化分页功能
- 新增 NANO_OUTLOOK_MCP_URL 和 NANO_OUTLOOK_MCP_SERVER_ID 环境变量配置 - 实现 Outlook 邮件和日历的分页查询功能,添加安全参数验证 - 为 app-instance 创建脚本添加 Outlook MCP 服务器 ID 参数 - 更新前端 Outlook 页面实现邮件列表和日历事件的分页浏览 - 添加 Git 忽略文件配置和 Docker 挂载路径修复 BREAKING CHANGE: Outlook 集成现在需要配置 MCP URL 和服务器 ID 环境变量
This commit is contained in:
@ -14,6 +14,8 @@ APP_INSTANCE_API_BASE=
|
||||
|
||||
# Used as a fallback when authz-service does not explicitly pass authz_base_url.
|
||||
DEFAULT_AUTHZ_BASE_URL=http://nano-authz-service:19090
|
||||
DEFAULT_AUTHZ_OUTLOOK_MCP_URL=
|
||||
DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||
|
||||
DEPLOY_PUBLIC_SCHEME=http
|
||||
DEPLOY_PUBLIC_BASE_DOMAIN=203.0.113.10.nip.io
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker.io \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg \
|
||||
&& install -m 0755 -d /etc/apt/keyrings \
|
||||
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||
&& . /etc/os-release \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
- `DEPLOY_CONTROL_API_TOKEN`
|
||||
- `APP_INSTANCE_API_KEY`
|
||||
- `DEFAULT_AUTHZ_BASE_URL`
|
||||
- `DEFAULT_AUTHZ_OUTLOOK_MCP_URL`
|
||||
- `DEFAULT_OUTLOOK_MCP_SERVER_ID`
|
||||
- `DEPLOY_PUBLIC_BASE_DOMAIN`
|
||||
- `DEPLOY_PUBLIC_PORT`
|
||||
- `DEPLOY_PUBLIC_SCHEME`
|
||||
@ -35,6 +37,15 @@ http://<instance-slug>.127.0.0.1.nip.io:8088
|
||||
|
||||
实例容器本身的 `20000-29999` 端口默认只绑定到部署机 `127.0.0.1`,外部入口应走 `router-proxy`。
|
||||
|
||||
如果你希望所有新实例默认带 Outlook MCP HTTP 工具,可以设置:
|
||||
|
||||
```bash
|
||||
DEFAULT_AUTHZ_OUTLOOK_MCP_URL=http://10.6.80.29:8000/mcp
|
||||
DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||
```
|
||||
|
||||
这样 `deploy-control` 创建的新实例会自动写入一条默认 MCP server 配置,并默认使用 `oauth_backend_token` + `mcp:<server_id>` 的 audience。
|
||||
|
||||
## 本机启动
|
||||
|
||||
```bash
|
||||
@ -51,3 +62,30 @@ uv run server.py
|
||||
- `/home/ivan/xuan/nano_project/router-proxy`
|
||||
|
||||
并传入对应环境变量,让容器内脚本路径仍能访问这两个目录。
|
||||
|
||||
关键点:
|
||||
|
||||
- 宿主机路径要原样挂进容器,不要改挂载目标路径
|
||||
- 同时显式传 `APP_INSTANCE_DIR` 和 `ROUTER_PROXY_DIR`
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name nano-deploy-control \
|
||||
--restart unless-stopped \
|
||||
--network nano-instance-edge \
|
||||
-p 8090:8090 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /home/ivan/xuan/nano_project/app-instance:/home/ivan/xuan/nano_project/app-instance \
|
||||
-v /home/ivan/xuan/nano_project/router-proxy:/home/ivan/xuan/nano_project/router-proxy \
|
||||
-e APP_INSTANCE_DIR=/home/ivan/xuan/nano_project/app-instance \
|
||||
-e ROUTER_PROXY_DIR=/home/ivan/xuan/nano_project/router-proxy \
|
||||
-e DEPLOY_CONTROL_API_TOKEN=change-me \
|
||||
-e APP_INSTANCE_IMAGE=nano/app-instance:latest \
|
||||
-e APP_INSTANCE_NETWORK_NAME=nano-instance-edge \
|
||||
-e APP_INSTANCE_API_KEY=sk-xxxxxxxx \
|
||||
nano/deploy-control:latest
|
||||
```
|
||||
|
||||
如果这里错把宿主机目录映射成容器内的另一个短路径,例如 `/app-instance`,那么 `deploy-control` 通过 Docker socket 创建实例时会把错误路径传给 Docker,最终导致实例容器拿不到 `config.json` 并持续重启。
|
||||
|
||||
@ -41,6 +41,8 @@ DEFAULT_MODEL = os.environ.get("APP_INSTANCE_MODEL", "openai/gpt-5").strip()
|
||||
DEFAULT_API_KEY = os.environ.get("APP_INSTANCE_API_KEY", "").strip()
|
||||
DEFAULT_API_BASE = os.environ.get("APP_INSTANCE_API_BASE", "").strip()
|
||||
DEFAULT_AUTHZ_BASE_URL = os.environ.get("DEFAULT_AUTHZ_BASE_URL", "").strip()
|
||||
DEFAULT_AUTHZ_OUTLOOK_MCP_URL = os.environ.get("DEFAULT_AUTHZ_OUTLOOK_MCP_URL", "").strip()
|
||||
DEFAULT_OUTLOOK_MCP_SERVER_ID = os.environ.get("DEFAULT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp").strip() or "outlook_mcp"
|
||||
PUBLIC_SCHEME = os.environ.get("DEPLOY_PUBLIC_SCHEME", "http").strip() or "http"
|
||||
PUBLIC_BASE_DOMAIN = os.environ.get("DEPLOY_PUBLIC_BASE_DOMAIN", "127.0.0.1.nip.io").strip()
|
||||
PUBLIC_HOST_TEMPLATE = os.environ.get("DEPLOY_PUBLIC_HOST_TEMPLATE", "{slug}.{base_domain}").strip()
|
||||
@ -48,6 +50,7 @@ PUBLIC_PORT = int(os.environ.get("DEPLOY_PUBLIC_PORT", "8088").strip() or "8088"
|
||||
AUTO_START_PROXY = os.environ.get("DEPLOY_AUTO_START_PROXY", "1").strip() not in {"0", "false", "False"}
|
||||
HEALTH_TIMEOUT_SECONDS = float(os.environ.get("DEPLOY_HEALTH_TIMEOUT_SECONDS", "60").strip() or "60")
|
||||
HEALTH_INTERVAL_SECONDS = float(os.environ.get("DEPLOY_HEALTH_INTERVAL_SECONDS", "1").strip() or "1")
|
||||
INSTANCE_INTERNAL_PORT = int(os.environ.get("APP_INSTANCE_INTERNAL_PORT", "8080").strip() or "8080")
|
||||
SERVER_HOST = os.environ.get("DEPLOY_CONTROL_HOST", "0.0.0.0").strip() or "0.0.0.0"
|
||||
SERVER_PORT = int(os.environ.get("DEPLOY_CONTROL_PORT", "8090").strip() or "8090")
|
||||
|
||||
@ -154,22 +157,37 @@ def build_public_url(host: str) -> str:
|
||||
return f"{PUBLIC_SCHEME}://{netloc}"
|
||||
|
||||
|
||||
def build_internal_api_base_url(record: dict[str, Any]) -> str:
|
||||
container_name = str(record.get("container_name", "") or "").strip()
|
||||
if container_name:
|
||||
return f"http://{container_name}:{INSTANCE_INTERNAL_PORT}"
|
||||
fallback = str(record.get("api_base_url", "") or record.get("public_url", "") or "").strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def wait_for_backend(record: dict[str, Any]) -> None:
|
||||
host_port = int(record.get("host_port", 0) or 0)
|
||||
if host_port <= 0:
|
||||
raise ApiError(HTTPStatus.BAD_GATEWAY, "instance host port missing from registry")
|
||||
container_name = str(record.get("container_name", "") or "").strip()
|
||||
targets: list[str] = []
|
||||
if container_name:
|
||||
targets.append(f"http://{container_name}:{INSTANCE_INTERNAL_PORT}/api/ping")
|
||||
if host_port > 0:
|
||||
targets.append(f"http://127.0.0.1:{host_port}/api/ping")
|
||||
if not targets:
|
||||
raise ApiError(HTTPStatus.BAD_GATEWAY, "instance health target missing from registry")
|
||||
|
||||
deadline = time.time() + HEALTH_TIMEOUT_SECONDS
|
||||
target = f"http://127.0.0.1:{host_port}/api/ping"
|
||||
last_error = "backend not ready"
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib_request.urlopen(target, timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
if payload.get("message") == "pong":
|
||||
return
|
||||
last_error = f"unexpected ping response from {target}"
|
||||
except (urllib_error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
last_error = str(exc)
|
||||
for target in targets:
|
||||
try:
|
||||
with urllib_request.urlopen(target, timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
if payload.get("message") == "pong":
|
||||
return
|
||||
last_error = f"unexpected ping response from {target}"
|
||||
except (urllib_error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
last_error = f"{target}: {exc}"
|
||||
time.sleep(HEALTH_INTERVAL_SECONDS)
|
||||
raise ApiError(HTTPStatus.BAD_GATEWAY, f"instance health check failed: {last_error}")
|
||||
|
||||
@ -197,6 +215,9 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
api_key = str(payload.get("api_key", "") or DEFAULT_API_KEY).strip()
|
||||
api_base = str(payload.get("api_base", "") or DEFAULT_API_BASE).strip()
|
||||
authz_base_url = str(payload.get("authz_base_url", "") or DEFAULT_AUTHZ_BASE_URL).strip()
|
||||
authz_outlook_mcp_url = str(
|
||||
payload.get("authz_outlook_mcp_url", "") or DEFAULT_AUTHZ_OUTLOOK_MCP_URL
|
||||
).strip()
|
||||
backend_name = str(payload.get("backend_name", "") or username).strip() or username
|
||||
image_name = str(payload.get("image_name", "") or INSTANCE_IMAGE).strip() or INSTANCE_IMAGE
|
||||
|
||||
@ -236,6 +257,9 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
command.extend(["--api-base", api_base])
|
||||
if authz_base_url:
|
||||
command.extend(["--authz-base-url", authz_base_url])
|
||||
if authz_outlook_mcp_url:
|
||||
command.extend(["--authz-outlook-mcp-url", authz_outlook_mcp_url])
|
||||
command.extend(["--outlook-mcp-server-id", DEFAULT_OUTLOOK_MCP_SERVER_ID])
|
||||
if payload.get("replace") is True:
|
||||
command.append("--replace")
|
||||
|
||||
@ -254,7 +278,7 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"instance": existing,
|
||||
"public_url": str(existing.get("public_url", "") or ""),
|
||||
"frontend_base_url": str(existing.get("frontend_base_url", "") or existing.get("public_url", "") or ""),
|
||||
"api_base_url": str(existing.get("api_base_url", "") or existing.get("public_url", "") or ""),
|
||||
"api_base_url": build_internal_api_base_url(existing),
|
||||
}
|
||||
|
||||
|
||||
@ -269,7 +293,7 @@ def resolve_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"instance": record,
|
||||
"public_url": str(record.get("public_url", "") or ""),
|
||||
"frontend_base_url": str(record.get("frontend_base_url", "") or record.get("public_url", "") or ""),
|
||||
"api_base_url": str(record.get("api_base_url", "") or record.get("public_url", "") or ""),
|
||||
"api_base_url": build_internal_api_base_url(record),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user