Expose AI-Q as an MCP Server#
AI-Q includes a standalone Model Context Protocol server that exposes the research workflow to MCP clients over stateless Streamable HTTP. The server is a public FastMCP application; it does not depend on a private transport SDK or the AI-Q REST API.
The default endpoint is http://localhost:9001/mcp. It advertises exactly three tools:
submit_query(query)classifies and starts the work.poll_query(job_id)returns status and best-effort progress.get_final_report(job_id)returns the completed answer or report.
Requirements and public configuration#
The shipped configs/config_mcp.yml uses hosted NVIDIA NIM models and Tavily search. Both credentials are
required for this MCP configuration:
Variable |
Purpose |
|---|---|
|
Hosted NIM inference |
|
Public web search |
|
Shared PostgreSQL database for MCP jobs and LangGraph checkpoints |
The config intentionally excludes the AI-Q API frontend, authentication providers, enterprise data sources, paper search, and the second async-job layer. FastMCP owns the transport and the submit/poll lifecycle.
Dependency isolation#
MCP is a separate uv project with its own mcp/pyproject.toml, mcp/uv.lock, and environment. The root AI-Q
workspace excludes it and keeps cryptography>=46.0.6,<47, which is compatible with NAT’s declared requirements.
The frozen MCP release/container profile instead pins cryptography==48.0.1 to replace the OpenSSL version
bundled in earlier wheels.
The supported distribution paths are the frozen MCP project in an AI-Q source checkout and the release container
built from the repository root. Here, standalone describes the MCP process and transport boundary, not a generic
Python wheel. aiq-mcp-server depends on aiq-agent, tavily-web-search, and other packages supplied by this
repository; that complete dependency closure is not published to a Python package index. Its locally buildable
wheel is an internal implementation artifact and is marked Private :: Do Not Upload.
The 48.0.1 override is security hardening for those supported runtime paths, not a functional MCP protocol
requirement or published package constraint. Use mcp/uv.lock through the commands below, or use the release
container, to retain the audited dependency profile.
For a local source checkout, start PostgreSQL and then run:
export NVIDIA_API_KEY="your-nvidia-api-key" # pragma: allowlist secret
export TAVILY_API_KEY="your-tavily-api-key" # pragma: allowlist secret
export AIQ_CHECKPOINT_DB="postgresql://aiq:local_mcp_password@127.0.0.1:1234/aiq_jobs" # pragma: allowlist secret
uv sync --project mcp --frozen
uv run --project mcp --frozen aiq-mcp-server
The component accepts these runtime settings:
Variable |
Default |
Purpose |
|---|---|---|
|
|
Uvicorn bind host |
|
|
Uvicorn bind port |
|
|
Streamable HTTP path |
|
|
Independent workflow-owning worker processes |
|
|
Python and Uvicorn log level |
|
|
NAT workflow configuration |
|
|
Optional dotenv file; process variables take precedence |
|
|
Shallow-query inline wait window |
|
|
Maximum accepted |
|
|
Browser CORS allowlist; an empty value disables CORS |
|
Local hosts |
Valid HTTP |
|
Local HTTP origins |
Valid browser |
Component and lifecycle#
The repository directory is mcp/, the distribution is aiq-mcp-server, and the Python import package is
aiq_mcp. This avoids shadowing the third-party mcp package.
Path |
Responsibility |
|---|---|
|
FastMCP tools, Starlette routes, process lifecycle, and Uvicorn launcher |
|
Long-lived NAT workflow and sessions |
|
Classification, background work, state rendering, polling, and cleanup |
|
Shared PostgreSQL job ledger |
|
Best-effort LangGraph todo progress |
|
Idempotent job-ledger bootstrap |
An outer Starlette lifespan starts the NAT workflow, job manager, and MCP session manager once per Uvicorn worker. Stateless MCP requests do not own those resources, so closing the request that submitted a query does not cancel its background task. Multiple workers share jobs and checkpoints through PostgreSQL.
Tool protocol#
Tool results use the field state. The HTTP health routes use status; these fields are deliberately different.
All job_id values below are canonical lowercase UUIDv4 strings.
Submit#
Meta queries complete synchronously:
{
"job_id": "<uuid>",
"depth": "meta",
"state": "complete",
"result": "<answer>"
}
A shallow query starts with a 10-second estimate and a 5-second first-poll hint. The server waits for the
configured inline window. If work finishes, it returns state: "complete" with result, or state: "failed"
with error. If the inline window expires, the task remains alive and the response is queued:
{
"job_id": "<uuid>",
"depth": "shallow",
"state": "queued",
"estimated_duration_seconds": 0,
"first_poll_after_seconds": 0
}
The zero values above are the result with the default 30-second inline wait. For a custom wait, the remaining
estimate is max(0, int(10 - inline_wait_seconds)).
If the inline wait encounters an infrastructure error after the shallow job has been enqueued, submit_query
returns the original queued payload instead. The estimate and first-poll hint are left unchanged so the caller
retains the job_id capability and can continue with poll_query.
Deep work never waits inline and always returns:
{
"job_id": "<uuid>",
"depth": "deep",
"state": "queued",
"estimated_duration_seconds": 180,
"first_poll_after_seconds": 180
}
Poll#
poll_query is status-only and never includes the final result. Queued and running jobs include a cadence and
todos; shallow cadence is 3 seconds and deep cadence is 180 seconds:
{
"job_id": "<uuid>",
"depth": "deep",
"state": "running",
"next_poll_after_seconds": 180,
"todos": [
{"content": "Gather sources", "status": "in_progress", "id": "optional-id"}
]
}
todos is always present for a found job. An empty list is normal for queued, shallow, early, or unavailable
progress. Valid normalized statuses are pending, in_progress, and completed; an upstream status that cannot
be normalized is preserved.
When polling returns state: "complete", stop polling and call get_final_report. A completed poll response has
job_id, depth, state, and todos, but no result or cadence. A failed response has the same fields plus
error, and no cadence.
Final report and stable errors#
A completed report is:
{
"job_id": "<uuid>",
"depth": "deep",
"state": "complete",
"result": "<report>"
}
Calling get_final_report while a job is queued or running returns:
{
"job_id": "<uuid>",
"depth": "deep",
"state": "not_ready",
"error": "job_not_ready"
}
Unknown, malformed, noncanonical, expired, or inaccessible capabilities return the same non-enumerating shape:
{"state": "not_found", "error": "job_not_found"}
A failed final report returns job_id, depth, state: "failed", and a sanitized error. Server exception
details are not exposed in logs or responses, and capability UUIDs are not written to logs.
Unexpected infrastructure exceptions during submission before a capability is returned, polling, or final-report retrieval are returned as MCP tool errors with stable public messages. Internal exception text is never serialized; the MCP boundary log entry records only the affected operation and exception type.
Health and transport contracts#
Request |
Response |
|---|---|
|
HTTP 200 and |
|
HTTP 503 and |
|
HTTP 200 and |
|
HTTP 405 with |
MCP Streamable HTTP request |
|
The MCP transport is stateless and uses JSON responses. A supported client initializes a new connection, lists the tools, submits work, and may close completely before another connection polls the same UUID.
Anonymous capability security#
The server does not require an Authorization header. Every call uses the constant database principal
anonymous; the random job UUID is therefore a bearer capability. Anyone who obtains it can poll or retrieve the
job until its database row expires. Do not put job IDs in URLs, logs, analytics, or support messages.
AIQ_MCP_ALLOWED_HOSTS and AIQ_MCP_ALLOWED_ORIGINS protect browser-reachable deployments from DNS rebinding and
untrusted web origins. CORS controls which browser applications may read responses. None of these settings
authenticate a user or create per-user authorization. Headless clients normally omit Origin but must send an
allowed Host.
An unauthenticated MCP endpoint can consume model/search quota. The server rejects submit_query requests whose
query exceeds AIQ_MCP_MAX_QUERY_CHARS characters before any job is enqueued, but it does not rate-limit callers.
Do not expose it directly to an untrusted network. Use network policy, an authenticated reverse proxy or gateway,
request and rate limits, and deployment monitoring when running beyond a trusted local environment.
Container deployment#
Build and start the loopback-only MCP/PostgreSQL stack from the repository root:
export NVIDIA_API_KEY="your-nvidia-api-key" # pragma: allowlist secret
export TAVILY_API_KEY="your-tavily-api-key" # pragma: allowlist secret
docker compose -f deploy/compose/docker-compose.mcp.yaml up --detach --build --wait
The MCP endpoint is http://127.0.0.1:9001/mcp; PostgreSQL is published at 127.0.0.1:1234. The local stack uses
a fixed development-only database password. Production should inject AIQ_CHECKPOINT_DB from secret management
and should not reuse the local Compose database credentials.
The image contains no private package indexes, private deployment metadata, bundled certificate authority, or in-process TLS configuration. Terminate HTTPS at a reverse proxy or platform ingress.
Run the supported-client smoke test:
uv run --project mcp --frozen python mcp/scripts/protocol_smoke.py --url http://127.0.0.1:9001/mcp
The smoke client rejects URLs containing userinfo, query parameters, or fragments before making a request and does not echo the rejected URL.
Stop the local stack with:
docker compose -f deploy/compose/docker-compose.mcp.yaml down --volumes
Public design differences#
The behavioral contract is frozen in mcp/REFERENCE_PARITY.md. The intentional public differences are:
FastMCP and an outer Starlette lifespan provide the transport and worker lifecycle.
Authentication, token propagation, private feedback hooks, and registry metadata are not included.
Public
AIQ_MCP_*,NVIDIA_API_KEY,TAVILY_API_KEY, andAIQ_CHECKPOINT_DBsettings replace private startup inputs.The default workflow uses public NIM and Tavily only; enterprise sources, private prompts/indexes, and paper search are not part of this configuration.
Bundled CA material and in-process certificate behavior are excluded.
Exact lowercase canonical UUID validation and capability-safe logging harden public error behavior.
The three tool schemas, state machine, polling cadence, PostgreSQL schema, background lifecycle, todo normalization, and client-safe error shapes otherwise remain compatible.