用Common Lisp构建MCP服务器:连接AI与外部工具的实践指南
1. 项目概述为什么用Common Lisp构建MCP服务器如果你和我一样是个对Lisp方言有特殊偏好的开发者同时又对当前AI应用开发中工具调用Tool Calling的混乱现状感到头疼那么用Common Lisp来构建一个MCPModel Context Protocol服务器可能是一个既酷又实用的选择。MCP协议简单来说是连接AI模型比如Claude、GPTs与外部工具、数据源的一套标准化“插座”和“插头”规范。它让AI能安全、可控地调用你提供的函数、读取你指定的资源而无需你为每个模型、每个平台都重写一遍适配层。那么为什么是Common Lisp在Python和JavaScript几乎统治了AI工具链的今天选择Common Lisp看起来像是一种“复古”的叛逆。但我的理由很实际表达力与稳定性。Common Lisp的宏系统允许你以极高的抽象层次来定义协议和数据结构这意味着MCP协议中那些嵌套的JSON Schema、资源描述和工具定义在Lisp里可以用更简洁、更易读的S-表达式来优雅地表达和生成。其次Lisp运行时如SBCL的稳定性和高性能对于需要长期运行、处理并发连接的服务器端应用来说是一个巨大的优势。最后这本身就是一个绝佳的练手项目能让你深入理解MCP协议的精髓同时打造一个完全受你控制、可深度定制的AI能力扩展后端。本文将带你从零开始用Common Lisp构建一个功能完整的MCP服务器。我们会涵盖从协议理解、项目骨架搭建、核心协议实现到工具与资源定义、错误处理乃至最终部署的完整流程。过程中我会分享许多从实际踩坑中总结出的经验比如如何处理Lisp与JSON的“阻抗不匹配”如何设计优雅的并发模型以及如何让服务器与Claude Desktop、Cursor等客户端稳定通信。2. MCP协议核心与项目设计思路在动手写代码之前我们必须吃透MCP协议的核心。MCP本质上是一个基于JSON-RPC 2.0的双向通信协议。服务器我们即将构建的和客户端如Claude Desktop通过标准输入输出stdio或SSEServer-Sent Events进行通信交换JSON格式的消息。2.1 MCP协议的三根支柱协议的核心交互围绕三个核心概念展开理解它们就掌握了MCP的命脉工具Tools这是AI模型可以主动调用的函数。比如一个“查询数据库”的工具AI在对话中判断需要查数据时会通过MCP协议调用这个工具并传入参数。服务器执行后将结果返回给AI。工具需要明确定义名称、描述和参数模式JSON Schema。资源Resources这是AI模型可以被动读取的内容。比如一个“项目日志文件”资源AI在需要了解日志内容时可以通过MCP协议请求读取该资源的内容。资源由URI标识并包含元数据如MIME类型。提示Prompts这是一些可复用的对话模板或指令片段。客户端可以查询服务器提供了哪些提示并在适当的时候将它们插入到与AI的对话中以引导AI的行为。我们的Lisp服务器核心任务就是向客户端“宣告”我们提供了哪些工具、资源和提示并处理客户端对这些实体的调用或读取请求。2.2 技术栈选型与项目骨架对于Common Lisp项目一个清晰的骨架至关重要。我选择以下库它们在稳定性、活跃度和易用性上达到了很好的平衡Web服务器与JSON-RPCHunchentoot是一个成熟、功能丰富的HTTP服务器我们将用它来处理潜在的HTTP传输SSE模式。但MCP over stdio才是更常见的方式因此核心的JSON-RPC消息解析和分发我们会自己处理。对于JSONjonathan或cl-json都是不错的选择jonathan的性能和API通常更优。并发处理lparallel或bordeaux-threads可以用来处理可能的并发请求。但需要注意的是MCP over stdio通常是顺序处理请求的除非你明确设计为异步。开发环境强烈推荐使用Quicklisp进行依赖管理并结合SLIME或Sly在Emacs中进行交互式开发。这种“编辑-编译-调试”的循环对于构建复杂协议服务器效率极高。项目目录结构可以这样规划mcp-server-lisp/ ├── src/ │ ├── package.lisp ; 定义包和依赖 │ ├── protocol.lisp ; MCP协议常量、数据结构定义 │ ├── server-core.lisp ; JSON-RPC消息循环、路由分发 │ ├── tools.lisp ; 工具定义与实现 │ ├── resources.lisp ; 资源定义与实现 │ └── prompts.lisp ; 提示定义 ├── examples/ ; 示例工具和资源实现 ├── mcp-server.asd ; ASDF系统定义文件 └── README.md在package.lisp中我们会定义主包并引入依赖(defpackage #:mcp-server (:use #:cl #:bordeaux-threads) (:import-from #:jonathan #:parse #:to-json) (:export #:start-server #:register-tool #:register-resource))设计思路关键点我们将采用一个全局的注册表例如哈希表来动态管理工具、资源和提示。服务器启动时初始化这个注册表。然后主循环从标准输入读取JSON-RPC请求解析后根据方法名如tools/list,tools/call路由到对应的处理函数。处理函数从注册表中查找目标执行Lisp函数或读取资源然后将结果封装成JSON-RPC响应写入标准输出。这个模式清晰地将协议层与业务逻辑分离。3. 核心协议层的实现详解这是服务器的引擎部分。我们将实现一个健壮的、能够处理MCP标准消息的主循环。3.1 实现JSON-RPC消息循环MCP over stdio模式下服务器需要持续读取标准输入*standard-input*。每条消息是一个完整的JSON对象遵循JSON-RPC 2.0规范包含jsonrpc,id,method,params等字段。首先我们需要一个函数来读取一行因为MCP消息通常以换行符分隔并解析JSON(defun read-json-rpc-message () 从标准输入读取一行并解析为JSON-RPC消息。 (let ((line (read-line *standard-input* nil nil))) (when line (handler-case (let ((parsed (jonathan:parse line))) ;; 基本验证必须有jsonrpc和method字段 (unless (and (string (gethash jsonrpc parsed) 2.0) (gethash method parsed)) (error Invalid JSON-RPC message)) parsed) (error (e) (format *error-output* Failed to parse JSON: ~a~% e) nil)))))接下来是核心的消息分发器。我们需要维护一个方法名到处理函数的映射(defvar *method-handlers* (make-hash-table :test equal)) (defun register-method-handler (method handler) (setf (gethash method *method-handlers*) handler)) (defun dispatch-message (message) (let* ((method (gethash method message)) (id (gethash id message)) (params (gethash params message)) (handler (gethash method *method-handlers*))) (if handler (handle-request id method params handler) ;; 如果方法未找到返回JSON-RPC方法错误 (send-error-response id -32601 Method not found)))) (defun handle-request (id method params handler) (handler-case (let ((result (funcall handler params))) (send-success-response id result)) (error (e) (format *error-output* Error handling ~a: ~a~% method e) (send-error-response id -32000 (format nil Internal error: ~a e)))))最后主循环将它们串联起来(defun run-message-loop () (loop for message (read-json-rpc-message) while message do (dispatch-message message)))注意这里有一个关键细节。read-line在遇到文件结束EOF时会返回nil我们的循环因此终止。MCP客户端如Claude Desktop在退出时会关闭管道从而产生EOF这是服务器正常退出的信号。确保你的服务器能优雅地处理这种情况而不是崩溃。3.2 实现MCP标准方法现在我们需要为MCP规定的几个标准方法注册处理器。首先是初始化握手(register-method-handler initialize (lambda (params) ;; 返回服务器能力声明 (list :|protocolVersion| 2024-11-05 :|capabilities| (list :|tools| (list :|listChanged| t) :|resources| (list :|listChanged| t)) :|serverInfo| (list :|name| My Lisp MCP Server :|version| 0.1.0))))tools/list和resources/list方法需要返回我们注册的所有工具和资源(defvar *tools-registry* (make-hash-table :test equal)) (defvar *resources-registry* (make-hash-table :test equal)) (register-method-handler tools/list (lambda (params) (declare (ignore params)) (list :|tools| (loop for tool being the hash-values of *tools-registry* collect tool)))) (register-method-handler resources/list (lambda (params) (declare (ignore params)) (list :|resources| (loop for resource being the hash-values of *resources-registry* collect (list :|uri| (gethash :uri resource) :|name| (gethash :name resource) :|description| (gethash :description resource) :|mimeType| (gethash :mime-type resource))))))最复杂的是tools/call它需要执行具体的Lisp函数(register-method-handler tools/call (lambda (params) (let* ((tool-name (gethash name params)) (arguments (gethash arguments params)) (tool (gethash tool-name *tools-registry*))) (unless tool (error Tool not found: ~a tool-name)) ;; 调用工具对应的Lisp函数 (let ((func (gethash :function tool))) (funcall func arguments)))))3.3 响应与错误格式的标准化发送响应必须严格遵守JSON-RPC格式。我们需要辅助函数(defun send-success-response (id result) (let ((response (list :|jsonrpc| 2.0 :|id| id :|result| result))) (write-line (jonathan:to-json response) *standard-output*) (finish-output *standard-output*))) ; 确保立即刷新输出 (defun send-error-response (id code message) (let ((response (list :|jsonrpc| 2.0 :|id| id :|error| (list :|code| code :|message| message)))) (write-line (jonathan:to-json response) *standard-output*) (finish-output *standard-output*)))实操心得finish-output至关重要。标准输出通常是缓冲的如果不强制刷新响应可能会滞留在缓冲区导致客户端等待超时。这是初期调试时最容易忽略的问题之一。4. 定义与实现工具、资源及提示协议层搭建好后我们就可以用Lisp优雅地定义业务功能了。4.1 工具Tools的定义与注册一个工具需要包含名称、描述、输入模式JSON Schema。我们可以定义一个宏来让工具注册变得声明式(defmacro define-tool (name description input-schema function) (setf (gethash ,name *tools-registry*) (list :|name| ,name :|description| ,description :|inputSchema| ,input-schema :function ,function))) ;; 示例一个获取当前时间的工具 (define-tool get_current_time 获取服务器的当前日期和时间。 (list :|type| object :|properties| (list :|format| (list :|type| string :|description| 时间格式例如 iso 或 human-readable :|default| iso)) :|required| nil) (lambda (args) (let ((format (or (gethash format args) iso))) (case (string-downcase format) (iso (list :|time| (local-time:format-timestring nil (local-time:now))) (human-readable (list :|time| (local-time:format-timestring nil (local-time:now) :format local-time:rfc-1123-format)) (otherwise (list :|time| 未知格式))))))这里的关键点input-schema必须是一个能被正确序列化为JSON的Lisp结构列表或哈希表。jonathan库可以很好地处理嵌套的列表结构。工具函数接收一个参数args这是一个哈希表包含了客户端调用时传入的参数你需要从中提取并验证。4.2 资源Resources的定义与读取资源更侧重于内容的提供。我们需要实现resources/read方法(register-method-handler resources/read (lambda (params) (let ((uri (gethash uri params))) (let ((resource (gethash uri *resources-registry*))) (unless resource (error Resource not found: ~a uri)) ;; 调用资源的读取函数 (let ((content-func (gethash :content-fn resource))) (list :|contents| (list (list :|uri| uri :|mimeType| (gethash :mime-type resource) :|text| (funcall content-func))))))))) (defmacro define-resource (uri name description mime-type content-generator) (setf (gethash ,uri *resources-registry*) (list :uri ,uri :name ,name :description ,description :mime-type ,mime-type :content-fn ,content-generator))) ;; 示例一个显示服务器状态的内存资源 (define-resource memory://server/status 服务器状态 显示当前服务器的内存使用和运行时间。 text/plain (lambda () (format nil 服务器状态报告~%~%运行时间: ~a秒~%内存占用: 约~a MB (get-internal-run-time) (truncate (/ (sb-ext:dynamic-space-size) (* 1024 1024))))))注意事项资源的URI可以是任何字符串但建议遵循一定的命名规范如file://,memory://。mimeType字段很重要它告诉客户端如何解释内容。对于文本常用text/plain或text/markdown。4.3 提示Prompts的集成提示的实现相对简单主要是提供一个列表。你可以将它们硬编码或者从文件加载(defvar *prompts-registry* (list)) (defun load-prompts-from-file (path) (with-open-file (stream path) (setf *prompts-registry* (jonathan:parse stream)))) (register-method-handler prompts/list (lambda (params) (declare (ignore params)) (list :|prompts| *prompts-registry*)))一个提示对象通常包含name,description,arguments的schema以及messages数组包含role和content。5. 高级主题通知、并发与真实世界工具示例基础功能完成后我们可以让服务器变得更强大。5.1 实现通知机制MCP支持服务器主动向客户端发送通知例如工具列表发生变化。这需要服务器在特定条件下向标准输出写入一个没有id的JSON-RPC通知消息。(defun send-notification (method params) (let ((notification (list :|jsonrpc| 2.0 :|method| method :|params| params))) (write-line (jonathan:to-json notification) *standard-output*) (finish-output *standard-output*))) ;; 例如当动态添加一个新工具后 (defun add-tool-dynamically (tool-def) (setf (gethash (getf tool-def :|name|) *tools-registry*) tool-def) (send-notification tools/listChanged (list))) ; 通知客户端工具列表已更新这允许客户端如IDE在工具变化时动态刷新其可用工具列表提供更好的用户体验。5.2 处理潜在并发问题虽然stdio模式通常是顺序的但工具函数本身可能执行较慢的I/O操作如网络请求、数据库查询。为了不阻塞主消息循环我们可以将工具调用放入线程池中执行。(register-method-handler tools/call (lambda (params) (let* ((tool-name (gethash name params)) (arguments (gethash arguments params)) (call-id (gethash id params)) ; 注意这里需要捕获请求的ID (tool (gethash tool-name *tools-registry*))) (unless tool (send-error-response call-id -32601 Tool not found)) ;; 在线程中执行 (bt:make-thread (lambda () (handler-case (let ((result (funcall (gethash :function tool) arguments))) (send-success-response call-id result)) (error (e) (send-error-response call-id -32000 (format nil Tool execution error: ~a e))))) :name (format nil tool-call-~a tool-name)))))重要警告上述简化示例中call-id是从params中获取的这是错误的。在JSON-RPC中id是顶层字段不在params内。正确的做法是在dispatch-message或handle-request阶段就将id和params一起传递给处理器。并发改造需要更精细地设计上下文传递避免竞争条件。5.3 构建一个实用的文件搜索工具让我们实现一个更复杂、更实用的工具在指定目录下递归搜索包含特定文本的文件。(define-tool search_files 在指定目录中递归搜索包含特定文本的文件。 (list :|type| object :|properties| (list :|root_dir| (list :|type| string :|description| 搜索的根目录路径。 :|default| .) :|pattern| (list :|type| string :|description| 要搜索的文本支持正则表达式。) :|file_ext| (list :|type| string :|description| 过滤的文件扩展名例如 .lisp可选。)) :|required| (list pattern)) (lambda (args) (let* ((root-dir (or (gethash root_dir args) .)) (pattern (gethash pattern args)) (file-ext (gethash file_ext args)) (matches ())) ;; 警告在生产环境中需要对root-dir进行安全检查防止目录遍历攻击。 (cl-fad:walk-directory root-dir (lambda (path) (when (and (or (null file-ext) (string-equal file-ext (pathname-type path))) (uiop:file-exists-p path) (not (cl-fad:directory-pathname-p path))) (with-open-file (stream path :direction :input :if-does-not-exist nil) (when stream (loop for line (read-line stream nil nil) for line-number from 1 while line when (cl-ppcre:scan pattern line) do (push (list :|file| (namestring path) :|line| line-number :|content| (string-trim (#\Space #\Tab #\Newline) line)) matches)))))) :directories nil) ; 只遍历文件 (list :|matches| (reverse matches))))) ; 反转以保持顺序这个工具展示了如何集成外部库如cl-fad用于文件遍历cl-ppcre用于正则匹配并返回结构化的复杂结果。AI模型可以很好地解析这个结果列表并向用户清晰地汇报。6. 调试、部署与客户端配置6.1 调试你的MCP服务器调试stdio服务器有其特殊性。最有效的方法是使用日志。重定向调试输出将调试信息写入标准错误*error-output*这样不会干扰与客户端的JSON-RPC通信。可以在关键节点记录收到的消息和发送的响应。(format *error-output* [DEBUG] Received method: ~a~% method)手动测试你可以编写一个简单的测试脚本模拟客户端向你的服务器进程发送JSON-RPC消息。在Lisp REPL中启动服务器后通过Slime的交互能力你可以直接调用内部函数进行单元测试。使用Claude Desktop测试这是终极测试。将你的服务器可执行文件路径配置到Claude Desktop的MCP设置中。Claude Desktop的日志通常很详细会显示协议握手、方法调用和任何错误信息是排查问题的最佳依据。6.2 构建可执行文件并部署使用SBCL你可以轻松构建一个独立的可执行文件;; 在你的主文件例如 main.lisp末尾 (sb-ext:save-lisp-and-die mcp-server :toplevel #mcp-server:start-server :executable t :compression t)在REPL中加载你的系统后执行上面的save-lisp-and-die函数就会生成一个名为mcp-server的二进制文件。这个文件包含了整个Lisp镜像可以在没有安装Lisp环境的机器上运行。6.3 配置Claude Desktop客户端在Claude Desktop中你需要编辑配置文件位于~/Library/Application Support/Claude/claude_desktop_config.json或类似路径。{ mcpServers: { my-lisp-server: { command: /absolute/path/to/your/mcp-server, args: [] } } }重启Claude Desktop后它应该会自动启动你的服务器并建立连接。你可以在Claude的对话中尝试使用你定义的工具例如输入“请调用get_current_time工具看看时间”。6.4 常见问题与排查技巧实录即使设计得再完善实际运行中总会遇到问题。下面是我在开发过程中遇到的一些典型问题及解决方法问题现象可能原因排查步骤与解决方案Claude Desktop提示“无法连接MCP服务器”或“服务器意外退出”。1. 可执行文件路径错误或权限不足。2. 服务器启动后立即崩溃如未捕获的初始化错误。3. 服务器没有正确读取stdin或写入stdout。1.检查路径和权限在终端中直接运行./mcp-server看是否能启动并等待输入。2.查看崩溃日志在终端运行可执行文件观察是否有错误信息输出。在save-lisp-and-die前确保所有初始化代码都被包裹在handler-case中。3.验证stdio通信写一个最简单的“回声”服务器测试循环确认能读一行、写一行。工具调用后无响应或超时。1. 服务器端没有及时刷新输出缓冲区缺少finish-output。2. 工具函数陷入死循环或长时间阻塞。3. JSON响应格式错误客户端无法解析。1.强制刷新输出在所有write-line或format到*standard-output*之后立即调用(finish-output *standard-output*)。2.添加超时机制在工具函数内部对可能长时间运行的操作如网络请求设置超时。或者如前所述将耗时操作放入线程。3.检查JSON格式使用在线JSON验证器检查服务器输出的字符串。确保结果是有效的JSON对象且结构符合MCP规范例如tools/call的结果应放在result字段下。客户端收到的工具列表为空。1.tools/list方法返回的JSON结构不正确。2. 工具注册的时机不对在initialize握手完成前客户端就请求了列表。3. 工具注册表哈希表的键名与返回的字段名不匹配。1.对比协议规范仔细阅读MCP协议文档确认tools/list返回的必须是{tools: [...]}格式数组中的每个工具对象必须包含name,description,inputSchema。2.确保初始化顺序在initialize方法被调用、返回capabilities后再处理其他请求是安全的。但通常注册应在服务器启动时完成。3.调试输出在tools/list处理器中将准备返回的Lisp数据结构打印到标准错误确认其内容正确。工具调用时参数解析失败。1. 客户端传入的参数格式与定义的inputSchema不匹配。2. 服务器端工具函数期望的参数类型与收到的JSON类型不匹配如期望字符串却收到数字。3. Lisp中处理JSON哈希表时键名大小写问题。1.严格验证Schema虽然MCP客户端如Claude通常会根据Schema生成参数但实现时可以在工具函数开头对参数进行验证。使用cl-json-schema等库进行验证。2.防御性编程在工具函数内使用(gethash key args :default-value)并提供合理的默认值或对关键参数进行类型检查和转换。3.注意键名大小写JSON键名通常是字符串且区分大小写。确保在Lisp中通过gethash查找时使用的字符串与Schema中定义的完全一致。构建一个MCP服务器尤其是在Common Lisp这样的环境中是一个将优雅的协议设计与强大的语言能力相结合的过程。它迫使你深入理解AI与外部世界交互的细节最终收获的是一个高度定制化、可扩展的AI能力增强平台。当你看到Claude能通过你编写的Lisp函数操作本地文件、查询内部数据时那种成就感是独特的。