Dayward AI
Week 3 · D15About 4 hours

Wiring Up MCP: a Hand-Written JSON-RPC Client, Two Transports, and a Tool Namespace

Wire someone else's tools into your own agent: hand-write an MCP client without the official SDK, get both stdio and Streamable HTTP transports working, aggregate remote tools into the local tool list with a namespace, and route them through the same approval gate from day five.

Today's goals 0/3

Sign in to tick these off and save your progress.

Today's Goals

  1. Hand-write an MCP client that can initialize, list tools, and call tools
  2. Explain the fit and failure modes of the stdio and Streamable HTTP transports
  3. Safely aggregate remote tools into the local tool list, handling name collisions and trust boundaries

The first two weeks built a self-sufficient Agent, every tool written by us. Week three connects outside things, starting with other people's tools. When you finish, scroll back up and tick the three goals off.

Plain-Language Walkthrough

Borrowing another department's tool room

For fourteen days, everything mca could do grew in our own hands. Today handles a different request — the job needs a tool we do not have. Check the team notes for a convention before touching the code; check the ticket system for a matching ticket afterwards. Neither lives in this repository, and neither should be ours to implement.

In shop-floor terms: the new hire has their own toolbox, but the department next door keeps a tool room with better gear. Today's job is to hand them a key to that room, while saying what is inside, how to borrow it, and who is responsible when something goes wrong.

MCP, the Model Context Protocol, is the standard shape of that key. It specifies how a tool room labels its shelves, hands a tool over, and reports a problem, so that tools and clients written to the same standard recognize each other. Today we write the client half.

Why not the official SDK? The same reason as the past fourteen days — this course is about writing it yourself — plus one more today: the traps at the protocol layer are the ones an SDK hides, and they are where production breaks. Half a message, an unfinished page, a notification mistaken for a response, a callee's self-issued permission taken at face value — an SDK absorbs all four, so you never learn they exist.

The protocol's three concerns, and the sliver we use today

Scope first. An MCP server offers tools, resources and prompts, and today we wire up only tools — tools are what the model reaches for on its own, while the other two are closer to "a human picks something and pastes it into the context."

Then the version. This course is written against the 2026-07-28 specification, which differs from many tutorials online by enough to send you down the wrong road. Three differences matter most.

One: the protocol is stateless. No initialize handshake, no handshake-complete notification. Version, identity and capabilities live in the _meta field of every request, repeated each time. It sounds wordy; the payoff is that any request can land on any replica without sticky routing.

Two: the server no longer sends JSON-RPC requests to the client. It puts "here is what I still need" into the result, and the client fills the gap and resends under a new id. We do not implement that branch today, but you must recognize it — a client that cannot will hand the model an unfinished result as if the work were done.

Three: two transports remain, stdio and Streamable HTTP. The previous version's separate long-lived GET, session header and stream resumption are gone — not deprecated, removed.

A complete request looks like this:

JSONJSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": { "query": "division by zero" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {},
      "io.modelcontextprotocol/clientInfo": { "name": "mca", "version": "0.15.0" }
    }
  }
}

Each key has a job. The version is mandatory; a server that rejects it answers -32022 with the versions it supports. Capabilities are mandatory too, and since this client neither renders forms nor lends the model out, it declares an empty object. The client identity is optional self-description, and the specification notes that nothing verifies it, so it may be displayed and logged, and must never inform a security decision.

One more trap: _meta hangs on params, not on the top level of the message. Get it wrong and the server says you failed to declare capabilities, when you clearly did — somewhere it does not look.

Hand-writing JSON-RPC: numbering, correlation, timeouts, teardown

The code at this layer is short, because it does four things.

Numbering: one incrementing id per request, unique only within this connection. Correlation: an incoming message finds the Promise waiting on its id, and a message with no id is a notification, not a response. Timeouts: over stdio, a server that never answers never answers, and that await hangs forever with the whole Agent behind it; there is no standard value to pick, but having none is always wrong. Teardown: when the transport dies, fail every waiting request at once, or a server that failed to start makes each request wait out its own timeout.

src/mcp/client.ts
request(method: string, params: Record<string, JsonValue> = {}) {
  if (this.dead) return Promise.reject(this.dead)
  const id = this.nextId++
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      this.pending.delete(id)
      reject(new McpError(`${method} timed out`))
    }, this.timeoutMs)
    this.pending.set(id, { resolve, reject, timer })
    this.transport.send({ jsonrpc: '2.0', id, method, params: withMeta(params) })
  })
}
 
private onMessage(message: JsonRpcMessage): void {
  // No id means a notification, not a response. Clients that correlate by
  // arrival order or array index go off by one right here
  if (typeof message.id !== 'number') return
  const waiter = this.pending.get(message.id)
  if (!waiter) return // A late response: it already timed out. Drop it, do not raise
  this.pending.delete(message.id)
  clearTimeout(waiter.timer)
  if (message.error) reject_(waiter, message.error)
  else waiter.resolve(message.result ?? {})
}

Fetching the tool list has one more unavoidable trap: tools/list is paginated. The server on your machine finishes in one page; the one your user installed may take ten. There is exactly one stopping rule — the absence of a next-page cursor. Do not guess from "this page is shorter than the last" (the specification never promises full pages), and do not parse the cursor (it is opaque, so pass it straight back). The failure is quiet: the user simply sees fewer tools, and nothing reports an error. The demo notes server holds three tools and returns two per page precisely to catch this.

stdio: one never-ending stream, and you draw the boundaries

The difference between the two transports is not speed; it is who draws the boundary of a message. On stdio the bytes are one never-ending stream and you split on newlines yourself, which produces today's most commonly miswritten line. The instinctive version:

TextText
child.stdout.on('data', chunk => {
  for (const line of chunk.split('\n')) handle(line)   // this line is wrong
})

What is wrong: one data event is not one line. It is "whatever was left over, plus some whole lines, plus a new leftover." The operating system guarantees byte order, not boundaries. Small messages behave perfectly; a tool result of a few kilobytes is certain to be cut in half, and parsing starts failing — unstably, too: fine on your machine, broken on the next one. The fix is a buffer that only hands out the part that ended in a newline:

src/mcp/transport.ts
export class LineFramer {
  private buffer = ''
 
  /** Feed a chunk of bytes, get back every complete line inside it */
  push(chunk: string): string[] {
    this.buffer += chunk
    const parts = this.buffer.split('\n')
    // The last piece has no trailing newline: it is half a line, keep it for next time
    this.buffer = parts.pop() ?? ''
    return parts.map((line) => line.trim()).filter((line) => line.length > 0)
  }
 
  /** At end of stream, hand back the leftover, or the final message is lost */
  flush(): string[] {
    const rest = this.buffer.trim()
    this.buffer = ''
    return rest ? [rest] : []
  }
}

Everything else stdio asks of you is about process lifecycle. Three rules.

Server logs go to stderr, and only stderr. One stray character on standard output and the client's parser starts complaining, in language that sounds like a broken protocol and mentions nothing about logs. Our matching guardrail: an unparseable line is recorded once and does not kill the transport.

A child process does not die with its parent. Kill it explicitly on exit, closing standard input first. Skip this and a few runs later the machine carries a trail of orphans.

A failure to start must surface immediately. A mistyped command or a missing dependency arrives on the process error event rather than the exit event, so catch both and translate either into "every waiting request fails together."

Streamable HTTP: one POST, two response shapes

On the HTTP side, HTTP draws the boundaries for you, so there is no framing to write. The cost shows up elsewhere.

First, the mandatory headers. This version adds three to every POST, mirroring the body's key fields onto the envelope:

httphttp
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search

Why duplicate them? Because middleboxes should not have to parse a body in order to route. A gateway splitting traffic by method name, or rate-limiting call requests specifically, needs only the headers. And once the middlebox decides on headers while the server executes on the body, a mismatch between the two is a vulnerability: a gateway configured for "listing is free, calling needs auth" is walked straight past by a request whose header says list and whose body says call. So the specification requires the server to verify that headers and body agree, answering 400 with -32020 when they do not. The demo server implements that check, and the self-test really sends a mismatched request at it.

An encoding note: HTTP header values are limited to visible ASCII, so a non-ASCII tool name cannot be expressed directly. The specification defines a sentinel format wrapping the Base64 of the raw bytes, and the server must decode before comparing.

Second, the response comes in two shapes, chosen by the server on the spot. A simple request gets ordinary JSON; a possibly slow one gets an event stream with progress notifications in the middle and the response, carrying the id, at the end. The client must accept both, and must not assume the last message in the stream is the response — the server is free to send a log notification after the result. One criterion: the message whose id matches is the response. Paired with it: a stream ending is not a request succeeding, so a mid-stream break must raise, or the caller waits out the timeout and the real cause is gone.

The demo ticket system answers exactly this way: a call emits a progress notification and then the result, and the self-test asserts "one notification plus one response correlated by id" — a client that mistakes the notification for the response reads "looking up the ticket system" and believes it is finished.

Which transport when: local things that travel with your machine use stdio; cross-machine things shared by a team use HTTP. The failure modes are symmetric — stdio fails by a process that will not start, logs polluting standard output, and orphaned children; HTTP by connection refused, a broken stream, and a middlebox rewriting things.

Now mount both servers at once: a notes library over stdio, a ticket system over HTTP. Each ships a tool called search — not a coincidence but an inevitability, since the specification only guarantees a tool name is unique within a single server. Disambiguation is the client's job, under one hard constraint: a server's self-reported name is not guaranteed unique across servers, so it must not be the basis for disambiguating. The prefix has to come from an alias in our own configuration, and a duplicate alias is a configuration error that should explode at startup rather than wait for the model to call the wrong tool. The shape is then bounded by what model APIs accept — most allow letters, digits, underscores and hyphens up to 64 characters — so we settle on mcp__alias__toolname.

src/mcp/mount.ts
export function namespacedName(alias: string, toolName: string): string {
  const raw = `mcp__${alias}__${toolName}`.replace(/[^A-Za-z0-9_-]/g, '_')
  if (raw.length <= 64) return raw
  // Truncation manufactures fresh collisions; the hash puts uniqueness back
  const digest = createHash('sha1').update(`${alias}::${toolName}`).digest('hex').slice(0, 6)
  return `${raw.slice(0, 57)}_${digest}`
}
 
// Reverse lookup goes through this table only. Never split the string back into
// alias and original name: tool names are allowed to contain underscores, and a
// truncated, hashed name cannot be taken apart at all
entryOf(localName: string) {
  return this.entries.get(localName)
}

Next comes the step most often written wrong: what goes back to the server on a call is the original name. The prefixed name circulates only between the model and the client; the server has never heard of it, and the symptom is that every remote call answers "no such tool." One technique goes with it: write the source into the description, because the description is the model's only basis for choosing a tool.

Everything else at this layer should be spent on failure, under one rule: try and catch each server during discovery, and never throw during a call. At discovery, wrap each server separately, record the reason and move to the next. At call time, translate every failure into a failed tool result, because the model can only change course if it can see what happened. This lab points the ticket system at an empty port and leaves the notes library's three tools usable — when one server dies, the worst outcome should be a few missing tools, not a failed turn.

One last thing: a dropped connection must be visible to the user. Quietly removing the dead server from the list makes the model behave as if that capability never existed, saying only "I could not find a related ticket." Losing a hand is an incident; pretending the hand was never there is a bigger one.

The trust boundary: who issues whose permit

Today's last decision is the only one that touches security directly.

A remote tool definition may carry annotations, one of which says "this is read-only." It looks exactly right: let read-only calls through, ask the user about the rest. But it cannot be believed, for one reason: it is a permit the callee issued to itself. The specification says so bluntly — a client must treat tool annotations as untrusted input.

So this course fixes one rule: remote tools are all treated as if they change things, landing in the ask branch of day five's approval gate. Permission tri-state and rule matching were covered on day five and are not repeated here — today's job is simply not to route around it.

Just as free is day three's truncation: remote and local results share the same 8000-character budget. This lab reads a deliberately long note and the result is truncated to 8052 characters — the 8000 of body plus a sentence saying how much was cut — on exactly the local path.

All of this works because to the loop, a remote tool is just one more tool definition. Not one line of the kernel changes today: truncation, approval, loop detection and snapshots all take effect automatically. This is another dividend from day two's layering — as long as a new capability can be expressed as a tool, it inherits every mechanism of the previous fourteen days for free.

One boundary: mounting must happen before the session is created, because the tool list is built once. Doing it mid-run is possible, and the price is that adding or removing tools dynamically invalidates the prompt cache (the tool table sits in the prompt prefix). That is a different decision, and this course does not make it.

Source Reading

Hands-On Lab

🧪 D15 lab: a zero-dependency MCP client that mounts a stdio and an HTTP server at once

Code location: labs/my-coding-agent-21days/day-15-mcp-client

The two demo servers in the lab directory are not exercises, they are what you connect to: the notes library runs as a child process with three tools returned two per page, and the ticket system runs on port 3115 and collides with it on search. Today leaves five exercises, all traps where the instinctive version behaves perfectly on small data: framing by splitting on newlines, taking only the first page, treating the stream's last message as the response, believing the self-declared read-only flag, and sending the namespaced name back to the server. The starter passes four of fourteen unmodified.

  1. Give the framer its buffer, then watch the first item and the long-note assertion turn green — the latter failed because a multi-kilobyte result was cut in half.
  2. Turn tool listing into a pagination loop with "no next cursor" as the criterion, and watch the notes library go from two tools to three.
  3. Make the event-stream branch hand up every message and use the id to pick the response, then watch the notification count go from 0 to 1.
  4. Force every remote tool's read-only flag to false, and watch the self-declared read-only search tool wait for approval too.
  5. Send the original name back to the server, run MOCK=1 SELFTEST=1 pnpm start for 14 of 14, then use the README's pipe commands to inspect the namespace, the approvals and the failure isolation.

Acceptance is five ticks: the self-test passes all 14 items; pagination surfaces three tools; one remote call receives one notification plus one response correlated by id; the two search tools coexist and each reverses back to its original name; and pointing the ticket system at an empty port leaves the notes library working, with a call to it producing a failed result rather than an exception.

Interview Questions

Today's three questions test implementation judgment for a protocol client, not "what is MCP":

  1. Hand-writing an MCP client without an SDK, which protocol details must you handle?
  2. Where do stdio and Streamable HTTP each fit? How do their failure modes differ?
  3. Aggregating third-party tools into your own Agent, what governance would you add?

Full prompts, analyses and key points are in this course's day-fifteen question bank. Question three discriminates most — most answer "prefix them to avoid collisions," and few can say why a server's read-only declaration must not be used as a permission.

Checklist and Tomorrow

  • I can say where version and capabilities live under a stateless protocol, and what a misplaced _meta looks like
  • I can name a JSON-RPC client's four jobs: numbering, correlating by id, timing out, and tearing down at once
  • I can explain why "no id means a notification" cannot be skipped
  • I can state the one criterion for pagination, and why getting it wrong reports no error
  • I can explain where half a message comes from, and why it only shows up on large results
  • I can name stdio's three lifecycle rules: logs to stderr, explicit teardown, failures surfacing immediately
  • I can say why the three mandatory headers exist, and why a header-body mismatch is a vulnerability
  • I can say why the last message in an event stream is not necessarily the response
  • I can explain why the prefix must come from our own alias, and why reverse lookup goes through a table
  • I can say why a self-declared read-only flag must not decide whether to ask for approval

Tomorrow is D16, "Loading Skills: Scanning, Progressive Disclosure, and Trigger Judgment." Today we wired up someone else's tools; tomorrow, someone else's experience. The difference is worth thinking about in advance: a tool occupies a slot in the tool list, while experience is just text — so on what grounds does it also need loading on demand?

Interview questions

  • If you hand-write an MCP client without the official SDK, which protocol details do you have to handle yourself?不用官方 SDK 手写一个 MCP 客户端,你必须自己处理哪些协议细节?
    Common in ChinaCommon overseasBasic#json-rpc#mcp-client

    How to reason about it · think before answering

    1. What is tested is whether you have actually written a protocol client. People who only used an SDK answer "connect, list tools, call tools"; people who wrote one start from the edge cases.
    2. How to break it down: protocol shape, request/response correlation, pagination, and error classification — each has a decision you must make yourself.
    3. Protocol shape - the 2026-07-28 revision is stateless. There is no handshake; version, identity and capabilities travel in the _meta of every single request, and _meta belongs on params, not at the top level of the envelope.
    4. Correlation needs four things - allocate ids, match responses by id, enforce a timeout, and fail every in-flight request at once when the transport dies. The key judgment is that a message without an id is a notification, not a response; matching by arrival order will drift.
    5. Pagination has exactly one criterion - you are done when there is no next cursor. Do not guess from a short page, and never parse the cursor; it is opaque to clients.
    6. Error classification splits in two - an error field in the response is a protocol error the model cannot fix, while a result flagged as a failed execution is meant for the model and must be fed back verbatim. Throwing on the latter turns a self-healing call into a hard failure.
    7. Likely follow-ups - why a late response is dropped instead of raised; how you pick a timeout; what happens if you declare an empty client capability set.

    分析过程 · 先想清楚再作答

    1. 这题在考「你是不是真写过一个协议客户端」。只用过 SDK 的人会答「连上、列工具、调工具」三步,写过的人会先说边界。
    2. 怎么拆:把它拆成协议形状、请求应答、分页、错误分类四块,每块各有一个必须自己做的决定。
    3. 协议形状这一块,2026-07-28 版是无状态的:没有握手,版本、身份、能力写在每一条请求的 _meta 里,而且是加在 params 上不是加在报文顶层。
    4. 请求应答这一块有四件事:发号、按 id 配对、超时、传输死掉时把所有在等的请求一次性失败掉。关键判断是「没有 id 的是通知不是响应」,按到达顺序配对一定会错位。
    5. 分页这一块判据唯一:没有下一页游标才是结束。不能用「这一页比上一页少」去猜,也不能解析游标——它对客户端不透明。
    6. 错误分类这一块要分两类:响应里带 error 的是协议错误,模型改不了;结果里标了执行失败的是给模型看的,要原样回灌让它换个参数。把后者也抛成异常,一次本来能自愈的调用就变成一次失败。
    7. 可预期的追问:为什么迟到的响应要丢掉而不是报错;超时值怎么定;客户端能力声明成空对象会有什么后果。

    Key points

    • Stateless protocol - no handshake; version and capabilities ride in the _meta of every request, attached to params
    • Four correlation duties - allocate ids, match by id, time out, fail all pending requests when the transport dies; a message without an id is a notification
    • Pagination ends only when there is no next cursor; the cursor is opaque and must be echoed back unchanged
    • Raise protocol errors; feed tool execution errors back to the model verbatim
    • Drop late responses silently; having no timeout at all is always wrong

    答题要点

    • 无状态协议:没有握手,版本与能力写在每条请求的 _meta 里,且挂在 params 上
    • 请求应答四件事:发号、按 id 配对、超时、传输死掉时一次性收摊;没有 id 的是通知
    • 分页只认「没有下一页游标」,游标不透明、原样带回
    • 协议错误抛出去,工具执行错误原样回灌给模型
    • 迟到的响应丢掉但不报错;不设超时一定是错的
  • When would you use the stdio transport versus Streamable HTTP, and how do their failure modes differ?stdio 与 Streamable HTTP 两种传输分别适合什么场景?它们的失败方式有什么不同?
    Common in ChinaCommon overseasIntermediate#transports#stdio-vs-http

    How to reason about it · think before answering

    1. This tests whether you have actually wired up both. People who used only one start from performance; people who used both start from who draws the message boundary.
    2. How to break it down - fit first, then message framing, then failure modes side by side. The third part is where candidates separate.
    3. Fit - stdio for local servers that travel with the user's machine and need no auth; HTTP for cross-machine, multi-user servers that need auth and rate limiting.
    4. Framing - on stdio the bytes are one endless stream and you must split on newlines yourself, which means buffering partial lines. That bug only shows up once a result is large enough to be chopped, so small payloads never reveal it. Over HTTP the boundary comes from HTTP, but a response has two shapes, and the event-stream shape interleaves notifications, so the rule is that only a message carrying a matching id is the response.
    5. Failure modes - stdio fails by a process that will not start, a server writing logs to stdout and corrupting the frames, and orphaned children after the parent exits. HTTP fails by refused connections, streams cut mid-flight, and middleboxes rewriting or buffering.
    6. Easy to miss - the 2026-07-28 revision removed stream resumption, so a broken stream means that request is lost and must be re-sent under a brand new id. The protocol layer does no compensating delivery.
    7. Likely follow-ups - why server logs must go to stderr; why the end of a stream does not imply success; whether one client implementation can serve both transports (yes, by abstracting the transport down to moving bytes).

    分析过程 · 先想清楚再作答

    1. 这题在考「你有没有两种都真的接过」。只接过一种的人会从性能答起,两种都接过的人会先说边界由谁划。
    2. 怎么拆:先说适用场景,再说消息边界,最后把失败方式对照着列——第三块才是区分度所在。
    3. 适用场景:本地的、跟着用户机器走的、不需要鉴权的用 stdio;跨机器的、多人共用的、要鉴权与限流的用 HTTP。
    4. 消息边界:stdio 上字节是一条永不结束的流,边界要自己按换行切,所以必须留缓冲区处理半行报文——这个 bug 只在结果大到被切开时才暴露,小报文测不出来。HTTP 上边界由 HTTP 划,但响应有两种形状,事件流那一支里混着通知,判据是「带 id 且 id 对得上的那条才是响应」。
    5. 失败方式:stdio 是进程起不来、服务端把日志打进标准输出污染报文、父进程退出留下孤儿进程;HTTP 是连不上、流中途断开、被中间层改写或缓冲。
    6. 一条容易漏的:2026-07-28 删掉了断流续传,流断了这次请求就是丢了,必须换一个新 id 重发,协议这一层不做补偿投递。
    7. 可预期的追问:为什么服务端的日志只能走 stderr;流结束为什么不等于请求成功;两种传输能不能共用同一个客户端实现(能,把传输抽成只管收发字节的接口)。

    Key points

    • stdio fits local, single-user, no-auth servers; HTTP fits cross-machine, multi-user servers needing auth and rate limits
    • On stdio you draw the boundaries yourself and must buffer partial lines; the bug only surfaces on large results
    • HTTP responses come in two shapes; the event stream interleaves notifications, and only a matching id marks the response
    • Typical stdio failures - process will not start, logs poison stdout, orphaned children
    • Typical HTTP failures - refused connection, stream cut mid-flight, middlebox rewriting; a broken stream must be re-sent under a new id

    答题要点

    • stdio 适合本地、单用户、无鉴权;HTTP 适合跨机器、多用户、要鉴权与限流
    • stdio 的边界要自己划,必须处理半行报文;这个 bug 只在大结果上暴露
    • HTTP 的响应有两种形状,事件流里混着通知,判据是 id 对得上
    • stdio 的典型失败:起不来、日志污染标准输出、孤儿进程
    • HTTP 的典型失败:连不上、流中断、被中间层改写;流断了必须换新 id 重发
  • What governance would you put in place before aggregating third-party MCP server tools into your own agent?把第三方 MCP server 的工具聚合进自己的 Agent,你会加哪些治理措施?
    Common in ChinaCommon overseasDeep dive#trust-boundary#tool-governance

    How to reason about it · think before answering

    1. This tests trust boundaries, not features. Most people only say "prefix the names to avoid collisions", which is the shallowest layer of governance.
    2. How to break it down - naming, trust, failure, budget. Give one concrete measure and one counter-example per layer.
    3. Naming - the prefix must come from your own configured alias, because the spec only guarantees tool-name uniqueness within a single server and explicitly says a server's self-reported name is not unique across servers and must not be used to disambiguate. Duplicate aliases should fail at startup. Reverse lookup goes through a table, never string splitting, since original names may contain underscores and truncated-plus-hashed names cannot be split back. The name sent back to the server must be the original one.
    4. Trust is the crux - a readOnly hint in the tool annotations cannot be used as a permission decision. It is a pass the callee issued to itself, and the spec requires clients to treat tool annotations as untrusted input. The safe stance is to treat every remote tool as state-changing and route all of them through the approval gate. By the same logic, tool descriptions are text written by someone else that lands in the model's context, so they are prompt-injection surface.
    5. Failure - wrap discovery per server in try/catch and record the reason; never throw during a call, translate every failure into a failed tool result. Make outages visible, because silent degradation makes the model say "I could not find anything" instead of "that system is unreachable".
    6. Budget - remote results share the same truncation budget as local ones, and when there are too many tools, mount on demand while knowing that adding or removing tools mid-session invalidates the prompt cache.
    7. Likely follow-ups - how to stop one server from blowing up the whole tool table; whether tool descriptions count as untrusted input; whether remote calls deserve their own audit log.

    分析过程 · 先想清楚再作答

    1. 这题在考信任边界,而不是功能。多数人只答「加前缀防重名」,那只是治理里最浅的一层。
    2. 怎么拆:分成命名、信任、失败、预算四层,每层给一条具体措施和一条反例。
    3. 命名层:前缀必须来自客户端自己的配置别名,因为规范只保证工具名在单个 server 内唯一,而且明说服务端自报的名字不保证跨 server 唯一、不该拿它消歧。别名撞了要在启动时报错。反查只能走一张表,不能切字符串——原名里本来就允许有下划线,截断加过哈希的更是拆不回来。调用时发回 server 的必须是原名。
    4. 信任层是这题的题眼:服务端在工具注解里声明的只读提示不能当权限用,那是被调用方给自己发的通行证,规范要求客户端把工具注解当成不可信输入。稳妥口径是远端工具一律按「会改东西」对待,全部过审批门。同理,工具描述是别人写的文本,会进模型上下文,属于提示注入面。
    5. 失败层:发现阶段逐个 try/catch 并记下原因,调用阶段一律不抛、全部翻译成一条失败的工具结果;掉线要让用户看得见,静默降级会让模型说「我查不到」而不是「那个系统连不上」。
    6. 预算层:远端结果和本地结果共用同一份截断预算;工具太多时按需挂载,但要知道动态增删工具会打掉提示缓存。
    7. 可预期的追问:怎么防止一个 server 把整个工具表撑爆;工具描述算不算不可信输入;要不要给远端调用单独记审计日志。

    Key points

    • Namespace prefixes come from client-side aliases; duplicate aliases fail at startup; reverse lookup uses a table, and the original name is what goes back to the server
    • A server's self-declared read-only annotation is untrusted; treat every remote tool as state-changing and route it through the approval gate
    • Tool descriptions are third-party text that enters the model's context, so they are prompt-injection surface and must be treated as untrusted input
    • Wrap discovery per server and record reasons; never throw during a call; make outages visible instead of degrading silently
    • Remote results share the local truncation budget; on-demand mounting saves context but invalidates the prompt cache

    答题要点

    • 命名空间前缀来自客户端配置的别名,别名冲突在启动时报错;反查走表不切字符串;发回 server 的是原名
    • 服务端自报的只读注解不可信,远端工具一律按会改东西对待、全部过审批门
    • 工具描述是别人写的文本且会进上下文,属于提示注入面,要当成不可信输入
    • 发现阶段逐个 try/catch 记原因,调用阶段一律不抛;掉线要让用户看得见,不做静默降级
    • 远端结果共用本地那份截断预算;按需挂载可以省上下文,但会打掉提示缓存

Comments