MCP and Skills: the Protocol, Server/Client, How It Differs From Function Calling; a Tour of the Claude Agent SDK
Get acquainted with the MCP protocol's server/client structure and how it differs from plain function calling, write a minimal MCP server and wire it into an agent, then take a quick tour of the Claude Agent SDK.
Today's Goals
- Explain MCP's server/client structure and the problem it solves
- Compare MCP against plain function calling and where each one fits
- Write a minimal MCP server and wire it into your own agent
Yesterday you tightened the gates around tools: allowlists, argument caps, and untrusted input never treated as instructions. Those tools are still hard-coded into the agent — every new capability means editing the host's code and redeploying. Today that layer comes apart too.
Plain-Language Walkthrough
One driver disk per printer
Twenty years ago a printer came with a driver disk in the box. Installing meant inserting it, a different computer meant inserting it again, and an OS upgrade could break the driver. Worse from the vendor's side: the same printer needed a separate driver written for every operating system, and a new OS version meant another release. What you actually maintain is the two version numbers multiplied together.
Then came a standard printing protocol: the printer speaks the protocol, the operating system listens to the protocol, and it works when plugged in. The vendor need not care what system you run and the system need not know each specific printer, so both sides evolve without disturbing each other.
Wiring capabilities into an agent is currently at the driver-disk stage. Your agent needs to look up orders, so you import a function in your code, write a tool description, and register it in the tool table; the risk team gives you a blocklist query, so you import again and write another description. Look at what your code looks like now:
// With no protocol: every new capability edits the host's code and redeploys
import { queryOrder } from './tools/order.js'
import { trackShipment } from './tools/shipment.js'
import { queryRiskList } from './vendor/risk-sdk.js' // the risk team's package; only they know the fields
export const TOOLS = [
{ name: 'query_order', description: 'Look up an order status, amount and creation time by order number', run: queryOrder },
{ name: 'track_shipment', description: 'Look up shipping events and the carrier by order number', run: trackShipment },
// The risk team renamed a return field last week, so this line, this file, and this
// service all need a release
{ name: 'query_risk_list', description: 'Check whether a user is on the risk blocklist', run: queryRiskList },
]# With no protocol: every new capability edits the host's code and redeploys
from dataclasses import dataclass
from typing import Callable
from tools.order import query_order
from tools.shipment import track_shipment
from vendor.risk_sdk import query_risk_list # the risk team's package; only they know the fields
@dataclass
class ToolSpec:
name: str
description: str
run: Callable[[dict], str]
TOOLS = [
ToolSpec("query_order", "Look up an order status, amount and creation time by order number", query_order),
ToolSpec("track_shipment", "Look up shipping events and the carrier by order number", track_shipment),
# The risk team renamed a return field last week, so this line and this service need a release
ToolSpec("query_risk_list", "Check whether a user is on the risk blocklist", query_risk_list),
]// Dependencies: JDK 17+. A record describes this kind of immutable registry most cleanly.
// With no protocol: every new capability edits the host's code and redeploys
record ToolSpec(String name, String description, Function<Map<String, Object>, String> run) {}
static final List<ToolSpec> TOOLS = List.of(
new ToolSpec("query_order", "Look up an order status, amount and creation time by order number", OrderTools::queryOrder),
new ToolSpec("track_shipment", "Look up shipping events and the carrier by order number", ShipmentTools::trackShipment),
// The risk team renamed a return field last week, so this line and this service need a release
new ToolSpec("query_risk_list", "Check whether a user is on the risk blocklist", RiskSdk::queryRiskList)
);// With no protocol: every new capability edits the host's code and redeploys
struct ToolSpec {
let name: String
let description: String
let run: ([String: Any]) -> String
}
let tools: [ToolSpec] = [
ToolSpec(name: "query_order", description: "Look up an order status, amount and creation time by order number", run: queryOrder),
ToolSpec(name: "track_shipment", description: "Look up shipping events and the carrier by order number", run: trackShipment),
// The risk team renamed a return field last week, so this line and this service need a release
ToolSpec(name: "query_risk_list", description: "Check whether a user is on the risk blocklist", run: queryRiskList),
]Not one line of that is wrong. The problem is that it welds "who provides this capability" and "who uses it" into the same compilation. Three consequences, each dearer than the last:
One, integration cost is multiplication. You have more than one host: your own support agent, a coding assistant in the IDE, an on-call bot in the ops channel. And more than one capability: orders, shipping, refunds, risk, the knowledge base. Three hosts times five capabilities is fifteen integrations, each tested and upgraded separately. A protocol's entire value is turning that multiplication into addition.
Two, responsibility boundaries are erased. The risk blocklist query is maintained by the risk team, and that integration code is in your repository, built by your CI, and blamed on you first. They rename a field and you have to reproduce it on your own service before you can say it is not your fault.
Three, adding a capability requires a full release. Edit code, pass review, build, stage, observe. Wiring an internal tool into a live agent temporarily should be a configuration matter and is now a deployment.
That is the driver-disk era. And MCP (Model Context Protocol) wants to do what the printing protocol did: insert a layer both sides recognize between your program and a capability provider.
Which raises a question — is the function calling you have been writing for twenty-odd days not also "let the model use external capabilities"? So how do MCP and it relate? Many people's first instinct is "MCP is function calling's successor, so you will not write function calling any more." That answer is thoroughly wrong, and it is the kind an interviewer punctures in one sentence. Let us get it straight.
Server and client: who provides a capability, who consumes it
Separate three roles first, because these three words get used interchangeably in interviews:
- MCP server (the capability provider): an independent program exposing what it can do per the protocol. It may be a dozen-line script of your own, a service another team maintains, or a third-party package.
- MCP client (the connector): the small piece of the host that talks to one server. One client connects to one server, which is where many people go wrong.
- The host: your agent application itself. It holds several clients, each attached to a server.
Continuing with printers: the server is the printer, the client is the print queue in the operating system, and the host is your computer. Your computer can attach three printers, each with its own queue.
A server can expose three kinds of thing, so do not remember only tools:
| Primitive | What it is | Who chooses |
|---|---|---|
| tools | executable actions, such as looking up an order or filing a ticket | the model chooses; they enter its tool list |
| resources | read-only data fetched by URI, such as a document or a table's current snapshot | usually the user or the host decides whether the model sees it |
| prompts | reusable prompt templates, such as "write a ticket summary in this format" | usually triggered by the user |
Remember the difference in one sentence: tools are chosen by the model, and resources and prompts usually by a person. That division is not arbitrary — making a large document a resource rather than a tool moves the decision about spending those tokens back from the model to a person, which is part of the context budget (D6 covered why that budget is worth squeezing).
Conversely, a client can declare its own capabilities so a server can ask the host to do things: sampling (the server asks the host to run a completion), roots (the host tells the server which directories are visible), and elicitation (the server asks the host to request an input from the user). Those get one sentence today; knowing they exist is enough, since they come up far less often than tools.
There are two transports, so do not mix them up:
- stdio: the server is a local executable the host launches as a subprocess, with the two communicating over its standard input and output. Most local tools take this road.
- Streamable HTTP: remote servers take this one, with one HTTP endpoint sending and receiving messages, upgraded to an event stream on the same endpoint when push is needed.
There is also an older two-endpoint HTTP-plus-SSE transport, marked legacy in the official SDKs and kept only for old clients. Presenting it as current in an interview tells the other side you read last year's articles.
The messages themselves hold nothing novel, being JSON-RPC 2.0. Under stdio, one message per line, newline-delimited:
--> {"jsonrpc":"2.0","id":2,"method":"tools/list"}
<-- {"jsonrpc":"2.0","id":2,"result":{"tools":[
{"name":"query_order",
"description":"Look up one order status, amount and creation time by order number. Looks up the order itself only; use track_shipment for shipping events.",
"inputSchema":{"type":"object",
"properties":{"order_id":{"type":"string","pattern":"^SO\\d{8}$"}},
"required":["order_id"]}}]}}Look closely at inputSchema — it is an ordinary JSON Schema, identical in shape to the tool parameter definitions you hand-wrote on D5. Remember that detail; the next section's whole thesis rests on it.
The handshake order is fixed: the client sends initialize with its protocol version and capabilities, the server replies with its own, the client sends notifications/initialized to say it is ready, and only then come tools/list and tools/call. An official SDK does that for you, and you must at least know those steps exist or you cannot diagnose being stuck at any of them. Hand-writing it once is the fastest way to understand:
import { spawn } from 'node:child_process'
import { createInterface } from 'node:readline'
// stdio transport: the server is a subprocess whose stdout is a dedicated JSON-RPC pipe
const child = spawn('node', ['mcp-server.js'], { stdio: ['pipe', 'pipe', 'inherit'] })
const reader = createInterface({ input: child.stdout })[Symbol.asyncIterator]()
function send(payload) {
child.stdin.write(JSON.stringify(payload) + '\n') // the newline is the message boundary
}
async function rpc(id, method, params) {
send({ jsonrpc: '2.0', id, method, params })
for (;;) {
const { value, done } = await reader.next()
if (done) throw new Error('the server has exited')
const msg = JSON.parse(value)
if (msg.id === id) return msg.result // notifications have no id, so skip and keep waiting
}
}
await rpc(1, 'initialize', {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'shop-agent', version: '1.0.0' },
})
send({ jsonrpc: '2.0', method: 'notifications/initialized' }) // handshake step three, not optional
const listed = await rpc(2, 'tools/list', {})
const called = await rpc(3, 'tools/call', {
name: 'query_order',
arguments: { order_id: 'SO20260901' },
})import json
import subprocess
# stdio transport: the server is a subprocess whose stdout is a dedicated JSON-RPC pipe
child = subprocess.Popen(
["python", "mcp_server.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
encoding="utf-8",
)
def send(payload: dict) -> None:
child.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n") # newline is the boundary
child.stdin.flush()
def rpc(rid: int, method: str, params: dict) -> dict:
send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
for line in child.stdout:
msg = json.loads(line)
if msg.get("id") == rid: # notifications have no id, so skip and keep waiting
return msg["result"]
raise RuntimeError("the server has exited")
rpc(1, "initialize", {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "shop-agent", "version": "1.0.0"},
})
send({"jsonrpc": "2.0", "method": "notifications/initialized"}) # handshake step three
listed = rpc(2, "tools/list", {})
called = rpc(3, "tools/call", {"name": "query_order", "arguments": {"order_id": "SO20260901"}})// Dependencies: JDK 17+ ProcessBuilder plus Jackson. BufferedReader splits by line for you
var child = new ProcessBuilder("java", "-jar", "mcp-server.jar").start();
var out = new BufferedWriter(new OutputStreamWriter(child.getOutputStream(), UTF_8));
var in = new BufferedReader(new InputStreamReader(child.getInputStream(), UTF_8));
var mapper = new ObjectMapper();
Consumer<ObjectNode> send = payload -> {
try {
out.write(mapper.writeValueAsString(payload));
out.write('\n'); // the newline is the message boundary
out.flush(); // without flush you wait forever: the request sits in a buffer
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
JsonNode rpc(int id, String method, ObjectNode params) throws IOException {
var payload = mapper.createObjectNode();
payload.put("jsonrpc", "2.0").put("id", id).put("method", method).set("params", params);
send.accept(payload);
for (String line = in.readLine(); line != null; line = in.readLine()) {
var msg = mapper.readTree(line);
if (msg.path("id").asInt(-1) == id) return msg.get("result"); // notifications have no id
}
throw new IOException("the server has exited");
}import Foundation
// The idiomatic Swift approach types the messages rather than casting dictionaries everywhere
struct RpcRequest<P: Encodable>: Encodable {
let jsonrpc = "2.0"
let id: Int?
let method: String
let params: P?
}
struct RpcResponse<R: Decodable>: Decodable {
let id: Int?
let result: R?
}
struct ToolDescriptor: Decodable { let name: String; let description: String? }
struct ToolsList: Decodable { let tools: [ToolDescriptor] }
let child = Process()
child.executableURL = URL(fileURLWithPath: "/usr/bin/env")
child.arguments = ["node", "mcp-server.js"]
let toServer = Pipe()
let fromServer = Pipe()
child.standardInput = toServer
child.standardOutput = fromServer
try child.run()
// bytes.lines already splits and buffers by line, so no readline to attach as in JS
var lines = fromServer.fileHandleForReading.bytes.lines.makeAsyncIterator()
func send<P: Encodable>(_ request: RpcRequest<P>) throws {
var data = try JSONEncoder().encode(request)
data.append(0x0A) // the newline is the message boundary
try toServer.fileHandleForWriting.write(contentsOf: data)
}
func rpc<P: Encodable, R: Decodable>(_ id: Int, _ method: String, _ params: P) async throws -> R {
try send(RpcRequest(id: id, method: method, params: params))
while let line = try await lines.next() {
guard let data = line.data(using: .utf8),
let envelope = try? JSONDecoder().decode(RpcResponse<R>.self, from: data),
envelope.id == id // notifications have no id, so skip and keep waiting
else { continue }
if let result = envelope.result { return result }
}
throw URLError(.badServerResponse)
}
let listed: ToolsList = try await rpc(2, "tools/list", [String: String]())Three engineering costs, all concrete. One, a stdio server must never log to stdout — that is the protocol pipe, and one extra line of plain text gives the client an unparseable message. The nastiest part is that running the server alone works perfectly and it only breaks when attached as a subprocess. Two, the subprocess's lifecycle is yours: a host exiting without killing the server leaves a pile of orphans. Three, the diagnostic chain got longer: a tool not being called used to have one cause (the model did not pick it) and now has three — the model did not pick it, the schema was lost in translation, or the server never started.
The chapter's most valuable sentence: MCP is not function calling's successor
Now the judgment can be stated:
Function calling is the contract between the model and your program; MCP is the contract between your program and a capability provider.
They are not on the same segment of the chain, so they are not substitutes but upstream and downstream. The model does not know MCP exists — what it receives is always a tool list and a JSON Schema. Every tool you fetch from a server's tools/list is ultimately translated into function calling's shape before being sent. After adopting MCP, not one line of your function-calling code goes away.
How cheap is that translation? Look at the code. MCP's inputSchema is already a JSON Schema, so this is not a format conversion but a straight copy:
// MCP's inputSchema is already a JSON Schema, so this is a straight copy, not a conversion
export function toFunctionTool(tool) {
return {
type: 'function',
function: {
name: tool.name,
description: tool.description ?? '', // omit it and the model guesses from the name (D5's rule)
parameters: {
type: 'object',
properties: tool.inputSchema.properties ?? {},
required: tool.inputSchema.required ?? [],
},
},
}
}
// Local tools and MCP tools merge into one array for the model - it cannot tell, and need not
const tools = [...localTools, ...mcpTools.map(toFunctionTool)]# MCP's input_schema is already a JSON Schema, so this is a straight copy
def to_function_tool(tool: dict) -> dict:
schema = tool.get("inputSchema", {})
return {
"type": "function",
"function": {
"name": tool["name"],
# omit description and the model guesses from the name (D5's rule)
"description": tool.get("description", ""),
"parameters": {
"type": "object",
"properties": schema.get("properties", {}),
"required": schema.get("required", []),
},
},
}
# Local tools and MCP tools merge into one list for the model - it cannot tell, and need not
tools = local_tools + [to_function_tool(t) for t in mcp_tools]// Dependencies: Jackson. MCP's inputSchema is already a JSON Schema, so this is a straight copy
static ObjectNode toFunctionTool(JsonNode tool, ObjectMapper mapper) {
var schema = tool.path("inputSchema");
var parameters = mapper.createObjectNode();
parameters.put("type", "object");
parameters.set("properties", schema.has("properties")
? schema.get("properties")
: mapper.createObjectNode());
parameters.set("required", schema.has("required")
? schema.get("required")
: mapper.createArrayNode());
var function = mapper.createObjectNode();
function.put("name", tool.get("name").asText());
// omit description and the model guesses from the name (D5's rule)
function.put("description", tool.path("description").asText(""));
function.set("parameters", parameters);
var wrapper = mapper.createObjectNode();
wrapper.put("type", "function");
wrapper.set("function", function);
return wrapper;
}// MCP's inputSchema is already a JSON Schema, so this is a straight copy.
// Giving it a Codable type in Swift is far safer than carrying a [String: Any] dictionary
struct JsonSchema: Codable {
var type: String = "object"
var properties: [String: SchemaField]?
var required: [String]?
}
struct SchemaField: Codable {
let type: String
let description: String?
let pattern: String?
}
struct McpTool: Decodable {
let name: String
let description: String?
let inputSchema: JsonSchema
}
struct FunctionTool: Encodable {
struct Function: Encodable {
let name: String
let description: String
let parameters: JsonSchema
}
let type = "function"
let function: Function
}
func toFunctionTool(_ tool: McpTool) -> FunctionTool {
let parameters = JsonSchema(
properties: tool.inputSchema.properties ?? [:],
required: tool.inputSchema.required ?? []
)
return FunctionTool(function: .init(
name: tool.name,
// omit description and the model guesses from the name (D5's rule)
description: tool.description ?? "",
parameters: parameters
))
}Precisely because that step is this cheap, it proves they are upstream and downstream rather than substitutes. If MCP really were function calling's successor, this function would be hard to write because the two models differ; in fact it only rehouses the same schema.
In passing, what an MCP tool result looks like, which lines up directly with D5's error feedback: the result is an array of content blocks plus an isError boolean. isError being true is not an exception — the remote call succeeded and the tool failed on business grounds (no such order). What genuinely throws is a different class: arguments failing schema validation, or a nonexistent tool name, which goes through JSON-RPC's error. Both must be caught and both turned into a correction the model can read, and D5's error feedback needs not one word changed. Handle only one class and the other manifests as the model receiving an empty sentence and spinning in place.
So when should you adopt MCP? Three criteria, consider it if any one hits, and if none hits write function calling directly:
- This capability is reused by several hosts. One server integrated once serves three hosts, turning multiplication into addition.
- This capability is maintained by another team or a third party. A process boundary is a responsibility boundary; they change theirs and your service needs no release.
- Capabilities must be added or removed without editing the host's code. Add a line to the configuration, restart the host, and the new tool appears in the tool list.
Push it in with none of those hitting and what you pay is: one more process, one more handshake, one more diagnostic layer, and a subprocess lifecycle to keep alive yourself. A capability written for your own agent, maintained by you, and needing no hot-plugging is better engineering as a local function.
By now this should feel familiar: D4 made the model a pluggable provider, the site's payments a PaymentProvider, D20 made notification channels providers, and today the capability provider becomes pluggable. That is the fourth recurrence of one move — lifting "who does this" out of the code into an interface plus a piece of configuration. Recognizing the pattern is worth far more than memorizing MCP's method names.
Skills: MCP extends what it can do, Skills extend how well it does it
Back to printers: a driver makes a printer usable, and the vendor's paper presets, layout templates, and color profiles make it used well. Those two solve different problems.
Skills are the latter. They add no calling capability at all and instead package the knowledge needed to do one class of thing well: a prompt describing how, a few runnable scripts, and some reference material. The shape is roughly:
skills/
+-- refund-review/
+-- SKILL.md one sentence on the problem, when to use it, and the detailed steps
+-- scripts/
| +-- check-policy.py validates whether a refund exceeds this tier's cap
+-- references/
+-- refund-rules.md the full refund rules, read in only when a ruling is neededOne sentence pins the difference: MCP extends what it can do, and Skills extend how well it does it. One adds an action, the other adds a methodology.
The key mechanism is loading on demand, which connects straight to D6's context budget. Suppose you have twenty such work instructions; stuffed into the system prompt they can eat tens of thousands of tokens, paid for on every round, and nineteen of them are irrelevant to the current problem. Loading on demand puts only each skill's one-line summary in the context normally, and the body and references are read only when the model judges it applicable. From paying the full amount every round to paying only when used.
Two engineering costs, both worth stating up front. One, the hit decision is probabilistic. When to load a skill is judged by the model reading that summary, and a vague summary is never matched — the same thing as D5's tool descriptions, and equally worth polishing repeatedly. Two, a skill's scripts get executed. A package that can run scripts is a new attack surface, and yesterday's least privilege and sandboxing all apply: which files a script can read, whether it can reach the network, and how long before it times out must be decided by your runtime rather than by the package's prose.
Vendors' concrete implementations and directory conventions for Skills are not uniform and still moving. This section gives the thinking and no APIs; follow the official docs when implementing — remembering the shape of "a prompt plus scripts plus references, loaded on demand" is enough.
Adding the Claude Agent SDK to D21's table
D21 already made the Pi SDK versus LangGraph decision, so today does not reopen it, only adds a third option to the same table:
| Framework | What it abstracts | When to choose it | The cost |
|---|---|---|---|
| Pi SDK | one agent's loop: the model, kernel, and application layers (D3) | one agent suffices, you want every step of the loop visible, and you want it embedded in your own program | orchestration between roles is yours to write |
| LangGraph | state flowing between roles: nodes, edges, reducers, checkpointers (D15 to D18) | there are branches, loops, human intervention, and resumption | overkill for a single agent, and debugging depends heavily on tracing |
| Claude Agent SDK | a general agent runtime: file access and command execution built in, able to attach MCP servers and Skills | the agent's object of work is a code repository or the files on a machine | bound to one model vendor, with the runtime doing more for you and showing less |
Ask yourself one question when choosing: do I want to abstract away the loop, the orchestration, or the whole runtime? Those three answers map to the three rows and are hard to get wrong.
One closing reminder, and it is D4's old line: before using a vendor-bound runtime like the Claude Agent SDK, confirm you can accept that binding. Capabilities should be pluggable and so should models — the two are the same principle.
Source Reading
Hands-On Lab
starter/ has five exercise points and runs fully offline under MOCK=1. Note that MOCK=1 blocks only the model call's network egress, and the MCP server really is launched and the protocol really is exercised — the tool list, the argument-validation error, and the call result in the self-checks are all genuine round trips. toFunctionTool's blank returns exactly the hollow schema this chapter criticizes, so run it as-is first and watch the model submit an empty argument object and be turned back by the server.
- Register
track_shipmentwithregisterToolinmcp-server.ts, rerun, and watch check 2 go from one tool to two — with no host code touched. - Complete
toFunctionTool, copying MCP'sinputSchemainto function calling'sparametersas-is, and confirm check 3's two schemas match field for field. - Genuinely issue a
tools/callinmcp-client.tsand flatten the result into text, and check 4 completes the full chain. - Feed the failure text back to the model in
agent.ts(business failure viaisError, protocol failure by catching the exception), and in check 5 the model corrects the order number itself. - Write
shouldUseMcp's three criteria so that "write function calling directly when none hits" becomes the default answer.
Interview Questions
Today's four questions are in the bank below, weighted toward what MCP is, how it relates to function calling, and when to use it. Expand a question and read the analysis before the key points — the "upstream and downstream, not substitutes" line in question 1 is this chapter's crux, and question 4's third-party risk is the most likely follow-up, so do not skip it.
Checklist and Tomorrow
- Explain MCP's server/client structure and the problem it solves
- Compare MCP against plain function calling and where each one fits
- Write a minimal MCP server and wire it into your own agent
- State in one sentence how MCP and Skills divide the work: one extends what it can do, the other how well
- All 5 acceptance criteria of the lab pass
- Answer at least 3 of the 4 interview questions without looking at the key points
Tomorrow (D24) goes back to fix retrieval. Today made integrating a capability cheap, so you will naturally wire in a pile of knowledge bases and retrieval services — and D12's retrieval is still crude: one vector-similarity path only, so queries needing exact matches such as a model number, an error code, or an order number often miss entirely, with no metric telling you how much was missed. However much you wire in, it cannot rescue a retrieval whose recall is low to begin with. So we fix upstream first: hybrid search, reranking, citations, and a way to measure whether it finds things at all.
Interview questions
What problem does MCP solve, and how is it different from function calling?MCP 协议解决了什么问题?它和 function calling 有什么区别?
Common in ChinaCommon overseasBasic#mcp#tool-calling#protocolHow to reason about it · think before answering
- This question has a canonical wrong answer that interviewers screen on: calling MCP 'function calling v2' or saying you no longer need function calling. Say that and the rest of your answer cannot recover the points.
- Put each one back on its own hop and the confusion disappears: function calling is the contract between the model and your program; MCP is the contract between your program and a capability provider. Different hops, so they stack — they do not replace each other.
- Offer a one-line proof: every tool returned by an MCP server's tools/list carries an inputSchema that is already plain JSON Schema, and all you do is copy it into the parameters field of a function-calling tool definition. The model never learns MCP exists, and adopting MCP removes not a single line of your function-calling code.
- Then answer what it actually solves: integration cost goes from multiplication to addition. N hosts times M capabilities means N times M integrations; a shared protocol makes it N plus M. It also draws a responsibility boundary — a third-party capability failing is no longer something you must first reproduce inside your own service.
- Volunteer the Skills distinction, since it is the natural follow-up: MCP extends what the agent can do (new callable actions), Skills extend how well it does it (a bundle of prompt, scripts and reference material, loaded on demand). One adds capability, the other adds method.
- Expect the follow-up: then where is MCP's value? In standardizing discovery and invocation, so capabilities can be owned by another team, reused by several hosts, and added or removed without a code change — while the hop to the model stays function calling.
分析过程 · 先想清楚再作答
- 这题有一个标准的错误答案,面试官就是靠它筛人:把 MCP 说成「function calling 的升级版」「以后不用写 function calling 了」。说出这句,后面讲得再多也已经扣完分了。
- 把两者放回各自的链路上就不会混:function calling 是「模型 ↔ 你的程序」之间的约定,MCP 是「你的程序 ↔ 能力提供方」之间的约定。它们不在同一段线上,所以是上下游,不是替代。
- 给一个能一句话验证的证据:MCP server 通过 tools/list 返回的每个工具,它的 inputSchema 本身就是 JSON Schema,你要做的只是把它搬进 function calling 的 parameters 字段发给模型。模型自始至终不知道 MCP 存在。接了 MCP 之后 function calling 那段代码一行都不会少。
- 再答「解决了什么问题」:接入成本从乘法变加法。N 个宿主乘 M 个能力等于 N 乘 M 份接入代码,有了协议就变成 N 加 M;顺带把责任边界划清楚了,第三方能力出问题不用先在你的服务里复现。
- 顺手把 Skills 也区分掉,这是很自然的追问:MCP 扩展的是「能做什么」(新增可调用的动作),Skills 扩展的是「怎么做得好」(一组提示词、脚本和参考资料打成的按需加载包)。一个给能力,一个给方法论。
- 可以预期的追问:那 MCP 的价值到底在哪?答案是它把「能力的发现与调用」标准化了,所以能力可以由别人维护、被多个宿主复用、不改代码就增删——但发给模型的那一段,永远还是 function calling。
Key points
- Function calling is the model-to-your-program contract; MCP is the your-program-to-provider contract — they stack rather than replace
- Every MCP tool still gets translated into a function-calling JSON Schema before it reaches the model, which never learns MCP exists
- It solves integration cost: N hosts times M capabilities becomes N plus M, and the process boundary becomes the ownership boundary
- Calling MCP an upgraded function calling is the classic wrong answer — naming that yourself scores points
- Distinguish Skills too: MCP extends what the agent can do, Skills extend how well it does it
答题要点
- function calling 是「模型和你的程序」之间的约定,MCP 是「你的程序和能力提供方」之间的约定,两者是上下游不是替代
- MCP server 列出的每个工具最终仍要翻译成 function calling 的 JSON Schema 发给模型,模型不知道 MCP 存在
- 它解决的是接入成本:N 个宿主乘 M 个能力的乘法,变成 N 加 M 的加法,同时把责任边界划到进程边界上
- 把 MCP 说成 function calling 的升级版是最常见的错误答案,主动点破这一点会加分
- 顺带区分 Skills:MCP 扩展「能做什么」,Skills 扩展「怎么做得好」
What roles do the MCP server and client play, what can a server expose, and which transports exist?MCP 里 server 和 client 分别承担什么角色?server 能暴露哪几类东西,传输方式有哪些?
Common in ChinaCommon overseasIntermediate#mcp#protocol#transportHow to reason about it · think before answering
- This looks like recall, but it discriminates on two small things: whether you separate host from client, and whether you know there are primitives beyond tools. 'Server provides tools, client calls them' is below the bar.
- Lay out three roles: the server is the capability provider and its own process; the client is the piece inside the host that talks to exactly one server; the host is your agent application, holding several clients at once. People who conflate host and client fall apart the moment you ask how they would connect to three servers.
- Cover all three server-side primitives and say who chooses each: tools are executable actions chosen by the model; resources are read-only data addressed by URI; prompts are reusable templates — the latter two are normally chosen by the user or host. That 'who chooses' framing shows you actually read the spec: modeling a large document as a resource rather than a tool moves the decision to spend those tokens from the model back to a human.
- The client side declares capabilities too, letting the server call back into the host: sampling asks the host to run a model completion, roots tells the server which directories are visible, elicitation asks the host to collect user input. Naming them without elaborating is the right level of detail.
- Two transports: stdio for a local subprocess, Streamable HTTP for remote. The dated detail worth knowing is that the older two-endpoint HTTP+SSE transport is now legacy, kept only for backwards compatibility — presenting it as current signals you read last year's blog posts.
- Expect the follow-up: anything special about stdio servers? Stdout is reserved for JSON-RPC, so every log line must go to stderr or the client receives unparseable messages; and the host owns the subprocess lifecycle, so it must reap the child on exit or leave orphans behind.
分析过程 · 先想清楚再作答
- 这题看着是背概念,实际区分度在两个小地方:一是能不能把宿主和 client 分开说,二是知不知道 tools 之外还有别的原语。只答「server 提供工具、client 调用工具」是及格线以下。
- 先把三个角色摆清楚:server 是能力提供方,一个独立进程;client 是宿主里负责跟某一个 server 说话的那一小块,一个 client 只连一个 server;宿主是你的 Agent 应用,它同时持有多个 client。很多人把宿主和 client 当成一个东西,一问「连三个 server 怎么办」就露馅。
- server 侧三种原语要一起说,并且要说清谁来选:tools 是可执行的动作,由模型来挑;resources 是按 URI 读的只读数据;prompts 是可复用的提示词模板,后两者通常由用户或宿主来挑。这句「谁来选」比原语名字本身更能体现你真读过协议——把一份大文档做成 resource 而不是 tool,等于把花不花这笔 token 的决定权从模型手里收回给人。
- client 侧也能声明能力让 server 反过来请求宿主:sampling 是让宿主跑一次模型补全,roots 是告诉 server 哪些目录可见,elicitation 是请宿主向用户要一条输入。知道有这三样、不展开,分寸刚好。
- 传输两种:stdio 用于本地子进程,Streamable HTTP 用于远程。这里有个时间戳式的加分点——旧的 HTTP 加 SSE 双端点传输已经被标为 legacy,只为兼容老客户端保留;把它当现行方案讲,等于告诉对方你看的是去年的文章。
- 可以预期的追问:stdio server 有什么特别要注意的?答 stdout 被 JSON-RPC 独占,所有日志必须走 stderr,否则 client 会收到解析不了的消息;另外子进程的生命周期归宿主管,退出时要杀掉,不然留一堆孤儿进程。
Key points
- The server is the capability provider in its own process; a client connects to exactly one server; the host holds many clients
- Three server-side primitives: tools chosen by the model, resources as URI-addressed read-only data, prompts as reusable templates — the latter two usually chosen by a human
- Clients can declare sampling, roots and elicitation so the server can call back into the host
- Two transports: stdio for local subprocesses and Streamable HTTP for remote; the old HTTP+SSE transport is legacy
- On stdio, stdout belongs to JSON-RPC so logs must go to stderr, and the host must reap the child process
答题要点
- server 是能力提供方(独立进程),client 是宿主里连接单个 server 的那一块,宿主可以同时持有多个 client
- server 侧三种原语:tools 由模型挑,resources 是按 URI 读的只读数据,prompts 是可复用模板,后两者通常由人来挑
- client 侧还能声明 sampling、roots、elicitation,让 server 反过来请求宿主做事
- 传输两种:stdio(本地子进程)与 Streamable HTTP(远程);旧的 HTTP 加 SSE 已是 legacy,不要当现行方案讲
- stdio server 的 stdout 被 JSON-RPC 独占,日志必须走 stderr;子进程生命周期由宿主负责回收
When should you reach for MCP instead of plain function calling, and what does it cost when you shouldn't?什么场景下应该考虑用 MCP,而不是直接写 function calling?不该用的时候硬上会付出什么代价?
Common in ChinaCommon overseasIntermediate#mcp#architecture#trade-offsHow to reason about it · think before answering
- The hinge is the second half. Answering only 'MCP is more standard and decoupled' is like saying 'microservices are more decoupled' — true-sounding but with no criterion, and the interviewer will immediately ask whether you turned every tool into an MCP server.
- Give three actionable criteria: the capability must be reused by more than one host, owned by another team or a third party, or added and removed without changing host code. Any one of them justifies MCP; none of them means write a local function. Making 'no' the default answer shows more engineering judgment than the criteria themselves.
- Attach a reason to each: multi-host reuse turns N times M into N plus M; external ownership makes the process boundary the responsibility boundary, so their change is not your release; hot-swapping demotes adding an internal tool from a deployment to a config change.
- Then state the costs honestly, which is where shipped experience shows: another process to keep alive, another handshake with its own timeouts and reconnects, and a debugging path that went from one hop to three — a tool that never got called might mean the model did not pick it, the schema lost fields in translation, or the server never started. On stdio you also own reaping the child process.
- One more point that is easy to miss and scores well: MCP does not change your cost structure. Tool descriptions still enter the context every turn, and more tools still degrade tool selection. The rule that you should consolidate tools past a certain count survives MCP unchanged — arguably it matters more, because now other people can add entries to your tool list.
- Expect the follow-up: so internal tools never go through MCP? Not quite. If you want the same capability available to an IDE assistant and an ops bot as well, the first criterion is met even though you own the code.
分析过程 · 先想清楚再作答
- 题眼在后半句。只会说「MCP 更标准更解耦」的人,等于说「微服务更解耦」——听起来对,但没有判据,面试官会立刻追问「那你们所有工具都做成 MCP server 了吗」。
- 先给判据,而且要是可执行的三条:能力要被多个宿主复用、能力由另一个团队或第三方维护、需要不改宿主代码就能增删能力。命中任意一条才考虑,**一条都不命中就直接写本地函数**——把默认答案摆成「不上」,这条比三条判据本身更能体现工程判断。
- 每条判据配一句为什么:多宿主复用把 N 乘 M 变成 N 加 M;别人维护时进程边界就是责任边界,他们改他们的、你不用发版;热插拔让加一个内部工具从一次发布降级成一次配置变更。
- 然后老实说代价,这是区分「用过」和「读过」的地方:多一个进程要保活、多一次握手要处理超时与重连、排障链路从一段变三段——工具没被调用,现在可能是模型没选、可能是 schema 翻译时丢了字段、也可能是 server 压根没起来。stdio 的子进程还要你自己回收,否则留孤儿进程。
- 还有一条容易被忽略但很加分:MCP 不改变你的成本结构。工具描述照样每轮都进上下文,工具多了照样会让模型选错——D5 那条「工具超过一定数量就该合并描述」在接了 MCP 之后一字不变,甚至更需要,因为现在别人可以往你的工具列表里塞东西。
- 可以预期的追问:那内部工具一律不上 MCP 吗?不是。有一类值得例外——你希望它能被 IDE 里的助手和运维机器人一起用,那第一条判据就命中了,即使它是你自己维护的。
Key points
- Three criteria, any one justifies MCP: reuse across hosts, ownership by another team, or add/remove without touching host code
- The default is no — if none of the three apply, a local function is the better engineering decision
- Costs: another process to supervise, another handshake with timeouts, and a debug path that grows from one hop to three
- MCP does not change your cost structure: descriptions still enter context every turn and too many tools still hurt selection
- Once third-party capabilities are attached, your tool list is no longer fully under your control, which is itself a design problem
答题要点
- 三条判据,命中任意一条才考虑 MCP:多宿主复用、由他人维护、需要不改代码增删能力
- 默认答案是不上:三条都不命中就直接写本地函数,这是更好的工程决策
- 代价是多一个进程要保活、多一次握手要处理超时、排障从一段链路变成三段
- MCP 不改变成本结构:工具描述照样每轮进上下文,工具过多照样会让模型选错,该合并还是要合并
- 第三方能力接进来之后,工具列表不再完全由你掌控,这本身就是需要设计的一件事
You are about to attach a third-party MCP server in production. What worries you, and what do you check?你要把一个第三方维护的 MCP server 接进生产环境,会担心什么、做哪些检查?
Common in ChinaCommon overseasDeep dive#mcp#security#operationsHow to reason about it · think before answering
- This stacks yesterday's security topic onto today's openness topic, and it discriminates hard: every benefit of MCP rests on the capability being maintained by someone else, and that is also its biggest risk.
- First name the new trust assumptions: you put someone else's code into your own process tree, you feed its returned text straight into the model, and you let it add entries to your tool list. Each maps to a class of risk.
- Then go through the checks. Execution: the server is a process that runs, so constrain which files it can read, whether it has network access, its timeout and the identity it runs as — the least-privilege and sandbox story from yesterday. Data: treat everything it returns as untrusted input, which is exactly the indirect-injection scenario where instructions hide in a field of a tool result. Tool output is never instructions, and the permission gate must live in your process and fire before the call.
- Third, governance, the part most people miss: the tool list can change at runtime — one listChanged notification and a new tool appears. So pin your allowlist by tool name, keep newly appearing tools out of the model's list until a human approves, and pin the server version instead of tracking upstream latest.
- Fourth, availability and cost: this is a new external dependency. If it is down your agent silently loses a set of capabilities, so you need timeouts, graceful degradation (tell the model the capability is temporarily unavailable rather than failing the whole turn), and its calls on your observability dashboard.
- Expect the follow-up: how do you decide it is worth attaching at all? Back to the three criteria — if only one host uses it and you could implement it yourself, you are taking third-party risk with no matching benefit.
分析过程 · 先想清楚再作答
- 这题是把昨天的安全和今天的开放性叠在一起考,区分度极高:接 MCP 的全部好处,都建立在「能力由别人维护」这一点上,而这一点同时就是它最大的风险。
- 第一层想清楚新增了什么信任假设:你把一段别人写的代码放进了自己的进程树,把它返回的文本直接喂给了模型,还允许它往你的工具列表里加条目。这三件事各自对应一类风险。
- 第二层逐条给检查项。执行侧:server 是一个会跑起来的进程,要限制它能读哪些文件、能不能联网、超时多久、以什么身份运行,也就是昨天讲的最小权限和沙箱那一套。数据侧:**它的返回结果一律当不可信输入**,这正是昨天间接注入的固定现场——工具返回的备注字段里可以藏指令;所以工具结果不能当指令执行,权限闸门必须在你自己的进程里、在调用之前判。
- 第三层是治理,最容易被漏掉:工具列表可以在运行中变化,server 发一条 listChanged 通知就能加一个新工具。所以你的白名单要按工具名固定,新出现的工具默认不进模型的工具列表,要有人点头;server 的版本要锁定,不能跟着上游 latest 漂。
- 第四层是可用性与成本:这是一个新的外部依赖,它挂了你的 Agent 就少一批能力,所以要有超时、要有降级(工具不可用时告诉模型「这个能力暂时不可用」而不是整轮失败),要把它的调用计入你的可观测面板。这三条正好复用前面几周讲过的东西。
- 可以预期的追问:怎么判断它值不值得接?答案回到那三条判据——如果这个能力只有你一个宿主用,而且你完全可以自己实现,那接一个第三方 server 承担的风险没有对应的收益。
Key points
- Three new trust assumptions: their code in your process tree, their text in your model context, their entries in your tool list
- Execution: least privilege — restrict filesystem and network, set timeouts, run as a low-privilege identity, sandbox where warranted
- Data: treat every result as untrusted input; tool output is never instructions, and the permission gate must fire in your process before the call
- Governance: allowlist by tool name so newly appearing tools stay out until approved, and pin the server version rather than tracking latest
- Availability: treat it as an external dependency with timeouts, graceful degradation and dashboard coverage
答题要点
- 三个新增信任假设:别人的代码进了你的进程树、它的返回文本进了模型上下文、它能往你的工具列表里加条目
- 执行侧按最小权限收紧:限制文件访问与网络、设超时、以低权限身份运行,必要时进沙箱
- 数据侧一律当不可信输入:工具返回结果不能当指令执行,权限闸门必须在自己的进程里、在调用之前判
- 治理侧锁死变化面:按工具名做白名单,新出现的工具默认不进模型的工具列表;锁定 server 版本,不跟 latest
- 可用性侧当外部依赖对待:超时、降级、把它的调用与失败计入可观测面板