暗无天日

=============>DarkSun的个人博客

EMCP:让 AI agent 操控 Emacs 的 MCP 服务器

这是什么

MCP 是 Anthropic 搞的一套协议(Model Context Protocol,模型上下文协议),目标是给 LLM 和外部工具之间定一个标准接口。你可以把它理解成 AI 版的 LSP:LSP 让编辑器和语言服务器说话,MCP 让 AI agent 和工具服务器说话。MCP 主要定义了三种核心能力,供 AI 模型使用:

  • 资源 (Resources):类似于文件或数据库记录等"只读"的数据,为AI提供上下文信息。
  • 工具 (Tools):AI模型可以执行的"函数"或"操作",比如搜索、发送邮件、修改文件等。
  • 提示 (Prompts):预定义的模板或指令,帮助用户更好地与AI交互

EMCP 就是跑在 Emacs 里的一个 MCP 服务器,它能把 Emacs 的能力包装成 MCP 工具暴露给外面的 AI agent。大模型可以通过 AI agent 做如下事情:

  • 查 Emacs 函数的文档和定义
  • 搜索和阅读 Info 手册
  • 变更配置
  • 执行任意 Elisp 代码
  • 发送按键序列,等于让 agent 替你敲键盘
  • 截图当前 Emacs 界面

Emacs 自带了一套自省(self-introspection)机制,这使得Emacs 特别适合被 agent 操控:

  • apropos 能正则搜所有符号
  • describe-function 能拿任何函数的文档
  • find-function 能跳到源码定义。

这些功能本来就是给人用的,现在正好喂给 agent。这使得 Emacs 几十年的积累直接成了 agent 的知识库。

安装

EMCP 在 MELPA 上,用 use-package 配合 elpaca 装一行搞定:

(use-package emcp
  :ensure t)

不想走 MELPA 也可以直接从仓库装,用 package-vc-install 加 recipe:

(emcp :fetcher git :url "https://codeberg.org/martenlienen/emcp")

装完之后需要告诉你的 AI 客户端去哪连 EMCP。不同客户端配置方式不一样,挑三种典型的说。

agent-shell

agent-shell 是 Emacs 里的 AI agent 运行时。EMCP 官方推荐用它,集成最顺。把 EMCP 加到 agent-shell-mcp-servers 里:

(add-to-list 'agent-shell-mcp-servers
             '((name . "emcp")
               (type . "http")
               (headers . ())
               (url . (lambda ()
                        (require 'emcp)
                        (let ((server (emcp-start emcp-default-profile)))
                          (emcp-server-url server))))))

URL 用 lambda 动态生成,因为端口号在启动时才确定。

Claude Code、Codex 这类非 Emacs 客户端

Claude Code 和 Codex 不在 Emacs 里跑,无法动态获取动态地址。解决办法是把端口号写死,再启动 EMCP。

先设端口:

(setq emcp-http-port 38913)

然后启动 EMCP 服务器。可以在 Emacs 里 M-x emcp-start 手动启动,也可以放进 init.el 自动启动:

(use-package emcp
  :ensure t
  :config
  (setq emcp-http-port 38913)
  (emcp-start emcp-default-profile))

服务跑起来之后,在命令行注册:

claude mcp add --transport http emcp http://127.0.0.1:38913/mcp

gptel(暂时有坑)

gptel 是 Emacs 里的另一个 LLM 客户端,作者 karthink。理论上这样配就行:

(setq mcp-hub-servers
      `(("emcp" . (:url ,(emcp-server-url (emcp-start emcp-default-profile))))))

但截至 2026 年 6 月,gptel 依赖的 mcp.el 只支持 MCP 协议的 2024-11-05 和 2025-03-26 版本,而 EMCP 实现的是 2025-11-25 版本。协议版本对不上,暂时用不了。等 mcp.el 更新。

Profile:给 agent 的能力分级

EMCP 内置了三个 Profile(能力配置文件),从低到高分别是 inspect、develop、full-control。这三个是 EMCP 默认提供的,你也可以自己定义新的 Profile(后面会讲怎么定义)。

inspect:只看不动

最保守的一档。agent 能做如下事情:

  • apropos:用正则搜 Emacs 符号
  • describe:查任意符号的文档
  • find-definition:跳到函数或变量的源码定义
  • find-references:找所有引用某个符号的地方
  • info-search:搜 Info 手册索引
  • /screenshot :这是一个提示语(prompt),不是工具。提示语由用户主动触发(在 agent 对话框里打 /screenshot 命令),工具由 agent 自己决定何时调用。inspect 档里 agent 不能自己要截图,只能等你主动给它看。

还有一个资源模板 info://{manual}/{node} ,agent 可以按这个格式读取 Info 手册内容。比如 agent 想查 Emacs 手册里 Dired 的章节,就访问 info://emacs/Dired ,EMCP 会把对应节点的文本返回给它。 {manual} 是手册名(如 emacs、elisp), {node} 是章节名。

这一档相当于给 agent 一本 Emacs 说明书。它问什么你都能让它自己查,但它改不了你的环境。

develop:能看也能摸

在 inspect 基础上加了几个能力:

  • get-variable / set-variable:读写 Emacs 变量的值
  • screenshot:agent 主动截图(和 inspect 里的 /screenshot 不一样,这个是 agent 自己决定什么时候截)

这一档适合开发场景。agent 不仅能查文档,还能读你当前变量的值来理解你的配置,看截图来理解你的界面状态。

full-control:完全控制

develop 基础上加上两个有破坏力的工具:

  • eval:执行任意 Elisp 代码
  • send-keys:发送按键序列,等于替你敲键盘

出于安全起见,这两个工具每次调用都要用户确认。

怎么选

  • inspect 适合:你只想让 agent 帮你查 Emacs 相关的信息(某个函数怎么用、某个变量的含义),不需要它碰你的环境。
  • develop 适合:你让 agent 帮你改配置、看截图来理解你当前的 Emacs 状态。
  • full-control 适合:你让 agent 帮你写Elisp代码、测试代码、操作界面,需要它执行代码或替你按键。

配置默认 profile:

(setq emcp-default-profile 'develop)

设成 nil(默认值)的话每次启动问你选哪个。 emcp-default-profile 设了之后还能临时换: C-u M-x emcp-start 会再问你一次。

安全机制:给 AI 的权力装刹车

evalsend-keys 这两个工具的问题很明显:一个能执行任意代码,一个能模拟键盘输入。放权给 agent 之后怎么防止它干坏事?

EMCP 的方案分两层:第一层是每次执行危险操作前弹出确认缓冲区让你审核;第二层是当你信任某个 agent 之后,可以把确认模式改成自动接受,省得每次都手动确认。

人工确认

每次 agent 调用 evalsend-keys ,EMCP 会弹一个确认缓冲区,把代码或按键序列摆出来让你选择是否执行。

确认模式

每次确认的时候你可以临时选择"本次接受"或"本次拒绝"。但如果你和某个 agent 合作了一阵,每次都点接受很烦,EMCP 允许你把确认行为设成自动模式:

  • ask:每次都问(默认)
  • accept:全部接受,不问(信任模式)
  • reject:全部拒绝,不问(不信任模式)

注意这个模式是为了减少操作摩擦,不是安全防线。安全防线是人工确认本身:即使设成 accept 模式,你也可以随时改回 ask 或 reject。

我们可以通过 M-x emcp-session-manager 命令切换策略。一个 session 就是一个 agent 和 EMCP 的一次连接。比如你同时开了两个 Claude Code 终端,一个在 ~/project-a 目录,一个在 ~/project-b 目录,它们就是两个 session。session manager 里能看到每个 session 是从哪个目录连过来的、用的什么 profile、当前确认模式是什么,并且可以在里面修改每个 session 的确认模式。

为什么 send-keys 的决策不缓存

send-keys 的注释里写了一句"Decisions are not cached across calls"。即使你上次接受了 C-x C-s=(保存),下次 agent 再发 =C-x C-s 你还得确认。

原因是按键的效果完全取决于当前环境。同样的 C-x C-s ,在普通 buffer 里是保存文件,在 minibuffer 里可能是确认输入,在某个特殊 mode 里可能是触发别的命令。你上次接受 C-x C-s 的决策不能指导下一次,因为下一次的环境可能完全不同。

eval 没有这个限制,因为代码执行的结果相对可预测(同样一段代码在同一个 Emacs 里跑,行为一致)。所以 eval 可以走自动模式, send-keys 必须逐次确认。

这个逻辑其实很简单,工具调用要不要逐次确认,得看它的行为是否依赖调用时的上下文。如果依赖(像按键),就得逐次确认。如果不依赖(像纯代码执行),可以走批量策略。

自建工具

EMCP 内置了十来个工具,覆盖了查文档、看状态、执行代码这几类常见需求。但 Emacs 的能力五花八门,如何把这些能力暴露给 agent 使用呢?

EMCP 提供了以下三个宏来帮助我们自定义:

  • emcp-deftool:定义工具
  • emcp-defresource:定义资源
  • emcp-defprompt:定义提示语

这三个宏是 EMCP 最有意思的部分。它们把 MCP 协议里的概念映射成了 Emacs Lisp 的声明式写法,你不需要手写 JSON-RPC 的消息格式,只需要按宏的参数格式填空。

=emcp-deftool=:声明一个工具

emcp-deftool 的基本结构:

(emcp-deftool 工具符号名
    ((参数1 "参数1的描述")
     (参数2 "参数2的描述" :type "integer" :default 10))
  "工具的 docstring。"
  :name "agent调用的名字"
  ;; 这里写工具逻辑,返回 MCP 格式的 alist
  `((content . [((type . "text")
                 (text . ,结果)))])))

看一个 README 里的例子,一个列出所有 buffer 的工具:

(emcp-deftool my-tool-list-buffers ()
  "List all open buffers with their file paths and major modes."
  :name "list-buffers"
  (let ((lines (mapcar (lambda (buf)
                         (format "%s\t%s\t%s"
                                 (buffer-name buf)
                                 (or (buffer-file-name buf) "")
                                 (buffer-local-value 'major-mode buf)))
                       (buffer-list))))
    `((content . [((type . "text")
                   (text . ,(string-join lines "\n")))]))))

这个工具没有参数,直接遍历 (buffer-list) 拼成文本返回。agent 调用 list-buffers 就能拿到你当前所有打开的 buffer 名字、文件路径和主 mode。

参数这块值得展开说一下。参数有两种写法:

  • 必填参数:比如 (buffer ...)
  • 可选参数: 通过 :default 设置了默认值,比如 (precision ... :default 12)

参数还支持 :type,指定 JSON schema 类型,默认是 "string" 。这对 agent 理解参数含义有帮助:agent 看到 :type "integer" 就知道该传数字而不是字符串。

看一个带参数的例子,README 里的计算器工具:

(emcp-deftool my-tool-calc
    ((expression "Algebraic expression to evaluate, e.g. sqrt(2*pi).")
     (precision "Number of significant digits." :type "integer" :default 12))
  "Evaluate an algebraic expression with calc."
  :name "calc"
  (let ((calc-internal-prec precision))
    `((content . [((type . "text")
                   (text . ,(calc-eval expression)))]))))

expression 是必填的,=precision= 是可选的(有 =:default 12=)。agent 看到 schema 之后就知道该怎么调。

=emcp-defresource=:定义资源

资源是 agent 可以读取的数据。有两种:静态资源和模板资源。

静态资源的 URI 是固定的,比如读 *scratch* buffer:

(emcp-defresource my-resource-scratch "emacs://buffer/*scratch*"
  "Read the *scratch* buffer."
  :name "scratch"
  :mime-type "text/plain"
  (let ((text (with-current-buffer "*scratch*"
                (buffer-substring-no-properties (point-min) (point-max)))))
    `((contents . [((uri . ,uri)
                    (mimeType . "text/plain")
                    (text . ,text))]))))

模板资源的 URI 里有 {param} 占位符,agent 按格式填参数来读不同的资源。参数会自动绑定到 body 里同名的变量:

(emcp-defresource my-resource-buffer "emacs://buffer/{name}"
  "Read a buffer by name."
  :name "buffer"
  :mime-type "text/plain"
  (if-let* ((buf (get-buffer name)))
      (let ((text (with-current-buffer buf
                    (buffer-substring-no-properties (point-min) (point-max)))))
        `((contents . [((uri . ,uri)
                        (mimeType . "text/plain")
                        (text . ,text))])))
    (error "Buffer %s does not exist" name)))

注意 name 这个变量没有在任何地方声明,它直接从 URI 模板的 {name} 里提取出来绑定的。agent 访问 emacs://buffer/init.el 的时候, name 的值就是 "init.el" 。这个自动绑定是模板资源的核心便利。

除了模板参数,body 里还有三个隐含变量:

  • uri :agent 请求的完整 URI。比如 agent 请求 emacs://buffer/init.el ,=uri= 的值就是 "emacs://buffer/init.el" 。上面的代码里就用到了这个变量。
  • server :EMCP 服务器对象,可以用来访问服务器的内部状态。
  • session :当前 agent 的连接信息,可以用来区分不同 agent 的请求。

这三个变量大部分时候用不到,但需要的时候不用自己绑定。

=emcp-defprompt=:定义提示语

提示语是用户触发的对话模板。你在 AI agent 的界面里(比如 Claude Code 的对话框)打 /review-buffer ,agent 就会把 prompt 生成的消息注入当前对话。这里的"客户端"指的是 agent 应用程序(Claude Code、agent-shell 等),不是大模型本身。

(emcp-defprompt my-prompt-review-buffer
    ((buffer "Name of the buffer to review" :default nil))
  "Review the contents of an Emacs buffer."
  :name "review-buffer"
  (let* ((buf (or (and buffer (get-buffer buffer))
                  (current-buffer)))
         (text (with-current-buffer buf
                 (buffer-substring-no-properties (point-min) (point-max)))))
    `((messages . [((role . "user")
                    (content . ((type . "text")
                                (text . ,(format "Review this code:\n\n%s" text)))))]))))

这个 prompt 把指定 buffer 的内容塞进一条 user 消息里,让 agent 去审查。 buffer 参数是可选的(有 :default nil ),不传的话默认用当前 buffer。

异步支持

有些操作不能立刻返回结果,比如跑一个 shell 命令要等进程结束。这时候加 :async t ,body 里就不写返回值了,改成调用 send-resultsend-error

(emcp-deftool my-tool-shell
    ((command "Shell command to run"))
  "Run a shell command asynchronously."
  :name "shell"
  :async t
  (let ((buf (generate-new-buffer " *emcp-shell*")))
    (set-process-sentinel
     (start-process-shell-command "emcp-shell" buf command)
     (lambda (proc _event)
       (let ((output (with-current-buffer (process-buffer proc)
                       (buffer-string))))
         (kill-buffer (process-buffer proc))
         (if (zerop (process-exit-status proc))
             (send-result `((content . [((type . "text") (text . ,output)))]))
           (send-error -1 (format "Exit %d" (process-exit-status proc))
                       `((output . ,output)))))))))

异步模式在三个宏上的用法一样:加 :async t=,用 =send-resultsend-error 回调返回。同步模式用返回值,异步模式用回调函数。这个设计让处理网络请求、进程等待、用户确认这类延迟操作变得自然。

Profile 管理

EMCP 用 emcp-profiles 变量管理所有 Profile。这是一个 alist,每个条目是 (名称 . 属性列表) 的结构。三个内置 Profile 就是这个变量的默认值。

:include 属性是 Profile 管理的关键:它让一个 Profile 继承另一个 Profile 的所有组件。develop 通过 :include (inspect) 继承了 inspect 的全部工具,再加上自己的额外工具。full-control 同理继承 inspect 和 develop。这就是为什么三个 Profile 是递进关系。

你可以定义自己的 Profile。比如你用 emcp-deftool 写了一个自定义工具 my-tool-recent-files ,想基于 develop 加上这个工具:

(add-to-list 'emcp-profiles
             '(my-dev . (:description "Develop + recent files."
                          :include (develop)
                          :tools (my-tool-recent-files))))

这个 my-dev Profile 继承 develop 的全部能力(develop 又继承 inspect),再加上你自定义的 recent-files 工具。

把默认 Profile 切到自定义的:

(setq emcp-default-profile 'my-dev)

M-x emcp-reload 重新加载配置。之后连上来的 agent 就能用新 Profile 了。

Profile 的完整属性列表:

  • :description :Profile 的文字描述,选择时会显示
  • :include :要继承的其他 Profile 名称列表(递归继承)
  • :prompts :提示语列表
  • :resources :资源列表
  • :tools :工具列表

每个列表里的元素是你用 emcp-defprompt / emcp-defresource / emcp-deftool 定义的符号名,或者内置组件的符号名(如 emcp-tools-apropos )。

自定义组件的其他玩法

同样的思路可以造很多工具:

  • *Messages* buffer 帮 agent 理解最近的操作
  • 列出当前所有 project(配合 project.el
  • org-agenda 的内容让 agent 知道你的待办
  • 查询 bookmark-alist 的书签列表

每个都是十几行 Elisp 的事。EMCP 的宏把 MCP 协议的复杂性藏起来了,你只管写 Emacs 逻辑,返回值用 alist 包一下就行。

如果你写了有用的工具,作者欢迎提 pull request 加进内置组件里。

最后说两句

EMCP 让 AI agent 真正操控了 Emacs,而不只是在旁边看代码。它之所以能做到这件事,是因为 Emacs 本来就自带了一整套自省能力( apropos 能搜所有符号、 describe 能查任何函数的文档、 info 能读手册),EMCP 只需要把这些现成能力用 MCP 协议包装一层暴露给 agent,不需要从零开始给 agent 写查询接口。

三个 emcp-def* 宏把 MCP 协议的概念映射到了 Elisp 的声明式写法里。用 Emacs 程序员最熟悉的方式(def 宏 + docstring + 关键字参数)来定义 MCP 组件。写一个 MCP 工具的体验和写一个 defun 几乎一样。

如果你在找一种让 AI agent 真正参与你的 Emacs 工作流的方式,EMCP 值得一试。

Emacs : MCP : AI-agent : LLM