Model Context Protocol (MCP) & Extensible Tool Integration

The model never talks to Postgres
MCP does not give a language model a database connection. It gives a host application a JSON-RPC 2.0 contract for listing, describing, and invoking capabilities that you implement, then the host decides what the model is allowed to see. That split is the whole protocol. Forget it and you will ship a “MCP server” that is really an unauthenticated RPC proxy with a JSON Schema taped to the front.
The current normative revision is 2026-07-28, published 28 July 2026, with the TypeScript schema in schema.ts as source of truth. Earlier revisions (2025-11-25 and before) were stateful: initialize / initialized, an Mcp-Session-Id, and servers that could fire JSON-RPC requests back at the client over a held-open stream. The new core is the opposite. Every request is self-contained. Capabilities, protocol version, and client identity travel in _meta. There is no protocol session to pin to a process. modelcontextprotocol
If you are still designing around sticky sessions, stop. If you are dumping two hundred tool schemas into the system prompt, also stop. The rest of this piece is the interface you actually implement, the places it fails, and the one integration pattern I would ship for anything that has to survive a load balancer.
Host, client, server: three roles, one 1:1 wire
The spec names three actors. The host is the LLM application: it creates clients, enforces consent, aggregates context, and talks to the model. A client is a connector inside that host, and it talks to exactly one server. A server exposes resources, tools, and prompts. blog.modelcontextprotocol
That 1:1 client-to-server rule is a security boundary, not a convenience. Servers are not supposed to see the conversation, and they are not supposed to see each other. Cross-server composition happens in the host. If you let one server’s tool result become another server’s unreviewed argument, you have collapsed that boundary yourself.
Servers advertise capabilities on server/discover. Clients put theirs on every request under _meta.io.modelcontextprotocol/clientCapabilities. A server that needs a capability the client did not declare returns -32021 (MissingRequiredClientCapability) and, on HTTP, 400 Bad Request. Missing required _meta fields is -32602 and the same 400. modelcontextprotocol
The three server primitives are not interchangeable:
| Primitive | Who drives it | What it is | Typical use |
|---|---|---|---|
| Tools | Model | Callable functions with inputSchema |
Writes, searches, API POSTs |
| Resources | Application | URI-addressed data | File contents, schema dumps, tickets |
| Prompts | User | Named templates | Slash commands, canned workflows |
That table is from the spec’s own control hierarchy. Treat a SQL query as a resource when the host should attach it. Treat it as a tool when the model should decide to run it. Exposing both for the same underlying thing is legitimate; collapsing them into “everything is a tool” is how context windows die. anthropic
JSON-RPC, then the bits JSON-RPC does not give you
Messages are JSON-RPC 2.0, UTF-8. Request IDs are string or integer and MUST NOT be null. Results in 2026-07-28 carry resultType. "complete" means you are done. "input_required" means Multi Round-Trip Request (MRTR): the server needs elicitation, sampling, or similar, and the client retries the same method with inputResponses and optional requestState. The retry uses a new JSON-RPC id. blog.modelcontextprotocol
Error codes you will actually hit:
| Code | Name | When |
|---|---|---|
-32700 … -32603 |
JSON-RPC standard | Parse / invalid request / method / params / internal |
-32020 |
HeaderMismatch |
HTTP header does not match body |
-32021 |
MissingRequiredClientCapability |
Client omitted a needed capability |
-32022 |
UnsupportedProtocolVersion |
Version the server will not speak |
-32002 |
(legacy) | Resource not found on 2025-11-25 and earlier; replaced by -32602 |
New codes in -32000 to -32019 are frozen. Application errors should live outside -32768 to -32000. Resource-not-found on the current revision is -32602, not -32002. Clients should still accept -32002 from old servers. modelcontextprotocol
Tool execution failures are not protocol errors. Unknown tool, malformed CallToolRequest, server crash: JSON-RPC error. Bad date, API 409, constraint violation: a normal tool result with isError: true and text the model can use to retry. Clients SHOULD feed execution errors to the model and MAY feed protocol errors. Mixing those two is the most common “the agent loops forever” bug. The model cannot fix -32602 Unknown tool. It can fix departure date must be in the future. blog.modelcontextprotocol
JSON Schema for tool I/O defaults to 2020-12 when $schema is absent. Implementations MUST support 2020-12. They MUST NOT auto-fetch $refs that resolve to a network URI; opt-in fetch, if you offer it, stays off by default and should reject loopback and private ranges. That last sentence is SSRF prevention written into the schema chapter. Honour it. modelcontextprotocol
Two transports. Pick one on purpose.
Protocol semantics are the same on every binding. Framing is not. blog.modelcontextprotocol
stdio is newline-delimited JSON-RPC on a client-launched subprocess. Use it for local servers the host starts. Credentials come from the environment. The authorization chapter says STDIO implementations SHOULD NOT follow the HTTP OAuth profile. Cancellation is notifications/cancelled. The process is not a conversation: the spec says clients SHOULD NOT treat a task or thread as the lifetime of the stdio process, and servers MUST NOT treat the connection as session state. modelcontextprotocol
Streamable HTTP is one POST endpoint. Introduced in 2025-03-26 to replace HTTP+SSE from 2024-11-05. Revision 2026-07-28 removed the GET stream, removed protocol sessions, and made server-to-client asks MRTR instead of server-initiated JSON-RPC. The client POSTs one JSON-RPC request, with Accept: application/json, text/event-stream. The server replies with a JSON object or a request-scoped SSE stream. Notifications such as progress ride that stream. Long-lived list/resource change notifications require a separate subscriptions/listen POST whose response is the SSE stream. Closing the SSE stream is cancellation. Last-Event-ID resume is gone. Send X-Accel-Buffering: no or nginx will sit on your events. devshelfhub
Required HTTP headers on every request in this revision: MCP-Protocol-Version, Mcp-Method, and on tools/call / resources/read / prompts/get, Mcp-Name. They MUST match the body. Mismatch is -32020 and 400. That is how a gateway rates-limits tools/call named execute_sql without parsing JSON. blog.modelcontextprotocol
Servers MUST validate Origin and answer invalid Origin with 403. Local HTTP servers SHOULD bind 127.0.0.1, not 0.0.0.0. DNS rebinding against a laptop MCP port is not hypothetical; the spec writes the countermeasure in the transport page because people skipped it. devshelfhub
I would not put a production remote server on stdio-over-SSH or a homegrown WebSocket. Streamable HTTP is the remote binding. stdio is the local one. Custom transports are allowed if they keep JSON-RPC, the message patterns, and per-request _meta. Byte-stream customs SHOULD reuse stdio framing. blog.modelcontextprotocol
Legacy HTTP+SSE is deprecated, with a twelve-month minimum offramp under the project’s deprecation policy. New work should not adopt it. blog.modelcontextprotocol
What a tool actually is on the wire
tools/list returns the catalog, paginated, now with ttlMs and cacheScope so clients can cache and keep prompt-cache prefixes stable. Servers SHOULD return a deterministic order. The catalog MAY vary by the authorization on this request, not by connection. blog.modelcontextprotocol
A tool has name, optional title, description, inputSchema (MUST be a JSON object, never null), optional outputSchema, optional annotations, optional icons. Names SHOULD be 1–128 characters, case-sensitive, [A-Za-z0-9_.-]. Uniqueness is per server. Aggregators MUST disambiguate; serverInfo.name is not unique enough to be the prefix. blog.modelcontextprotocol
Annotations are untrusted unless the server is. The spec says that twice, in the security principles and in the tools chapter. A readOnlyHint from a random npm MCP is a string an attacker wrote. modelcontextprotocol
Empty-argument tools should use { "type": "object", "additionalProperties": false }, not a missing schema. blog.modelcontextprotocol
tools/call takes name and arguments. A complete result has content (text / image / audio / resource_link / embedded resource) and optional structuredContent. If you declare outputSchema, structured results MUST conform; clients SHOULD validate. For compatibility, also put the JSON in a text block. structuredContent is server data. It is not “JSON mode” from the LLM. blog.modelcontextprotocol
x-mcp-header on a primitive property (string, integer, boolean; not number) tells HTTP clients to emit Mcp-Param-{name}. Integers must sit in the IEEE-754 safe range. The property must be statically reachable through properties only: no oneOf, no $ref, no arrays. Sensitive values must not be marked. Clients on Streamable HTTP MUST drop the whole tool from tools/list if the annotation is illegal. Use this for region, tenant, or shard keys your WAF already knows how to route. Do not use it for the query string. blog.modelcontextprotocol
Stateful work has no session id. Return an explicit handle (basket_id, tx_id) and take it as an argument next time. The spec’s own shopping-cart example is the pattern. Handles are names, not capabilities. On an authenticated server, bind handle to the subject from the token, not from an argument. Generate unauthenticated handles with enough entropy and a lifetime you put in the tool description. Expired handle: isError: true, so the model can mint a new one. blog.modelcontextprotocol
Resources are how you feed context without a function call
resources/list, resources/read, resources/templates/list. URIs identify them. file:// need not be a real filesystem. https:// SHOULD mean the client can fetch it itself. Custom schemes must be RFC 3986. Missing resource: -32602, never an empty contents array. Clients still accept legacy -32002. blog.modelcontextprotocol
List and read responses carry cache hints the same way tools do. Subscribe via subscriptions/listen with resourceSubscriptions; updates arrive as notifications/resources/updated tagged with io.modelcontextprotocol/subscriptionId. blog.modelcontextprotocol
If the host can attach a schema or a file, prefer a resource. Tools that return resource_link let the model point at data the host then reads under its own policy. Those links are not required to appear in resources/list. blog.modelcontextprotocol
Path sanitization for file:// is a MUST. Directory traversal through an MCP file server is a solved class of bug that people reintroduce because the URI looks like a path. blog.modelcontextprotocol
Authorization is optional, and that is the trap
Authorization is OPTIONAL. HTTP implementations SHOULD follow the OAuth profile. STDIO SHOULD NOT, and should take credentials from the environment. yobitel
When you do HTTP auth, the MCP server is an OAuth 2.1 resource server. The client is an OAuth 2.1 client. Discovery is RFC 9728 Protected Resource Metadata (MUST for servers and clients), then RFC 8414 or OIDC Discovery for the AS (clients MUST support both). Tokens go in Authorization: Bearer, never in the query string, on every request. The server MUST check they were issued for this resource (RFC 8707 audience). It MUST NOT accept or transit anyone else’s tokens. yobitel
That last rule is the one enterprise integrations violate. Token passthrough is an anti-pattern the security guidance forbids by name: accept a token minted for some other API, forward it downstream, lose audience, lose audit, become a confused deputy. deepwiki
Client registration priority in 2026-07-28: Client ID Metadata Documents (CIMD) SHOULD, pre-registration, then Dynamic Client Registration which is deprecated, kept for compatibility, twelve-month minimum. CIMD: the client_id is an HTTPS URL the AS fetches. DCR’s unbounded client database and self-asserted metadata are the reason. blog.modelcontextprotocol
RFC 9207 iss on the authorization response: AS SHOULD emit it and MUST advertise authorization_response_iss_parameter_supported if they do; clients MUST validate when present, and MUST reject absence if the AS advertised support. Comparison is simple string compare after form-decoding. No scheme folding, no trailing-slash normalization. A future revision is expected to upgrade AS emission from SHOULD to MUST. PKCE does not stop mix-up; the client would send code_verifier to the attacker’s token endpoint. yobitel
Clients MUST send resource= (canonical MCP URI, no fragment, preferably no trailing slash) on authorize and token requests. Scope selection: prefer scope from the 401 WWW-Authenticate, else scopes_supported from PRM. Runtime 403 with error="insufficient_scope" is the step-up path; the client unions previously granted scopes with the challenge so it does not drop rights. Servers SHOULD put every scope for the current operation in one challenge, not drip them. I would start with a tiny discovery/read scope and step up. Shipping files:* db:* admin:* in scopes_supported and having the client request all of them is how a stolen token becomes a company-wide incident. yobitel
HTTP status: 401 invalid/missing token, 403 insufficient scope, 400 malformed auth. yobitel
The attacks that are in the official playbook, not a blog
Confused deputy on an MCP proxy that uses one static client_id toward a third-party AS, plus DCR, plus a consent cookie: the attacker’s redirect_uri harvests a code after the cookie skips consent. Mitigation is per-MCP-client consent before the third-party redirect, exact redirect_uri match, state stored only after consent, __Host- cookies. deepwiki
SSRF during metadata fetch: malicious resource_metadata pointing at 169.254.169.254 or 10.0.0.0/8. Clients in server-side deployments MUST think about this. Prefer HTTPS, block private ranges (do not write your own IP parser), do not blindly follow redirects, consider an egress proxy. CIMD turns the AS into an HTTP client of attacker URLs; same controls apply there. deepwiki
Local servers: the host MUST show the exact command before one-click install. Prefer stdio so only the parent talks to it. If you serve HTTP locally, bind loopback and require a token. Authorization URLs MUST be https (or loopback http in dev). Reject javascript:, data:, file:. Do not os.system a URL open. devshelfhub
Tool descriptions are prompt-injection surface. Integrity-check the catalog. The NSA Cybersecurity Information Sheet on MCP (May 2026) goes further on sandboxing (Landlock, seccomp, network namespaces) and tool-inventory pinning; that is institutional guidance, not the protocol, but it is the right operational overlay if you run this in a place that has an audit function. agent-drop
Icons: HTTPS or data: only, no credentials on fetch, treat SVG as executable. modelcontextprotocol
Do not put the catalog in the prompt
Anthropic’s November 2025 engineering note measured the failure mode everyone hits: thousands of tools, definitions occupying hundreds of thousands of tokens before the user speaks, intermediate results copied through the model twice. Their Google Drive → Salesforce example: a two-hour transcript (~50,000 tokens) pulled into context then written back out. Presenting MCP servers as code APIs in a filesystem, and letting the agent import only what it needs, dropped an example workload from 150,000 tokens to 2,000 (they quote 98.7%). Filter 10,000 spreadsheet rows in the sandbox; log five. Keep PII in the execution environment and tokenize before the model. webfuse
That pattern is the correct client architecture once you leave the “five tools” demo. MCP remains the discovery and invocation protocol. The model writes TypeScript (or Python) against generated wrappers. You still validate inputSchema at the server. You still require user consent for side effects. You add a sandbox, resource limits, and an allowlist of which servers the code may call. Direct tools/call from the model is fine for a small, stable set. It does not scale.
ttlMs / cacheScope on list results exist so you can keep a hashed catalog in the host and only bust it when notifications/tools/list_changed fires on a listen stream. Deterministic list order is for the model’s prompt cache, not for your OCD. blog.modelcontextprotocol
A server you can actually run
Python SDK v2.0.0 (stable 28 July 2026) renamed FastMCP to MCPServer. The mcp PyPI name is v2; pin mcp>=1.27,<2 if you are not migrating. v2 servers speak 2026-07-28 and still answer legacy initialize. x
That is the decorator surface from the v2 announcement. Wire it to Streamable HTTP in production, stdio for a desktop host. On HTTP, put OAuth in front, validate audience, filter tools/list by scope. On stdio, the OS user is the principal. x
TypeScript v2 split @modelcontextprotocol/sdk into @modelcontextprotocol/server and @modelcontextprotocol/client. Tool schemas use Standard Schema (Zod v4, Valibot, ArkType). createMcpHandler serves both revisions on one endpoint. Node 20+, ESM only. x
Go sets StreamableHTTPOptions.Stateless = true to speak 2026-07-28; leave it unset and clients negotiate down. That explicitness is the right default for a rolling fleet. x
A conforming tools/call POST, headers included:
If the header Mcp-Name is add_item and the body says delete_item, the server MUST 400 with -32020. Gateways that route on the header without that check are lying to themselves. devshelfhub
MRTR when the tool needs a login mid-call: first response resultType: "input_required" with inputRequests (for example elicitation/create), then the client POSTs tools/call again with inputResponses and requestState. Do not hold an SSE GET open for that. That GET does not exist in this revision. blog.modelcontextprotocol
REST, databases, and enterprise tools are backends, not MCP types
There is no MCP verb for “REST”. You wrap the HTTP call in tools/call, put the JSON body in arguments, and map 4xx from the API to isError: true. Rate-limit on the MCP server. The spec requires servers to validate inputs, implement access control, rate-limit, and sanitize outputs. blog.modelcontextprotocol
There is no MCP verb for “SQL”. A read-only schema dump is a resource. A parameterized query is a tool with a tight inputSchema (enums for table names you allow, maxLength on strings, no concatenated SQL). If you expose a generic execute_sql string, you have given the model a shell. I would not.
Enterprise SaaS: one MCP server per product boundary, OAuth to your AS, token exchange (RFC 8693) at the trust boundary if you must call downstream as the user. Do not forward the inbound bearer. CoSAI and the MCP security guidance agree on that point. deepwiki
Tasks (long-running work with tasks/get / tasks/update) live in the io.modelcontextprotocol/tasks extension, not the core. MCP Apps (inline UI) and Skills-over-MCP are extensions too. Opt-in both sides. Roots, sampling, and logging are deprecated as of 2026-07-28, still working for at least twelve months. New servers should not adopt them. modelcontextprotocol
Extensions negotiate during the same capability path as core. Do not assume a client that speaks tools speaks tasks.
What to do in the next two days
Read schema.ts for 2026-07-28, not a tutorial that still shows initialize. Pin your SDK major. If the server is remote, speak Streamable HTTP, emit Mcp-Method / Mcp-Name, put RFC 9728 on /.well-known/oauth-protected-resource, bind tokens to this audience, and list tools by scope. If it is local, stdio, show the command, sandbox it.
Keep the catalog out of the model once it no longer fits in a glance. Code execution against generated wrappers is the integration that matches how MCP is specified in 2026: stateless requests, explicit handles, cacheable lists. The protocol will not save you from a 400-tool system prompt or a passthrough token. Those are choices, and they are the ones that fail in production.
