Skip to content

feat(mcp): host deployment-scoped and organization-scoped MCP servers - #2207

Merged
Deepak-Kesavan merged 48 commits into
mainfrom
worktree-mcp-server
Aug 7, 2026
Merged

feat(mcp): host deployment-scoped and organization-scoped MCP servers#2207
Deepak-Kesavan merged 48 commits into
mainfrom
worktree-mcp-server

Conversation

@hari-kuriakose

@hari-kuriakose hari-kuriakose commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

What

Hosts two MCP servers for Unstract, so coding agents can discover and run
document extraction as tool calls instead of hand-rolling HTTP requests.

Deployment server Platform server
Scope one API deployment one organization
Credential that deployment's API key a PlatformApiKey
Authenticated by the view itself CustomAuthMiddleware
URL /deployment/api/<org>/<api_name>/mcp /api/v1/unstract/<org>/mcp/
Tools extract, poll status discovery + state changes

They share the JSON-RPC transport (transport.py) and differ only in
authentication and tool surface. Neither key works on the other server.

Approach

A hand-rolled JSON-RPC 2.0 endpoint mounted in the existing Django app rather
than a separate service to deploy and scale, with a declarative tool registry
behind it:

  • Scoped via the URL, so an MCP session inherits the deployment or organization
    the caller already addresses.
  • Authenticated with the credential that already exists for that scope — no
    second key to mint or revoke.
  • MCPToolRegistry maps a tool name to a callable plus the JSON schema clients
    need, so tools/list is generated rather than maintained by hand.

Only the hosted HTTP transport is implemented; a stdio server is a separate
concern and out of scope here.

The app is backend/mcp_server/ — deliberately not _v2 (that suffix marks
apps carried through the v0.93.0 v1→v2 multitenancy migration; every app added
since is unsuffixed), and not bare mcp, which would shadow the PyPI mcp SDK
package on the import path.

Endpoint

An MCP session is scoped to one API deployment and mirrors that deployment's
REST URL:

POST /deployment/api/<org_name>/<api_name>/     # REST (existing)
POST /deployment/api/<org_name>/<api_name>/mcp  # MCP  (new)

Auth is the deployment's existing API key — same key, same management UI.
There is no second credential to mint or revoke.

claude mcp add --transport http unstract \
  https://<host>/deployment/api/<org_name>/<api_name>/mcp \
  --header "Authorization: Bearer <api_key>"

A /<api_key> path variant exists for MCP clients that cannot set headers.
It is a workaround for client limitations, not a recommended path — credentials
in a URL can reach access logs and proxies, so prefer the header form.

Tools

Tool Purpose
readMeFirst Orientation guide, built from the live deployment
getApiInfo Deployment name, description, workflow, active state
extractDocument Run extraction over S3 pre-signed URLs. Consumes quota
getExecutionStatus Poll for a pending extraction's result

Platform server

claude mcp add --transport http unstract-platform \
  https://<host>/api/v1/unstract/<org_name>/mcp/ \
  --header "Authorization: Bearer <platform_api_key>"

DiscoveryreadMeFirst, whoami, listApiDeployments, listWorkflows,
listPipelines, listPromptStudioProjects, listPromptStudioDocuments,
listPrompts, getWorkflowEndpoints, listToolInstances, listTags

ObservabilitylistExecutions, getExecutionDetail, getUsageSummary,
getExecutionStatus

State changes (read_write, reversible) — setApiDeploymentActive,
setPipelineActive

Billable (read_write, budgeted) — executePipeline, indexDocument,
fetchResponse, bulkFetchResponse, singlePassExtraction, extractDocument

23 tools. To extract through a deployed API, an agent calls
listApiDeployments for an api_name then opens a separate session against the
deployment server; the Prompt Studio tools are for prompts before deployment.

The spend guard

Billable tools drive LLM inference and embedding, so they are budgeted per
organization over a rolling window (MCP_BILLABLE_CALL_LIMIT, default 50/hour).

It counts calls, not tokens. There is no OSS budget to gate on — usage_v2
reports after the fact, and subscription enforcement lives in the enterprise
overlay — so a call counter is the strongest guard implementable here.

Three deliberate behaviours: budget is consumed on invocation and never
refunded
, which is the opposite of the rate-limit slot in tools/execution.py
(that models concurrency; this models money already spent, and refunding would
let an agent spend without limit by failing in a loop); exhaustion returns a
retryable isError result, not a permission error, so the agent waits
rather than concluding the tool is broken; and it fails open, because
bounding runaway loops isn't worth taking the server down when Redis blips.
whoami reports remaining budget so an agent can pace itself.

How write tools are authorized

Tiers are defined over HTTP methods (ApiKeyPermission.allows), but every MCP
call is a POST — so the middleware's tier check can't tell listWorkflows
from executePipeline. Each tool declares the method its REST equivalent
would use, and check_tool_allowed re-applies the key's tier against that.
Reusing those semantics rather than inventing a parallel scheme means a tool
marked DELETE is full_access-only for the same reason a REST DELETE is.

A writes=True tool left at the default required_method="GET" would slip past
the guard, so a test asserts none exists.

Deliberately not exposed

  • Anything whose response carries a credential. Connector and adapter
    configuration, notification webhooks, and all key management — several of
    these return decrypted secrets in their ordinary responses, and an agent's
    context is not a safe place for one. See the README for the full list.
  • Deletions, and anything changing who can access the org (password resets,
    role assignment, member removal). Every write tool here is reversible.

Two tests keep this true: one seeds an org with fake secrets, invokes every
read tool and fails if any appears in the output; another asserts no tool name
suggests credential, connector or adapter access. Where a tool must touch
something adjacent to a secret — getWorkflowEndpoints reaches a connector
instance — it returns named fields only, never serializer.data.

The URL placement is security-critical. WHITELISTED_PATHS is matched with
startswith, so everything under /deployment/... — including the deployment
MCP server — is exempt from CustomAuthMiddleware, while the tenant path is
not. That is exactly how this server inherits platform-key auth. Moving it under
the whitelisted prefix would silently remove all authentication, so a test
asserts it isn't whitelisted.

Because that auth is in middleware, the platform auth tests go through
django.test.Client and the real URL. APIRequestFactory — correct for the
deployment server, which owns its auth — bypasses middleware entirely and would
have passed against a completely open endpoint.

Two constraints, documented rather than worked around:

  1. A read-tier key cannot use the platform server at all. The middleware
    gates tiers on HTTP method and every MCP call is a POST, so a read key is
    refused before the view runs — even for the read tools. Use read_write.
    Special-casing MCP paths in shared middleware is a maintainer decision, not
    this app's to make unilaterally.
  2. Platform tools reach the whole organization. A platform key resolves to a
    service account, and is_service_account=True makes for_user() return
    self.all(), ignoring per-user sharing regardless of the USER role the key
    was created with. For reads that is a disclosure caveat; for writes it
    means the key can modify any org resource. whoami, readMeFirst and every
    write tool's description say so.

Design decisions

  • Auth delegates to DeploymentHelper — the same validation the REST
    endpoint uses, so the two surfaces cannot drift on who is allowed in.
    Org scoping falls out of this: a valid key for org A cannot reach org B's
    deployment (covered by the wrong org case).
  • Execution delegates to ExecutionRequestSerializer — URL validation
    (S3-only, HTTPS-only) and the file-count cap live there. Reimplementing them
    would let the MCP surface silently diverge from REST on what input is safe.
  • All auth failures answer identically (401, no detail) so the endpoint
    cannot be used to enumerate deployment names.
  • Tool errors are JSON-RPC results with isError: true, not protocol
    errors — clients treat protocol errors as unrecoverable transport faults,
    whereas an agent-fixable problem should be readable and retryable.

Tests

82 passing. Protocol/dispatch, tool logic, the spend guard and redaction in
the unit tier; both auth boundaries and the credential-leak sweep in the
integration tier. Registers mcp-server-auth and
mcp-platform-auth critical paths in tests/critical_paths.yaml.

Full backend unit tier: 331 passed, no regressions. The 35 integration
failures in workflow_manager/execution/ are pre-existing in my sandbox (no
Redis) — the identical 35 fail on the base commit.

Cross-tenant tests assert a platform key can neither see nor modify another
organization's deployments, workflows, pipelines or Prompt Studio projects — the
write-side case matters more, since the consequence there is mutation rather
than disclosure. Each write tool is also asserted against its delegated helper
with assert_called_once_with, the pattern that caught the bugs below.

The credential-leak sweep caught a real leak: an execution's error_message
can embed the connection string a failed connector tried, and listExecutions
returned it verbatim. Error text now passes through redact_secrets. The same
test also caught getExecutionDetail being uncovered, and building it exposed
that spend_guard.peek() did not fail open like consume().

Earlier, writing the happy-path tests caught three more, all fixed:

  1. Tool descriptions said documents could be any reachable URL — the serializer
    accepts only S3 pre-signed URLs, so an agent following the description
    would have failed every call.
  2. The tags schema advertised an unbounded array; the serializer caps it at 1.
  3. Documents were downloaded before the rate-limit slot was taken, so a
    call about to be rejected still pulled every document over the network.

Not implemented / notes for review

  • OAuth 2.1 + dynamic client registration is deliberately out of scope.
    It is what browser-based one-click connectors use; bearer auth covers Claude
    Code and API clients. Adding it later is additive — discovery endpoints
    alongside this router, no transport change.

  • MCP_PATH_PREFIX was removed. It was introduced by this branch and
    nothing outside it depended on it; the deployment MCP path now follows
    API_DEPLOYMENT_PATH_PREFIX. A second knob for a prefix that should never
    change was only a way to break already-configured clients.

  • No live-deployment smoke test. The happy path is covered by mocks at the
    DeploymentHelper boundary, and a full MCP session (initialize → tools/list →
    tools/call) was exercised end-to-end against a real DB during development, but
    nothing has run a real extraction through this path. Worth one manual check
    against a live deployment before merge.

  • Cloud mounting: confirmed working. Mounted in backend/base_urls.py.
    The enterprise overlay defines its own ROOT_URLCONF, but that module is
    built on top of this file's urlpatterns rather than replacing them, so the
    /mcp/ route is inherited automatically and no cloud-side change is needed.
    (Verified by inspection of the enterprise overlay; nothing from it is
    reproduced here.)

  • Protocol version is 2025-06-18, matching the transport rules actually
    implemented (Streamable HTTP; GET answers 405 rather than opening an SSE
    stream). initialize negotiates per spec — it echoes the client's requested
    revision when supported, and 2024-11-05 is still accepted since the
    request/response subset used here is identical across the two.

🤖 Generated with Claude Code

hari-kuriakose and others added 2 commits July 24, 2026 15:43
Exposes an Unstract API deployment to coding agents over the Model Context
Protocol, so an agent can run document extraction as a tool call instead of
hand-rolling HTTP requests.

Follows the hosted-MCP pattern used in the mfbt backend: a hand-rolled
JSON-RPC 2.0 endpoint mounted in the existing app (not a separate service),
with a declarative tool registry behind it.

Endpoint mirrors the deployment's own REST URL and reuses its API key, so
there is no second credential to mint or revoke:

    POST /deployment/api/<org_name>/<api_name>/     # REST
    POST /mcp/<org_name>/<api_name>/                # MCP

Tools: readMeFirst, getApiInfo, extractDocument, getExecutionStatus.

Auth goes through the same DeploymentHelper validation the REST endpoint
uses, and execution through ExecutionRequestSerializer, so the MCP surface
cannot drift from the REST one on who is allowed in or what input is valid.
All auth failures answer identically (401, no detail) so the endpoint cannot
be used to enumerate deployment names.

OAuth 2.1 with dynamic client registration is deliberately not implemented;
bearer auth covers Claude Code and API clients, and OAuth would be additive.

Tests: 20 passing (protocol/dispatch in the unit tier, auth boundary in the
integration tier). Registers the mcp-server-auth critical path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The transport and auth tests never reached the extraction helpers on a
successful call, so the mapping from tool kwargs into execute_workflow was
executed by nothing — a renamed kwarg would have passed every test and failed
on the first real extraction.

Adding that coverage surfaced three real problems:

- Tool descriptions promised documents could be passed as any reachable URL.
  The execution serializer accepts *only* S3 pre-signed URLs, so an agent
  following the description would have failed every call. Descriptions, JSON
  schema and README now state the S3 requirement.
- The tags schema advertised an unbounded array; the serializer caps it at
  one. Both limits are now sourced from the serializers rather than restated,
  so the advertised schema tracks what is actually enforced.
- Documents were downloaded before the rate-limit slot was taken, so a call
  about to be rejected still pulled every document over the network. The slot
  is now acquired first, with the fetch inside the try block so a failed fetch
  still releases it.

Also converts a RateLimitExceeded raised from deeper in the stack into an
agent-readable message instead of letting it reach the generic
"failed unexpectedly" branch.

Tests: 34 passing (was 20).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Summary by CodeRabbit

  • New Features

    • Added hosted MCP endpoints for deployment- and organization-scoped access.
    • Added JSON-RPC tool discovery, document extraction, execution tracking, workflow operations, observability, and Prompt Studio capabilities.
    • Added bearer-token authentication, permission controls, organization isolation, and configurable billable-call budgets.
    • Added safeguards to redact credentials, limit results, and prevent unauthorized data access.
  • Documentation

    • Added comprehensive MCP setup, usage, authentication, tool, and security documentation.
  • Tests

    • Added coverage for authentication, permissions, protocol behavior, budgeting, tool execution, redaction, and tenant isolation.

Walkthrough

Hosted MCP support adds deployment- and organization-scoped JSON-RPC endpoints, shared transport, tool registries, authentication, execution and Prompt Studio tools, organization isolation, secret redaction, billable-call budgets, and extensive tests.

Changes

Hosted MCP servers

Layer / File(s) Summary
Routing and JSON-RPC transport
backend/mcp_server/{transport.py,constants.py,context.py,exceptions.py,urls.py,platform_urls.py}, backend/{backend/urls_v2.py,api_v2/execution_urls.py}
Adds MCP routes, protocol negotiation, JSON-RPC envelopes, request validation, tool dispatch, and deployment or platform context hooks.
Authentication and platform authorization
backend/mcp_server/{views.py,platform_views.py}, backend/backend/settings/base.py, backend/permissions/permission.py
Adds Bearer-header deployment authentication, middleware-based platform authentication, permission-tier checks, CSRF handling, organization scoping, and configurable billable-call limits.
Registries and MCP tools
backend/mcp_server/{registry.py,tools/*}
Registers deployment and platform tools for discovery, observability, state changes, execution, extraction, and Prompt Studio operations.
Budget and secret controls
backend/mcp_server/{spend_guard.py,sanitize.py}, backend/sample.env
Adds fixed-window organization budgets, preflight checks, UUID validation, listing limits, and recursive credential redaction.
Tests and documentation
backend/mcp_server/tests/*, backend/mcp_server/README.md, tests/critical_paths.yaml
Adds coverage for protocol behavior, authentication, authorization, tenant isolation, tool reachability, execution flows, Prompt Studio dispatch, budget enforcement, input guards, and credential leakage.

Estimated code review effort: 5 (Critical) | ~120 minutes

Suggested reviewers: shuveb, jaseemjaskp, deepak-kesavan, muhammad-ali-e

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant DjangoURLs
  participant MCPView
  participant MCPRegistry
  participant SpendGuard
  Client->>DjangoURLs: POST MCP JSON-RPC request
  DjangoURLs->>MCPView: Route deployment or platform endpoint
  MCPView->>MCPRegistry: Resolve and validate tool
  MCPRegistry->>SpendGuard: Check billable budget
  SpendGuard-->>MCPRegistry: Allow or deny call
  MCPRegistry-->>MCPView: Return sanitized tool result
  MCPView-->>Client: Return JSON-RPC response
Loading
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-mcp-server

hari-kuriakose and others added 2 commits July 25, 2026 17:25
The `_v2` suffix in this codebase marks apps carried through the v0.93.0
v1->v2 multitenancy data migration (see backend/migrating/v2/README.md), not
current naming convention. Every app added since — pg_queue, dashboard_metrics,
platform_api, configuration — is unsuffixed. A brand-new app with no v1
predecessor should not claim the marker.

Named `mcp_server` rather than plain `mcp` because `mcp` is also the PyPI
package name for the MCP SDK (which mfbt's server uses). A bare `mcp` app
shadows it on the import path: `import mcp` resolved to the Django app, so a
later `pip install mcp` would break silently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ment one

The deployment MCP server can only ever expose one workflow: its credential is
an API deployment key, which resolves to no user and grants nothing beyond that
deployment. Reaching the rest of the platform needs a different credential —
and Unstract already has one.

PlatformApiKey (backend/platform_api/) is an org-scoped bearer key that
CustomAuthMiddleware resolves to a service-account User, validating the key's
organization against the URL and rejecting unknown, inactive and wrong-org keys
before any view runs. So the new server does not authenticate at all; it is
mounted where that middleware can do it.

That placement is the security-critical detail. WHITELISTED_PATHS is matched
with startswith, so the deployment server's /mcp/ prefix is exempt from the
middleware while the tenant path is not:

    POST /mcp/<org>/<api_name>/          deployment key, view authenticates
    POST /api/v1/unstract/<org>/mcp/     platform key, middleware authenticates

Moving the latter under the whitelisted prefix would silently remove all
authentication, so a test asserts it is not whitelisted.

Because that auth lives in middleware, the platform auth tests go through
django.test.Client and the real URL. APIRequestFactory — correct for the
deployment server, which owns its auth — bypasses middleware entirely and would
have passed against a completely open endpoint.

Shared JSON-RPC transport extracted to transport.py so the two servers cannot
drift on protocol behaviour; the deployment server's URL, behaviour and 34
tests are unchanged.

The platform tools are read-only by design (readMeFirst, whoami,
listApiDeployments, listWorkflows, listPromptStudioProjects). Listings go
through each model's for_user manager so platform sharing rules apply, and are
capped with an explicit truncation note rather than silently cut.

Two constraints are documented rather than worked around:
- A read-tier key cannot use the server at all: the middleware gates tiers on
  HTTP method and every MCP call is a POST. Special-casing MCP paths in shared
  middleware is a maintainer decision, not this app's to make.
- Service accounts bypass per-user sharing (for_user returns self.all()), so
  listings cover the whole org. whoami and readMeFirst both say so, since an
  agent assuming otherwise would misread a listing.

check_tool_allowed refuses write tools below read_write, and a test pins the
registry as read-only so adding the first write tool is a deliberate act.

Tests: 50 in this app (was 34); full backend unit tier 310 passing.
Registers the mcp-platform-auth critical path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hari-kuriakose hari-kuriakose changed the title feat(mcp): host an MCP server for Unstract API deployments feat(mcp): host deployment-scoped and organization-scoped MCP servers Jul 25, 2026
hari-kuriakose and others added 4 commits July 25, 2026 20:25
…rite tools

Three changes.

1. The deployment MCP endpoint moves to a suffix on the deployment's own
   execution URL:

       POST /deployment/api/<org>/<api_name>/          (REST)
       POST /deployment/api/<org>/<api_name>/mcp       (MCP)

   Its URLs now live in api_v2/execution_urls.py, next to the endpoint they
   extend, which also makes them inherit the deployment prefix's existing
   WHITELISTED_PATHS entry instead of needing one of their own.

2. MCP_PATH_PREFIX is removed. It was introduced by this branch's first commit
   and nothing outside it ever depended on it; the deployment MCP path is now
   determined by API_DEPLOYMENT_PATH_PREFIX, and a second knob for a prefix
   that should never change was just a way to break clients.

3. The platform server gains write tools: setApiDeploymentActive,
   setPipelineActive and executePipeline, alongside a new listPipelines.

Authorization for the write tools reuses machinery that already exists rather
than inventing a scheme. Platform key tiers are defined over HTTP methods
(ApiKeyPermission.allows), but every MCP call is a POST — so the middleware's
tier check cannot tell listWorkflows from executePipeline. Each tool now
declares the method its REST equivalent would use, and check_tool_allowed
re-applies the key's tier against that. A tool marked DELETE is therefore
full_access-only for exactly the same reason a REST DELETE is.

Two categories stay out, deliberately:

* Credential operations. Key creation and rotation return the secret in their
  response, so exposing them would give an agent processing untrusted document
  content a way to mint or exfiltrate credentials. The codebase already reasons
  this way — see CanRotatePlatformApiKey's docstring. A test asserts no tool
  name suggests credential handling.
* Deletions. The write tools here are reversible by construction; removing a
  workflow or project is not.

The previous read-only invariant test is replaced rather than dropped: it now
asserts every writes=True tool declares a non-GET required_method, so a write
tool cannot silently slip past the guard by keeping the default.

Write tools resolve their target through for_user, so a key cannot touch
another organization's resources — asserted directly, since the consequence
there is mutation rather than mere disclosure. The service-account caveat now
appears in every write tool's description, not just whoami: for reads it was a
disclosure note, for writes it means the key can modify any org resource.

Tests: 59 in this app (was 50); full backend unit tier 311 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…deferred read tools

Four changes.

1. Documents the platform server's exclusions in its README — the
   credential-bearing surfaces and destructive operations no tool wraps, and
   why. Framed as what this server does not do rather than as an inventory of
   which endpoints return secrets, since this repository is public.

2. Adds the billable Prompt Studio operations: indexDocument, fetchResponse,
   bulkFetchResponse and singlePassExtraction. Each delegates to
   PromptStudioCoreView rather than reimplementing run-id generation, profile
   resolution, lookup gating and Celery dispatch — a parallel implementation
   would drift from the UI on the first change to either.

3. Adds a spend guard for them. There is no OSS token budget to gate on
   (usage_v2 reports after the fact; subscription enforcement is enterprise),
   so this is a per-organization *call* budget in Redis. Enforced at the
   transport via a `billable` flag, so a new costly tool cannot be added
   without being budgeted. Three deliberate behaviours: budget is consumed on
   invocation and never refunded — the opposite of the rate-limit slot in
   tools/execution.py, because that models concurrency while this models money
   already spent, and refunding would let an agent spend without limit by
   failing in a loop; exhaustion returns a retryable isError result rather than
   a permission error, so the agent waits instead of giving up; and it fails
   open, because bounding runaway loops is not worth taking the server offline
   when Redis blips. whoami reports remaining budget so an agent can pace
   itself.

4. Adds the read tools previously skipped for no good reason: listExecutions,
   getExecutionDetail, getUsageSummary, getWorkflowEndpoints, listToolInstances,
   listTags and listPipelines. Execution history in particular closes the gap
   where an agent could trigger work but not see how it went.

Two things surfaced while building this, both now fixed:

* A registry-wide test seeds an org with fake secrets, invokes every read tool
  and fails if any appears in the output. It caught getExecutionDetail being
  uncovered, then caught a real leak — an execution's error_message can embed
  the connection string a failed connector tried, which listExecutions returned
  verbatim. Error text is now passed through redact_secrets.
* spend_guard.peek() did not fail open, so whoami broke when the cache was
  unavailable. Now matches consume().

getWorkflowEndpoints is the case to look at for the pattern: a workflow
endpoint references a connector instance whose metadata decrypts on access, so
the tool returns the shape of the connection and never the configuration. All
responses here are built field-by-field from named attributes; never
serializer.data.

Tests: 82 in this app (was 59); full backend unit tier 331 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hari-kuriakose hari-kuriakose self-assigned this Jul 25, 2026
hari-kuriakose and others added 8 commits July 26, 2026 11:41
Adds MCP_REDIS_DB, defaulting to REDIS_DB. Nothing about the current
deployment changes: with both unset the budget counter stays in the shared
cache DB and the guard keeps using django.core.cache, which is also what makes
override_settings(CACHES=...) work in tests.

The knob exists so MCP state can be moved to its own Redis DB later without
that being a breaking change. When the two differ the guard builds a client for
the configured DB, preferring a CACHES["mcp"] alias if the operator configured
one so they control pooling and auth rather than having them inferred. This
follows the shape of CacheService.clear_cache_optimized: Django cache by
default, raw client only when a specific DB is required.

Worth recording why the default matters. Sharing DB 0 means a FLUSHDB or a
cache-wide eviction resets every organization's window — a fail-open outcome,
consistent with a guard that already allows calls when the cache is
unreachable. It is a loop bound, not an audited ledger, and the README now says
so alongside the key layout and TTL-as-window mechanics.

Also adds MCP_BILLABLE_CALL_LIMIT and MCP_BILLABLE_WINDOW_SECONDS to
sample.env, which were introduced without being documented there.

Removes CAPTURE3.md from the branch. The findings it captured are unfixed
issues in the existing REST API and are being tracked elsewhere; a public PR
was the wrong place for them.

Tests: 87 in this app (was 82) — the new ones pin that a matching DB uses the
shared cache, that REDIS_DB's empty-string default coerces to 0 the same way
downstream code coerces it, and that a differing DB really does get a different
client. peek() now has its own fail-open test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r GET per spec

The four billable Prompt Studio tools each require a document_id, and
fetchResponse also a prompt_id, but nothing in the registry returned either.
An MCP client sees only tools/list — it has no database and cannot invent a
UUID — so the most expensive tools on the server were listed and uncallable.

Adds listPromptStudioDocuments and listPrompts as the producers. Both resolve
through the project rather than by a bare id lookup: the platform key
authenticates as a service account, for which for_user() returns everything,
so the project join is the scoping that actually holds. listPrompts builds its
response field by field instead of via a serializer, because ToolStudioPrompt
carries a profile_manager FK (the LLM, embedding and vector-store adapters)
and a webhook URL that may embed a token — both of which the README promises
never reach an agent, and neither of which was pinned by a test until now.

The durable fix is test_registry_reachability: every id a tool requires must
have a declared producer. The bug class was invisible per tool and only wrong
in aggregate, so the invariant is asserted over the registry.

Also, per the Streamable HTTP spec (rev 2025-06-18), GET opens a
server-to-client SSE stream and a server offering none must answer 405. This
server pushes nothing, so GET now returns 405 with Allow: POST, keeping the
identity body for uptime probes. The "every MCP call is a POST" comments were
imprecise in the same way and now say that every JSON-RPC *message* arrives as
a POST — which is what makes required_method necessary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… docs

test_no_credential_leak drives every read tool through a hardcoded argument
map and raises if a tool requires an argument the map lacks. Both new tools
require project_id, so without an entry the sweep would have failed outright —
and the whole reason listPrompts builds its response by hand is that
ToolStudioPrompt carries credential-bearing fields. Seeds a project, a
document and a prompt whose webhook URL embeds a canary, so the sweep now
actually exercises the tool whose leak risk motivated the hand-built dict.

Also updates the README's GET description, which still promised a 200 identity
response, and the last "every MCP call is a POST" comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…de to the registry

readMeFirst's tool listing is hand-maintained prose, so it drifted the moment
listPromptStudioDocuments and listPrompts were added to the registry without
being added to the guide. That is a softer form of the unreachability bug those
two tools were built to fix: an agent trusting the guide would find the four
billable tools and no way to obtain the document_id and prompt_id they require.

The reachability invariant did not catch it because it checks the registry's
schemas, not this prose. Extends it to call both readMeFirst handlers and
compare their listings against the registry — every registered tool must be
named, no phantom tools may be advertised, and the billable grouping must match
the registry's billable flags exactly, since that grouping is the guide's main
signal about which calls cost money.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Requiring a deployment key to extract bought no safety: the platform server
already spends money through executePipeline and the Prompt Studio tools, and
it deliberately cannot hand over a deployment key (key retrieval is excluded),
so an agent was told to go get a credential it had no way to obtain. Both
servers now extract, and the caller picks the blast radius — one deployment, or
one credential across the organization.

The work is delegated to tools.execution rather than reimplemented, so both
servers share one path through validation, rate limiting and the execution
helper. Only the target lookup differs: there it arrives with the credential,
here it is resolved from api_name through for_user.

Two divergences, both consequences of the credential:

llm_profile_id is not offered. It is validated against the API key's owner, and
a platform key resolves to a service account with no meaningful owner. Passing
some deployment's key to satisfy that check would assert a principal the caller
is not. Omitting the field is also what keeps the serializer from ever reading
the api_key this context lacks — an invisible coupling, so it is pinned.

getExecutionStatus re-checks the execution against the organization first.
DeploymentHelper.get_execution_status does a bare lookup by id with no tenant
filter, and TENANT_APPS is empty so there is no per-tenant schema to fall back
on. The deployment server's API key made this moot; nothing does here.

Also corrects the _is_service_account docstring, which still claimed DELETE was
blocked for all API keys — untrue since full_access was added, and it
contradicted platform_api/permissions.py. The conclusion it draws was right;
the stated reason was not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… sweep off Redis

The org-scope check in get_platform_execution_status is the one genuinely new
security boundary this change creates — on the deployment server the API key
made it moot — and nothing exercised it. Adds the refusal cases: another
tenant's execution, an unknown id, a pipeline run with no deployment behind it,
and extraction naming a deployment outside the org. The cross-tenant test also
asserts the refusal does not name the owning organization, since confirming an
id exists elsewhere is itself a disclosure.

The leak sweep now invokes getExecutionStatus, and the seeded execution has
status ERROR — which ExecutionStatus counts as terminal, so the tool takes its
is_completed branch and reads results through a raw Redis client that
override_settings(CACHES=...) does not reach. Stubbed the same way
_handle_execution_cache already is, so the sweep stays in the unit tier's reach
rather than failing in CI for a reason unrelated to credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The budget is consumed on invocation and never refunded — right for a call that
may have spent tokens upstream, wrong for one refused on an argument the server
could check for free. extractDocument made this visible: api_name is
caller-supplied prose rather than an id copied from a listing, so an agent
guessing at deployment names could exhaust the organization's window without
ever reaching an LLM.

Adds an optional preflight to MCPTool, run after the permission check and
before the budget is claimed, and gives every billable tool one that resolves
what it names. The handlers already did these lookups; they just did them on the
far side of the counter. executePipeline was found by the new invariant rather
than by inspection — it had the same shape and no preflight — so its resolution
is now shared between the preflight and the handler.

Pinned four ways: a failed preflight leaves the counter at zero, repeated bad
names never exhaust the window, a passing preflight still consumes budget (the
hook must not become a way around the guard), and an exhausted budget still
reports the bad name rather than masking it — that last one is what
distinguishes running preflight *before* the claim from merely reporting after
it. Reversing the two in transport.py fails three of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The server advertised protocol revision 2024-11-05 while the transport followed
2025-06-18 rules — Streamable HTTP, and answering GET with 405 rather than an
SSE stream. The version string was describing a server this is not.

Bumping the constant alone would have been worse than the inconsistency: the
initialize handler ignored the client's requested version entirely and always
returned the server's own, so every client pinned to 2024-11-05 would have been
told to disconnect. The spec's rule is an echo, not an announcement — respond
with the requested version when supported, and offer one of ours otherwise —
so that is now implemented.

2024-11-05 stays in the supported set deliberately. The subset implemented here
(initialize, tools/list, tools/call, ping) is identical across the two
revisions; what differs is server-initiated streaming and session ids, neither
of which this server does. Accepting the older revision therefore costs nothing
and keeps working clients working.

Pinned through the view as well as the pure function, so the handler is proven
wired to the negotiation rather than to the constant: removing the echo fails
three tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds deployment-scoped and optional organization-scoped MCP servers.

  • Adds JSON-RPC transport, declarative tool registries, deployment and platform authentication boundaries, and document-extraction tooling.
  • Adds organization-scoped discovery, observability, reversible state-change, Prompt Studio, and billable execution tools.
  • Adds per-organization billable-call budgeting, secret redaction, URL routing, configuration, documentation, and comprehensive tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported polling-scope, Prompt Studio dispatch, missing-object handling, and exception-redaction issues are addressed in the current code.

Important Files Changed

Filename Overview
backend/mcp_server/transport.py Implements JSON-RPC parsing, protocol negotiation, tool dispatch, permission checks, and sanitized failure handling.
backend/mcp_server/tools/execution.py Adds deployment-scoped extraction and polling, with polling constrained to the initiating API deployment.
backend/mcp_server/tools/platform_execution.py Adds organization-scoped deployment extraction and polling while resolving the deployment recorded on each execution.
backend/mcp_server/tools/prompt_studio.py Delegates Prompt Studio operations while preserving payloads, required ViewSet state, and expected error translation.
backend/mcp_server/spend_guard.py Adds a deliberately fail-open, per-organization budget for billable MCP calls.
backend/mcp_server/sanitize.py Adds structured credential redaction and sanitized exception logging, including username-less Redis URLs.
backend/mcp_server/views.py Authenticates deployment-scoped MCP requests and constructs the corresponding tool context.
backend/mcp_server/platform_views.py Serves the optional platform MCP endpoint using middleware-established organization and platform-key context.
backend/backend/urls_v2.py Conditionally mounts the organization-scoped MCP endpoint under the authenticated tenant URL space.
backend/api_v2/execution_urls.py Mounts the deployment MCP endpoint alongside existing deployed API execution routes.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Django as Django routing/auth
    participant MCP as MCP transport
    participant Registry as Tool registry
    participant Guard as Permission/spend guards
    participant Services as Existing Unstract services

    Client->>Django: POST JSON-RPC request
    alt Deployment-scoped endpoint
        Django->>MCP: Deployment API-key context
    else Platform endpoint enabled
        Django->>MCP: Platform-key organization context
    end
    MCP->>Registry: Resolve and validate tool
    Registry->>Guard: Check tier, preflight, and budget
    Guard-->>Registry: Allow or retryable tool error
    Registry->>Services: Delegate operation
    Services-->>Registry: Result or sanitized error
    Registry-->>MCP: MCP tool result
    MCP-->>Client: JSON-RPC response
Loading

Reviews (22): Last reviewed commit: "docs(mcp): state the URL rule extractDoc..." | Re-trigger Greptile

Comment thread backend/mcp_server/tools/execution.py
Comment thread backend/mcp_server/tools/prompt_studio.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (12)
backend/mcp_server/tools/prompt_studio.py (1)

123-126: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Logging duplication across five handlers.

Each billable handler repeats the same logger.info(f"MCP <tool> ... org ... key ...") shape. A small _log_call(context, tool, **fields) helper would keep the format consistent (and make it easy to switch to structured logging later). Note context.platform_key.name is an operator-assigned label, so no secret is logged — worth keeping it that way if this is ever refactored.

Also applies to: 173-176, 217-221, 253-257

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tools/prompt_studio.py` around lines 123 - 126, The five
billable handlers duplicate MCP call logging; introduce a shared
_log_call(context, tool, **fields) helper that preserves the existing message
format, including context.org_name and context.platform_key.name, then replace
the repeated logger.info blocks in the handlers around index_document and the
other referenced call sites with this helper.
backend/mcp_server/tests/test_platform_tools.py (2)

122-152: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Restore UserContext after tests that switch organizations.

These tests flip the thread-local org identifier to seed foreign fixtures and rely on the next setUp to reset it. Any test elsewhere in the same worker that assumes an unset/other identifier inherits the last value. self.addCleanup(UserContext.set_organization_identifier, None) in each setUp makes the isolation explicit.

Also applies to: 235-256

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tests/test_platform_tools.py` around lines 122 - 152,
Update each affected test setUp, including the setup covering
test_listings_do_not_leak_across_organizations and the tests around the
additional referenced range, to register
self.addCleanup(UserContext.set_organization_identifier, None). Ensure cleanup
restores the thread-local organization identifier after every test that may
switch organizations, without changing the test assertions or fixture behavior.

289-297: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No coverage for the billable Prompt Studio tools that go through _dispatch.

PromptStudioProducerToolsTest covers only the two listing tools; index_document, fetch_response, bulk_fetch_response and single_pass_extraction are untested. Those are exactly the paths that swap request._full_data before dispatching PromptStudioCoreView (see the concern on backend/mcp_server/tools/prompt_studio.py lines 57-69) — a test asserting the view actually observes the tool payload would settle it and pin the contract.

Also applies to: 346-359

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tests/test_platform_tools.py` around lines 289 - 297,
Extend PromptStudioProducerToolsTest to cover index_document, fetch_response,
bulk_fetch_response, and single_pass_extraction through _dispatch. Assert the
dispatched PromptStudioCoreView observes each tool’s payload, including the
required document_id and other request fields, after request._full_data is
replaced; retain the existing listing-tool coverage.
backend/mcp_server/context.py (1)

37-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider precise types under TYPE_CHECKING instead of Any.

user, platform_key and request are Any, so tool handlers get no checking on context.platform_key.name, context.user, etc. if TYPE_CHECKING: imports (User, PlatformApiKey, rest_framework.request.Request) with string annotations avoid runtime import cycles while keeping the contract typed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/context.py` around lines 37 - 40, Replace the Any
annotations for user, platform_key, and request in the context model with
forward-referenced precise types, and add TYPE_CHECKING-only imports for User,
PlatformApiKey, and rest_framework.request.Request to avoid runtime cycles. Keep
org_name unchanged and ensure tool handlers receive the typed context
attributes.
backend/mcp_server/README.md (1)

44-47: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a language to the fenced blocks (markdownlint MD040).

text/http on these four blocks silences the lint warning.

Also applies to: 53-55, 60-62, 156-159

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/README.md` around lines 44 - 47, Add an explicit language
identifier, such as text or http, to the fenced code blocks in README.md,
including the blocks around the REST/MCP endpoints and the referenced sections
at lines 53-55, 60-62, and 156-159, so every fenced block satisfies markdownlint
MD040.

Source: Linters/SAST tools

backend/mcp_server/tools/platform.py (1)

509-518: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse _resolve_pipeline here.

Same lookup and error text as lines 431-439; two copies will drift.

♻️ Proposed refactor
-    pipeline = Pipeline.objects.for_user(context.user).filter(id=pipeline_id).first()
-    if pipeline is None:
-        raise MCPToolError(
-            f"No pipeline with id '{pipeline_id}' in organization "
-            f"'{context.org_name}'. Call listPipelines to see valid ids."
-        )
+    pipeline = _resolve_pipeline(context, pipeline_id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tools/platform.py` around lines 509 - 518, Update
set_pipeline_active to use the existing _resolve_pipeline helper for pipeline
lookup and missing-pipeline errors, passing the current context and pipeline_id.
Remove the duplicated Pipeline.objects query and inline MCPToolError block while
preserving the function’s subsequent behavior.
backend/mcp_server/tools/observability.py (1)

93-99: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

_org_workflow_ids materialises every workflow id per call.

Each execution tool pays a full id fetch plus an unbounded IN (...) clause. Passing the queryset (Workflow.objects.for_user(context.user).values("id")) lets the database do it as a subquery, and list_executions no longer needs the Python-side membership scan at line 138 (compare workflow_id directly instead).

Also applies to: 134-135

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tools/observability.py` around lines 93 - 99, Update
_org_workflow_ids to return the filtered workflow ID queryset via values("id")
instead of materializing a list, allowing execution queries to use a database
subquery. In list_executions, remove the Python-side membership scan and compare
workflow_id directly against the queryset while preserving the existing
visibility filtering.
backend/mcp_server/tools/platform_execution.py (1)

227-236: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Ambiguous deployment when several deployments share a workflow.

.first() picks an arbitrary deployment; the resulting context drives process_completed_execution. Consider ordering deterministically or noting why any deployment on the workflow is equivalent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tools/platform_execution.py` around lines 227 - 236, The
deployment lookup in the execution context must be deterministic when multiple
APIDeployment records share a workflow. Update the query in the deployment
resolution block to apply an explicit, stable ordering before first(), or
document and enforce a verified equivalence if any matching deployment is valid;
preserve the existing MCPToolError behavior when none exists.
backend/mcp_server/transport.py (1)

109-110: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Annotate the unset registry as optional.

registry: MCPToolRegistry = None is an invalid annotation/value pairing; subclasses always set it, so MCPToolRegistry | None documents the base-class state honestly.

♻️ Proposed tweak
-    registry: MCPToolRegistry = None
+    registry: MCPToolRegistry | None = None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/transport.py` around lines 109 - 110, Update the
base-class registry annotation to allow an unset value by declaring it as
MCPToolRegistry | None while retaining the None default. Leave subclass
assignments and registry behavior unchanged.

Source: Linters/SAST tools

backend/mcp_server/tests/test_platform_auth.py (1)

60-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Implicit Optional on url.

-    def _post(self, auth: str | None = None, body: dict | None = None, url: str = None):
+    def _post(
+        self, auth: str | None = None, body: dict | None = None, url: str | None = None
+    ):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tests/test_platform_auth.py` at line 60, Update the _post
method signature so the url parameter explicitly uses the appropriate optional
string type, matching the existing auth and body annotations while preserving
its current default behavior.

Source: Linters/SAST tools

backend/mcp_server/tests/test_registry_reachability.py (1)

129-141: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Deployment producers are never checked against the registry.

PlatformRegistryReachabilityTest has test_every_declared_producer_is_actually_registered, but DEPLOYMENT_ID_PRODUCERS has no equivalent — renaming extractDocument would leave execution_id unobtainable with this suite still green.

♻️ Proposed addition
     assert unproducible == {}, (
         f"Unreachable on the deployment server: {unproducible}"
     )
+
+    def test_every_declared_producer_is_actually_registered(self) -> None:
+        missing = sorted(
+            {
+                producer
+                for producer in DEPLOYMENT_ID_PRODUCERS.values()
+                if DEPLOYMENT_TOOLS.get(producer) is None
+            }
+        )
+
+        assert missing == [], f"Declared id producers are not registered: {missing}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tests/test_registry_reachability.py` around lines 129 -
141, Add a deployment-registry test alongside DeploymentRegistryReachabilityTest
that verifies every ID declared in DEPLOYMENT_ID_PRODUCERS is registered among
DEPLOYMENT_TOOLS, matching
PlatformRegistryReachabilityTest.test_every_declared_producer_is_actually_registered.
Report any unregistered producer mappings and preserve the existing required-ID
coverage test.
backend/mcp_server/tests/test_spend_guard.py (1)

156-175: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test doesn't actually exercise a failing tool through the real dispatch path.

The RuntimeError here is raised and caught locally — it never reaches _call_tool, and nothing in spend_guard/the view ever refunds on failure, so this assertion is vacuously true regardless of the code under test. A stronger pin would drive a billable tool through self.view._call_tool(...) with a handler that actually raises, then assert spend_guard.peek(ORG).used is unchanged — mirroring the style already used in SpendGuardDispatchTest below.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tests/test_spend_guard.py` around lines 156 - 175, Update
test_budget_is_not_refunded_when_a_tool_then_fails to dispatch a billable tool
through self.view._call_tool, using a handler that raises RuntimeError. Assert
after the dispatch failure that spend_guard.peek(ORG).used remains equal to
used_before, mirroring the setup and assertion style in SpendGuardDispatchTest
so the real refund behavior is exercised.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/mcp_server/tools/observability.py`:
- Around line 328-342: The list_tags function must scope Tag queries to the
organization from context, matching the boundary used by get_usage_summary, and
use LIST_LIMIT instead of the hardcoded 100 slice. Include the standard
_truncation_note in the response so callers can distinguish capped results from
complete listings.
- Around line 246-276: Update get_usage_summary to scope the
Usage.objects.aggregate query to the current organization before calculating
totals, using the same organization-scoping mechanism as the other observability
tools (such as for_user). Ensure the returned totals and context.org_name refer
only to that organization, and do not rely on an unfiltered Usage manager or
implicit tenant context.

In `@backend/mcp_server/tools/platform_execution.py`:
- Around line 214-225: Validate caller-supplied UUIDs before applying UUID-typed
ORM filters, preserving each site’s existing MCPToolError response for invalid
or inaccessible values. Update platform_execution.py lines 214-225 in the
execution lookup, observability.py lines 200-212 in get_execution_detail, and
observability.py lines 137-143 for the optional workflow_id filter; parse with
uuid.UUID or catch the ORM field error, and route failures through the existing
actionable MCPToolError messages.

In `@backend/mcp_server/tools/prompt_studio.py`:
- Around line 57-69: The payload is assigned to the existing request’s
_full_data, but PromptStudioCoreView.as_view() creates a new DRF Request and
ignores that override. Update the request setup around
PromptStudioCoreView.as_view and initialize_request so the newly wrapped request
is seeded with payload, or invoke the action using the view-initialized request;
preserve restoration of the original request data and ensure the view parses
only the tool payload.

In `@backend/mcp_server/transport.py`:
- Around line 199-204: Validate the params value in the request handling flow
before passing it to _dispatch: accept only dictionaries, defaulting missing or
null params to an empty dictionary, and return the existing JSON-RPC -32602
invalid-params response for truthy non-dict values. Preserve normal dispatch
behavior for valid parameter dictionaries.

In `@backend/mcp_server/urls.py`:
- Around line 27-35: Review the mcp_server_with_key route and ensure requests
containing api_key are scrubbed or excluded from nginx, Gunicorn, and APM access
and trace logs before deployment. Reuse the existing logging configuration
mechanisms where available, or document and enforce an appropriate key-rotation
policy if path redaction cannot be guaranteed.

In `@backend/mcp_server/views.py`:
- Around line 82-93: Move DeploymentHelper.validate_parameters and
get_deployment_by_api_name into the same try block as validate_api, preserving
their existing order. Keep the existing warning and None return in the shared
except Exception handler so missing parameters, unknown APIs, and invalid keys
follow the same response path.

---

Nitpick comments:
In `@backend/mcp_server/context.py`:
- Around line 37-40: Replace the Any annotations for user, platform_key, and
request in the context model with forward-referenced precise types, and add
TYPE_CHECKING-only imports for User, PlatformApiKey, and
rest_framework.request.Request to avoid runtime cycles. Keep org_name unchanged
and ensure tool handlers receive the typed context attributes.

In `@backend/mcp_server/README.md`:
- Around line 44-47: Add an explicit language identifier, such as text or http,
to the fenced code blocks in README.md, including the blocks around the REST/MCP
endpoints and the referenced sections at lines 53-55, 60-62, and 156-159, so
every fenced block satisfies markdownlint MD040.

In `@backend/mcp_server/tests/test_platform_auth.py`:
- Line 60: Update the _post method signature so the url parameter explicitly
uses the appropriate optional string type, matching the existing auth and body
annotations while preserving its current default behavior.

In `@backend/mcp_server/tests/test_platform_tools.py`:
- Around line 122-152: Update each affected test setUp, including the setup
covering test_listings_do_not_leak_across_organizations and the tests around the
additional referenced range, to register
self.addCleanup(UserContext.set_organization_identifier, None). Ensure cleanup
restores the thread-local organization identifier after every test that may
switch organizations, without changing the test assertions or fixture behavior.
- Around line 289-297: Extend PromptStudioProducerToolsTest to cover
index_document, fetch_response, bulk_fetch_response, and single_pass_extraction
through _dispatch. Assert the dispatched PromptStudioCoreView observes each
tool’s payload, including the required document_id and other request fields,
after request._full_data is replaced; retain the existing listing-tool coverage.

In `@backend/mcp_server/tests/test_registry_reachability.py`:
- Around line 129-141: Add a deployment-registry test alongside
DeploymentRegistryReachabilityTest that verifies every ID declared in
DEPLOYMENT_ID_PRODUCERS is registered among DEPLOYMENT_TOOLS, matching
PlatformRegistryReachabilityTest.test_every_declared_producer_is_actually_registered.
Report any unregistered producer mappings and preserve the existing required-ID
coverage test.

In `@backend/mcp_server/tests/test_spend_guard.py`:
- Around line 156-175: Update test_budget_is_not_refunded_when_a_tool_then_fails
to dispatch a billable tool through self.view._call_tool, using a handler that
raises RuntimeError. Assert after the dispatch failure that
spend_guard.peek(ORG).used remains equal to used_before, mirroring the setup and
assertion style in SpendGuardDispatchTest so the real refund behavior is
exercised.

In `@backend/mcp_server/tools/observability.py`:
- Around line 93-99: Update _org_workflow_ids to return the filtered workflow ID
queryset via values("id") instead of materializing a list, allowing execution
queries to use a database subquery. In list_executions, remove the Python-side
membership scan and compare workflow_id directly against the queryset while
preserving the existing visibility filtering.

In `@backend/mcp_server/tools/platform_execution.py`:
- Around line 227-236: The deployment lookup in the execution context must be
deterministic when multiple APIDeployment records share a workflow. Update the
query in the deployment resolution block to apply an explicit, stable ordering
before first(), or document and enforce a verified equivalence if any matching
deployment is valid; preserve the existing MCPToolError behavior when none
exists.

In `@backend/mcp_server/tools/platform.py`:
- Around line 509-518: Update set_pipeline_active to use the existing
_resolve_pipeline helper for pipeline lookup and missing-pipeline errors,
passing the current context and pipeline_id. Remove the duplicated
Pipeline.objects query and inline MCPToolError block while preserving the
function’s subsequent behavior.

In `@backend/mcp_server/tools/prompt_studio.py`:
- Around line 123-126: The five billable handlers duplicate MCP call logging;
introduce a shared _log_call(context, tool, **fields) helper that preserves the
existing message format, including context.org_name and
context.platform_key.name, then replace the repeated logger.info blocks in the
handlers around index_document and the other referenced call sites with this
helper.

In `@backend/mcp_server/transport.py`:
- Around line 109-110: Update the base-class registry annotation to allow an
unset value by declaring it as MCPToolRegistry | None while retaining the None
default. Leave subclass assignments and registry behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 385f1c11-2f16-4d90-98be-e1bb61deecb3

📥 Commits

Reviewing files that changed from the base of the PR and between 023b140 and 8e031e2.

📒 Files selected for processing (38)
  • backend/api_v2/execution_urls.py
  • backend/backend/settings/base.py
  • backend/backend/urls_v2.py
  • backend/mcp_server/README.md
  • backend/mcp_server/__init__.py
  • backend/mcp_server/apps.py
  • backend/mcp_server/constants.py
  • backend/mcp_server/context.py
  • backend/mcp_server/exceptions.py
  • backend/mcp_server/platform_urls.py
  • backend/mcp_server/platform_views.py
  • backend/mcp_server/registry.py
  • backend/mcp_server/spend_guard.py
  • backend/mcp_server/tests/__init__.py
  • backend/mcp_server/tests/test_mcp_auth.py
  • backend/mcp_server/tests/test_mcp_protocol.py
  • backend/mcp_server/tests/test_no_credential_leak.py
  • backend/mcp_server/tests/test_platform_auth.py
  • backend/mcp_server/tests/test_platform_tier_guard.py
  • backend/mcp_server/tests/test_platform_tools.py
  • backend/mcp_server/tests/test_redaction.py
  • backend/mcp_server/tests/test_registry_reachability.py
  • backend/mcp_server/tests/test_spend_guard.py
  • backend/mcp_server/tests/test_tool_errors.py
  • backend/mcp_server/tests/test_tool_execution.py
  • backend/mcp_server/tools/__init__.py
  • backend/mcp_server/tools/execution.py
  • backend/mcp_server/tools/info.py
  • backend/mcp_server/tools/observability.py
  • backend/mcp_server/tools/platform.py
  • backend/mcp_server/tools/platform_execution.py
  • backend/mcp_server/tools/prompt_studio.py
  • backend/mcp_server/transport.py
  • backend/mcp_server/urls.py
  • backend/mcp_server/views.py
  • backend/permissions/permission.py
  • backend/sample.env
  • tests/critical_paths.yaml

Comment thread backend/mcp_server/tools/observability.py
Comment thread backend/mcp_server/tools/observability.py
Comment thread backend/mcp_server/tools/platform_execution.py
Comment thread backend/mcp_server/transport.py
Comment thread backend/mcp_server/urls.py Outdated
Comment thread backend/mcp_server/views.py

@chandrasekharan-zipstack chandrasekharan-zipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated multi-lens review — the two hosted MCP servers

Strong PR overall: in-process delegation (no third URL-encoded copy of the API), auth-by-URL-placement, the per-tool tier guard, the spend guard and credential redaction are all soundly designed, and the test suite is above-average — dynamic registry sweeps, the real middleware stack where auth lives in middleware, and explicit anti-tautology guards. No hard blocker. The 4 inline comments are worth fixing; nits are batched here to keep the noise down.

Nits (non-blocking)

  • platform_urls.py:8 — the docstring attributes the deployment server's auth exemption to a startswith match on /mcp/, but it's actually exempt because it sits under the /deployment/ prefix (that path doesn't even start with /mcp). Wrong mental model in a security-critical routing comment — the README states it correctly.
  • platform.py:433/513, observability.py:205, platform_execution.py:215 — a typo'd UUID hits .filter(id=…) → Django ValidationError → the generic "failed unexpectedly, contact your administrator", instead of the actionable "no such id, call listX" these handlers otherwise produce. Catch ValidationError/ValueError in the _resolve_* helpers.
  • transport.py:160 — the GET 405 uses DRF Response, so a browser sending Accept: text/html gets the browsable-API HTML renderer; the POST path deliberately uses JsonResponse to avoid exactly that.
  • spend_guard.py:155 vs :191peek (used < limit) and consume (used > limit) disagree by one, so whoami can report 0 remaining while a billable call still succeeds.
  • urls.py:30 — the /mcp/<api_key> path variant puts a credential in the URL (reaches access logs/proxies). Already acknowledged in the PR body; noting for the record.
  • Wording: spend_guard.py:6 & README:244 say "rolling window" but the impl is a fixed/tumbling window (the files say "fixed window" elsewhere); README:176 calls it "remaining spend budget" where it counts billable calls, not tokens.
  • observability.py:225getExecutionDetail returns an incomplete files list on a partial load failure with no degradation marker, so an agent reads it as "no per-file failures".

Generated with a multi-agent review (Claude Code).

Comment thread backend/mcp_server/tests/test_no_credential_leak.py
Comment thread backend/mcp_server/registry.py
Comment thread backend/mcp_server/transport.py Outdated
Comment thread backend/mcp_server/spend_guard.py Outdated
@chandrasekharan-zipstack

Copy link
Copy Markdown
Contributor

Design question — the per-tool tier guard is defense-in-depth only today, and its future value rests on an unenforced invariant.

Tracing the platform server's authorization end-to-end:

  • Every JSON-RPC message is an HTTP POST. CustomAuthMiddleware runs ApiKeyPermission.allows("POST") before the view, so a read key is refused on every call — including initialize, tools/list, and the read-only discovery tools. The read-tier branch in check_tool_allowed (platform_views.py) is therefore unreachable today.
  • read_write and full_access both allow POST, and every registered tool declares GET or POST (no DELETE tool exists). So check_tool_allowed never actually refuses any request that reaches it.

Net: the per-tool tier mechanism protects nothing at present — it's purely prospective. That's fine to ship, but two decisions are worth making explicit rather than by default:

  1. State read_write as the minimum tier for the platform server. The PR body notes the read-key constraint; promoting it to a documented requirement (in whoami/README) closes the gap between "a read key should be able to call read tools" that the docstrings imply and the actual behaviour. Or, if read-tier read-only access was intended, add a middleware carve-out so POST-to-MCP isn't rejected on tier alone and defer the method/tier decision to check_tool_allowed.
  2. The guard's entire future value depends on each tool's required_method being correct — which isn't enforced at construction (see the inline comment on registry.py:60). A __post_init__ invariant would make the guard trustworthy the day a DELETE (or otherwise mutating) tool is added, instead of relying on a reviewer catching a wrong flag.

Not blocking — flagging so the "is the tier model doing what we think it does" question gets a deliberate answer.

Audit of the two human review summaries (chandrasekharan's 4785967101,
athul-rs's 4827776046) found five items never tracked: I had seeded only the
*inline* comments into the remediation ledger, so batched summary items were
invisible to the loop. Four of the twelve were already fixed as side effects;
one was waived by athul-rs himself.

**getExecutionDetail degraded silently** (the only behaviour bug of the five).
A failed per-file load was caught, logged, and returned a partial `files` list
with no marker — so an agent asking "which files failed" reads a short or empty
list as "none did", the opposite of the truth. Now sets `files_complete: False`
with a note pointing at `failed_files` as authoritative. The same marker covers
hitting LOG_LIMIT, which truncated just as silently.

**platform_urls.py docstring had the wrong mental model** in a security-critical
routing comment: it attributed the deployment server's auth exemption to a
`startswith` match on `/mcp/`. Verified against settings — `WHITELISTED_PATHS`
contains `/{API_DEPLOYMENT_PATH_PREFIX}` (i.e. `/deployment`) and never mentions
`/mcp`. The exemption is the prefix; the docstring now says so, and says
re-mounting under `/deployment/` is what would remove authentication.

**"rolling window" was wrong** in spend_guard.py:6 and README:243, and the same
file said "fixed window" 56 lines later. The TTL-expiry implementation is fixed;
both now say so and explain the difference.

**README called it "remaining spend budget"** where it counts billable calls,
not tokens — the distinction the module docstring is at pains to make.

**The PR description listed 19 platform tools against 23 registered.** The
listing had also gone stale, omitting listPromptStudioDocuments, listPrompts,
extractDocument and getExecutionStatus — exactly the four making up the gap.
Description updated; ToolCountIsDocumentedTest now pins both counts and asserts
every registered platform tool appears in README.md, so a count alone cannot
pass while a tool is swapped.

Tests: 450 passed, 0 failed.
Comment thread backend/mcp_server/tools/execution.py Outdated
CI's integration tier — which I have never been able to run locally — failed on
`test_no_read_tool_returns_a_credential`:

    MCPToolError: Execution 'b6df8dc2...' did not come from an API deployment.

My breakage, from tightening execution scoping to `pipeline_id`. The fixture
creates a WorkflowExecution directly with only `workflow_id`, so it records no
originating deployment; the platform poll then cannot resolve one and refuses
before the tool renders anything to scan.

The fixture was the unrealistic part, not the guard: `DeploymentHelper` sets
`pipeline_id = api.id` on every real API-deployment run, so an execution without
one is not a shape production produces. It now links the deployment the sweep
already creates.

Reproduced both directions against the real code path with the ORM stubbed —
the old fixture raises exactly the CI error, the new one lets the tool run.

Worth noting what this cost: one red test in integration-backend meant both MCP
critical paths (`mcp-server-auth`, `mcp-platform-auth`) failed to attest and 7
pre-existing paths reported as regressions — the cascade athul-rs predicted on
the `Allow` thread.

Tests: 450 passed locally (unit tier); the integration tier remains CI-only.
@hari-kuriakose

Copy link
Copy Markdown
Contributor Author

Audit of both review summaries — 5 missed items, now cleared (afac6400), plus a CI fix (0158891e)

Prompted by the question on issuecomment-5090152311. Root cause: when I set up the remediation loop I seeded only the inline comments into its ledger, so batched summary items were invisible to it. That was a scoping mistake on my part, not a judgement about the content — @chandrasekharan-zipstack's 7 nits and @athul-rs's 4 items were never tracked.

@chandrasekharan-zipstack4785967101

Item Status
platform_urls.py:8 blames /mcp/ startswith; really the /deployment/ prefix Fixed afac6400
Typo'd UUID → ValidationError → generic error in _resolve_* Fixed earlier (78a4027, fa426e4)
transport.py:160 DRF Response → browsable HTML renderer Fixed earlier (557fba3)
peek/consume off-by-one Waived@athul-rs showed they agree and peek().allowed is never read
urls.py:30 credential in URL Fixed earlier (8e418db) — route removed
"rolling window" wording (2 files) Fixed afac6400
README "remaining spend budget" counts calls not tokens Fixed afac6400
getExecutionDetail partial load, no degradation marker Fixed afac6400

@athul-rs4827776046

The three merge blockers were all inline and are done. The two skippable comments and the peek/consume waiver needed no action. Of the doc drift: platform_urls.py and "rolling window" are the duplicates above; "description says 19 platform tools, registry registers 23" was open — and the listing was stale too, missing listPromptStudioDocuments, listPrompts, extractDocument and getExecutionStatus, exactly the four making up the gap. PR description updated.

The one behaviour bug of the five

getExecutionDetail caught a failed per-file load, logged it, and returned a partial files list with no marker. An agent asking "which files failed" reads a short or empty list as "none did" — the inverse of the truth. Now sets files_complete: False with a note pointing at failed_files as authoritative; the same marker covers hitting LOG_LIMIT, which truncated just as silently. Three tests, including one asserting the marker is absent on the happy path so it keeps meaning something.

Verified rather than restated: WHITELISTED_PATHS contains /{API_DEPLOYMENT_PATH_PREFIX} and never mentions /mcp; spend_guard.py contradicted itself, saying "rolling" on line 6 and "fixed" on line 62.

ToolCountIsDocumentedTest now pins both registry counts and asserts every registered platform tool appears in README.md — a count alone would pass while one tool was swapped for another.

Separately: the integration tier ran, and I had broken it

CI on 60c31f5a failed test_no_credential_leak.py::test_no_read_tool_returns_a_credential:

MCPToolError: Execution 'b6df8dc2...' did not come from an API deployment.

Mine, from tightening execution scoping to pipeline_id. The fixture created a WorkflowExecution with only workflow_id, so it recorded no originating deployment. The fixture was the unrealistic part — DeploymentHelper sets pipeline_id = api.id on every real API-deployment run — so it now links the deployment the sweep already creates. Reproduced both directions against the real code path with the ORM stubbed before pushing.

@athul-rs — this is the cascade you described on the Allow thread: one red test in integration-backend meant both MCP critical paths failed to attest and 7 pre-existing paths reported as regressions. Fixed in 0158891e; the next CI run is the check.

greptile, execution.py:224. `logger.exception(f"...: {error}")` wrote the raw
exception two lines above where the same text was redacted for the client — so
the secret the agent could not see went to the logs instead, where it outlives
the request and is shipped to aggregation, reaching an audience the credential
was never scoped to.

Fixed more broadly than the one line, because the same pattern was at six sites
and the exception at each comes from upstream: `transport.py:398` catches *any*
tool handler, `prompt_studio.py:142` catches the delegated view (adapters,
vector stores), `spend_guard.py:134` renders a cache error that can carry the
Redis URL with its password.

Added `sanitize.log_exception`, which redacts the message **and deliberately
drops the traceback**. That second part is not in the suggestion but matters
more: `redact_secrets` can only clean the string it is given, while a traceback
renders frame locals — precisely where a connection string or key sits. Losing
the stack is the cost; the exception type and redacted message are kept, which
is what identifies the failure.

Three log sites left as-is (`transport.py:394` bad-arguments TypeError, and the
spend-guard re-seed path) raise from local code with no upstream text.

One existing test asserted `logger.exception` was called; it now asserts a 5xx
is logged at error level, which is the contract — naming the method would have
pinned the implementation this change deliberately alters.

Verified non-tautological: removing the redaction fails 3 tests.

Tests: 454 passed, 0 failed. CI is green on 0158891 including the integration
tier, so this lands on a passing baseline.
Comment thread backend/mcp_server/tools/prompt_studio.py Outdated
greptile, prompt_studio.py:115. A well-formed but nonexistent `document_id`
escaped `_dispatch`'s exception arm, so the agent got "failed unexpectedly"
after the non-refundable billable budget had already been spent on a call that
did no work.

Verified the mechanism rather than assuming it: the four actions do not resolve
their arguments the same way. `bulk_fetch_response` catches
`DocumentManager.DoesNotExist` and answers 404 (views.py:743-748), but
`index_document`, `fetch_response` and `single_pass_extraction` call a bare
`.objects.get(pk=...)` (views.py:476, 581, 833). That raises
`ObjectDoesNotExist`, whose MRO is `(ObjectDoesNotExist, Exception,
BaseException)` — neither `APIException` nor `Http404`, so the arm did not see
it. Exactly the three tools named.

Caught alongside `Http404` now, since both mean "the thing you named is not
there". Rendered as a 404 with an actionable detail rather than
`str(error)`: `str(DocumentManager.DoesNotExist())` is empty, so the agent
would have received a blank message. It now says which listing to re-read.

Deliberately not logged. An agent naming an id that does not exist is ordinary,
and logging it at error level would make a caller's typo look like a server
fault — the same distinction the 5xx branch above draws.

Also corrects that branch's docstring, stale since the previous commit: it said
a 5xx is logged "with its traceback", which `log_exception` no longer attaches.

Three tests, including one that the base class is caught rather than a specific
model's subclass, and one that a missing object is *not* logged. Removing
`ObjectDoesNotExist` from the arm fails all three with the raw exception.

Tests: 457 passed, 0 failed. CI green on 10aa824 including the integration tier.
Comment thread backend/mcp_server/spend_guard.py Outdated
…e pattern

greptile, spend_guard.py:178. I converted `peek` to `log_exception` in the
previous commit and left both `consume` branches raw, so a cache failure on a
billable call still wrote the connection error verbatim.

Writing the test found the larger half: **`peek` was leaking too.** The URL
pattern required at least one character of userinfo —
`[^\s:/@]+:` — but Redis URLs conventionally omit the username entirely:

    redis://:s3cr3tpw@cache.internal:6379/0

so the most common credential-bearing URL in this codebase was the one shape
the pattern did not match, and converting the call site alone would have fixed
nothing. `+` -> `*` on the userinfo.

Verified the widening does not over-redact: `https://example.com/a.pdf`,
`redis://localhost:6379/0`, a pre-signed S3 URL with `X-Amz-Signature`, and
prose containing a bare URL all pass through unchanged. The host and port
survive redaction, so an operator can still see which cache failed.

Four tests. Three drive the real `consume`/`peek` handlers with a
credential-bearing `redis.ConnectionError` and assert nothing reaches the mocked
logger; the fourth greps this module's own source for
`logger.error(f"...{error}")`, so a *new* cache handler written the old way
fails rather than silently reintroducing the leak — which is exactly how the two
`consume` branches came to differ from `peek`.

Reverting either half fails: the call-site conversion alone leaves 3 red, the
pattern alone leaves the same 3 red.

Tests: 463 passed, 0 failed.
Picks up ResourceTable (frontend/src/components/widgets/resource-table/),
which cloud main's agentic-prompt-studio plugin imports — without it the
Cloud Frontend image fails to build:

  Could not resolve "../../../components/widgets/resource-table/ResourceTable"
  from "src/plugins/agentic-prompt-studio/pages/Projects.jsx"

Also brings in #2223, which covers the Platform API key branch of
CustomAuthMiddleware — the auth path the platform MCP server relies on.
Comment thread backend/mcp_server/sanitize.py Outdated
- constants.py: add the canonical JSON-RPC `message` strings beside the codes
  they belong to. The spec fixes these strings and a client may match on them,
  so they are defined once rather than repeated at each raise site.
- transport.py: use them, replacing 3x "Invalid Request" and 5x "Invalid params"
  (sonar: duplicated literals).
- transport.py: `registry` is None on the base class, so the hint is
  `MCPToolRegistry | None` (sonar: don't assign None to a non-Optional).
- transport.py: extract `_run_preflight` out of `_call_tool`, which was at
  cognitive complexity 16 against a limit of 15. The block is self-contained —
  three except arms and an early return — and pulling it out leaves the
  ordering guarantee it exists for (validate before charging budget) stated in
  one place instead of buried mid-function.
- tools/prompt_studio.py: two parameter descriptions were repeated verbatim
  3x each; define `_PROJECT_ID_DESC` and `_PROJECT_ID_DESC_WITH_HINT` so an
  edit cannot silently apply to only some tool schemas.

No behaviour change. The seventh Sonar finding is in sanitize.py, left alone
pending the redaction question raised in review.
The catch-all in extract_document returned `redact_secrets(str(error))` to the
MCP client. Greptile flagged this as the reason for its 4/5 confidence:
"pattern redaction removes recognized secrets without removing other internal
details".

That is right, and understated. The handler catches *any* failure from a
connector, a provider client or the execution stack, so the text can carry
internal hostnames, filesystem paths, row ids and stack detail — none of which
redaction targets, and none of which an MCP client should see. Worse,
"recognized" is doing more work than it can bear: redact_secrets currently
misses every credential name with a prefix (`access_token=`, `client_secret=`)
because its pattern is anchored with `\b`, so it does not reliably remove even
the recognised ones. That is raised separately in review.

The failure is already logged in full one line above, with the api name, so an
operator can correlate it by name and timestamp. The client now gets a fixed
message instead.

Expected, agent-actionable failures are unaffected: validation errors and
MCPToolError still carry their specific text, because those are written for the
agent to act on. Only the unexpected catch-all is generalised.
… check liveness in preflight

Five review findings, all independent of the open sanitize.py question.

transport.py — `method` and `params.name` were checked for truthiness but not
type. JSON permits any type there, so a non-string `method` reached
`method.startswith(...)` as an AttributeError, and an unhashable `name` (a JSON
object or array) reached `registry.get()` — a dict lookup — as
`TypeError: unhashable type`. Both escaped as a Django 500 with no JSON-RPC
envelope, the one failure shape a conformant client cannot parse. The `params`
check immediately below already documented this exact class; it simply was not
applied to the other two.

registry.py — `getExecutionStatus` advertised `readOnlyHint: true`. It sets
neither `writes` nor `billable`, so the derivation made it read-only, but the
result store is one-shot: reading a COMPLETED execution acknowledges it and the
payload cannot be fetched again. A client is told it is safe to poll freely,
which is how a result gets lost. Adds a `consumes_result` flag that
participates in the derivation, rather than letting a tool declare a hint its
own flags contradict — the property that method exists to preserve.

observability.py, platform.py — three tools filtered a UUID column with a raw
client-supplied string. A malformed id raises Django's ValidationError, which
surfaces as "Tool execution failed" instead of the actionable message
`valid_uuid` produces. `list_executions` is deliberately untouched: its
membership check against visible ids runs first and already rejects cleanly.

platform_execution.py, platform.py — the preflights resolved their target but
skipped the liveness check the handler makes, so calling either against an
inactive deployment or a paused pipeline claimed a non-refundable budget slot
for a call that reached no LLM. That is the precise failure preflight exists to
prevent, and it compounds: `setApiDeploymentActive` and `setPipelineActive` are
registered alongside, so an agent can deactivate a target and then exhaust
MCP_BILLABLE_CALL_LIMIT retrying against it, locking the organization out of
every billable tool.
`test_read_tools_are_marked_read_only` and its inverse classified tools with
`tool.writes or tool.billable` — the previous derivation, duplicated in the
test rather than sourced from it — so adding `consumes_result` left
getExecutionStatus in the "read tool" bucket and the assertion failed.

Both filters now include `consumes_result`, and a named test pins the actual
intent: getExecutionStatus reads, but reading acknowledges a one-shot result,
so it must not be advertised as read-only. That test is explicit rather than
left to the loops because it is the only tool in this category on either
server — a refactor dropping the flag would otherwise just make one subtest
quietly stop running instead of failing.

Deliberately NOT changed: the three `writes or billable` filters in
test_no_credential_leak.py. They use the same expression for a different
question — which tools return data worth sweeping for credentials — and
adding `consumes_result` there would exclude getExecutionStatus from the leak
sweep, which is the opposite of what that suite is for.

Not verified locally: `uv run pytest` cannot install in this environment
(django-celery-beat==2.5.0 ships a wheel with duplicate ZIP entries), so CI is
the check.
# Conflicts:
#	backend/backend/urls_v2.py
Only the deployment-scoped MCP server is required for the initial release. The
organization-scoped one exposes 23 tools that reach the whole organization, so
it now ships off behind MCP_PLATFORM_SERVER_ENABLED (default false) rather than
being removed — the code is reviewed and tested, and turning it on later is a
settings change instead of a revert.

The deployment server is unaffected: it mounts unconditionally from
api_v2/execution_urls.py and shares only the transport.

Enabled in settings/test.py, deliberately. Several of its tests drive requests
through the full URL stack precisely so CustomAuthMiddleware runs, and an
unmounted route would 404 them — turning a suite that asserts a bad credential
is *rejected* into one that passes because nothing is there. Shipping-off and
untested are different things.

Also resolves the merge with main, which added the global-api-deployment mount
on the same line the platform MCP server previously occupied.

Worth noting this defers rather than answers the tier question raised in
review: a `read`-tier platform key cannot use that server at all, because every
MCP call is an HTTP POST and the middleware gates tiers on method. Nothing is
exposed while it is off.
getExecutionStatus returned "-32002 Tool execution failed" for any well-formed
execution_id that matched no execution. Reproduced against a live deployment:
polling a random UUID gives an opaque protocol error rather than anything an
agent can act on.

ExecutionQuerySerializer.validate_execution_id raises two different types —
ValidationError for a malformed UUID, and ExecutionDoesNotExistError (an
APIException, not a ValidationError) for a well-formed id that does not exist.
Only the first was caught, so the second escaped to the transport catch-all.

The deployment-scoped not-found message further down the function was therefore
unreachable: the serializer runs three lines earlier and raises first. The new
arm answers with that same wording, so whether an id belongs to another
deployment or to no execution at all is indistinguishable to the caller — which
is the existing scoping guard's intent, and the next step is the same either
way.

Found by calling the deployed endpoint, not by reading: the app's own tests
drive the view through APIRequestFactory and never exercise this path.
The key=value redaction pattern anchored on `\b`, which reads as "whole word
only" but does not behave that way: underscore is a word character, so there is
no boundary inside `aws_secret_access_key` for `\b` to find and the match never
started. Every prefixed spelling was therefore unredacted — `aws_secret_access_key`,
`db_password`, `x-api-key`, `client_secret`, `my_token` — while only the bare
names were ever covered. Those prefixed forms are the shapes credentials
actually travel under in an env dump or an SDK error, i.e. the common case,
not the exotic one.

Replaced with a negative lookbehind that keeps the part of `\b`'s intent that
was paying for itself — a match still cannot begin mid-word after an
alphanumeric, so `hastoken` is not a hit — while allowing a `_`/`-` separated
prefix. Guarding against bare-word false positives was never `\b`'s job here:
`token_count=5` has always been safe because `token` is not followed by a
delimiter, and it still is.

Also scopes the Bearer pattern's case-insensitivity to the keyword. Under a
pattern-wide `(?i)` the token class `[A-Za-z0-9._-]` was the same class written
twice, which reads as deliberate but is redundant — SonarCloud python:S5869.

The literal pre-filter needs no change: `secret`, `access_key` and the rest are
already anchors, so the newly-matching strings all reach the regex pass.
The previous form used `(?i:bearer)` to keep the token class ASCII-explicit
while folding only the keyword's case. SonarCloud reads the scoped-flag group
as a pointless wrapper (python:S6395), so this reverts to a pattern-wide `(?i)`
and writes the token class in one case, which is what `(?i)` already means.

`\w` would have been shorter still, and wrong: it is Unicode by default, so it
would quietly widen what counts as a token character. The explicit ASCII class
keeps the previous behaviour exactly.

No behaviour change — `test_bearer_is_matched_case_insensitively` pins all four
spellings.
… one

`_SECRET_KEY_NAMES`'s comment claims it shares a vocabulary with the key=value
pattern. It did not. `secret_key`, `private_key` and `access_key_id` were
listed there as credentials and were not matchable in free text, so the same
name was redacted when it arrived as a dict key and passed through untouched
when it arrived in an error message — the same secret reaching an agent by a
different route.

`private_key` needed a second fix to be reachable at all. `redact_secrets`
short-circuits on a literal pre-filter before running any regex, and no anchor
contained "private", so the alternative would have been dead code even once
added. All three spellings the pattern accepts are now anchors.

Deliberately NOT added: a `credentials?` plural. It matches inside
`has_credentials: true` — an ordinary boolean field — because the lookbehind
permits the `has_` prefix and only the trailing `s` currently keeps the
delimiter from lining up. This pattern also runs over extracted document output
via `redact_structure`, where a false positive destroys real data rather than
being merely noisy, so the plural stays with `_is_secret_key`, which can demand
a whole-key match. Pinned as a test so it is not "fixed" later by accident.

Still no bare `key` alternative: `prompt_key` and `foreign_key` are real field
names on these paths.

Verified over 42 strings — 24 that must redact, 18 that must survive verbatim
(`token_count`, `monkey`, `keyboard`, `public_key_algorithm`, `primary_key`).
All three descriptions a client reads — the tool description, the
`document_urls` schema, and the readMeFirst guide — said that only S3
pre-signed URLs are accepted and that an ordinary public link is rejected. The
validator only ever checked the *host*: HTTPS, on an `*.amazonaws.com` S3
endpoint. It has never looked for a signature, so a publicly-readable S3 object
is accepted and extracts normally.

This was not a cosmetic inaccuracy. Found by attaching the server to a real MCP
client and giving a fresh agent a plain-English task: it read that sentence,
concluded the call would be refused, and declined to make it — citing the
tool's own warning not to call speculatively. A second, independent run reached
the same conclusion. For an MCP tool the description *is* the interface, and
this one talked a working call out of existing. Confirmed against the live
deployment afterwards: the same unsigned public URL extracted successfully.

The wording now states the restriction that is real (S3 host, HTTPS) and names
the pre-signed URL as the usual way to reach a private object rather than as
the only accepted form.

Pinned by tests that run the real serializer over the accept/reject matrix, so
the prose and the validator cannot drift apart again silently. The guard checks
all three description sites, because the claim was wrong in all three and
fixing one would have left an agent reading either of the others. It asserts
the absence of the specific false sentences rather than word co-occurrence —
correct wording legitimately contains both "publicly readable" and "rejected"
(of a non-S3 host). Verified to fail against the old text.
@sonarqubecloud

sonarqubecloud Bot commented Aug 7, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
e2e-api-deployment e2e 3 0 0 0 20.6
e2e-coowners e2e 1 0 0 0 1.4
e2e-etl e2e 1 0 0 0 8.3
e2e-login e2e 2 0 0 0 1.3
e2e-prompt-studio e2e 1 0 0 0 4.6
e2e-smoke e2e 2 0 0 0 1.0
e2e-workflow e2e 1 0 0 0 16.7
integration-backend integration 267 0 0 26 44.8
integration-connectors integration 1 0 0 7 7.9
integration-workers integration 140 0 0 1 50.2
unit-backend unit 998 0 0 1 30.8
unit-connectors unit 63 0 0 0 9.2
unit-core unit 33 0 0 0 1.0
unit-platform-service unit 15 0 0 0 2.8
unit-rig unit 109 0 0 0 4.3
unit-sdk1 unit 480 0 0 0 21.0
unit-workers unit 1335 0 0 1 86.2
TOTAL 3452 0 0 36 312.3

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

@Deepak-Kesavan
Deepak-Kesavan merged commit aac5ede into main Aug 7, 2026
16 checks passed
@Deepak-Kesavan
Deepak-Kesavan deleted the worktree-mcp-server branch August 7, 2026 07:54
chandrasekharan-zipstack pushed a commit that referenced this pull request Aug 7, 2026
…ot hold (#2231)

* fix(mcp): skip the platform auth tests where their preconditions do not hold

These tests assert that a credential is *rejected*, which only means something
if the endpoint exists and something is there to reject it. Neither holds on
Unstract Cloud, and both went unnoticed because a missing endpoint fails in the
direction that looks like success.

Cloud main went red the moment #2207 landed. `copy_cloud_deps` overwrites the
OSS `settings/test.py` with a redirect to `test_cloud`, which derives from
`settings/cloud` — so `MCP_PLATFORM_SERVER_ENABLED = True` never reaches the
cloud suite, `urls_v2` leaves the route unmounted, and five of these tests 404.

The sixth is the reason this is a skip and not a deselect:
`test_bad_credentials_are_rejected` *passed* on cloud. A 404 satisfies "this
request is refused" exactly as well as the 401 it was written to assert, so it
reported green for auth that was never reached. Ignoring the file would have
kept that hidden; a skip names it.

Enabling the flag on cloud would not have fixed it either. The same
`test_cloud.py` drops `CUSTOM_AUTH_MIDDLEWARE` from the default test
`MIDDLEWARE`, and this view carries `permission_classes = []` and deliberately
does not re-authenticate — so the endpoint would have gone from unmounted to
unauthenticated, trading five failures for a different five.

So both preconditions are checked, and checked directly rather than by reading
`MCP_PLATFORM_SERVER_ENABLED`: the flag is what causes the mount in the OSS
URLconf, but it is not what these tests need. Asking for the route and the
middleware stays correct for any tree that mounts the server another way, and
the skip re-arms on its own if cloud ever runs this endpoint with auth.

The route probe resolves a path with no organization segment, unlike the URL
the tests request — tenant middleware strips the org from `path_info` before
resolution, so resolving the request URL as written 404s even on OSS.

Verified against real merged trees: OSS reports no unmet precondition and the
suite runs unchanged; the cloud tree reports the route reason with the flag off
and the middleware reason with it on. 198 unit tests still pass.

This does not change what ships. The deployment-scoped MCP server — the one
required for release — is mounted unconditionally in `api_v2/execution_urls.py`
and is unaffected.

* fix(mcp): gate on the flag, and assert the wiring when it is on

Addresses review: skipping on a 404 can hide a genuine regression.

That is right, and it is the direction that matters. Where the org-scoped
server is meant to be enabled, an unmounted route is a bug — and the previous
version would have gone quiet exactly when it should have shouted, which is the
same failure this suite exists to catch, one level up.

So the flag now decides whether the server is expected here, and the route and
middleware become assertions rather than skip conditions:

  MCP_PLATFORM_SERVER_ENABLED off -> skip; the server is deliberately absent
  (Unstract Cloud ships it that way) and there is nothing to authenticate.

  MCP_PLATFORM_SERVER_ENABLED on  -> the route must resolve and
  CUSTOM_AUTH_MIDDLEWARE must be in MIDDLEWARE, or the suite fails and says
  which one is missing.

The middleware assertion is kept because the flag alone does not make these
tests meaningful: cloud's `test_cloud.py` drops CUSTOM_AUTH_MIDDLEWARE from the
default test MIDDLEWARE, and this view carries `permission_classes = []` and
does not re-authenticate. Enabling the flag there without the middleware would
move the endpoint from unmounted to unauthenticated — so if someone turns the
server on for the cloud suite, this fails with the reason rather than passing
against an open endpoint.

Verified in all four states: enabled and wired up runs; disabled skips; enabled
with the middleware dropped fails naming the middleware; enabled against a
URLconf without the mount fails naming the path. 198 unit tests still pass.
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.

4 participants