APAP/MCP服务器POC:共享服务层重构
雅阁项目 |想法#4——强化APAP/MCP服务器
这是我的GSoC应用程序中提出的核心架构更改的概念验证:通过引入MCP工具和REST路由直接使用的共享服务层,消除了APAP参考实现的MCP处理程序中的内部HTTP循环。
作者 Jay Guwalani——马里兰大学帕克分校研究科学家 | jguwalan@umd.edu
______________________________________________________________________
演示

______________________________________________________________________
问题
当前RI中的每个MCP工具调用都需要通过整个Express堆栈进行不必要的HTTP往返,才能到达位于同一进程中的数据库:
MCP Tool -> makeApiRequest() -> fetch('http://localhost:9000/...') -> Express -> Handler -> Drizzle -> Postgres这 makeApiRequest() 助手在 handlers/mcp.ts 实际上是通过网络调用自己。最重要的是,错误处理都是简单的字符串(throw new Error('Failed to load template'))404、400或500之间没有区别。
我在普利司通遇到了同样的架构,五个专门的代理通过内部REST网关路由到共享的Postgres实例。修复方法是相同的:将业务逻辑拉入共享服务层,并让每个传输直接调用它。
修复
MCP Tool ----\
>-- services/agreementService.ts --> Drizzle --> Postgres
REST API ----/一个功能。两个消费者。没有HTTP循环。错误修复会自动传播到这两个协议。
项目结构
src/
config.ts Zod-validated env vars, fail-fast on startup
index.ts Server entry, wires Express + MCP transports + health check
db/
schema.ts Drizzle schema (mirrors APAP RI exactly)
client.ts Connection pool factory with DI for tests
services/
errors.ts Typed error hierarchy (6 classes replacing bare strings)
templateService.ts Template CRUD via Drizzle
agreementService.ts Agreement CRUD + convert + trigger
index.ts Barrel export
handlers/
mcp.ts MCP tool/resource registration (SSE + StreamableHTTP)
routes/
api.ts REST router, same service imports as MCP
middleware/
logging.ts Pino structured logging, request-id correlation
healthz.ts /healthz for Docker readiness probes使此工作的不变量:
- 每个服务功能都需要
db作为其第一个参数(无需Postgres即可测试) - 服务返回键入的结果,从不返回原始HTTP响应
- 服务投掷
ServiceError子类,从不裸露字符串 - 服务不从Express或MCP SDK导入任何内容
快速开始
Docker(30秒):
git clone https://github.com/JayDS22/apap-mcp-poc.git
cd apap-mcp-poc
docker compose up本地(需要运行Postgres):
cp .env_example .env # edit credentials if needed
npm install
npx drizzle-kit push
npm run dev无论哪种方式,你都会得到:
APAP MCP POC server listening on http://0.0.0.0:9000
REST API: http://0.0.0.0:9000/capabilities
MCP SSE: http://0.0.0.0:9000/sse
MCP Streamable: POST http://0.0.0.0:9000/mcp
Health: http://0.0.0.0:9000/healthz端到端演练
服务器运行后,遍历整个生命周期:
# Health check
curl http://localhost:9000/healthz
# {"status":"ok","timestamp":"2026-03-28T..."}
# Capabilities (matches APAP RI format)
curl http://localhost:9000/capabilities
# ["TEMPLATE_MANAGE","AGREEMENT_MANAGE","SHARED_MODEL_MANAGE","AGREEMENT_CONVERT_HTML"]
# Create a template
curl -s -X POST http://localhost:9000/templates \
-H 'Content-Type: application/json' \
-d '{
"uri": "resource:org.accordproject.protocol@1.0.0.Template#latedelivery",
"author": "dan",
"displayName": "Late Delivery and Penalty",
"version": "1.0.0",
"description": "Penalties for late delivery of goods",
"license": "Apache-2.0",
"keywords": ["late", "delivery", "penalty"],
"metadata": {"$class": "org.accordproject.protocol@1.0.0.TemplateMetadata", "runtime": "typescript", "template": "clause", "cicero": "0.25.x"},
"templateModel": {"$class": "org.accordproject.protocol@1.0.0.TemplateModel", "typeName": "LatePenaltyClause", "model": {"ctoFiles": []}},
"text": {"templateMark": "Late Delivery and Penalty clause text..."}
}'
# Create an agreement referencing the template
curl -s -X POST http://localhost:9000/agreements \
-H 'Content-Type: application/json' \
-d '{
"uri": "apap://agreement-demo1",
"data": {"$class": "io.clause.latedeliveryandpenalty@0.1.0.TemplateModel", "forceMajeure": false, "penaltyPercentage": 10.5, "capPercentage": 55, "clauseId": "demo-1"},
"template": "resource:org.accordproject.protocol@1.0.0.Template#latedelivery",
"agreementStatus": "DRAFT"
}'
# Convert to markdown
curl http://localhost:9000/agreements/1/convert/markdown
# Convert to HTML (open in browser)
curl http://localhost:9000/agreements/1/convert/html -o /tmp/agreement.html && open /tmp/agreement.html
# Trigger agreement logic
curl -s -X POST http://localhost:9000/agreements/1/trigger \
-H 'Content-Type: application/json' \
-d '{"$class":"io.clause.latedeliveryandpenalty@0.1.0.LateDeliveryAndPenaltyRequest","forceMajeure":false,"goodsValue":1000}'
# Structured error handling -- typed error, not a bare string
curl http://localhost:9000/agreements/9999
# {"error":{"code":"AGREEMENT_NOT_FOUND","message":"Agreement not found: 9999","details":{"identifier":9999}}}MCP检查员
npx @modelcontextprotocol/inspector
# Open http://127.0.0.1:6274
# Select SSE transport, URL: http://localhost:9000/sse
# Browse Resources, call Tools (getAgreement, convert-agreement-to-format, trigger-agreement)测试
npm test # 53 tests + coverage
npm run test:unit # 44 unit tests (mocked DB, no Postgres)
npm run test:integration # 9 integration tests (real Express, mock DB)
npm run typecheck # TypeScript strict mode服务层覆盖范围: 98.69%的语句/92.3%的分支/100%的函数. errors.ts 和 templateService.ts 全面达到100%。
错误处理
RI投掷 new Error('Failed to load template') 在每一条失败的道路上。此POC用带有机器可读代码、HTTP状态映射和结构化详细信息的键入错误替换了这些错误:
| 错误类别 | HTTP | 代码 | 何时 |
|---|---|---|---|
TemplateNotFoundError | 404 | TEMPLATE_NOT_FOUND | 找不到模板ID/URI |
AgreementNotFoundError | 404 | AGREEMENT_NOT_FOUND | 未找到协议ID |
AgreementConversionError | 500 | AGREEMENT_CONVERSION_FAILED | 渲染失败 |
InvalidPayloadError | 400 | INVALID_PAYLOAD | 触发器负载不是有效的JSON |
TemplateDuplicateError | 409 | TEMPLATE_DUPLICATE | URI唯一性违规 |
ValidationError | 422 | VALIDATION_ERROR | 架构验证失败 |
MCP处理程序和REST路由器都有自己的catch块,用于映射 ServiceError 为他们的协议制定正确的响应形状。任何不是 ServiceError 被视为真正的500。
GSoC时间线映射
| 周 | 阶段 | 本POC涵盖的内容 |
|---|---|---|
| 1-4 | 服务层+错误类型 | src/services/ --完整的CRUD、转换、触发器、6个错误类 |
| 5-8 | 测试基础设施 | __tests__/ --跨两个MCP传输的单元+集成 |
| 9-10 | CI/CD | .github/workflows/ci.yml +Docker编写 |
| 11-12 | 观察性+文档 | Pino日志、健康检查、此自述文件 |
提案的第3-4阶段(OpenAPI验证、负载测试、贡献者文档)直接建立在这个基础之上。
现有技术
我已经在生产环境中两次发布了这种精确的共享服务层模式:
普利司通(2022-2024): 五个专业代理(分析、行程数据、驾驶员安全、车队性能、碰撞分析)通过内部REST网关路由到Postgres。重构为共享服务,将P95延迟降低了40%,并在其生命周期中首次将系统置于测试覆盖范围内。
阿亚医疗(2025): LangGraph多代理管道,具有5个以上代理(筛选、技能评估、匹配、调度、常见问题解答),通过OCI/AWS上的类型化服务功能共享Postgres后端。相同的模式,相同的可测试性DI方法。
链接
- APAP存储库 --POC重构的上游代码库
- 雅阁项目
- GSoC 2026创意 --想法#4
- MCP协议规范
许可证
阿帕奇-2.0
