Skip to content

feat: expose durable Global Ask through authenticated MCP - #655

Merged
seonghobae merged 3 commits into
fix/global-ask-graph-fact-provenancefrom
feat/mcp-global-ask-current-contract
Aug 25, 2026
Merged

feat: expose durable Global Ask through authenticated MCP#655
seonghobae merged 3 commits into
fix/global-ask-graph-fact-provenancefrom
feat/mcp-global-ask-current-contract

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Outcome

Expose Global Ask to authenticated MCP clients without reviving the historical synchronous Ask fork. MCP submits and reads the same durable job contract as REST.

Contract

  • exact MCP resource-audience OAuth and provisioned-account resolution
  • current Keyverse organization/workspace/role scope binding
  • pre-auth Host, Origin, and streamed request-body admission
  • one fail-closed distributed Valkey quota unit per admitted tool call
  • deployment-supplied quota capacity inputs; no guessed library default
  • submit_global_ask and read_global_ask_job preserve verification opt-in, knowledge cutoff, owner scope, citations, and limitations
  • dedicated opt-in Compose service

ADR 0218 and the PRD/gap baseline define the acceptance boundary. Historical #270/#286/#258 were used only as reusable implementation evidence.

Verification

  • uv run --extra dev --extra backend pytest -q: 1,435 passed, 17 skipped
  • focused post-merge trust/auth/config/docstring suite: 64 passed
  • docker compose config --quiet
  • Keycloak realm JSON parse
  • git diff --check

The live MCP/k6 capacity acceptance remains explicitly open until an application-ready synthetic deployment supplies measured quota values. No production-capacity claim is made.

Stacked on #632; merge the parent first.


Open in Devin Review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@seonghobae
seonghobae merged commit 1a9f968 into fix/global-ask-graph-fact-provenance Aug 25, 2026
1 of 4 checks passed
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: af7f785c-e76f-4dfe-a430-901eac9dee81

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

❤️ Share

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 3 potential issues.

Open in Devin Review

Comment thread backend/app/mcp_server.py
Comment on lines +95 to +113
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Add Retry-After when a tool call marked the current request."""

async def send_with_retry(message: Message) -> None:
"""Attach the request-scoped quota delay to the response start."""
retry_after = scope.get("state", {}).get(_RETRY_AFTER_STATE_KEY)
if message.get("type") == "http.response.start" and isinstance(
retry_after, int
):
headers = [
(name, value)
for name, value in message.get("headers", [])
if name.lower() != b"retry-after"
]
headers.append((b"retry-after", str(retry_after).encode("ascii")))
message = {**message, "headers": headers}
await send(message)

await self._app(scope, receive, send_with_retry)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Retry-After header relies on shared scope state

A rate-limit rejection stores retry_after_seconds on request.scope['state'] in _account (backend/app/mcp_server.py:157-161), and McpRetryAfterHeaderApp reads scope['state'] at response time to add the header. This works only if the SDK's request scope is the same ASGI scope dict the outer middleware sees. If the SDK builds a separate scope for JSON-RPC dispatch, the header is silently omitted; the structured retry_after_seconds data still returns.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +37 to +56
body = bytearray()
while True:
message = await receive()
message_type = message.get("type")
if message_type == "http.disconnect":
await _send_error(send, 400, "mcp_request_disconnected")
return
if message_type != "http.request":
await _send_error(send, 400, "mcp_invalid_request_body")
return
chunk = message.get("body", b"")
if not isinstance(chunk, bytes):
await _send_error(send, 400, "mcp_invalid_request_body")
return
if len(body) + len(chunk) > self._maximum_bytes:
await _send_error(send, 413, "mcp_request_too_large")
return
body.extend(chunk)
if not message.get("more_body", False):
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Body admission does not bound empty chunk streams

The read loop caps total bytes but a client sending endless empty http.request chunks with more_body=True never exceeds the cap and never breaks (backend/app/mcp_admission.py:38-56). Each iteration awaits receive(), so it is not a busy loop, but the coroutine and connection stay open indefinitely. No chunk-count or time bound guards this.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +29 to +35
if not account.has_permission("post_read"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "post_read permission required")
normalized_question = question.strip()
if not normalized_question:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Permission check now precedes blank-question check

The old REST ask_agent returned 422 for a blank question before enforcing post_read. The shared service checks permission first (backend/app/global_ask_service.py:29-35), so a provisioned account without post_read submitting a blank question now gets 403 instead of 422. The status for that edge case changed.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant