northcinder.shop
§ · Nested detail page

本地 MCP 与远端只读桥 深度解析

这一页拆 NorthCinder 的工具面:本地 server 可以进入授权和 checkout,remote bridge 只允许 search 与 trust。关键不是工具数量,而是权限按进程边界拆开。

§ 00 · IMPORT CHAIN

导入链与契约

本地 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。

src/remote/src/mcp-server.ts · L1-31TypeScript
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.
§ 01 · CORE IMPL

核心实现,逐段拆

▸本地 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。

src/client/src/server.ts · L339-426TypeScript
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。

src/remote/src/mcp-server.ts · L76-121TypeScript
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}
§ 02 · KEY FLOW

关键流程

client search -> verify ranking -> audit remote search -> sanitize upstream error -> verify ranking -> return read-only result remote never registers request_purchase_authorization / approve_purchase / complete_checkout

本地工具链从 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 的安全承诺复述成架构结论。

§ 03 · EDGE CASES

边界 / 失败模式

  • 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。
Sub-page · 2026-08-20 Source · cinderline/northcinder ↑ northcinder 主页面