Skip to content

Add OAuth support with client credentials and refresh token grant types#683

Open
Noravee wants to merge 64 commits intomainfrom
mcp-oauth
Open

Add OAuth support with client credentials and refresh token grant types#683
Noravee wants to merge 64 commits intomainfrom
mcp-oauth

Conversation

@Noravee
Copy link
Collaborator

@Noravee Noravee commented Jan 28, 2026

MCP OAuth Authorization Implementation

Overview

Neuro-san supports machine-to-machine authentication for MCP servers that require credentials before granting access to tools.
This guide explains the available authentication methods and how to configure them.


Authentication Methods

Neuro-san supports three authentication methods, applied in the following priority order:

  1. Headers (highest priority)

    • Typically uses the Authorization field with Bearer <token_value>
    • Required fields depend on the authentication scheme expected by the MCP server
  2. Refresh Token (fallback if headers unavailable or invalid)

    • Exchanges client ID and refresh token for an access token
    • Used when both client credentials and refresh token are available
  3. Client Credentials (lowest priority)

    • Exchanges client ID and/or client secret for an access token
    • Used when only client information is provided

Configuration Methods

Authentication data can be provided in two ways: through sly_data or via an environment variable configuration file.

Method 1: Using sly_data

Pass authentication credentials directly in the sly_data object.
You can specify different credentials for different MCP URLs.

Available Fields:

  • http_headers - HTTP headers for authentication
  • mcp_client_info - Client credentials for token exchange
  • mcp_tokens - Token information (only available via sly_data)

Example:

{
    "http_headers": {
        "<MCP_URL_1>": {
            "Authorization": "Bearer <token_value>"
        }
    },
    "mcp_client_info": {
        "<MCP_URL_2>": {
            "client_id": "<client_id>",
            "client_secret": "<client_secret>",  # Optional if token_endpoint_auth_method is None
            "token_endpoint_auth_method": "client_secret_post",  # Optional, default: "client_secret_basic"
            "scope": "<scope>"  # Optional, default: None
        }
    },
    "mcp_tokens": {
        "<MCP_URL_3>": {
            "access_token": "<access_token>",
            "refresh_token": "<refresh_token>"
        }
    }
}

Method 2: Using Configuration File

Set the AGENT_MCP_INFO_FILE environment variable to point to a HOCON configuration file.

Important Notes:

  • MCP_SERVERS_INFO_FILE is deprecated and will be removed in version 0.7.0
  • Server info (e.g., token endpoints) can only be configured via environment variable
  • If not provided, server info from discovery will be used
  • Server URLs must match those defined in the agent network HOCON file
  • We strongly recommend not storing secrets directly in any source file.
    Source files can easily be committed to version control, and checking in secrets is a serious security risk.
    If these configuration files need to be committed, use HOCON substitution (e.g., environment variable references)
    instead of hardcoding secret values.

Example Configuration:

{
    "mcp_server_url_1": {
        "http_headers": {
            "Authorization": "Bearer <token>"
        },
        "mcp_client_info": {
            "client_id": "<client_id>",
            "client_secret": "<client_secret>",
            "token_endpoint_auth_method": "client_secret_post",
            "scope": "<scope>"
        },
        "mcp_server_info": {
            "token_endpoint": "https://example.com/token"  # Optional, default: discovery endpoint or base_url/token
        },
        "auth_timeout": 300.0,  # Optional timeout in seconds, default: 300.0
        "tools": ["tool_1", "tool_2"]  # Optional tool filtering
    }
}

Alternative Environment Variables:

  • AGENT_MCP_TIMEOUT_SECONDS - Can be used instead of the auth_timeout field

Configuration Precedence Rules

When authentication data exists in multiple locations, the following precedence applies:

  1. sly_data takes precedence over configuration file for headers and client info on the same server
  2. Configuration file tool filtering is used only if no tool filtering exists in the agent network HOCON file

Field Reference

http_headers

Field Description Required
Authorization Authentication header, typically Bearer <token> Depends on server

mcp_client_info

Field Description Required Default
client_id OAuth client identifier Yes -
client_secret OAuth client secret Conditional* -
token_endpoint_auth_method Token exchange method No client_secret_basic
scope Requested OAuth scopes No None

*Required unless token_endpoint_auth_method is None

mcp_tokens

Field Description Required
access_token Current access token Yes
refresh_token Token for refreshing access No

mcp_server_info

Field Description Required Default
token_endpoint Custom token endpoint URL No Discovery endpoint or base_url/token

UPDATE Mar 4, 2026

  • Add more detailed to mcp_authentication.md
  • Add mcp_info_hocon_reference.md

UPDATE Feb 27, 2026

Convert draft to PR

  • Consolidate MCP configuration:

    • Remove AGENT_MCP_CLIENTS_INFO and AGENT_MCP_TOKENS.
    • Use a single config file referenced by the AGENT_MCP_INFO_FILE environment variable.
    • This file now contains headers, client info, and server info.
    • The previous MCP_SERVERS_INFO_FILE is deprecated and will be removed in version 0.7.0.
    • Note: Tokens should not be stored in this file; they must be provided via sly_data.
  • Remove ExtendedOauthClientProvider:

    • This class previously extended the SDK base class to handle non-JSON token responses.
    • Since this behavior is non-standard, the class has been removed.
  • Update token endpoint resolution:

    • Explicitly provided token endpoints now take priority over discovery or default endpoints.
  • Add unit tests for new classes:

    • SlyDataTokenStorage
    • ClientCredentialsOauthProvider
    • RefreshTokenOauthProvider
    • OauthProviderFactory
  • Add documentation:

    • Introduce mcp_authentication.md to document the new authentication flow and configuration structure.

UPDATE Feb 20,2026

Rework OAuth Implementation to Machine-to-Machine Authentication

Based on feedback from @d1donlydfink and @andreidenissov-cog, refactors the OAuth implementation to focus exclusively on machine-to-machine (M2M) authentication flows.

Changes

Removed

  • AuthorizationCodeOauthProvider - no longer needed for M2M flows
  • User authorization callback handler - not applicable for M2M authentication
  • File-based token storage implementation

Added

  • RefreshTokenOauthProvider - handles token refresh for M2M flows
  • Sly data-based token storage
  • Environment variable support for client credentials and tokens - enables easier configuration

Authorization Flow Priority

  • Use refresh token flow if there is one otherwise use client credentials flow
  • Do not authenticate if there is no client info.

Tests

  • Tests have been done with a custom MCP server.

Note

  • Documentation and tests will be added once the implementation is agreed upon.

Jan 28, 2026

Authorization Flow Priority

The implementation supports multiple OAuth flows with the following priority order:

  1. Client Credentials Flow (Machine-to-Machine)

    • Client sends HTTP POST to token endpoint with client_id and client_secret
    • Receives access token directly
    • No user interaction required
    • Ideal for server-to-server communication
  2. Dynamic Client Registration (Fallback)

    • No previous connection required between client and server
    • Server provides registration endpoint via OAuth discovery
    • Client registers dynamically to obtain credentials
    • Falls back to this when no pre-configured credentials exist

Architecture Overview

The OAuth flow is primarily handled by the MCP SDK with custom extensions to support additional use cases.

Class Hierarchy

Base Classes

  • OAuthClientProvider (from MCP SDK)

    • Base OAuth provider from the SDK
    • Handles dynamic client registration
    • Supports Client ID Metadata Documents (CIMD)
    • Implements OAuth discovery protocols
  • ExtendedOAuthClientProvider (extends OAuthClientProvider)

    • Our base OAuth class for all providers
    • Extends SDK provider to handle both JSON and form-encoded token responses
    • Provides fallback parsing when servers don't return standard JSON responses
    • Maintains compatibility with all SDK features

Specialized Providers

  • ClientCredentialsOAuthProvider (extends ExtendedOAuthClientProvider)

    • Implements client_credentials grant flow
    • Allows manual configuration of token endpoint (bypasses discovery when needed)
    • Based on SDK's implementation but uses our extended base class
    • Ideal for machine-to-machine authentication
  • AuthorizationCodeOAuthProvider (extends ExtendedOAuthClientProvider)

    • Implements authorization_code grant flow
    • Allows manual configuration of authorization and token endpoints
    • Supports both OAuth discovery and manual endpoint configuration
    • Handles user authentication via browser and local callback server

Storage and Factory

  • FileTokenStorage (extends TokenStorage from SDK)

    • Persists OAuth tokens and client credentials to JSON files
    • Supports both dynamic credentials (from registration) and hardcoded credentials
    • Provides methods to get/set tokens and client information
    • Platform-agnostic storage paths
  • OAuthProviderFactory

    • Factory pattern for generating appropriate OAuth provider
    • Automatically selects provider based on available credentials and configuration
    • Decision logic:
      1. If client_id/secret exist, grant type is client_credentialsClientCredentialsOAuthProvider
      2. If client_id/secret, redirect uri exist → AuthorizationCodeOAuthProvider
      3. Otherwise → ExtendedOAuthClientProvider (dynamic registration)
    • Manages OAuth callback server lifecycle
    • Handles cleanup of temporary resources

Tested MCP servers

@Noravee Noravee marked this pull request as draft January 28, 2026 19:23
@Noravee Noravee marked this pull request as ready for review January 28, 2026 19:33
@Noravee Noravee marked this pull request as draft January 28, 2026 19:34
Extended OAuthClientProvider that handles both JSON and form-encoded token responses.
Tries JSON first (standard), then falls back to form-encoded format.

See https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/client/auth/oauth2.py#L397.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Extend the base class from the SDK.

Copy link
Collaborator

@d1donlydfink d1donlydfink Feb 2, 2026

Choose a reason for hiding this comment

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

So all the methods in this class are private methods with an underscore in front of them.
If we are overriding private methods in this implementation we should have a lot more comments surrounding what the entry points are that we are overriding and why we need to hack things in this particular manner.

This level of private method extension is highly likely to be snatched away from us without a moment's notice and we would have to be prepared for dealing with the scrambles that ensue.

That all said, if this really is the only way to do what you need, perhaps it's worth petitioning the MCP people to make this part of the API properly extensible so it has lasting support. Someone somewhere used that underscore prefix for a reason.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Great point about the private methods - you're absolutely right that this is fragile and could break without warning. Let me explain the situation and our options:

I wanted to use the SDK's OAuth providers directly, but they're designed primarily for user authorization flows (Authorization Code grant) rather than pure machine-to-machine scenarios. The modifications needed to make them work for M2M are:

  1. Token response format handling - Supporting both JSON and form-encoded responses
  2. User-provided endpoints - The SDK only uses discovered endpoints, doesn't allow manual override
  3. client_secret_post bug - Token exchange fails with this auth method in the SDK
  4. Refresh token flow - Assumes Authorization Code context, not suitable for M2M

The SDK's architecture forces us to override private methods (_initialize(), _perform_authorization()) because:

  • These are the only extension points for customizing the auth flow
  • There are no public/protected hooks for injecting custom logic
  • The class hierarchy isn't designed for the level of customization we need

I agree this isn't sustainable long-term. Here's what I propose:

Option 1: Upstream Fix

  1. Open an issue in the MCP SDK repo explaining our M2M use case
  2. Submit a PR with:
    • Proper extension points (protected methods or hooks)
    • M2M-specific improvements
    • Better separation between user auth and M2M flows

Pros: Benefits the entire community, sustainable long-term
Cons: Unknown timeline for acceptance, may require significant rework of the SDK's OAuth module (the code there is messy and probably needs refactoring anyway)

Option 2: Custom Implementation

Build our own OAuth client that:

  • Implements the exact flow we need
  • Doesn't rely on SDK internals
  • Gives us full control

Pros: Complete control, no dependency on SDK changes
Cons: We own the maintenance burden, have to keep up with OAuth spec changes

Option 3: Hybrid Approach

  1. Immediately: Open SDK issue + PR to make this properly extensible
  2. Short-term: Keep current implementation with extensive documentation
  3. Medium-term: Switch to SDK if accepted, or migrate to custom implementation if not

Regardless of direction, I have added comprehensive comments to our current implementation:

Does this approach work for you? Please let me know what you think.

Note:

The OAuth code in the SDK feels like it needs a broader refactor anyway - the current architecture mixes concerns between grant types and doesn't have clean extension points.

self.context.update_token_expiry(token_response)
await self.context.storage.set_tokens(token_response)

async def _parse_form_token_response(self, response: httpx.Response) -> OAuthToken:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Add a method to parse text token response.

if os.name == "nt":
base_path = os.path.join(os.getenv("APPDATA", os.path.expanduser("~")), "mcp-auth")
else:
base_path = os.path.join(os.path.expanduser("~"), ".mcp-auth")
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Default to .mcp-auth at home directory.

parsed: ParseResult = urlparse(mcp_server_url)
if not parsed.hostname:
raise ValueError(f"Invalid MCP server URL: {mcp_server_url}")
mcp_server_host: str = parsed.hostname
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Use hostname of the MCP server for the subfolder under the main directory.

self.mcp_info_path: PosixPath = Path(base_path).expanduser() / mcp_server_host
self.mcp_info_path.mkdir(parents=True, exist_ok=True)
self.tokens_file: PosixPath = self.mcp_info_path / "tokens.json"
self.client_info_file: PosixPath = self.mcp_info_path / "client_info.json"
Copy link
Collaborator Author

@Noravee Noravee Jan 28, 2026

Choose a reason for hiding this comment

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

Client credentials will be in client_info.json and token in tokens.json. Users can manually put client infos (id, secret, redirect_uri, grant type) here.

html_content = """
<html><body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>Authentication Successful!</h1>
<p>You can close this window and return to your application.</p>
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

HTML page to redirect user to after authentication.

self.timeout = timeout
self.callback_handler: Optional[OauthCallbackHandler] = None

async def get_auth(self) -> OAuthClientProvider:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Create auth based on client info.
client credentials -> authorization code -> dynamic client registration

self._load_mcp_servers_info()
return self._mcp_servers_info.get(server_url, {})

def _prepare_headers(
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Refactor LangchainMcpAdapter.


return None

def _create_oauth_provider(self, server_url: str) -> OauthProviderFactory:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Add method to create OAuth provider.


# Get OAuth endpoints from server config (optional - will be discovered if not provided)
auth_endpoint = server_info.get("authorization_endpoint")
token_endpoint = server_info.get("token_endpoint")
Copy link
Collaborator Author

@Noravee Noravee Jan 28, 2026

Choose a reason for hiding this comment

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

Authorization and token endpoints can be taken from MCP_SERVERS_INFO_FILE.


# Get callback port from environment or use default
callback_port_env = os.environ.get("AGENT_MCP_CALLBACK_PORT")
callback_port = int(callback_port_env) if callback_port_env else 3000
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The callback port can be set by AGENT_MCP_CALLBACK_PORT. Default to 3000.

if not file_path:
file_path = os.environ.get("MCP_SERVERS_INFO_FILE")
# "MCP_SERVERS_INFO_FILE" will be deprecated in neuro-san==0.7.
file_path = os.environ.get("AGENT_MCP_SERVERS_INFO_FILE") or os.environ.get("MCP_SERVERS_INFO_FILE")
Copy link
Collaborator Author

@Noravee Noravee Jan 28, 2026

Choose a reason for hiding this comment

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

Add AGENT_MCP_SERVERS_INFO_FILE for consistency. MCP_SERVERS_INFO_FILE is still valid but should be deprecated and removed soon.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah. Nice backwards compatibility.

Copy link
Contributor

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

Adds machine-to-machine OAuth support for connecting to authenticated MCP servers, including refresh-token and client-credentials grant flows, plus configuration via sly_data and an MCP info file.

Changes:

  • Introduces OAuth token storage backed by sly_data and new OAuth providers for client_credentials and refresh_token flows.
  • Updates LangChainMcpAdapter to load MCP configuration from AGENT_MCP_INFO_FILE (with backward-compatible fallback) and to attach headers/auth when creating MCP clients.
  • Adds/updates documentation, Docker env vars, requirements pinning, and comprehensive unit tests for the new auth components.

Reviewed changes

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

Show a summary per file
File Description
neuro_san/internals/run_context/langchain/mcp/langchain_mcp_adapter.py Adds MCP info loading, header/auth preparation, tool filtering/tagging, and OAuth error handling.
neuro_san/internals/run_context/langchain/mcp/mcp_info_restorer.py Replaces old restorer with unified MCP info file loader + new env var.
neuro_san/internals/run_context/langchain/mcp/sly_data_token_storage.py Implements token/client-info storage backed by sly_data dict references.
neuro_san/internals/run_context/langchain/mcp/client_credentials_oauth_provider.py Adds client-credentials OAuth provider based on MCP SDK internals with token endpoint override support.
neuro_san/internals/run_context/langchain/mcp/refresh_token_oauth_provider.py Adds refresh-token OAuth provider adapted for M2M flows.
neuro_san/internals/run_context/langchain/mcp/oauth_provider_factory.py Chooses refresh-token vs client-credentials provider based on available data.
neuro_san/internals/run_context/langchain/core/base_tool_factory.py Passes sly_data through to MCP adapter so tokens can be updated in-place.
requirements.txt Pins MCP SDK dependency to support OAuth flows.
neuro_san/deploy/Dockerfile Adds AGENT_MCP_INFO_FILE and AGENT_MCP_TIMEOUT_SECONDS env vars (+ deprecation notes).
docs/mcp_authentication.md New guide documenting the auth flows and configuration precedence.
docs/agent_hocon_reference.md Replaces inline MCP auth section with a link to the new guide.
tests/.../test_*.py Adds unit tests for token storage, providers, factory, and adapter integration behavior.
Comments suppressed due to low confidence (6)

neuro_san/internals/run_context/langchain/mcp/oauth_provider_factory.py:90

  • Factory defaults token_endpoint_auth_method to client_secret_basic even when client_secret is missing (e.g., credentials only contain client_id). This will likely produce an invalid auth header/body. Validate required fields based on token_endpoint_auth_method (or default auth_method to None when no secret is provided) and fail fast with a clear error/log.
        return ClientCredentialsOauthProvider(
            server_url=self.server_url,
            storage=self.storage,
            client_id=credentials.get("client_id"),
            client_secret=credentials.get("client_secret"),
            token_endpoint=self.token_endpoint,
            token_endpoint_auth_method=credentials.get("token_endpoint_auth_method", "client_secret_basic"),
            scopes=credentials.get("scope"),
            timeout=self.timeout
        )

neuro_san/internals/run_context/langchain/mcp/mcp_info_restorer.py:74

  • McpInfoRestorer.restore() is annotated to return Dict[str, Any], but it returns None when no env var/file is provided. Either change the return type to Optional[Dict[str, Any]] or return an empty dict to match the annotation/interface expectations.
    neuro_san/internals/run_context/langchain/mcp/sly_data_token_storage.py:68
  • tokens.expires_in can be absent/None per OAuth specs, but the log message formats it with %d, which raises TypeError and triggers the error path (and logs a misleading failure). Use a safe format (e.g., %s) or guard for None before logging.
        try:
            # Clear and update token in-place
            self.tokens.clear()
            self.tokens.update(tokens.model_dump(mode="json"))
            self.logger.info("Tokens saved (expires in %d s)", tokens.expires_in)
        except (AttributeError, TypeError) as errors:

neuro_san/internals/run_context/langchain/mcp/oauth_provider_factory.py:75

  • get_auth() is typed to return Auth, but it returns None when no credentials are available. Update the return type to Optional[Auth] (and adjust docstring/call sites if needed) to avoid type confusion for callers.
    async def get_auth(self) -> Auth:
        """
        Get appropriate OAuth provider based on stored credentials and tokens.

        Flow implementation:
        - Refresh token flow (if refresh token is available)
        - Client credentials flow (no refresh token, but client credentials are available)
        """
        credentials: Dict[str, Any] = await self.storage.get_client_info_dict()
        tokens: Dict[str, Any] = await self.storage.get_tokens_dict()
        refresh_token: str = tokens.get("refresh_token")

        if credentials:
            # If there is a refresh token, prioritize refresh token flow to reuse existing authorization
            if refresh_token:
                return await self._create_refresh_token_provider(credentials)
            return await self._create_client_credentials_provider(credentials)

        # No client credentials, no auth provider can be created
        return None

neuro_san/internals/run_context/langchain/mcp/client_credentials_oauth_provider.py:87

  • client_secret is typed as required (str), but OauthProviderFactory passes credentials.get("client_secret"), which can be None, and the docs allow omitting client_secret when token_endpoint_auth_method is None. Consider making client_secret Optional[str] and explicitly handling the "no secret" case (including validation when auth_method requires a secret).
    def __init__(
        self,
        server_url: str,
        storage: TokenStorage,
        client_id: str,
        client_secret: str,
        token_endpoint: str = None,
        token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post", None] = "client_secret_basic",
        scopes: str | None = None,
        timeout: float = 300.0

tests/neuro_san/internals/run_context/langchain/mcp/test_client_credentials_oauth_provider.py:276

  • The comment says the provided endpoint should NOT be used, but the assertion verifies the provided endpoint is used. Update the comment to match the test behavior (provided endpoint preferred over discovered).
        # Should use discovered endpoint, not the provided one
        assert str(request.url) == "https://fallback.auth.com/token"

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

Copy link
Collaborator

@d1donlydfink d1donlydfink left a comment

Choose a reason for hiding this comment

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

See review comments sprinkled throughout. Mostly:

  • Questions about documentation
  • Backwards compatibility questions.
  • "expires_in" is not enough information to know when to issue a refresh for a token.

@Noravee
Copy link
Collaborator Author

Noravee commented Mar 4, 2026

  • Add more detailed to mcp_authentication.md
  • Add mcp_info_hocon_reference.md

@Noravee Noravee requested a review from d1donlydfink March 4, 2026 18:46
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