Skip to content

Add localhost-only MCP admin HTTP endpoint backed by existing CA admin operations#1

Merged
etnt merged 6 commits intomainfrom
copilot/create-mcp-server-tcp-endpoint
Apr 16, 2026
Merged

Add localhost-only MCP admin HTTP endpoint backed by existing CA admin operations#1
etnt merged 6 commits intomainfrom
copilot/create-mcp-server-tcp-endpoint

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 16, 2026

This PR introduces a new MCP-facing REST-like admin surface equivalent to the existing admin API behavior, and exposes it via a dedicated Cowboy TCP listener bound to localhost only.

  • New MCP admin API surface

    • Added cryptic_mcp_admin_handler to provide REST-like endpoints for admin operations already supported in server logic:
      • list_users
      • register_user
      • suspend_user
      • revoke_user
      • reactivate_user
      • get_user_info
      • list_certificates
      • revoke_certificate
    • Endpoint paths are namespaced under /mcp/v1/admin/....
    • Uses JSON request/response contracts and required-field validation for write operations.
  • Dedicated localhost TCP endpoint in Cowboy

    • Added a separate Cowboy cleartext listener (cryptic_mcp_listener) in cryptic_server.
    • Listener binds to 127.0.0.1 and a configurable port (mcp_tcp_port), with explicit lifecycle wiring (start + stop).
    • Keeps MCP admin traffic isolated from the existing mTLS WebSocket/CA public routes.
  • Configuration and defaults

    • Added mcp_tcp_enabled and mcp_tcp_port to app/sys config.
    • MCP listener is disabled by default unless explicitly enabled.
  • Admin/authn/authz and audit behavior

    • Enforces admin-only access using existing bootstrap-admin semantics (registered_by = undefined).
    • Accepts admin fingerprint from header (x-admin-gpg-fp) or JSON body.
    • Captures peer IP in audit logs for admin state transitions.
    • Aligns operation timestamps and audit timestamps on mutation paths.
  • Security/error handling hardening

    • Sanitizes API error responses (internal_server_error / operation_failed) instead of exposing internal terms.
    • Removes query-string fallback for admin fingerprint to avoid credential leakage in URLs.
{ok, _} = cowboy:start_clear(
    cryptic_mcp_listener,
    [
        %% Security boundary: bind admin MCP endpoint to localhost only.
        {ip, {127, 0, 0, 1}},
        {port, Port}
    ],
    #{env => #{dispatch => Dispatch}}
).

@etnt etnt marked this pull request as ready for review April 16, 2026 16:44
Copilot AI review requested due to automatic review settings April 16, 2026 16:44
@etnt etnt merged commit b9fb260 into main Apr 16, 2026
2 of 5 checks passed
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new localhost-only Cowboy HTTP listener that exposes an MCP-facing admin API under /mcp/v1/admin/..., backed by existing CA admin operations in cryptic_ca_store.

Changes:

  • Added a dedicated Cowboy cleartext listener (cryptic_mcp_listener) bound to 127.0.0.1 and controlled via mcp_tcp_enabled / mcp_tcp_port.
  • Introduced cryptic_mcp_admin_handler implementing REST-like JSON endpoints for admin operations (users + certificates).
  • Added default configuration entries for the MCP listener in cryptic.app.src and config/sys.config.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/cryptic_server.erl Starts/stops the new cryptic_mcp_listener and wires routes to the MCP admin handler.
src/cryptic_mcp_admin_handler.erl Implements the MCP admin HTTP API surface and maps requests to CA store operations.
src/cryptic.app.src Adds default app env settings for the MCP TCP listener (disabled by default).
config/sys.config Adds system config defaults for the MCP TCP listener (disabled by default).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +104 to +107
GpgFp = maps:get(<<"gpg_fp">>, BodyMap),
GpgPub = maps:get(<<"gpg_pub">>, BodyMap),
Metadata = maps:get(<<"metadata">>, BodyMap, null),
case cryptic_ca_store:register_user(DbRef, GpgFp, GpgPub, AdminFp, Metadata) of
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

register_user passes metadata from the decoded JSON body straight into cryptic_ca_store:register_user/5. With jsx:decode(..., [return_maps]), metadata will be an Erlang map when the client sends a JSON object, but the store API expects JSON-encoded binary (or undefined). This will likely make the DB insert fail (esqlite bind can’t store maps) and also diverges from the existing CA WebSocket admin implementation which encodes metadata before storing it. Consider normalizing metadata here (undefined when missing/null; jsx:encode/1 when it’s a map; accept binary as-is).

Copilot uses AI. Check for mistakes.
Comment on lines +225 to +239
handle_post_operation(<<"revoke_certificate">>, BodyMap, AdminFp, DbRef, Req) ->
with_required_fields(
BodyMap,
[<<"serial">>, <<"reason">>],
fun() ->
Serial = maps:get(<<"serial">>, BodyMap),
Reason = maps:get(<<"reason">>, BodyMap),
case cryptic_ca_store:revoke_certificate(DbRef, Serial, AdminFp, Reason) of
ok ->
{200, #{
type => <<"revoke_certificate_response">>,
status => <<"success">>,
serial => Serial,
reason => Reason
}, Req};
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revoke_certificate relies on cryptic_ca_store:revoke_certificate/4, which writes its own audit log entry with ip_address = undefined. Since this handler otherwise captures peer_ip/1 on mutation paths, certificate revocations currently won’t include the caller IP in the audit trail. If the goal is to have peer IP for admin state transitions, consider either extending the store API to accept ip_address (and store it) or inserting a separate audit entry from the handler after a successful revocation.

Copilot uses AI. Check for mistakes.
Comment on lines +313 to +334
get_admin_fingerprint(Req, BodyMap) ->
%% Priority order: explicit header, then JSON body.
case cowboy_req:header(<<"x-admin-gpg-fp">>, Req) of
undefined ->
case maps:get(<<"admin_gpg_fp">>, BodyMap, undefined) of
undefined ->
undefined;
Fp ->
Fp
end;
Fp ->
Fp
end.

is_admin(undefined, _DbRef) ->
false;
is_admin(GpgFp, DbRef) ->
%% Bootstrap/root admins are identities with no registered_by owner.
case cryptic_ca_store:get_gpg_identity(DbRef, GpgFp) of
{ok, #gpg_identity{registered_by = undefined}} -> true;
_ -> false
end.
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admin authorization is based solely on a caller-supplied fingerprint (x-admin-gpg-fp header or JSON body) plus a DB lookup. Because there’s no cryptographic proof (mTLS, HMAC’d token, etc.), any local process that can reach 127.0.0.1 can impersonate an admin by sending a known bootstrap admin fingerprint. Consider adding an actual authentication factor for this listener (e.g., require a configured shared-secret/Bearer token, use a Unix domain socket with filesystem permissions, or enable TLS client cert auth even on localhost) and validating the fingerprint format (length/hex) before DB lookup.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants