Skip to content

Resolve user ID from configurable HTTP header#1620

Closed
ericevans-nv wants to merge 1 commit intoNVIDIA:developfrom
ericevans-nv:feat/mcp-client-header-auth-support
Closed

Resolve user ID from configurable HTTP header#1620
ericevans-nv wants to merge 1 commit intoNVIDIA:developfrom
ericevans-nv:feat/mcp-client-header-auth-support

Conversation

@ericevans-nv
Copy link
Copy Markdown
Contributor

@ericevans-nv ericevans-nv commented Feb 20, 2026

Description

Adds auth_token_header config field to MCPClientConfig that specifies an HTTP header containing a JWT for user identification. When set, the user ID is extracted from JWT claims and used as the per-user session key, as an alternative to the nat-session cookie. Authentication with the MCP server still proceeds through the normal OAuth flow.

This supports deployments where an upstream service sends the user's identity as a JWT in an HTTP header (e.g., Authorization) rather than a session cookie, such as HTTP streaming scenarios.

By Submitting this PR I confirm:

  • I am familiar with the Contributing Guidelines.
  • We require that all contributors "sign-off" on their commits. This certifies that the contribution is your original work, or you have rights to submit it under the same license, or a compatible license.
    • Any contribution which contains commits that are not Signed-Off will not be accepted.
  • When the PR is ready for review, new or existing tests cover these changes.
  • When the PR is ready for review, the documentation is up to date with these changes.

Summary by CodeRabbit

  • New Features

    • Support for HTTP header–based authentication using JWTs to improve user identification and session routing
    • New configuration option to specify which HTTP header contains authentication tokens for routing decisions
  • Tests

    • Added test coverage for JWT-based authentication header resolution

@ericevans-nv ericevans-nv self-assigned this Feb 20, 2026
@ericevans-nv ericevans-nv added the improvement Improvement to existing functionality label Feb 20, 2026
@ericevans-nv ericevans-nv requested a review from a team as a code owner February 20, 2026 04:06
@ericevans-nv ericevans-nv added the non-breaking Non-breaking change label Feb 20, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 20, 2026

Walkthrough

Adds optional header-based authentication to the MCP client: a new auth_token_header config field and logic to extract a JWT from that header to determine user_id for session routing; falls back to existing context/session routing when not configured.

Changes

Cohort / File(s) Summary
Configuration Schema
packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_config.py
Add optional `auth_token_header: str
Authentication & Routing
packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py
Add _resolve_user_id_from_auth_header() to extract/verify JWT from configured header and return user_id. Update _response_fn session routing to use header-derived user_id when configured, otherwise fall back to _get_session_id_from_context().
Tests
packages/nvidia_nat_mcp/tests/client/test_mcp_client_impl.py
Add test_resolve_user_id_from_auth_header() with mocked Context and JWT; adjust test setup to set group._client_config.auth_token_header = None for the default session path and add jwt import.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant MCP as MCPFunctionGroup
    participant Config as MCPClientConfig
    participant Context as Context
    participant JWT as JWT Decoder
    participant Session as Session Router

    Client->>MCP: _response_fn(request)
    MCP->>Config: is auth_token_header set?
    alt Header-based auth enabled
        MCP->>Context: read HTTP headers
        Context-->>MCP: headers
        MCP->>JWT: decode/extract token from header
        JWT-->>MCP: return user_id (claims.sub)
        MCP->>Session: route by user_id
    else Header-based auth disabled
        MCP->>MCP: _get_session_id_from_context()
        MCP->>Session: route by session_id
    end
    Session-->>MCP: session selected
    MCP-->>Client: return response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The PR title clearly and concisely describes the main change: adding JWT header-based user ID resolution for session routing. It accurately reflects the primary objective of the changeset across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ericevans-nv ericevans-nv changed the title Resolve per-user ID key from configurable JWT header Resolve user ID from configurable HTTP header Feb 20, 2026
Signed-off-by: Eric Evans <194135482+ericevans-nv@users.noreply.github.com>
@ericevans-nv ericevans-nv force-pushed the feat/mcp-client-header-auth-support branch from 738c3c8 to f52d269 Compare February 20, 2026 04:15
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py (1)

256-286: Add exception handling for consistency with _get_session_id_from_context.

_get_session_id_from_context wraps its entire body in try/except: return None, gracefully degrading to the "temporarily unavailable" path on any unexpected error (e.g. no active context). _resolve_user_id_from_auth_header has no such guard: if _Ctx.get() raises, the exception propagates through _response_fn's outer except Exception as e and str(e) is returned to the caller — potentially exposing internal error details and diverging from the cookie path's behavior.

🔧 Proposed fix
 def _resolve_user_id_from_auth_header(self) -> str | None:
     ...
     if not self._client_config or not self._client_config.auth_token_header:
         return None

+    try:
         from nat.builder.context import Context as _Ctx
         from nat.runtime.connection_auth import resolve_user_id

         headers = getattr(_Ctx.get().metadata, "headers", None)
         if headers is None:
             return None

         raw_value: str | None = headers.get(self._client_config.auth_token_header)
         if not raw_value:
             return None

         user_id: str | None = resolve_user_id(raw_value, {})
         if not user_id:
             logger.warning("Could not resolve user ID from header '%s'", self._client_config.auth_token_header)
             return None

         logger.debug("Resolved user_id '%s' from header '%s'",
                      truncate_session_id(user_id),
                      self._client_config.auth_token_header)
         return user_id
+    except Exception:
+        return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py` around
lines 256 - 286, _wrap the body of _resolve_user_id_from_auth_header in a
try/except that catches Exception and returns None (mirroring
_get_session_id_from_context) so any unexpected errors (e.g. _Ctx.get() raising)
do not propagate; locate the method _resolve_user_id_from_auth_header and
enclose the existing logic that references _Ctx.get(), headers, and
resolve_user_id(...) in this guard, ensuring on exception you return None to
maintain the same graceful "temporarily unavailable" behavior as
_get_session_id_from_context and avoid leaking internal errors via _response_fn.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/nvidia_nat_mcp/tests/client/test_mcp_client_impl.py`:
- Line 187: Add an inline Ruff S106 suppression on the MCPClientConfig
invocation that sets auth_token_header to "Authorization" in the test (the line
calling MCPClientConfig(server=server_cfg, auth_token_header="Authorization")).
Specifically, update the test line where MCPClientConfig is constructed to
include a local noqa/ruff: noqa-style suppression for S106 so Ruff recognizes
this as a false positive for the auth_token_header parameter; keep the
suppression inline next to the auth_token_header usage to limit scope to this
expression.
- Line 21: The test imports PyJWT via "import jwt" (used in
test_resolve_user_id_from_auth_header) but relies on it transitively; add an
explicit test dependency on pyjwt (matching the required version, e.g., ~=2.11)
to packages/nvidia_nat_mcp/pyproject.toml under the
[tool.poetry.dev-dependencies] or test requirements so the test no longer
depends on a transitive package and remains stable if nvidia-nat-core removes
PyJWT.

---

Nitpick comments:
In `@packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py`:
- Around line 256-286: _wrap the body of _resolve_user_id_from_auth_header in a
try/except that catches Exception and returns None (mirroring
_get_session_id_from_context) so any unexpected errors (e.g. _Ctx.get() raising)
do not propagate; locate the method _resolve_user_id_from_auth_header and
enclose the existing logic that references _Ctx.get(), headers, and
resolve_user_id(...) in this guard, ensuring on exception you return None to
maintain the same graceful "temporarily unavailable" behavior as
_get_session_id_from_context and avoid leaking internal errors via _response_fn.

Comment thread packages/nvidia_nat_mcp/tests/client/test_mcp_client_impl.py
Comment thread packages/nvidia_nat_mcp/tests/client/test_mcp_client_impl.py
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/nvidia_nat_mcp/tests/client/test_mcp_client_impl.py (1)

178-191: Missing failure-path coverage for _resolve_user_id_from_auth_header.

The test covers the happy path but leaves the following paths untested:

  • Header configured but absent from the request (should return None gracefully).
  • JWT is malformed / not decodable.
  • _Ctx.get() raises an exception (exercises the missing guard flagged in client_impl.py).
  • Empty claims dict returned by resolve_user_id (e.g., no name/sub claim present).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nvidia_nat_mcp/tests/client/test_mcp_client_impl.py` around lines
178 - 191, Add negative-path tests for
MCPFunctionGroup._resolve_user_id_from_auth_header: write test cases that (1)
configure auth_token_header but provide no such header in ctx.metadata.headers
and assert it returns None, (2) supply a malformed/undecodable JWT in the
Authorization header and assert it returns None, (3) patch
nat.builder.context.Context.get (mock_context_cls.get) to raise an exception and
assert the method handles it and returns None, and (4) supply a valid JWT whose
claims are empty or missing "name"/"sub" and assert the result is None; use the
same test harness pattern as the existing test (patch Context, set
mock_context_cls.get.return_value = ctx or side_effect to raise, construct
MCPClientConfig/MCPFunctionGroup) and keep assertions explicit for each failure
path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py`:
- Around line 256-280: The method _resolve_user_id_from_auth_header should
mirror _get_session_id_from_context by guarding the entire body in a try/except
Exception that returns None on error; wrap calls to _Ctx.get() and
resolve_user_id(...) in that try block so any exception (e.g., Context.get()
when no request context) is swallowed and the function returns None instead of
letting the exception propagate to _response_fn and leak to callers; keep
existing logging for unresolved user_id but do not log exceptions here—just
return None.
- Around line 268-272: The code is currently decoding JWTs without signature
verification via resolve_user_id(user_auth_header, {}), which allows forged
headers to impersonate users; update the MCPClientConfig to include verification
material (a JWKS URL or public key / verification options) and change
client_impl.py to pass those verification options into resolve_user_id (or call
a verified decode function such as decode_jwt_payload with the provided
key/JWKS) instead of an empty dict; alternatively, if you truly rely on a
trusted gateway, add an explicit comment and config flag (e.g.,
require_trusted_gateway=True) documenting the trust boundary and fail fast when
no verification material is supplied to auth_token_header handling.
- Line 272: The current session routing uses resolve_user_id (used to set
user_id in client_impl.py) which prefers the "name" JWT claim and can cause
collisions; update resolve_user_id to prefer the standard unique "sub" claim
first and only fall back to "name", "email", or "preferred_username" to ensure
per-user MCP client sessions are uniquely routed, and update any
docstring/comments for resolve_user_id and the user_id usage in client_impl.py
to reflect this change.

---

Duplicate comments:
In `@packages/nvidia_nat_mcp/tests/client/test_mcp_client_impl.py`:
- Line 187: The test sets auth_token_header as a literal in the MCPClientConfig
instantiation (client_cfg = MCPClientConfig(server=server_cfg,
auth_token_header="Authorization")), which Ruff flags as S106; suppress this
false positive by appending a noqa S106 comment to that expression (add "# noqa:
S106" to the same line where auth_token_header is passed) so the linter ignores
the hardcoded-string check for this test helper.
- Line 21: The file contains a duplicated explicit import of the PyJWT package
("import jwt")—remove the redundant import from
tests/client/test_mcp_client_impl.py (the lone "import jwt" statement) so the
test reuses the existing jwt import/fixture or dependency declared elsewhere;
ensure no other references break and that any needed jwt usage relies on the
shared import or test utilities instead of adding a new explicit dependency.

---

Nitpick comments:
In `@packages/nvidia_nat_mcp/tests/client/test_mcp_client_impl.py`:
- Around line 178-191: Add negative-path tests for
MCPFunctionGroup._resolve_user_id_from_auth_header: write test cases that (1)
configure auth_token_header but provide no such header in ctx.metadata.headers
and assert it returns None, (2) supply a malformed/undecodable JWT in the
Authorization header and assert it returns None, (3) patch
nat.builder.context.Context.get (mock_context_cls.get) to raise an exception and
assert the method handles it and returns None, and (4) supply a valid JWT whose
claims are empty or missing "name"/"sub" and assert the result is None; use the
same test harness pattern as the existing test (patch Context, set
mock_context_cls.get.return_value = ctx or side_effect to raise, construct
MCPClientConfig/MCPFunctionGroup) and keep assertions explicit for each failure
path.

Comment on lines +256 to +280
def _resolve_user_id_from_auth_header(self) -> str | None:
"""Extract a JWT from the configured HTTP header and resolve the user ID from its claims."""
if not self._client_config or not self._client_config.auth_token_header:
return None

from nat.builder.context import Context as _Ctx
from nat.runtime.connection_auth import resolve_user_id

headers = getattr(_Ctx.get().metadata, "headers", None)
if headers is None:
return None

user_auth_header: str | None = headers.get(self._client_config.auth_token_header)
if not user_auth_header:
return None

user_id: str | None = resolve_user_id(user_auth_header, {})
if not user_id:
logger.warning("Could not resolve user ID from header '%s'", self._client_config.auth_token_header)
return None

logger.debug("Resolved user_id '%s' from header '%s'",
truncate_session_id(user_id),
self._client_config.auth_token_header)
return user_id
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

_resolve_user_id_from_auth_header missing exception guard — inconsistent with sibling method.

_get_session_id_from_context wraps its entire body in try/except Exception: return None to silently degrade when the context is unavailable. _resolve_user_id_from_auth_header has no such guard, so if _Ctx.get() throws (e.g., when called outside a request context), the exception escapes to _response_fn's outer except Exception as e: handler, returning the raw exception string to the caller and logging it at WARNING level instead of silently returning None.

🔧 Proposed fix
 def _resolve_user_id_from_auth_header(self) -> str | None:
     """Extract a JWT from the configured HTTP header and resolve the user ID from its claims."""
     if not self._client_config or not self._client_config.auth_token_header:
         return None

+    try:
         from nat.builder.context import Context as _Ctx
         from nat.runtime.connection_auth import resolve_user_id

         headers = getattr(_Ctx.get().metadata, "headers", None)
         if headers is None:
             return None

         user_auth_header: str | None = headers.get(self._client_config.auth_token_header)
         if not user_auth_header:
             return None

         user_id: str | None = resolve_user_id(user_auth_header, {})
         if not user_id:
             logger.warning("Could not resolve user ID from header '%s'", self._client_config.auth_token_header)
             return None

         logger.debug("Resolved user_id '%s' from header '%s'",
                      truncate_session_id(user_id),
                      self._client_config.auth_token_header)
         return user_id
+    except Exception:
+        logger.debug("Failed to resolve user ID from auth header", exc_info=True)
+        return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py` around
lines 256 - 280, The method _resolve_user_id_from_auth_header should mirror
_get_session_id_from_context by guarding the entire body in a try/except
Exception that returns None on error; wrap calls to _Ctx.get() and
resolve_user_id(...) in that try block so any exception (e.g., Context.get()
when no request context) is swallowed and the function returns None instead of
letting the exception propagate to _response_fn and leak to callers; keep
existing logging for unresolved user_id but do not log exceptions here—just
return None.

Comment thread packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py
if not user_auth_header:
return None

user_id: str | None = resolve_user_id(user_auth_header, {})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the resolve_user_id function and examine its implementation
rg -n "def resolve_user_id" --type py -A 20

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 2161


🏁 Script executed:

#!/bin/bash
# Find the test file and examine the test at line 191
fd -e py "test" --type f | xargs rg -l "_resolve_user_id_from_auth_header\|resolve_user_id" | head -5

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 51


🏁 Script executed:

#!/bin/bash
# Find test files related to client_impl
find . -name "*test*.py" -type f | xargs rg -l "client_impl\|resolve_user_id" 2>/dev/null | head -10

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 51


🏁 Script executed:

#!/bin/bash
# Read the client_impl.py file to see the test and context
cat -n packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py | sed -n '180,200p'

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 1256


🏁 Script executed:

#!/bin/bash
# Search for test files related to connection_auth
find . -name "*test*.py" -o -name "*_test.py" | head -20

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 1532


🏁 Script executed:

#!/bin/bash
# Search for "Alice" in the codebase to find the test mentioned
rg "Alice" --type py -B 3 -A 3

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 19203


🏁 Script executed:

#!/bin/bash
# Examine the test file to understand the implementation
cat -n packages/nvidia_nat_mcp/tests/client/test_mcp_client_impl.py | head -100

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 4588


🏁 Script executed:

#!/bin/bash
# Find the test_resolve_user_id_from_auth_header test
rg -n "test_resolve_user_id_from_auth_header" packages/nvidia_nat_mcp/tests/client/test_mcp_client_impl.py -A 15

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 878


🏁 Script executed:

#!/bin/bash
# Find _resolve_user_id_from_auth_header in client_impl.py
rg -n "_resolve_user_id_from_auth_header\|resolve_user_id" packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py -B 2 -A 10

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 51


🏁 Script executed:

#!/bin/bash
# Read the client_impl.py file around line 272
cat -n packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py | sed -n '265,280p'

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 807


🏁 Script executed:

#!/bin/bash
# Find the method that calls _resolve_user_id_from_auth_header
rg -n "_resolve_user_id_from_auth_header" packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py -B 30 | head -50

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 2685


🏁 Script executed:

#!/bin/bash
# Find where _resolve_user_id_from_auth_header is called
rg -n "_resolve_user_id_from_auth_header()" packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py -B 2 -A 2

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 708


🏁 Script executed:

#!/bin/bash
# Read more context around the session routing
cat -n packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py | sed -n '540,570p'

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 2085


🏁 Script executed:

#!/bin/bash
# Verify the complete priority order in resolve_user_id
cat -n packages/nvidia_nat_core/src/nat/runtime/connection_auth.py | sed -n '74,93p'

Repository: NVIDIA/NeMo-Agent-Toolkit

Length of output: 1041


Confirm session routing uses unique JWT claim for data isolation.

The code uses resolve_user_id, which prioritizes the "name" claim over "sub" when routing to per-user MCP client sessions (line 546). This creates a collision risk: two users with the same display name will be routed to the same session, violating data isolation.

While the function falls back through "email" and "preferred_username" before "sub", the primary reliance on "name" is not documented. Either confirm this is intentional and document why it is safe in your deployment context, or switch the priority to prefer "sub" (the JWT standard unique subject identifier) with "name" as a fallback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nvidia_nat_mcp/src/nat/plugins/mcp/client/client_impl.py` at line
272, The current session routing uses resolve_user_id (used to set user_id in
client_impl.py) which prefers the "name" JWT claim and can cause collisions;
update resolve_user_id to prefer the standard unique "sub" claim first and only
fall back to "name", "email", or "preferred_username" to ensure per-user MCP
client sessions are uniquely routed, and update any docstring/comments for
resolve_user_id and the user_id usage in client_impl.py to reflect this change.

Copy link
Copy Markdown
Member

@willkill07 willkill07 left a comment

Choose a reason for hiding this comment

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

Documentation should be added/updated with a clear security warning. This is an attack vector that has to be well documented.

Copy link
Copy Markdown
Contributor

@mnajafian-nv mnajafian-nv left a comment

Choose a reason for hiding this comment

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

Conditional approval upon your review of Will's comments and my inline feedbacks.

Comment on lines +256 to +280
def _resolve_user_id_from_auth_header(self) -> str | None:
"""Extract a JWT from the configured HTTP header and resolve the user ID from its claims."""
if not self._client_config or not self._client_config.auth_token_header:
return None

from nat.builder.context import Context as _Ctx
from nat.runtime.connection_auth import resolve_user_id

headers = getattr(_Ctx.get().metadata, "headers", None)
if headers is None:
return None

user_auth_header: str | None = headers.get(self._client_config.auth_token_header)
if not user_auth_header:
return None

user_id: str | None = resolve_user_id(user_auth_header, {})
if not user_id:
logger.warning("Could not resolve user ID from header '%s'", self._client_config.auth_token_header)
return None

logger.debug("Resolved user_id '%s' from header '%s'",
truncate_session_id(user_id),
self._client_config.auth_token_header)
return user_id
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
def _resolve_user_id_from_auth_header(self) -> str | None:
"""Extract a JWT from the configured HTTP header and resolve the user ID from its claims."""
if not self._client_config or not self._client_config.auth_token_header:
return None
from nat.builder.context import Context as _Ctx
from nat.runtime.connection_auth import resolve_user_id
headers = getattr(_Ctx.get().metadata, "headers", None)
if headers is None:
return None
user_auth_header: str | None = headers.get(self._client_config.auth_token_header)
if not user_auth_header:
return None
user_id: str | None = resolve_user_id(user_auth_header, {})
if not user_id:
logger.warning("Could not resolve user ID from header '%s'", self._client_config.auth_token_header)
return None
logger.debug("Resolved user_id '%s' from header '%s'",
truncate_session_id(user_id),
self._client_config.auth_token_header)
return user_id
def _resolve_user_id_from_auth_header(self) -> str | None:
"""Extract a JWT from the configured HTTP header and resolve the user ID from its claims."""
if not self._client_config or not self._client_config.auth_token_header:
return None
try:
from nat.builder.context import Context as _Ctx
from nat.runtime.connection_auth import resolve_user_id
headers = getattr(_Ctx.get().metadata, "headers", None)
if headers is None:
return None
user_auth_header: str | None = headers.get(self._client_config.auth_token_header)
if not user_auth_header:
return None
user_id: str | None = resolve_user_id(user_auth_header, {})
if not user_id:
logger.warning("Could not resolve user ID from header '%s'", self._client_config.auth_token_header)
return None
logger.debug("Resolved user_id '%s' from header '%s'",
truncate_session_id(user_id),
self._client_config.auth_token_header)
return user_id
except Exception:
logger.debug("Failed to resolve user ID from auth header", exc_info=True)
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I suggest, wrapping this block in try/except Exception: return None so we match _get_session_id_from_context and don’t leak exceptions (and internal errors) to the caller via _response_fn.
Optional: log at debug with exc_info=True in the except.


assert group._resolve_user_id_from_auth_header() == "Alice"


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
@patch("nat.builder.context.Context")
def test_resolve_user_id_from_auth_header_missing_header_returns_none(mock_context_cls):
"""When auth_token_header is set but the header is absent, returns None."""
ctx = MagicMock()
ctx.metadata.headers = {}
mock_context_cls.get.return_value = ctx
server_cfg = MCPServerConfig(transport="streamable-http", url="http://localhost:9901/mcp")
client_cfg = MCPClientConfig(server=server_cfg, auth_token_header="Authorization")
group = MCPFunctionGroup(config=client_cfg)
group._client_config = client_cfg
assert group._resolve_user_id_from_auth_header() is None
@patch("nat.builder.context.Context")
def test_resolve_user_id_from_auth_header_context_raises_returns_none(mock_context_cls):
"""When Context.get() raises, returns None instead of propagating."""
mock_context_cls.get.side_effect = RuntimeError("No context")
server_cfg = MCPServerConfig(transport="streamable-http", url="http://localhost:9901/mcp")
client_cfg = MCPClientConfig(server=server_cfg, auth_token_header="Authorization")
group = MCPFunctionGroup(config=client_cfg)
group._client_config = client_cfg
assert group._resolve_user_id_from_auth_header() is None
@patch("nat.runtime.connection_auth.resolve_user_id")
@patch("nat.builder.context.Context")
def test_resolve_user_id_from_auth_header_malformed_jwt_returns_none(mock_context_cls, mock_resolve_user_id):
"""When the header value is invalid, returns None (e.g. decode error or no user)."""
mock_resolve_user_id.return_value = None
ctx = MagicMock()
ctx.metadata.headers = {"Authorization": "Bearer not-a-valid-jwt"}
mock_context_cls.get.return_value = ctx
server_cfg = MCPServerConfig(transport="streamable-http", url="http://localhost:9901/mcp")
client_cfg = MCPClientConfig(server=server_cfg, auth_token_header="Authorization")
group = MCPFunctionGroup(config=client_cfg)
group._client_config = client_cfg
assert group._resolve_user_id_from_auth_header() is None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I suggest we add tests for: (1) header configured but missing → None, (2) Context.get() raises → None, (3) invalid token / resolve_user_id returns None or raises → None. Patch Context at nat.builder.context.Context; for (3) patch resolve_user_id at nat.runtime.connection_auth.resolve_user_id so the test is self-contained.

Comment on lines +145 to +146
auth_token_header: str | None = Field(
default=None, description="HTTP header name containing a token used for user identification.")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
auth_token_header: str | None = Field(
default=None, description="HTTP header name containing a token used for user identification.")
auth_token_header: str | None = Field(
default=None,
description="HTTP header name containing a token used for user identification. "
"When set, the header value is used to resolve the session user ID (e.g. from JWT claims). "
"Only set this when the header is set by a trusted upstream (e.g. gateway); no verification is performed here.")

"""
session_aware_tools: bool = Field(default=True,
description="Session-aware tools are created if True. Defaults to True.")
auth_token_header: str | None = Field(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@ericevans-nv Thanks for this change. I think we can simplify/unify user-id resolution by first using Context.get().user_id (already populated during request/session handling) and only falling back to header parsing when it’s missing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I missed that this PR touches only shared MCPFunctionGroup. per_user_mcp_client_function_group already uses Context.get().user_id, which is the right source of truth.
I’d suggest aligning shared MCP routing to the same pattern and removing auth_token_header from MCP config to avoid duplicate JWT parsing logic in the plugin.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The auth_token_header config serves a different purpose: it allows deployers to specify a custom header name containing the JWT, since the UI does not send nat-session cookies over HTTP and the header name is only known to the deployer. Context.user_id is populated exclusively from the Authorization header or the nat-session cookie, so it won't have the correct value when a non-standard header is used. That said, I agree we should align the pattern, I'll update the PR so the custom header resolution works for both the shared MCPFunctionGroup and per_user_mcp_client_function_group.

@ericevans-nv
Copy link
Copy Markdown
Contributor Author

Closing this PR. After discussion with the team, HTTP headers will always send JWTs in the standard Authorization header, which the session layer already handles. The custom header support isn't needed at this time. If a non-standard header requirement comes up in the future, we can revisit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

improvement Improvement to existing functionality non-breaking Non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants