Skip to content

Commit d925ac7

Browse files
fix(connector-mcp): an include a credentialed MCP server does not list degrades the instance as a credential refusal, not the boot (#28357)
Fixes #28311 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:MCP 连接器实例发送了凭据、而服务器的 `tools/list` 没有列出 `include` 点名的工具时,不再按“声明错误”让整个服务启动失败,而是按“凭据问题”把该实例降级,存储的凭据标为 `rejected`;没有凭据的实例行为完全不变。 - 为什么改:GitHub 这类服务器按令牌权限过滤工具列表,权限不够的令牌放在环境变量里会让整个运行时下次重启起不来,放在凭据存储里又提示管理员“去修声明”;能修的其实是凭据,而列表本身分不清是“被隐藏”还是“根本没有”,所以提示同时写明两种原因。 - 风险与回滚:只改变“实例发送了 `auth` 凭据且工具缺失”这一种情况的归类,复用已有的 `ConnectorCredentialRejectedError` 与 `rejected` 状态,没有新类型、新状态。回滚即还原 `mcp-provider.ts` 里的 `if (credentialSent)` 分支。 - 先看:`packages/connectors/connector-mcp/src/mcp-provider.ts`(`if (credentialSent)` 分支与 403 的理由)、`packages/connectors/connector-mcp/src/mcp-include-credential-scope.test.ts`(环境变量路径与存储路径两条启动测试)、`content/docs/automation/connectors.mdx`。 - 两分钟验证:依赖闭包构建后运行 `pnpm --filter @objectstack/connector-mcp exec vitest run src/mcp-include-credential-scope.test.ts`,预期 `Tests 9 passed (9)`。 - 需要你做的:无,席位落地。 ## What changes - **`@objectstack/connector-mcp` (`mcp-provider.ts`).** When `providerConfig.include` names a tool the server's `tools/list` does not carry, the refusal now depends on whether the handshake carried the instance's credential (an `http` transport with the instance `auth` sent as a header — the same predicate `credentialVerified` already used): - **carried:** `ConnectorCredentialRejectedError`, status `403`. The message names the missing tools, the tools the server lists for that credential, and both causes with the fix for each (the credential lacks the scopes those tools need, or the declaration names a tool the server does not have). The materializer's existing credential path then applies: degraded, boot not failed; a stored credential marked `rejected` and held; an environment or vault credential retried on the backoff. - **not carried** (no `auth`, an `api-key` sent as a query parameter, a `stdio` transport, or a credential written into `providerConfig.transport.headers`, which is part of the declaration): the plain configuration fault, byte-identical message, fatal at boot as before. - **Docs.** `content/docs/automation/connectors.mdx`: the `include` paragraph, the "credential the upstream refuses" paragraph and two rows of the failure-mode table. `packages/apps/connector-github/README.md`: the Credentials paragraph no longer says an environment-variable token stops the server from starting. - **Contract text the change would otherwise make false.** `ConnectorCredentialRejectedError` / `CONNECTOR_CREDENTIAL_REJECTED` TSDoc in `packages/spec` (comments only, no type change) and the `sys_connector_credential.status` field description now name the second way the marker is raised. - **Tests.** `src/mcp-include-credential-scope.test.ts` (new): the three probes the card was filed from, made permanent on the real provider, the real materializer and the real credential store (ObjectQL over SQLite), plus controls. For the store, `connector-mcp` gains three devDependencies (`@objectstack/objectql`, `@objectstack/driver-sql`, `@objectstack/platform-objects`, all already in its build closure) and a `vitest.config.ts` that reads them from source. - **Changeset.** `@objectstack/connector-mcp` patch, `@objectstack/service-automation` patch (the field description). ## Tests At head `b6312fe6`: - `pnpm --filter @objectstack/connector-mcp test`: `Test Files 6 passed (6)`, `Tests 65 passed (65)`; `typecheck` exit 0, test-typecheck ledger unchanged (`1 file(s) / 5 error(s)`, the new file adds none). - `pnpm --filter @objectstack/connector-app-github test` (validate + vitest): `Validation passed`, `Test Files 2 passed (2)`, `Tests 10 passed (10)`; `typecheck` exit 0. - `@objectstack/service-automation` (the description edit), at `c7628191`, no later commit touches it: `Test Files 237 passed (237)`, `Tests 2985 passed (2985)`; `typecheck` exit 0. - The new file, 9 cases: credentialed refusal (403, both causes, missing and listed tools, client closed, no token in the message); controls — no `auth`, `api-key` as a query parameter, a credential in `transport.headers`, `stdio` with `auth`, and a credential whose listing carries every entry (materializes, `credentialVerified`); through the materializer — environment credential (boot not failed, degraded, listed `rejected` / `env`, not held, recovers on a reconcile once the same token's scopes cover the tools), stored credential (degraded with the credential reason, row `rejected` with that reason, held as `credential`, a reconcile dials nothing, a better-scoped value recovers it with `lastVerifiedAt` set; neither token in any log line; no "Fix its declaration" line), and no credential (boot still fails with the declaration fault). - **Ablation, once, at `a51f1291`:** `node scripts/ablation-replace.mjs` replaced `if (credentialSent) {` with `if (false as boolean) {` (anchor 1 to 0, blob `05b010b670de` to `2dd4cd2773e3`): `Tests 3 failed | 6 passed (9)`, the three credential-path cases red, every control green; restored, blob equals HEAD, `git diff HEAD` empty. The test imports the provider by relative path from `src`, so no build sits between the mutation and the run. ## Acceptance notes - **Can the server tell the two causes apart? Measured: no.** `github/github-mcp-server` at `f2a07cd1`: the scope filter is applied to the inventory before `tools/list` (`pkg/http/handler.go` `PATScopeFilter`, `pkg/github/scope_filter.go`), the result carries no field about hidden tools, and the HTTP layer sets no scope header on that answer. The only scope signal is `WWW-Authenticate: Bearer error="insufficient_scope"` with `403` on a `tools/call` (`pkg/http/middleware/scope_challenge.go`). So the message names both causes, and status `403` follows that same convention (RFC 6750 section 3.1); the message itself quotes no HTTP status. - **The GitHub app's own two tools are never hidden by GitHub's server.** At `f2a07cd1`, `search_issues` and `issue_read` are declared with `scopes.PublicRead(scopes.Repo)`, whose visibility check returns true for any scopes (`pkg/scopes/scopes.go`, `PublicRead`), matching GitHub's `docs/scope-filtering.md` "Public Repository Access". The defect is reachable with GitHub through other tools (write tools, `notifications`, project and org tools) and with any server that filters by credential. The README paragraph now says so. - **Row state.** The existing `rejected` state is reused; no new state. It clears when the value is set again or a handshake lists every entry (`markVerified`). A credentialed instance whose `include` has a real typo now marks its stored credential `rejected` too; the message names that cause, and editing the declaration re-attempts it and clears the mark on success. - **Where the probes live.** In `connector-mcp`'s own tests, which run without the app; `packages/apps/connector-github/test/boot.test.ts` is unchanged. - `packages/services/service-automation/src/plugin.ts` is not touched. Its hold log line ("its upstream refused its credential") reads acceptably for this case and is left alone. - `origin/main` was merged at `efdddc90`, after #28306 landed. Main has moved since; GitHub's merge of the newer commits is clean (`git merge-tree` exit 0), and none of them touches these packages. ## Verification <details><summary>Gate families derived for this diff: 114 run, 0 NOT MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `b6312fe6`: 114 commands for 10 paths, 569 changed lines (+533 / -36) (under the 3000 threshold). Every command was run with its exit code captured before any pipe. 112 exited 0 on the first pass. `pnpm --filter @objectstack/spec run check:skill-examples` and `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: unbuilt `client-react` and siblings). Re-run once the dists existed, both exited 0 (`277 prose examples type-check across 3 surface(s)`; `113 published require entry point(s) across 70 package(s) load`). `--ran` over the final record: `114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED`. </details> <details><summary>Not run locally, by rule</summary> `test:repo`, `pnpm lint` and the other repo-wide scans belong to CI. Dogfood does not apply: no `!` in the title and no `(narrowing)` arm. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcVBKAFk1ntXSjvPt7U)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 23d1303 commit d925ac7

10 files changed

Lines changed: 568 additions & 51 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/connector-mcp': patch
3+
'@objectstack/service-automation': patch
4+
---
5+
6+
fix(connector-mcp): when a server that was sent the instance's credential does not list an `include`d tool, the instance is degraded as a credential refusal instead of failing the boot as a declaration fault
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
Some MCP servers list only the tools the credential they are sent may use: GitHub's hides each tool a classic personal access token's scopes do not cover. Its `tools/list` answer says nothing about what it hid, so a tool missing from it may be hidden from this credential or may not exist on the server at all.
12+
13+
- **Before.** When `providerConfig.include` named a tool the server did not list, the `mcp` provider refused with a plain configuration fault in every case. With the credential in an environment variable, the whole boot failed. With a credential stored through the credential door, the instance was degraded with a reason telling the administrator to fix the app's declaration.
14+
- **Now.** When the handshake carried the instance's `auth` credential (an `http` transport, the credential sent as a header), the refusal is a `ConnectorCredentialRejectedError` with status `403`. Its message names the missing tools and the tools the server does list for that credential, and it names both causes with the fix for each: give the credential the scopes those tools need and set it again, or fix `providerConfig.include`. The materializer handles it as it handles a `401` / `403`: the instance is degraded and the boot does not fail. A stored credential is marked `rejected` and held until it is set again; an environment or vault credential is retried on the backoff, so widening the token's scopes where it was issued recovers the instance without a restart.
15+
- **Unchanged.** An instance whose handshake carried no static credential (no `auth`, an `api-key` sent as a query parameter, a `stdio` transport, or a grant-bearing `oauth2` / `app-credential` instance, whose token is borrowed per request from an organization's connection) keeps the plain configuration fault, fatal at boot, with the same message as before.
16+
- **Where it reads.** The `sys_connector_credential.status` field description (`@objectstack/service-automation`) now names this second way a stored credential becomes `rejected`.

‎content/docs/automation/connectors.mdx‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,19 @@ keep a destructive tool away from your flows and agents. It must be an array of
250250
tool names; any other shape (a string such as `include: 'web_search'`, an
251251
object) fails the boot before connecting, and never falls back to exposing
252252
every tool. An entry the server's `tools/list` does not carry — a typo, or a
253-
tool the server renamed or dropped — fails the boot with an error naming the
254-
entry and listing the server's tools; it is never ignored. Leave `include` out
255-
to expose every tool.
253+
tool the server renamed or dropped — is never ignored: the refusal names the
254+
entry and lists the server's tools. On an instance that sends no static
255+
credential — no `auth`, an `api-key` sent as a query parameter, a `stdio`
256+
transport, or a grant-bearing `oauth2` / `app-credential` instance, whose token
257+
is borrowed per request — it fails the boot. When the instance sends a static
258+
`auth` credential (`bearer`, `basic`, or an `api-key` header), the entry may
259+
instead be a tool the server hides from that credential: some servers list
260+
only the tools a credential is allowed to use (GitHub's hides each tool a
261+
classic token's scopes do not cover), and the answer does not say what it hid.
262+
That instance is degraded as a credential the upstream refuses (below), never
263+
failing the boot, and its reason names both causes: a credential without the
264+
scopes those tools need, or a declaration naming a tool the server does not
265+
have. Leave `include` out to expose every tool.
256266

257267
<Callout type="warn">
258268
**Declarative stdio transports are denied by default.** Materializing one
@@ -378,7 +388,9 @@ plus a `credentialNote` naming the ones still to set.
378388

379389
**A credential the upstream refuses** (HTTP `401` / `403`) degrades the
380390
instance, with a reason quoting the upstream's message, never the value. An
381-
`mcp` instance finds this out when it connects. A `rest` or `openapi` instance
391+
`mcp` instance finds this out when it connects, and treats a server that
392+
accepts the credential but does not list a tool its `include` names the same
393+
way. A `rest` or `openapi` instance
382394
finds out on the first call answering `401` / `403`; that call returns its
383395
answer as usual, and the instance is degraded after it. What happens next
384396
depends on where the value came from:
@@ -768,11 +780,11 @@ retry):
768780
| `credentialRef` (or a `signed` instance's `vendorPublicKeyRef`) resolves to nothing | **Not fatal**: the instance registers **degraded**, naming the reference — set it (`PUT …/credential-refs/:ref`), and it materializes without a restart |
769781
| A `signed` reference resolves to something that is not a key of the scheme's kind (an `alipay-v3` key that is not RSA) | **Hard boot error** naming the instance and which reference cannot sign or verify, never the value |
770782
| A response to an `alipay-v3` call is unsigned (below status 400) or its signature does not verify | The dispatch **fails** naming the instance and the status; the response's body is never returned |
771-
| The upstream refuses the credential (`401` / `403`) | **Not fatal**: the instance registers **degraded**. A stored credential is marked `rejected` and waits until it is set again (or a restart); an environment or vault credential is retried on the backoff |
783+
| The upstream refuses the credential (`401` / `403`), or an `mcp` server sent a static credential does not list a tool the instance's `include` names | **Not fatal**: the instance registers **degraded**. A stored credential is marked `rejected` and waits until it is set again (or a restart); an environment or vault credential is retried on the backoff |
772784
| Instance `name` collides with a plugin-registered connector | **Hard boot error** — there is no silent precedence between the two worlds |
773785
| stdio transport without host opt-in | **Hard boot error** with the `declarativeStdio` fix inline — a security rejection is never retried |
774786
| MCP server unreachable / `tools/list` fails; remote spec URL unreachable or transiently failing (408/429/5xx) | **Not fatal**: the instance registers **degraded** (visible on `GET /api/v1/automation/connectors` with the reason), dispatches fail with that reason, and the platform retries on a 5s→5min backoff — recovery is automatic |
775-
| A retry of a degraded instance reaches the upstream and meets a configuration fault (an `include` naming a tool or operation the upstream does not offer) | **Not fatal**: the instance stays **degraded**, now with the configuration reason, and is **not retried** — dispatches fail saying to edit its declaration; an edit of the entry (or a restart) re-attempts it |
787+
| A retry of a degraded instance reaches the upstream and meets a configuration fault (an `include` naming a tool or operation the upstream does not offer; on an `mcp` instance that sends a static credential, the credential row above applies instead) | **Not fatal**: the instance stays **degraded**, now with the configuration reason, and is **not retried** — dispatches fail saying to edit its declaration; an edit of the entry (or a restart) re-attempts it |
776788
| Wrong spec URL (other 4xx) or unparseable document | Configuration fault — **hard boot error** |
777789
| A `connector_action` input that, once evaluated, breaks the action's `inputSchema` (a missing `required` parameter, a wrong `type`) | **The step fails before dispatch** with `VALIDATION_ERROR`, naming each parameter path, and nothing is sent; a `fault` edge or `try_catch` can route it. Supply the parameter or send the declared type: text is never converted, so cast it in a CEL envelope (`int()`, `double()`, `bool()`). `GET /api/v1/automation/connectors` lists the schema; `os validate` does not judge the input against the `inputSchema` yet |
778790
| A bad entry arrives via **runtime reload** (a Studio publish, a direct active save through `PUT /api/v1/meta/connector/:name`, or a delete, made on any replica — peers reconcile on [cluster lane 2](/docs/kernel/cluster#62-the-cluster-invalidation-family); or a dev reload) | Logged and **skipped**, never crashing the live server; a changed instance's old connector keeps serving until its replacement materializes |

‎packages/apps/connector-github/README.md‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,18 @@ install dialog right after installing. An environment variable named
3434
`GITHUB_MCP_TOKEN` on the server takes precedence over a stored value.
3535

3636
Prefer a fine-grained token. With a classic token, GitHub's server hides every
37-
tool whose scope the token lacks. If that hides `search_issues` or
38-
`issue_read`, the connector does not start without them: a value set in
39-
**Connector Credentials** leaves `github_mcp` disconnected with a reason naming
40-
the missing tools, and a value set as the server environment variable stops the
41-
server from starting at all. Replace the token with one that carries the `repo`
42-
scope: a new value in **Connector Credentials** takes effect at once, a new
43-
environment variable at the next start.
37+
tool whose scope the token lacks, though it keeps read tools that also work on
38+
public repositories listed whatever the scopes, `search_issues` and
39+
`issue_read` among them ([PAT scope filtering](https://github.com/github/github-mcp-server/blob/main/docs/scope-filtering.md#public-repository-access)).
40+
If the server does leave out a tool this app includes, `github_mcp` stays
41+
disconnected with a reason naming the missing tools and both possible causes,
42+
the token's scopes or the app's declaration, and its flows fail; the server
43+
itself keeps starting, wherever the value is set. Replace the token with one
44+
that carries the `repo` scope: a new value in **Connector Credentials** takes
45+
effect at once, a new environment variable at the next start. Widening the
46+
scopes of the token you already use works too: set it again in **Connector
47+
Credentials**, or, for the environment variable, wait for the connector's next
48+
automatic retry, at most five minutes away.
4449

4550
The token is only ever sent to GitHub. It is never stored in the app's
4651
metadata.

‎packages/connectors/connector-mcp/package.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
"@objectstack/types": "workspace:*"
3434
},
3535
"devDependencies": {
36+
"@objectstack/driver-sql": "workspace:*",
37+
"@objectstack/objectql": "workspace:*",
38+
"@objectstack/platform-objects": "workspace:*",
3639
"@objectstack/service-automation": "workspace:*",
3740
"@types/node": "^26.6.5",
3841
"typescript": "^6.0.3",

0 commit comments

Comments
 (0)