Skip to content

feat(AI-3669): support pinning MCP queries to an explicit workspace ID - #654

Open
Matovidlo wants to merge 6 commits into
mainfrom
martinvasko-ai-3669-use-app-workspace-for-kai-queries-in-apps
Open

feat(AI-3669): support pinning MCP queries to an explicit workspace ID#654
Matovidlo wants to merge 6 commits into
mainfrom
martinvasko-ai-3669-use-app-workspace-for-kai-queries-in-apps

Conversation

@Matovidlo

@Matovidlo Matovidlo commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Description

Linear: AI-3669

Change Type

  • Major (breaking changes, significant new features)
  • Minor (new features, enhancements, backward compatible)
  • Patch (bug fixes, small improvements, no new features)

Summary

Kai currently always queries the default per-branch, MCP-managed workspace — even when invoked
inside a Data App, which has its own dedicated workspace with its own (potentially more
restricted) permissions. This is the first step towards fixing that: it adds a workspace_id
option to the server Config, settable via CLI (--workspace-id), env (KBC_WORKSPACE_ID), or
— since HTTP headers are read per-request — the X-Workspace-Id header. When set,
WorkspaceManager now resolves that specific workspace via the existing _find_ws_by_id lookup
instead of the default per-branch workspace, and takes precedence over the existing
workspace_schema option when both are set.

This mirrors the workspace_schema/X-Workspace-Schema mechanism that already existed, but
keyed by workspace ID instead of schema — Data App runtimes only know their own workspace as a
WORKSPACE_ID (see tools/data_apps.py), not a schema name, so the ID-based lookup is what a
caller (e.g. kai-agent, when Kai is embedded in a running Data App) will actually be able to
supply.

Follow-up work (separate repos, not part of this PR): kai-client needs to accept and forward a
workspace id as a header, kai-agent needs to thread that header through to both of its MCP
client paths (backend-side calls and the sandbox's own MCP client), and the Data App /
kbc-ui embed needs to actually supply the app's WORKSPACE_ID into the chat request.

Review round 2 (@MiroCillik) — what changed

  • The pin is now decoupled from the two tools/data_apps.py bookkeeping call sites
    (get_data_app_workspace_id()/get_data_app_branch_id()), which previously leaked a pinned
    session's workspace into a different data app's own persisted WORKSPACE_ID secret.
  • A server-configured workspace_id/workspace_schema is now authoritative over a request
    header (warns instead of silently being overridden); an empty header/env value no longer
    un-pins a server-side default.
  • workspace_id now requires the KBC_/X- prefix (a bare WORKSPACE_ID env var collides
    with the variable Keboola injects into Data App containers), is validated as numeric, and its
    lookup now falls back to the production-branch client and maps 400/403 like 404.
  • _WspInfo's repr no longer prints credentials.
  • See the review thread for the full point-by-point replies.

Design note (not fully resolved in this PR)

  • Trust boundary: the workspace pin is a client-supplied header, not an enforcement boundary
    — it narrows access only as much as whoever sets the header is trusted to. This is not a
    regression (workspace_schema has always had the same trust model), but it means this PR does
    not by itself prevent a Data App user from reaching org data outside their app's workspace
    (the AJDA-3052 "no backdoor" goal) — get_tables/get_bucket_detail/get_project_info still
    read through the caller's own Storage API token, unaffected by this pin. Limiting the token
    itself is tracked separately in keboola/connection#7981/#7985.
  • Read-only enforcement policy (open): a pinned workspace with write access currently only
    gets a LOG.warning, not a hard rejection — whether a Data App's platform-provisioned
    workspace actually has readOnlyStorageAccess: true is unconfirmed against a real stack.
    Enforcing blindly could break the feature outright if it turns out to be false. Once
    confirmed, this should either be promoted to a hard raise (if read-only) or backed by a
    SELECT-only guard in tools/sql.py (if not) — query_data has no statement-type guard of
    its own today regardless of this pin.

Testing

  • Tested with Cursor AI desktop (Streamable-HTTP transports)

Optional testing

  • Tested with Cursor AI desktop (all transports)
  • Tested with claude.ai web and canary-orion MCP (Streamable-HTTP)
  • Tested with In Platform Agent on canary-orion
  • Tested with RO chat on canary-orion

Unit tests cover the workspace_id/workspace_schema config parsing (incl. the KBC_-prefix
requirement and empty-value handling), WorkspaceManager's pin-vs-managed workspace split
(precedence, not-found, non-readonly-warning, branch fallback, 400/403 handling), the
apply_request_config authoritative-pin behavior, and the CLI --workspace-id flag. Full
pytest suite passes (1732 passed). ruff format/ruff check clean; tox -e check-tools-docs
unaffected (no tool signature changes).

Checklist

  • Self-review completed
  • Unit tests added/updated (if applicable)
  • Integration tests added/updated (if applicable)
  • Project version bumped according to the change type
  • Documentation updated (if applicable)

@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown

AI-3669

@Matovidlo

Copy link
Copy Markdown
Contributor Author

@claude review

Copilot AI 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.

Pull request overview

This PR adds support for pinning workspace resolution to an explicit Keboola workspace ID (e.g., when running inside a Data App) by introducing workspace_id into server configuration and threading it into WorkspaceManager, where it takes precedence over workspace_schema.

Changes:

  • Add Config.workspace_id with per-request override via HTTP header and CLI flag, and pass it into session/workspace initialization.
  • Update WorkspaceManager to resolve a workspace by ID (with precedence over schema) and raise if the ID is not found.
  • Add unit tests for config parsing and workspace resolution-by-id behavior; bump project version to 1.75.0.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/keboola_mcp_server/config.py Adds workspace_id to server config model and documents precedence over workspace_schema.
src/keboola_mcp_server/cli.py Adds --workspace-id CLI option and wires it into Config construction.
src/keboola_mcp_server/mcp.py Threads workspace_id from request/config into WorkspaceManager.create() during session setup.
src/keboola_mcp_server/workspace.py Implements workspace lookup by explicit ID with precedence and a not-found error.
tests/test_config.py Extends config parsing tests to cover the new workspace_id field.
tests/test_workspace.py Adds unit tests for _get_workspace() resolving by ID and failing when not found.
pyproject.toml Bumps project version to 1.75.0.
uv.lock Updates locked project version metadata to 1.75.0.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_config.py
Comment thread src/keboola_mcp_server/cli.py
Comment thread src/keboola_mcp_server/workspace.py Outdated
@Matovidlo
Matovidlo force-pushed the martinvasko-ai-3669-use-app-workspace-for-kai-queries-in-apps branch from 4a4aa06 to 476e56b Compare August 7, 2026 10:42
@Matovidlo
Matovidlo marked this pull request as ready for review August 7, 2026 10:42
@Matovidlo
Matovidlo requested a review from a team as a code owner August 7, 2026 10:42
@Matovidlo
Matovidlo requested review from cjayyy and removed request for a team August 7, 2026 10:42

@keboola-pr-reviewer-bot keboola-pr-reviewer-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: needs_human (risk 3/5) · profile keboola-mcp-server

Adds an opt-in, per-request-header-settable workspace_id that redirects which workspace SQL queries target — a load-bearing surface worth a human glance.

Concerns:

  • src/keboola_mcp_server/workspace.py: New X-Workspace-Id header redirects which workspace SQL queries execute against.
  • src/keboola_mcp_server/config.py: Per-request header-settable workspace selector on the auth boundary.

Suggested reviewers: @keboola/ai-swimlane-kai-assistant

@Matovidlo
Matovidlo requested a review from MiroCillik August 11, 2026 11:28
@Matovidlo

Copy link
Copy Markdown
Contributor Author

@MiroCillik can you check it please ? 🙏

@MiroCillik

Copy link
Copy Markdown
Member

@Matovidlo and please, so the Workspace id will be passed to KAI (and then to MCP?) on each request (question/prompt)? Or when is the MCP server instantiated?

@Matovidlo

Copy link
Copy Markdown
Contributor Author

@MiroCillik the Kai is connected to MCP during conversation, it start with it and during it it should be working with workspace. For Kai in client invoked in data app, the lifecykle should be somewhere on start of the app and end with its decrease

@MiroCillik MiroCillik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review: pinning MCP queries to an explicit workspace ID

Reviewed at high effort (multi-agent finder + independent adversarial verification of every candidate). The mechanism is a clean mirror of the existing workspace_schema / X-Workspace-Schema path and the plumbing is correct; conventions all check out (minor bump 1.75.2 → 1.76.0, uv.lock synced, no tool signature change so TOOLS.md is unaffected).

10 findings are left as inline comments. Almost all of them trace back to one design decision rather than sloppy code: the pin lands on the session-wide WorkspaceManager rather than on the query path, and it is selected by an untrusted per-request header with no ownership, read-only, or branch validation.

What I'd fix before merging

  1. get_workspace_id() is not query-only — the pinned id also reaches tools/data_apps.py, where it gets written into other data apps' persisted WORKSPACE_ID secret. This one is a concrete data-corruption bug, not a hypothetical (see comment on mcp.py:373).
  2. if self._workspace_id: is a truthiness check — an empty X-Workspace-Id header silently un-pins the session back to the unrestricted project-wide workspace. A restriction that disappears silently is worse than one that fails loudly.
  3. No readonly validation on the pinned workspace — every other resolution path enforces read-only storage access (_find_ws_in_branch requires info.readonly, _create_ws always passes read_only_storage_access=True) and query_data has no statement-type guard, so a pinned writable workspace turns the agent's SQL path read-write.
  4. A bare WORKSPACE_ID env var (no KBC_ prefix) pins every session — and that is exactly the variable Keboola injects into Data App containers, which is the deployment scenario this feature targets.

The rest (branch-scoped workspace_detail lookups, non-404 statuses bypassing the intended ValueError, _WspInfo credentials in an INFO log, the untested create() pass-through, missing README docs) are individually smaller but cheap to address.

Design note — this is the transport for the restriction, not the restriction

Because the workspace is chosen by a header the client supplies, the restriction is only as trustworthy as whoever sets it. That is the same trust model X-Workspace-Schema already had, so it is not a regression — but for the AJDA-3052 use case ("don't give data-app users a backdoor to all org data") a client-supplied header is not an enforcement boundary. The real boundary has to be the token's own grants, which is what keboola/connection#7981 / #7985 are building.

Explicitly out of scope for this PR: limiting the token itself. Note that this also means the pin narrows only query_data — bucket/table/column metadata still flows through the caller's Storage API token (get_tables, get_bucket_detail, get_project_info) and is unaffected, so the agent can still enumerate the whole project's structure. Worth stating in the PR description so the initiative doesn't assume it's covered here.

Process

The PR body is missing the mandated Release Notes section (Justification / Plans for Customer Communication / Impact Analysis / Deployment Plan / Rollback Plan / Post-Release Support Plan).

client,
config.workspace_schema,
kubernetes_token_path=kubernetes_token_path,
workspace_id=config.workspace_id,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The pin is session-wide, not query-scoped — it leaks into other data apps' secrets.

get_workspace_id() (workspace.py:912) just returns (await self._get_workspace()).id, and it is used well beyond query execution: tools/data_apps.py:555 and :711 feed it into _get_secrets(workspace_id=...), and :1099 into legacy_secrets = {SECRET_WORKSPACE_ID: ...} — i.e. it is written into a data app's persisted configuration.

Failure scenario: Kai runs inside Data App A with the session pinned via X-Workspace-Id: <A's workspace>. A user asks it to create or update Data App B → B's config is written with A's workspace id as SECRET_WORKSPACE_ID. Deployed app B then queries through app A's workspace (wrong data scope, possibly broader than intended) and breaks permanently the moment app A or its workspace is deleted. get_branch_id() (workspace.py:916) leaks the same way.

Suggestion: scope the pin to the query path instead of the whole WorkspaceManager — e.g. keep the default-resolution workspace for get_workspace_id()/get_branch_id() consumers and use the pinned one only for execute_query/get_table_info/dialect resolution. If a session-wide pin is intentional, then data_apps.py must stop deriving the app's workspace id from the session manager.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Proposed fix

The pin belongs to the query path; data_apps.py needs the MCP-managed workspace, i.e. what it got before this PR. Split the resolution rather than patching call sites ad hoc:

async def _get_workspace(self) -> _Workspace:
    """The workspace queries run against — honours an explicit `workspace_id` pin."""
    if self._workspace:
        return self._workspace
    if self._workspace_id is not None:
        self._workspace = self._init_workspace(await self._resolve_pinned())
        return self._workspace
    return await self._get_managed_workspace()

async def _get_managed_workspace(self) -> _Workspace:
    """The MCP-managed (or `workspace_schema`-pinned) workspace, ignoring `workspace_id`.

    Data apps persist this id into their own configuration (`SECRET_WORKSPACE_ID`), so it must
    never be the caller-supplied pin of whichever session happened to create the app.
    """
    if self._managed_workspace:
        return self._managed_workspace
    ...  # existing workspace_schema -> _find_ws_in_branch -> _create_ws body

Then add an explicit accessor and switch the leaking call sites:

async def get_data_app_workspace_id(self) -> int:
    return (await self._get_managed_workspace()).id
  • tools/data_apps.py:555, :711, :1099get_data_app_workspace_id()
  • same treatment for get_branch_id() at those three sites

_get_managed_workspace keeps honouring workspace_schema, so existing --workspace-schema deployments are unchanged and the whole fix is additive. Worth adding a docstring line on get_workspace_id() stating it is pin-aware and must not feed anything persisted — that's what stops a fourth call site reintroducing this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Implemented essentially as you suggested, keeping get_workspace_id()/get_branch_id() pin-aware for their existing callers (tools/project.py:201, tools/storage/tools.py:722, which report the workspace actually being queried) rather than repointing them -- so instead I added get_data_app_workspace_id()/get_data_app_branch_id() backed by a new _get_managed_workspace() (your _get_managed_workspace, same split), and switched all three tools/data_apps.py call sites (555/557, 711/712, 1099) to the new accessors. Docstrings on all four methods now state which is pin-aware.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Resolved -- see the detailed reply on the follow-up thread you left on this same line with the concrete diff (mcp.py:373, second review pass).

Comment thread src/keboola_mcp_server/workspace.py Outdated
if self._workspace:
return self._workspace

if self._workspace_id:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Truthiness check → an empty header silently un-pins the workspace.

Config._read_options stores any present header value verbatim, including '', and mcp.py:291 applies request headers on top of the server config. So a deployment with KBC_WORKSPACE_ID=<app workspace> that receives a request with an empty X-Workspace-Id: (an unset template value on the kai-client / Data App side — a very common failure mode) ends up with workspace_id=''.

if self._workspace_id: is then falsy, this branch is skipped entirely, and execution falls through to _find_ws_in_branch() / _create_ws() — the MCP-managed workspace with read access to every bucket in the project. No error, no warning: the in-app data-access restriction is silently gone for that request.

Suggested change
if self._workspace_id:
if self._workspace_id is not None:

...plus rejecting a blank value explicitly, e.g. normalise ''None in Config.__post_init__ (as it already does for branch_id) and raise if a pin was expected but is empty. Given the security intent, failing loudly is the right default here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Proposed fix

Fix it at the config layer rather than at this if, so the un-pin cannot happen at all and every field benefits. Collapse the elif chain in Config._read_options into a candidate loop:

for name in field_names:
    value: str | None = _NO_VALUE_MARKER
    for dict_name in (name, f'KBC_{name}', f'X-{name}'):
        # An empty header/env value means "not provided": an unset header template
        # (`X-Workspace-Id:`) must not override a server-side pin with ''.
        if (key := cls._normalize(dict_name)) in data and data[key] != '':
            value = data[key]
            break

Then keep if self._workspace_id is not None: here as defense in depth. Add empty-value cases to the existing test_from_dict / test_replace_by parametrize lists.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Alternative approach 💡

Fixed at the config layer, but scoped rather than applied to every field: a blanket empty-means-absent change breaks the existing branch_id behavior ({'branch-id': ''} -> Config() is a real, tested feature -- clearing a server-configured branch via an empty header). Added an opt-in empty_means_absent field metadata flag instead, set on workspace_id and workspace_schema only. Combined with the require_prefix flag from the config.py:30 thread, _read_options now loops candidates per-field with both opt-ins honored. Kept is not None in workspace.py as defense in depth. Added the empty-header case to test_config.py.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Resolved -- see the detailed reply on the follow-up thread (workspace.py:830, second review pass).

Comment thread src/keboola_mcp_server/workspace.py Outdated
# use the workspace that was explicitly requested (e.g. a Data App's own workspace)
# this workspace must never be written to the default branch metadata
LOG.info(f'Looking up workspace by id: {self._workspace_id}')
if info := await self._find_ws_by_id(self._workspace_id):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The pinned workspace is not validated as read-only — the header can widen access from read-only to read-write.

Every other resolution path enforces the read-only invariant:

  • _find_ws_in_branch (line 705): if info.id and info.backend and info.schema and info.readonly
  • _create_ws: always provisions with read_only_storage_access=True

_find_ws_by_id checks only wi.id and wi.backend and wi.schema — never wi.readonly. And tools/sql.py has no statement-type guard (no SELECT-only check anywhere), so read-only storage access is the only thing preventing query_data from mutating data.

Failure scenario: X-Workspace-Id: <id of a writable transformation/sandbox workspace in the project> yields a read-write workspace, and the agent's next query_data call can run INSERT / UPDATE / DROP TABLE against project data. For a feature whose purpose is narrowing access, that inversion is worth an explicit guard:

if info := await self._find_ws_by_id(self._workspace_id):
    if not info.readonly:
        raise ValueError(f'Workspace {self._workspace_id} has no read-only storage access.')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Proposed fix — but decide the policy first

Which fix is correct depends on a fact I can't get from the repo: whether a Data App's platform-provisioned workspace actually has readOnlyStorageAccess: true. Worth checking before coding:

curl -s -H "X-StorageApi-Token: $TOKEN" \
  "$KBC_URL/v2/storage/branch/default/workspaces/<data-app-workspace-id>" | jq '.readOnlyStorageAccess'
  • If true → enforce it, matching _find_ws_in_branch:
    if not info.readonly:
        raise ValueError(f'Workspace {self._workspace_id} has no read-only storage access.')
  • If false → do not enforce; it would break the feature outright. Instead LOG.warning on a writable pin and open a follow-up for a SELECT-only guard in tools/sql.py, which is the real missing control here — today nothing stops query_data running DDL/DML against any writable workspace, pin or no pin.

Either way it's worth a line in the PR description, since it's the difference between this header narrowing access and widening it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Question

Went with the warn-not-raise fallback for now -- I have no way to check readOnlyStorageAccess on a real Data App workspace from here. Added a LOG.warning('... has no read-only storage access.') plus a comment noting: promote to a raise once confirmed read-only, and either way the missing SELECT-only guard in tools/sql.py is a real follow-up regardless of this policy. Can you (or whoever has access to a stack with a live Data App workspace) run the curl check and confirm? Happy to flip this to the hard raise in a follow-up commit once we know.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Addressed with a warning for now, pending confirmation against a real workspace -- see the detailed reply on the follow-up thread (workspace.py:834, second review pass).

Comment thread src/keboola_mcp_server/workspace.py Outdated
# use the workspace that was explicitly requested (e.g. a Data App's own workspace)
# this workspace must never be written to the default branch metadata
LOG.info(f'Looking up workspace by id: {self._workspace_id}')
if info := await self._find_ws_by_id(self._workspace_id):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-404 statuses bypass the intended ValueError and surface as a raw HTTPStatusError.

_find_ws_by_id (line 669) maps only 404 → None and re-raises everything else. The header value is unvalidated and interpolated straight into the Storage API path by workspace_detail, whose signature declares workspace_id: int.

So X-Workspace-Id: my-app-workspace (non-numeric, or a typo) gets a 400 from Storage API, which propagates out of _get_workspace(), and every data tool fails with an opaque:

Client error '400 Bad Request' for url 'https://connection.../v2/storage/branch/default/workspaces/my-app-workspace'

...rather than the actionable No Keboola workspace found: workspace_id=... this branch was written to produce. Same for a 403 on a workspace the token may not read. Worth validating the id is an int at the Config boundary, and/or treating 400/403 like 404 here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Proposed fix

Two layers. First, map the non-404 statuses where the lookup happens (pairs well with the branch-fallback refactor suggested on line 839 — both want the HTTP call split into a _fetch_ws helper):

except HTTPStatusError as e:
    if e.response.status_code in (400, 403, 404):
        LOG.warning(f'Workspace lookup failed: id={workspace_id}, status={e.response.status_code}')
        return None
    raise

Second, validate the shape at the config boundary so a typo'd id fails with something readable instead of reaching the Storage API at all — in Config.__post_init__, alongside the existing branch_id normalisation:

if self.workspace_id is not None and not str(self.workspace_id).isdigit():
    raise ValueError(f'Invalid workspace_id: {self.workspace_id!r}')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Both layers added: 400/403 now map to None in a shared _fetch_ws(client, workspace_id) helper (also used by the branch-fallback fix on the :870/:839 thread), and Config.__post_init__ now raises Invalid workspace_id: ... when it isn't all-digits, alongside the existing branch_id normalization.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Resolved -- see the detailed reply on the follow-up thread (workspace.py:834, second review pass).

Comment thread src/keboola_mcp_server/workspace.py Outdated
# this workspace must never be written to the default branch metadata
LOG.info(f'Looking up workspace by id: {self._workspace_id}')
if info := await self._find_ws_by_id(self._workspace_id):
LOG.info(f'Found workspace: {info}')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This logs the whole _WspInfo, whose credentials field is the workspace's backend credential blob.

_WspInfo is a plain frozen dataclass with the default __repr__, so {info} prints every field — including credentials, which for BigQuery is a serialized service-account JSON (as _init_workspace shows by doing json.loads(info.credentials)).

On a BigQuery-backend project, the first request carrying X-Workspace-Id writes that service-account JSON — private_key included — into the application logs at INFO level, from where it ships to Datadog. Anyone with log-read access then has long-lived credentials to that dataset and can read project data entirely outside Keboola, with no MCP audit trail.

Note this line is reachable by a caller-chosen id now, which is what changes the exposure profile. Suggest logging only the safe fields:

LOG.info(f'Found workspace: id={info.id}, schema={info.schema}, backend={info.backend}, readonly={info.readonly}')

(The identical pre-existing LOG.info(f'Found workspace: {info}') in the schema path has the same problem and is worth fixing in the same pass.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Proposed fix

Fix the type rather than the two call sites — that also covers the pre-existing schema-path log and any future interpolation of _WspInfo:

@dataclass(frozen=True, repr=False)
class _WspInfo:
    ...
    def __repr__(self) -> str:
        return (
            f'_WspInfo(id={self.id!r}, schema={self.schema!r}, backend={self.backend!r}, '
            f'credentials={"****" if self.credentials else None}, readonly={self.readonly!r})'
        )

(repr=False is needed so the dataclass machinery doesn't generate one over it.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Implemented exactly as suggested: @dataclass(frozen=True, repr=False) + a custom __repr__ redacting credentials. Every existing LOG.info(f'... {info}') call site (id path, schema path, default-branch path) is covered by this single fix, no per-call-site changes needed. Added a unit test asserting the secret never appears in repr(info).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Resolved -- see the detailed reply on the follow-up thread (workspace.py:835, second review pass).

Comment thread src/keboola_mcp_server/workspace.py Outdated
LOG.info(f'Looking up workspace by id: {self._workspace_id}')
if info := await self._find_ws_by_id(self._workspace_id):
LOG.info(f'Found workspace: {info}')
self._workspace = self._init_workspace(info)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No ownership validation, and it silently overrides the operator's own pin.

The resolved workspace is accepted with no check that it is the caller's / the app's workspace — any workspace id visible to the project token works. Combined with the documented precedence over workspace_schema, this means a request header beats server-side deployment configuration: apply_request_config() deliberately protects storage_api_url from headers, but nothing protects the workspace pin.

Failure scenario: a server deployed with KBC_WORKSPACE_SCHEMA=<restricted app schema> (the current way a deployment fences off data access) receives X-Workspace-Id: <another workspace in the same project>. The header wins, _get_workspace() returns the other workspace, data the operator intentionally fenced off becomes readable through query_data, and the audit trail shows a normal tool call.

At minimum: LOG.warning when a header-supplied workspace_id overrides a server-configured workspace_schema/workspace_id, and consider treating a server-side pin as authoritative (same reasoning as own_stack_storage_api_url).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Proposed fix

Mirror the own_stack_storage_api_url reasoning in apply_request_config: a deployment that pinned itself shouldn't be overridable by a header, while the shared-server case — no server-side pin, header supplies it, which is the actual AJDA-3052 flow — keeps working:

server_config = config
config = config.replace_by(http_rq.headers)

if server_config.workspace_id or server_config.workspace_schema:
    if config.workspace_id != server_config.workspace_id:
        LOG.warning(f'Ignoring requested workspace_id "{config.workspace_id}"; server is pinned.')
        config = dataclasses.replace(
            config,
            workspace_id=server_config.workspace_id,
            workspace_schema=server_config.workspace_schema,
        )
elif config.workspace_id and config.workspace_schema:
    LOG.warning(f'Both workspace_id and workspace_schema supplied; using workspace_id={config.workspace_id}.')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Implemented your apply_request_config snippet, with one small extension: made it symmetric over both workspace_id and workspace_schema (checked/restored together) since the same silent-override risk applies to a schema-based server pin, not just an id-based one. LOG.warning fires whenever either is overridden; a server with no pin of its own keeps taking either from the request headers unchanged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Resolved -- see the detailed reply on the follow-up thread (workspace.py:836, second review pass).

Comment thread src/keboola_mcp_server/workspace.py Outdated
self._workspace = self._init_workspace(info)
return self._workspace
else:
raise ValueError(f'No Keboola workspace found: workspace_id={self._workspace_id}')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

workspace_detail is branch-scoped, so a cross-branch pin 404s and this error message is misleading.

AsyncStorageClient.workspace_detail hits branch/{self._branch_id}/workspaces/{workspace_id} (clients/storage.py:1111), where _branch_id defaults to 'default' (storage.py:321). On a project with the storage-branches feature, create() keeps the dev-branch client (line 592), so a session bound to dev branch 123 looks the pinned id up under branch/123/workspaces/<id>.

A Data App workspace that lives in the default/production branch → 404 → _find_ws_by_id returns None → this ValueError fires, and every query_data, get_table_detail (storage/tools.py:722), get_project_info (project.py:201) and data-app tool call in that session fails. The message says the workspace does not exist when it does. The mirror case (dev-branch workspace, default-branch session) fails too.

Worth noting _find_ws_by_id's only previous caller — _create_ws (line 794) — was inherently branch-safe because it looked up a workspace it had just created with the same client, so this is a new exposure for the helper. Either resolve the pinned id against the prod client explicitly, or make the error say which branch was searched.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Proposed fix

Split the HTTP call into a helper and fall back to the default branch:

async def _find_ws_by_id(self, workspace_id: str | int) -> _WspInfo | None:
    info = await self._fetch_ws(self._client, workspace_id)
    if info is None and self._client.branch_id is not None:
        # `workspace_detail` is branch-scoped; a pinned workspace (e.g. a Data App's own)
        # may live in the default branch while this session is bound to a dev branch.
        info = await self._fetch_ws(await self._client.with_branch_id(None), workspace_id)
    return info

_fetch_ws(client, id) is the current try/except body, and is also where the 400/403 mapping from the other thread belongs. And include the branch in the error message here so it stops being misleading:

raise ValueError(f'No Keboola workspace found: workspace_id={self._workspace_id}, branch_id={self._client.branch_id}')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Implemented via the shared _fetch_ws helper: _find_ws_by_id tries this manager's client first, then the production-branch client via with_branch_id(None) if that differs. Also added branch_id={self._client.branch_id} to the final ValueError message so a genuine not-found no longer reads as if only one branch was ever checked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Resolved -- see the detailed reply on the follow-up thread (workspace.py:870, second review pass).

Comment thread src/keboola_mcp_server/config.py Outdated
"""The branch ID to access the storage API using the MCP tools."""
workspace_schema: str | None = None
"""Workspace schema to access the buckets, tables and execute sql queries."""
workspace_id: str | None = None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A bare WORKSPACE_ID env var — no KBC_ prefix — will pin every session on the server.

_read_options probes three spellings per field, and the first is the bare field name: _normalize('workspace_id') == 'workspaceid'. Since server.py:192 runs config.replace_by(os.environ), a plain WORKSPACE_ID in the process environment is picked up — and it also silently overrides the new --workspace-id CLI flag.

That matters here specifically because WORKSPACE_ID is exactly the variable Keboola injects into Data App containers (see resources/data_app/sapi_query_data_code.py:11 and SECRET_WORKSPACE_ID in tools/data_apps.py). Run the MCP server or the in-platform agent in any such container — the AJDA-3052 scenario — and every session, for every user and branch, is pinned to that one workspace: users whose token cannot see it get ValueError: No Keboola workspace found: workspace_id=... on query_data / get_table_details / get_project_info; users who can see it silently query the wrong workspace.

workspace_schema has the same latent collision but WORKSPACE_SCHEMA isn't an injected variable, so this is the first field where it actually bites. Options: require the KBC_ prefix for this field, or rename it (e.g. pinned_workspace_id) so the bare spelling can't collide.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Proposed fix

Add a per-field opt-out rather than changing the prefix rules globally — bare env/header names are relied on elsewhere, including the integtests (headers = {'storage_token': ..., 'workspace_schema': ...}):

workspace_id: str | None = field(default=None, metadata={'require_prefix': True})
candidates = (f'KBC_{name}', f'X-{name}') if f.metadata.get('require_prefix') else (name, f'KBC_{name}', f'X-{name}')

Heads-up: this breaks the two new test_config.py cases that pass a bare workspace_id key — switch them to KBC_WORKSPACE_ID and keep the X-Workspace-Id case.

The cleaner long-term fix is to require the KBC_ prefix for env vars generally (server.py:192 currently hands the whole environment to replace_by), but that risks breaking deployments relying on bare names, so the targeted flag is the safer call for this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Implemented as suggested: require_prefix metadata on workspace_id only (kept workspace_schema and everything else on the existing bare/KBC_/X- candidate order, since integtests rely on the bare workspace_schema header key). Updated the two test_config.py cases that used a bare workspace_id key to KBC_WORKSPACE_ID, kept the X-Workspace-Id case, and added a numeric-shape validation test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Resolved -- see the detailed reply on the follow-up thread (config.py:30, second review pass).

Comment thread tests/test_workspace.py


@pytest.mark.asyncio
async def test_get_workspace_resolves_by_id_when_set():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The workspace_id pass-through in create() has zero coverage — I mutation-tested it.

Both new tests construct WorkspaceManager(...) directly, so the two changed cls(...) calls in create() (workspace.py:592 for storage-branches projects, :595 for the prod-client path) are never exercised. Deleting workspace_id=workspace_id from the storage-branches branch — exactly the omission that duplicated two-call shape invites — leaves the entire suite green while every dev-branch session falls back to the unrestricted default MCP workspace. The restriction stops applying and no test notices.

The existing parametrized test_workspace_manager_create_is_branch_aware (line 202) already asserts manager._workspace_schema == workspace_schema and is the natural place to add a workspace_id axis — which also satisfies CONTRIBUTING.md § Testing Requirements ("extend existing parametrized tests rather than adding new test functions for related scenarios").

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Proposed fix

Extend test_workspace_manager_create_is_branch_aware (line 202) with a workspace_id axis and assert manager._workspace_id == workspace_id. That is specifically what kills the mutation — two cases are enough:

  • dev_branch_with_sb_explicit_id (covers workspace.py:592)
  • default_branch_without_sb_explicit_id (covers workspace.py:595)

Two more worth adding while here:

  • an empty-header case in test_config.py, for the ''-means-unset change
  • one _get_workspace test asserting the data-app accessor still returns the managed workspace while the session is pinned — the regression test for the mcp.py:373 thread

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Added both named cases (dev_branch_with_sb_explicit_id, default_branch_without_sb_explicit_id) to test_workspace_manager_create_is_branch_aware, an empty-header case in test_config.py, and a regression test (test_get_data_app_workspace_id_ignores_the_pin) asserting get_data_app_workspace_id() returns the managed workspace while _get_workspace() still returns the pinned one in the same session.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Resolved -- see the detailed reply on the follow-up thread (tests/test_workspace.py:261, second review pass).

)
parser.add_argument('--storage-token', metavar='STR', help='Keboola Storage API token.')
parser.add_argument('--workspace-schema', metavar='STR', help='Keboola Storage API workspace schema.')
parser.add_argument('--workspace-id', metavar='STR', help='Keboola Storage API workspace ID.')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The new option is documented nowhere outside the source.

README.md mentions KBC_WORKSPACE_SCHEMA in ten places — the required-credentials list (line 134), a dedicated section (line 151), every client config sample, and the troubleshooting table (**Workspace Issues** | Confirm KBC_WORKSPACE_SCHEMA is correct) — while the new, functionally overlapping KBC_WORKSPACE_ID / --workspace-id / X-Workspace-Id appears in none of them.

The PR names kai-client, kai-agent and the kbc-ui embed as follow-up consumers; they can't discover the option or learn that it takes precedence over the schema. The troubleshooting row also now gives wrong advice for sessions pinned by ID.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Proposed fix

Add a KBC_WORKSPACE_ID subsection next to KBC_WORKSPACE_SCHEMA (README.md:151) covering:

  • what it is and that it takes precedence over KBC_WORKSPACE_SCHEMA
  • that it is the option Data App / kai-agent callers supply, as X-Workspace-Id per request
  • that the CLI equivalent is --workspace-id

And update the troubleshooting row (line 441) to name both:

| **Workspace Issues** | Confirm `KBC_WORKSPACE_SCHEMA` / `KBC_WORKSPACE_ID` is correct |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Added the KBC_WORKSPACE_ID subsection next to KBC_WORKSPACE_SCHEMA covering precedence, the X-Workspace-Id per-request form, and the --workspace-id CLI flag, plus the troubleshooting row naming both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed ✅ in commit 71ea3bd

Resolved -- see the detailed reply on the follow-up thread (cli.py:56, second review pass).

@MiroCillik

Copy link
Copy Markdown
Member

Suggested sequencing for the review fixes

I've replied to each review thread with a concrete proposed fix. Grouping them into three commits keeps the diff reviewable:

1. fix(AI-3669): decouple workspace pin from managed-workspace consumers — the two that change behaviour

  • split _get_workspace() / _get_managed_workspace() and switch the three tools/data_apps.py call sites to a pin-agnostic accessor (thread on mcp.py:373)
  • server-side pin becomes authoritative over the header + warn on override (thread on workspace.py:836)

2. fix(AI-3669): harden workspace-id resolution — defensive, no behaviour change when unpinned

  • empty header/env value means "not provided", fixed in Config._read_options (thread on workspace.py:830)
  • require_prefix metadata so a bare WORKSPACE_ID env var can't pin the server (thread on config.py:30)
  • _fetch_ws helper: default-branch fallback + 400/403 mapping + branch in the error message (threads on workspace.py:839 and :834)
  • workspace_id shape validation in Config.__post_init__
  • redacting _WspInfo.__repr__ (thread on workspace.py:835)

3. test + docs(AI-3669)

  • workspace_id axis on test_workspace_manager_create_is_branch_aware, empty-header config case, managed-workspace regression test (thread on tests/test_workspace.py:247)
  • KBC_WORKSPACE_ID README section + troubleshooting row (thread on cli.py:56)
  • Release Notes section in the PR description

One thing to settle before coding

The read-only question (thread on workspace.py:834) is the only fix whose shape is still open, because it depends on whether a Data App's platform-provisioned workspace has readOnlyStorageAccess: true. If it does, enforce the invariant like every other resolution path does; if it doesn't, enforcing would break the feature outright and the right move is a warning plus a follow-up for a SELECT-only guard in tools/sql.py. Worth checking against a real app workspace on a stack first — everything else can proceed independently of the answer.

Not blocking this PR

Per offline discussion: limiting the token itself is out of scope here, and so is restricting the metadata tools (get_tables, get_bucket_detail, get_project_info) that read through the caller's Storage API token rather than the workspace. Both belong to the keboola/connection#7981 / #7985 work. Worth stating in the PR description so the AJDA-3052 rollout doesn't assume this PR covers them.

@Matovidlo
Matovidlo force-pushed the martinvasko-ai-3669-use-app-workspace-for-kai-queries-in-apps branch from 3fbc164 to 71ea3bd Compare August 13, 2026 06:21
@Matovidlo
Matovidlo requested a review from MiroCillik August 13, 2026 06:56

@cjayyy cjayyy 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.

Code review of the workspace_id pin. Six findings below, most-severe first. Tests for the touched files pass locally (351 passed); the trust-boundary and read-only-enforcement questions already covered in the PR description are deliberately out of scope here.

The first three are the ones I'd want resolved before merge. The last one is arguably intentional-but-undocumented rather than a defect.

Comment thread src/keboola_mcp_server/config.py
Comment thread src/keboola_mcp_server/workspace.py Outdated
Comment thread src/keboola_mcp_server/tools/data_apps.py Outdated
Comment thread src/keboola_mcp_server/workspace.py
Comment thread src/keboola_mcp_server/workspace.py Outdated
Comment thread src/keboola_mcp_server/config.py Outdated
@Matovidlo
Matovidlo force-pushed the martinvasko-ai-3669-use-app-workspace-for-kai-queries-in-apps branch from 0dc5996 to 737b743 Compare August 13, 2026 10:22
@Matovidlo

Copy link
Copy Markdown
Contributor Author

@cjayyy thanks for the review — all six addressed in 798c7b2 / e711a02:

  1. Malformed header → 500 — fixed. Config.replace_by() now catches the workspace_id validation ValueError and degrades to "not provided" instead of raising, so a junk X-Workspace-Id no longer produces an unhandled server error and no longer runs ahead of the server-pin guard.
  2. 404→400/403 widening leaking into _create_ws — fixed. _fetch_ws/_find_ws_by_id now take a strict flag; the post-creation lookup in _create_ws uses strict=True so a 400/403 there re-raises instead of masking into a generic "workspace creation failed" and leaving an orphaned workspace+config behind. The caller-supplied pin lookup keeps the wide (400/403/404-alike) behavior, since that's the one where it's actually justified.
  3. sql_dialect mismatch — fixed. Added get_data_app_sql_dialect() mirroring the other managed-workspace accessors; both modify_streamlit_data_app and modify_streamlit_data_app_internal now read it instead of the pin-aware get_sql_dialect().
  4. Unwanted workspace provisioning on a pinned session — fixed per your suggestion: _get_managed_workspace() now raises instead of falling through to _create_ws() when a session is pinned via workspace_id and no managed workspace already exists.
  5. Prod-fallback binding to the wrong client — fixed. _find_ws_by_id now rebinds self._client to the prod client when that's the one that actually resolved the workspace.
  6. empty_means_absent regressing workspace_schema — fixed, scoped to workspace_id only. Confirmed empirically against origin/main that an empty X-Workspace-Schema header there falls back to '' (falsy, not literally None — your repro was close but not exact), so the regression test asserts against that.

Added/extended regression tests for all six in test_workspace.py / test_config.py. Full suite green (1737 passed) plus ruff + check-tools-docs.

@Matovidlo
Matovidlo requested a review from cjayyy August 13, 2026 11:24

@cjayyy cjayyy 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.

Re-reviewed against e711a02a. Five of the six are genuinely fixed — I verified each behaviorally rather than just reading the diffs:

# Finding Status
1 Malformed X-Workspace-Id → unhandled error ✅ Fixed
2 400/403 swallowed on creation path ✅ Fixed
3 sql_dialect / workspace_id split-brain ✅ Fixed
4 Pinned session auto-provisions a workspace ✅ Fixed
5 Prod-branch fallback crosses branch isolation ⚠️ Fixed, but the fix regresses #3
6 empty_means_absent on workspace_schema ✅ Fixed

Nice touches worth calling out: the strict flag threading is the right shape (creation path re-raises, caller-supplied pin stays lenient); the replace_by degradation correctly preserves a server-side pin when a junk header arrives (Config(workspace_id='111').replace_by({'X-Workspace-Id':'abc'}).workspace_id == '111') while the CLI/env constructor stays strict; and get_data_app_sql_dialect() mirrors the existing accessors cleanly.

One new issue from the #5 fix — details inline. Full suite: 1737 passed (the one test_json_logging failure is environmental, no bare python on PATH, and fails on main too).

Comment thread src/keboola_mcp_server/workspace.py Outdated
Adds a `workspace_id` Config field (X-Workspace-Id header / KBC_WORKSPACE_ID
env / --workspace-id CLI) so a caller can target a specific existing
workspace instead of the default per-branch one, e.g. a Data App's own
workspace when Kai queries run inside it. Takes precedence over
workspace_schema when both are set.
…den its resolution

- split _get_workspace() (pin-aware) from a new _get_managed_workspace()
  (workspace_schema + default MCP workspace, ignoring workspace_id); add
  get_data_app_workspace_id()/get_data_app_branch_id() backed by the latter
  and switch tools/data_apps.py's three call sites to them, so a session
  pinned to one Data App's workspace can no longer leak that workspace into
  a *different* app's own persisted WORKSPACE_ID secret
- make a server-configured workspace_id/workspace_schema authoritative over
  a request header in apply_request_config(), warning when a header would
  have overridden it (mirrors the existing storage_api_url protection); a
  server with no pin of its own still takes it from the request
- treat an empty workspace_id/workspace_schema header/env value as "not
  provided" (Config._read_options, opt-in via an `empty_means_absent` field
  metadata flag so branch_id's existing empty-clears-the-value behavior is
  unaffected), plus keep `is not None` in workspace.py as defense in depth
- require the KBC_/X- prefix for workspace_id specifically (`require_prefix`
  metadata), since the bare WORKSPACE_ID spelling collides with the
  variable Keboola injects into Data App containers
- validate workspace_id is numeric in Config.__post_init__
- resolve a pinned id against the production-branch client too (via a
  shared _fetch_ws helper), since a Data App's workspace is not tied to any
  particular branch and workspace_detail is branch-scoped; include the
  branch id in the not-found error message
- treat 400/403 like 404 when resolving a pinned id
- warn (not yet enforce -- unconfirmed whether a Data App's
  platform-provisioned workspace is actually read-only) when a pinned
  workspace has no read-only storage access
- redact _WspInfo.__repr__ so logging it can never leak backend credentials
ruff (SIM102) flags the nested if as combinable; also fixes the CI lint
failure that the previous commit's local ruff run (older pinned version)
didn't catch.
… in sync, stop auto-provisioning for pinned sessions

- _fetch_ws/_find_ws_by_id gain a strict mode: the post-creation lookup in
  _create_ws now re-raises a 400/403 instead of masking it as "not found" and
  leaving an orphaned workspace+config behind. The caller-supplied pin lookup
  keeps treating 400/403/404 alike.
- the prod-branch fallback in _find_ws_by_id now rebinds this manager's
  client to the client the workspace was actually resolved on, so queries
  don't run against a production workspace through a dev-branch-bound client.
- add get_data_app_sql_dialect(), mirroring the other managed-workspace
  accessors, and use it in modify_streamlit_data_app(_internal) instead of
  the pin-aware get_sql_dialect() -- the dialect baked into a deployed app's
  source code must match the workspace whose id/branch are persisted into
  that same app, not whichever workspace the session happened to be pinned to.
- _get_managed_workspace() now raises instead of provisioning a brand new
  MCP-managed workspace when a session is pinned via workspace_id and none
  already exists -- that workspace would be billed to this session's token
  and provisioning can outright fail on a read-only token.
…ers, scope empty-header opt-out to workspace_id

replace_by() previously let __post_init__'s workspace_id validation raise
ValueError straight out of request-header handling: a junk X-Workspace-Id
header became an unhandled server error (a 500 via preview.py, which maps
ValueError to 400 only around the call this bypassed), and did so before
the server-pin guard in mcp.py even ran, so a bad header from any caller
could disrupt a server that never opted into honoring that header at all.
Now a malformed workspace_id degrades to "not provided" instead.

Also scope the empty_means_absent flag to workspace_id only. It was applied
to the pre-existing workspace_schema field too, which silently changed
behavior for existing callers: an empty X-Workspace-Schema header used to
clear a server default (the multi-user opt-out the README describes) and
would otherwise keep re-pinning to it instead.
…e prod-branch fallback

The #5 fix rebound self._client to the prod client that resolved a pinned
workspace, but self._client is shared by every other lookup this manager
makes -- including _find_ws_in_branch for the *managed* workspace, which
feeds get_data_app_workspace_id()/get_data_app_branch_id()/
get_data_app_sql_dialect() and gets persisted into a Data App's own config.
A dev-branch session pinned to a workspace that only resolves via the prod
fallback would then silently do its managed-workspace bookkeeping against
production too -- reopening the #3 cross-branch bleed through a different
door.

_find_ws_by_id now returns the resolving client alongside the info instead
of mutating self._client; _init_workspace takes an optional client override
so only the workspace actually being constructed uses it, leaving the
manager's own client (and everything else that reads it) untouched.
@Matovidlo
Matovidlo force-pushed the martinvasko-ai-3669-use-app-workspace-for-kai-queries-in-apps branch from e711a02 to 1eb0bec Compare August 13, 2026 12:39
@Matovidlo
Matovidlo requested a review from cjayyy August 13, 2026 12:42

@cjayyy cjayyy 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.

Re-verified against 1eb0becc. All six findings are resolved. 1eb0becc takes the (info, client) approach — _find_ws_by_id/_create_ws return the resolving client and _init_workspace gained a client override, so only the workspace being constructed uses it and self._client is left alone.

# Finding Status
1 Malformed X-Workspace-Id → unhandled error
2 400/403 swallowed on creation path
3 sql_dialect / workspace_id split-brain
4 Pinned session auto-provisions a workspace
5 Prod-branch fallback crosses branch isolation
6 empty_means_absent on workspace_schema

The #5 fix now satisfies both properties that were previously mutually exclusive. My original round-2 reproduction passes, and printing the values it asserts on:

pinned workspace id      = 123
pinned ws bound client   = prod    <- #5: pin uses the client that resolved it
manager client branch_id = 456     <- #3: manager stays on its own branch
managed lookup branch_id = 456     <- managed bookkeeping no longer leaks to prod

I also mutation-tested test_pin_resolution_via_prod_fallback_does_not_leak_into_managed_lookup — reintroducing the single self._client = prod_client line on top of the new API fails it and test_find_ws_by_id_falls_back_to_production_branch, so the regression is genuinely pinned down rather than passing vacuously.

Spot-checked that the rebase didn't undo the earlier fixes: the replace_by degradation still preserves a server-side pin against a junk header ('111'), the constructor stays strict, X-Workspace-Schema: '' clears again while X-Workspace-Id: '' keeps its pin, and strict=True / get_data_app_sql_dialect() / the no-provision guard are all still in place. Also audited the tuple-return call sites — all three consumers unpack correctly, and the two _WspInfo-returning paths (workspace_schema, _find_ws_in_branch) correctly omit the client override.

Suite: 1738 passed. The single test_json_logging failure is environmental (no bare python on PATH) and fails on main too.

Two notes, neither blocking and neither a code request:

  • My verification uses mocked clients throughout, so it establishes control flow, not behavior against a real stack. The Streamable-HTTP / canary testing checkboxes in the description are still unticked — worth exercising the pinned path end-to-end before this reaches production.
  • The two open design questions you documented (the header trust boundary, and warn-vs-reject for a writable pinned workspace) are unchanged by these fixes and still tracked for follow-up.

Nice work on the turnaround.

@Matovidlo

Copy link
Copy Markdown
Contributor Author

@MiroCillik one more round please just to be sure I did not miss anything

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants