diff --git a/HISTORY.org b/HISTORY.org index 679c0656..fcb9f7e6 100644 --- a/HISTORY.org +++ b/HISTORY.org @@ -2,6 +2,8 @@ * Release history ** Main branch change +- Feat: Add more emacs mcps: buffer and project query tools and xref support + - Referencing https://github.com/acmorrow/claude-code-ide-extras, port 4 mcps from there. ** 1.67 - Chore: Size side windows by body width and add test diff --git a/README.org b/README.org index 84c5b89f..c61ada16 100644 --- a/README.org +++ b/README.org @@ -196,6 +196,11 @@ AI Code includes an Emacs MCP server with these built-in tools: - =imenu_list_symbols= - =xref_find_references= - =treesit_info= +- .. and others + +screenshot inside Codex cli: + +[[file:./emacs_mcp_tools.png]] ***** Use It with an AI CLI @@ -447,6 +452,7 @@ The following books introduce how to use AI to assist programming and potentiall - Gemini CLI (`[[https://github.com/linchen2chris/gemini-cli.el][gemini-cli.el]]`) - [[https://github.com/xenodium/agent-shell][agent-shell]] ([[https://github.com/xenodium/acp.el][acp.el]]) - [[https://eca.dev/][ECA (Editor Code Assistant)]] +- [[https://github.com/acmorrow/claude-code-ide-extras][claude-code-ide-extras.el]] ** License diff --git a/ai-code-mcp-server.el b/ai-code-mcp-server.el index 1906561f..238efaf4 100644 --- a/ai-code-mcp-server.el +++ b/ai-code-mcp-server.el @@ -1,6 +1,6 @@ ;;; ai-code-mcp-server.el --- MCP tools core for AI Code Interface -*- lexical-binding: t; -*- -;; Author: Yoav Orot, Kang Tu, AI Agent +;; Author: Yoav Orot, Kang Tu, Andrew Morrow, AI Agent ;; SPDX-License-Identifier: Apache-2.0 ;;; Commentary: @@ -14,6 +14,8 @@ (require 'cl-lib) (require 'imenu) (require 'project) +(require 'seq) +(require 'subr-x) (require 'xref) (require 'ai-code-input) @@ -52,6 +54,28 @@ Each item is a plist with at least `:function', `:name', and `:description'." :name "project_info" :description "Get information about the current project context." :args nil) + (:function ai-code-mcp-buffer-query + :name "buffer_query" + :description "Read contents from an Emacs buffer by line range." + :args ((:name "buffer_name" + :type string + :description "Name of the buffer to read.") + (:name "start_line" + :type integer + :description "1-based first line to read." + :optional t) + (:name "num_lines" + :type integer + :description "Number of lines to read from start_line." + :optional t))) + (:function ai-code-mcp-get-project-files + :name "get_project_files" + :description "List regular files in the current project." + :args nil) + (:function ai-code-mcp-get-project-buffers + :name "get_project_buffers" + :description "List open buffers that belong to the current project." + :args nil) (:function ai-code-mcp-imenu-list-symbols :name "imenu_list_symbols" :description "List useful symbols in a file via imenu." @@ -67,6 +91,18 @@ Each item is a plist with at least `:function', `:name', and `:description'." (:name "file_path" :type string :description "Path to the file that provides backend context."))) + (:function ai-code-mcp-xref-find-definitions-at-point + :name "xref_find_definitions_at_point" + :description "Find definitions of the identifier at a file location." + :args ((:name "file_path" + :type string + :description "Path to the file that provides backend context.") + (:name "line" + :type integer + :description "1-based line number.") + (:name "column" + :type integer + :description "0-based column number."))) (:function ai-code-mcp-treesit-info :name "treesit_info" :description "Return tree-sitter node information for a file location." @@ -183,6 +219,91 @@ Required keys are `:function', `:name', and `:description'." "No active buffer") file-count))) +(defun ai-code-mcp--validate-buffer-query-range (start-line num-lines) + "Validate optional buffer query range arguments START-LINE and NUM-LINES." + (when (or (and start-line (not num-lines)) + (and num-lines (not start-line))) + (error "Arguments start_line and num_lines must both be provided or both be omitted")) + (when (and start-line + (or (< start-line 1) + (< num-lines 1))) + (error "Arguments start_line and num_lines must be positive integers"))) + +(defun ai-code-mcp--drop-trailing-newline (text) + "Return TEXT without a single trailing newline." + (if (string-suffix-p "\n" text) + (substring text 0 -1) + text)) + +(defun ai-code-mcp-buffer-query (buffer-name &optional start-line num-lines) + "Return contents from BUFFER-NAME. +When START-LINE and NUM-LINES are non-nil, return only that line range." + (let ((buffer (get-buffer buffer-name))) + (if (not buffer) + (format "Error: Buffer not found: %s" buffer-name) + (ai-code-mcp--validate-buffer-query-range start-line num-lines) + (with-current-buffer buffer + (save-excursion + (if (not start-line) + (buffer-substring-no-properties (point-min) (point-max)) + (goto-char (point-min)) + (forward-line (1- start-line)) + (let ((start-pos (point))) + (forward-line num-lines) + (ai-code-mcp--drop-trailing-newline + (buffer-substring-no-properties start-pos (point)))))))))) + +(defun ai-code-mcp--project-files (project-dir) + "Return absolute regular files inside PROJECT-DIR." + (let* ((default-directory (file-name-as-directory project-dir)) + (project (project-current nil project-dir)) + (project-root default-directory)) + (or (ignore-errors + (when (and project (fboundp 'project-files)) + (seq-filter + #'file-regular-p + (mapcar (lambda (file) + (if (file-name-absolute-p file) + file + (expand-file-name file project-root))) + (project-files project))))) + (cl-labels + ((collect-files (dir) + (apply + #'append + (mapcar + (lambda (entry) + (cond + ((member entry '("." "..")) nil) + ((string-prefix-p "." entry) nil) + (t + (let ((path (expand-file-name entry dir))) + (cond + ((file-directory-p path) + (collect-files path)) + ((file-regular-p path) + (list path)) + (t nil)))))) + (directory-files dir nil nil t))))) + (collect-files project-root))))) + +(defun ai-code-mcp-get-project-files () + "Return regular files in the current project as relative paths." + (let ((project-dir (ai-code-mcp--project-directory))) + (if (not (and project-dir (file-directory-p project-dir))) + nil + (mapcar #'ai-code-mcp--display-path + (ai-code-mcp--project-files project-dir))))) + +(defun ai-code-mcp-get-project-buffers () + "Return open buffers that belong to the current project." + (let ((project-dir (ai-code-mcp--project-directory))) + (delq nil + (mapcar + (lambda (buffer) + (ai-code-mcp--project-buffer-entry buffer project-dir)) + (buffer-list))))) + (defun ai-code-mcp-imenu-list-symbols (file-path) "Return formatted imenu entries for FILE-PATH." (let* ((resolved-file (ai-code-mcp--require-file-path file-path)) @@ -205,8 +326,29 @@ Required keys are `:function', `:name', and `:description'." (format "No references found for '%s'" identifier) (mapcar #'ai-code-mcp--format-xref-item items)))))))) +(defun ai-code-mcp-xref-find-definitions-at-point (file-path line column) + "Return formatted xref definitions for the identifier at FILE-PATH:LINE:COLUMN." + (let ((buffer (ai-code-mcp--file-buffer + (ai-code-mcp--require-file-path file-path)))) + (with-current-buffer buffer + (save-excursion + (goto-char (point-min)) + (forward-line (1- line)) + (move-to-column column) + (let ((backend (xref-find-backend))) + (if (not backend) + (format "No xref backend available for %s" file-path) + (let ((identifier (xref-backend-identifier-at-point backend))) + (if (not identifier) + (format "No identifier at %s:%d:%d" file-path line column) + (let ((items (xref-backend-definitions backend identifier))) + (if (not items) + (format "No definitions found for '%s'" identifier) + (mapcar #'ai-code-mcp--format-xref-item items))))))))))) + (defun ai-code-mcp-treesit-info (file-path &optional line column whole-file) - "Return tree-sitter information for FILE-PATH at LINE and COLUMN." + "Return tree-sitter information for FILE-PATH at LINE and COLUMN. +When WHOLE-FILE is non-nil, inspect the root node instead." (cond ((not (and (fboundp 'treesit-available-p) (treesit-available-p))) @@ -332,9 +474,35 @@ Required keys are `:function', `:name', and `:description'." "Return RESULT converted to a tool response string." (cond ((stringp result) result) - ((listp result) (mapconcat #'identity result "\n")) + ((listp result) + (mapconcat (lambda (item) + (if (stringp item) + item + (format "%S" item))) + result + "\n")) (t (format "%s" result)))) +(defun ai-code-mcp--project-buffer-entry (buffer project-dir) + "Return buffer metadata for BUFFER when it belongs to PROJECT-DIR." + (when (ai-code-mcp--buffer-in-project-p buffer project-dir) + (with-current-buffer buffer + `((name . ,(buffer-name buffer)) + (mode . ,major-mode) + (file . ,(buffer-file-name)) + (modified . ,(buffer-modified-p buffer)))))) + +(defun ai-code-mcp--buffer-in-project-p (buffer project-dir) + "Return non-nil when BUFFER belongs to PROJECT-DIR." + (and (file-directory-p project-dir) + (with-current-buffer buffer + (let ((file (buffer-file-name)) + (buffer-dir default-directory)) + (or (and file + (file-in-directory-p file project-dir)) + (and buffer-dir + (file-in-directory-p buffer-dir project-dir))))))) + (defun ai-code-mcp--project-directory () "Return the best available project directory." (or (when-let ((context (ai-code-mcp-get-session-context))) @@ -352,17 +520,21 @@ Required keys are `:function', `:name', and `:description'." (defun ai-code-mcp--display-path (file-path) "Return FILE-PATH relative to the active project when possible." - (let ((project-dir (ai-code-mcp--project-directory))) - (if (and project-dir - (string-prefix-p (expand-file-name project-dir) - (expand-file-name file-path))) - (file-relative-name file-path project-dir) - (file-name-nondirectory file-path)))) + (let* ((expanded-path (and file-path (expand-file-name file-path))) + (project-dir (ai-code-mcp--project-directory)) + (project-root (and project-dir + (file-name-as-directory + (expand-file-name project-dir))))) + (if (and expanded-path + project-root + (file-in-directory-p expanded-path project-root)) + (file-relative-name expanded-path project-root) + expanded-path))) (defun ai-code-mcp--require-file-path (file-path) "Return FILE-PATH as an absolute path or signal an error." (unless file-path - (error "file_path is required")) + (error "Argument file_path is required")) (expand-file-name file-path)) (defun ai-code-mcp--file-buffer (file-path) @@ -392,7 +564,8 @@ Required keys are `:function', `:name', and `:description'." (defun ai-code-mcp--format-xref-item (item) "Return a human-readable line for xref ITEM." (let* ((location (xref-item-location item)) - (group (xref-location-group location)) + (group (ai-code-mcp--display-path + (xref-location-group location))) (marker (xref-location-marker location)) (line (with-current-buffer (marker-buffer marker) (save-excursion diff --git a/emacs_mcp_tools.png b/emacs_mcp_tools.png new file mode 100644 index 00000000..11bee2f2 Binary files /dev/null and b/emacs_mcp_tools.png differ diff --git a/test/test_ai-code-mcp-server.el b/test/test_ai-code-mcp-server.el index 12390c79..a51a8c81 100644 --- a/test/test_ai-code-mcp-server.el +++ b/test/test_ai-code-mcp-server.el @@ -116,9 +116,13 @@ (plist-get tool :name)) ai-code-mcp-server-tools) #'string<))) - (should (equal '("imenu_list_symbols" + (should (equal '("buffer_query" + "get_project_buffers" + "get_project_files" + "imenu_list_symbols" "project_info" "treesit_info" + "xref_find_definitions_at_point" "xref_find_references") tool-names))))) @@ -130,9 +134,13 @@ (alist-get 'name tool)) (alist-get 'tools tools-result)) #'string<))) - (should (equal '("imenu_list_symbols" + (should (equal '("buffer_query" + "get_project_buffers" + "get_project_files" + "imenu_list_symbols" "project_info" "treesit_info" + "xref_find_definitions_at_point" "xref_find_references") tool-names))))) @@ -220,6 +228,229 @@ (should (member "sample.el:4: beta" result)))) (delete-directory project-dir t)))) +(ert-deftest ai-code-test-mcp-server-source-requires-seq-explicitly () + "The MCP server source should declare its seq dependency explicitly." + (with-temp-buffer + (insert-file-contents "ai-code-mcp-server.el") + (goto-char (point-min)) + (should (search-forward "(require 'seq)" nil t)))) + +(ert-deftest ai-code-test-mcp-buffer-query-returns-selected-buffer-lines () + "Buffer query should return the requested line range from a live buffer." + (let ((buffer (generate-new-buffer " *ai-code-mcp-buffer-query*"))) + (unwind-protect + (with-current-buffer buffer + (insert "alpha\nbeta\ngamma\ndelta\n") + (should (equal "beta\ngamma" + (ai-code-mcp-buffer-query + (buffer-name buffer) + 2 + 2)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ai-code-test-mcp-buffer-query-preserves-trailing-whitespace () + "Buffer query should preserve trailing whitespace in the selected text." + (let ((buffer (generate-new-buffer " *ai-code-mcp-buffer-query-whitespace*"))) + (unwind-protect + (with-current-buffer buffer + (insert "alpha\nbeta \n") + (should (equal "beta " + (ai-code-mcp-buffer-query + (buffer-name buffer) + 2 + 1)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ai-code-test-mcp-buffer-query-requires-positive-line-range () + "Buffer query should reject non-positive line range arguments." + (let ((buffer (generate-new-buffer " *ai-code-mcp-buffer-query-range*"))) + (unwind-protect + (with-current-buffer buffer + (insert "alpha\nbeta\n") + (should-error + (ai-code-mcp-buffer-query (buffer-name buffer) 0 1)) + (should-error + (ai-code-mcp-buffer-query (buffer-name buffer) 1 0))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ai-code-test-mcp-get-project-files-returns-relative-project-paths () + "Project files should list regular files relative to the session project root." + (let* ((project-dir (make-temp-file "ai-code-mcp-project-files-" t)) + (file-a (expand-file-name "alpha.el" project-dir)) + (file-b (expand-file-name "nested/beta.el" project-dir)) + (buffer (generate-new-buffer " *ai-code-mcp-project-files*")) + (ai-code-mcp--sessions (make-hash-table :test 'equal)) + (ai-code-mcp--current-session-id "session-project-files")) + (unwind-protect + (progn + (make-directory (file-name-directory file-b) t) + (with-temp-file file-a + (insert "(message \"alpha\")\n")) + (with-temp-file file-b + (insert "(message \"beta\")\n")) + (ai-code-mcp-register-session "session-project-files" project-dir buffer) + (should (equal '("alpha.el" "nested/beta.el") + (sort (ai-code-mcp-get-project-files) #'string<)))) + (when (buffer-live-p buffer) + (kill-buffer buffer)) + (delete-directory project-dir t)))) + +(ert-deftest ai-code-test-mcp-get-project-files-skips-hidden-directories () + "Project files should skip hidden directories such as .git." + (let* ((project-dir (make-temp-file "ai-code-mcp-project-files-hidden-" t)) + (file-a (expand-file-name "alpha.el" project-dir)) + (file-b (expand-file-name "nested/beta.el" project-dir)) + (hidden-file (expand-file-name ".git/HEAD" project-dir)) + (buffer (generate-new-buffer " *ai-code-mcp-project-files-hidden*")) + (ai-code-mcp--sessions (make-hash-table :test 'equal)) + (ai-code-mcp--current-session-id "session-project-files-hidden")) + (unwind-protect + (progn + (make-directory (file-name-directory file-b) t) + (make-directory (file-name-directory hidden-file) t) + (with-temp-file file-a + (insert "(message \"alpha\")\n")) + (with-temp-file file-b + (insert "(message \"beta\")\n")) + (with-temp-file hidden-file + (insert "ref: refs/heads/main\n")) + (ai-code-mcp-register-session + "session-project-files-hidden" + project-dir + buffer) + (should (equal '("alpha.el" "nested/beta.el") + (sort (ai-code-mcp-get-project-files) #'string<)))) + (when (buffer-live-p buffer) + (kill-buffer buffer)) + (delete-directory project-dir t)))) + +(ert-deftest ai-code-test-mcp-get-project-buffers-lists-open-buffers-in-project () + "Project buffers should include file-visiting buffers under the active project." + (let* ((project-dir (make-temp-file "ai-code-mcp-project-buffers-" t)) + (project-file (expand-file-name "alpha.el" project-dir)) + (other-dir (make-temp-file "ai-code-mcp-other-project-" t)) + (other-file (expand-file-name "other.el" other-dir)) + (session-buffer (generate-new-buffer " *ai-code-mcp-project-buffers*")) + (ai-code-mcp--sessions (make-hash-table :test 'equal)) + (ai-code-mcp--current-session-id "session-project-buffers") + project-buffer + other-buffer) + (unwind-protect + (progn + (with-temp-file project-file + (insert "(message \"project\")\n")) + (with-temp-file other-file + (insert "(message \"other\")\n")) + (setq project-buffer (find-file-noselect project-file t) + other-buffer (find-file-noselect other-file t)) + (ai-code-mcp-register-session + "session-project-buffers" + project-dir + session-buffer) + (let ((result (ai-code-mcp-get-project-buffers))) + (should (seq-some + (lambda (entry) + (equal project-file (alist-get 'file entry))) + result)) + (should-not (seq-some + (lambda (entry) + (equal other-file (alist-get 'file entry))) + result)))) + (when (buffer-live-p project-buffer) + (kill-buffer project-buffer)) + (when (buffer-live-p other-buffer) + (kill-buffer other-buffer)) + (when (buffer-live-p session-buffer) + (kill-buffer session-buffer)) + (delete-directory project-dir t) + (delete-directory other-dir t)))) + +(ert-deftest ai-code-test-mcp-xref-find-definitions-at-point-uses-location-context () + "Definitions-at-point should resolve via the xref backend at a file location." + (let* ((project-dir (make-temp-file "ai-code-mcp-xref-defs-" t)) + (file-path (expand-file-name "defs.el" project-dir)) + visited-buffer) + (unwind-protect + (progn + (with-temp-file file-path + (insert "(defun alpha ()\n") + (insert " (beta))\n\n") + (insert "(defun beta ()\n") + (insert " t)\n")) + (cl-letf (((symbol-function 'xref-find-backend) + (lambda () 'mock-backend)) + ((symbol-function 'xref-backend-identifier-at-point) + (lambda (_backend) "beta")) + ((symbol-function 'xref-backend-definitions) + (lambda (_backend identifier) + (list (xref-make + (format "%s definition" identifier) + (xref-make-file-location file-path 4 0)))))) + (should (equal '("defs.el:4: beta definition") + (ai-code-mcp-xref-find-definitions-at-point + file-path + 2 + 3)))) + (setq visited-buffer (find-buffer-visiting file-path))) + (when (buffer-live-p visited-buffer) + (kill-buffer visited-buffer)) + (delete-directory project-dir t)))) + +(ert-deftest ai-code-test-mcp-display-path-keeps-external-sibling-absolute () + "Display path should keep sibling paths outside the project absolute." + (let* ((project-dir (make-temp-file "ai-code-mcp-display-path-" t)) + (sibling-dir (concat project-dir "-sibling")) + (external-file (expand-file-name "other.el" sibling-dir)) + (buffer (generate-new-buffer " *ai-code-mcp-display-path*")) + (ai-code-mcp--sessions (make-hash-table :test 'equal)) + (ai-code-mcp--current-session-id "session-display-path")) + (unwind-protect + (progn + (make-directory sibling-dir t) + (with-temp-file external-file + (insert "(message \"other\")\n")) + (ai-code-mcp-register-session "session-display-path" project-dir buffer) + (should (equal (expand-file-name external-file) + (ai-code-mcp--display-path external-file)))) + (when (buffer-live-p buffer) + (kill-buffer buffer)) + (let ((visited-buffer (find-buffer-visiting external-file))) + (when (buffer-live-p visited-buffer) + (kill-buffer visited-buffer))) + (delete-directory project-dir t) + (delete-directory sibling-dir t)))) + +(ert-deftest ai-code-test-mcp-format-xref-item-preserves-external-absolute-path () + "Xref items outside the project should keep their absolute file path." + (let* ((project-dir (make-temp-file "ai-code-mcp-xref-project-" t)) + (external-dir (make-temp-file "ai-code-mcp-xref-external-" t)) + (external-file (expand-file-name "index.el" external-dir)) + (buffer (generate-new-buffer " *ai-code-mcp-xref-format*")) + (ai-code-mcp--sessions (make-hash-table :test 'equal)) + (ai-code-mcp--current-session-id "session-xref-format")) + (unwind-protect + (progn + (with-temp-file external-file + (insert "(message \"external\")\n")) + (ai-code-mcp-register-session "session-xref-format" project-dir buffer) + (should (equal + (format "%s:1: external summary" + (expand-file-name external-file)) + (ai-code-mcp--format-xref-item + (xref-make + "external summary" + (xref-make-file-location external-file 1 0)))))) + (when (buffer-live-p buffer) + (kill-buffer buffer)) + (let ((visited-buffer (find-buffer-visiting external-file))) + (when (buffer-live-p visited-buffer) + (kill-buffer visited-buffer))) + (delete-directory project-dir t) + (delete-directory external-dir t)))) + (provide 'test_ai-code-mcp-server) ;;; test_ai-code-mcp-server.el ends here