Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 81 additions & 25 deletions ai-code-mcp-http-server.el
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ When nil, an available port is selected automatically."
integer)
:group 'ai-code-mcp-http-server)

(defcustom ai-code-mcp-http-server-max-body-bytes (* 1024 1024)
"Maximum accepted MCP HTTP request body size in bytes."
:type 'natnum
:group 'ai-code-mcp-http-server)

(define-error 'ai-code-mcp-http-server-request-too-large
"MCP HTTP request body is too large")

(defvar ai-code-mcp-http-server--server nil
"Server process for the local MCP HTTP transport.")

Expand Down Expand Up @@ -80,12 +88,17 @@ When nil, an available port is selected automatically."

(defun ai-code-mcp-http-server--filter (process chunk)
"Accumulate CHUNK for PROCESS and handle a full request."
(let* ((data (concat (or (process-get process :data) "") chunk))
(request (ai-code-mcp-http-server--parse-request data)))
(process-put process :data data)
(when request
(process-put process :data nil)
(ai-code-mcp-http-server--handle-request process request))))
(let ((data (concat (or (process-get process :data) "") chunk)))
(condition-case err
(let ((request (ai-code-mcp-http-server--parse-request data)))
(process-put process :data data)
(when request
(process-put process :data nil)
(ai-code-mcp-http-server--handle-request process request)))
(ai-code-mcp-http-server-request-too-large
(process-put process :data nil)
(ai-code-mcp-http-server--send-response
process 413 "text/plain" (error-message-string err))))))

(defun ai-code-mcp-http-server--parse-request (data)
"Parse DATA when it includes a full HTTP request."
Expand All @@ -100,6 +113,11 @@ When nil, an available port is selected automatically."
(content-length (string-to-number
(or (cdr (assoc "content-length" headers))
"0"))))
(when (or (< content-length 0)
(> content-length ai-code-mcp-http-server-max-body-bytes))
(signal 'ai-code-mcp-http-server-request-too-large
(list (format "Request body exceeds %d bytes"
ai-code-mcp-http-server-max-body-bytes))))
(when (<= (+ body-start content-length) (string-bytes data))
(pcase-let ((`(,method ,path)
(ai-code-mcp-http-server--parse-request-line request-line)))
Expand Down Expand Up @@ -136,28 +154,61 @@ When nil, an available port is selected automatically."
-32603
(format "Internal error: %s" (error-message-string err))))))

(defun ai-code-mcp-http-server--json-content-type-p (request)
"Return non-nil when REQUEST declares JSON content."
(when-let* ((value (cdr (assoc "content-type" (plist-get request :headers)))))
(string-match-p "\\`application/json\\(?:[ \t]*;\\|\\'\\)" (downcase value))))

(defun ai-code-mcp-http-server--valid-json-rpc-envelope-p (json-object)
"Return non-nil when JSON-OBJECT is a valid JSON-RPC request envelope."
(and (equal (alist-get 'jsonrpc json-object) "2.0")
(stringp (alist-get 'method json-object))))

(defun ai-code-mcp-http-server--handle-post (process request)
"Handle POST REQUEST on PROCESS."
(let ((response
(ai-code-mcp-http-server--json-rpc-response
(plist-get request :path)
(plist-get request :body))))
(if (null response)
(ai-code-mcp-http-server--send-accepted process)
(ai-code-mcp-http-server--send-json
process
200
response))))

(defun ai-code-mcp-http-server--json-rpc-response (path body)
(let* ((path (plist-get request :path))
(body (or (plist-get request :body) ""))
(session-id (ai-code-mcp-http-server--session-id-from-path path)))
(cond
((not session-id)
(ai-code-mcp-http-server--send-response process 404 "text/plain" "Not Found"))
((not (ai-code-mcp-get-session-context session-id))
(ai-code-mcp-http-server--send-response process 404 "text/plain" "Unknown MCP session"))
((not (ai-code-mcp-http-server--json-content-type-p request))
(ai-code-mcp-http-server--send-response
process 415 "text/plain" "Content-Type must be application/json"))
((> (string-bytes body) ai-code-mcp-http-server-max-body-bytes)
(ai-code-mcp-http-server--send-response process 413 "text/plain" "Request body too large"))
(t
(condition-case err
(let* ((json-object (json-parse-string body :object-type 'alist))
(id (alist-get 'id json-object)))
(if (not (ai-code-mcp-http-server--valid-json-rpc-envelope-p json-object))
(ai-code-mcp-http-server--send-json-error
process id -32600 "Invalid JSON-RPC request" 400)
(let ((response
(ai-code-mcp-http-server--json-rpc-response
path body json-object session-id)))
(if (null response)
(ai-code-mcp-http-server--send-accepted process)
(ai-code-mcp-http-server--send-json process 200 response)))))
(json-parse-error
(ai-code-mcp-http-server--send-json-error
process nil -32700
(format "Parse error: %s" (error-message-string err))
400)))))))

(defun ai-code-mcp-http-server--json-rpc-response (path body &optional json-object session-id)
"Return a JSON-RPC response alist for PATH and BODY.
JSON-OBJECT and SESSION-ID may be supplied when REQUEST was already validated.
Returns nil for notifications."
(let* ((json-object (json-parse-string body :object-type 'alist))
(let* ((json-object (or json-object
(json-parse-string body :object-type 'alist)))
(id (alist-get 'id json-object))
(method (alist-get 'method json-object))
(params (alist-get 'params json-object))
(ai-code-mcp--current-session-id
(ai-code-mcp-http-server--session-id-from-path path)))
(or session-id (ai-code-mcp-http-server--session-id-from-path path))))
(when id
`((jsonrpc . "2.0")
(id . ,id)
Expand All @@ -172,15 +223,17 @@ Returns nil for notifications."
(error nil))))

(defun ai-code-mcp-http-server--session-id-from-path (path)
"Extract session ID from PATH."
(when (string-match "\\`/mcp/\\([^/?]+\\)" path)
"Extract session ID from exact MCP PATH."
(when (and (stringp path)
(string-match "\\`/mcp/\\([^/?]+\\)\\'" path))
(match-string 1 path)))

(defun ai-code-mcp-http-server--send-json-error (process id code message)
"Send a JSON-RPC error response with ID, CODE, and MESSAGE on PROCESS."
(defun ai-code-mcp-http-server--send-json-error (process id code message &optional http-code)
"Send a JSON-RPC error response with ID, CODE, and MESSAGE on PROCESS.
HTTP-CODE defaults to 500."
(ai-code-mcp-http-server--send-json
process
500
(or http-code 500)
`((jsonrpc . "2.0")
(id . ,id)
(error . ((code . ,code)
Expand Down Expand Up @@ -219,7 +272,10 @@ Returns nil for notifications."
"Return the HTTP reason phrase for CODE."
(alist-get code '((200 . "OK")
(202 . "Accepted")
(400 . "Bad Request")
(404 . "Not Found")
(413 . "Payload Too Large")
(415 . "Unsupported Media Type")
(500 . "Internal Server Error"))
"OK"))

Expand Down
97 changes: 97 additions & 0 deletions test/test_ai-code-mcp-http-hardening.el
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
;;; test_ai-code-mcp-http-hardening.el --- MCP HTTP validation tests -*- lexical-binding: t; -*-

;; Author: Kang Tu <tninja@gmail.com>
;; SPDX-License-Identifier: Apache-2.0

;;; Commentary:
;; Focused tests for local MCP HTTP request validation.

;;; Code:

(require 'ert)
(require 'cl-lib)
(require 'ai-code-mcp-http-server)

(defun ai-code-test-mcp-http--request (path body &optional content-type)
"Build a POST request for PATH and BODY with CONTENT-TYPE."
(list :method "POST"
:path path
:headers `(("content-type" . ,(or content-type "application/json")))
:body body))

(ert-deftest ai-code-test-mcp-http-hardening-rejects-non-session-path ()
"POST endpoints outside an exact MCP session path should be rejected."
(let (response dispatched)
(cl-letf (((symbol-function 'ai-code-mcp-dispatch)
(lambda (&rest _args) (setq dispatched t)))
((symbol-function 'ai-code-mcp-http-server--send-response)
(lambda (_process code content-type body)
(setq response (list code content-type body)))))
(ai-code-mcp-http-server--handle-post
'process
(ai-code-test-mcp-http--request
"/other/session" "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}")))
(should (equal (car response) 404))
(should-not dispatched)))

(ert-deftest ai-code-test-mcp-http-hardening-rejects-unregistered-session ()
"An exact MCP path should still require a registered session."
(let (response)
(cl-letf (((symbol-function 'ai-code-mcp-get-session-context)
(lambda (_session-id) nil))
((symbol-function 'ai-code-mcp-http-server--send-response)
(lambda (_process code content-type body)
(setq response (list code content-type body)))))
(ai-code-mcp-http-server--handle-post
'process
(ai-code-test-mcp-http--request
"/mcp/missing" "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}")))
(should (equal (car response) 404))))

(ert-deftest ai-code-test-mcp-http-hardening-requires-json-content-type ()
"MCP POST requests should require JSON content."
(let (response)
(cl-letf (((symbol-function 'ai-code-mcp-get-session-context)
(lambda (_session-id) '(:project-dir "/tmp")))
((symbol-function 'ai-code-mcp-http-server--send-response)
(lambda (_process code content-type body)
(setq response (list code content-type body)))))
(ai-code-mcp-http-server--handle-post
'process
(ai-code-test-mcp-http--request
"/mcp/live" "{}" "text/plain")))
(should (equal (car response) 415))))

(ert-deftest ai-code-test-mcp-http-hardening-rejects-invalid-jsonrpc-envelope ()
"Requests should declare JSON-RPC 2.0 and a string method."
(let (response)
(cl-letf (((symbol-function 'ai-code-mcp-get-session-context)
(lambda (_session-id) '(:project-dir "/tmp")))
((symbol-function 'ai-code-mcp-http-server--send-json-error)
(lambda (_process id code message &optional http-code)
(setq response (list id code message http-code)))))
(ai-code-mcp-http-server--handle-post
'process
(ai-code-test-mcp-http--request
"/mcp/live" "{\"jsonrpc\":\"1.0\",\"id\":7,\"method\":42}")))
(should (equal (nth 0 response) 7))
(should (equal (nth 1 response) -32600))
(should (equal (nth 3 response) 400))))

(ert-deftest ai-code-test-mcp-http-hardening-rejects-oversized-body ()
"The local HTTP transport should cap request body size."
(let ((ai-code-mcp-http-server-max-body-bytes 4)
response)
(cl-letf (((symbol-function 'ai-code-mcp-get-session-context)
(lambda (_session-id) '(:project-dir "/tmp")))
((symbol-function 'ai-code-mcp-http-server--send-response)
(lambda (_process code content-type body)
(setq response (list code content-type body)))))
(ai-code-mcp-http-server--handle-post
'process
(ai-code-test-mcp-http--request "/mcp/live" "12345")))
(should (equal (car response) 413))))

(provide 'test_ai-code-mcp-http-hardening)

;;; test_ai-code-mcp-http-hardening.el ends here
10 changes: 8 additions & 2 deletions test/test_ai-code-mcp-http-server.el
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@
(ert-deftest ai-code-test-mcp-http-server-notification-returns-accepted ()
"Notification requests should return HTTP 202 with an empty body."
(let ((captured-response nil))
(cl-letf (((symbol-function 'ai-code-mcp-http-server--send-response)
(cl-letf (((symbol-function 'ai-code-mcp-get-session-context)
(lambda (_session-id) '(:project-dir "/tmp")))
((symbol-function 'ai-code-mcp-http-server--send-response)
(lambda (_process code content-type body)
(setq captured-response
(list :code code
Expand All @@ -64,6 +66,7 @@
(ai-code-mcp-http-server--handle-post
nil
(list :path "/mcp/session-http"
:headers '(("content-type" . "application/json"))
:body (json-encode
'((jsonrpc . "2.0")
(method . "notifications/initialized")
Expand All @@ -75,14 +78,17 @@
"JSON-RPC errors should preserve the originating request id."
(let ((captured-payload nil)
(captured-code nil))
(cl-letf (((symbol-function 'ai-code-mcp-http-server--send-json)
(cl-letf (((symbol-function 'ai-code-mcp-get-session-context)
(lambda (_session-id) '(:project-dir "/tmp")))
((symbol-function 'ai-code-mcp-http-server--send-json)
(lambda (_process code payload)
(setq captured-code code)
(setq captured-payload payload))))
(ai-code-mcp-http-server--handle-request
nil
(list :method "POST"
:path "/mcp/session-http"
:headers '(("content-type" . "application/json"))
:body (json-encode
'((jsonrpc . "2.0")
(id . 17)
Expand Down
Loading