本地 MCP 与远端只读桥 深度解析
这一页拆 NorthCinder 的工具面:本地 server 可以进入授权和 checkout,remote bridge 只允许 search 与 trust。关键不是工具数量,而是权限按进程边界拆开。
导入链与契约
本地 server 的依赖注入在 client/src/server.ts:113 暴露 authorizations、checkout、audit、profile、watches 与 orders;remote bridge 的文件头在 remote/src/mcp-server.ts:1 直接说明无授权与 checkout,remote/src/mcp-server.ts:25 还强调 stateless。搜索结果被本地缓存于 client/src/server.ts:371,用于后续 authorization 绑定真实 offer。
1/** 2 * The optional self-hosted, read-only NorthCinder MCP bridge, exposed over 3 * Streamable HTTP for clients that cannot run a local stdio process. The 4 * person deploying it owns the process and keys; there is no NorthCinder-operated endpoint. 5 * 6 * Deliberately NARROWER than client/src/server.ts (the stdio client): exactly 7 * two tools, both read-only — 8 * - search_products: criteria search, neutrality-ranked, with the buyer brief 9 * buyer's brief and the profile interpretation echo (this variant is 10 * STATELESS v1 — there is no per-user profile store on this shared host, 11 * so the interpretation echo always shows "nothing applied"). 12 * - get_trust_signal: merchant trust lookup. 13 * 14 * NO authorization/checkout/watch/profile/orders tools exist here — there is 15 * no code path from this server to a purchase, a mandate, or a checkout rail 16 * (safety contract law: no unattended checkout anywhere). This is enforced structurally 17 * (the tools are simply never registered), and remote/test/mcp-server.test.ts 18 * asserts their absence architecturally, the same way watch asserts local 19 * checkout-tool absence from watch-only surfaces. 20 * 21 * Because each HTTP request in the Streamable-HTTP *stateless* transport mode 22 * gets a fresh McpServer + transport (see src/http-server.ts), there is no 23 * cross-call session memory: get_trust_signal takes a full `merchant` object 24 * (as returned by search_products in the SAME tool-call response) rather than 25 * a session-remembered merchantId. 26 * 27 * Neutrality-proof loop (ranking verification): this server calls the SAME buyer-run engine the 28 * stdio client calls, over the SAME NorthCinderServiceClient HTTP contract, and 29 * re-runs verifySearchRanking over the engine's own inputs exactly like the 30 * stdio client does — the remote bridge does not get to skip the honesty 31 * that the local client is held to.
核心实现,逐段拆
▸本地 search_products:验证、缓存、审计
本地 search_products 从 client/src/server.ts:339 开始校验 SearchRankResponseSchema,client/src/server.ts:365 记录 searchId,client/src/server.ts:373 写入 merchant map,client/src/server.ts:382 重新计算排名,client/src/server.ts:412 写入 search audit。这些状态让后续工具只能引用本 session 已见过的 offer。
339 function finalizeSearch(params: { 340 searchId: string; 341 interpreted: InterpretedQuery; 342 data: SearchRankResponse; 343 continuedFrom?: string; 344 }) { 345 const { searchId, interpreted, continuedFrom } = params; 346 const parsedData = SearchRankResponseSchema.safeParse(params.data); 347 if (!parsedData.success) { 348 return failure({ 349 code: "invalid_service_response", 350 message: "configured engine returned an invalid search response", 351 }); 352 } 353 const data = parsedData.data; 354 const query = interpreted.criteria; 355 const { results, storeStatuses, trustSignals, registeredStores, browserObservationReport } = data; 356 357 seenSearches.set(searchId, interpreted); 358 359 for (const r of results) { 360 seenOffers.set(offerKey(r.offer.sourceStore, r.offer.id), r.offer); 361 const merchantKey = trustKey(r.offer.merchant); 362 seenMerchants.set(merchantKey, r.offer.merchant); 363 const keys = seenMerchantKeysById.get(r.offer.merchant.id) ?? new Set<string>(); 364 keys.add(merchantKey); 365 seenMerchantKeysById.set(r.offer.merchant.id, keys); 366 } 367 368 const verification = verifySearchRanking(data, query); 369 const coverage = verifyStoreCoverage(registeredStores, storeStatuses); 370 const brief = composeBuyersBrief({ 371 searchId, 372 results, 373 interpretedQuery: interpreted, 374 storeStatuses, 375 trustSignals, 376 }); 377 seenBriefs.set(searchId, brief); 378 379 const localTrustEvidence: Record<string, ReturnType<typeof localTrustEvidenceFor>> = {}; 380 const localTrustEvidenceKeys: Record<string, string> = {}; 381 for (const f of brief.finalists) { 382 const finalistKey = offerKey(f.sourceStore, f.offerId); 383 const fullMerchant = seenOffers.get(finalistKey)?.merchant; 384 if (!fullMerchant) continue; 385 const merchantKey = trustKey(fullMerchant); 386 localTrustEvidenceKeys[finalistKey] = merchantKey; 387 const lines = localTrustEvidenceFor(fullMerchant); 388 if (lines.length > 0) localTrustEvidence[merchantKey] = lines; 389 } 390 391 deps.audit.append({ 392 type: "search", 393 searchId, 394 ...(continuedFrom !== undefined ? { continuedFrom } : {}), 395 query, 396 interpretedQuery: interpreted, 397 storeStatuses, 398 ...(browserObservationReport !== undefined ? { browserObservationReport } : {}), 399 rankingVerified: verification.verified, 400 ...(verification.verified === false 401 ? { 402 rankingDivergences: verification.divergences, 403 rankingExpectedOrder: verification.expectedOrder, 404 rankingActualOrder: verification.actualOrder, 405 } 406 : {}), 407 ranking: results.map((r) => ({ 408 offerId: r.offer.id, 409 title: r.offer.product.title, 410 store: r.offer.sourceStore, 411 merchantId: r.offer.merchant.id, 412 price: r.offer.price, 413 sponsored: r.offer.sponsored, 414 ...(r.offer.acquisition !== undefined ? { acquisition: r.offer.acquisition } : {}), 415 score: r.score, 416 reasons: r.reasons, 417 })), 418 }); 419 420 const statusLines = storeStatuses 421 .map((s) => 422 s.ok 423 ? ` ✓ ${s.store}: ${s.offerCount} offer(s) in ${s.durationMs}ms` 424 : ` ✗ ${s.store}: ${s.error.code} — ${s.error.message}`, 425 ) 426 .join("\n");
▸远端 bridge:只读且清洗错误
remote bridge 仍然会校验 ranking 与 coverage,但在 remote/src/mcp-server.ts:76 先清洗上游 service 错误,remote/src/mcp-server.ts:125 注册 search_products,remote/src/mcp-server.ts:176 处理 upstream failure,remote/src/mcp-server.ts:199 复用 verifyRanking,remote/src/mcp-server.ts:219 返回 rankingVerified。
76/** 77 * Sanitize an upstream-service error before it reaches an authenticated 78 * third-party MCP client on this PUBLIC bridge. 79 * 80 * `service-client.ts` (mirrored from the trusted local stdio client, where 81 * this detail is fine) builds `service_unreachable` messages containing the 82 * real `NORTHCINDER_SERVICE_URL` + path, and `service_error`/HTTP-error messages 83 * that can echo up to 200 chars of the upstream response body. Neither 84 * infra topology nor upstream internals should ever reach an arbitrary 85 * API-key holder — do not log the detail either, because production log 86 * access is broader than the upstream credential boundary; return a generic, 87 * bounded message. Every other error code (invalid_query, invalid_merchant, 88 * invalid_service_response, …) is already safe to pass through verbatim — 89 * those messages describe the caller's OWN input or a schema mismatch, not 90 * upstream infra. 91 */ 92function sanitizeServiceError(error: ToolFailure): ToolFailure { 93 const isUnreachable = error.code === "service_unreachable"; 94 const isUpstreamHttpError = error.message.startsWith("service HTTP "); 95 if (!isUnreachable && !isUpstreamHttpError) return error; 96 console.error(`[northcinder-remote] upstream error suppressed from client response (${error.code})`); 97 return { 98 code: error.code, 99 message: isUnreachable ? "configured engine unavailable" : "configured engine error", 100 }; 101} 102 103function money(m: { amount: number; currency: string }): string { 104 return `${(m.amount / 100).toFixed(2)} ${m.currency}`; 105} 106 107/** 108 * Verify that the service accounted for every adapter it says is registered. 109 * Keep this consumer-local rather than trusting the local client package: the 110 * self-hosted bridge is independently deployable and must not silently bless an 111 * omitted (or duplicated) store status. 112 */ 113function verifyStoreCoverage(registeredStores: readonly string[] | undefined, statuses: readonly { store: string }[]) { 114 if (registeredStores === undefined) return { verified: "not_applicable" as const, missing: [], unexpected: [] }; 115 const registered = new Set(registeredStores); 116 const counts = new Map<string, number>(); 117 for (const status of statuses) counts.set(status.store, (counts.get(status.store) ?? 0) + 1); 118 const missing = [...registered].filter((store) => !counts.has(store)).sort(); 119 const unexpected = [...counts.keys()].filter((store) => !registered.has(store) || (counts.get(store) ?? 0) !== 1).sort(); 120 return { verified: missing.length === 0 && unexpected.length === 0, missing, unexpected }; 121}
关键流程
本地工具链从 client/src/server.ts:507 注册 search_products,client/src/server.ts:648 开始 profile 工具,client/src/server.ts:904 开始 watches;remote 只在 remote/src/mcp-server.ts:123 创建 server。这种差异是安全边界本身,而不是部署形态的偶然结果。
▸验证联动
这页把实现和测试一起看:源码引用说明控制点,测试引用说明这些控制点已经被行为化约束,避免只把 README 的安全承诺复述成架构结论。
边界 / 失败模式
client/src/server.ts:572接受 browser observations,但后续购买前仍会因 native revalidation 被拒。remote/src/mcp-server.ts:95对任意错误消息做截断,避免把内部异常原样暴露给 API key 持有人。client/src/server.ts:285的 local trust evidence 只展示,不影响排序,防止本地历史污染 ranking。