Support Agent MCP

Support Agent MCP

Provides order lookup, customer lookup, and refund issuance tools with categorized errors to ensure accurate routing and distinguish access failures from valid empty results.

Category
访问服务器

README

Domain 2 -- Support-Agent MCP Tool & Error Design

A runnable, test-driven implementation of the CCA Domain 2 build exercise. A support-agent MCP surface exposes three tools -- an ambiguous read pair (lookup_order / get_customer) and a mutation (issue_refund) -- whose descriptions route requests, returns categorized errors the model can act on, and keeps an access failure distinct from a valid empty result so the agent never lies about what it could not read.

Quick start

poetry install --with dev
poetry run pytest                      # NO API key needed
poetry run python -m support_agent.demo

The demo runs four sections against the real code:

=== routing: "check order #12345 status" ===
  with mutual boundaries -> lookup_order
  with naive getters     -> AMBIGUOUS (ambiguous between get_customer, lookup_order)

=== four error categories ===
  transient   isError=True   category=transient    retryable=True   code=ORDERS_UPSTREAM_TIMEOUT
  validation  isError=True   category=validation   retryable=True   code=INVALID_ORDER_ID
  business    isError=True   category=business     retryable=False  code=ALREADY_REFUNDED
  permission  isError=True   category=permission   retryable=False  code=REFUND_FORBIDDEN

=== access failure vs. valid empty (same request, opposite meaning) ===
  order absent    isError=False  category=None         retryable=False  code=ORDER_NOT_FOUND
  db unreachable  isError=True   category=transient    retryable=True   code=ORDERS_DB_UNREACHABLE

=== cross-domain resolve_cap_basis ===
  no fees (valid) isError=False  category=None         retryable=False  code=NO_FEES_ON_RECORD  resolved_basis=0
  fee svc down    isError=True   category=transient    retryable=True   code=FEE_SERVICE_UNREACHABLE  resolved_basis=NONE

The headline

"check order #12345 status" must route to lookup_order, never to get_customer. The routing is done by the description -- specifically the mutual WHEN NOT TO USE boundary, where each of the ambiguous pair names the other as the place to send colliding requests. With the real boundaried descriptions the request routes to lookup_order; swap in naive "Retrieves [entity] information" getters and the same request goes ambiguous. That delta is the proof the boundary is load-bearing, not decorative.

route (router.py) is the deterministic stand-in for the model's tool selection -- the offline seam, exactly like Domain 1's ScriptedClient. The thing under test is the descriptions, not the router: the scorer is uniform and tool-agnostic, so changing the outcome means changing the descriptions. The live analog (ModelRouter in live.py) sends the same descriptions to the real model with tool_choice="auto". See tests/test_routing.py and tests/test_tool_descriptions.py.

How the deliverables map to code

Deliverable Where Correct pattern (demonstrated) Distractor (shown failing)
1. Three tool definitions tools.py Four-component descriptions; mutation not overloaded "Retrieves X information" generic getters
2. Mutual disambiguation tools.py, router.py Each boundary names the other tool; sibling is disqualified One-sided boundary -> the other reads as a catch-all
3. Four categorized errors errors.py, handlers.py isError+_meta; business is isError:true/retryable false Model business failure as a valid empty result
4. Access-failure vs. valid-empty handlers.py, errors.py isError:false (absent) vs isError:true+transient (unreachable) Same shape for both -> "no such order" on an outage
5. .mcp.json with ${VAR} .mcp.json, config.py Project scope, ${VAR} on every secret, scanner proves zero leaks Hardcoded credential in committed config
Cross-domain resolve_cap_basis cap_basis.py Unreachable -> no number; no-fees -> valid $0 Collapse "unreachable" into "fees = $0" (fake zero exposure)
Categories survive propagation propagation.py Coordinator reads error_category/retryable off _meta Subagent flattens the error to "it failed"

The one decision the field name encodes -- retryable

retryable answers "can recovery-by-retry succeed?", not "retry the identical call." Transient -> yes, same call. Validation -> yes, but only after the agent fixes the arguments (a verbatim retry fails the same way -- the prose says so). Business / permission -> no; change strategy or escalate. This keeps a coordinator's recovery deterministic: a retryable: true validation error never means "replay the same bytes." See the design doc's note on why this is pinned.

Access failure vs. valid empty -- the confident lie

The dangerous failure is making the two identical. If a database outage returned the same shape as a genuine miss (isError: false, "no order found"), the model cannot tell "we looked and it isn't there" from "we couldn't look" -- and it will fluently tell the customer the order does not exist. The isError split forces the loop to branch: false -> answer the user; true + transient -> retry, never assert non-existence. tests/test_access_vs_empty.py asserts the outage never renders as "no order found" and always reads "UNKNOWN".

Cross-domain hook -- resolve_cap_basis (for the Domain 1 reviewer)

The Domain 1 reviewer handles numeric caps with deterministic arithmetic. A formula cap ("fees paid in the trailing 12 months") needs an external figure, and resolving it is the same access-vs-empty pattern pointed at a fee service:

  • fee service unreachable -> access failure (isError: true, transient), and it carries no resolved_basis -- collapsing it into $0 would make an unbounded cap read as zero exposure and clear the reviewer's send gate on a fabricated number.
  • account genuinely has no fees -> valid empty (isError: false, resolved_basis: 0).

propagation.py shows the category surviving from an isolated subagent up to a coordinator (the distractor flatten_to_failed throws it away). What the coordinator then DOES with an unresolved cap -- escalate rather than fabricate a clean "no exposure" verdict -- is Domain 5 (load-bearing failure), flagged there, not solved here.

tool_choice is not a routing fix

Routing is a description problem. The live path uses tool_choice="auto" (model decides whether and which tool to call). Forcing a tool would fix one request and misroute every other; any would force a call on turns that should just talk to the user. Forced choice is for a known constrained sub-step, never a substitute for descriptions that route on their own.

Module guide

Module Responsibility
tools.py The three tool definitions + render_description; the naive distractor surface
router.py route -- the deterministic tool-selection seam (offline analog of the model)
errors.py mcp_error / mcp_ok builders + the named scenario objects (the exact prose)
handlers.py lookup_order / get_customer / issue_refund -- map backend outcomes to results
backend.py OrdersBackend / CustomersBackend seam + StubBackend (stages each condition)
cap_basis.py resolve_cap_basis + StubFeeService -- the cross-domain access-vs-empty tool
propagation.py Category survival from subagent to coordinator (and the flattening distractor)
config.py Load .mcp.json, expand ${VAR}, scan for hardcoded secrets (rejects default secrets)
server.py The MCP server in .mcp.json -- testable dispatch core + optional mcp-SDK glue
live.py Optional ModelRouter -- real-model routing via tool_choice="auto"
demo.py The four-section offline demonstration

The live path (optional)

poetry install --with dev --with live
export ANTHROPIC_API_KEY=...           # or cp .env.example .env

ModelRouter (live.py) sends the real descriptions to claude-opus-4-8 with tool_choice="auto" and reports which tool the model selected. The deterministic route powers every test, so the suite never needs a key.

The live group also installs the mcp SDK, so the server in .mcp.json runs:

python -m support_agent.server        # stdio MCP server over in-memory sample data

server.py's dispatch core is covered by the offline suite; only the stdio glue needs the SDK (same opt-in pattern as the model path).

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选