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) ;; 或者自己接到别的传输上——这是个**纯函数**
核心是 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 侧的三条校验(错一条就静默少一半功能,所以都做实):
_meta.protocolVersion 与 _meta.clientCapabilities 必填,缺 → -32602;-32022,且 data.supported 必须列出我们支持的版本,
客户端要靠它重挑;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` 之前先过一遍。(capabilities s)只报真有东西的那几位。空的工具表还报 tools 是谎报能力位——客户端会照着
去 tools/list,拿回一个空数组,然后不知道该怪谁。
只报**真有东西**的那几位。空的工具表还报 `tools` 是谎报能力位——客户端会照着 去 `tools/list`,拿回一个空数组,然后不知道该怪谁。
(check-headers msg headers)HTTP 层用:头与信封是否一致。一致返回 nil,不一致返回一条 -32020 错误响应。
只有传输层看得见头,所以这条校验不能藏在 handle-message 里;但规则属于
协议,所以也不能让每个 web 层各写一遍。折中就是这个函数:web 层拿到请求后
先过一遍它,再把 body 交给 handle-message。
headers 是小写键的 map(HTTP 头大小写不敏感,Ring 也给小写)。
HTTP 层用:头与信封是否一致。一致返回 nil,不一致返回一条 `-32020` 错误响应。 **只有传输层看得见头**,所以这条校验不能藏在 `handle-message` 里;但规则属于 协议,所以也不能让每个 web 层各写一遍。折中就是这个函数:web 层拿到请求后 先过一遍它,再把 body 交给 `handle-message`。 `headers` 是**小写键**的 map(HTTP 头大小写不敏感,Ring 也给小写)。
(handle-message s msg)一条进、一条出的纯函数。通知返回 nil(协议禁止给通知回响应)。
modern 请求走无状态语义并带上 resultType / _meta.serverInfo;
legacy 请求(initialize 或没有 _meta 的)按老语义回。
**一条进、一条出**的纯函数。通知返回 nil(协议禁止给通知回响应)。 modern 请求走无状态语义并带上 `resultType` / `_meta.serverInfo`; legacy 请求(`initialize` 或没有 `_meta` 的)按老语义回。
(server {:keys [name version tools resources prompts instructions ctx
expose-error-messages?]
:or {name "clj-agent-mcp" version "0.3"}})构造一个 server(纯值,没有连接、没有线程)。
:name / :version 自报家门(Implementation):instructions 用法说明,客户端可能塞进 system prompt:tools 内联工具数组 {:name :description :parameters :handler},
:handler 是 (fn [args ctx] result)——与 clj-agent 的
内联工具同形,所以现成的工具直接放进来就能暴露出去:resources [{:uri :name :description :mimeType :read (fn [uri] contents)}]:prompts [{:name :description :arguments :get (fn [args] messages)}]:ctx 传给工具 handler 的第二个参数(缺省 {}):expose-error-messages? 工具抛异常时,把异常消息也回给客户端(缺省 false)server 的对面是外部客户端,而异常消息里常有路径、内部结构、上游 URL 与
request-id。缺省只回异常类名(够模型判断「该不该换个说法重试」),完整消息
记进 server 日志。本地开发要看细节,把 :expose-error-messages? 打开——
这是一次显式的取舍,不是偷偷做的决定。
构造一个 server(**纯值**,没有连接、没有线程)。
- `:name` / `:version` 自报家门(`Implementation`)
- `:instructions` 用法说明,客户端可能塞进 system prompt
- `:tools` 内联工具数组 `{:name :description :parameters :handler}`,
`:handler` 是 `(fn [args ctx] result)`——**与 clj-agent 的
内联工具同形**,所以现成的工具直接放进来就能暴露出去
- `:resources` `[{:uri :name :description :mimeType :read (fn [uri] contents)}]`
- `:prompts` `[{:name :description :arguments :get (fn [args] messages)}]`
- `:ctx` 传给工具 handler 的第二个参数(缺省 `{}`)
- `:expose-error-messages?` 工具抛异常时,把**异常消息**也回给客户端(缺省 `false`)
## 为什么异常消息默认不外泄
server 的对面是**外部客户端**,而异常消息里常有路径、内部结构、上游 URL 与
request-id。缺省只回**异常类名**(够模型判断「该不该换个说法重试」),完整消息
记进 server 日志。本地开发要看细节,把 `:expose-error-messages?` 打开——
这是一次**显式**的取舍,不是偷偷做的决定。(stdio-server! s)(stdio-server! s in out)把 stdin/stdout 当传输跑起来(阻塞,直到 stdin 关闭)。
两条必须守住的:
p/encode 出来的 JSON 本身不含换行。把 stdin/stdout 当传输跑起来(**阻塞**,直到 stdin 关闭)。 两条必须守住的: 1. **响应只写 stdout,日志只写 stderr**。往 stdout 打一行日志,对面的解析器就 废了——这是 stdio server 最常见的翻车方式; 2. **一行一条,不能有裸换行**。`p/encode` 出来的 JSON 本身不含换行。
我们同时支持的版本,从新到旧。modern 一个、legacy 两个。
server/discover 与 -32022 的 data.supported 都报这一份,不能各报各的。
我们**同时**支持的版本,从新到旧。modern 一个、legacy 两个。 `server/discover` 与 `-32022` 的 `data.supported` 都报这一份,不能各报各的。
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |