AttestoMCP

Hex.pmHexdocs.pmElixir CILicense: MITElixir

Plug/Phoenix helpers for protecting HTTP-based Model Context Protocol servers with attesto.

Where it fits

attesto_mcp is a narrow integration layer. It does not implement MCP, JSON-RPC, tools, prompts, resources, transports, or server lifecycle. It wraps the HTTP endpoint that an MCP server implementation exposes and connects that endpoint to Attesto's OAuth/OIDC token verification, DPoP proof verification, mTLS certificate binding, scope algebra, and metadata builders.

Use it when your MCP server is a Plug or Phoenix endpoint and you want:

Relationship to attesto and attesto_phoenix

attesto is the protocol engine: JWT access tokens, DPoP, mTLS, PKCE, JWKS, discovery, and scopes. attesto_mcp reuses those checks and adds MCP-facing Plug ergonomics.

attesto_phoenix is the Phoenix/Ecto authorization-server layer: routes, controllers, registration, stores, and Phoenix-friendly configuration. MCP servers that need dynamic client registration should expose it through the authorization server layer rather than duplicate RFC 7591 here.

MCP authorization

The MCP authorization spec treats a protected HTTP MCP server as an OAuth resource server. Clients discover authorization information through OAuth Protected Resource Metadata (RFC 9728), then use Authorization Server Metadata (RFC 8414) for issuer endpoints.

This package provides builders for:

It intentionally avoids a hard dependency on a specific Elixir MCP SDK. Existing packages have different license and maintenance profiles, and the auth boundary is a normal Plug boundary.

Installation

The package is prepared for Hex but has not been published yet.

def deps do
  [
    {:attesto_mcp, "~> 0.1.0"}
  ]
end

Minimal Plug/Phoenix usage

Protect the mounted MCP endpoint before forwarding to whichever MCP server plug you use:

pipeline :mcp_auth do
  plug AttestoMCP.Plug.Authenticate,
    config: &MyApp.Attesto.config/0,
    htu: fn _conn -> "https://mcp.example.com/mcp" end,
    replay_check: &MyApp.DPoPReplay.check_and_record/2,
    resource_path: "/mcp",
    principal: fn claims, sender ->
      MyApp.Principals.from_token(claims, sender)
    end

  plug AttestoMCP.Plug.RequireScopes,
    scopes: [AttestoMCP.Scopes.tools_call()]
end

scope "/" do
  pipe_through [:mcp_auth]
  forward "/mcp", to: MyApp.MCPServerPlug
end

After authentication, downstream code can read:

For mTLS-bound access tokens, supply certificate context from your TLS layer:

plug AttestoMCP.Plug.Authenticate,
  config: &MyApp.Attesto.config/0,
  cert_der: fn conn ->
    MyApp.TLS.client_certificate_der(conn)
  end

The callback must return the DER-encoded certificate that the TLS layer already authenticated, or nil when no certificate was presented.

Metadata

Serve protected-resource metadata from the well-known location derived from your MCP resource identifier:

metadata =
  AttestoMCP.Metadata.protected_resource(conn, "/mcp",
    authorization_servers: ["https://auth.example.com"],
    resource_name: "Example MCP server",
    scopes_supported: AttestoMCP.Scopes.all(),
    tls_client_certificate_bound_access_tokens: true
  )

Authorization-server metadata belongs at the issuer:

AttestoMCP.Metadata.authorization_server(config,
  authorization_endpoint: "https://auth.example.com/oauth/authorize",
  token_endpoint_auth_methods_supported: ["client_secret_basic", "private_key_jwt"],
  registration_endpoint: "https://auth.example.com/oauth/register"
)

Dynamic client registration should be exposed by the authorization server. When using attesto_phoenix, enable its registration route and callbacks there. Only advertise registration response fields such as client_secret_expires_at, registration_access_token, and registration_client_uri if the authorization server implementation returns and persists them correctly.

Scope conventions

The package ships common MCP-style scope strings as conventions:

Server-specific prefixes are available:

AttestoMCP.Scopes.server("search", :tools_call)
# "search:mcp:tools:call"

These helpers are not policy. The authorization server decides what to issue and each MCP route decides what to require.

DPoP nonce and replay

DPoP proof replay protection is required for protected-resource requests. Pass a shared :replay_check callback, such as an ETS store for a single node or a database-backed store for clustered deployments. Without that callback, DPoP requests fail closed through Attesto unless you explicitly acknowledge the risk with Attesto's lower-level option.

If the server requires DPoP nonces, also pass :nonce_check and :nonce_issue. Nonce failures produce use_dpop_nonce with a fresh DPoP-Nonce header so the client can retry.

Security notes

Development

mix deps.get
mix format --check-formatted
mix credo --strict
mix test
mix docs

License

MIT. See LICENSE.