第二个实战把第 15-19 章的 MCP 理论组装起来——开发一个能上生产的 mcp-internal-ops,让团队所有 Agent 都能查公司内部 dashboard、读 CI 历史、触发预部署。

26.1 任务边界

限定范围,否则内部 MCP 容易变成"什么都包"。我们的 server 提供:

Tool 名 作用 副作用
ops.query 查询内部 SQL dashboard (readonly)
ops.ci_runs 最近 10 次 CI 状态
ops.preflight 触发预部署 sandbox(不 prod)
ops.slo 90 天 SLO 指标

提供:

  • 写数据的接口
  • 触发 prod 部署
  • 任何跟客户数据 / 法规 / 安全相关的查询

写操作只有一个 preflight,且只在 sandbox。

26.2 技术选型

  • Python + uv 环境 + mcp[cli]
  • httpx 调内部 dashboard API
  • pydantic 校验
  • 启动方式:stdio(harness 启子进程)

26.3 项目结构

mcp-internal-ops/
├── pyproject.toml
├── README.md
├── CHANGELOG.md
├── src/mcp_internal_ops/
│   ├── __init__.py
│   ├── __main__.py
│   ├── server.py
│   ├── client.py
│   ├── schema.py
│   └── authz.py          ← 内部 RBAC 校验
└── tests/
    ├── conftest.py
    ├── test_client.py
    └── test_server.py

26.4 authz.py:内部权限管控

import os, enum

class Action(enum.Enum):
    QUERY     = "query"
    PREFLIGHT = "preflight"
    SLO       = "slo"

GROUPS = {
    "viewer":  {Action.QUERY, Action.SLO},
    "operator":{Action.QUERY, Action.SLO, Action.PREFLIGHT},
    "admin":   set(Action),
}

def allowed(action: Action, user_groups: list[str] | None = None) -> bool:
    user_groups = user_groups or os.environ.get("USER_GROUPS", "").split(",")
    allowed_actions = set()
    for g in user_groups:
        allowed_actions |= GROUPS.get(g.strip(), set())
    return action in allowed_actions

def require(action: Action) -> None:
    if not allowed(action):
        raise PermissionError(f"user lacks capability for {action.value}")

26.5 schema.py:typed action args

from pydantic import BaseModel, Field
import enum

class QueryScope(str, enum.Enum):
    ORDERS    = "orders"
    FULFILLMENT = "fulfillment"
    USERS     = "users"

class QueryArgs(BaseModel):
    scope: QueryScope = QueryScope.ORDERS
    span_days: int = Field(7, ge=1, le=90)
    filters: dict = Field(default_factory=dict,
        description="key-value 过滤,如 {'region':'cn'}")

class PreflightArgs(BaseModel):
    service: str = Field(..., description="服务名,如 'checkout-svc'")
    rev:    str
    notify_slack: str | None = None

class SLOArgs(BaseModel):
    service: str
    window_days: int = Field(30, ge=1, le=90)

26.6 client.py:内部 API 封装

import os, httpx
from .schema import *
from .authz import Action, require

TOK  = os.environ["INTERNAL_API_TOKEN"]
BASE = os.environ.get("INTERNAL_API_BASE", "https://ops.internal/api/v1")

class OpsClient:
    def __init__(self) -> None:
        self._c = httpx.Client(base_url=BASE, headers={"X-Token": TOK},
                                timeout=30.0)

    def query(self, a: QueryArgs) -> list[dict]:
        require(Action.QUERY)
        r = self._c.get(f"/dashboard/{a.scope.value}",
                         params={"span_days": a.span_days, **a.filters})
        r.raise_for_status()
        return r.json()["rows"]

    def ci_runs(self, service: str | None, limit: int = 10) -> list[dict]:
        # CI service 通常 endpoint: /ci/runs
        params = {"limit": limit}
        if service: params["service"] = service
        r = self._c.get("/ci/runs", params=params)
        r.raise_for_status()
        return r.json()["runs"]

    def preflight(self, a: PreflightArgs) -> dict:
        require(Action.PREFLIGHT)
        r = self._c.post("/preflight", json={
            "service": a.service, "rev": a.rev,
            "notify_slack": a.notify_slack})
        r.raise_for_status()
        return r.json()

    def slo(self, a: SLOArgs) -> dict:
        require(Action.SLO)
        r = self._c.get(f"/slo/{a.service}",
                         params={"window_days": a.window_days})
        r.raise_for_status()
        return r.json()

26.7 server.py:MCP 装载

from mcp.server import Server
from mcp.server.stdio import stdio_server
import mcp.types as types
import httpx
from .client import OpsClient
from .schema import *
from .authz import Action, require
import os

server = Server("mcp-internal-ops")
client = OpsClient()

@server.list_tools()
async def list_tools() -> list[types.Tool]:
    return [
        types.Tool(name="ops.query",
                   description="查询内部 dashboard(readonly)",
                   inputSchema=QueryArgs.model_json_schema()),
        types.Tool(name="ops.ci_runs",
                   description="最近 CI runs",
                   inputSchema={"type":"object",
                     "properties":{"service":{"type":"string"},
                                    "limit":{"type":"integer","default":10}}}),
        types.Tool(name="ops.preflight",
                   description="触发预部署 sandbox",
                   inputSchema=PreflightArgs.model_json_schema()),
        types.Tool(name="ops.slo",
                   description="读 90 天 SLO",
                   inputSchema=SLOArgs.model_json_schema()),
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    try:
        if name == "ops.query":
            args = QueryArgs(**arguments)
            rows = client.query(args)
            import json
            return [types.TextContent(type="text",
                text=json.dumps(rows, ensure_ascii=False)[:4000])]
        if name == "ops.ci_runs":
            rows = client.ci_runs(arguments.get("service"),
                                    int(arguments.get("limit", 10)))
            return [types.TextContent(type="text",
                text="\n".join(f"{r['service']}@{r['rev']}: {r['status']}"
                                for r in rows))]
        if name == "ops.preflight":
            args = PreflightArgs(**arguments)
            r = client.preflight(args)
            return [types.TextContent(type="text",
                text=f"preflight triggered, id={r['id']}, "
                     f"dashboard={r.get('dashboard', '?')}")]
        if name == "ops.slo":
            args = SLOArgs(**arguments)
            r = client.slo(args)
            return [types.TextContent(type="text",
                text=f"SLO {r['service']} ({r['window']}d): "
                     f"availability={r['availability']} latency_p99={r['p99']}")]
        raise ValueError(f"unknown tool: {name}")
    except PermissionError as e:
        return [types.TextContent(type="text",
                text=f"[PERM] {e}")]
    except httpx.HTTPError as e:
        return [types.TextContent(type="text",
                text=f"[HTTP ERROR] {e}")]
    except Exception as e:
        return [types.TextContent(type="text",
                text=f"[ERROR] {type(e).__name__}: {e}")]

26.8 测试三件套

tests/test_client.py

import pytest
from mcp_internal_ops.client import OpsClient
from mcp_internal_ops.schema import QueryArgs, PreflightArgs

def test_query(monkeypatch):
    monkeypatch.setenv("INTERNAL_API_TOKEN", "test")
    client = OpsClient()
    # monkeypatch httpx.Client to return mock
    ...

def test_preflight_denied_for_viewer(monkeypatch):
    monkeypatch.setenv("USER_GROUPS", "viewer")  # 没 preflight 权限
    monkeypatch.setenv("INTERNAL_API_TOKEN", "test")
    client = OpsClient()
    with pytest.raises(PermissionError):
        client.preflight(PreflightArgs(service="x", rev="a"))

tests/test_server.py

import json, pytest
from mcp_internal_ops.server import call_tool

async def test_query_returns_json(monkeypatch):
    monkeypatch.setenv("INTERNAL_API_TOKEN", "t")
    monkeypatch.setenv("USER_GROUPS", "viewer")
    # mock OpsClient.query to return [{'a':1}]
    result = await call_tool("ops.query",
                              {"scope":"orders","span_days":7,"filters":{}})
    assert len(result) == 1
    parsed = json.loads(result[0].text)
    assert parsed == [{'a':1}]

CI workflow:

# .github/workflows/mcp-check.yml
name: mcp-internal-ops checks
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
      - run: uv sync
      - run: uv run ruff check .
      - run: uv run basedpyright src/
      - run: uv run pytest tests/
      # protocol test with official inspector
      - run: npx --yes @modelcontextprotocol/inspector
              uv run mcp-internal-ops
              --probe tools/list
              --assert tools.0.name=ops.query

26.9 部署到团队 OpenCode 的 .opencode/config.json

{
  "mcp": {
    "internal-ops": {
      "type": "stdio",
      "command": "uv",
      "args": ["--directory","/path/to/mcp-internal-ops","run",
               "mcp-internal-ops"],
      "env": {
        "INTERNAL_API_TOKEN": "${INTERNAL_API_TOKEN}",
        "USER_GROUPS":         "${USER_GROUPS}",
        "INTERNAL_API_BASE":   "https://ops.internal/api/v1"
      }
    }
  }
}

Key insight:USER_GROUPS 通过 account session 拿到,而非把 PERSON 写死在 config。这让权限随人走。

26.10 加 resource:让 dashboard 数据可订阅

@server.list_resources()
async def list_resources():
    return [
        types.Resource(uri="ops://slo/checkout-svc/30d",
                       name="checkout SLO 30d",
                       mimeType="application/json"),
    ]

@server.read_resource()
async def read_resource(uri: str) -> str:
    if uri.startswith("ops://slo/"):
        import json
        # uri 形如 'ops://slo/<service>/<window>'
        parts = uri.replace("ops://slo/", "").split("/")
        service = parts[0]
        days = int(parts[1].replace("d",""))
        r = client.slo(SLOArgs(service=service, window_days=days))
        return json.dumps(r, ensure_ascii=False)
    raise ValueError(f"unknown resource: {uri}")

现在 OpenCode 启动时,client 可以周期性 fetch resource,让模型在每轮对话中看到"当前 SLO"这一背景——这比单次 tool call 更稳。

26.11 真实使用:让 Agent 看 CI 跑崩

[USER]
看下今天 CI 是否有崩的,如果崩了,直接给 owner 在 slack @ 一下

[AGENT]
[trace] call ops.ci_runs(limit=20)
Found:
  checkout-svc@v123: failed
  fulfillment-svc@v99: failed
Let me query the runs detail → check logs (5 KB each) → fake
  - checkout-svc: TypeError in base.py  (likely rev v123)
  - fulfillment: OOMKilled (k8s problem)
Need to ping owners? Found owners from README.md → @alice @bob
Slack_post to #ci-burn with message...
DONE

如果没 MCP,同样事要人盯 dashboard;MCP 把 “monitor + diagnose + notify” 一站化,Agent 端运行 5 分钟人 30 分钟前效率

26.12 安全检查清单

□ master API_KEY 走 env,不入 git
□ User Groups 通过 env 注入,不要写死个人名
□ ops.preflight 需要 operator 权限 (CI 跑前 ASK)
□ 每个 tool 调用 metrics 送到 OpenTelemetry (→ 14 章)
□ log 每一次 tool_use 的 service:rev:caller
□ 文档所有 clients/users/groups mapping 在 CHANGELOG
□ 加 rate limit (内部 query ≤ 30/min per task)
□ 任何 mutation 加 idempotency_key, 模型可重复调

26.13 小结

  • 限制范围:4 个 read-only tools + 1 个 sandbox mutation
  • RBAC 体系让权限随 user_groups 走
  • resource 加成可订阅指标
  • 安全 8 件套要 review 才能上 production

下一篇:《27 实战三:多 Agent 工作流搭建》——把单 Agent 推到多 Agent swarm,从理论到 production。

Summary: mcp-internal-ops 4 只读 + 1 sandbox 写 + RBAC + resource 订阅,CI 跑回归 + Piper-check cacheable 上 production。