Skip to content

fix(mcp): contribute Connect an Agent into the Account app nav so a non-admin can mint their own key - #17646

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-16746-connect-agent-account-nav
Sep 11, 2026
Merged

fix(mcp): contribute Connect an Agent into the Account app nav so a non-admin can mint their own key#17646
os-sales merged 4 commits into
mainfrom
claude/issue-16746-connect-agent-account-nav

Conversation

@claude

@claude claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #16746

Clause-②: no

Declared by the dispatching domain:cli PM seat (session session_01TSf4DV7ziu4V5j73e46b7c), matching the claim comment on #16746. Verified against the DIFF: the payload gains one array ELEMENT of an existing shape, not a new key — NavigationContributionSchema is untouched and both contributions parse against the real contract (pinned in this PR). 0 new error codes. The permission-VISIBILITY aspect is deliberately not clause-②: a runtime permission/security behaviour change sits on the manual floor, and that floor was discharged by the maintainer ruling of 2026-09-08 (decision batch #85, option A, verbatim 「其他同意」), not by this declaration.

What changed

CONNECT_AGENT_UI_BUNDLE (packages/mcp/src/connect-ui.ts) now carries a second
navigationContributions entry, the near-twin of the existing one, targeting the account
app's grp_account_developer group. The Setup entry is byte-unchanged, so admins keep the page
where the shipped guide and the runtime's own error text point.

app: 'account' · group: 'grp_account_developer' · priority: 200
items: [{ id: 'nav_connect_agent', type: 'page', pageName: 'connect_agent',
          label: 'Connect an Agent', icon: 'bot' }]

Nothing else moves: no backend change, no authorization change, no change to which permissions
exist, no gate added or removed anywhere, and packages/platform-objects is targeted by name,
never edited
. Executes the recorded ruling (option A, director seat decision batch #85,
2026-09-08, maintainer verbatim 「其他同意」).

Why this shape and not the others — each refused on measurement

route why not
ungate Setup (the page, or group_integrations) the app-level setup.access gate fires before the group gate, so dropping the group gate alone changes nothing; dropping both serves 14+ unrelated Setup surfaces (Users, Organization, Business Units, Branding, Feature Flags, …) to every signed-in user — far past what the ruling asked for
an account.app.ts entry gated on requiresService: 'mcp' that gate is strictly weaker than the page's own registration condition — the mcp service registers unconditionally in init() while this bundle registers behind isMcpServerEnabled() — so the entry outlives its page and 404s for every signed-in user on OS_MCP_SERVER_ENABLED=false
{ type: 'component', componentRef: 'mcp:connect-agent' } mcp:connect-agent lives in objectui's SDUI widget registry, not the app-component registry those nav items resolve through

A navigation contribution registers exactly when the page registers, which is why both
entries live in this one bundle and neither carries a gate of its own — an opted-out deployment
still gets no page and neither entry.

The item-id question, answered from the fold

Both entries deliberately share the item id nav_connect_agent. That is scoped, not a
collision, read off SchemaRegistry rather than assumed: contributions are keyed by target
app
(appNavContributions, a Map keyed by app name) and applyNavContributions(app) consults only
get(app.name), so a nav item id is unique within one app's navigation tree. Nothing indexes
it across apps — no id-keyed registry, no de-duplication by id — and the translation bundles are
keyed apps.APP.navigation.NAV_ID, which makes one shared id two distinct keys. One destination
keeps one identity.

The two items are separate object literals rather than one shared const: the fold
structuredClones the app but pushes ...c.items by reference, so a shared literal would
sit in two apps' navigation trees at once.

Acceptance — both halves measured over the real composition

Real SETUP_APP / ACCOUNT_APP / SETUP_NAV_CONTRIBUTIONS, the real
CONNECT_AGENT_UI_BUNDLE, the real fold and the real RBAC-by-route filter (RestServer with
this repo's established stub-the-exec-context pattern from
packages/rest/src/meta-app-publish-gate.test.ts), driven from packages/cli — the one package
that depends on all four:

# principal read result
M1 no system permissions GET /api/v1/meta/apps/account 200, 12 nav ids, grp_account_developer children = ['nav_account_api_keys', 'nav_account_oauth_apps', 'nav_connect_agent']
M2 the same principal GET /api/v1/meta/apps/setup 403 PERMISSION_DENIED, connect_agent absent from the body
M3 setup.access + manage_platform_settings GET /api/v1/meta/apps/setup 200, 34 nav ids, nav_connect_agent present — positive control: the harness does reach the card
M4 both of the above apps/setup admin entry count 34, unchanged

⚠️ Declared, not hidden: that measurement was a scratch file, run once and deleted — it is
not committed. packages/mcp declares no dependency on @objectstack/rest,
@objectstack/objectql or @objectstack/platform-objects, so a permanent both-halves pin cannot
live inside this PR's declared file surface, and this file deliberately does not reimplement the
fold or the filter. See Acceptance notes for where that pin belongs.

The committed pin, and proof it can fail

packages/mcp/src/connect-agent-account-nav.test.ts (7 tests) pins the half this package owns:
the contribution aims at the ungated app and group; its item carries nothing the server-side nav
filter could strip for a permissionless caller (requiredPermissions / requiresService /
visible); the Setup entry is byte-unchanged; both contributions parse against the real
NavigationContributionSchema; and — the load-bearing one — the bundle declares no permission
key anywhere and no apps collection, so it cannot reach the card by widening Setup instead.

Two ablations, each committed-first, proven on disk, and restored with git checkout HEAD --
verified by hash equality against the HEAD blob plus an empty git diff HEAD:

leg mutation (on-disk proof) result
A2 the account contribution aims at setup instead of account"app: 'account'," 1→0, "app: 'setup'," 1→2 5 failed / 2 passed
B group: 'group_integrations' dropped from the Setup contribution (a top-level append escapes the group gate) — occurrences 1→0 2 failed / 5 passed, both of them the Setup-guard assertions

⚠️ A first attempt at leg A deleted the whole contribution block and produced a parse error,
so the test never ran — a void reading, recorded rather than quietly retried, and replaced by the
parse-safe A2 above.

Acceptance notes

  • ⚠️ Where the permanent both-halves pin belongs: packages/cli/test/. Read from
    package.json, packages/cli is the only workspace package depending on @objectstack/mcp,
    @objectstack/rest, @objectstack/objectql and @objectstack/platform-objects at once —
    the same argument packages/cli/scripts/check-app-nav-i18n.mjs makes in its own header for
    living there ("cli is the composition root … the ONLY workspace package that depends on all
    eleven Setup nav contributors at once"). It is outside this PR's declared file surface and
    packages/cli/** has siblings in flight this round, so it is reported rather than taken.
  • node packages/cli/scripts/check-app-nav-i18n.mjs passes (10 contributors, 54 merged setup
    nav ids, 4 locales). Measured reason it is silent on the new entry: the gate scopes itself to
    APP_NAME = 'setup' and continues on any contribution whose app differs, so it demands no
    translation key for a contributed account item. Consequence, noted: no
    apps.account.navigation.nav_connect_agent label exists in any locale, so the entry renders its
    English literal under zh-CN / ja-JP / es-ES while the Setup twin renders translated. Those
    keys live in packages/platform-objects/src/apps/translations/ — outside this surface.
    app-nav-translation-parity.test.ts cannot see it either (it walks statically declared nav
    only, and asserts the reverse direction for STUDIO_APP alone).
  • The runtime text this card was raised on — packages/mcp/src/plugin.ts:372 and
    packages/mcp/README.md:92, both directing users to "Setup → Connect an Agent" — becomes true
    for non-admins with this change and needs no edit. Recorded so nobody files it twice.
  • Deferred option C (a per-user "My API keys" panel) does not collide and keeps its whole
    scope: C authors a new panel, this adds a second nav entry to the existing page, and
    account.app.ts already ships nav_account_api_keys in the exact group targeted here.
  • Premise shift, declared: the card's urgency paragraph says the OAuth self-service path "is
    currently blocked by MCP OAuth cannot complete on 17.3.0: plugin-auth passes validAudiences, which @better-auth/oauth-provider 1.7.2 no longer reads — every resource= request fails with invalid_target … is not configured #16530 and narrowed by OAuth-connected MCP agents run under the mcp_agent_data_* ceiling ∩ user, not "as yourself": a viewAllRecords manager sees 5 accounts / 0 opportunities over OAuth vs 9 / 23 over an API key #16549". Both are now closed, so that half of the
    argument has expired. The ruling does not rest on it — it rests on API keys being per-user
    credentials — and re-grading is triage's, not this PR's.
  • ⚠️ main moved under this branch and brought a breaking retirement whose name collides with
    the shape used here: feat(spec)!: retire the type: 'page' list-view mount and its pageName binding #17298, "retire the type: 'page' list-view mount and its pageName
    binding". Read rather than assumed — it touches packages/spec/src/ui/view.zod.ts, not
    app.zod.ts, its own FROM → TO table prescribes "reach the page from the app's navigation:
    { id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }"
    as the
    replacement, it calls PageNavItem.pageName "the page mount that has always rendered", and it
    states that the nav twin validateNavTargetRefs is untouched. origin/main is merged in here
    and the affected slice was re-verified on the merge result.

Gates

58 families derived on the final diff with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, each run with
output redirected and $? captured before any pipe, then reconciled with --ran:

Run reconciliation — 58 derived, 57 run, 1 NOT-MEASURED, 0 UNRUN.
  NOT-MEASURED · DERIVED (1) — pnpm check:dual-build-cjs-loads
    [recorded exit 3 on line 38 — PREREQUISITE NOT MET]

check:dual-build-cjs-loads refused its own prerequisite: it reads built output and 12 packages
outside this diff's closure have no dist (@objectstack/studio, @objectstack/client-react,
the four connectors, …). Its own text says so — "⛔ This is NOT a pass: nothing was measured."
It needs a repo-wide pnpm build, which is CI's run; CI checks out and builds fresh. NOT
MEASURED
, recorded as neither green nor red.

Plus the two the tool does not name: pnpm lint — run repo-wide to completion
(eslint . --no-inline-config, 2m24s, exit 0), so no narrowing was claimed — and
node packages/cli/scripts/check-app-nav-i18n.mjs (exit 0, verdict line quoted above).

Receiving package, re-run on the merge result: pnpm --filter @objectstack/mcp test — 30 files,
320 tests passed — and pnpm --filter @objectstack/mcp typecheck. The new test file is proven to
be in the type-checked program rather than assumed: tsc -p tsconfig.test.json --listFiles
counts it 1 (and 0 in the main program, which is tsconfig.json's own exclude, unchanged).
packages/spec moved on the incoming side, so pnpm --filter @objectstack/spec build && check:generated ran on the merge result too.

Every heavy run went through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-16746.

维护者速读(草稿)

改了什么 — 「连接智能体」这个页面,以前只在「系统设置」里有入口。现在在每个人自己的
「账户 → 开发者」里也加了一个入口,就挨着已经在那里的「API 密钥」。设置里那个入口原样保留,
管理员看到的东西一点没变。

为什么改 — 我们对外说的是「Claude 只能看到和操作您自己有权限的数据」,而后台本来就是
「谁调用就发给谁」。但界面上只有管理员能走到那个按钮,所以普通销售、销售经理照着指南操作,
第一步就卡住了。运行时自己报的错误消息也在把他们指向一个打不开的页面。维护者 9 月 8 日已裁决
按这个方向做(路线 A)。

风险与代价(含回滚) — 后台、授权、对外承诺一个字都没动,没有新增或删除任何权限开关。
唯一的变化是「账户」应用里多了一个菜单项。已实测:没有任何权限的用户现在能看到这个菜单项,
而「系统设置」对他仍然是 403 拒绝——这两件事同时成立,是本次验收的全部内容。回滚就是删掉那
一个对象字面量,一次 revert,无数据迁移、无兼容包袱。⚠️ 一个小缺口:这个新菜单项目前没有
中文/日文/西班牙文标签(翻译文件在另一个包里,不在本 PR 范围),所以在非英文界面下它会显示
英文原文。不影响功能。

席位意见 — (留空,待席位定稿)

你要做的 — 只需确认一件事:把「连接智能体」放进每个人自己的账户页,而不是继续只放在管理员
的系统设置里,符合你 9 月 8 日的裁决意图。其余都是机械落地。


Generated by Claude Code


Generated by Claude Code

`POST /api/v1/keys` mints a `sys_api_key` bound to the caller and the page
says the key "acts as you", but the only nav entry sat in Setup behind
`requiredPermissions: ['setup.access']` — so every non-admin following the
two-step guide stopped at step 1 while the endpoint behind the button
accepted them all along.

Adds a second `navigationContributions` entry in the same bundle, targeting
the `account` app's `grp_account_developer` group beside the
`nav_account_api_keys` entry already shipping there. The Setup entry stays
for admins. Backend, authorization and the published "acts as you" promise
do not move, and no gate of any kind is added or removed: a contribution
registers exactly when the page registers.

Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Co-authored-by: Claude <noreply@anthropic.com>
…Setup guard

Pins the half this package owns: the contribution is aimed at the ungated
`account` app / `grp_account_developer` group, its item carries nothing the
server-side nav filter could strip for a permissionless caller, the Setup
entry is byte-unchanged, and — the load-bearing one — this bundle declares no
permission key and no `apps` collection, so it cannot reach the card by
widening Setup instead. Both contributions are parsed against the real
`NavigationContributionSchema`, which is what makes the shared item id an
accepted fact rather than an unenforced one.

Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/mcp, touching 7 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via /api/v1/keys (route, a path literal on a changed line))
  • content/docs/ai/connect-mcp.mdx (via connect_agent (literal, a string literal in navigationContributions), /api/v1/keys (route, a path literal on a changed line))
  • content/docs/api/index.mdx (via /api/v1/keys (route, a path literal on a changed line))
  • content/docs/deployment/environment-variables.mdx (via /api/v1/keys (route, a path literal on a changed line))
  • content/docs/getting-started/build-with-claude-code.mdx (via /api/v1/keys (route, a path literal on a changed line))
  • content/docs/getting-started/examples.mdx (via navigationContributions (symbol, a field of const object CONNECT_AGENT_UI_BUNDLE))
  • content/docs/permissions/capabilities.mdx (via setup.access (literal, a string literal on a changed line))
  • content/docs/permissions/permission-sets.mdx (via setup.access (literal, a string literal on a changed line))
  • content/docs/permissions/system-context.mdx (via setup.access (literal, a string literal on a changed line))
  • content/docs/protocol/kernel/config-resolution.mdx (via setup.access (literal, a string literal on a changed line))
  • content/docs/protocol/kernel/plugin-spec.mdx (via navigationContributions (symbol, a field of const object CONNECT_AGENT_UI_BUNDLE))
  • content/docs/ui/audience-based-interfaces.mdx (via setup.access (literal, a string literal on a changed line))
  • content/docs/ui/setup-app.mdx (via navigationContributions (symbol, a field of const object CONNECT_AGENT_UI_BUNDLE), setup.access (literal, a string literal on a changed line))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via /api/v1/keys (route, a path literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1f0b5659e430717645877f01c4128be567731e35packageMentionDocs.

Which tree this was computed on

This run read content/docs from 743a70a30577f20c3938598c4805bd0e0c9cca1f — the merge of head 32f62417bdac521233be5445f9e657a607146558 into base 1f0b5659e430717645877f01c4128be567731e35, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 743a70a30577f20c3938598c4805bd0e0c9cca1f && git checkout 743a70a30577f20c3938598c4805bd0e0c9cca1f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1f0b5659e430717645877f01c4128be567731e35 32f62417bdac521233be5445f9e657a607146558 && git checkout -B drift-repro 1f0b5659e430717645877f01c4128be567731e35 && git merge --no-ff 32f62417bdac521233be5445f9e657a607146558

node scripts/docs-audit/affected-docs.mjs --json 1f0b5659e430717645877f01c4128be567731e35

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1f0b5659e430717645877f01c4128be567731e35 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-sales
os-sales marked this pull request as ready for review September 11, 2026 08:23
@os-sales
os-sales enabled auto-merge September 11, 2026 08:23
@os-sales
os-sales added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit f19dbcf Sep 11, 2026
40 of 41 checks passed
@os-sales
os-sales deleted the claude/issue-16746-connect-agent-account-nav branch September 11, 2026 08:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants