summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/ai-mcp.el416
-rw-r--r--tests/test-ai-mcp-helpers.el419
2 files changed, 835 insertions, 0 deletions
diff --git a/modules/ai-mcp.el b/modules/ai-mcp.el
new file mode 100644
index 00000000..3b552d8d
--- /dev/null
+++ b/modules/ai-mcp.el
@@ -0,0 +1,416 @@
+;;; ai-mcp.el --- MCP server integration for GPTel -*- lexical-binding: t; coding: utf-8; -*-
+;; Author: Craig Jennings <c@cjennings.net>
+;; Maintainer: Craig Jennings <c@cjennings.net>
+;; Version 0.1
+;; Package-Requires: ((emacs "30.1") (mcp "0.1.0") (gptel "0.9.8"))
+;; Keywords: convenience, tools, ai
+;;
+;;; Commentary:
+;; Wires mcp.el's MCP server inventory into GPTel. GPTel agents gain
+;; access to the MCP servers Claude Code already uses (linear, notion,
+;; figma, slack-deepsat, drawio, google-calendar, google-docs-personal,
+;; google-docs-work, google-keep), with write-confirmation gating and a
+;; doctor for diagnosing prerequisites.
+;;
+;; Design doc: docs/design/mcp-el-gptel-integration.org
+;;
+;; File organization (seven sections, populated by phases):
+;; 1. Constants and defcustoms <- this phase
+;; 2. Public commands <- later phase
+;; 3. Pure helpers <- this phase
+;; 4. mcp.el compatibility layer <- later phase
+;; 5. Registration pipeline <- later phase
+;; 6. Async state machine <- later phase
+;; 7. UI <- later phase
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'json)
+
+;;;; --- 1. Constants and defcustoms -----------------------------------
+
+(defgroup cj/ai-mcp nil
+ "MCP server integration for GPTel."
+ :group 'gptel
+ :prefix "cj/")
+
+(defcustom cj/mcp-claude-config
+ (expand-file-name "~/.claude.json")
+ "Path to the Claude Code config that holds MCP server env vars.
+The config is read at server-spawn time and cached by mtime."
+ :type 'file
+ :group 'cj/ai-mcp)
+
+(defconst cj/mcp-server-specs
+ '((:name "linear"
+ :transport http
+ :url "https://mcp.linear.app/mcp"
+ :auth in-protocol
+ :risk write-capable)
+ (:name "notion"
+ :transport http
+ :url "https://mcp.notion.com/mcp"
+ :auth in-protocol
+ :risk write-capable)
+ (:name "figma"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "figma-developer-mcp" "--stdio")
+ :secret-args ("--figma-api-key" :figma-api-key)
+ :auth args-token
+ :risk arg-leak)
+ (:name "slack-deepsat"
+ :transport sse
+ :url "http://127.0.0.1:13080/sse"
+ :auth local
+ :risk write-capable)
+ (:name "drawio"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@drawio/mcp")
+ :auth none
+ :risk none)
+ (:name "google-calendar"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@cocal/google-calendar-mcp")
+ :env (:GOOGLE_OAUTH_CREDENTIALS t)
+ :auth oauth
+ :risk write-capable)
+ (:name "google-docs-personal"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@a-bonus/google-docs-mcp")
+ :env (:GOOGLE_CLIENT_ID t :GOOGLE_CLIENT_SECRET t :GOOGLE_MCP_PROFILE t)
+ :auth oauth
+ :risk write-capable)
+ (:name "google-docs-work"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@a-bonus/google-docs-mcp")
+ :env (:GOOGLE_CLIENT_ID t :GOOGLE_CLIENT_SECRET t :GOOGLE_MCP_PROFILE t)
+ :auth oauth
+ :risk write-capable)
+ (:name "google-keep"
+ :transport stdio
+ :command "uvx"
+ :args ("--from" "keep-mcp" "python" "-m" "server.cli")
+ :env (:GOOGLE_EMAIL t :GOOGLE_MASTER_TOKEN t)
+ :auth token
+ :risk write-capable))
+ "Static, secret-free description of the MCP servers we wire to GPTel.
+Each entry is a plist describing one server. `:env' values are
+placeholders (t) replaced at spawn time from `cj/mcp-claude-config'.
+`:secret-args' (e.g. for figma) names the flag whose value is pulled
+from the Claude config's args at spawn time.")
+
+(defcustom cj/mcp-enabled-servers
+ (mapcar (lambda (s) (plist-get s :name)) cj/mcp-server-specs)
+ "List of MCP server names to start.
+Defaults to every server in `cj/mcp-server-specs'. Set to a
+shorter list to disable specific servers without editing the
+spec. Changes take effect on next `cj/mcp-restart-failed' or
+Emacs restart."
+ :type '(repeat string)
+ :group 'cj/ai-mcp)
+
+(defcustom cj/mcp-start-on-entry-points
+ '(toggle-gptel)
+ "GPTel entry points that trigger MCP startup.
+Symbols correspond to commands: `toggle-gptel', `gptel-send',
+`gptel-quick-ask', `gptel-rewrite-with-directive',
+`gptel-magit-generate-message'. Default: only full chat
+\(`toggle-gptel')."
+ :type '(repeat symbol)
+ :group 'cj/ai-mcp)
+
+(defcustom cj/mcp-startup-timeout 30
+ "Seconds before a still-starting MCP server is marked failed."
+ :type 'integer
+ :group 'cj/ai-mcp)
+
+(defcustom cj/mcp-tool-timeout 60
+ "Seconds before an in-flight MCP tool call times out."
+ :type 'integer
+ :group 'cj/ai-mcp)
+
+(defcustom cj/mcp-tool-confirm-overrides nil
+ "Per-tool confirmation overrides.
+Alist mapping fully qualified MCP tool name (e.g.,
+\"mcp__linear__create_issue\") to t or nil. Wins over the
+pattern-based classifier in `cj/mcp--confirm-p'."
+ :type '(alist :key-type string :value-type boolean)
+ :group 'cj/ai-mcp)
+
+(defcustom cj/mcp-tool-audit-log-enabled t
+ "When non-nil, append metadata for every MCP tool call to the audit log."
+ :type 'boolean
+ :group 'cj/ai-mcp)
+
+;; Classifier patterns: name prefixes that indicate read vs write.
+
+(defconst cj/mcp--write-name-patterns
+ '("\\`create\\b" "\\`update\\b" "\\`delete\\b" "\\`remove\\b"
+ "\\`send\\b" "\\`post\\b" "\\`add\\b" "\\`move\\b"
+ "\\`invite\\b" "\\`share\\b" "\\`upload\\b" "\\`set\\b"
+ "\\`patch\\b" "\\`import\\b" "\\`sync\\b" "\\`merge\\b"
+ "\\`close\\b" "\\`reopen\\b" "\\`archive\\b" "\\`unarchive\\b"
+ "\\`approve\\b" "\\`reject\\b" "\\`label\\b" "\\`assign\\b"
+ "\\`reply\\b" "\\`comment\\b" "\\`trash\\b" "\\`restore\\b"
+ "\\`pin\\b" "\\`unpin\\b" "\\`copy\\b" "\\`rename\\b")
+ "Tool-name prefixes that indicate a write/mutate operation.
+Matched after the `mcp__SERVER__' prefix is stripped.")
+
+(defconst cj/mcp--read-name-patterns
+ '("\\`get\\b" "\\`list\\b" "\\`read\\b" "\\`search\\b"
+ "\\`find\\b" "\\`fetch\\b" "\\`view\\b" "\\`query\\b"
+ "\\`describe\\b" "\\`show\\b" "\\`check\\b")
+ "Tool-name prefixes that indicate a read-only operation.")
+
+;; Secret-pattern list for redaction. Each entry is (REGEX
+;; . GROUP-NUMBER); the substring matched by GROUP-NUMBER is replaced
+;; with "***".
+
+(defconst cj/mcp--secret-redaction-patterns
+ '(("\\(--token\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3)
+ ("\\(--secret\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3)
+ ("\\(--password\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3)
+ ("\\(--figma-api-key\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3)
+ ("\\(Authorization:\\s-*\\)\\(\\S-[^\"\n]*\\)" . 2)
+ ("\\([?&]token=\\)\\([^&[:space:]\"]+\\)" . 2))
+ "List of (REGEX . GROUP-NUMBER) for masking secrets in user-facing strings.
+Applied in order by `cj/mcp--redact'.")
+
+;;;; --- 3. Pure helpers -----------------------------------------------
+
+;; ---- secrets redaction ----
+
+(defun cj/mcp--redact (str)
+ "Return STR with known secret patterns replaced by `***'.
+Returns nil when STR is not a string. See
+`cj/mcp--secret-redaction-patterns' for the matched patterns."
+ (when (stringp str)
+ (let ((result str))
+ (dolist (entry cj/mcp--secret-redaction-patterns result)
+ (let ((re (car entry))
+ (group (cdr entry))
+ (start 0))
+ (while (and (< start (length result))
+ (string-match re result start))
+ (setq result
+ (concat (substring result 0 (match-beginning group))
+ "***"
+ (substring result (match-end group))))
+ (setq start (+ (match-beginning group) 3))))))))
+
+;; ---- confirm-policy classifier ----
+
+(defun cj/mcp--strip-name-prefix (name)
+ "Strip the `mcp__SERVER__' prefix from NAME, if present."
+ (replace-regexp-in-string "\\`mcp__[^_]+__" "" name))
+
+(defun cj/mcp--name-matches-p (name patterns)
+ "Non-nil if NAME matches any regexp in PATTERNS."
+ (cl-some (lambda (p) (string-match-p p name)) patterns))
+
+(defun cj/mcp--confirm-p (gptel-name &optional remote-name)
+ "Return non-nil if a tool should register with `:confirm t'.
+GPTEL-NAME is the fully qualified `mcp__SERVER__TOOL' string.
+REMOTE-NAME, if provided, overrides the prefix-strip of GPTEL-NAME.
+
+Decision order:
+1. `cj/mcp-tool-confirm-overrides' alist entry wins.
+2. Bare name matches a write pattern → t.
+3. Bare name matches a read pattern → nil.
+4. Neither → t (fail closed)."
+ (let ((override (assoc gptel-name cj/mcp-tool-confirm-overrides)))
+ (cond
+ (override (cdr override))
+ (t
+ (let ((bare (or remote-name (cj/mcp--strip-name-prefix gptel-name))))
+ (cond
+ ((cj/mcp--name-matches-p bare cj/mcp--write-name-patterns) t)
+ ((cj/mcp--name-matches-p bare cj/mcp--read-name-patterns) nil)
+ (t t)))))))
+
+;; ---- description normalizer ----
+
+(defun cj/mcp--normalize-description (server-name raw-tool)
+ "Return a normalized description string for RAW-TOOL from SERVER-NAME.
+Prefix `[SERVER]' for reads, `[SERVER WRITE]' for writes,
+`[SERVER ?]' for unknown classification, then the upstream
+description unchanged."
+ (let* ((remote-name (plist-get raw-tool :name))
+ (upstream (or (plist-get raw-tool :description)
+ "(no description provided by server)"))
+ (suffix (cond
+ ((cj/mcp--name-matches-p remote-name
+ cj/mcp--write-name-patterns)
+ " WRITE")
+ ((cj/mcp--name-matches-p remote-name
+ cj/mcp--read-name-patterns)
+ "")
+ (t " ?"))))
+ (format "[%s%s] %s" server-name suffix upstream)))
+
+;; ---- Claude config reader (mtime-cached, structured returns) ----
+
+(defvar cj/mcp--config-cache nil
+ "Cache for the parsed Claude config.
+Plist of (:path P :mtime M :data PARSED) or nil when empty.")
+
+(defun cj/mcp--invalidate-config-cache ()
+ "Force the next `cj/mcp--read-claude-config' call to reparse."
+ (setq cj/mcp--config-cache nil))
+
+(defun cj/mcp--read-claude-config (&optional path)
+ "Return a structured plist describing the Claude config state.
+PATH defaults to `cj/mcp-claude-config'.
+
+Result shape:
+ (:ok t :data PLIST)
+ (:ok nil :reason missing-file)
+ (:ok nil :reason unreadable)
+ (:ok nil :reason malformed-json :message STR)
+
+The parsed result is cached by (PATH, MTIME); subsequent calls
+reparse only if the file has changed."
+ (let ((path (or path cj/mcp-claude-config)))
+ (cond
+ ((not (file-exists-p path))
+ (list :ok nil :reason 'missing-file))
+ ((not (file-readable-p path))
+ (list :ok nil :reason 'unreadable))
+ (t
+ (let ((mtime (file-attribute-modification-time
+ (file-attributes path))))
+ (if (and cj/mcp--config-cache
+ (equal (plist-get cj/mcp--config-cache :path) path)
+ (equal (plist-get cj/mcp--config-cache :mtime) mtime))
+ (list :ok t :data (plist-get cj/mcp--config-cache :data))
+ (condition-case err
+ (let* ((json-object-type 'plist)
+ (json-array-type 'list)
+ (data (with-temp-buffer
+ (insert-file-contents path)
+ (goto-char (point-min))
+ (json-read))))
+ (setq cj/mcp--config-cache
+ (list :path path :mtime mtime :data data))
+ (list :ok t :data data))
+ (error
+ (setq cj/mcp--config-cache nil)
+ (list :ok nil :reason 'malformed-json
+ :message (error-message-string err))))))))))
+
+;; ---- env / secret-args resolution ----
+
+(defun cj/mcp--get-server-entry (server-name &optional config-result)
+ "Return the parsed Claude-config entry plist for SERVER-NAME.
+CONFIG-RESULT, if provided, is a return value from
+`cj/mcp--read-claude-config' (avoids re-reading). Returns nil
+when the config is unavailable or SERVER-NAME is unknown."
+ (let ((result (or config-result (cj/mcp--read-claude-config))))
+ (when (plist-get result :ok)
+ (let* ((data (plist-get result :data))
+ (servers (plist-get data :mcpServers))
+ (server-key (intern (concat ":" server-name))))
+ (plist-get servers server-key)))))
+
+(defun cj/mcp--get-env (server-name &optional config-result)
+ "Return the env plist for SERVER-NAME from the parsed Claude config.
+CONFIG-RESULT, if provided, is reused to avoid re-reading the
+config. Returns nil when the config is unavailable, the server
+is unknown, or the server has no env section."
+ (plist-get (cj/mcp--get-server-entry server-name config-result) :env))
+
+(defun cj/mcp--get-secret-arg (server-name flag &optional config-result)
+ "Return the secret value for SERVER-NAME's FLAG from the Claude config.
+FLAG is the option name (e.g. \"--figma-api-key\"). Returns the
+value following `FLAG=' in the server entry's args, or nil if
+not found."
+ (let* ((entry (cj/mcp--get-server-entry server-name config-result))
+ (args (plist-get entry :args))
+ (prefix (concat flag "=")))
+ (cl-some
+ (lambda (a)
+ (when (and (stringp a) (string-prefix-p prefix a))
+ (substring a (length prefix))))
+ args)))
+
+;; ---- server-alist builder (pure transform from specs + config) ----
+
+(defun cj/mcp--resolve-env (env-spec server-name config-result)
+ "Return a flat (KEY1 VAL1 KEY2 VAL2 ...) list for ENV-SPEC.
+ENV-SPEC is a plist of `(:VAR1 t :VAR2 t)`. Values come from
+SERVER-NAME's env subtree in the parsed Claude config. Vars
+without a value are omitted."
+ (let ((source-env (cj/mcp--get-env server-name config-result))
+ (result nil))
+ (cl-loop for (key _placeholder) on env-spec by #'cddr
+ do (let ((value (plist-get source-env key)))
+ (when value
+ (push key result)
+ (push value result))))
+ (nreverse result)))
+
+(defun cj/mcp--resolve-args (args secret-args-spec server-name config-result)
+ "Return ARGS with `:secret-args' placeholders filled in.
+SECRET-ARGS-SPEC is (FLAG-STRING SLOT-KEYWORD). When the value is
+available in the Claude config, append `FLAG=VALUE' to ARGS;
+otherwise return ARGS unchanged."
+ (if (not secret-args-spec)
+ args
+ (let* ((flag (car secret-args-spec))
+ (value (cj/mcp--get-secret-arg server-name flag config-result)))
+ (if value
+ (append args (list (format "%s=%s" flag value)))
+ args))))
+
+(defun cj/mcp--spec-to-alist-entry (spec config-result)
+ "Translate one SPEC plist into a `(NAME . PLIST)' alist entry.
+Pulls env values from CONFIG-RESULT; splices `:secret-args' into
+`:args' for stdio specs that declare one."
+ (let* ((name (plist-get spec :name))
+ (transport (plist-get spec :transport))
+ (entry (list :type (symbol-name transport)))
+ (env-spec (plist-get spec :env))
+ (secret-args-spec (plist-get spec :secret-args)))
+ (pcase transport
+ ('stdio
+ (setq entry (append entry
+ (list :command (plist-get spec :command)
+ :args (cj/mcp--resolve-args
+ (plist-get spec :args)
+ secret-args-spec
+ name
+ config-result)))))
+ ((or 'http 'sse)
+ (setq entry (append entry
+ (list :url (plist-get spec :url))))))
+ (when env-spec
+ (let ((env-pairs (cj/mcp--resolve-env env-spec name config-result)))
+ (when env-pairs
+ (setq entry (append entry (list :env env-pairs))))))
+ (cons name entry)))
+
+(defun cj/mcp--build-server-alist (&optional specs enabled-names config-result)
+ "Return an alist suitable for `mcp-hub-servers'.
+SPECS defaults to `cj/mcp-server-specs'. ENABLED-NAMES defaults
+to `cj/mcp-enabled-servers'. CONFIG-RESULT, if provided, is a
+parsed Claude-config result (reused for env/secret resolution).
+Does not mutate SPECS."
+ (let* ((specs (or specs cj/mcp-server-specs))
+ (enabled-names (or enabled-names cj/mcp-enabled-servers))
+ (config-result (or config-result (cj/mcp--read-claude-config))))
+ (delq nil
+ (mapcar
+ (lambda (spec)
+ (let ((name (plist-get spec :name)))
+ (when (member name enabled-names)
+ (cj/mcp--spec-to-alist-entry spec config-result))))
+ specs))))
+
+(provide 'ai-mcp)
+;;; ai-mcp.el ends here
diff --git a/tests/test-ai-mcp-helpers.el b/tests/test-ai-mcp-helpers.el
new file mode 100644
index 00000000..5a995ff2
--- /dev/null
+++ b/tests/test-ai-mcp-helpers.el
@@ -0,0 +1,419 @@
+;;; test-ai-mcp-helpers.el --- Tests for pure helpers in ai-mcp.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Normal / Boundary / Error tests for the side-effect-free helpers in
+;; ai-mcp.el: secrets redaction, confirm-policy classifier, description
+;; normalizer, Claude-config reader (mtime-cached), env / secret-args
+;; resolution, server-alist builder. No real `~/.claude.json' reads;
+;; fixtures are written to per-test temp files. No real subprocesses
+;; or network calls.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-mcp)
+
+;; -------------------------------------------------------- fixtures
+
+(defconst test-ai-mcp--sentinel "REDACTED_TEST_SECRET"
+ "Sentinel that must never appear in any user-facing output.")
+
+(defconst test-ai-mcp--fixture-json
+ "{
+ \"mcpServers\": {
+ \"drawio\": {
+ \"type\": \"stdio\",
+ \"command\": \"npx\",
+ \"args\": [\"-y\", \"@drawio/mcp\"]
+ },
+ \"google-calendar\": {
+ \"type\": \"stdio\",
+ \"command\": \"npx\",
+ \"args\": [\"-y\", \"@cocal/google-calendar-mcp\"],
+ \"env\": {
+ \"GOOGLE_OAUTH_CREDENTIALS\": \"REDACTED_TEST_SECRET\"
+ }
+ },
+ \"google-docs-personal\": {
+ \"type\": \"stdio\",
+ \"command\": \"npx\",
+ \"args\": [\"-y\", \"@a-bonus/google-docs-mcp\"],
+ \"env\": {
+ \"GOOGLE_CLIENT_ID\": \"REDACTED_TEST_SECRET\",
+ \"GOOGLE_CLIENT_SECRET\": \"REDACTED_TEST_SECRET\",
+ \"GOOGLE_MCP_PROFILE\": \"personal\"
+ }
+ },
+ \"figma\": {
+ \"type\": \"stdio\",
+ \"command\": \"npx\",
+ \"args\": [\"-y\", \"figma-developer-mcp\", \"--figma-api-key=REDACTED_TEST_SECRET\", \"--stdio\"]
+ },
+ \"linear\": {
+ \"type\": \"http\",
+ \"url\": \"https://mcp.linear.app/mcp\"
+ },
+ \"slack-deepsat\": {
+ \"type\": \"sse\",
+ \"url\": \"http://127.0.0.1:13080/sse\"
+ }
+ }
+}"
+ "Fixture matching the shape of a real ~/.claude.json mcpServers tree.")
+
+(defun test-ai-mcp--write-fixture (&optional content)
+ "Write CONTENT (defaults to the standard fixture) to a temp file.
+Return the file path."
+ (let ((tmp (make-temp-file "test-ai-mcp-" nil ".json")))
+ (with-temp-file tmp
+ (insert (or content test-ai-mcp--fixture-json)))
+ tmp))
+
+(defmacro test-ai-mcp--with-fixture (var &rest body)
+ "Bind VAR to a fresh fixture file path and BODY-eval. Clean up after."
+ (declare (indent 1))
+ `(let ((,var (test-ai-mcp--write-fixture))
+ (cj/mcp--config-cache nil))
+ (unwind-protect (progn ,@body)
+ (when (file-exists-p ,var) (delete-file ,var)))))
+
+;; -------------------------------------------------------- redact
+
+(ert-deftest test-ai-mcp-redact-token-eq-normal ()
+ "Normal: --token=VALUE has the value replaced by ***."
+ (should (equal (cj/mcp--redact "--token=abc123") "--token=***")))
+
+(ert-deftest test-ai-mcp-redact-token-spaced-boundary ()
+ "Boundary: --token VALUE (space separator) is also redacted."
+ (should (equal (cj/mcp--redact "--token abc123") "--token ***")))
+
+(ert-deftest test-ai-mcp-redact-secret-flag-normal ()
+ "Normal: --secret=VALUE is redacted."
+ (should (equal (cj/mcp--redact "--secret=topsecret") "--secret=***")))
+
+(ert-deftest test-ai-mcp-redact-password-flag-normal ()
+ "Normal: --password=VALUE is redacted."
+ (should (equal (cj/mcp--redact "--password=hunter2") "--password=***")))
+
+(ert-deftest test-ai-mcp-redact-figma-api-key-normal ()
+ "Normal: --figma-api-key=VALUE is redacted (covers the figma case)."
+ (should (equal (cj/mcp--redact "--figma-api-key=figd_xyz")
+ "--figma-api-key=***")))
+
+(ert-deftest test-ai-mcp-redact-authorization-header-normal ()
+ "Normal: Authorization header value (scheme + token) is masked."
+ (should (equal (cj/mcp--redact "Authorization: Bearer ghp_xyz123")
+ "Authorization: ***")))
+
+(ert-deftest test-ai-mcp-redact-url-token-normal ()
+ "Normal: ?token=VALUE in a URL is masked."
+ (should (equal (cj/mcp--redact "https://api.example/v1?token=abc123&page=2")
+ "https://api.example/v1?token=***&page=2")))
+
+(ert-deftest test-ai-mcp-redact-no-secrets-boundary ()
+ "Boundary: a string with no known secrets is returned unchanged."
+ (should (equal (cj/mcp--redact "hello world, nothing secret here")
+ "hello world, nothing secret here")))
+
+(ert-deftest test-ai-mcp-redact-empty-string-boundary ()
+ "Boundary: empty string returns empty string."
+ (should (equal (cj/mcp--redact "") "")))
+
+(ert-deftest test-ai-mcp-redact-multiple-secrets-boundary ()
+ "Boundary: multiple secrets in one string are all redacted."
+ (let* ((input "--token=abc --secret=xyz --password=qwe")
+ (out (cj/mcp--redact input)))
+ (should (equal out "--token=*** --secret=*** --password=***"))))
+
+(ert-deftest test-ai-mcp-redact-nil-input-error ()
+ "Error: nil input returns nil rather than signaling."
+ (should (null (cj/mcp--redact nil))))
+
+(ert-deftest test-ai-mcp-redact-sentinel-never-leaks ()
+ "Sentinel REDACTED_TEST_SECRET is replaced wherever it lives in a secret slot."
+ (dolist (input (list (format "--token=%s" test-ai-mcp--sentinel)
+ (format "--figma-api-key=%s" test-ai-mcp--sentinel)
+ (format "Authorization: Bearer %s" test-ai-mcp--sentinel)
+ (format "https://x/y?token=%s" test-ai-mcp--sentinel)))
+ (let ((out (cj/mcp--redact input)))
+ (should-not (string-match-p test-ai-mcp--sentinel out)))))
+
+;; -------------------------------------------------------- confirm-p
+
+(ert-deftest test-ai-mcp-confirm-p-write-pattern-normal ()
+ "Normal: a write-prefixed tool name returns t."
+ (should (cj/mcp--confirm-p "mcp__linear__create_issue")))
+
+(ert-deftest test-ai-mcp-confirm-p-read-pattern-normal ()
+ "Normal: a read-prefixed tool name returns nil."
+ (should-not (cj/mcp--confirm-p "mcp__linear__list_issues")))
+
+(ert-deftest test-ai-mcp-confirm-p-unknown-fails-closed-boundary ()
+ "Boundary: a name matching neither read nor write defaults to t (fail closed)."
+ (should (cj/mcp--confirm-p "mcp__linear__frobnicate")))
+
+(ert-deftest test-ai-mcp-confirm-p-explicit-remote-name-boundary ()
+ "Boundary: REMOTE-NAME arg overrides the prefix-strip of GPTEL-NAME."
+ ;; The gptel-name claims read, but the explicit remote-name is a write
+ ;; verb, so confirm should still fire.
+ (should (cj/mcp--confirm-p "mcp__linear__list_issues" "create_issue")))
+
+(ert-deftest test-ai-mcp-confirm-p-override-wins-boundary ()
+ "Boundary: cj/mcp-tool-confirm-overrides wins over the classifier."
+ (let ((cj/mcp-tool-confirm-overrides
+ '(("mcp__linear__create_issue" . nil))))
+ (should-not (cj/mcp--confirm-p "mcp__linear__create_issue"))))
+
+;; -------------------------------------------------------- normalize-description
+
+(ert-deftest test-ai-mcp-normalize-description-read-normal ()
+ "Normal: a read tool gets the bare [SERVER] prefix."
+ (should (equal
+ (cj/mcp--normalize-description
+ "linear"
+ '(:name "list_issues" :description "List issues in a Linear team."))
+ "[linear] List issues in a Linear team.")))
+
+(ert-deftest test-ai-mcp-normalize-description-write-normal ()
+ "Normal: a write tool gets [SERVER WRITE] prefix."
+ (should (equal
+ (cj/mcp--normalize-description
+ "linear"
+ '(:name "create_issue" :description "Create a new Linear issue."))
+ "[linear WRITE] Create a new Linear issue.")))
+
+(ert-deftest test-ai-mcp-normalize-description-unknown-boundary ()
+ "Boundary: a tool matching neither classifier gets [SERVER ?] prefix."
+ (should (equal
+ (cj/mcp--normalize-description
+ "google-keep"
+ '(:name "frobnicate" :description "Do the frob thing."))
+ "[google-keep ?] Do the frob thing.")))
+
+(ert-deftest test-ai-mcp-normalize-description-missing-upstream-boundary ()
+ "Boundary: missing upstream description falls back to a placeholder."
+ (should (equal
+ (cj/mcp--normalize-description
+ "linear"
+ '(:name "list_issues"))
+ "[linear] (no description provided by server)")))
+
+;; -------------------------------------------------------- read-claude-config
+
+(ert-deftest test-ai-mcp-read-claude-config-good-fixture-normal ()
+ "Normal: parsing a well-formed fixture returns :ok t and the parsed data."
+ (test-ai-mcp--with-fixture path
+ (let ((result (cj/mcp--read-claude-config path)))
+ (should (plist-get result :ok))
+ (should (plist-get (plist-get result :data) :mcpServers)))))
+
+(ert-deftest test-ai-mcp-read-claude-config-missing-file-error ()
+ "Error: missing file returns :ok nil with :reason missing-file."
+ (let ((cj/mcp--config-cache nil)
+ (path "/nonexistent/path/never-will-exist.json"))
+ (let ((result (cj/mcp--read-claude-config path)))
+ (should-not (plist-get result :ok))
+ (should (eq (plist-get result :reason) 'missing-file)))))
+
+(ert-deftest test-ai-mcp-read-claude-config-malformed-json-error ()
+ "Error: malformed JSON returns :ok nil with :reason malformed-json and a message."
+ (let ((cj/mcp--config-cache nil)
+ (tmp (make-temp-file "test-ai-mcp-malformed-" nil ".json")))
+ (unwind-protect
+ (progn
+ (with-temp-file tmp (insert "{ this is not valid json ::: "))
+ (let ((result (cj/mcp--read-claude-config tmp)))
+ (should-not (plist-get result :ok))
+ (should (eq (plist-get result :reason) 'malformed-json))
+ (should (stringp (plist-get result :message)))))
+ (delete-file tmp))))
+
+(ert-deftest test-ai-mcp-read-claude-config-empty-object-boundary ()
+ "Boundary: an empty JSON object parses to ok with empty data plist."
+ (let ((cj/mcp--config-cache nil)
+ (tmp (make-temp-file "test-ai-mcp-empty-" nil ".json")))
+ (unwind-protect
+ (progn
+ (with-temp-file tmp (insert "{}"))
+ (let ((result (cj/mcp--read-claude-config tmp)))
+ (should (plist-get result :ok))
+ ;; :mcpServers is absent; plist-get returns nil.
+ (should-not (plist-get (plist-get result :data) :mcpServers))))
+ (delete-file tmp))))
+
+(ert-deftest test-ai-mcp-read-claude-config-cache-hit-boundary ()
+ "Boundary: a second read with the same mtime reuses the cache.
+We detect cache reuse by mutating the cached :data alist after the first
+read and verifying the second read returns the mutated value."
+ (test-ai-mcp--with-fixture path
+ (let* ((first (cj/mcp--read-claude-config path))
+ (cache cj/mcp--config-cache))
+ (should (plist-get first :ok))
+ ;; Mutate the cached :data so a cache-hit returns the marker.
+ (plist-put cache :data '(:sentinel cache-was-hit))
+ (let ((second (cj/mcp--read-claude-config path)))
+ (should (equal (plist-get second :data) '(:sentinel cache-was-hit)))))))
+
+(ert-deftest test-ai-mcp-read-claude-config-cache-invalidate-on-mtime-boundary ()
+ "Boundary: changing the file's mtime forces a reparse."
+ (test-ai-mcp--with-fixture path
+ (let* ((first (cj/mcp--read-claude-config path))
+ (cache cj/mcp--config-cache))
+ (should (plist-get first :ok))
+ ;; Poison the cache, then bump mtime; the next read should reparse.
+ (plist-put cache :data '(:sentinel cache-was-hit))
+ (set-file-times path (time-add (current-time) 2))
+ ;; Update the cache var since set-file-times changed file mtime.
+ (setq cj/mcp--config-cache cache)
+ (let ((second (cj/mcp--read-claude-config path)))
+ ;; Real reparse should give us the real data, not the sentinel.
+ (should (plist-get (plist-get second :data) :mcpServers))))))
+
+(ert-deftest test-ai-mcp-read-claude-config-missing-mcpservers-boundary ()
+ "Boundary: a valid JSON without :mcpServers parses but the subtree is nil."
+ (let ((cj/mcp--config-cache nil)
+ (tmp (make-temp-file "test-ai-mcp-no-mcp-" nil ".json")))
+ (unwind-protect
+ (progn
+ (with-temp-file tmp (insert "{\"other\": 1}"))
+ (let ((result (cj/mcp--read-claude-config tmp)))
+ (should (plist-get result :ok))
+ (should-not (plist-get (plist-get result :data) :mcpServers))))
+ (delete-file tmp))))
+
+;; -------------------------------------------------------- get-env / get-secret-arg
+
+(ert-deftest test-ai-mcp-get-env-known-server-with-env-normal ()
+ "Normal: env-bearing server returns its env plist."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (env (cj/mcp--get-env "google-calendar")))
+ (should (equal (plist-get env :GOOGLE_OAUTH_CREDENTIALS)
+ test-ai-mcp--sentinel)))))
+
+(ert-deftest test-ai-mcp-get-env-known-server-without-env-boundary ()
+ "Boundary: a server with no env subtree returns nil."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path))
+ (should-not (cj/mcp--get-env "drawio")))))
+
+(ert-deftest test-ai-mcp-get-env-unknown-server-error ()
+ "Error: unknown server returns nil without signaling."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path))
+ (should-not (cj/mcp--get-env "no-such-server")))))
+
+(ert-deftest test-ai-mcp-get-secret-arg-figma-normal ()
+ "Normal: figma's --figma-api-key= value is extracted from args."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (value (cj/mcp--get-secret-arg "figma" "--figma-api-key")))
+ (should (equal value test-ai-mcp--sentinel)))))
+
+(ert-deftest test-ai-mcp-get-secret-arg-missing-flag-error ()
+ "Error: a flag not in the server's args returns nil."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (value (cj/mcp--get-secret-arg "figma" "--no-such-flag")))
+ (should (null value)))))
+
+;; -------------------------------------------------------- build-server-alist
+
+(ert-deftest test-ai-mcp-build-server-alist-all-enabled-normal ()
+ "Normal: with default specs and all-enabled list, alist has all 9 entries."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist)))
+ (should (= (length alist) 9))
+ ;; Every name appears.
+ (dolist (name '("linear" "notion" "figma" "slack-deepsat" "drawio"
+ "google-calendar" "google-docs-personal"
+ "google-docs-work" "google-keep"))
+ (should (assoc name alist))))))
+
+(ert-deftest test-ai-mcp-build-server-alist-filter-by-enabled-boundary ()
+ "Boundary: enabled subset of names produces a subset alist."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist
+ cj/mcp-server-specs
+ '("drawio" "linear"))))
+ (should (= (length alist) 2))
+ (should (assoc "drawio" alist))
+ (should (assoc "linear" alist))
+ (should-not (assoc "figma" alist)))))
+
+(ert-deftest test-ai-mcp-build-server-alist-stdio-shape-normal ()
+ "Normal: a stdio entry has :type, :command, :args (no :url)."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist
+ cj/mcp-server-specs '("drawio"))))
+ (let ((entry (cdr (assoc "drawio" alist))))
+ (should (equal (plist-get entry :type) "stdio"))
+ (should (equal (plist-get entry :command) "npx"))
+ (should (listp (plist-get entry :args)))
+ (should-not (plist-get entry :url))))))
+
+(ert-deftest test-ai-mcp-build-server-alist-http-shape-normal ()
+ "Normal: an http entry has :type and :url (no :command)."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist
+ cj/mcp-server-specs '("linear"))))
+ (let ((entry (cdr (assoc "linear" alist))))
+ (should (equal (plist-get entry :type) "http"))
+ (should (equal (plist-get entry :url) "https://mcp.linear.app/mcp"))
+ (should-not (plist-get entry :command))))))
+
+(ert-deftest test-ai-mcp-build-server-alist-sse-shape-normal ()
+ "Normal: an sse entry has :type and :url."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist
+ cj/mcp-server-specs '("slack-deepsat"))))
+ (let ((entry (cdr (assoc "slack-deepsat" alist))))
+ (should (equal (plist-get entry :type) "sse"))
+ (should (equal (plist-get entry :url)
+ "http://127.0.0.1:13080/sse"))))))
+
+(ert-deftest test-ai-mcp-build-server-alist-env-merge-normal ()
+ "Normal: env-bearing server has its env plist merged into the entry."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist
+ cj/mcp-server-specs '("google-calendar"))))
+ (let* ((entry (cdr (assoc "google-calendar" alist)))
+ (env (plist-get entry :env)))
+ (should env)
+ (should (equal (plist-get env :GOOGLE_OAUTH_CREDENTIALS)
+ test-ai-mcp--sentinel))))))
+
+(ert-deftest test-ai-mcp-build-server-alist-secret-args-splice-normal ()
+ "Normal: figma's --figma-api-key= is spliced into :args from Claude config."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (alist (cj/mcp--build-server-alist
+ cj/mcp-server-specs '("figma"))))
+ (let* ((entry (cdr (assoc "figma" alist)))
+ (args (plist-get entry :args))
+ (api-arg (cl-find-if
+ (lambda (a) (string-prefix-p "--figma-api-key=" a))
+ args)))
+ (should api-arg)
+ (should (equal api-arg (format "--figma-api-key=%s"
+ test-ai-mcp--sentinel)))))))
+
+(ert-deftest test-ai-mcp-build-server-alist-no-mutation-boundary ()
+ "Boundary: building the alist does not mutate `cj/mcp-server-specs'."
+ (test-ai-mcp--with-fixture path
+ (let* ((cj/mcp-claude-config path)
+ (snapshot (copy-tree cj/mcp-server-specs)))
+ (cj/mcp--build-server-alist)
+ (should (equal cj/mcp-server-specs snapshot)))))
+
+(provide 'test-ai-mcp-helpers)
+;;; test-ai-mcp-helpers.el ends here