Liking cljdoc? Tell your friends :D

im.ttalk.agent.mcp.client

MCP 客户端:双时代。

(def c (client {:url "https://example.com/mcp"}))     ;; 或 {:command ["npx" …]}
(connect! c)                                            ;; 幂等;自动判时代
(list-tools c)
(call-tool c "get_weather" {:city "北京"})
(close! c)

判时代的算法(照规范 PR #2844 与官方 SDK 的实现写)

先按 modern 发一条 server/discover,然后看回来的是什么:

回来的判定为什么
DiscoverResult,supportedVersions 含我们这版modern,结束对面是新 server
DiscoverResult,但不含我们这版挑一个双方都支持的:还有 modern 版就换版重试,只剩 legacy 就退回 initialize它懂 discover,只是版本对不上
-32022 UnsupportedProtocolVersion同上(按 data.supported 重挑)这是新 server 的信号,不是老 server
-32021 / -32020原样抛,不回退能力不够 / 信封头不匹配——退回 initialize 也治不好,只会把真正的错误藏起来
其他任何 JSON-RPC 错误回退 initialize-32601(没这方法)、-32602(被 _meta 搞晕)、-32700…… 都说明对面是老 server。判据不能只认一个码,这是规范明写的
HTTP 400 / 404 且响应体里没有可识别的 modern 错误回退老 server 在 HTTP 层就把无会话的 POST 拒了(404 = 它要 Mcp-Session-Id)
超时没回回退stdio 上老 server 见到陌生方法可能干脆不吭声

时代是 server 的属性,不是单条请求的:判一次,记在 client 上,后续请求 都按那个时代走。

一处没做、且要说清楚的

server → client 的请求(elicitation / MRTR 的补充输入)只在 stdio 上到得了: stdio 的读线程会把它交给 :on-notification;而 HTTP 那侧,这类请求是从 POST 响应的 SSE 流里回来的,本模块的 HTTP 传输只取流里第一条 data: (见 mcp.transport),所以 HTTP 上收不到。 input_required 的结果原样返回(protocol/input-required? 可判), 调用方要接就自己接——不替想象中的用户把 MRTR 的整套状态机写出来。

MCP 客户端:**双时代**。

```clojure
(def c (client {:url "https://example.com/mcp"}))     ;; 或 {:command ["npx" …]}
(connect! c)                                            ;; 幂等;自动判时代
(list-tools c)
(call-tool c "get_weather" {:city "北京"})
(close! c)
```

## 判时代的算法(照规范 PR #2844 与官方 SDK 的实现写)

先按 **modern** 发一条 `server/discover`,然后看回来的是什么:

| 回来的 | 判定 | 为什么 |
|---|---|---|
| `DiscoverResult`,`supportedVersions` 含我们这版 | **modern**,结束 | 对面是新 server |
| `DiscoverResult`,但不含我们这版 | 挑一个双方都支持的:还有 modern 版就换版重试,只剩 legacy 就退回 `initialize` | 它懂 discover,只是版本对不上 |
| `-32022` UnsupportedProtocolVersion | 同上(按 `data.supported` 重挑) | 这是**新 server 的信号**,不是老 server |
| `-32021` / `-32020` | **原样抛,不回退** | 能力不够 / 信封头不匹配——退回 `initialize` 也治不好,只会把真正的错误藏起来 |
| 其他任何 JSON-RPC 错误 | 回退 `initialize` | `-32601`(没这方法)、`-32602`(被 `_meta` 搞晕)、`-32700`…… 都说明对面是老 server。**判据不能只认一个码**,这是规范明写的 |
| HTTP 400 / 404 且响应体里没有可识别的 modern 错误 | 回退 | 老 server 在 HTTP 层就把无会话的 POST 拒了(404 = 它要 `Mcp-Session-Id`) |
| 超时没回 | 回退 | stdio 上老 server 见到陌生方法可能干脆不吭声 |

**时代是 server 的属性,不是单条请求的**:判一次,记在 client 上,后续请求
都按那个时代走。

## 一处**没做**、且要说清楚的

**server → client 的请求(elicitation / MRTR 的补充输入)只在 stdio 上到得了**:
stdio 的读线程会把它交给 `:on-notification`;而 HTTP 那侧,这类请求是从
POST 响应的 SSE 流里回来的,本模块的 HTTP 传输只取流里第一条 `data:`
(见 `mcp.transport`),所以 HTTP 上收不到。
`input_required` 的结果**原样返回**(`protocol/input-required?` 可判),
调用方要接就自己接——不替想象中的用户把 MRTR 的整套状态机写出来。
raw docstring

im.ttalk.agent.mcp.protocol

MCP 协议的纯数据层:方法名、_meta 键、错误码、版本、能力、信封构造与解析。

零 I/O、零状态——传输在 mcp.transport,会话与回退策略在 mcp.client, 服务端派发在 mcp.server。本 ns 只回答「这条消息长什么样」。

两个时代(本 ns 同时认识,因为编解码是共用的)

规范 2026-07-28(SEP-2575 + SEP-2567)把 MCP 改成了无状态协议:

legacy(≤ 2025-11-25)modern(≥ 2026-07-28)
开场initialize 握手 + notifications/initialized没有握手,server/discover 可选
版本/能力/身份握手时协商一次,之后靠会话记住每条请求自带,放在 params._meta 里
HTTP 会话Mcp-Session-Id没有会话;Mcp-Method / Mcp-Name 变必填头
结果直接是 result 对象多一个 resultType(complete / input_required)
roots / sampling / logging正常特性全部 deprecated(SEP-2577)

「服务器不得从上一条请求推断任何东西」是 modern 的核心不变量——所以 request 每次都把版本与能力塞进 _meta,这不是冗余,是协议要求。

一处必须照做否则静默出错的地方

modern 的 HTTP 传输要求 MCP-Protocol-Version 头与 _meta 里的版本一致, 不一致 server 回 -32020 HeaderMismatch。所以「往信封里写版本」和「往头里写 版本」必须是同一个值的两次投影,不能各写各的——transport 那侧因此从信封里 反读版本(protocol-version-of),而不是自己记一份。

MCP 协议的**纯数据层**:方法名、`_meta` 键、错误码、版本、能力、信封构造与解析。

**零 I/O、零状态**——传输在 `mcp.transport`,会话与回退策略在 `mcp.client`,
服务端派发在 `mcp.server`。本 ns 只回答「这条消息长什么样」。

## 两个时代(本 ns 同时认识,因为编解码是共用的)

规范 `2026-07-28`(SEP-2575 + SEP-2567)把 MCP 改成了**无状态**协议:

| | legacy(≤ 2025-11-25) | modern(≥ 2026-07-28) |
|---|---|---|
| 开场 | `initialize` 握手 + `notifications/initialized` | **没有握手**,`server/discover` 可选 |
| 版本/能力/身份 | 握手时协商一次,之后靠会话记住 | **每条请求自带**,放在 `params._meta` 里 |
| HTTP 会话 | `Mcp-Session-Id` | **没有会话**;`Mcp-Method` / `Mcp-Name` 变必填头 |
| 结果 | 直接是 result 对象 | 多一个 `resultType`(`complete` / `input_required`) |
| roots / sampling / logging | 正常特性 | **全部 deprecated**(SEP-2577) |

「服务器不得从上一条请求推断任何东西」是 modern 的核心不变量——所以
`request` 每次都把版本与能力塞进 `_meta`,这不是冗余,是协议要求。

## 一处**必须**照做否则静默出错的地方

modern 的 HTTP 传输要求 `MCP-Protocol-Version` 头与 `_meta` 里的版本**一致**,
不一致 server 回 `-32020 HeaderMismatch`。所以「往信封里写版本」和「往头里写
版本」必须是同一个值的两次投影,不能各写各的——`transport` 那侧因此从信封里
反读版本(`protocol-version-of`),而不是自己记一份。
raw docstring

im.ttalk.agent.mcp.server

MCP 服务端:把一组内联工具(以及资源、提示词)暴露成 MCP server。

(def s (server {:name "clj-agent" :version "0.3"
                :instructions "这台机器上的天气与文件工具。"
                :tools [{:name "get_weather"
                         :description "查天气"
                         :parameters {:type "object"
                                      :properties {:city {:type "string"}}
                                      :required ["city"]}
                         :handler (fn [args _ctx] (str "晴," (get args "city")))}]}))

(stdio-server! s)        ;; 阻塞:把 stdin/stdout 当传输
(handle-message s msg)   ;; 或者自己接到别的传输上——这是个**纯函数**

分层:本 ns 不碰 web

核心是 handle-message:一条进、一条出(通知返回 nil),没有 I/O、没有会话。 把它接到 Ring / http-kit / 别的什么上,是调用方几行的事,也是 design-principles §2 划的那条线(HTTP 服务端依赖不进库)。stdio 循环留在模块里——它不碰 web。

双时代

同一个 handle-message 同时服务两种客户端,靠请求自己的形状分流:

请求判定依据
params._meta 里有 protocolVersionmodern,无状态服务规范的兼容矩阵:带 modern _meta 的请求按本版语义处理
method = initializelegacy,回一份 InitializeResult同上:initialize 选择 legacy 语义
两者都不是当 legacy 请求处理我们是无状态的,本来也不靠会话记东西

modern 侧的三条校验(错一条就静默少一半功能,所以都做实):

  1. _meta.protocolVersion 与 _meta.clientCapabilities 必填,缺 → -32602;
  2. 版本不认识 → -32022,且 data.supported 必须列出我们支持的版本, 客户端要靠它重挑;
  3. HTTP 的 MCP-Protocol-Version / Mcp-Method / Mcp-Name 头要与信封一致, 不一致 → -32020。头只有传输层看得见,所以这条由 check-headers 提供,web 层在调 handle-message 之前先过一遍。
MCP 服务端:把一组**内联工具**(以及资源、提示词)暴露成 MCP server。

```clojure
(def s (server {:name "clj-agent" :version "0.3"
                :instructions "这台机器上的天气与文件工具。"
                :tools [{:name "get_weather"
                         :description "查天气"
                         :parameters {:type "object"
                                      :properties {:city {:type "string"}}
                                      :required ["city"]}
                         :handler (fn [args _ctx] (str "晴," (get args "city")))}]}))

(stdio-server! s)        ;; 阻塞:把 stdin/stdout 当传输
(handle-message s msg)   ;; 或者自己接到别的传输上——这是个**纯函数**
```

## 分层:本 ns **不碰 web**

核心是 `handle-message`:一条进、一条出(通知返回 nil),没有 I/O、没有会话。
把它接到 Ring / http-kit / 别的什么上,是调用方几行的事,也是 `design-principles`
§2 划的那条线(HTTP 服务端依赖不进库)。stdio 循环留在模块里——它不碰 web。

## 双时代

同一个 `handle-message` 同时服务两种客户端,靠**请求自己的形状**分流:

| 请求 | 判定 | 依据 |
|---|---|---|
| `params._meta` 里有 `protocolVersion` | **modern**,无状态服务 | 规范的兼容矩阵:带 modern `_meta` 的请求按本版语义处理 |
| `method = initialize` | **legacy**,回一份 `InitializeResult` | 同上:`initialize` 选择 legacy 语义 |
| 两者都不是 | 当 legacy 请求处理 | 我们是无状态的,本来也不靠会话记东西 |

modern 侧的三条校验(错一条就静默少一半功能,所以都做实):

1. `_meta.protocolVersion` 与 `_meta.clientCapabilities` **必填**,缺 → `-32602`;
2. 版本不认识 → `-32022`,且 `data.supported` 必须**列出我们支持的版本**,
   客户端要靠它重挑;
3. HTTP 的 `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` 头要与信封一致,
   不一致 → `-32020`。头只有传输层看得见,所以这条由 `check-headers`
   提供,web 层在调 `handle-message` 之前先过一遍。
raw docstring

im.ttalk.agent.mcp.tools

把 MCP server 的工具接成 clj-agent 的内联工具。

(def servers [{:url "https://example.com/mcp" :server-id "demo"}
              {:command ["npx" "-y" "@modelcontextprotocol/server-filesystem" "/tmp"]}])

(create-agent (update spec :tools into (connect-servers servers)))

产出是普通 map({:name :description :parameters :handler}),所以本 ns ——以及整个 clj-agent-mcp 模块——不需要 require 任何 clj-agent 的东西。 谁要用谁 conj 进 :tools。

连接发生在装配期

connect-servers 会真的去连、去 tools/list。取舍与理由:server 挂了就在起 服务时报错(这里是记 warn 跳过那一个),不要每轮对话多一次 tools/list 的 网络往返。代价是「server 中途新增了工具」要重启才看得见——notifications/ tools/list_changed 那条路等有人真要再说。

MCP Apps

工具的 _meta 里带 io.modelcontextprotocol/ui-resource-uri 说明它会画一块 界面。这里只做两件事:在描述里告诉模型「这个工具会出界面」,以及把 uri 记在工具的 metadata 上。真正把界面拉起来是前端的事(agui.mcp 那半边)。

把 MCP server 的工具接成 clj-agent 的**内联工具**。

```clojure
(def servers [{:url "https://example.com/mcp" :server-id "demo"}
              {:command ["npx" "-y" "@modelcontextprotocol/server-filesystem" "/tmp"]}])

(create-agent (update spec :tools into (connect-servers servers)))
```

产出是**普通 map**(`{:name :description :parameters :handler}`),所以本 ns
——以及整个 `clj-agent-mcp` 模块——不需要 require 任何 clj-agent 的东西。
谁要用谁 conj 进 `:tools`。

## 连接发生在**装配期**

`connect-servers` 会真的去连、去 `tools/list`。取舍与理由:server 挂了就在起
服务时报错(这里是记 warn 跳过那一个),**不要每轮对话多一次 `tools/list` 的
网络往返**。代价是「server 中途新增了工具」要重启才看得见——`notifications/
tools/list_changed` 那条路等有人真要再说。

## MCP Apps

工具的 `_meta` 里带 `io.modelcontextprotocol/ui-resource-uri` 说明它会画一块
界面。这里只做两件事:**在描述里告诉模型**「这个工具会出界面」,以及把 uri
记在工具的 metadata 上。真正把界面拉起来是前端的事(`agui.mcp` 那半边)。
raw docstring

im.ttalk.agent.mcp.transport

客户端传输:Streamable HTTP 与 stdio。

一个协议、两个实现,外加「传输就是一个函数」这条后门:

(send! t msg)   ;; 请求 → 响应 map;通知 → nil
(close! t)      ;; 释放(stdio 杀子进程;HTTP 无状态,无操作)

(fn [msg] resp) 也是合法传输(send! 对 IFn 有实现)——测试因此不用起 服务、不用起进程,一个纯函数就能把整条客户端逻辑跑完。这条不是给测试开的 特例,它就是这层的最小契约。

Streamable HTTP(2026-07-28)

POST 一条 JSON-RPC,Accept: application/json, text/event-stream,响应可能是 两种之一。三个头是必填且不能与信封不一致:

头值不对会怎样
MCP-Protocol-Version与 _meta 里的版本同一个值-32020 HeaderMismatch
Mcp-Method与 body 的 method 同一个值同上
Mcp-Name只对 tools/call / prompts/get(取 name)与 resources/read(取 uri)同上

版本是从信封里反读的(protocol/protocol-version-of),不是传输自己记一份 ——两处各记一份就是 -32020 的来源。

没有会话:modern 不再有 Mcp-Session-Id。但双时代客户端要连老 server, 所以这里仍然会收下并回传 server 给的 session id;modern 路径上 server 不会 给,这段代码就是死的。

stdio

一行一条 JSON(消息内不得有裸换行)。读端是一条后台线程:响应按 id 交给 等待中的 promise,其余(通知、日志)交给 :on-notification。

为什么不能「写一行、读一行」了事:协议允许 server 在响应之前先推通知 (进度、日志、订阅事件),同步读会把它们当成响应。而且 2026-07-28 明说 「一条 stdio 连接不是一个会话」,客户端可以在同一个进程上交错发无关请求。

客户端传输:Streamable HTTP 与 stdio。

一个协议、两个实现,外加「传输就是一个函数」这条后门:

```clojure
(send! t msg)   ;; 请求 → 响应 map;通知 → nil
(close! t)      ;; 释放(stdio 杀子进程;HTTP 无状态,无操作)
```

**`(fn [msg] resp)` 也是合法传输**(`send!` 对 `IFn` 有实现)——测试因此不用起
服务、不用起进程,一个纯函数就能把整条客户端逻辑跑完。这条不是给测试开的
特例,它就是这层的最小契约。

## Streamable HTTP(2026-07-28)

POST 一条 JSON-RPC,`Accept: application/json, text/event-stream`,响应可能是
两种之一。三个头是**必填**且**不能与信封不一致**:

| 头 | 值 | 不对会怎样 |
|---|---|---|
| `MCP-Protocol-Version` | 与 `_meta` 里的版本**同一个值** | `-32020 HeaderMismatch` |
| `Mcp-Method` | 与 body 的 `method` 同一个值 | 同上 |
| `Mcp-Name` | 只对 `tools/call` / `prompts/get`(取 `name`)与 `resources/read`(取 `uri`) | 同上 |

版本是从**信封里反读**的(`protocol/protocol-version-of`),不是传输自己记一份
——两处各记一份就是 `-32020` 的来源。

**没有会话**:modern 不再有 `Mcp-Session-Id`。但双时代客户端要连老 server,
所以这里仍然会**收下并回传** server 给的 session id;modern 路径上 server 不会
给,这段代码就是死的。

## stdio

一行一条 JSON(消息内不得有裸换行)。读端是一条**后台线程**:响应按 id 交给
等待中的 promise,其余(通知、日志)交给 `:on-notification`。

为什么不能「写一行、读一行」了事:协议允许 server 在响应之前先推通知
(进度、日志、订阅事件),同步读会把它们当成响应。而且 2026-07-28 明说
「一条 stdio 连接不是一个会话」,客户端可以在同一个进程上交错发无关请求。
raw docstring

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close