diff --git a/.secrets.baseline b/.secrets.baseline
index 0e6f9882d..b8985fdb5 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -290,5 +290,5 @@
}
]
},
- "generated_at": "2026-05-22T20:01:44Z"
+ "generated_at": "2026-07-02T05:57:01Z"
}
diff --git a/README.md b/README.md
index c77490406..78707e5de 100644
--- a/README.md
+++ b/README.md
@@ -380,7 +380,7 @@ For development, contribution, and documentation, refer to:
- [ ] **[NeMo Guardrails](https://github.com/NVIDIA-NeMo/Guardrails) Integration:** Enhance safety and security guardrails.
- [ ] **[NVIDIA Dynamo](https://github.com/ai-dynamo/dynamo) Integration:** Reduce latency via priority scheduling at scale.
-- [ ] **MCP Authentication:** Implement secure login/auth for MCP connections.
+- [x] **Per-user MCP OAuth:** Connect each signed-in user to protected MCP data sources through the UI.
- [x] **Skills & Sandboxing:** Support built-in deep research skills with job-scoped sandbox execution.
- [ ] **Custom Skill Management:** Add UI and lifecycle controls for user-provided skill bundles.
- [ ] **Dynamic Model Routing:** Allow sub-agents to automatically select the optimal model per task.
diff --git a/configs/config_web_frag_mcp_auth.yml b/configs/config_web_frag_mcp_auth.yml
new file mode 100644
index 000000000..4e5b1c9ea
--- /dev/null
+++ b/configs/config_web_frag_mcp_auth.yml
@@ -0,0 +1,297 @@
+# Web mode with Foundational RAG PLUS a protected per-user OAuth MCP data source
+# (example: Google Drive). This is config_web_frag.yml with the per-user MCP auth
+# wiring added, kept as a separate config so the base web config stays minimal.
+#
+# Features:
+# - Web search enabled by default
+# - Knowledge retrieval using Foundational RAG.
+# Requires a RAG server and ingest server to be running (not deployed by this
+# blueprint). Example RAG deployment: https://github.com/NVIDIA-AI-Blueprints/rag/tree/main
+# - Protected per-user MCP source (`gdrive`): AIQ surfaces connection state on the
+# source card, gates job submission until connected (409 mcp_auth_required), and
+# owns the OAuth connect/callback. See the `authentication` / `object_stores`
+# sections below for setup (MCP_GDRIVE_URL, AIQ_PUBLIC_URL, MCP_TOKEN_STORE_TYPE).
+
+general:
+ use_uvloop: true
+ telemetry:
+ logging:
+ console:
+ _type: console
+ level: INFO
+ # tracing:
+ # langsmith: # Optional: LangSmith tracing - requires langsmith API key. Set using `export LANGSMITH_API_KEY=`
+ # _type: langsmith
+ # project: nvidia-aiq
+
+ front_end:
+ _type: aiq_api
+ runner_class: aiq_api.plugin.AIQAPIWorker
+ # =========================================================================
+ # Knowledge API is automatically enabled when knowledge_retrieval function
+ # is configured
+ # =========================================================================
+ # Async Job API Settings
+ # =========================================================================
+ # Async job infrastructure database (NAT JobStore + EventStore)
+ # Used by: /v1/jobs/async routes, SSE streaming, job status persistence
+ # Requires async driver for SQLite (aiosqlite) or PostgreSQL (asyncpg)
+ # Environment overrides:
+ # - NAT_JOB_STORE_DB_URL (direct override)
+ # - NAT_JOB_STORE_DB_URL_DEV / NAT_JOB_STORE_DB_URL_PROD (via NAT_ENV)
+ db_url: ${NAT_JOB_STORE_DB_URL:-sqlite+aiosqlite:///./jobs.db}
+ # Job expiry - how long completed jobs stay in database before cleanup
+ expiry_seconds: 86400 # 24 hours (min: 600, max: 604800/7 days)
+ cors:
+ allow_origin_regex: 'http://localhost(:\d+)?|http://127.0.0.1(:\d+)?'
+ allow_methods:
+ - GET
+ - POST
+ - DELETE
+ - OPTIONS
+ allow_headers:
+ - "*"
+ allow_credentials: true
+ expose_headers:
+ - "*"
+
+llms:
+ nemotron_llm_intent:
+ _type: nim
+ model_name: nvidia/nemotron-3-super-120b-a12b
+ base_url: "https://integrate.api.nvidia.com/v1"
+ temperature: 0.5
+ top_p: 0.9
+ max_tokens: 4096
+ num_retries: 5
+ chat_template_kwargs:
+ enable_thinking: true
+
+ nemotron_super_llm:
+ _type: nim
+ model_name: nvidia/nemotron-3-super-120b-a12b
+ base_url: "https://integrate.api.nvidia.com/v1"
+ temperature: 0.7
+ top_p: 0.7
+ max_tokens: 65536
+ num_retries: 5
+ chat_template_kwargs:
+ enable_thinking: true
+
+functions:
+ # =========================================================================
+ # Data Source Registry
+ # =========================================================================
+ # Central registry that controls:
+ # 1. UI toggles — each source appears as an on/off switch in the frontend
+ # 2. Per-message filtering — users can select active sources per request
+ # 3. Tool auto-inheritance — agents with no explicit `tools` list receive
+ # every tool listed here (use `exclude_tools` on agents to specialize)
+ #
+ # Source entry fields:
+ # id, name, description, tools, requires_auth (default: false),
+ # default_enabled (default: true)
+ #
+ # See docs/source/customization/tools-and-sources.md for full details.
+ # =========================================================================
+ data_sources:
+ _type: data_source_registry
+ sources:
+ - id: web_search
+ name: "Web Search"
+ description: "Search the web for real-time information."
+ tools:
+ - web_search_tool
+ - advanced_web_search_tool
+ - id: knowledge_layer
+ name: "Knowledge Base"
+ description: "Search uploaded documents and files."
+ tools:
+ - knowledge_search
+ # Protected per-user MCP source (example: a Google Drive MCP server). `per_user_auth`
+ # makes AIQ surface connection state on the source card, gate job submission
+ # until connected (409 mcp_auth_required), and own the OAuth connect/callback.
+ - id: gdrive
+ name: "Google Drive"
+ description: "Search and read your authorized Google Drive files."
+ default_enabled: false
+ # requires_auth (the old AIQ-login gate) is left false so the source card
+ # is visible/Connectable in no-auth local runs; per_user_auth is the gate.
+ requires_auth: false
+ per_user_auth:
+ required: true
+ provider: google
+ mcp_server_id: gdrive
+ auth_provider: mcp_oauth2_gdrive # -> the `authentication` entry below
+ # Clean names + descriptions so the research agent recognizes these as the
+ # way to access the user's Drive (the raw MCP tools are terse/blank, which
+ # made the small model fall back to web search / "no access").
+ tool_overrides:
+ gdrive_search:
+ alias: google_drive_search
+ description: >-
+ Search the USER'S connected Google Drive for files by name or content.
+ Use this whenever the user asks to find or look up a document in their
+ Google Drive. Returns matching files with their URLs/ids.
+ gdrive_get_file:
+ alias: google_drive_read_file
+ description: >-
+ Read the full text contents of a specific Google Drive file given its
+ URL or id (from google_drive_search). Use this to read or summarize a
+ document the user referenced in their Google Drive.
+ gdrive_get_metadata:
+ alias: google_drive_file_metadata
+ description: >-
+ Get metadata (name, type, owner, last modified) for a Google Drive file
+ by URL or id.
+ # NOTE: no `tools:` here, and intentionally NO config-declared function group.
+ # A per_user_mcp_client declared in config gets built by NAT's per-user
+ # interactive (WebSocket) session builder, which crashes for a user with no
+ # token. Instead the async-job worker builds the MCP client IN CODE per-job
+ # (from this source's auth_provider) with the owner's token — see
+ # aiq_api.mcp_auth.runtime_tools. This keeps the interactive WS path untouched.
+
+ web_search_tool:
+ _type: tavily_web_search
+ max_results: 5
+ max_content_length: 1000
+
+ advanced_web_search_tool:
+ _type: tavily_web_search
+ max_results: 2
+ advanced_search: true
+
+ # Knowledge Retrieval (see sources/knowledge_layer/KNOWLEDGE-LAYER-SETUP.md)
+ knowledge_search:
+ _type: knowledge_retrieval
+ backend: foundational_rag
+ collection_name: ${COLLECTION_NAME:-test_collection}
+ top_k: 5
+ rag_url: ${RAG_SERVER_URL:-http://localhost:8081}
+ ingest_url: ${RAG_INGEST_URL:-http://localhost:8082}
+ timeout: 300
+
+ # Paper Search (optional - requires SERPER_API_KEY)
+ # Uncomment the block below and set SERPER_API_KEY to enable academic paper search.
+ # paper_search_tool:
+ # _type: paper_search
+ # max_results: 5
+ # serper_api_key: ${SERPER_API_KEY}
+
+ # =========================================================================
+ # Agents — inherit all registry tools; use exclude_tools to specialize
+ # =========================================================================
+ intent_classifier:
+ _type: intent_classifier
+ llm: nemotron_llm_intent
+ # tools: omitted -> inherits all from data_source_registry
+ # exclude_tools: []
+
+ clarifier_agent:
+ _type: clarifier_agent
+ llm: nemotron_super_llm
+ planner_llm: nemotron_super_llm
+ # tools: omitted -> inherits all from data_source_registry
+ # exclude_tools: []
+ max_turns: 3
+ enable_plan_approval: true
+ log_response_max_chars: 2000
+ verbose: true
+
+ shallow_research_agent:
+ _type: shallow_research_agent
+ llm: nemotron_super_llm
+ # tools: omitted -> inherits all from data_source_registry
+ exclude_tools:
+ - advanced_web_search_tool
+ max_llm_turns: 10
+ max_tool_iterations: 5
+
+ deep_research_agent:
+ _type: deep_research_agent
+ enable_citation_verification: true
+ orchestrator_llm: nemotron_super_llm
+ source_router_llm: nemotron_super_llm
+ researcher_llm: nemotron_super_llm
+ planner_llm: nemotron_super_llm
+ writer_llm: nemotron_super_llm
+ # tools: omitted -> inherits all from data_source_registry
+ exclude_tools:
+ - web_search_tool
+
+# =========================================================================
+# Protected per-user MCP source wiring (example: Google Drive) — OPTIONAL
+# =========================================================================
+# Uncomment together with the `gdrive` data_sources entry above to enable a
+# per-user OAuth-protected MCP source.
+#
+# How it fits together:
+# - `mcp_oauth2_gdrive` (authentication): standard NAT MCP OAuth provider. AIQ
+# treats this as the single source of truth — it derives the shared token
+# storage, redirect_uri, scopes and client_id from here, and reuses NAT's
+# well-known discovery for the authorize/token endpoints.
+# - `mcp_gdrive` (function_groups): the job-time per_user_mcp_client. It reads
+# the same token AIQ wrote at connect time from the same object store.
+#
+# Requirements (see frontends/aiq_api/src/aiq_api/mcp_auth/factory.py):
+# * token_storage_object_store MUST be a shared, persistent object store — the
+# API process writes the token and a separate worker process reads it.
+# * redirect_uri MUST point at AIQ's callback: /v1/auth/mcp/gdrive/callback
+# and be reachable by the user's browser (i.e. AIQ's public URL).
+# * client_id is OPTIONAL. Omitted -> NAT dynamic client registration (DCR):
+# connect works, but silent token refresh won't survive the API->worker
+# process boundary, so users Reconnect when a source shows "expired" (the UI
+# supports this). Set a fixed (e.g. ECI public) client_id for silent refresh.
+#
+# Example below wires a per-user OAuth-protected MCP server (e.g. a Google Drive
+# MCP service). Point MCP_GDRIVE_URL at your provider's MCP endpoint.
+# Set: MCP_GDRIVE_URL=https://your-mcp-server.example.com/mcp
+# AIQ_PUBLIC_URL=
+#
+# NOTE: deliberately NO `function_groups: mcp_gdrive` here. A per_user_mcp_client
+# declared in config is built by NAT's per-user interactive (WebSocket) session
+# builder, which fails for a user with no token and breaks interactive chat. The
+# async-job worker instead builds the per-user MCP client IN CODE, per job, from
+# the `mcp_oauth2_gdrive` auth provider below (its server_url) + the job owner's
+# token. See aiq_api.mcp_auth.runtime_tools.open_per_user_mcp_tools.
+
+authentication:
+ mcp_oauth2_gdrive:
+ _type: mcp_oauth2
+ server_url: ${MCP_GDRIVE_URL:-https://your-mcp-server.example.com/mcp}
+ redirect_uri: ${AIQ_PUBLIC_URL:-http://localhost:8000}/v1/auth/mcp/gdrive/callback
+ token_storage_object_store: mcp_token_store
+ # scopes are discovered from MaaS protected-resource metadata; pin only to override.
+ # client_id: ${MCP_GDRIVE_CLIENT_ID} # optional — see note above
+ # client_secret: ${MCP_GDRIVE_CLIENT_SECRET}
+
+# A shared object store is required so the API process (connect) and the worker
+# process (job) see the same token. Two modes, selected by MCP_TOKEN_STORE_TYPE:
+# * aiq_sqlite (default) — a SQLite file, serviceless and shared across the API
+# and worker processes on the same host. Works locally with nothing deployed.
+# * redis — a networked store for multi-replica / multi-host deployments
+# (requires nvidia-nat-redis; deploy sets MCP_TOKEN_STORE_TYPE=redis).
+# Fields not used by the active _type are ignored, so both sets can coexist here.
+#
+# aiq_sqlite requirement: the API and worker processes must resolve db_path to the
+# SAME file, so they must share a filesystem (single host / shared volume). Prefer
+# an ABSOLUTE path — a relative path only works if both processes share a working
+# directory. Separate hosts/pods do NOT share a filesystem; use redis there.
+object_stores:
+ mcp_token_store:
+ _type: ${MCP_TOKEN_STORE_TYPE:-aiq_sqlite}
+ bucket_name: mcp-tokens
+ # aiq_sqlite (use an absolute path in any multi-process deployment):
+ db_path: ${MCP_TOKEN_DB:-./mcp_tokens.db}
+ # redis (S3/MySQL also available via their NAT packages):
+ host: ${REDIS_HOST:-localhost}
+ port: ${REDIS_PORT:-6379}
+ password: ${REDIS_PASSWORD}
+ # ttl: 2592000 # optional, seconds (both modes)
+
+workflow:
+ _type: chat_deepresearcher_agent
+ enable_escalation: true
+ enable_clarifier: true
+ use_async_deep_research: true
+ checkpoint_db: ${AIQ_CHECKPOINT_DB:-./checkpoints.db}
diff --git a/deploy/.env.example b/deploy/.env.example
index 03b5d7130..472f0f49f 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -47,6 +47,47 @@ DASK_DISTRIBUTED__LOGGING__DISTRIBUTED=warning
# -----------------------------------------------------------------------------
# AIQ_SUMMARY_DB=
+# -----------------------------------------------------------------------------
+# Per-user MCP auth token store (config_web_frag_mcp_auth.yml)
+# Shared, persistent store the API process (connect) and the Dask worker (job)
+# both reach to read/write per-user MCP tokens.
+#
+# MCP_TOKEN_STORE_TYPE selects the backend:
+# * aiq_sqlite (config default) — a SQLite file, no service required. Good for
+# local/single-host runs; only spans processes that share the filesystem.
+# Override the path with MCP_TOKEN_DB (default ./mcp_tokens.db).
+# * redis — networked, safe across replicas/hosts. Compose and Helm set this.
+#
+# Compose defaults to the bundled `redis` service, which is unauthenticated and
+# reachable only on the internal Docker network. For production, point these at a
+# managed Redis and set REDIS_PASSWORD — which also requires uncommenting
+# `password: ${REDIS_PASSWORD}` in config_web_frag_mcp_auth.yml.
+# -----------------------------------------------------------------------------
+# MCP_TOKEN_STORE_TYPE=redis
+# MCP_TOKEN_DB=./mcp_tokens.db
+# REDIS_HOST=redis
+# REDIS_PORT=6379
+# REDIS_PASSWORD=
+
+# -----------------------------------------------------------------------------
+# Per-user MCP source connection (config_web_frag_mcp_auth.yml gdrive source)
+# Required to actually CONNECT a protected source — separate from the token store
+# above. On connect, AIQ discovers the OAuth endpoints by reaching the MCP server,
+# so MCP_GDRIVE_URL must be set to a reachable server (the placeholder default is
+# not). Without these the gdrive card shows but connect fails with
+# "Source is not configured for MCP OAuth". Compose passes these in via env_file.
+#
+# MCP_GDRIVE_URL - the MCP server endpoint (must be reachable from the backend)
+# AIQ_PUBLIC_URL - public base URL of AIQ's API; used for the OAuth redirect_uri
+# (/v1/auth/mcp/gdrive/callback)
+# Optionally pin a pre-registered OAuth client instead of dynamic registration:
+# MCP_GDRIVE_CLIENT_ID / MCP_GDRIVE_CLIENT_SECRET
+# -----------------------------------------------------------------------------
+# MCP_GDRIVE_URL=https://your-mcp-server.example.com/mcp
+# AIQ_PUBLIC_URL=http://localhost:8000
+# MCP_GDRIVE_CLIENT_ID=
+# MCP_GDRIVE_CLIENT_SECRET=
+
# -----------------------------------------------------------------------------
# Environment Variables for Modal Sandbox for Skills Execution (optional)
# -----------------------------------------------------------------------------
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
index 0c5eb3916..3c13f928c 100644
--- a/deploy/Dockerfile
+++ b/deploy/Dockerfile
@@ -69,8 +69,14 @@ COPY configs/ ./configs/
# to avoid leaking .env, Helm charts, compose files, or other dev artifacts.
COPY deploy/entrypoint.py deploy/start_web.py ./deploy/
-# Install dependencies using uv sync
-RUN uv sync --frozen --no-dev --no-install-workspace
+# Install dependencies using uv sync. We do NOT pass --no-dev: the workspace
+# source packages (tavily-web-search, exa-web-search, ...) and their runtime
+# deps (e.g. langchain-tavily) are declared in the [dependency-groups] dev group,
+# so --no-dev would omit them and the editable source installs below would
+# import-fail at runtime. Syncing with the dev group installs those deps from the
+# frozen lock (still pinned, no re-resolve). TODO(deploy): move the source
+# packages to a dedicated non-dev group to keep test-only tooling out of the image.
+RUN uv sync --frozen
# Install workspace packages (without CLI for base)
RUN uv pip install --no-deps -e . \
diff --git a/deploy/compose/README.md b/deploy/compose/README.md
index 5d8704161..68a96293c 100644
--- a/deploy/compose/README.md
+++ b/deploy/compose/README.md
@@ -120,6 +120,29 @@ Services started:
- `aiq-blueprint-ui` (port 3000)
- `postgres` (port 5432)
+### Per-user MCP authentication
+
+Per-user MCP authentication is optional. To try it locally, set the MCP source and OAuth variables documented in `deploy/.env.example`, then apply the dedicated override with the default stack:
+
+```bash
+cd deploy/compose
+docker compose --env-file ../.env \
+ -f docker-compose.yaml \
+ -f docker-compose.per-user-auth.yaml \
+ up -d --build
+```
+
+The override selects `config_web_frag_mcp_auth.yml` and starts a persistent Redis service on the private Compose network. Redis is not published on a host port and has no password, so this stack is for local development only. Production deployments should use a managed Redis service and set `BACKEND_CONFIG`, `MCP_TOKEN_STORE_TYPE=redis`, `REDIS_HOST`, `REDIS_PORT`, and, when required, `REDIS_PASSWORD` in `deploy/.env` without applying the local override.
+
+Stop the local per-user-auth stack with the same file set:
+
+```bash
+docker compose --env-file ../.env \
+ -f docker-compose.yaml \
+ -f docker-compose.per-user-auth.yaml \
+ down
+```
+
## Foundational RAG (FRAG) prerequisites
If you switch the backend to `configs/config_web_frag.yml`, you must run a compatible RAG server and ingest server separately and set:
diff --git a/deploy/compose/docker-compose.per-user-auth.yaml b/deploy/compose/docker-compose.per-user-auth.yaml
new file mode 100644
index 000000000..5e9a60518
--- /dev/null
+++ b/deploy/compose/docker-compose.per-user-auth.yaml
@@ -0,0 +1,31 @@
+# Optional local stack for configs/config_web_frag_mcp_auth.yml.
+# Apply this file together with docker-compose.yaml; it is not standalone.
+
+services:
+ aiq-agent:
+ environment:
+ CONFIG_FILE: /app/configs/config_web_frag_mcp_auth.yml
+ MCP_TOKEN_STORE_TYPE: redis
+ REDIS_HOST: redis
+ REDIS_PORT: "6379"
+ depends_on:
+ redis:
+ condition: service_healthy
+
+ redis:
+ image: redis:7-alpine
+ command: ["redis-server", "--appendonly", "yes"]
+ volumes:
+ - redis-data:/data
+ networks:
+ - aiq-network
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+volumes:
+ redis-data:
+ driver: local
diff --git a/deploy/compose/docker-compose.yaml b/deploy/compose/docker-compose.yaml
index 81fd0c2eb..77335d041 100644
--- a/deploy/compose/docker-compose.yaml
+++ b/deploy/compose/docker-compose.yaml
@@ -140,7 +140,6 @@ volumes:
driver: local
postgres-data:
driver: local
-
networks:
aiq-network:
driver: bridge
diff --git a/deploy/helm/README.md b/deploy/helm/README.md
index adf442391..9d5004781 100644
--- a/deploy/helm/README.md
+++ b/deploy/helm/README.md
@@ -196,6 +196,7 @@ The backend loads a workflow config at startup. Switch configs with `--set`:
|-------------|-------------|
| `configs/config_web_default_llamaindex.yml` | Default — LlamaIndex backend (no external RAG required) |
| `configs/config_web_frag.yml` | Foundational RAG mode (requires a running RAG service) |
+| `configs/config_web_frag_mcp_auth.yml` | Foundational RAG with optional per-user MCP authentication |
```bash
helm upgrade --install aiq aiq2-web-2.0.0.tgz -n ns-aiq \
@@ -206,6 +207,36 @@ helm upgrade --install aiq aiq2-web-2.0.0.tgz -n ns-aiq \
--set aiq.apps.backend.env.CONFIG_FILE=configs/config_web_frag.yml
```
+### Per-user MCP authentication with external Redis
+
+The chart does not install Redis. When selecting the per-user authentication config, provide a Redis service that the backend and its workers can both reach. The deployer owns its availability, persistence, networking, and backup.
+
+Add `REDIS_PASSWORD` to the existing `aiq-credentials` Secret when the Redis service requires authentication, then create a values file:
+
+```yaml
+# aiq-per-user-auth-values.yaml
+aiq:
+ apps:
+ backend:
+ env:
+ CONFIG_FILE: configs/config_web_frag_mcp_auth.yml
+ MCP_TOKEN_STORE_TYPE: redis
+ REDIS_HOST: redis.example.com
+ REDIS_PORT: "6379"
+ secretEnv:
+ REDIS_PASSWORD: REDIS_PASSWORD
+```
+
+Apply it to either the downloaded chart or a source-chart installation:
+
+```bash
+helm upgrade --install aiq aiq2-web-2.0.0.tgz -n ns-aiq \
+ --wait --timeout 10m \
+ -f aiq-per-user-auth-values.yaml
+```
+
+Configure `MCP_GDRIVE_URL`, `AIQ_PUBLIC_URL`, and any OAuth client credentials required by the protected MCP source through the same `env` and `secretEnv` maps. NAT 1.8's Redis object store in this image supports host, port, database, and optional password; this example does not support TLS, ACL usernames, Sentinel, or Redis Cluster.
+
## FRAG Integration
To use the Foundational RAG (FRAG) config, you need a running NVIDIA RAG Blueprint deployment. See the [RAG Blueprint Helm deployment guide](https://github.com/NVIDIA-AI-Blueprints/rag/blob/develop/docs/deploy-helm.md) for setup instructions.
diff --git a/docs/source/customization/mcp-tools.md b/docs/source/customization/mcp-tools.md
index 1c4d60c2d..2dfe2b520 100644
--- a/docs/source/customization/mcp-tools.md
+++ b/docs/source/customization/mcp-tools.md
@@ -8,22 +8,20 @@ Model Context Protocol (MCP) is an open protocol that standardizes how applicati
context to LLM applications. The AIQ Blueprint is built on the NVIDIA NeMo Agent toolkit (NAT), so
AIQ can use MCP servers as data sources through NAT function groups.
-This guide is written for AIQ 2.1 deployments running NAT `1.6.0` or later. Verify your installed
-version with `uv pip show nvidia-nat`.
+This guide targets AIQ deployments pinned to NAT `1.8.0`. Verify your installed version with
+`uv pip show nvidia-nat`.
## What this guide covers
-**Supported in AIQ 2.1:**
+**Supported:**
- Connect AIQ to an unauthenticated MCP server.
- Connect AIQ to an MCP server with backend service-account credentials.
+- Connect each signed-in user to a protected MCP server through the AIQ UI using MCP OAuth.
- Forward the signed-in AIQ user's identity to a downstream service from a custom AIQ tool.
-**Planned for AIQ 2.2 / 2.3:**
+**Not yet first-party:**
-- Native per-user MCP OAuth driven by the AIQ UI. NAT 1.6 ships the protocol-level support
- (`mcp_oauth2`, `per_user_mcp_client`); the AIQ UI cannot yet drive per-MCP consent. See the
- short [planning note](#per-user-mcp-oauth-planned) below.
- A first-party AIQ-token pass-through MCP auth provider. Today, if your MCP server trusts the AIQ
user's bearer token, you must implement and register a custom NAT auth provider in your
deployment package.
@@ -41,14 +39,14 @@ For the full NAT MCP reference:
| MCP server has no per-user auth | `mcp_client` function group | [Connect AIQ to an MCP Server](#connect-aiq-to-an-mcp-server) |
| MCP server uses backend / app credentials | `mcp_client` + `mcp_service_account` | [Service-Account MCP Servers](#service-account-mcp-servers) |
| Downstream API trusts the AIQ user's bearer token | Custom AIQ tool using `get_auth_token()` | [Forwarding AIQ User Identity](#forwarding-aiq-user-identity-from-a-tool) |
-| MCP server requires per-user OAuth consent | Planned for AIQ 2.2 / 2.3 | [Per-User MCP OAuth (planned)](#per-user-mcp-oauth-planned) |
+| MCP server requires per-user OAuth consent | `per_user_mcp_client` + `mcp_oauth2` | [Per-User MCP OAuth](#per-user-mcp-oauth) |
## Prerequisites
Install NAT and the MCP package on the same release line as your AIQ deployment:
```bash
-uv pip install "nvidia-nat[mcp]==1.6.0" nvidia-nat-mcp==1.6.0
+uv pip install "nvidia-nat[mcp]==1.8.0" nvidia-nat-mcp==1.8.0 nvidia-nat-redis==1.8.0
```
Keep `nvidia-nat`, `nvidia-nat-core`, `nvidia-nat-eval`, and `nvidia-nat-mcp` on the same release
@@ -276,34 +274,59 @@ functions:
endpoint: ${INTERNAL_SEARCH_URL}
```
-This is the AIQ-user-identity MCP pattern fully supported in 2.1. The two alternatives —
+This is the supported AIQ-user-identity MCP pattern. The two alternatives —
protocol-level pass-through via a custom NAT auth provider, and an auth-forwarding MCP proxy — are
-viable in NAT but are not first-class in AIQ 2.1; treat them as deployment-side extensions.
+viable in NAT but are not first-class in AIQ; treat them as deployment-side extensions.
For the broader auth context (UI sign-in flow, validator registration, headless API callers), see
[Authentication](../deployment/authentication.md).
-## Per-User MCP OAuth (planned)
-
-NAT 1.6 ships the protocol-level building blocks for per-user MCP OAuth — `mcp_oauth2` (auth
-provider for MCP OAuth flows) and `per_user_mcp_client` (function group with per-user token
-storage). What AIQ 2.1 **does not yet** ship is the UI integration that drives this flow: the
-data-source API does not return per-MCP auth status, connect / disconnect URLs, scopes, or token
-expiry, and the UI has no per-source "Connect" / "Reconnect" controls.
-
-Until that lands, the recommended patterns for AIQ deployments remain:
-
-- **Service-account MCP** when the access can be shared at the application level.
-- **AIQ user-identity tools** (the section above) when the downstream service trusts the AIQ
- bearer token.
-
-Beyond the UI, two further gaps exist in 2.1: AIQ's `/v1/data_sources` does not surface per-MCP
-auth status (connect URL, scopes, token expiry, error state), and async deep research jobs cannot
-yet resolve per-user MCP tokens inside Dask workers. The full per-user MCP OAuth integration —
-backend status APIs, UI controls, and worker-side token resolution — is tracked on the AIQ 2.2 /
-2.3 roadmap. Refer to the
+## Per-User MCP OAuth
+
+Use per-user MCP OAuth when each AIQ user must authorize the upstream MCP server with their own
+identity. The reference configuration is
+[`configs/config_web_frag_mcp_auth.yml`](../../../configs/config_web_frag_mcp_auth.yml). It combines
+an OAuth-protected data source, NAT's `per_user_mcp_client`, an `mcp_oauth2` provider, and a shared
+token object store.
+
+Set these values before starting AIQ:
+
+- `MCP_GDRIVE_URL`: protected streamable-HTTP MCP endpoint. The example calls the source `gdrive`,
+ but the mechanism is not Google Drive-specific.
+- `AIQ_PUBLIC_URL`: externally reachable AIQ origin used to construct the OAuth callback URL.
+- `MCP_GDRIVE_CLIENT_ID` and `MCP_GDRIVE_CLIENT_SECRET`: only when the MCP authorization server
+ requires a pre-registered OAuth client rather than dynamic client registration.
+- `MCP_TOKEN_STORE_TYPE`: `aiq_sqlite` for a single-host example or `redis` for multi-process and
+ multi-host deployments.
+
+The UI reads connection status from `/v1/data_sources` and presents Connect, Reconnect, and
+Disconnect actions for the protected source. AIQ owns the OAuth callback and stores the resulting
+token under the current AIQ user identity. Submitting a job with a disconnected protected source
+fails with `409 mcp_auth_required`; AIQ does not silently run the job without that source.
+
+Both interactive WebSocket sessions and REST-submitted async jobs resolve the user's MCP tools.
+Async workers open their own MCP client for the job and read the same token store as the API, so
+the API and workers must share that store:
+
+- `aiq_sqlite` requires the API and worker processes to share the same absolute database path on
+ one host.
+- `redis` is the supported example for multiple processes, hosts, or Kubernetes pods. Each
+ protected source must reference an object store with a distinct bucket or namespace;
+ configuration fails closed when two sources reference the same object-store configuration.
+
+This example targets the AIQ web/API deployment, which owns the connect and callback routes and
+supplies user identity to jobs. Raw NAT CLI runs do not provide that browser OAuth lifecycle; use
+an unauthenticated or service-account MCP configuration for standalone CLI execution.
+
+For a local Redis-backed stack, use the
+[per-user-auth Compose override](https://github.com/NVIDIA-AI-Blueprints/aiq/blob/develop/deploy/compose/README.md#per-user-mcp-authentication).
+For a released chart, provide an external Redis service as described in the
+[Helm deployment guide](https://github.com/NVIDIA-AI-Blueprints/aiq/blob/develop/deploy/helm/README.md#per-user-mcp-authentication-with-external-redis).
+The default Compose and Helm deployments remain Redis-free when this example is not selected.
+
+See the
[NAT MCP authentication guide](https://docs.nvidia.com/nemo/agent-toolkit/latest/components/auth/mcp-auth/index.html)
-if you want to follow NAT's MCP OAuth surface directly.
+for protocol details.
## Security Guidance
@@ -312,9 +335,11 @@ if you want to follow NAT's MCP OAuth surface directly.
- Store secrets in environment variables or a secret manager, not in YAML checked into source
control.
- Use service-account MCP auth only when shared app-level access is acceptable.
+- Use `per_user_auth: true` for upstream MCP OAuth; `requires_auth: true` only gates a source on
+ AIQ sign-in and does not authorize the upstream MCP server.
- Keep token forwarding scoped to trusted internal services and HTTPS endpoints.
-- Mark user-authenticated data sources with `requires_auth: true` so the UI can prevent
- unauthenticated use.
+- Use `requires_auth: true` for sources that depend on AIQ sign-in but do not have a separate
+ upstream OAuth connection.
## Troubleshooting
diff --git a/frontends/aiq_api/src/aiq_api/auth/middleware.py b/frontends/aiq_api/src/aiq_api/auth/middleware.py
index cf87821ac..f635f4e2d 100644
--- a/frontends/aiq_api/src/aiq_api/auth/middleware.py
+++ b/frontends/aiq_api/src/aiq_api/auth/middleware.py
@@ -193,12 +193,24 @@ def build_request_trace_tags(
"/v1/jobs/async/agents",
"/v1/jobs/async/submit",
"/v1/jobs/async/job/", # prefix — matches /v1/jobs/async/job/{id}/*
+ "/v1/auth/mcp/", # prefix — per-user MCP auth: {id}/status, {id}/connect, {id}/callback
]
# External paths that require no token (monitoring, etc.)
AUTH_EXEMPT_PATHS: set[str] = {"/health", "/docs", "/redoc", "/openapi.json"}
+def _is_oauth_callback_path(path: str) -> bool:
+ """The MCP OAuth redirect callback (``/v1/auth/mcp/{source_id}/callback``).
+
+ It must be auth-exempt: the provider redirects the user's browser here with no
+ AIQ token. It is secured by the unguessable OAuth ``state`` (bound to the
+ principal + source when the flow was started via /connect), not by a request
+ token. ``/status`` and ``/connect`` are NOT exempt — they need the principal.
+ """
+ return path.startswith("/v1/auth/mcp/") and path.endswith("/callback")
+
+
def _load_external_hostnames() -> set[str]:
"""Read ``AIQ_EXTERNAL_HOSTNAMES`` env var; fall back to staging hostname."""
env = os.getenv("AIQ_EXTERNAL_HOSTNAMES", "")
@@ -388,7 +400,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await self._send_json(send, 404, {"detail": "Not found"})
return
- if is_external and path in AUTH_EXEMPT_PATHS:
+ if is_external and (path in AUTH_EXEMPT_PATHS or _is_oauth_callback_path(path)):
user = {"type": "anonymous", "skip_clarifier": True}
await self._call_app(scope, receive, send, headers, user)
return
diff --git a/frontends/aiq_api/src/aiq_api/jobs/runner.py b/frontends/aiq_api/src/aiq_api/jobs/runner.py
index dbdb531cc..40c00eb3d 100644
--- a/frontends/aiq_api/src/aiq_api/jobs/runner.py
+++ b/frontends/aiq_api/src/aiq_api/jobs/runner.py
@@ -449,6 +449,7 @@ async def run_agent_job(
auth_token: str | None = None,
initial_files: dict[str, Any] | None = None,
output_metadata: dict[str, Any] | None = None,
+ owner_user_id: str | None = None,
):
"""
Dask task to run any registered agent with cancellation support and telemetry.
@@ -482,6 +483,9 @@ async def run_agent_job(
data sources that require authentication (requires_auth: true).
initial_files: Optional DeepAgents virtual filesystem files to seed into state.
output_metadata: Optional metadata to persist alongside the final report.
+ owner_user_id: Canonical per-user key (``principal_user_id``), set on the NAT
+ Context so per_user_mcp_client retrieves the token the owner connected
+ via /v1/auth/mcp/{id}/connect.
"""
# Propagate auth token into the current async task's context so tools
@@ -559,6 +563,14 @@ async def run_agent_job(
provider, llm = await _create_llm_provider(builder, fn_config)
+ # Bind the job owner's identity on the NAT context before tools are built,
+ # so per_user_mcp_client resolves the token this user connected via
+ # /v1/auth/mcp/{id}/connect (keyed by principal_user_id).
+ if owner_user_id:
+ from nat.builder.context import ContextState
+
+ ContextState.get().user_id.set(owner_user_id)
+
# Resolve tools: use explicit list or auto-inherit from data_source_registry
tool_refs = fn_config.tools
if not tool_refs:
@@ -680,41 +692,57 @@ async def run_agent_job(
callbacks.append(AgentEventCallback(event_store))
callbacks.append(nat_profiler_callback)
- # Instantiate agent with callbacks
- agent = _create_agent_instance(
- agent_cls=agent_cls,
- llm_provider=provider,
- llm=llm,
- tools=tools,
- fn_config=fn_config,
- verbose=verbose,
- callbacks=callbacks,
- job_id=job_id,
- # Artifact harvesting rides 284's job store + event stream: the same db_url
- # backs the SqlArtifactStore, and event_store.store carries artifact SSE
- # events. Inert unless sandbox.artifact_capture is enabled in config.
- artifact_db_url=db_url,
- artifact_emit=event_store.store,
- )
+ # Resolve per-user MCP source tools for the job owner (Context.user_id
+ # set above); connections stay open via mcp_stack for the agent run.
+ # Best-effort: the helper never raises, so this can't break a job.
+ from contextlib import AsyncExitStack
- # Capture the runtime so the terminal path can release the sandbox. None for
- # agents without a sandbox runtime; close()/terminate() are then no-ops.
- sandbox_runtime = getattr(agent, "deepagents_runtime", None)
-
- # Run agent - LLM/tool events will be nested under workflow span
- result = await _run_agent(
- agent=agent,
- input_text=input_text,
- builder=builder,
- config=config,
- function_name=agent_config_name,
- function_config=fn_config,
- monitor=cancellation_monitor,
- available_documents=available_documents,
- data_sources=data_sources,
- event_store=event_store,
- initial_files=initial_files,
- )
+ from ..mcp_auth.runtime_tools import open_per_user_mcp_tools
+
+ async with AsyncExitStack() as mcp_stack:
+ mcp_tools = await open_per_user_mcp_tools(
+ builder=builder,
+ data_sources=data_sources,
+ exit_stack=mcp_stack,
+ wrapper_type=LLMFrameworkEnum.LANGCHAIN,
+ )
+ agent_tools = [*tools, *mcp_tools] if mcp_tools else tools
+
+ # Instantiate agent with callbacks
+ agent = _create_agent_instance(
+ agent_cls=agent_cls,
+ llm_provider=provider,
+ llm=llm,
+ tools=agent_tools,
+ fn_config=fn_config,
+ verbose=verbose,
+ callbacks=callbacks,
+ job_id=job_id,
+ # Artifact harvesting rides 284's job store + event stream: the same db_url
+ # backs the SqlArtifactStore, and event_store.store carries artifact SSE
+ # events. Inert unless sandbox.artifact_capture is enabled in config.
+ artifact_db_url=db_url,
+ artifact_emit=event_store.store,
+ )
+
+ # Capture the runtime so the terminal path can release the sandbox. None for
+ # agents without a sandbox runtime; close()/terminate() are then no-ops.
+ sandbox_runtime = getattr(agent, "deepagents_runtime", None)
+
+ # Run agent - LLM/tool events will be nested under workflow span
+ result = await _run_agent(
+ agent=agent,
+ input_text=input_text,
+ builder=builder,
+ config=config,
+ function_name=agent_config_name,
+ function_config=fn_config,
+ monitor=cancellation_monitor,
+ available_documents=available_documents,
+ data_sources=data_sources,
+ event_store=event_store,
+ initial_files=initial_files,
+ )
# Emit WORKFLOW_END event for Phoenix
context.intermediate_step_manager.push_intermediate_step(
diff --git a/frontends/aiq_api/src/aiq_api/jobs/submit.py b/frontends/aiq_api/src/aiq_api/jobs/submit.py
index d79e2597f..7f93e5bda 100644
--- a/frontends/aiq_api/src/aiq_api/jobs/submit.py
+++ b/frontends/aiq_api/src/aiq_api/jobs/submit.py
@@ -31,6 +31,7 @@
from aiq_agent.auth import Principal
from aiq_agent.auth import get_current_principal
from aiq_api.auth import get_current_trace_tags
+from aiq_api.mcp_auth.provider import principal_user_id
from ..registry import get_agent_config
from .access import _make_no_auth_principal
@@ -252,6 +253,21 @@ async def submit_agent_job(
if principal is None:
raise RuntimeError("Verified current principal required for async job submission")
+ # Preflight protected MCP sources before enqueue. The REST submit route also
+ # does this (returning 409), but programmatic submitters — notably the chat
+ # researcher's async deep-research path — call this directly and would otherwise
+ # bypass the check, so this is the single chokepoint both paths share. Skipped
+ # when no MCP auth provider is active in this process (nothing to enforce).
+ from aiq_api.mcp_auth.active import get_active_mcp_auth_provider
+ from aiq_api.mcp_auth.preflight import McpAuthRequiredError
+ from aiq_api.mcp_auth.preflight import evaluate_mcp_auth
+
+ mcp_provider = get_active_mcp_auth_provider()
+ if mcp_provider is not None:
+ block = await evaluate_mcp_auth(mcp_provider, principal, data_sources)
+ if block is not None:
+ raise McpAuthRequiredError(block)
+
job_store = JobStore(scheduler_address=scheduler_address, db_url=db_url)
resolved_job_id = job_store.ensure_job_id(job_id)
loop = asyncio.get_running_loop()
@@ -293,6 +309,7 @@ async def _rollback_partial_submission() -> None:
auth_token,
initial_files,
output_metadata,
+ principal_user_id(principal),
],
)
except IntegrityError as e:
diff --git a/frontends/aiq_api/src/aiq_api/mcp_auth/__init__.py b/frontends/aiq_api/src/aiq_api/mcp_auth/__init__.py
new file mode 100644
index 000000000..998cb76e3
--- /dev/null
+++ b/frontends/aiq_api/src/aiq_api/mcp_auth/__init__.py
@@ -0,0 +1,49 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Per-user MCP auth control plane for AIQ.
+
+AIQ owns the product control plane (status surfacing, connect actions,
+structured auth-required responses, submit-time preflight). The actual OAuth
+mechanics and per-user token storage are NAT's. AIQ mints the provider
+authorization URL and completes the callback using NAT's public OAuth/token
+primitives, then writes the resulting token into the *same* NAT token storage
+that the headless job-time ``per_user_mcp_client`` reads from. See
+``aiq-nat-oauth-execution-gap`` design notes.
+"""
+
+from .models import McpAuthRequiredResponse
+from .models import McpAuthRequiredSource
+from .models import PerUserAuthInfo
+from .models import SourceAuthStatusResponse
+from .models import SourceConnectResponse
+from .provider import AuthStatus
+from .provider import ProtectedSourceAuthProvider
+from .provider import SourceAuthChallenge
+from .provider import SourceAuthState
+from .provider import principal_user_id
+
+__all__ = [
+ "AuthStatus",
+ "McpAuthRequiredResponse",
+ "McpAuthRequiredSource",
+ "PerUserAuthInfo",
+ "ProtectedSourceAuthProvider",
+ "SourceAuthChallenge",
+ "SourceAuthState",
+ "SourceAuthStatusResponse",
+ "SourceConnectResponse",
+ "principal_user_id",
+]
diff --git a/frontends/aiq_api/src/aiq_api/mcp_auth/active.py b/frontends/aiq_api/src/aiq_api/mcp_auth/active.py
new file mode 100644
index 000000000..f25c46b96
--- /dev/null
+++ b/frontends/aiq_api/src/aiq_api/mcp_auth/active.py
@@ -0,0 +1,44 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Process-global handle to the per-user MCP auth provider.
+
+The provider is built once at route registration in the API process. Both the
+REST submit route and programmatic submitters (e.g. the chat researcher's async
+deep-research submit) run in that same process, so they can reach the live
+provider through this module to run the connect-state preflight before enqueue.
+
+It is intentionally a simple module-global: there is one provider per API
+process and no per-request state. Returns ``None`` when MCP auth was never
+registered (e.g. unit tests that call ``submit_agent_job`` directly), in which
+case callers skip the preflight — there is nothing to enforce.
+"""
+
+from __future__ import annotations
+
+from .provider import ProtectedSourceAuthProvider
+
+_active_provider: ProtectedSourceAuthProvider | None = None
+
+
+def set_active_mcp_auth_provider(provider: ProtectedSourceAuthProvider | None) -> None:
+ """Register the provider built at route setup as the process-wide instance."""
+ global _active_provider
+ _active_provider = provider
+
+
+def get_active_mcp_auth_provider() -> ProtectedSourceAuthProvider | None:
+ """Return the registered provider, or ``None`` if MCP auth is not configured."""
+ return _active_provider
diff --git a/frontends/aiq_api/src/aiq_api/mcp_auth/factory.py b/frontends/aiq_api/src/aiq_api/mcp_auth/factory.py
new file mode 100644
index 000000000..274095221
--- /dev/null
+++ b/frontends/aiq_api/src/aiq_api/mcp_auth/factory.py
@@ -0,0 +1,265 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Build a :class:`NatMcpAuthProvider` from the registry + NAT's mcp_oauth2 config.
+
+The NAT ``authentication: mcp_oauth2`` provider is the single source of truth.
+A protected source's registry entry points at it via ``per_user_auth.auth_provider``
+(falling back to ``mcp_server_id``); AIQ resolves that provider through the
+builder and derives:
+
+ * the shared token storage (from ``token_storage_object_store``) — so the token
+ AIQ writes at connect time is the same one the job-time ``per_user_mcp_client``
+ reads;
+ * ``redirect_uri`` / ``scopes`` / ``client_id`` / ``use_pkce``;
+ * the authorize/token endpoints, via NAT's own discovery (RFC 8414 / RFC 9728
+ well-known), reusing NAT rather than a parallel config surface.
+
+Deployment notes for the cross-process design:
+ 1. ``token_storage_object_store`` must name a shared, persistent object store
+ (the API process writes the token; a separate worker process reads it).
+ This is REQUIRED.
+ 2. ``client_id`` (manual registration) is OPTIONAL. Without it, NAT performs
+ dynamic client registration (DCR) — the connect flow works, but the refresh
+ token is bound to the connect-process's registered client, so the worker
+ cannot silently refresh it. That degrades gracefully to the "expired ->
+ Reconnect" UX. Set a fixed (e.g. ECI public) ``client_id`` only if you want
+ silent cross-process refresh.
+
+Endpoint discovery runs once at startup per source and is guarded: if it fails
+(server unreachable, no well-known), the source is left unconfigured and its
+status surfaces as ``error`` rather than crashing route registration.
+"""
+
+from __future__ import annotations
+
+import logging
+
+import httpx
+
+from aiq_agent.common.data_source_registry import get_all_sources
+from nat.authentication.token_storage import InMemoryTokenStorage
+from nat.authentication.token_storage import ObjectStoreTokenStorage
+from nat.authentication.token_storage import TokenStorageBase
+
+from .nat_provider import NatMcpAuthProvider
+from .nat_provider import OAuthSourceSettings
+
+logger = logging.getLogger(__name__)
+
+
+async def _resolve_token_storage(builder, cfg, source_id: str) -> TokenStorageBase:
+ """Resolve the token storage for a source from the mcp_oauth2 config.
+
+ Uses the configured object store (shared across processes) when present;
+ otherwise falls back to a process-local in-memory store with a loud warning
+ (dev only — tokens will not be visible to job workers).
+ """
+ object_store_name = getattr(cfg, "token_storage_object_store", None)
+ if object_store_name:
+ object_store = await builder.get_object_store_client(object_store_name)
+ return ObjectStoreTokenStorage(object_store)
+ logger.warning(
+ "Source '%s': mcp_oauth2 provider has no token_storage_object_store; using a process-local "
+ "in-memory token store. Tokens connected via the API will NOT be visible to job workers. "
+ "Set token_storage_object_store to a shared object store for real deployments.",
+ source_id,
+ )
+ return InMemoryTokenStorage()
+
+
+async def _probe_for_oauth_challenge(server_url: str) -> httpx.Response | None:
+ """Send an unauthenticated MCP request to elicit the 401 + WWW-Authenticate.
+
+ MCP servers point at their authorization server via the 401's RFC 9728
+ ``resource_metadata`` hint. Returns the 401 response (for NAT discovery) or
+ None if the server didn't challenge (NAT then falls back to well-known).
+ """
+ if not server_url:
+ return None
+ try:
+ async with httpx.AsyncClient(timeout=15) as client:
+ response = await client.post(
+ server_url,
+ json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
+ headers={"Accept": "application/json, text/event-stream"},
+ )
+ if response.status_code == 401:
+ return response
+ logger.debug("MCP probe of %s returned %s (no 401 challenge)", server_url, response.status_code)
+ except Exception as exc:
+ logger.warning("MCP auth probe failed for %s: %s", server_url, exc)
+ return None
+
+
+async def _resolve_oauth_settings(source_id: str, pua, nat_provider, cfg) -> OAuthSourceSettings | None:
+ """Derive AIQ-side OAuth settings from the NAT provider, discovering endpoints.
+
+ Returns None (source left unconfigured -> status 'error') if endpoints or a
+ client_id cannot be resolved.
+
+ NAT private-API surface (review on every nvidia-nat upgrade; pinned to 1.8.0).
+ All accesses below are defensive (``getattr`` defaults + the surrounding
+ ``try/except``), so a renamed/removed member degrades to status 'error' with a
+ warning rather than crashing:
+
+ * ``nat_provider._discover_and_register(response=...)`` -- coroutine; runs
+ well-known discovery + RFC 7591 dynamic client registration, populating the
+ cached members below.
+ * ``nat_provider._cached_endpoints`` -- object | None; ``.authorization_url``
+ and ``.token_url`` (str-able).
+ * ``nat_provider._cached_credentials`` -- object | None; ``.client_id`` and
+ ``.client_secret`` (str).
+ * ``nat_provider._effective_scopes`` -- Iterable[str] | None; scopes resolved
+ from protected-resource metadata.
+ * ``nat_provider._discoverer`` -- object | None; ``._resource_from_metadata``
+ (str | None) is the RFC 9728 resource identifier.
+ """
+ redirect_uri = str(cfg.redirect_uri) if getattr(cfg, "redirect_uri", None) else ""
+ scopes = list(getattr(cfg, "scopes", None) or [])
+ client_id = getattr(cfg, "client_id", None)
+ client_secret = getattr(cfg, "client_secret", None)
+ server_url = str(getattr(cfg, "server_url", "") or "")
+ # OAuth resource indicator (RFC 8707/9728); default to the server URL, matching
+ # NAT's `resource = _resource_from_metadata or server_url`.
+ resource = server_url or None
+
+ authorization_url = token_url = None
+ # Reuse NAT's discovery (well-known metadata + RFC 7591 DCR when no client_id
+ # is set). This module is the explicit NAT integration seam, so reaching
+ # discovery internals is acceptable (version-pinned to the resolved nat release).
+ # MCP servers (e.g. NVIDIA MaaS) advertise their AS via the 401 WWW-Authenticate
+ # header (RFC 9728 resource_metadata), not the root well-known, so we probe for
+ # that 401 first; without it unauthenticated discovery cannot locate the AS.
+ try:
+ challenge = await _probe_for_oauth_challenge(str(getattr(cfg, "server_url", "") or ""))
+ await nat_provider._discover_and_register(response=challenge) # noqa: SLF001 — NAT seam
+ endpoints = getattr(nat_provider, "_cached_endpoints", None)
+ credentials = getattr(nat_provider, "_cached_credentials", None)
+ if endpoints is not None:
+ authorization_url = str(endpoints.authorization_url)
+ token_url = str(endpoints.token_url)
+ if credentials is not None and not client_id:
+ client_id = credentials.client_id
+ client_secret = credentials.client_secret
+ # Prefer scopes resolved by discovery (protected-resource metadata) when
+ # the config didn't pin any.
+ discovered_scopes = getattr(nat_provider, "_effective_scopes", None)
+ if discovered_scopes:
+ scopes = list(discovered_scopes)
+ # RFC 9728 resource identifier from protected-resource metadata, mirroring
+ # NAT: `_discoverer._resource_from_metadata or server_url` (auth_provider.py).
+ discoverer = getattr(nat_provider, "_discoverer", None)
+ resource = getattr(discoverer, "_resource_from_metadata", None) or resource
+ except Exception as exc:
+ logger.warning("Source '%s': OAuth endpoint discovery failed: %s", source_id, exc)
+
+ if not (authorization_url and token_url and client_id):
+ logger.warning(
+ "Source '%s': could not resolve OAuth endpoints/client_id from provider '%s'; "
+ "connect will be unavailable. Ensure the mcp_oauth2 server is reachable and client_id is set.",
+ source_id,
+ pua.auth_provider or pua.mcp_server_id or source_id,
+ )
+ return None
+
+ return OAuthSourceSettings(
+ source_id=source_id,
+ mcp_server_id=pua.mcp_server_id or source_id,
+ provider=pua.provider,
+ authorization_url=authorization_url,
+ token_url=token_url,
+ client_id=client_id,
+ client_secret=client_secret,
+ scopes=scopes,
+ redirect_uri=redirect_uri,
+ use_pkce=bool(getattr(cfg, "use_pkce", True)),
+ token_endpoint_auth_method=(getattr(cfg, "token_endpoint_auth_method", None) or "client_secret_post"),
+ resource=resource,
+ )
+
+
+async def build_mcp_auth_provider(builder) -> NatMcpAuthProvider:
+ """Construct the provider from registry metadata + NAT mcp_oauth2 config."""
+ settings_by_source: dict[str, OAuthSourceSettings] = {}
+ storages: dict[str, TokenStorageBase] = {}
+ # Guard against credential cross-contamination: NAT's ObjectStoreTokenStorage
+ # keys tokens by user only (``tokens/{sha256(user_id)}``), so two protected
+ # sources sharing one token-storage object store would overwrite each other's
+ # credentials for the same user. AIQ can't fix this with a per-source key
+ # prefix, because NAT's job-time per_user_mcp_client reads the token itself via
+ # the source's mcp_oauth2 provider (unprefixed) — a prefix here would desync
+ # that read. So each protected source needs its OWN token-storage bucket; fail
+ # closed (skip the later source) when one is reused rather than silently
+ # clobbering tokens.
+ claimed_stores: dict[str, str] = {} # object_store name -> first source_id to claim it
+
+ for source in get_all_sources():
+ pua = source.per_user_auth
+ if pua is None or not pua.required:
+ continue
+ ref = pua.auth_provider or pua.mcp_server_id or source.id
+ try:
+ nat_provider = await builder.get_auth_provider(ref)
+ except Exception as exc:
+ logger.warning(
+ "Source '%s' declares per_user_auth but NAT auth provider '%s' is not configured: %s",
+ source.id,
+ ref,
+ exc,
+ )
+ continue
+
+ cfg = getattr(nat_provider, "config", None)
+ if cfg is None:
+ logger.warning("Source '%s': auth provider '%s' has no config; skipping", source.id, ref)
+ continue
+
+ object_store_name = getattr(cfg, "token_storage_object_store", None)
+ if object_store_name:
+ prior = claimed_stores.get(object_store_name)
+ if prior is not None:
+ logger.error(
+ "Source '%s' shares token_storage_object_store '%s' with source '%s'. NAT keys tokens "
+ "per user only, so sharing one store lets these sources overwrite each other's "
+ "credentials. Give each protected source its own token-storage object store (distinct "
+ "bucket). Skipping '%s' — it will surface as unconfigured until this is resolved.",
+ source.id,
+ object_store_name,
+ prior,
+ source.id,
+ )
+ continue
+ claimed_stores[object_store_name] = source.id
+
+ try:
+ storage = await _resolve_token_storage(builder, cfg, source.id)
+ except Exception as exc:
+ logger.error("Source '%s': could not resolve token storage: %s", source.id, exc)
+ continue
+
+ settings = await _resolve_oauth_settings(source.id, pua, nat_provider, cfg)
+ if settings is None:
+ continue
+
+ settings_by_source[source.id] = settings
+ storages[source.id] = storage
+
+ if settings_by_source:
+ logger.info("MCP auth configured for sources: %s", ", ".join(sorted(settings_by_source)))
+ return NatMcpAuthProvider(
+ settings_by_source=settings_by_source,
+ token_storage_resolver=lambda s: storages[s.source_id],
+ )
diff --git a/frontends/aiq_api/src/aiq_api/mcp_auth/models.py b/frontends/aiq_api/src/aiq_api/mcp_auth/models.py
new file mode 100644
index 000000000..c31ed0e54
--- /dev/null
+++ b/frontends/aiq_api/src/aiq_api/mcp_auth/models.py
@@ -0,0 +1,88 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""API response models for per-user MCP auth."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel
+from pydantic import Field
+
+AuthStatusLiteral = Literal["connected", "not_connected", "expired", "error"]
+
+
+class PerUserAuthInfo(BaseModel):
+ """Per-user MCP auth block attached to a data source in API responses.
+
+ Combines the static declaration (``required``/``type``/``provider``/
+ ``mcp_server_id`` from the registry) with the current user's dynamic state
+ (``status``/``expires_at``/``last_error``) and the action URLs.
+
+ ``connect_url`` is a stable AIQ API surface. ``auth_url`` is a short-lived
+ provider/NAT login URL and is only populated when AIQ has intentionally
+ started or reused an auth challenge — never from a read-only listing.
+ """
+
+ required: bool = False
+ type: Literal["mcp_oauth2"] = "mcp_oauth2"
+ provider: str | None = None
+ mcp_server_id: str | None = None
+ status: AuthStatusLiteral | None = None
+ connect_url: str | None = None
+ auth_url: str | None = None
+ expires_at: datetime | None = None
+ last_error: str | None = None
+
+
+class SourceAuthStatusResponse(BaseModel):
+ """Response for ``GET /v1/auth/mcp/{source_id}/status``."""
+
+ source_id: str
+ status: AuthStatusLiteral
+ expires_at: datetime | None = None
+ connect_url: str | None = None
+ last_error: str | None = None
+
+
+class SourceConnectResponse(BaseModel):
+ """Response for ``POST /v1/auth/mcp/{source_id}/connect``."""
+
+ source_id: str
+ status: Literal["auth_required", "connected"] = "auth_required"
+ auth_url: str | None = None
+ expires_at: datetime | None = Field(
+ default=None,
+ description="Expiry of the auth challenge (auth_url), not of the eventual token",
+ )
+
+
+class McpAuthRequiredSource(BaseModel):
+ """A single blocked source in a 409 mcp_auth_required response."""
+
+ source_id: str
+ status: AuthStatusLiteral
+ connect_url: str
+ auth_url: str | None = None
+
+
+class McpAuthRequiredResponse(BaseModel):
+ """Body of the 409 returned by submit preflight when sources need auth."""
+
+ error: Literal["mcp_auth_required"] = "mcp_auth_required"
+ message: str = "One or more selected data sources require connection before this job can start."
+ sources: list[McpAuthRequiredSource] = Field(default_factory=list)
diff --git a/frontends/aiq_api/src/aiq_api/mcp_auth/nat_provider.py b/frontends/aiq_api/src/aiq_api/mcp_auth/nat_provider.py
new file mode 100644
index 000000000..8a4447e46
--- /dev/null
+++ b/frontends/aiq_api/src/aiq_api/mcp_auth/nat_provider.py
@@ -0,0 +1,304 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""NAT-backed implementation of :class:`ProtectedSourceAuthProvider`.
+
+This implementation never invokes NAT's blocking, browser-opening MCP auth
+flow. Instead it mints the provider authorization URL with ``authlib`` (the
+same library NAT uses), completes the ``code -> token`` exchange in AIQ's own
+callback, and writes the resulting token into NAT's *public* token storage in
+the exact shape NAT writes (``AuthResult`` with a ``BearerTokenCred`` and the
+raw token dict incl. ``refresh_token``). The headless job-time
+``per_user_mcp_client`` then finds that token via ``token_storage.retrieve``
+and never has to authenticate interactively.
+
+The per-source token storage MUST be a shared, persistent backend (an
+``ObjectStore``) — the connect endpoint runs in the API process while the job
+runs in a separate worker process, so in-memory storage would not be visible
+across the boundary.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import secrets
+from collections.abc import Callable
+from dataclasses import dataclass
+from dataclasses import field
+from datetime import UTC
+from datetime import datetime
+from datetime import timedelta
+
+import pkce
+from authlib.integrations.httpx_client import AsyncOAuth2Client
+from pydantic import BaseModel
+from pydantic import Field
+from pydantic import SecretStr
+
+from aiq_agent.auth import Principal
+from nat.authentication.token_storage import TokenStorageBase
+from nat.data_models.authentication import AuthResult
+from nat.data_models.authentication import BearerTokenCred
+
+from .provider import SourceAuthChallenge
+from .provider import SourceAuthState
+from .provider import principal_user_id
+
+logger = logging.getLogger(__name__)
+
+_DEFAULT_CHALLENGE_TTL = timedelta(seconds=300)
+
+
+class OAuthSourceSettings(BaseModel):
+ """OAuth2 settings for one protected MCP source.
+
+ These mirror the fields NAT's ``mcp_oauth2`` provider uses. ``mcp_server_id``
+ is the NAT auth-provider/server key — the token written here must be
+ retrievable by the same key the job-time provider uses (which is the per-user
+ key within that server's token storage).
+ """
+
+ source_id: str
+ mcp_server_id: str
+ provider: str | None = None
+ authorization_url: str
+ token_url: str
+ client_id: str
+ client_secret: SecretStr | None = None
+ scopes: list[str] = Field(default_factory=list)
+ redirect_uri: str
+ use_pkce: bool = True
+ token_endpoint_auth_method: str = "client_secret_post"
+ resource: str | None = Field(
+ default=None,
+ description=(
+ "OAuth resource indicator (RFC 8707 / RFC 9728) added to the authorization request, matching NAT's "
+ "MCPOAuth2Provider. Derived from the protected-resource metadata's `resource` (falling back to the "
+ "server URL). Authorization-request only — NAT does not send it at token exchange."
+ ),
+ )
+
+
+@dataclass
+class _PendingFlow:
+ source_id: str
+ user_id: str
+ client: AsyncOAuth2Client
+ verifier: str | None
+ settings: OAuthSourceSettings
+ expires_at: datetime
+
+
+@dataclass
+class NatMcpAuthProvider:
+ """Concrete :class:`ProtectedSourceAuthProvider` backed by NAT primitives.
+
+ Args:
+ settings_by_source: OAuth settings per protected source id.
+ token_storage_resolver: Maps settings -> the NAT ``TokenStorageBase`` for
+ that source. In production this resolves a shared ``ObjectStore``; in
+ tests it can return an ``InMemoryTokenStorage`` per source.
+ challenge_ttl: How long a minted ``auth_url`` / pending flow stays valid.
+ now: Clock injection for tests.
+ """
+
+ settings_by_source: dict[str, OAuthSourceSettings]
+ token_storage_resolver: Callable[[OAuthSourceSettings], TokenStorageBase]
+ challenge_ttl: timedelta = _DEFAULT_CHALLENGE_TTL
+ now: Callable[[], datetime] = field(default_factory=lambda: lambda: datetime.now(UTC))
+
+ # DEPLOYMENT CONSTRAINT — single API replica (or sticky sessions):
+ # ``_pending`` holds in-PROCESS OAuth flow state (PKCE verifier + authlib
+ # client) keyed by the OAuth ``state``. The browser hits /connect on one
+ # replica and the provider redirects /callback back to the API; the callback
+ # MUST land on the SAME process that minted the state, or complete_callback
+ # raises "Unknown or expired auth state". The Helm chart pins the backend to
+ # replicas: 1, so this holds today. Scaling the API beyond one replica
+ # requires either session affinity on the ingress/service (route a user's
+ # /connect and /callback to the same pod) or moving pending-flow state to a
+ # shared store (e.g. Redis). The token store is already shared/cross-process;
+ # only this short-lived (challenge_ttl) pending state is process-local.
+ _pending: dict[str, _PendingFlow] = field(default_factory=dict)
+ _storage_cache: dict[str, TokenStorageBase] = field(default_factory=dict)
+ _lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+
+ # ── storage helpers ──
+ def _storage(self, settings: OAuthSourceSettings) -> TokenStorageBase:
+ cached = self._storage_cache.get(settings.source_id)
+ if cached is None:
+ cached = self.token_storage_resolver(settings)
+ self._storage_cache[settings.source_id] = cached
+ return cached
+
+ def is_protected(self, source_id: str) -> bool:
+ """Whether this source is configured for per-user MCP OAuth."""
+ return source_id in self.settings_by_source
+
+ # ── ProtectedSourceAuthProvider ──
+ async def get_status(self, principal: Principal, source_id: str) -> SourceAuthState:
+ settings = self.settings_by_source.get(source_id)
+ if settings is None:
+ # Not configured for OAuth — treat as an error so the UI can surface it
+ # rather than silently claiming "connected".
+ return SourceAuthState(status="error", last_error="Source is not configured for MCP OAuth")
+
+ user_id = principal_user_id(principal)
+ try:
+ auth_result = await self._storage(settings).retrieve(user_id)
+ except Exception as exc: # storage backend failure — report, don't crash the listing
+ logger.warning("Token storage read failed for source=%s: %s", source_id, exc)
+ return SourceAuthState(status="error", last_error="Could not read auth state")
+
+ if auth_result is None or not auth_result.credentials:
+ return SourceAuthState(status="not_connected")
+ if auth_result.is_expired():
+ return SourceAuthState(status="expired", expires_at=auth_result.token_expires_at)
+ return SourceAuthState(status="connected", expires_at=auth_result.token_expires_at)
+
+ async def start_auth(self, principal: Principal, source_id: str) -> SourceAuthChallenge:
+ settings = self.settings_by_source.get(source_id)
+ if settings is None:
+ raise ValueError(f"Source '{source_id}' is not configured for MCP OAuth")
+
+ user_id = principal_user_id(principal)
+ state = secrets.token_urlsafe(24)
+
+ client = AsyncOAuth2Client(
+ client_id=settings.client_id,
+ client_secret=(settings.client_secret.get_secret_value() if settings.client_secret else None),
+ redirect_uri=settings.redirect_uri,
+ scope=" ".join(settings.scopes) if settings.scopes else None,
+ code_challenge_method="S256" if settings.use_pkce else None,
+ token_endpoint_auth_method=settings.token_endpoint_auth_method,
+ )
+
+ verifier = challenge = None
+ if settings.use_pkce:
+ verifier, challenge = pkce.generate_pkce_pair()
+
+ # RFC 8707 resource indicator on the authorize request, matching NAT's
+ # MCPOAuth2Provider (authorization_kwargs={"resource": ...}). Authorize-only:
+ # NAT does not send it at token exchange, so complete_callback omits it too.
+ extra = {"resource": settings.resource} if settings.resource else {}
+ auth_url, _ = client.create_authorization_url(
+ settings.authorization_url,
+ state=state,
+ code_verifier=verifier if settings.use_pkce else None,
+ code_challenge=challenge if settings.use_pkce else None,
+ **extra,
+ )
+
+ expires_at = self.now() + self.challenge_ttl
+ async with self._lock:
+ stale = self._prune_locked()
+ self._pending[state] = _PendingFlow(
+ source_id=source_id,
+ user_id=user_id,
+ client=client,
+ verifier=verifier,
+ settings=settings,
+ expires_at=expires_at,
+ )
+ await self._aclose_flows(stale)
+ logger.info("Started MCP auth challenge for source=%s user=%s state=%s", source_id, user_id, state[:8])
+ return SourceAuthChallenge(source_id=source_id, auth_url=auth_url, state=state, expires_at=expires_at)
+
+ async def require_connected(
+ self,
+ principal: Principal,
+ source_ids: list[str],
+ ) -> list[SourceAuthChallenge]:
+ blocked: list[SourceAuthChallenge] = []
+ for source_id in source_ids:
+ if not self.is_protected(source_id):
+ continue # unprotected / unknown sources never block submission
+ state = await self.get_status(principal, source_id)
+ if state.status == "connected":
+ continue
+ # Best-effort: mint an auth_url so the client can act immediately. If
+ # minting fails, still report the source as blocked (connect_url only).
+ try:
+ challenge = await self.start_auth(principal, source_id)
+ except Exception as exc:
+ logger.warning("Could not mint auth_url during preflight for source=%s: %s", source_id, exc)
+ challenge = SourceAuthChallenge(source_id=source_id, auth_url="", state="")
+ blocked.append(challenge)
+ return blocked
+
+ # ── callback completion (AIQ owns the redirect route) ──
+ async def complete_callback(self, state: str, authorization_response_url: str) -> str:
+ """Exchange the callback's code for a token and persist it. Returns source_id.
+
+ Raises ``KeyError`` for an unknown/expired state and propagates token
+ exchange errors to the caller (the route maps them to an HTML error).
+ """
+ async with self._lock:
+ stale = self._prune_locked()
+ flow = self._pending.pop(state, None)
+ await self._aclose_flows(stale)
+ if flow is None:
+ raise KeyError("Unknown or expired auth state")
+
+ try:
+ token = await flow.client.fetch_token(
+ url=flow.settings.token_url,
+ authorization_response=authorization_response_url,
+ code_verifier=flow.verifier,
+ state=state,
+ )
+ finally:
+ await flow.client.aclose()
+
+ auth_result = _auth_result_from_token(token)
+ await self._storage(flow.settings).store(flow.user_id, auth_result)
+ logger.info("Completed MCP auth for source=%s user=%s", flow.source_id, flow.user_id)
+ return flow.source_id
+
+ def _prune_locked(self) -> list[_PendingFlow]:
+ """Pop expired flows and return them so the caller can close their clients.
+
+ Returns the popped flows rather than discarding them: each holds an
+ ``AsyncOAuth2Client`` (an httpx client pool) that must be ``aclose()``d to
+ avoid leaking connection state. Pruning runs under ``self._lock`` and is
+ synchronous, so the async close happens outside the lock via
+ :meth:`_aclose_flows`.
+ """
+ now = self.now()
+ expired = [s for s, f in self._pending.items() if f.expires_at <= now]
+ return [self._pending.pop(s) for s in expired]
+
+ @staticmethod
+ async def _aclose_flows(flows: list[_PendingFlow]) -> None:
+ for flow in flows:
+ try:
+ await flow.client.aclose()
+ except Exception as exc: # best-effort cleanup; never fail the caller
+ logger.debug("Error closing expired auth flow client: %s", exc)
+
+
+def _auth_result_from_token(token: dict) -> AuthResult:
+ """Build a NAT ``AuthResult`` in the same shape NAT's OAuth provider writes."""
+ expires_at: datetime | None = None
+ if token.get("expires_at"):
+ expires_at = datetime.fromtimestamp(float(token["expires_at"]), tz=UTC)
+ access_token = token.get("access_token")
+ if not access_token:
+ raise ValueError("Token response missing access_token")
+ return AuthResult(
+ credentials=[BearerTokenCred(token=SecretStr(access_token))],
+ token_expires_at=expires_at,
+ raw=dict(token),
+ )
diff --git a/frontends/aiq_api/src/aiq_api/mcp_auth/preflight.py b/frontends/aiq_api/src/aiq_api/mcp_auth/preflight.py
new file mode 100644
index 000000000..0abe7edba
--- /dev/null
+++ b/frontends/aiq_api/src/aiq_api/mcp_auth/preflight.py
@@ -0,0 +1,102 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Transport-agnostic preflight for per-user MCP auth.
+
+This is the single source of truth for "may this job be enqueued given which
+protected sources the caller selected?". It is reused by two enforcement points
+so they cannot drift:
+
+ * the REST ``/v1/jobs/async/submit`` route, which wraps a block in a 409
+ JSON response (``_preflight_mcp_auth``); and
+ * ``submit_agent_job`` itself, which raises :class:`McpAuthRequiredError` so
+ that programmatic callers (e.g. the chat researcher's async deep-research
+ submit) cannot bypass the route-level check.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from aiq_agent.auth import Principal
+from aiq_api.auth.errors import AuthError
+
+from .models import McpAuthRequiredResponse
+from .models import McpAuthRequiredSource
+from .provider import ProtectedSourceAuthProvider
+from .serialize import connect_url_for
+
+logger = logging.getLogger(__name__)
+
+
+async def evaluate_mcp_auth(
+ provider: ProtectedSourceAuthProvider,
+ principal: Principal,
+ data_sources: list[str] | None,
+) -> McpAuthRequiredResponse | None:
+ """Return a block descriptor if a selected protected source needs connecting.
+
+ ``data_sources is None`` means the job may use any tool, so every protected
+ source must be connected; an explicit list restricts the check to those ids.
+ Returns ``None`` when nothing is blocked (no protected sources selected, or
+ all connected).
+ """
+ from aiq_agent.common.data_source_registry import get_all_sources
+ from aiq_agent.common.data_source_registry import get_source
+
+ if data_sources is None:
+ protected_ids = [s.id for s in get_all_sources() if s.per_user_auth and s.per_user_auth.required]
+ else:
+ protected_ids = [
+ sid for sid in data_sources if (src := get_source(sid)) and src.per_user_auth and src.per_user_auth.required
+ ]
+ if not protected_ids:
+ return None
+
+ challenges = await provider.require_connected(principal, protected_ids)
+ if not challenges:
+ return None
+
+ blocked: list[McpAuthRequiredSource] = []
+ for challenge in challenges:
+ state = await provider.get_status(principal, challenge.source_id)
+ blocked.append(
+ McpAuthRequiredSource(
+ source_id=challenge.source_id,
+ status=state.status if state.status != "connected" else "not_connected",
+ connect_url=connect_url_for(challenge.source_id),
+ auth_url=challenge.auth_url or None,
+ )
+ )
+ return McpAuthRequiredResponse(sources=blocked)
+
+
+class McpAuthRequiredError(AuthError):
+ """Raised by ``submit_agent_job`` when a selected protected source is not connected.
+
+ Subclasses :class:`AuthError` so the chat researcher's deep-research node
+ surfaces ``str(self)`` to the user (instead of a generic failure) and the
+ structured ``response`` is available for clients that want connect URLs.
+ """
+
+ error_code = "mcp_auth_required"
+
+ def __init__(self, response: McpAuthRequiredResponse) -> None:
+ self.response = response
+ names = ", ".join(s.source_id for s in response.sources) or "the selected data source"
+ super().__init__(
+ f"Connect the following data source(s) before starting deep research: {names}. "
+ "Open the data sources panel, click Connect to sign in, then try again."
+ )
diff --git a/frontends/aiq_api/src/aiq_api/mcp_auth/provider.py b/frontends/aiq_api/src/aiq_api/mcp_auth/provider.py
new file mode 100644
index 000000000..bf9001700
--- /dev/null
+++ b/frontends/aiq_api/src/aiq_api/mcp_auth/provider.py
@@ -0,0 +1,94 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Adapter boundary for protected MCP source auth.
+
+This Protocol is the single seam between AIQ's product control plane and NAT's
+MCP OAuth mechanics. All NAT calls live behind it (see ``nat_provider``) so the
+route handlers and submit preflight never import NAT auth internals directly.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Literal
+from typing import Protocol
+from typing import runtime_checkable
+
+from aiq_agent.auth import Principal
+
+AuthStatus = Literal["connected", "not_connected", "expired", "error"]
+
+
+def principal_user_id(principal: Principal) -> str:
+ """Canonical per-user key for NAT token storage.
+
+ This MUST match the ``user_id`` that the headless worker sets on the NAT
+ ``Context`` at job time, otherwise a token connected here will not be found
+ when ``per_user_mcp_client`` looks it up during execution. Keying by
+ ``type:sub`` keeps anonymous/no-auth principals distinct from verified ones.
+ """
+ return f"{principal.type}:{principal.sub}"
+
+
+@dataclass(frozen=True)
+class SourceAuthState:
+ """Current per-user auth state for a single source."""
+
+ status: AuthStatus
+ expires_at: datetime | None = None
+ last_error: str | None = None
+
+
+@dataclass(frozen=True)
+class SourceAuthChallenge:
+ """Result of starting (or reusing) an auth challenge for a source.
+
+ ``auth_url`` is the provider login URL to hand to the client. ``state`` is
+ the opaque OAuth state bound to (principal, source) used by the callback to
+ complete the flow. ``expires_at`` is the challenge's expiry, not the token's.
+ """
+
+ source_id: str
+ auth_url: str
+ state: str
+ expires_at: datetime | None = None
+
+
+@runtime_checkable
+class ProtectedSourceAuthProvider(Protocol):
+ """Product-facing interface AIQ depends on; NAT lives behind the impl."""
+
+ async def get_status(self, principal: Principal, source_id: str) -> SourceAuthState:
+ """Return the current per-user auth state for ``source_id`` (read-only)."""
+ ...
+
+ async def start_auth(self, principal: Principal, source_id: str) -> SourceAuthChallenge:
+ """Start or resume the OAuth flow and return a provider login URL."""
+ ...
+
+ async def require_connected(
+ self,
+ principal: Principal,
+ source_ids: list[str],
+ ) -> list[SourceAuthChallenge]:
+ """Return a challenge per *blocked* source; empty list means all connected.
+
+ Used by submit preflight. Implementations should attempt to include an
+ ``auth_url`` when one can be safely minted, but may return a challenge
+ with an empty ``auth_url`` if only ``connect_url`` can be offered.
+ """
+ ...
diff --git a/frontends/aiq_api/src/aiq_api/mcp_auth/runtime_tools.py b/frontends/aiq_api/src/aiq_api/mcp_auth/runtime_tools.py
new file mode 100644
index 000000000..18d30096b
--- /dev/null
+++ b/frontends/aiq_api/src/aiq_api/mcp_auth/runtime_tools.py
@@ -0,0 +1,234 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Per-job runtime resolution of per-user MCP source tools (built in code).
+
+AIQ agents inherit data-source tools at *build* time, but a per-user MCP source's
+tools are per-user/dynamic — the MCP client connects and enumerates tools using
+the *user's* token. Two consequences shaped this design:
+
+ * The tools can't be inherited statically by agents (no user at build time).
+ * A ``per_user_mcp_client`` declared in the *config* is built by NAT's per-user
+ *interactive* (WebSocket) session builder, which fails for a user with no
+ token — breaking interactive chat. So we do NOT declare it in config.
+
+Instead the headless async-job worker builds the per-user MCP client **in code**,
+per job, after it has set ``Context.user_id`` to the job owner: it reads the MCP
+endpoint from the source's ``mcp_oauth2`` auth provider, connects with the owner's
+stored token (no interactive flow), enumerates the tools, and wraps them for the
+agent. The client stays open via the caller's ``AsyncExitStack`` for the run.
+"""
+
+from __future__ import annotations
+
+import logging
+from contextlib import AsyncExitStack
+
+from aiq_agent.common.data_source_registry import get_all_sources
+from nat.builder.framework_enum import LLMFrameworkEnum
+
+logger = logging.getLogger(__name__)
+
+
+class PerUserMcpSourceUnavailableError(RuntimeError):
+ """An explicitly-selected protected MCP source could not be resolved at run time.
+
+ Raised (rather than silently continuing) when the caller selected specific data
+ sources and one of them is a configured per-user MCP source whose tools cannot
+ be built — typically because the owner's token is missing or expired. Surfacing
+ this lets the client prompt a reconnect instead of returning a web-only answer
+ that misrepresents which sources were actually used.
+ """
+
+ def __init__(self, source_ids: list[str]) -> None:
+ self.source_ids = source_ids
+ names = ", ".join(source_ids)
+ super().__init__(
+ f"The following selected data source(s) are not connected (or the connection expired): {names}. "
+ "Reconnect them in the data sources panel and try again."
+ )
+
+
+def _resolve_type_registry(builder):
+ """Resolve NAT's type registry through dependency-tracking child builders."""
+ current = builder
+ seen: set[int] = set()
+ while current is not None and id(current) not in seen:
+ seen.add(id(current))
+ registry = getattr(current, "_registry", None)
+ if registry is not None:
+ return registry
+ current = getattr(current, "_workflow_builder", None)
+ raise TypeError(f"Could not resolve a NAT type registry from builder {type(builder).__name__}")
+
+
+async def _token_usable(builder, cfg, source_id: str) -> bool:
+ """Return whether the job owner has a usable (present, non-expired) token.
+
+ If a token exists but is expired or has no credentials, it is deleted so the
+ next ``get_status`` reports the source as disconnected (the UI then shows
+ Reconnect) instead of falsely "connected". Best-effort: any error resolving
+ the store is treated as "not usable" so we skip rather than crash the job.
+ """
+ from nat.builder.context import Context
+
+ from .factory import _resolve_token_storage
+
+ user_id = Context.get().user_id
+ if not user_id:
+ return False
+ try:
+ storage = await _resolve_token_storage(builder, cfg, source_id)
+ auth = await storage.retrieve(user_id)
+ except Exception as exc:
+ logger.warning("Source '%s': could not read token state: %s", source_id, exc)
+ return False
+
+ if auth is None or not auth.credentials:
+ return False # nothing stored -> status is already not_connected
+ if auth.is_expired():
+ # Invalidate so the card stops showing "connected" and prompts Reconnect.
+ try:
+ await storage.delete(user_id)
+ except Exception as exc:
+ logger.debug("Source '%s': failed to delete expired token: %s", source_id, exc)
+ logger.warning("Source '%s': stored token is expired; invalidated. User must reconnect.", source_id)
+ return False
+ return True
+
+
+async def open_per_user_mcp_tools(
+ *,
+ builder,
+ data_sources: list[str] | None,
+ exit_stack: AsyncExitStack,
+ wrapper_type: LLMFrameworkEnum | str = LLMFrameworkEnum.LANGCHAIN,
+) -> list:
+ """Build per-user MCP clients for selected protected sources; return their tools.
+
+ Args:
+ builder: The per-job ``WorkflowBuilder`` (resolves the auth provider + the
+ framework tool wrapper).
+ data_sources: Selected source ids, or ``None`` meaning "all" (so every
+ connected protected source's tools are made available).
+ exit_stack: An ``AsyncExitStack`` whose lifetime spans the agent run; the
+ MCP client contexts are entered here and torn down when it closes.
+ wrapper_type: Agent framework to wrap tools for.
+
+ Returns:
+ Framework-wrapped tools (possibly empty). Best-effort for the "all" case
+ (``data_sources is None``): a failure resolving one source is logged and
+ skipped so it never breaks the job.
+
+ Raises:
+ PerUserMcpSourceUnavailableError: when ``data_sources`` is an explicit list
+ and one of the selected, configured per-user MCP sources cannot be
+ resolved (missing/expired token, unreachable server). The caller asked
+ for those sources specifically, so failing is preferable to silently
+ answering without them.
+
+ Precondition: ``Context.user_id`` must already be set to the job owner.
+ """
+ from nat.plugins.mcp.client.client_config import MCPServerConfig
+ from nat.plugins.mcp.client.client_config import MCPToolOverrideConfig
+ from nat.plugins.mcp.client.client_config import PerUserMCPClientConfig
+ from nat.plugins.mcp.client.client_impl import per_user_mcp_client_function_group
+
+ selected = None if data_sources is None else {s.lower() for s in data_sources}
+ tools: list = []
+ # Explicitly-selected per-user sources we couldn't resolve -> fail closed below.
+ unavailable: list[str] = []
+
+ for source in get_all_sources():
+ pua = source.per_user_auth
+ if pua is None or not pua.required or not pua.auth_provider:
+ continue
+ explicitly_selected = selected is not None and source.id.lower() in selected
+ if selected is not None and not explicitly_selected:
+ continue
+
+ try:
+ # The mcp_oauth2 provider's server_url is the MCP endpoint; reuse it so
+ # the connect flow and the job-time client target the same server, and
+ # the client authenticates via the same provider (stored token lookup).
+ provider = await builder.get_auth_provider(pua.auth_provider)
+ server_url = str(getattr(getattr(provider, "config", None), "server_url", "") or "")
+ if not server_url:
+ logger.warning(
+ "Source '%s': auth provider '%s' has no server_url; cannot build MCP client.",
+ source.id,
+ pua.auth_provider,
+ )
+ if explicitly_selected:
+ unavailable.append(source.id)
+ continue
+
+ # Reconcile UI status with reality: the data-source card reports
+ # "connected" from an offline token read, but the token can be expired
+ # while the card still says connected. The use-site is authoritative —
+ # if the owner's stored token is missing/expired here, invalidate it so
+ # the next get_status returns not_connected/expired (UI -> Reconnect)
+ # and skip, rather than failing the live MCP call and silently dropping
+ # the tool while the UI keeps claiming connected.
+ if not await _token_usable(builder, getattr(provider, "config", None), source.id):
+ if explicitly_selected:
+ unavailable.append(source.id)
+ continue
+
+ # Give terse/blank MCP tools clear names + descriptions so the agent
+ # reliably selects them over web search (declared on the source).
+ tool_overrides = {
+ name: MCPToolOverrideConfig(alias=ov.get("alias"), description=ov.get("description"))
+ for name, ov in (pua.tool_overrides or {}).items()
+ }
+ client_cfg = PerUserMCPClientConfig(
+ server=MCPServerConfig(transport="streamable-http", url=server_url, auth_provider=pua.auth_provider),
+ tool_overrides=tool_overrides,
+ )
+ group = await exit_stack.enter_async_context(per_user_mcp_client_function_group(client_cfg, builder))
+ fns = await group.get_accessible_functions()
+ # Resolve the tool wrapper via the builder's type registry rather than
+ # `builder._registry` directly: in server mode `builder` is a ChildBuilder,
+ # which has no `_registry` (it delegates to its parent), so the attribute
+ # access raised AttributeError and this whole block was silently swallowed
+ # — dropping the selected source's tools and falling back to web search.
+ wrapper = _resolve_type_registry(builder).get_tool_wrapper(llm_framework=wrapper_type)
+ wrapped = [wrapper.build_fn(name, fn, builder) for name, fn in fns.items()]
+ tools.extend(wrapped)
+
+ # Map these runtime-resolved tools to their data source so the agents'
+ # citation/source capture treats their results as sources. Without this,
+ # get_source_id_for_tool returns None for them and shallow research raises
+ # EmptySourceRegistryError ("no sources captured") even on a successful read.
+ from aiq_agent.common.data_source_registry import register_tool_sources
+
+ register_tool_sources({getattr(t, "name", ""): source.id for t in wrapped if getattr(t, "name", "")})
+ logger.info("Resolved %d per-user MCP tool(s) for source '%s'.", len(wrapped), source.id)
+ except Exception:
+ logger.exception(
+ "Failed to resolve per-user MCP tools for source '%s'; continuing without them.",
+ source.id,
+ )
+ if explicitly_selected:
+ unavailable.append(source.id)
+
+ # Fail closed for sources the caller singled out but we couldn't resolve (e.g.
+ # a token that expired between submit-time preflight and job execution). For the
+ # "all" case (data_sources is None) we stay best-effort — the user didn't ask
+ # for these specifically, so a missing one shouldn't sink the whole run.
+ if unavailable:
+ raise PerUserMcpSourceUnavailableError(unavailable)
+
+ return tools
diff --git a/frontends/aiq_api/src/aiq_api/mcp_auth/serialize.py b/frontends/aiq_api/src/aiq_api/mcp_auth/serialize.py
new file mode 100644
index 000000000..e3181a302
--- /dev/null
+++ b/frontends/aiq_api/src/aiq_api/mcp_auth/serialize.py
@@ -0,0 +1,68 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Helpers to project registry + auth state into API models."""
+
+from __future__ import annotations
+
+from aiq_agent.auth import Principal
+from aiq_agent.common.data_source_registry import DataSourceMeta
+
+from .models import PerUserAuthInfo
+from .provider import ProtectedSourceAuthProvider
+
+# Statuses for which the client should be offered a way to (re)connect.
+_ACTIONABLE = {"not_connected", "expired", "error"}
+
+
+def connect_url_for(source_id: str) -> str:
+ return f"/v1/auth/mcp/{source_id}/connect"
+
+
+def status_url_for(source_id: str) -> str:
+ return f"/v1/auth/mcp/{source_id}/status"
+
+
+async def build_listing_auth_info(
+ provider: ProtectedSourceAuthProvider,
+ principal: Principal,
+ source: DataSourceMeta,
+) -> PerUserAuthInfo | None:
+ """Build the ``per_user_auth`` block for ``GET /v1/data_sources``.
+
+ Read-only: never mints ``auth_url`` (no OAuth state is created here). Returns
+ ``None`` for sources with no per-user auth declaration so the field is
+ omitted entirely.
+ """
+ pua = source.per_user_auth
+ if pua is None:
+ return None
+
+ info = PerUserAuthInfo(
+ required=pua.required,
+ type=pua.type,
+ provider=pua.provider,
+ mcp_server_id=pua.mcp_server_id,
+ )
+ if not pua.required:
+ return info
+
+ state = await provider.get_status(principal, source.id)
+ info.status = state.status
+ info.expires_at = state.expires_at
+ info.last_error = state.last_error
+ if state.status in _ACTIONABLE:
+ info.connect_url = connect_url_for(source.id)
+ return info
diff --git a/frontends/aiq_api/src/aiq_api/mcp_auth/sqlite_object_store.py b/frontends/aiq_api/src/aiq_api/mcp_auth/sqlite_object_store.py
new file mode 100644
index 000000000..756111e91
--- /dev/null
+++ b/frontends/aiq_api/src/aiq_api/mcp_auth/sqlite_object_store.py
@@ -0,0 +1,245 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""A SQLite-backed NAT ``ObjectStore`` for serviceless, cross-process storage.
+
+NAT ships only ``in_memory`` (process-local) in core, plus ``redis``/``s3``/
+``mysql`` in separate packages — all of which are network services. The per-user
+MCP token store, however, is written by the API process (OAuth ``/connect`` and
+``/callback``) and read by a *separate* Dask worker process at job time, so an
+in-memory store can't bridge that gap and a network service can't run "with no
+deployment".
+
+A file on local disk is the one option that is both serviceless **and** visible
+across processes: both processes open the same SQLite file (WAL mode), exactly
+how this project's job/checkpoint/summary stores already span the same two
+processes locally. The same class points at a path locally and is swapped for
+``redis`` in deployment via the config's env-selectable ``_type``.
+
+This implements NAT's ``ObjectStore`` interface only; the token serialization,
+refresh, and expiry logic stay in NAT's ``ObjectStoreTokenStorage`` /
+``mcp_oauth2`` on top — nothing here is token-specific.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import time
+
+import aiosqlite
+from pydantic import Field
+
+from nat.builder.builder import Builder
+from nat.cli.register_workflow import register_object_store
+from nat.data_models.object_store import KeyAlreadyExistsError
+from nat.data_models.object_store import NoSuchKeyError
+from nat.data_models.object_store import ObjectStoreBaseConfig
+from nat.object_store.interfaces import ObjectStore
+from nat.object_store.models import ObjectStoreItem
+from nat.utils.type_utils import override
+
+logger = logging.getLogger(__name__)
+
+_SCHEMA = """
+CREATE TABLE IF NOT EXISTS objects (
+ key TEXT PRIMARY KEY,
+ data BLOB NOT NULL,
+ content_type TEXT,
+ metadata TEXT,
+ expires_at REAL
+)
+"""
+
+
+def _restrict_file(path: str) -> None:
+ """chmod ``path`` to owner-only (0600) if it exists; best-effort."""
+ try:
+ if os.path.exists(path):
+ os.chmod(path, 0o600)
+ except OSError as exc: # e.g. unsupported on the platform — don't crash startup
+ logger.warning("Could not restrict permissions on %s: %s", path, exc)
+
+
+def _ensure_private_file(path: str) -> None:
+ """Ensure the token DB file exists with owner-only (0600) permissions.
+
+ Creating it ourselves with ``O_CREAT`` + ``0o600`` closes the window where
+ sqlite would otherwise create it at the umask default (0644), and we chmod
+ unconditionally so an existing 0644 file is tightened on next open.
+ """
+ if not os.path.exists(path):
+ try:
+ fd = os.open(path, os.O_CREAT | os.O_WRONLY, 0o600)
+ os.close(fd)
+ except OSError as exc:
+ logger.warning("Could not pre-create token DB %s with private perms: %s", path, exc)
+ return
+ _restrict_file(path)
+
+
+class AiqSqliteObjectStoreConfig(ObjectStoreBaseConfig, name="aiq_sqlite"):
+ """SQLite-file object store — serviceless and shared across processes.
+
+ Suited to local/single-host runs where standing up Redis/S3/MySQL is
+ undesirable. For multi-replica deployments use a networked object store
+ (e.g. ``redis``) instead, since a SQLite file is only shared by processes
+ that can see the same path.
+ """
+
+ db_path: str = Field(
+ default="./mcp_tokens.db",
+ description="Path to the SQLite database file (created on first use).",
+ )
+ bucket_name: str | None = Field(
+ default=None,
+ description="Optional key prefix so multiple logical buckets can share one file.",
+ )
+ ttl: int | None = Field(
+ default=None,
+ description="TTL in seconds for stored objects (None = no expiration).",
+ )
+
+
+class AiqSqliteObjectStore(ObjectStore):
+ """ObjectStore backed by a single SQLite file.
+
+ Cross-process visibility relies on WAL mode + a busy timeout: each process
+ opens its own connection to the same file, and committed writes from one
+ process are readable by the others.
+ """
+
+ def __init__(self, db_path: str, bucket_name: str | None = None, ttl: int | None = None) -> None:
+ self._db_path = db_path
+ self._prefix = f"{bucket_name}/" if bucket_name else ""
+ self._ttl = ttl
+ self._db: aiosqlite.Connection | None = None
+ self._lock = asyncio.Lock()
+
+ # ── connection / helpers ──
+ async def _conn(self) -> aiosqlite.Connection:
+ if self._db is None:
+ # The serialized objects hold plaintext access/refresh tokens, so the
+ # file must never be group/world-readable. Create it 0600 BEFORE sqlite
+ # opens it (default umask 022 would otherwise yield 0644), and tighten
+ # an existing file too.
+ _ensure_private_file(self._db_path)
+ db = await aiosqlite.connect(self._db_path)
+ # WAL allows concurrent readers across processes alongside one writer;
+ # busy_timeout waits out a peer's write lock instead of failing fast.
+ await db.execute("PRAGMA journal_mode=WAL")
+ await db.execute("PRAGMA busy_timeout=5000")
+ await db.execute(_SCHEMA)
+ await db.commit()
+ # WAL/SHM sidecars are created by sqlite on first write and also carry
+ # token bytes; restrict them once they exist.
+ for suffix in ("-wal", "-shm"):
+ _restrict_file(self._db_path + suffix)
+ self._db = db
+ return self._db
+
+ def _k(self, key: str) -> str:
+ return f"{self._prefix}{key}"
+
+ def _expiry(self) -> float | None:
+ return time.time() + self._ttl if self._ttl else None
+
+ @staticmethod
+ def _expired(expires_at: float | None) -> bool:
+ return expires_at is not None and expires_at <= time.time()
+
+ def _row_to_item(self, row: tuple) -> ObjectStoreItem:
+ data, content_type, metadata = row[0], row[1], row[2]
+ return ObjectStoreItem(
+ data=data,
+ content_type=content_type,
+ metadata=json.loads(metadata) if metadata else None,
+ )
+
+ # ── ObjectStore interface ──
+ @override
+ async def put_object(self, key: str, item: ObjectStoreItem) -> None:
+ k = self._k(key)
+ async with self._lock:
+ db = await self._conn()
+ # An expired row is logically absent, so let a fresh put replace it.
+ async with db.execute("SELECT expires_at FROM objects WHERE key = ?", (k,)) as cur:
+ existing = await cur.fetchone()
+ if existing is not None and not self._expired(existing[0]):
+ raise KeyAlreadyExistsError(key)
+ await db.execute(
+ "INSERT OR REPLACE INTO objects (key, data, content_type, metadata, expires_at) VALUES (?, ?, ?, ?, ?)",
+ (k, item.data, item.content_type, json.dumps(item.metadata) if item.metadata else None, self._expiry()),
+ )
+ await db.commit()
+
+ @override
+ async def upsert_object(self, key: str, item: ObjectStoreItem) -> None:
+ k = self._k(key)
+ async with self._lock:
+ db = await self._conn()
+ await db.execute(
+ "INSERT OR REPLACE INTO objects (key, data, content_type, metadata, expires_at) VALUES (?, ?, ?, ?, ?)",
+ (k, item.data, item.content_type, json.dumps(item.metadata) if item.metadata else None, self._expiry()),
+ )
+ await db.commit()
+
+ @override
+ async def get_object(self, key: str) -> ObjectStoreItem:
+ k = self._k(key)
+ async with self._lock:
+ db = await self._conn()
+ async with db.execute(
+ "SELECT data, content_type, metadata, expires_at FROM objects WHERE key = ?", (k,)
+ ) as cur:
+ row = await cur.fetchone()
+ if row is None:
+ raise NoSuchKeyError(key)
+ if self._expired(row[3]):
+ await db.execute("DELETE FROM objects WHERE key = ?", (k,))
+ await db.commit()
+ raise NoSuchKeyError(key)
+ return self._row_to_item(row)
+
+ @override
+ async def delete_object(self, key: str) -> None:
+ k = self._k(key)
+ async with self._lock:
+ db = await self._conn()
+ cur = await db.execute("DELETE FROM objects WHERE key = ?", (k,))
+ await db.commit()
+ if cur.rowcount == 0:
+ raise NoSuchKeyError(key)
+
+ async def aclose(self) -> None:
+ if self._db is not None:
+ await self._db.close()
+ self._db = None
+
+
+@register_object_store(config_type=AiqSqliteObjectStoreConfig)
+async def aiq_sqlite_object_store(config: AiqSqliteObjectStoreConfig, builder: Builder):
+ store = AiqSqliteObjectStore(db_path=config.db_path, bucket_name=config.bucket_name, ttl=config.ttl)
+ # Log the ABSOLUTE path: the API and worker must resolve db_path to the same
+ # file. With a relative path that only holds if they share a working dir — a
+ # cwd divergence otherwise creates two files and silently loses tokens. The
+ # absolute path here makes such a mismatch visible in each process's logs.
+ logger.info("SQLite object store initialized at %s", os.path.abspath(config.db_path))
+ try:
+ yield store
+ finally:
+ await store.aclose()
diff --git a/frontends/aiq_api/src/aiq_api/register.py b/frontends/aiq_api/src/aiq_api/register.py
index 44f6f4a04..3ca50d4cf 100644
--- a/frontends/aiq_api/src/aiq_api/register.py
+++ b/frontends/aiq_api/src/aiq_api/register.py
@@ -21,6 +21,9 @@
"""
# Import the plugin registration - this makes it discoverable by NAT
+# Importing the module runs its @register_object_store decorator, adding the
+# serviceless SQLite token store (_type: aiq_sqlite) to NAT's object-store registry.
+from .mcp_auth import sqlite_object_store as _sqlite_object_store # noqa: F401
from .plugin import register_aiq_api
__all__ = ["register_aiq_api"]
diff --git a/frontends/aiq_api/src/aiq_api/routes/auth.py b/frontends/aiq_api/src/aiq_api/routes/auth.py
new file mode 100644
index 000000000..f702540e4
--- /dev/null
+++ b/frontends/aiq_api/src/aiq_api/routes/auth.py
@@ -0,0 +1,163 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Per-user MCP auth routes: status, connect, and OAuth callback.
+
+AIQ owns these routes (the control plane). The actual OAuth mechanics live
+behind :class:`ProtectedSourceAuthProvider`. The callback completes NAT's token
+exchange and persists the token, then closes the popup.
+"""
+
+from __future__ import annotations
+
+import html
+import json
+import logging
+
+from fastapi import FastAPI
+from fastapi import HTTPException
+from fastapi import Request
+from fastapi.responses import HTMLResponse
+
+from aiq_agent.common.data_source_registry import get_source
+
+from ..jobs.access import require_verified_principal
+from ..mcp_auth.models import SourceAuthStatusResponse
+from ..mcp_auth.models import SourceConnectResponse
+from ..mcp_auth.nat_provider import NatMcpAuthProvider
+from ..mcp_auth.serialize import connect_url_for
+
+logger = logging.getLogger(__name__)
+
+_CALLBACK_HTML = """
+{title}
+
+
{message}
+
+"""
+
+
+def _callback_page(source_id: str, *, ok: bool, message: str) -> HTMLResponse:
+ # `message` can carry a provider-controlled value (e.g. the OAuth `error`
+ # query param reflected by the callback), so HTML-escape it before it lands in
+ # the page body — otherwise a crafted `error` executes script in AIQ's origin.
+ # `source_id` is a validated registry id and json.dumps escapes it for the JS
+ # string context.
+ page = _CALLBACK_HTML.format(
+ title="Connected" if ok else "Authentication failed",
+ message=html.escape(message),
+ source_id_js=json.dumps(source_id),
+ ok_js="true" if ok else "false",
+ )
+ return HTMLResponse(content=page, status_code=200 if ok else 400, headers={"Cache-Control": "no-cache"})
+
+
+def _require_protected_source(source_id: str):
+ """Return the registry source or raise 404 if it isn't a protected MCP source."""
+ source = get_source(source_id)
+ if source is None or source.per_user_auth is None or not source.per_user_auth.required:
+ raise HTTPException(404, f"Unknown protected data source: {source_id}")
+ return source
+
+
+def register_mcp_auth_routes(app: FastAPI, provider: NatMcpAuthProvider) -> None:
+ """Register the per-user MCP auth routes against ``provider``."""
+
+ @app.get(
+ "/v1/auth/mcp/{source_id}/status",
+ response_model=SourceAuthStatusResponse,
+ tags=["mcp auth"],
+ summary="Get per-user auth status for a protected source",
+ )
+ async def mcp_auth_status(source_id: str) -> SourceAuthStatusResponse:
+ _require_protected_source(source_id)
+ principal = require_verified_principal()
+ state = await provider.get_status(principal, source_id)
+ connect_url = connect_url_for(source_id) if state.status != "connected" else None
+ return SourceAuthStatusResponse(
+ source_id=source_id,
+ status=state.status,
+ expires_at=state.expires_at,
+ connect_url=connect_url,
+ last_error=state.last_error,
+ )
+
+ @app.post(
+ "/v1/auth/mcp/{source_id}/connect",
+ response_model=SourceConnectResponse,
+ tags=["mcp auth"],
+ summary="Start (or resume) the OAuth flow for a protected source",
+ )
+ async def mcp_auth_connect(source_id: str) -> SourceConnectResponse:
+ _require_protected_source(source_id)
+ principal = require_verified_principal()
+
+ # If already connected, don't mint a new challenge.
+ state = await provider.get_status(principal, source_id)
+ if state.status == "connected":
+ return SourceConnectResponse(source_id=source_id, status="connected")
+
+ try:
+ challenge = await provider.start_auth(principal, source_id)
+ except ValueError as exc:
+ # Source declared but not configured for OAuth in this deployment.
+ raise HTTPException(503, str(exc)) from exc
+ except Exception as exc:
+ logger.exception("Failed to start MCP auth for source=%s", source_id)
+ raise HTTPException(502, "Could not start authentication flow") from exc
+
+ return SourceConnectResponse(
+ source_id=source_id,
+ status="auth_required",
+ auth_url=challenge.auth_url,
+ expires_at=challenge.expires_at,
+ )
+
+ @app.get(
+ "/v1/auth/mcp/{source_id}/callback",
+ tags=["mcp auth"],
+ summary="OAuth redirect callback for a protected source",
+ include_in_schema=False,
+ )
+ async def mcp_auth_callback(source_id: str, request: Request) -> HTMLResponse:
+ _require_protected_source(source_id)
+ state = request.query_params.get("state")
+ if not state:
+ return _callback_page(source_id, ok=False, message="Missing state. Please restart the connection.")
+
+ error = request.query_params.get("error")
+ if error:
+ return _callback_page(source_id, ok=False, message=f"Authorization was denied: {error}")
+
+ try:
+ completed_source = await provider.complete_callback(state, str(request.url))
+ except KeyError:
+ return _callback_page(source_id, ok=False, message="This connection link has expired. Please try again.")
+ except Exception:
+ logger.exception("MCP auth callback failed for source=%s", source_id)
+ return _callback_page(source_id, ok=False, message="Authentication failed. Please try again.")
+
+ if completed_source != source_id:
+ # State was bound to a different source — refuse to cross the streams.
+ logger.warning("Callback source mismatch: path=%s flow=%s", source_id, completed_source)
+ return _callback_page(source_id, ok=False, message="Connection mismatch. Please try again.")
+
+ return _callback_page(source_id, ok=True, message="Connected. You can close this window.")
+
+ logger.info("Registered /v1/auth/mcp/{source_id} status, connect, and callback routes")
diff --git a/frontends/aiq_api/src/aiq_api/routes/jobs.py b/frontends/aiq_api/src/aiq_api/routes/jobs.py
index f7d46e584..96121cb49 100644
--- a/frontends/aiq_api/src/aiq_api/routes/jobs.py
+++ b/frontends/aiq_api/src/aiq_api/routes/jobs.py
@@ -53,6 +53,7 @@
from nat.builder.framework_enum import LLMFrameworkEnum
from ..jobs.access import require_verified_principal
+from ..mcp_auth.models import PerUserAuthInfo
from ..registry import AGENT_REGISTRY
from ..registry import get_agent_config
@@ -226,6 +227,18 @@ async def _get_agent_available_source_ids(builder: WorkflowBuilder, agent_config
sid = get_source_id_for_tool(name)
if sid is not None:
source_ids.add(sid)
+
+ # Per-user MCP sources (e.g. Google Drive) contribute NO static tools — their
+ # tools are resolved per-user at run time by open_per_user_mcp_tools, so they
+ # never appear in the loop above. Treat a configured protected source as an
+ # available runtime candidate so submit validation doesn't 422 it; connectivity
+ # is enforced separately by the MCP auth preflight (409 mcp_auth_required).
+ from aiq_agent.common.data_source_registry import get_all_sources
+
+ for source in get_all_sources():
+ pua = source.per_user_auth
+ if pua is not None and pua.required:
+ source_ids.add(source.id)
return sorted(source_ids)
@@ -307,6 +320,24 @@ async def _validate_data_sources_for_agent(
)
+async def _preflight_mcp_auth(provider, principal, data_sources: list[str] | None):
+ """Return a 409 JSONResponse if any selected protected source is not connected, else None.
+
+ Thin HTTP wrapper over the shared :func:`evaluate_mcp_auth`; the same check
+ runs inside ``submit_agent_job`` (raising instead) so programmatic submitters
+ cannot bypass it. Source existence is validated earlier by
+ ``_validate_data_sources_for_agent``.
+ """
+ from fastapi.responses import JSONResponse
+
+ from ..mcp_auth.preflight import evaluate_mcp_auth
+
+ body = await evaluate_mcp_auth(provider, principal, data_sources)
+ if body is None:
+ return None
+ return JSONResponse(status_code=409, content=body.model_dump(mode="json"))
+
+
class JobStatusResponse(BaseModel):
"""Job status response."""
@@ -411,7 +442,15 @@ class DataSource(BaseModel):
id: str = Field(..., description="Unique identifier for the data source")
name: str = Field(..., description="Display name")
description: str | None = Field(default=None, description="Human-readable description")
+ default_enabled: bool = Field(
+ default=True,
+ description="Whether the source is toggled on by default in the UI (from registry metadata)",
+ )
requires_auth: bool = Field(default=False, description="Whether user authentication is required")
+ per_user_auth: PerUserAuthInfo | None = Field(
+ default=None,
+ description="Per-user MCP OAuth state for a protected source (omitted for unprotected sources)",
+ )
async def register_job_routes(app: FastAPI, builder: WorkflowBuilder, worker: FastApiFrontEndPluginWorker) -> None:
@@ -436,6 +475,21 @@ async def register_job_routes(app: FastAPI, builder: WorkflowBuilder, worker: Fa
from ..jobs.report_context import to_initial_files
from ..jobs.submit import JobIdConflictError
from ..jobs.submit import submit_agent_job as submit_authorized_job
+ from ..mcp_auth.factory import build_mcp_auth_provider
+ from ..mcp_auth.preflight import McpAuthRequiredError
+ from ..mcp_auth.serialize import build_listing_auth_info
+ from .auth import register_mcp_auth_routes
+
+ # Per-user MCP auth control plane. The provider is shared by the data-source
+ # listing, the status/connect/callback routes, and submit preflight so a flow
+ # started via /connect can be completed by /callback in the same process.
+ mcp_auth_provider = await build_mcp_auth_provider(builder)
+ # Publish the provider process-wide so submit_agent_job() can run the same
+ # connect-state preflight for programmatic submitters, not just this REST route.
+ from ..mcp_auth.active import set_active_mcp_auth_provider
+
+ set_active_mcp_auth_provider(mcp_auth_provider)
+ register_mcp_auth_routes(app, mcp_auth_provider)
if not get_all_sources():
logger.warning(
@@ -467,16 +521,22 @@ async def list_agents() -> AgentListResponse:
summary="List data sources",
)
async def list_data_sources() -> list[DataSource]:
- """List available data sources dynamically from the registry."""
- return [
- DataSource(
- id=source.id,
- name=source.name,
- description=source.description,
- requires_auth=source.requires_auth,
+ """List available data sources, including the current user's per-source auth state."""
+ principal = require_verified_principal()
+ sources = []
+ for source in get_all_sources():
+ per_user_auth = await build_listing_auth_info(mcp_auth_provider, principal, source)
+ sources.append(
+ DataSource(
+ id=source.id,
+ name=source.name,
+ description=source.description,
+ default_enabled=source.default_enabled,
+ requires_auth=source.requires_auth,
+ per_user_auth=per_user_auth,
+ )
)
- for source in get_all_sources()
- ]
+ return sources
logger.info("Registered /v1/data_sources and /v1/jobs/async/agents routes")
@@ -549,7 +609,12 @@ async def health_check():
),
responses={
400: {"description": "Unknown agent type or invalid request"},
- 409: {"description": "A custom job_id was supplied that collides with an existing job"},
+ 409: {
+ "description": (
+ "A custom job_id was supplied that collides with an existing job, or a selected "
+ "protected data source requires per-user OAuth connection"
+ )
+ },
422: {"description": "One or more unknown or agent-unavailable data source IDs"},
500: {"description": "Failed to persist async job authorization metadata"},
503: {"description": "Dask scheduler not available"},
@@ -591,6 +656,13 @@ async def submit_job(
if _sandbox_caps_configured() and _agent_uses_sandbox(builder, agent_config.config_name):
await _enforce_sandbox_concurrency(db_url, principal)
+ # Preflight protected MCP sources: block before enqueue if a selected
+ # protected source is not connected. When data_sources is None the job
+ # may use any tool, so every protected source must be connected.
+ mcp_block = await _preflight_mcp_auth(mcp_auth_provider, principal, req.data_sources)
+ if mcp_block is not None:
+ return mcp_block
+
# Propagate auth token to Dask worker for requires_auth data sources
from aiq_agent.auth import get_auth_token
@@ -608,6 +680,14 @@ async def submit_job(
)
except JobIdConflictError:
raise HTTPException(409, f"Job already exists: {req.job_id}")
+ except McpAuthRequiredError as e:
+ # submit_agent_job runs the same MCP preflight and raises if a selected
+ # protected source became disconnected between the route preflight above
+ # and enqueue. Surface the SAME 409 mcp_auth_required contract instead of
+ # letting it fall through to the generic 500 handler.
+ from fastapi.responses import JSONResponse
+
+ return JSONResponse(status_code=409, content=e.response.model_dump(mode="json"))
except RuntimeError as e:
# The principal is resolved above, so a RuntimeError here is an
# availability/config failure (e.g. scheduler not configured), not an
@@ -1436,6 +1516,7 @@ async def _sse_generator_postgres(job_store, job_id: str, db_url: str, start_eve
Achieves sub-10ms latency compared to 500ms polling interval.
"""
import asyncio
+ import time
import asyncpg
@@ -1450,6 +1531,12 @@ async def _sse_generator_postgres(job_store, job_id: str, db_url: str, start_eve
sequence_id = start_event_id
terminal_statuses = {JobStatus.SUCCESS.value, JobStatus.FAILURE.value, JobStatus.INTERRUPTED.value}
is_reconnect = start_event_id > 0
+ # Emit an SSE keepalive comment after this many seconds of silence so an
+ # upstream idle timeout (OpenShift router / edge / proxy) never closes the
+ # connection. job.heartbeat only starts once the worker runs, so it does not
+ # cover worker cold-start on the first request — this keepalive does.
+ SSE_KEEPALIVE_INTERVAL = 15.0
+ last_keepalive = time.monotonic()
def format_sse(event_type: str, data: dict, event_id: int | None = None) -> str:
"""Format an SSE frame and advance (or set) the monotonic event sequence id."""
@@ -1560,6 +1647,13 @@ def notification_handler(connection, pid, channel_name, payload):
last_event_id = db_event_id
event_type = event.pop("type", "event")
yield format_sse(event_type, event, db_event_id)
+ # Keepalive during silent periods (e.g. worker cold-start) so an
+ # upstream idle timeout never closes the connection.
+ if fallback_events:
+ last_keepalive = time.monotonic()
+ elif (time.monotonic() - last_keepalive) >= SSE_KEEPALIVE_INTERVAL:
+ last_keepalive = time.monotonic()
+ yield ": keepalive\n\n"
job = await job_store.get_job(job_id)
if not job:
@@ -1624,6 +1718,7 @@ async def _sse_generator_polling(job_store, job_id: str, db_url: str, start_even
Supports graceful shutdown via the SSE connection manager.
"""
import asyncio
+ import time
from nat.front_ends.fastapi.async_jobs.job_store import JobStatus
@@ -1638,6 +1733,11 @@ async def _sse_generator_polling(job_store, job_id: str, db_url: str, start_even
is_reconnect = start_event_id > 0
in_replay_mode = True
replay_mode_announced = False
+ # Emit an SSE keepalive comment after this many seconds of silent live polling
+ # so an upstream idle timeout never closes the connection (e.g. during worker
+ # cold-start, before the first event or 30s heartbeat arrives).
+ SSE_KEEPALIVE_INTERVAL = 15.0
+ last_keepalive = time.monotonic()
def format_sse(event_type: str, data: dict, event_id: int | None = None) -> str:
"""Format an SSE frame and advance (or set) the monotonic event sequence id."""
@@ -1677,6 +1777,7 @@ def format_sse(event_type: str, data: dict, event_id: int | None = None) -> str:
events = await EventStore.get_events_async(db_url, job_id, last_event_id, limit)
if events:
+ last_keepalive = time.monotonic()
logger.info(f"SSE: Fetched {len(events)} events for job {job_id} (after_id={last_event_id})")
elif job.status in terminal_statuses:
logger.warning(f"SSE: No events found for completed job {job_id} (after_id={last_event_id})")
@@ -1732,6 +1833,12 @@ def format_sse(event_type: str, data: dict, event_id: int | None = None) -> str:
replay_mode_announced = True
yield format_sse("stream.mode", {"mode": "live"})
+ # Keepalive during silent live periods (e.g. worker cold-start) so the
+ # idle-looking connection isn't closed by an upstream idle timeout.
+ if (time.monotonic() - last_keepalive) >= SSE_KEEPALIVE_INTERVAL:
+ last_keepalive = time.monotonic()
+ yield ": keepalive\n\n"
+
shutdown_signaled = await connection_manager.wait_or_shutdown(0.5)
if shutdown_signaled:
logger.info("SSE stream closing for job %s due to server shutdown (during wait)", job_id)
diff --git a/frontends/aiq_api/tests/test_auth.py b/frontends/aiq_api/tests/test_auth.py
index d3a56acd8..7c70e3a1f 100644
--- a/frontends/aiq_api/tests/test_auth.py
+++ b/frontends/aiq_api/tests/test_auth.py
@@ -897,6 +897,20 @@ def test_path_allowed_exact_and_prefix(self) -> None:
assert mw._path_allowed("/v1/jobs/async/job/abc/result") is True
assert mw._path_allowed("/v1/jobs/async/job") is True
assert mw._path_allowed("/nope") is False
+ # Per-user MCP auth routes must be reachable externally.
+ assert mw._path_allowed("/v1/auth/mcp/gdrive/status") is True
+ assert mw._path_allowed("/v1/auth/mcp/gdrive/connect") is True
+ assert mw._path_allowed("/v1/auth/mcp/gdrive/callback") is True
+
+ def test_mcp_oauth_callback_is_auth_exempt(self) -> None:
+ from aiq_api.auth.middleware import _is_oauth_callback_path
+
+ # Only the OAuth redirect callback is exempt (no AIQ token; secured by state).
+ assert _is_oauth_callback_path("/v1/auth/mcp/gdrive/callback") is True
+ # status/connect must still require auth (they need the principal).
+ assert _is_oauth_callback_path("/v1/auth/mcp/gdrive/status") is False
+ assert _is_oauth_callback_path("/v1/auth/mcp/gdrive/connect") is False
+ assert _is_oauth_callback_path("/v1/data_sources") is False
@pytest.mark.asyncio
async def test_non_http_passthrough(self) -> None:
diff --git a/frontends/aiq_api/tests/test_job_submit_data_sources.py b/frontends/aiq_api/tests/test_job_submit_data_sources.py
index a6e17ec90..1d0045ee4 100644
--- a/frontends/aiq_api/tests/test_job_submit_data_sources.py
+++ b/frontends/aiq_api/tests/test_job_submit_data_sources.py
@@ -643,3 +643,25 @@ async def test_validation_does_not_call_get_all_tool_refs_when_fn_config_tools_i
_, kwargs = builder.get_tools.await_args
assert kwargs["tool_names"] == ["knowledge_search_tool"]
submitted_job.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_list_data_sources_exposes_default_enabled(submit_app):
+ """GET /v1/data_sources must surface the registry's default_enabled (not hardcode True)."""
+ app, _submitted_job, _builder = submit_app
+ # Re-populate at request time: list_data_sources() reads the registry per request.
+ reset_registry()
+ populate_from_config(
+ [
+ {"id": "web_search", "name": "Web Search", "description": "x"}, # default_enabled -> True
+ {"id": "off_by_default", "name": "Off", "description": "y", "default_enabled": False},
+ ]
+ )
+
+ with TestClient(app) as client:
+ response = client.get("/v1/data_sources")
+
+ assert response.status_code == 200
+ by_id = {s["id"]: s for s in response.json()}
+ assert by_id["web_search"]["default_enabled"] is True
+ assert by_id["off_by_default"]["default_enabled"] is False
diff --git a/frontends/aiq_api/tests/test_mcp_auth_factory.py b/frontends/aiq_api/tests/test_mcp_auth_factory.py
new file mode 100644
index 000000000..515a8ed3d
--- /dev/null
+++ b/frontends/aiq_api/tests/test_mcp_auth_factory.py
@@ -0,0 +1,143 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+
+from aiq_agent.common.data_source_registry import populate_from_config
+from aiq_agent.common.data_source_registry import reset_registry
+from aiq_api.mcp_auth.factory import build_mcp_auth_provider
+
+
+class _FakeProvider:
+ """Stand-in for NAT's MCPOAuth2Provider with discovery already resolved."""
+
+ def __init__(self, *, discover_ok: bool = True, client_id: str | None = "client-xyz"):
+ self.config = SimpleNamespace(
+ server_url="", # empty -> factory probe is skipped (offline test)
+ redirect_uri="https://aiq.example/v1/auth/mcp/gdrive/callback",
+ scopes=["https://www.googleapis.com/auth/drive.readonly"],
+ client_id=client_id,
+ client_secret="shh", # pragma: allowlist secret
+ use_pkce=True,
+ token_endpoint_auth_method="client_secret_post",
+ token_storage_object_store="mcp_token_store",
+ )
+ self._discover_ok = discover_ok
+ self._cached_endpoints = None
+ self._cached_credentials = None
+
+ async def _discover_and_register(self, response=None):
+ if not self._discover_ok:
+ raise RuntimeError("server unreachable")
+ self._cached_endpoints = SimpleNamespace(
+ authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
+ token_url="https://oauth2.googleapis.com/token",
+ )
+ self._cached_credentials = SimpleNamespace(client_id="client-xyz", client_secret="shh")
+
+
+class _FakeBuilder:
+ def __init__(self, provider):
+ self._provider = provider
+
+ async def get_auth_provider(self, name):
+ if self._provider is None:
+ raise KeyError(name)
+ return self._provider
+
+ async def get_object_store_client(self, name):
+ return SimpleNamespace(name=name) # ObjectStoreTokenStorage only needs an object
+
+
+@pytest.fixture(autouse=True)
+def _registry():
+ reset_registry()
+ yield
+ reset_registry()
+
+
+def _register_gdrive():
+ populate_from_config(
+ [
+ {
+ "id": "gdrive",
+ "name": "Google Drive",
+ "requires_auth": True,
+ "per_user_auth": {
+ "required": True,
+ "provider": "google",
+ "mcp_server_id": "gdrive",
+ "auth_provider": "mcp_oauth2_gdrive",
+ },
+ },
+ ]
+ )
+
+
+def test_factory_resolves_settings_from_nat_provider():
+ _register_gdrive()
+ provider = build_from(_FakeBuilder(_FakeProvider()))
+ assert provider.is_protected("gdrive")
+ settings = provider.settings_by_source["gdrive"]
+ assert settings.authorization_url == "https://accounts.google.com/o/oauth2/v2/auth"
+ assert settings.token_url == "https://oauth2.googleapis.com/token"
+ assert settings.client_id == "client-xyz"
+ assert settings.redirect_uri.endswith("/v1/auth/mcp/gdrive/callback")
+ assert settings.scopes == ["https://www.googleapis.com/auth/drive.readonly"]
+
+
+def test_factory_skips_source_when_provider_missing():
+ _register_gdrive()
+ provider = build_from(_FakeBuilder(None)) # get_auth_provider raises
+ assert not provider.is_protected("gdrive") # left unconfigured -> status will be 'error'
+
+
+def test_factory_skips_source_when_discovery_fails():
+ _register_gdrive()
+ provider = build_from(_FakeBuilder(_FakeProvider(discover_ok=False, client_id=None)))
+ assert not provider.is_protected("gdrive")
+
+
+def test_factory_skips_second_source_sharing_token_store():
+ """Two protected sources must not share one token-storage object store.
+
+ NAT keys tokens per user only, so a shared store would let the sources
+ overwrite each other's credentials. The factory fails closed: the first
+ source claims the store, the second is skipped (left unconfigured).
+ """
+ populate_from_config(
+ [
+ {
+ "id": "gdrive",
+ "name": "Google Drive",
+ "requires_auth": True,
+ "per_user_auth": {"required": True, "mcp_server_id": "gdrive", "auth_provider": "mcp_oauth2_gdrive"},
+ },
+ {
+ "id": "notion",
+ "name": "Notion",
+ "requires_auth": True,
+ "per_user_auth": {"required": True, "mcp_server_id": "notion", "auth_provider": "mcp_oauth2_notion"},
+ },
+ ]
+ )
+ # Both auth providers resolve to a store named "mcp_token_store" (the fake's
+ # default), so the second source collides with the first.
+ provider = build_from(_FakeBuilder(_FakeProvider()))
+ assert provider.is_protected("gdrive")
+ assert not provider.is_protected("notion")
+
+
+def test_factory_ignores_unprotected_sources():
+ populate_from_config([{"id": "web_search", "name": "Web", "description": "x"}])
+ provider = build_from(_FakeBuilder(_FakeProvider()))
+ assert provider.settings_by_source == {}
+
+
+def build_from(builder):
+ return asyncio.run(build_mcp_auth_provider(builder))
diff --git a/frontends/aiq_api/tests/test_mcp_auth_provider.py b/frontends/aiq_api/tests/test_mcp_auth_provider.py
new file mode 100644
index 000000000..98176c5da
--- /dev/null
+++ b/frontends/aiq_api/tests/test_mcp_auth_provider.py
@@ -0,0 +1,176 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import asyncio
+from datetime import UTC
+from datetime import datetime
+from datetime import timedelta
+from unittest.mock import AsyncMock
+
+import pytest
+
+from aiq_agent.auth import Principal
+from aiq_api.mcp_auth.nat_provider import NatMcpAuthProvider
+from aiq_api.mcp_auth.nat_provider import OAuthSourceSettings
+from aiq_api.mcp_auth.nat_provider import _auth_result_from_token
+from aiq_api.mcp_auth.provider import ProtectedSourceAuthProvider
+from aiq_api.mcp_auth.provider import principal_user_id
+from nat.authentication.token_storage import InMemoryTokenStorage
+
+PRINCIPAL = Principal(type="jwt", sub="user-1")
+
+
+def _settings() -> OAuthSourceSettings:
+ return OAuthSourceSettings(
+ source_id="gdrive",
+ mcp_server_id="gdrive",
+ provider="google",
+ authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
+ token_url="https://oauth2.googleapis.com/token",
+ client_id="client-123",
+ client_secret="secret", # pragma: allowlist secret
+ scopes=["https://www.googleapis.com/auth/drive.readonly"],
+ redirect_uri="https://aiq.example/v1/auth/mcp/gdrive/callback",
+ )
+
+
+@pytest.fixture
+def provider() -> tuple[NatMcpAuthProvider, InMemoryTokenStorage]:
+ store = InMemoryTokenStorage()
+ prov = NatMcpAuthProvider(
+ settings_by_source={"gdrive": _settings()},
+ token_storage_resolver=lambda _s: store,
+ )
+ return prov, store
+
+
+def test_satisfies_protocol(provider):
+ prov, _ = provider
+ assert isinstance(prov, ProtectedSourceAuthProvider)
+
+
+def test_status_not_connected_initially(provider):
+ prov, _ = provider
+ state = asyncio.run(prov.get_status(PRINCIPAL, "gdrive"))
+ assert state.status == "not_connected"
+
+
+def test_status_error_for_unconfigured_source(provider):
+ prov, _ = provider
+ state = asyncio.run(prov.get_status(PRINCIPAL, "not-configured"))
+ assert state.status == "error" and state.last_error
+
+
+def test_start_auth_mints_provider_url_with_pkce_and_state(provider):
+ prov, _ = provider
+ challenge = asyncio.run(prov.start_auth(PRINCIPAL, "gdrive"))
+ assert challenge.auth_url.startswith("https://accounts.google.com/o/oauth2/v2/auth?")
+ assert f"state={challenge.state}" in challenge.auth_url
+ assert "code_challenge=" in challenge.auth_url
+ assert "client_id=client-123" in challenge.auth_url
+ assert challenge.state in prov._pending # pending flow registered for the callback
+ # Back-compat: with no resource configured, no resource indicator is added.
+ assert "resource=" not in challenge.auth_url
+
+
+def test_start_auth_includes_resource_when_set():
+ # Mirrors NAT's MCPOAuth2Provider, which adds the RFC 8707 resource indicator to
+ # the authorize request (authorization_kwargs={"resource": ...}).
+ from urllib.parse import parse_qs
+ from urllib.parse import urlparse
+
+ settings = _settings().model_copy(update={"resource": "https://maas.example/maas/gdrive/mcp"})
+ prov = NatMcpAuthProvider(
+ settings_by_source={"gdrive": settings},
+ token_storage_resolver=lambda _s: InMemoryTokenStorage(),
+ )
+ challenge = asyncio.run(prov.start_auth(PRINCIPAL, "gdrive"))
+ params = parse_qs(urlparse(challenge.auth_url).query)
+ assert params.get("resource") == ["https://maas.example/maas/gdrive/mcp"]
+
+
+def test_connected_after_token_stored(provider):
+ prov, store = provider
+ expires = datetime.now(UTC) + timedelta(hours=1)
+ asyncio.run(
+ store.store(
+ principal_user_id(PRINCIPAL),
+ _auth_result_from_token({"access_token": "tok", "refresh_token": "r", "expires_at": expires.timestamp()}),
+ )
+ )
+ state = asyncio.run(prov.get_status(PRINCIPAL, "gdrive"))
+ assert state.status == "connected"
+ assert abs((state.expires_at - expires).total_seconds()) < 2
+
+
+def test_expired_token_reports_expired(provider):
+ prov, store = provider
+ past = datetime.now(UTC) - timedelta(minutes=5)
+ asyncio.run(
+ store.store(
+ principal_user_id(PRINCIPAL),
+ _auth_result_from_token({"access_token": "old", "expires_at": past.timestamp()}),
+ )
+ )
+ state = asyncio.run(prov.get_status(PRINCIPAL, "gdrive"))
+ assert state.status == "expired"
+
+
+def test_require_connected_blocks_only_disconnected_protected(provider):
+ prov, _ = provider
+ blocked = asyncio.run(prov.require_connected(PRINCIPAL, ["gdrive", "web_search"]))
+ # web_search is not configured/protected -> ignored; gdrive blocked with an auth_url
+ assert [c.source_id for c in blocked] == ["gdrive"]
+ assert blocked[0].auth_url.startswith("https://accounts.google.com")
+
+
+def test_complete_callback_exchanges_and_stores(provider, monkeypatch):
+ prov, store = provider
+ challenge = asyncio.run(prov.start_auth(PRINCIPAL, "gdrive"))
+
+ # Stub the network token exchange on the pending flow's authlib client.
+ flow = prov._pending[challenge.state]
+ expires = datetime.now(UTC) + timedelta(hours=1)
+ flow.client.fetch_token = AsyncMock(
+ return_value={"access_token": "fresh", "refresh_token": "r", "expires_at": expires.timestamp()}
+ )
+ flow.client.aclose = AsyncMock()
+
+ completed = asyncio.run(
+ prov.complete_callback(challenge.state, f"https://aiq.example/cb?code=abc&state={challenge.state}")
+ )
+ assert completed == "gdrive"
+ # Token now retrievable under the principal's key -> status connected
+ state = asyncio.run(prov.get_status(PRINCIPAL, "gdrive"))
+ assert state.status == "connected"
+ assert challenge.state not in prov._pending
+
+
+def test_complete_callback_unknown_state_raises(provider):
+ prov, _ = provider
+ with pytest.raises(KeyError):
+ asyncio.run(prov.complete_callback("nope", "https://aiq.example/cb?code=x&state=nope"))
+
+
+def test_prune_closes_expired_flow_client():
+ # An abandoned challenge (started, never completed) must have its
+ # AsyncOAuth2Client closed when pruned, otherwise it leaks httpx connections.
+ clock = [datetime(2026, 1, 1, tzinfo=UTC)]
+ prov = NatMcpAuthProvider(
+ settings_by_source={"gdrive": _settings()},
+ token_storage_resolver=lambda _s: InMemoryTokenStorage(),
+ challenge_ttl=timedelta(minutes=5),
+ now=lambda: clock[0],
+ )
+ challenge = asyncio.run(prov.start_auth(PRINCIPAL, "gdrive"))
+ closer = AsyncMock()
+ prov._pending[challenge.state].client.aclose = closer
+
+ # Advance past the TTL so the flow is expired, then trigger a prune.
+ clock[0] = clock[0] + timedelta(minutes=10)
+ asyncio.run(prov.start_auth(PRINCIPAL, "gdrive"))
+
+ closer.assert_awaited_once()
+ assert challenge.state not in prov._pending
diff --git a/frontends/aiq_api/tests/test_mcp_auth_routes.py b/frontends/aiq_api/tests/test_mcp_auth_routes.py
new file mode 100644
index 000000000..8e770c60d
--- /dev/null
+++ b/frontends/aiq_api/tests/test_mcp_auth_routes.py
@@ -0,0 +1,187 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import json
+from datetime import UTC
+from datetime import datetime
+from datetime import timedelta
+from unittest.mock import AsyncMock
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from aiq_agent.auth import Principal
+from aiq_agent.common.data_source_registry import populate_from_config
+from aiq_agent.common.data_source_registry import reset_registry
+from aiq_api.mcp_auth.nat_provider import NatMcpAuthProvider
+from aiq_api.mcp_auth.nat_provider import OAuthSourceSettings
+from aiq_api.mcp_auth.nat_provider import _auth_result_from_token
+from aiq_api.mcp_auth.provider import principal_user_id
+from aiq_api.routes import auth as auth_routes
+from nat.authentication.token_storage import InMemoryTokenStorage
+
+PRINCIPAL = Principal(type="jwt", sub="user-1")
+
+
+def _settings() -> OAuthSourceSettings:
+ return OAuthSourceSettings(
+ source_id="gdrive",
+ mcp_server_id="gdrive",
+ provider="google",
+ authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
+ token_url="https://oauth2.googleapis.com/token",
+ client_id="client-123",
+ client_secret="secret", # pragma: allowlist secret
+ scopes=["drive.readonly"],
+ redirect_uri="https://aiq.example/v1/auth/mcp/gdrive/callback",
+ )
+
+
+@pytest.fixture(autouse=True)
+def registry():
+ reset_registry()
+ populate_from_config(
+ [
+ {"id": "web_search", "name": "Web Search", "description": "x"},
+ {
+ "id": "gdrive",
+ "name": "Google Drive",
+ "description": "Drive",
+ "requires_auth": True,
+ "per_user_auth": {"required": True, "provider": "google", "mcp_server_id": "gdrive"},
+ },
+ ]
+ )
+ yield
+ reset_registry()
+
+
+@pytest.fixture
+def client(monkeypatch):
+ store = InMemoryTokenStorage()
+ provider = NatMcpAuthProvider(settings_by_source={"gdrive": _settings()}, token_storage_resolver=lambda _s: store)
+ monkeypatch.setattr(auth_routes, "require_verified_principal", lambda: PRINCIPAL)
+ app = FastAPI()
+ auth_routes.register_mcp_auth_routes(app, provider)
+ return TestClient(app), provider, store
+
+
+def test_status_not_connected_offers_connect_url(client):
+ tc, _, _ = client
+ resp = tc.get("/v1/auth/mcp/gdrive/status")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["status"] == "not_connected"
+ assert body["connect_url"] == "/v1/auth/mcp/gdrive/connect"
+
+
+def test_status_unknown_source_404(client):
+ tc, _, _ = client
+ assert tc.get("/v1/auth/mcp/web_search/status").status_code == 404 # not a protected source
+ assert tc.get("/v1/auth/mcp/nope/status").status_code == 404
+
+
+def test_connect_returns_auth_url_structured(client):
+ tc, _, _ = client
+ resp = tc.post("/v1/auth/mcp/gdrive/connect")
+ assert resp.status_code == 200
+ body = resp.json()
+ # The auth_url is returned in a structured response (not UI-only) for all clients.
+ assert body["status"] == "auth_required"
+ assert body["auth_url"].startswith("https://accounts.google.com/o/oauth2/v2/auth?")
+
+
+def test_connect_when_already_connected(client):
+ tc, provider, store = client
+ import asyncio
+
+ expires = datetime.now(UTC) + timedelta(hours=1)
+ asyncio.run(
+ store.store(
+ principal_user_id(PRINCIPAL),
+ _auth_result_from_token({"access_token": "t", "expires_at": expires.timestamp()}),
+ )
+ )
+ body = tc.post("/v1/auth/mcp/gdrive/connect").json()
+ assert body["status"] == "connected" and body.get("auth_url") is None
+
+
+def test_connect_then_callback_completes(client):
+ tc, provider, store = client
+ auth_url = tc.post("/v1/auth/mcp/gdrive/connect").json()["auth_url"]
+ state = auth_url.split("state=")[1].split("&")[0]
+
+ # Stub the token exchange on the pending flow.
+ flow = provider._pending[state]
+ expires = datetime.now(UTC) + timedelta(hours=1)
+ flow.client.fetch_token = AsyncMock(
+ return_value={"access_token": "fresh", "refresh_token": "r", "expires_at": expires.timestamp()}
+ )
+ flow.client.aclose = AsyncMock()
+
+ cb = tc.get(f"/v1/auth/mcp/gdrive/callback?code=abc&state={state}")
+ assert cb.status_code == 200
+ assert "Connected" in cb.text
+ assert tc.get("/v1/auth/mcp/gdrive/status").json()["status"] == "connected"
+
+
+def test_callback_unknown_state_renders_error(client):
+ tc, _, _ = client
+ cb = tc.get("/v1/auth/mcp/gdrive/callback?code=abc&state=bogus")
+ assert cb.status_code == 400
+ assert "expired" in cb.text.lower()
+
+
+def test_callback_provider_denied(client):
+ tc, _, _ = client
+ cb = tc.get("/v1/auth/mcp/gdrive/callback?error=access_denied&state=whatever")
+ assert cb.status_code == 400
+ assert "denied" in cb.text.lower()
+
+
+def test_callback_error_is_html_escaped(client):
+ """A provider-controlled `error` must not inject markup into the AIQ origin."""
+ tc, _, _ = client
+ payload = "
"
+ cb = tc.get("/v1/auth/mcp/gdrive/callback", params={"error": payload, "state": "whatever"})
+ assert cb.status_code == 400
+ # The raw script tag must not appear; the escaped form must.
+ assert "" not in cb.text
+ assert "<script>" in cb.text
+
+
+def test_preflight_blocks_disconnected_with_409(client, monkeypatch):
+ import asyncio
+
+ from aiq_api.routes import jobs as jobs_routes
+
+ _, provider, _ = client
+ result = asyncio.run(jobs_routes._preflight_mcp_auth(provider, PRINCIPAL, ["gdrive", "web_search"]))
+ assert result is not None and result.status_code == 409
+ body = json.loads(result.body)
+ assert body["error"] == "mcp_auth_required"
+ assert len(body["sources"]) == 1
+ src = body["sources"][0]
+ assert src["source_id"] == "gdrive"
+ assert src["connect_url"] == "/v1/auth/mcp/gdrive/connect"
+ assert src["auth_url"].startswith("https://accounts.google.com")
+
+
+def test_preflight_allows_when_connected(client):
+ import asyncio
+
+ from aiq_api.routes import jobs as jobs_routes
+
+ _, provider, store = client
+ expires = datetime.now(UTC) + timedelta(hours=1)
+ asyncio.run(
+ store.store(
+ principal_user_id(PRINCIPAL),
+ _auth_result_from_token({"access_token": "t", "expires_at": expires.timestamp()}),
+ )
+ )
+ result = asyncio.run(jobs_routes._preflight_mcp_auth(provider, PRINCIPAL, ["gdrive"]))
+ assert result is None # connected -> no block
diff --git a/frontends/aiq_api/tests/test_runtime_tools_token.py b/frontends/aiq_api/tests/test_runtime_tools_token.py
new file mode 100644
index 000000000..adaef0e0a
--- /dev/null
+++ b/frontends/aiq_api/tests/test_runtime_tools_token.py
@@ -0,0 +1,115 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import asyncio
+from contextlib import AsyncExitStack
+from datetime import UTC
+from datetime import datetime
+from datetime import timedelta
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+from pydantic import SecretStr
+
+from aiq_api.mcp_auth import runtime_tools
+from aiq_api.mcp_auth.runtime_tools import PerUserMcpSourceUnavailableError
+from nat.authentication.token_storage import InMemoryTokenStorage
+from nat.builder.context import ContextState
+from nat.data_models.authentication import AuthResult
+from nat.data_models.authentication import BearerTokenCred
+
+USER = "verified:alice"
+
+
+def _auth(*, expired: bool) -> AuthResult:
+ delta = timedelta(hours=-1) if expired else timedelta(hours=1)
+ return AuthResult(
+ credentials=[BearerTokenCred(token=SecretStr("tok"))],
+ token_expires_at=datetime.now(UTC) + delta,
+ )
+
+
+async def _check(stored: AuthResult | None) -> tuple[bool, bool]:
+ """Returns (usable, token_still_present_after)."""
+ store = InMemoryTokenStorage()
+ ContextState.get().user_id.set(USER)
+ if stored is not None:
+ await store.store(USER, stored)
+
+ async def _fake_resolve(builder, cfg, source_id):
+ return store
+
+ # _token_usable does `from .factory import _resolve_token_storage`, so patch it there.
+ with patch("aiq_api.mcp_auth.factory._resolve_token_storage", _fake_resolve):
+ usable = await runtime_tools._token_usable(builder=None, cfg=None, source_id="gdrive")
+ still_there = (await store.retrieve(USER)) is not None
+ return usable, still_there
+
+
+def test_valid_token_is_usable_and_kept():
+ usable, still_there = asyncio.run(_check(_auth(expired=False)))
+ assert usable is True
+ assert still_there is True
+
+
+def test_expired_token_is_not_usable_and_invalidated():
+ # The core fix: an expired token must be reported unusable AND deleted, so the
+ # next get_status flips the card to Reconnect instead of false "connected".
+ usable, still_there = asyncio.run(_check(_auth(expired=True)))
+ assert usable is False
+ assert still_there is False
+
+
+def test_missing_token_is_not_usable():
+ usable, still_there = asyncio.run(_check(None))
+ assert usable is False
+ assert still_there is False
+
+
+def _gdrive_source():
+ return SimpleNamespace(
+ id="gdrive",
+ per_user_auth=SimpleNamespace(required=True, auth_provider="mcp_oauth2_gdrive", tool_overrides={}),
+ )
+
+
+class _Builder:
+ async def get_auth_provider(self, name):
+ return SimpleNamespace(config=SimpleNamespace(server_url="https://mcp.example/mcp"))
+
+
+async def _open(data_sources):
+ async with AsyncExitStack() as stack:
+ return await runtime_tools.open_per_user_mcp_tools(
+ builder=_Builder(), data_sources=data_sources, exit_stack=stack
+ )
+
+
+async def _not_usable(*_args, **_kwargs):
+ return False
+
+
+def test_explicitly_selected_unusable_source_fails_closed():
+ # A source the caller named specifically that we can't resolve (expired/missing
+ # token) must raise rather than silently answering without it.
+ with (
+ patch("aiq_api.mcp_auth.runtime_tools.get_all_sources", return_value=[_gdrive_source()]),
+ patch("aiq_api.mcp_auth.runtime_tools._token_usable", _not_usable),
+ ):
+ with pytest.raises(PerUserMcpSourceUnavailableError) as exc:
+ asyncio.run(_open(["gdrive"]))
+ assert "gdrive" in str(exc.value)
+
+
+def test_all_sources_mode_skips_unusable_without_raising():
+ # When data_sources is None ("all"), stay best-effort: an unusable source is
+ # skipped, not fatal.
+ with (
+ patch("aiq_api.mcp_auth.runtime_tools.get_all_sources", return_value=[_gdrive_source()]),
+ patch("aiq_api.mcp_auth.runtime_tools._token_usable", _not_usable),
+ ):
+ tools = asyncio.run(_open(None))
+ assert tools == []
diff --git a/frontends/aiq_api/tests/test_sqlite_object_store.py b/frontends/aiq_api/tests/test_sqlite_object_store.py
new file mode 100644
index 000000000..148f3ac62
--- /dev/null
+++ b/frontends/aiq_api/tests/test_sqlite_object_store.py
@@ -0,0 +1,147 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import os
+import stat
+import sys
+
+import pytest
+
+from aiq_api.mcp_auth.sqlite_object_store import AiqSqliteObjectStore
+from nat.data_models.object_store import KeyAlreadyExistsError
+from nat.data_models.object_store import NoSuchKeyError
+from nat.object_store.models import ObjectStoreItem
+
+
+@pytest.fixture
+def db_path(tmp_path):
+ return str(tmp_path / "tokens.db")
+
+
+def _item(data: bytes = b'{"tok": 1}', **kw) -> ObjectStoreItem:
+ return ObjectStoreItem(data=data, content_type=kw.get("content_type"), metadata=kw.get("metadata"))
+
+
+async def test_cross_connection_round_trip(db_path):
+ """A second connection (proxy for the worker process) reads what the first wrote.
+
+ This is the property Redis exists for: the API process writes the token and a
+ *separate* Dask worker process reads it at job time. Two independent
+ connections to the same file stand in for those two processes.
+ """
+ writer = AiqSqliteObjectStore(db_path, bucket_name="mcp-tokens")
+ await writer.put_object("alice", _item(b"secret", metadata={"u": "alice"}))
+
+ reader = AiqSqliteObjectStore(db_path, bucket_name="mcp-tokens")
+ got = await reader.get_object("alice")
+ assert got.data == b"secret"
+ assert got.metadata == {"u": "alice"}
+
+ await writer.aclose()
+ await reader.aclose()
+
+
+async def test_put_rejects_existing_key(db_path):
+ store = AiqSqliteObjectStore(db_path)
+ await store.put_object("k", _item())
+ with pytest.raises(KeyAlreadyExistsError):
+ await store.put_object("k", _item())
+ await store.aclose()
+
+
+async def test_upsert_overwrites(db_path):
+ store = AiqSqliteObjectStore(db_path)
+ await store.put_object("k", _item(b"v1"))
+ await store.upsert_object("k", _item(b"v2"))
+ assert (await store.get_object("k")).data == b"v2"
+ await store.aclose()
+
+
+async def test_get_and_delete_missing_raise(db_path):
+ store = AiqSqliteObjectStore(db_path)
+ with pytest.raises(NoSuchKeyError):
+ await store.get_object("nope")
+ with pytest.raises(NoSuchKeyError):
+ await store.delete_object("nope")
+ await store.aclose()
+
+
+async def test_delete_removes(db_path):
+ store = AiqSqliteObjectStore(db_path)
+ await store.put_object("k", _item())
+ await store.delete_object("k")
+ with pytest.raises(NoSuchKeyError):
+ await store.get_object("k")
+ await store.aclose()
+
+
+async def test_expired_object_is_absent(db_path):
+ # Negative TTL => already expired on write; reads must treat it as missing.
+ store = AiqSqliteObjectStore(db_path, ttl=-1)
+ await store.upsert_object("k", _item())
+ with pytest.raises(NoSuchKeyError):
+ await store.get_object("k")
+ # And an expired key does not block a fresh put.
+ fresh = AiqSqliteObjectStore(db_path)
+ await fresh.put_object("k", _item(b"new"))
+ assert (await fresh.get_object("k")).data == b"new"
+ await store.aclose()
+ await fresh.aclose()
+
+
+async def test_bucket_prefix_isolates_keys(db_path):
+ a = AiqSqliteObjectStore(db_path, bucket_name="a")
+ b = AiqSqliteObjectStore(db_path, bucket_name="b")
+ await a.put_object("same", _item(b"in-a"))
+ await b.put_object("same", _item(b"in-b"))
+ assert (await a.get_object("same")).data == b"in-a"
+ assert (await b.get_object("same")).data == b"in-b"
+ await a.aclose()
+ await b.aclose()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes not meaningful on Windows")
+async def test_db_file_is_owner_only(db_path):
+ """The token DB (and its WAL/SHM sidecars) must not be group/world-readable.
+
+ The stored objects contain plaintext access/refresh tokens, so a 0644 file
+ would let other local users read the credential store.
+ """
+ store = AiqSqliteObjectStore(db_path, bucket_name="mcp-tokens")
+ # Force the connection (and thus file creation + WAL sidecars) to happen.
+ await store.put_object("alice", _item(b"secret"))
+
+ for path in (db_path, db_path + "-wal", db_path + "-shm"):
+ if os.path.exists(path):
+ mode = stat.S_IMODE(os.stat(path).st_mode)
+ assert mode & 0o077 == 0, f"{path} is accessible to group/other: {oct(mode)}"
+
+ await store.aclose()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes not meaningful on Windows")
+async def test_existing_loose_db_is_tightened(db_path):
+ """An already-present 0644 DB file is chmod'd to 0600 on next open."""
+ # Simulate a pre-existing world-readable file left by an older build.
+ with open(db_path, "wb"):
+ pass
+ os.chmod(db_path, 0o644)
+
+ store = AiqSqliteObjectStore(db_path)
+ await store.put_object("k", _item())
+ assert stat.S_IMODE(os.stat(db_path).st_mode) & 0o077 == 0
+ await store.aclose()
+
+
+async def test_registered_with_nat():
+ """The store is discoverable by NAT under _type: aiq_sqlite."""
+ from nat.runtime.loader import PluginTypes
+ from nat.runtime.loader import discover_and_register_plugins
+
+ discover_and_register_plugins(PluginTypes.ALL)
+ from nat.cli.type_registry import GlobalTypeRegistry
+
+ names = [getattr(i, "local_name", "") for i in GlobalTypeRegistry.get().get_registered_object_stores()]
+ assert "aiq_sqlite" in names
diff --git a/frontends/aiq_api/tests/test_submit_mcp_auth_guard.py b/frontends/aiq_api/tests/test_submit_mcp_auth_guard.py
new file mode 100644
index 000000000..bd87c2d5e
--- /dev/null
+++ b/frontends/aiq_api/tests/test_submit_mcp_auth_guard.py
@@ -0,0 +1,156 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""submit_agent_job() must run the per-user MCP auth preflight before enqueue.
+
+This guards the programmatic submit path (e.g. the chat researcher's async
+deep-research submit), which bypasses the REST route's 409 preflight. The check
+lives in submit_agent_job so both paths share one chokepoint.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import UTC
+from datetime import datetime
+from datetime import timedelta
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from aiq_agent.auth import Principal
+from aiq_agent.common.data_source_registry import populate_from_config
+from aiq_agent.common.data_source_registry import reset_registry
+from aiq_api.jobs import submit as submit_mod
+from aiq_api.mcp_auth import active as active_mod
+from aiq_api.mcp_auth.nat_provider import NatMcpAuthProvider
+from aiq_api.mcp_auth.nat_provider import OAuthSourceSettings
+from aiq_api.mcp_auth.nat_provider import _auth_result_from_token
+from aiq_api.mcp_auth.preflight import McpAuthRequiredError
+from aiq_api.mcp_auth.provider import principal_user_id
+from nat.authentication.token_storage import InMemoryTokenStorage
+
+PRINCIPAL = Principal(type="jwt", sub="user-1", email="u@example.com")
+
+
+def _settings() -> OAuthSourceSettings:
+ return OAuthSourceSettings(
+ source_id="gdrive",
+ mcp_server_id="gdrive",
+ provider="google",
+ authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
+ token_url="https://oauth2.googleapis.com/token",
+ client_id="client-123",
+ client_secret="secret", # pragma: allowlist secret
+ scopes=["drive.readonly"],
+ redirect_uri="https://aiq.example/v1/auth/mcp/gdrive/callback",
+ )
+
+
+class _FakeJobStore:
+ submitted = False
+
+ def __init__(self, **_kwargs):
+ pass
+
+ def ensure_job_id(self, job_id):
+ return job_id or "job-1"
+
+ async def submit_job(self, *, job_id, expiry_seconds, job_fn, job_args):
+ _FakeJobStore.submitted = True
+
+
+@pytest.fixture(autouse=True)
+def registry():
+ reset_registry()
+ populate_from_config(
+ [
+ {"id": "web_search", "name": "Web Search", "description": "x"},
+ {
+ "id": "gdrive",
+ "name": "Google Drive",
+ "description": "Drive",
+ "requires_auth": True,
+ "per_user_auth": {"required": True, "provider": "google", "mcp_server_id": "gdrive"},
+ },
+ ]
+ )
+ yield
+ reset_registry()
+
+
+@pytest.fixture
+def patched(monkeypatch):
+ import nat.front_ends.fastapi.async_jobs.job_store as js_mod
+
+ monkeypatch.setenv("NAT_DASK_SCHEDULER_ADDRESS", "tcp://localhost:8786")
+ monkeypatch.setattr(js_mod, "JobStore", _FakeJobStore)
+ monkeypatch.setattr(
+ submit_mod,
+ "get_agent_config",
+ lambda _t: SimpleNamespace(class_path="pkg.mod.Agent", config_name="deep_research_agent", public=True),
+ )
+ monkeypatch.setattr(submit_mod, "create_job_access", MagicMock())
+ _FakeJobStore.submitted = False
+
+ store = InMemoryTokenStorage()
+ provider = NatMcpAuthProvider(settings_by_source={"gdrive": _settings()}, token_storage_resolver=lambda _s: store)
+ active_mod.set_active_mcp_auth_provider(provider)
+ yield store
+ active_mod.set_active_mcp_auth_provider(None)
+
+
+def _submit(data_sources):
+ return asyncio.run(
+ submit_mod.submit_agent_job(
+ agent_type="deep_researcher",
+ input_text="query",
+ owner="u@example.com",
+ principal=PRINCIPAL,
+ data_sources=data_sources,
+ )
+ )
+
+
+def test_blocks_when_protected_source_not_connected(patched):
+ with pytest.raises(McpAuthRequiredError) as exc:
+ _submit(["gdrive"])
+ assert _FakeJobStore.submitted is False # never enqueued
+ assert [s.source_id for s in exc.value.response.sources] == ["gdrive"]
+ assert "gdrive" in str(exc.value) # user-facing message names the source
+
+
+def test_blocks_when_data_sources_none_and_protected_disconnected(patched):
+ # data_sources=None means "any tool" -> every protected source must be connected.
+ with pytest.raises(McpAuthRequiredError):
+ _submit(None)
+ assert _FakeJobStore.submitted is False
+
+
+def test_allows_unprotected_only_selection(patched):
+ job_id = _submit(["web_search"]) # no protected source selected
+ assert job_id == "job-1"
+ assert _FakeJobStore.submitted is True
+
+
+def test_allows_when_protected_source_connected(patched):
+ store = patched
+ expires = datetime.now(UTC) + timedelta(hours=1)
+ asyncio.run(
+ store.store(
+ principal_user_id(PRINCIPAL),
+ _auth_result_from_token({"access_token": "t", "expires_at": expires.timestamp()}),
+ )
+ )
+ job_id = _submit(["gdrive"])
+ assert job_id == "job-1"
+ assert _FakeJobStore.submitted is True
+
+
+def test_no_active_provider_skips_guard(patched):
+ # When MCP auth is not configured in this process, there is nothing to enforce.
+ active_mod.set_active_mcp_auth_provider(None)
+ job_id = _submit(["gdrive"])
+ assert job_id == "job-1"
+ assert _FakeJobStore.submitted is True
diff --git a/frontends/aiq_api/tests/test_submit_owner_user_id.py b/frontends/aiq_api/tests/test_submit_owner_user_id.py
new file mode 100644
index 000000000..1890892c9
--- /dev/null
+++ b/frontends/aiq_api/tests/test_submit_owner_user_id.py
@@ -0,0 +1,80 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""submit_agent_job must forward the owner's canonical user_id as the last job arg
+so the worker can bind it on the NAT Context and per_user_mcp_client finds the token."""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from aiq_agent.auth import Principal
+from aiq_api.jobs import submit as submit_mod
+from aiq_api.mcp_auth.provider import principal_user_id
+
+
+class _FakeJobStore:
+ last_job_args = None
+
+ def __init__(self, **_kwargs):
+ pass
+
+ def ensure_job_id(self, job_id):
+ return job_id or "job-1"
+
+ async def submit_job(self, *, job_id, expiry_seconds, job_fn, job_args):
+ _FakeJobStore.last_job_args = job_args
+
+
+@pytest.fixture
+def patched(monkeypatch):
+ import nat.front_ends.fastapi.async_jobs.job_store as js_mod
+
+ monkeypatch.setenv("NAT_DASK_SCHEDULER_ADDRESS", "tcp://localhost:8786")
+ monkeypatch.setattr(js_mod, "JobStore", _FakeJobStore)
+ monkeypatch.setattr(
+ submit_mod,
+ "get_agent_config",
+ lambda _t: SimpleNamespace(class_path="pkg.mod.Agent", config_name="deep_research_agent", public=True),
+ )
+ monkeypatch.setattr(submit_mod, "create_job_access", MagicMock())
+ _FakeJobStore.last_job_args = None
+
+
+def test_submit_forwards_owner_user_id_as_last_arg(patched):
+ principal = Principal(type="jwt", sub="user-1", email="u@example.com")
+ asyncio.run(
+ submit_mod.submit_agent_job(
+ agent_type="deep_researcher",
+ input_text="query",
+ owner="u@example.com",
+ principal=principal,
+ auth_token="token-1",
+ )
+ )
+ job_args = _FakeJobStore.last_job_args
+ assert job_args is not None
+ # Owner user_id is appended last. Trailing worker args are:
+ # data_sources, auth_token, initial_files, output_metadata, owner_user_id.
+ assert job_args[-1] == principal_user_id(principal) == "jwt:user-1"
+ assert job_args[-4] == "token-1"
+
+
+def test_context_user_id_binding_mechanism():
+ """The worker binds owner_user_id via ContextState.user_id; Context.user_id reads it.
+
+ Guards the contract runner.run_agent_job relies on (and that per_user_mcp_client
+ reads via Context.get().user_id) against NAT-side changes.
+ """
+ from nat.builder.context import Context
+ from nat.builder.context import ContextState
+
+ token = ContextState.get().user_id.set("jwt:user-9")
+ try:
+ assert Context.get().user_id == "jwt:user-9"
+ finally:
+ ContextState.get().user_id.reset(token)
diff --git a/frontends/ui/src/adapters/api/data-sources-client.ts b/frontends/ui/src/adapters/api/data-sources-client.ts
index dae976482..1a221e1b5 100644
--- a/frontends/ui/src/adapters/api/data-sources-client.ts
+++ b/frontends/ui/src/adapters/api/data-sources-client.ts
@@ -19,6 +19,29 @@ const getBaseUrl = (): string => {
// Types
// ============================================================================
+/** Per-user MCP auth status for a protected source. */
+export type PerUserAuthStatus = 'connected' | 'not_connected' | 'expired' | 'error'
+
+/** Per-user MCP OAuth block attached to a protected data source (mirrors the API). */
+export interface PerUserAuthInfoFromAPI {
+ required: boolean
+ /** Auth mechanism (only mcp_oauth2 today) */
+ type?: 'mcp_oauth2'
+ /** Provider identifier, e.g. 'google' */
+ provider?: string | null
+ /** MCP server/auth-provider key */
+ mcp_server_id?: string | null
+ status?: PerUserAuthStatus | null
+ /** Stable URL to (re)start the connect flow */
+ connect_url?: string | null
+ /** Short-lived provider login URL (only present when an auth challenge was started) */
+ auth_url?: string | null
+ /** Token expiry (ISO timestamp) */
+ expires_at?: string | null
+ /** Last error detail, if status is 'error' */
+ last_error?: string | null
+}
+
export interface DataSourceFromAPI {
/** Unique identifier for the data source */
id: string
@@ -32,6 +55,8 @@ export interface DataSourceFromAPI {
default_enabled?: boolean
/** Whether the source requires user authentication */
requires_auth?: boolean
+ /** Per-user MCP OAuth state (present only for protected MCP sources) */
+ per_user_auth?: PerUserAuthInfoFromAPI | null
}
export interface DataSourcesResponse {
diff --git a/frontends/ui/src/adapters/api/index.ts b/frontends/ui/src/adapters/api/index.ts
index 8a803c9d4..0300b8d3b 100644
--- a/frontends/ui/src/adapters/api/index.ts
+++ b/frontends/ui/src/adapters/api/index.ts
@@ -72,8 +72,20 @@ export type {
DataSourcesClientOptions,
DataSourceFromAPI,
DataSourcesResponse,
+ PerUserAuthInfoFromAPI,
+ PerUserAuthStatus,
} from './data-sources-client'
+// MCP Auth Client (per-user OAuth control plane)
+export { createMcpAuthClient, openAuthPopupAndWait } from './mcp-auth-client'
+export type {
+ McpAuthClient,
+ McpAuthClientOptions,
+ SourceAuthStatusResponse,
+ SourceConnectResponse,
+ AuthPopupResult,
+} from './mcp-auth-client'
+
// Documents Schemas
export {
DocumentFileStatusSchema,
diff --git a/frontends/ui/src/adapters/api/mcp-auth-client.spec.ts b/frontends/ui/src/adapters/api/mcp-auth-client.spec.ts
new file mode 100644
index 000000000..430c84b13
--- /dev/null
+++ b/frontends/ui/src/adapters/api/mcp-auth-client.spec.ts
@@ -0,0 +1,96 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
+import { openAuthPopupAndWait } from './mcp-auth-client'
+import type { PerUserAuthStatus } from './data-sources-client'
+
+describe('openAuthPopupAndWait', () => {
+ let fakePopup: { closed: boolean; close: ReturnType }
+ let openSpy: ReturnType
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ fakePopup = { closed: false, close: vi.fn(() => { fakePopup.closed = true }) }
+ openSpy = vi.fn(() => fakePopup)
+ vi.stubGlobal('open', openSpy)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ vi.clearAllMocks()
+ })
+
+ // The core regression: clicking "Reconnect" starts from an `expired` status, so
+ // the first status probe sees `expired`. The popup must stay open through that
+ // baseline and only resolve once auth actually completes (`connected`).
+ test('does not close on the pre-existing expired status (Reconnect)', async () => {
+ const statuses: PerUserAuthStatus[] = ['expired', 'expired', 'connected']
+ let i = 0
+ const pollStatus = vi.fn(async () => statuses[Math.min(i++, statuses.length - 1)])
+
+ const result = openAuthPopupAndWait('https://provider/auth', 'gdrive', {
+ pollStatus,
+ pollIntervalMs: 1000,
+ })
+
+ // First two probes return the baseline `expired` — popup must remain open.
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(fakePopup.close).not.toHaveBeenCalled()
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(fakePopup.close).not.toHaveBeenCalled()
+
+ // Third probe flips to `connected` → resolve as success and close the popup.
+ await vi.advanceTimersByTimeAsync(1000)
+ await expect(result).resolves.toEqual({ ok: true, sourceId: 'gdrive' })
+ expect(fakePopup.close).toHaveBeenCalled()
+ })
+
+ test('resolves failure only on a NEW error, not the pre-existing one', async () => {
+ // Baseline already `error`; staying `error` must not resolve (it's the state
+ // we're trying to fix). A transition expired -> error would, but unchanged
+ // error should keep waiting until the popup closes / times out.
+ const pollStatus = vi.fn(async (): Promise => 'error')
+ const result = openAuthPopupAndWait('https://provider/auth', 'gdrive', {
+ pollStatus,
+ pollIntervalMs: 1000,
+ })
+
+ await vi.advanceTimersByTimeAsync(3000)
+ expect(fakePopup.close).not.toHaveBeenCalled()
+
+ // User gives up and closes the popup → resolve empty (caller re-checks status).
+ fakePopup.closed = true
+ await vi.advanceTimersByTimeAsync(700)
+ await expect(result).resolves.toEqual({})
+ })
+
+ test('resolves success via callback postMessage from the popup', async () => {
+ const result = openAuthPopupAndWait('https://provider/auth', 'gdrive', {})
+ // A genuine completion is posted by the callback page via window.opener, so
+ // event.source is the popup we opened.
+ const evt = new MessageEvent('message', { data: { type: 'mcp-auth', source_id: 'gdrive', ok: true } })
+ Object.defineProperty(evt, 'source', { value: fakePopup })
+ window.dispatchEvent(evt)
+ await expect(result).resolves.toEqual({ ok: true, sourceId: 'gdrive' })
+ expect(fakePopup.close).toHaveBeenCalled()
+ })
+
+ test('ignores an mcp-auth message from an untrusted source', async () => {
+ const result = openAuthPopupAndWait('https://provider/auth', 'gdrive', {})
+ // No event.source (a spoofed / cross-window message) must NOT be accepted.
+ window.dispatchEvent(new MessageEvent('message', { data: { type: 'mcp-auth', source_id: 'gdrive', ok: true } }))
+ // The spoofed message is ignored; the promise only settles when the popup
+ // actually closes (empty result), and the popup is never closed by the spoof.
+ fakePopup.closed = true
+ await vi.advanceTimersByTimeAsync(700)
+ await expect(result).resolves.toEqual({})
+ expect(fakePopup.close).not.toHaveBeenCalled()
+ })
+
+ test('resolves empty when the popup is blocked', async () => {
+ openSpy.mockReturnValueOnce(null)
+ await expect(openAuthPopupAndWait('https://provider/auth', 'gdrive', {})).resolves.toEqual({})
+ })
+})
diff --git a/frontends/ui/src/adapters/api/mcp-auth-client.ts b/frontends/ui/src/adapters/api/mcp-auth-client.ts
new file mode 100644
index 000000000..6e5f81c57
--- /dev/null
+++ b/frontends/ui/src/adapters/api/mcp-auth-client.ts
@@ -0,0 +1,218 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * MCP Auth API Client
+ *
+ * Per-user MCP OAuth control plane: read a protected source's connection
+ * status, start the connect flow (returns a provider login URL), and a helper
+ * to open that URL in a popup and resolve once the popup closes or the callback
+ * page posts back.
+ */
+
+import { apiConfig } from './config'
+import type { PerUserAuthStatus } from './data-sources-client'
+
+const getBaseUrl = (): string => {
+ const isBrowser = typeof window !== 'undefined'
+ return isBrowser ? '' : apiConfig.baseUrl
+}
+
+const apiUrl = (path: string): string => {
+ const baseUrl = getBaseUrl()
+ return baseUrl ? `${baseUrl}${path}` : `/api${path}`
+}
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface SourceAuthStatusResponse {
+ source_id: string
+ status: PerUserAuthStatus
+ expires_at?: string | null
+ connect_url?: string | null
+ last_error?: string | null
+}
+
+export interface SourceConnectResponse {
+ source_id: string
+ status: 'auth_required' | 'connected'
+ auth_url?: string | null
+ expires_at?: string | null
+}
+
+export interface McpAuthClientOptions {
+ authToken?: string
+}
+
+// ============================================================================
+// Client Factory
+// ============================================================================
+
+export const createMcpAuthClient = (options: McpAuthClientOptions = {}) => {
+ const { authToken } = options
+
+ const getHeaders = (): Record => {
+ const headers: Record = { 'Content-Type': 'application/json' }
+ if (authToken) {
+ headers['Authorization'] = `Bearer ${authToken}`
+ }
+ return headers
+ }
+
+ return {
+ /** Read the current per-user auth status for a protected source. */
+ async getStatus(sourceId: string, signal?: AbortSignal): Promise {
+ const response = await fetch(apiUrl(`/v1/auth/mcp/${encodeURIComponent(sourceId)}/status`), {
+ method: 'GET',
+ headers: getHeaders(),
+ signal,
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to fetch auth status: ${response.statusText}`)
+ }
+ return response.json()
+ },
+
+ /** Start (or resume) the OAuth flow; returns a provider login URL to open. */
+ async connect(sourceId: string): Promise {
+ const response = await fetch(apiUrl(`/v1/auth/mcp/${encodeURIComponent(sourceId)}/connect`), {
+ method: 'POST',
+ headers: getHeaders(),
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to start connection: ${response.statusText}`)
+ }
+ return response.json()
+ },
+ }
+}
+
+export type McpAuthClient = ReturnType
+
+// ============================================================================
+// Popup helper
+// ============================================================================
+
+export interface AuthPopupResult {
+ /** True if the callback page reported success via postMessage. Undefined if we
+ * only observed the window closing (caller should re-check status). */
+ ok?: boolean
+ /** The source id reported by the callback page, if any. */
+ sourceId?: string
+}
+
+export interface AuthPopupOptions {
+ /**
+ * Optional backend status probe. When provided, the popup also resolves as
+ * soon as the source reports a terminal status. This is the reliable signal:
+ * the provider's pages typically send `Cross-Origin-Opener-Policy`, which
+ * severs `window.opener` so the callback's `postMessage` and the parent's
+ * `popup.closed` check both go silent — leaving the card stuck on
+ * "Connecting…". The callback persists the token before it messages the
+ * opener, so a status probe sees the result regardless.
+ */
+ pollStatus?: () => Promise
+ /** How often to probe backend status, in ms. Default 1500. */
+ pollIntervalMs?: number
+ /** Stop waiting after this long, in ms, so a never-finished login can't poll
+ * forever. Default 180000 (3 min). */
+ timeoutMs?: number
+}
+
+/**
+ * Open the provider login URL in a popup and resolve when it closes, the
+ * callback page posts back, or (when `pollStatus` is supplied) the backend
+ * reports a terminal status. The caller should still re-fetch the source status
+ * after this resolves to confirm the connection.
+ */
+export function openAuthPopupAndWait(
+ authUrl: string,
+ sourceId: string,
+ options: AuthPopupOptions = {}
+): Promise {
+ const { pollStatus, pollIntervalMs = 1500, timeoutMs = 180_000 } = options
+ return new Promise((resolve) => {
+ const popup = window.open(authUrl, `mcp-auth-${sourceId}`, 'popup,width=520,height=680')
+
+ // Popup blocked — fall back to a same-tab redirect is too disruptive, so
+ // resolve immediately and let the caller surface the URL / re-check status.
+ if (!popup) {
+ resolve({})
+ return
+ }
+
+ let settled = false
+ const finish = (result: AuthPopupResult) => {
+ if (settled) return
+ settled = true
+ window.removeEventListener('message', onMessage)
+ clearInterval(poll)
+ if (statusPoll !== undefined) clearInterval(statusPoll)
+ clearTimeout(timeout)
+ resolve(result)
+ }
+
+ const onMessage = (event: MessageEvent) => {
+ // Only trust messages from the popup we opened: the callback page posts via
+ // window.opener.postMessage, so a genuine completion has event.source === popup.
+ // Reject anything else (a synthetic or cross-window message) so an unrelated
+ // page can't spoof an auth-complete. If COOP severs the opener relationship
+ // event.source won't match — the status poll below is the fallback for that.
+ if (event.source !== popup) return
+ const data = event.data
+ if (data && data.type === 'mcp-auth' && data.source_id === sourceId) {
+ try {
+ popup.close()
+ } catch {
+ /* ignore */
+ }
+ finish({ ok: !!data.ok, sourceId })
+ }
+ }
+ window.addEventListener('message', onMessage)
+
+ const poll = setInterval(() => {
+ if (popup.closed) {
+ finish({})
+ }
+ }, 700)
+
+ // Authoritative resolve path: poll the backend until the source reaches a
+ // terminal status. Survives the COOP opener-severing described above.
+ //
+ // `expired`/`error` are the PRE-EXISTING states that make the card show
+ // "Reconnect", so they're also what the first poll sees before the user has
+ // authenticated. Treating them as terminal would close the popup ~1.5s after
+ // it opens (UI: "Session expired") before login can complete. So we snapshot
+ // the baseline on the first probe and only resolve on a real transition:
+ // `connected` (success), or a NEW `error` that differs from the baseline.
+ let baselineStatus: PerUserAuthStatus | undefined
+ const statusPoll: ReturnType | undefined = pollStatus
+ ? setInterval(() => {
+ void (async () => {
+ let status: PerUserAuthStatus | undefined
+ try {
+ status = await pollStatus()
+ } catch {
+ return // transient probe failure — keep polling
+ }
+ if (baselineStatus === undefined) baselineStatus = status
+ const succeeded = status === 'connected'
+ const newlyErrored = status === 'error' && baselineStatus !== 'error'
+ if (succeeded || newlyErrored) {
+ try {
+ popup.close()
+ } catch {
+ /* ignore */
+ }
+ finish({ ok: succeeded, sourceId })
+ }
+ })()
+ }, pollIntervalMs)
+ : undefined
+
+ const timeout = setTimeout(() => finish({}), timeoutMs)
+ })
+}
diff --git a/frontends/ui/src/app/api/jobs/async/[...path]/route.ts b/frontends/ui/src/app/api/jobs/async/[...path]/route.ts
index 50c4eecec..3d0b34802 100644
--- a/frontends/ui/src/app/api/jobs/async/[...path]/route.ts
+++ b/frontends/ui/src/app/api/jobs/async/[...path]/route.ts
@@ -42,6 +42,27 @@ const buildBackendUrl = (path: string[]): string => {
return `${backendBase}/v1/jobs/async/${pathString}`
}
+/**
+ * Forward a non-2xx backend response to the browser.
+ *
+ * If the backend returned a JSON body (e.g. the 409 `mcp_auth_required` payload
+ * with its `sources`/`auth_url` details), pass it through verbatim with the
+ * original status so the client can act on the structure. Only fall back to the
+ * BACKEND_ERROR envelope for non-JSON error bodies, where there is nothing
+ * structured to preserve.
+ */
+const forwardBackendError = (status: number, errorText: string): NextResponse => {
+ try {
+ const parsed = JSON.parse(errorText)
+ return NextResponse.json(parsed, { status })
+ } catch {
+ return NextResponse.json(
+ { error: { code: 'BACKEND_ERROR', message: `Backend returned ${status}: ${errorText}` } },
+ { status }
+ )
+ }
+}
+
/**
* Get auth headers from request, including idToken cookie.
* Returns empty object when REQUIRE_AUTH=false to prevent user identification.
@@ -117,19 +138,7 @@ export async function GET(
if (!response.ok) {
const errorText = await response.text()
console.error('[Deep Research API] Backend error:', response.status, errorText)
-
- return new NextResponse(
- JSON.stringify({
- error: {
- code: 'BACKEND_ERROR',
- message: `Backend returned ${response.status}: ${errorText}`,
- },
- }),
- {
- status: response.status,
- headers: { 'Content-Type': 'application/json' },
- }
- )
+ return forwardBackendError(response.status, errorText)
}
// For SSE streams, pass through the response body
@@ -245,19 +254,7 @@ export async function POST(
if (!response.ok) {
const errorText = await response.text()
console.error('[Deep Research API] Backend error:', response.status, errorText)
-
- return new NextResponse(
- JSON.stringify({
- error: {
- code: 'BACKEND_ERROR',
- message: `Backend returned ${response.status}: ${errorText}`,
- },
- }),
- {
- status: response.status,
- headers: { 'Content-Type': 'application/json' },
- }
- )
+ return forwardBackendError(response.status, errorText)
}
// Return JSON response
diff --git a/frontends/ui/src/features/chat/store.ts b/frontends/ui/src/features/chat/store.ts
index 2e8ef16c4..9a617d6b2 100644
--- a/frontends/ui/src/features/chat/store.ts
+++ b/frontends/ui/src/features/chat/store.ts
@@ -378,17 +378,32 @@ const patchConversationMessageById = (
return didPatch ? { ...conversation, messages, updatedAt: new Date() } : conversation
}
+/**
+ * A protected per-user source (e.g. Google Drive) must be connected before it
+ * can be part of the active selection — otherwise it appears in "Selected Data
+ * Sources" and is submitted while unusable. Mirrors the card toggle, "Enable
+ * All", and the initial-fetch gate in the layout store.
+ */
+const isSelectableDataSource = (source: {
+ per_user_auth?: { required?: boolean; status?: string | null } | null
+}): boolean => !(source.per_user_auth?.required && source.per_user_auth.status !== 'connected')
+
const getDefaultEnabledDataSourceIds = (): string[] => {
const layoutStore = useLayoutStore.getState()
- return layoutStore.availableDataSources?.map((source) => source.id) ?? []
+ return (layoutStore.availableDataSources ?? []).filter(isSelectableDataSource).map((source) => source.id)
}
const restoreConversationDataSources = (conversation: Conversation): void => {
const layoutStore = useLayoutStore.getState()
if (conversation.enabledDataSourceIds) {
- const availableIds = new Set(layoutStore.availableDataSources?.map((source) => source.id) ?? [])
- const validIds = conversation.enabledDataSourceIds.filter((id) => availableIds.has(id))
+ // Only restore sources that are still available AND currently selectable —
+ // a protected source saved as enabled must not come back while it's not
+ // connected (e.g. an old session that had Google Drive on).
+ const selectableIds = new Set(
+ (layoutStore.availableDataSources ?? []).filter(isSelectableDataSource).map((source) => source.id)
+ )
+ const validIds = conversation.enabledDataSourceIds.filter((id) => selectableIds.has(id))
layoutStore.setEnabledDataSources(validIds)
return
}
diff --git a/frontends/ui/src/features/layout/components/DataConnectionCard.tsx b/frontends/ui/src/features/layout/components/DataConnectionCard.tsx
index 254cfa53f..076a0fca2 100644
--- a/frontends/ui/src/features/layout/components/DataConnectionCard.tsx
+++ b/frontends/ui/src/features/layout/components/DataConnectionCard.tsx
@@ -10,8 +10,8 @@
'use client'
-import { type FC } from 'react'
-import { Flex, Text, Switch } from '@/adapters/ui'
+import { type FC, useCallback, useState } from 'react'
+import { Flex, Text, Switch, Button } from '@/adapters/ui'
import { Globe } from '@/adapters/ui/icons'
import type { DataSource } from '../data-sources'
@@ -28,6 +28,16 @@ interface DataConnectionCardProps {
unavailableReason?: string
/** Callback when toggle state changes */
onToggle: (id: string, enabled: boolean) => void
+ /** Start the per-user OAuth connect flow for a protected source */
+ onConnect?: (id: string) => void | Promise
+}
+
+/** Human-readable status line for a protected MCP source. */
+const STATUS_LABELS: Record = {
+ connected: 'Connected',
+ not_connected: 'Not connected',
+ expired: 'Session expired',
+ error: 'Connection error',
}
/**
@@ -41,16 +51,38 @@ export const DataConnectionCard: FC = ({
isBusy = false,
unavailableReason,
onToggle,
+ onConnect,
}) => {
- // Combine availability and busy state
+ const [connecting, setConnecting] = useState(false)
+
+ // Per-user MCP auth state (present only for protected sources).
+ const perUserAuth = source.perUserAuth
+ const isProtected = !!perUserAuth?.required
+ const authStatus = perUserAuth?.status ?? undefined
+ // A protected source must be connected before it can be enabled.
+ const needsConnect = isProtected && authStatus !== 'connected'
+
+ // Combine availability and busy state. A protected-but-unconnected source
+ // cannot be toggled — the Connect action replaces the switch.
const isDisabled = !isAvailable || isBusy
+ const canToggle = !isDisabled && !needsConnect
const handleToggle = () => {
- if (!isDisabled) {
+ if (canToggle) {
onToggle(source.id, !isEnabled)
}
}
+ const handleConnect = useCallback(async () => {
+ if (connecting || !onConnect) return
+ setConnecting(true)
+ try {
+ await onConnect(source.id)
+ } finally {
+ setConnecting(false)
+ }
+ }, [connecting, onConnect, source.id])
+
const handleCardClick = () => {
handleToggle()
}
@@ -72,15 +104,19 @@ export const DataConnectionCard: FC = ({
align="center"
justify="between"
role="button"
- tabIndex={isDisabled ? -1 : 0}
+ tabIndex={canToggle ? 0 : -1}
onClick={handleCardClick}
onKeyDown={handleCardKeyDown}
className={`border-base rounded-lg border p-3 transition-colors ${
- isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-surface-raised-50'
+ isDisabled
+ ? 'cursor-not-allowed opacity-50'
+ : canToggle
+ ? 'cursor-pointer hover:bg-surface-raised-50'
+ : ''
}`}
aria-pressed={isEnabled}
- aria-disabled={isDisabled}
- aria-label={`${source.name}: ${isEnabled ? 'enabled' : 'disabled'}${isDisabled ? ' (disabled)' : ''}`}
+ aria-disabled={!canToggle}
+ aria-label={`${source.name}: ${isEnabled ? 'enabled' : 'disabled'}${!canToggle ? ' (disabled)' : ''}`}
title={
isBusy
? 'Data source changes disabled during active operations'
@@ -106,17 +142,43 @@ export const DataConnectionCard: FC = ({
{source.description}
+ {isProtected && (
+
+ {perUserAuth?.lastError && authStatus === 'error'
+ ? perUserAuth.lastError
+ : STATUS_LABELS[authStatus ?? 'not_connected']}
+
+ )}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}