Skip to content

Commit dd03ee0

Browse files
feat(connector-mcp): a stdio MCP server takes its credential from the secrets layer through transport.credentialEnv (#29621)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi95wDB21nUbCmU5szPePATGS)_ Fixes #29434 domain:server#3 · os-steve · session_01ADHkmRFhrJgbwLP5mvZkeJ Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up #29625 — skills/objectstack-automation/references/integration-recipes.md recipe 6 still says a stdio server's credentials ride `transport.env`; `content/docs/automation/connectors.mdx` is updated here. ## 维护者速读 - 改了什么:`mcp` 连接器的 stdio 服务器现在可以从密钥层拿凭据。连接器照常声明 `auth: { type, credentialRef }`,再在 `providerConfig.transport.credentialEnv` 写上服务器读取的环境变量名,平台解析出的密钥只放进子进程的环境变量。 - 为什么改:以前 stdio 服务器的 API key 只能以明文写在 `transport.env` 里,存在元数据中;#29341 的提示也只能劝作者改用 http。 - 风险与回滚:只是新增一个作者可写的可选键(Metadata-protocol: added,connector-mcp 升 minor),没写 `credentialEnv` 的 stdio 实例行为完全不变;新键写错(变量名非法、用在 http 上、没有 auth、与 env 重名)按配置错误拒绝。回滚即 revert 本 PR。 - 先看:`packages/connectors/connector-mcp/src/mcp-provider.ts`(`credentialEnvOf`、stdio 分支合并 env)、`packages/connectors/connector-mcp/src/mcp-stdio-credential-env.test.ts`、`packages/lint/src/lint-connector-credential-literals.ts`(提示措辞)。 - 两分钟验证:`pnpm --filter @objectstack/connector-mcp exec vitest run src/mcp-stdio-credential-env.test.ts`,预期 17/17 通过(真实子进程只回报变量「已设置」和长度)。 - 需要你做的:无,席位落地。 ## What changes A declarative `provider: 'mcp'` instance over **stdio** can now take its credential from the secrets layer instead of a literal in `transport.env` (route (c), ruled by the seat on the card). - **Route.** The entry declares `auth: { type: 'bearer' | 'api-key' | 'basic', credentialRef }` exactly as an http instance does. The automation service resolves the reference through its existing chain (environment, then the stored connector credential, then the host resolver). The `mcp` provider then puts the resolved secret (the bearer token, the api-key key, or the basic password) into the child's environment under the variable that the new provider-owned key `providerConfig.transport.credentialEnv` names. `providerConfig` is provider-owned and untyped in the spec (ADR-0023), so there is no `packages/spec` hunk. - **Inherited for free.** All of this keys off `entry.auth`: Setup → Connector credentials lists the reference, a credential write re-materializes and re-spawns the instance, and a reference no arm answers degrades the instance naming the reference. That last case never calls the provider, so the server is never spawned. - **Where the value goes.** It only goes into the `env` handed to the spawn (the SDK's `StdioClientTransport` merges it over its default inherited set). It never goes onto the connector `def`, into a log, or into a message. A metadata read serves `auth.credentialRef` and `transport.credentialEnv`, which are two names; `transport.env` stays withheld whole, as before. - **Refusals.** These are configuration faults, thrown plain before anything is spawned, and none of them names a value: - a `credentialEnv` that is not an environment-variable name; - a `credentialEnv` on an http transport; - a `credentialEnv` with no static `auth` (absent, or `none`); - a `credentialEnv` naming a variable that `transport.env` also sets. A grant-bearing instance on stdio still meets the existing credential-handle refusal. That refusal's sentence now names this route (before, it pointed at "a static credential for the stdio one", which reached nothing). - **Unchanged (ruling Q2 A).** A stdio transport with no `credentialEnv` still does not apply `auth`. The silent drop is #29607. A stdio handshake does not count as the credential being sent (`credentialSent` / `credentialVerified` stay http-only). - **Advisory (#29341, `connector-credential-literal`).** The stdio env finding now steers to the route: "move it to the connector's `auth.credentialRef` with `transport.credentialEnv`". Its hint uses the same `auth: { type: 'bearer', credentialRef }` spelling as the header hint. The matching `os explain` paragraph is updated too. - **Pending release note corrected.** `.changeset/29341-connector-credential-literal.md` (the #29341 advisory's changeset, still pending on `main`) said that a stdio MCP server takes no `auth`, and steered authors to http. It would have shipped in the same release as this route. Its stdio bullet now names the `credentialEnv` route; this is a prose-only edit. `Check Changeset` / `check-empty-changeset` stays red on that file by design (the DELIBERATE CORRECTION class). The seat ordered the correction in the claim amendment on #29434. - **Docs.** In `content/docs/automation/connectors.mdx`, the stdio bullet now names `credentialEnv`, there is a new "A stdio server's credential" section with an `os:check` example, and the grant-bearing paragraph sentence is updated. **Forced test edits.** `packages/lint/src/lint-connector-credential-literals.test.ts` had two edits: - the stdio env message pin now names the new route (was: "a stdio server takes no `auth`; `auth.credentialRef` reaches it only over http"); - the `os explain` fact `A stdio MCP server takes no \`auth\`` is replaced by `A stdio MCP server takes the same \`auth\`` and `` `providerConfig.transport.credentialEnv` ``. One test was added in that file (the hint names the route with the shared spelling). ## Tests `packages/connectors/connector-mcp/src/mcp-stdio-credential-env.test.ts` (new, 17 tests). Every value in it is an obvious placeholder. - **Real spawns through the SDK.** The fixture child reports only whether the variable is set and its length, never the value. - bearer, api-key and basic each reach the child in the named variable, and the materialization carries no value; - control: the same instance with no `credentialEnv` starts the child without the variable; - control: a literal in `transport.env` reaches the child as today. - **Refusals.** Each of the four is thrown plain and names no value, and nothing is spawned (the not-a-name case is run with both a malformed string and a non-string). A grant handle on stdio meets the handle refusal, which now names `credentialEnv`. - **Metadata read.** The connector redactor serves `auth.credentialRef` and `transport.credentialEnv`, and `redactedKeys` is exactly `providerConfig.transport.env`. - **Through the automation service** (LiteKernel + AutomationServicePlugin + ConnectorMcpPlugin, a real stdio child): - a reference resolved by the host reaches the spawned server, and the served descriptor carries no value; - an unresolvable reference degrades the instance with `credential '…' is not set`, and the child's start marker is never written. - **Advisory.** The control (a literal in `transport.env` still draws the advisory) and the new wording are pinned in the lint test. <details><summary>Ablations (each leg through <code>scripts/ablation-replace.mjs</code>: anchor 1→0, blob moved, then restored to blob == HEAD with an empty <code>git diff HEAD</code>; the suites import <code>src</code>, so no dist leg)</summary> - A1, the env merge dropped (`env: env`): 4 failed (bearer, api-key, basic, composition-resolved), `expected 'unset:0' to be 'set:28'`. - A2, the variable-name check dropped: 2 failed (not-a-name, not-a-string), `expected a throw`. - A3, the http refusal dropped: 1 failed (`credentialEnv on an http transport`). - A4, the no-auth refusal dropped: 2 failed (no auth: `TypeError` instead of the plain refusal; `none`: no throw). - A5, the clash refusal dropped: 1 failed (`sets environment variable … twice`). - A6, the old advisory steer restored: 1 failed (the stdio message pin, old vs new sentence). </details> ## Acceptance notes - **Declaration and level:** `credentialEnv` is a key a metadata author writes, so the kind is `added` even with no spec hunk (AGENTS.md Post-Task Checklist step 3; precedent PR #27387, `providerConfig.include` on `connector-openapi`). `@objectstack/connector-mcp` takes `minor`. `@objectstack/lint` changes only message text and stays `patch`. Both lines follow the seat's claim amendment on #29434. - **Review face:** `node scripts/pm/record-recognisers.mjs --template --paths` on the final file list. Without the changesets it reads the declaration face, `TIER_DEFAULT`. With them it refuses and defers to the changeset's `Clause-②: yes (widening)` line, which is the same declaration face. - **Tool-list refusals over stdio** stay on the plain path. The seat confirmed option A: `credentialSent` / `credentialVerified` stay http-only. - **One secret per instance.** A server that reads two secrets keeps the second in `env`, or is declared as two instances. An `api-key`'s `headerName` / `paramName` are http placement and are not read on stdio. A `basic` username is not a secret, so it goes in `env` if the server reads it. - `protocol.ts` and every ADR are untouched. No ADR declares or rules out a stdio credential route. ADR-0024 §3 (credentials at the process/transport boundary) and ADR-0097 §3 (`credentialRef` resolved by the host) both hold as written. ## Verification Readings head: `f3ff3349` (merges `origin/main` `81cd9eb0`). Every reading below was taken on that head. The one commit after it, `a76ad3f8`, changes only the two changesets' prose and front matter, so no test, gate or dogfood reading was re-taken. On `a76ad3f8` the changeset gates ran as follows: `check-adr-0087-registration`, `check-changeset-no-major`, `check:changeset-gate-self-tests`, `check:pm-changeset-deadline-census`, `check:issue-citations` and `check:nul-bytes` all exit 0. `check-empty-changeset` exits 1 on `.changeset/29341-connector-credential-literal.md`, the declared deliberate correction above. <details><summary>Package suites and typecheck</summary> - `@objectstack/connector-mcp`: Test Files 9/9, Tests 119/119; typecheck exit 0 (incl. `check:test-typecheck`). - `@objectstack/lint`: Test Files 192/192, Tests 7892/7892; typecheck exit 0. </details> <details><summary>Full dogfood battery (credentials card)</summary> - Closure build `turbo run build --filter='@objectstack/dogfood^...'` (plus the connector-mcp and lint closures) under the lock: `VERDICT command-exit 0`, 68/68 tasks. - `pnpm --filter @objectstack/dogfood test` under the lock: `VERDICT command-exit 0`, Test Files 398 passed and 3 skipped (401), Tests 3280 passed and 28 skipped (3308), 0 failed. </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 87 families derived on `f3ff3349`, all 87 run. - Three first answered exit 3 (PREREQUISITE NOT MET) and were re-run after their prerequisite, then exited 0: - `check-plugin-teardown-shape --self-test`, after fetching its pinned fixture commit; - `check:skill-examples` and `check:dual-build-cjs-loads`, after `turbo run build --filter='./packages/**'` (78/78 cache hits). - `--ran` reconciliation: `87 derived, 87 run, 0 NOT-MEASURED, 0 UNRUN` (a DERIVED zero). - `pnpm lint` (repo-wide eslint, `--no-inline-config`): exit 0. - CI families are not re-run locally; the PR check-runs are the record. </details> <!-- signed: domain:server#3 · os-steve · session_01ADHkmRFhrJgbwLP5mvZkeJ --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 322cf90 commit dd03ee0

9 files changed

Lines changed: 512 additions & 33 deletions

‎.changeset/29341-connector-credential-literal.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ Metadata-protocol: none
1111
A declarative connector entry's four maps — `providerConfig.headers`, `providerConfig.defaultHeaders`, `providerConfig.transport.headers` and `providerConfig.transport.env` — are withheld whole on the metadata read path, but a literal credential in one of them is still stored in metadata. The same `credential-literal` predicate the `flow-credential-literal` advisory asks now judges those four maps and names each credential-shaped entry by its position (never its value), with the `auth.credentialRef` route:
1212

1313
- a header entry: move it to the connector's `auth: { type: 'bearer' | 'api-key' | 'basic', credentialRef }`, which the `rest` provider and an `mcp` http transport send themselves;
14-
- a stdio `transport.env` entry: a stdio MCP server takes no `auth`, so the warning says `auth.credentialRef` reaches the server only over an http transport.
14+
- a stdio `transport.env` entry: move it to the connector's `auth: { type: 'bearer' | 'api-key' | 'basic', credentialRef }` and name the variable in `providerConfig.transport.credentialEnv`; the `mcp` provider hands the resolved credential to the stdio server in that variable itself.
1515

1616
Always a `warning`; nothing is refused and every save lands. The rule runs on all three CLI commands and on the runtime publish gate for `connector` (`PUT /meta/connector/:name`), where it rides the save response's `advisories`. `os explain connector-credential-literal` carries the reasoning.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/connector-mcp': minor
3+
'@objectstack/lint': patch
4+
---
5+
6+
Clause-②: yes (widening)
7+
Metadata-protocol: added
8+
9+
`provider: 'mcp'`: a stdio MCP server can now take its credential from the secrets layer instead of a literal in `transport.env`. Declare the instance's `auth` with a `credentialRef` as for an http server, and name the environment variable the server reads in the new `providerConfig.transport.credentialEnv` key: the host resolves the reference (environment, Setup → Connector credentials, the host's resolver) and the provider hands the resolved secret (the `bearer` token, the `api-key` key, or the `basic` password) to the child in that variable. The value is never stored, logged, served on a metadata read or put in a message; a reference that resolves to nothing degrades the instance naming the reference and the server is not started.
10+
11+
```ts
12+
providerConfig: { transport: { kind: 'stdio', command: 'repo-mcp-server', credentialEnv: 'REPO_MCP_TOKEN' } },
13+
auth: { type: 'bearer', credentialRef: 'REPO_MCP_TOKEN' },
14+
```
15+
16+
Refused at materialization as configuration faults (a failed boot for the host's own entry): a `credentialEnv` that is not an environment-variable name, one on an `http` transport, one with no `bearer` / `api-key` / `basic` `auth`, and one naming a variable `transport.env` also sets. A stdio transport that names no `credentialEnv` is unchanged: it does not apply `auth`.
17+
18+
`@objectstack/lint`: the `connector-credential-literal` advisory's finding for a credential literal in `transport.env` now steers to this route (`auth.credentialRef` with `transport.credentialEnv`) instead of telling the author to move to an http transport.

‎content/docs/automation/connectors.mdx‎

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,10 @@ allowlist — only the listed tools become actions) and **`toolsSnapshot`**
228228
HTTP. Resolved `auth` is folded into the request headers; a grant-bearing
229229
`auth` (`oauth2`, `app-credential`) is asked for on every request instead
230230
(see *Grant-bearing kinds* below).
231-
- `{ kind: 'stdio', command, args?, env? }` — a **local child process**,
232-
policy-gated (below). Credentials for a stdio server ride `transport.env`,
233-
not `auth`.
231+
- `{ kind: 'stdio', command, args?, env?, credentialEnv? }` — a **local child
232+
process**, policy-gated (below). Resolved `auth` is handed to the server in
233+
its environment, under the variable `credentialEnv` names (see
234+
[*A stdio server's credential*](#a-stdio-servers-credential) below).
234235

235236
{/* os:check */}
236237
```typescript
@@ -346,6 +347,49 @@ characters fails the boot before connecting, and so does a pinned tool that
346347
`include` leaves out — the instance never exposes it, so its pin would never be
347348
checked.
348349

350+
#### A stdio server's credential
351+
352+
A stdio MCP server reads its credential from an environment variable, and the
353+
value stays out of metadata the way an http server's does: declare `auth` with
354+
a `credentialRef`, and name the variable in `transport.credentialEnv`. The
355+
platform resolves the reference like any instance's (the environment, then a
356+
credential set under Setup → Connector credentials, then the host's resolver)
357+
and the provider hands the resolved secret to the child in that variable — the
358+
token of a `bearer`, the key of an `api-key`, the password of a `basic` (whose
359+
username, not a secret, goes in `env` if the server reads it).
360+
361+
{/* os:check */}
362+
```typescript
363+
import { defineConnector } from '@objectstack/spec/integration';
364+
365+
export const RepoToolsConnector = defineConnector({
366+
name: 'repo_tools',
367+
label: 'Repository Tools (MCP, stdio)',
368+
type: 'api',
369+
provider: 'mcp',
370+
providerConfig: {
371+
transport: {
372+
kind: 'stdio',
373+
command: 'repo-mcp-server',
374+
env: { LOG_LEVEL: 'info' },
375+
credentialEnv: 'REPO_MCP_TOKEN',
376+
},
377+
},
378+
auth: { type: 'bearer', credentialRef: 'REPO_MCP_TOKEN' },
379+
});
380+
```
381+
382+
The value is never stored, logged, served on a metadata read (which shows
383+
`credentialRef` and `credentialEnv`, two names) or carried in a message; it
384+
exists only in the environment the server is started with. A reference that
385+
resolves to nothing never starts the server: the instance is degraded naming
386+
the reference, and a credential set later starts it. These fail the boot:
387+
a `credentialEnv` that is not a variable name, one on an `http` transport,
388+
one with no `bearer` / `api-key` / `basic` `auth` to place, and one naming a
389+
variable `env` also sets. A stdio transport that names no `credentialEnv`
390+
does not apply `auth`. A literal credential typed into `env` instead is
391+
reported by the `connector-credential-literal` advisory, which points here.
392+
349393
<Callout type="warn">
350394
**Declarative stdio transports are denied by default.** Materializing one
351395
spawns a local process *from metadata* — which a runtime Studio publish can
@@ -581,8 +625,9 @@ platform's to fill, so it is left out of the action's input schema, as
581625
`Authorization` is.
582626

583627
An **`mcp`** instance carries a grant-bearing credential only over an `http`
584-
transport (a stdio server takes its credentials in `transport.env`; a handle
585-
on a stdio transport, or beside a static credential, fails the boot). Every
628+
transport (a stdio server is handed a static credential once, at spawn, in
629+
`transport.credentialEnv`; a handle on a stdio transport, or beside a static
630+
credential, fails the boot). Every
586631
HTTP request of its MCP session asks the handle. Each organization a dispatch
587632
runs for opens its own session with its own token, so organizations never
588633
share one. The tools are listed at materialization, outside any run, so the

‎packages/connectors/connector-mcp/src/mcp-connector.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ import type { Connector } from '@objectstack/spec/integration';
2121
* **Credentials live with the MCP server, not in `ConnectorSchema`** (ADR-0024
2222
* §3). The operator supplies `env` (stdio) / `headers` (http) which we pass
2323
* straight to the transport; they are never copied into the serialized `def`
24-
* (which is exposed via discovery) and must never be logged.
24+
* (which is exposed via discovery) and must never be logged. A declarative
25+
* instance's resolved `auth` reaches them through the `mcp` provider factory:
26+
* as a header over http, and over stdio in the variable its
27+
* `transport.credentialEnv` names.
2528
*
2629
* **Trust:** launching a stdio server runs a local process. Sandboxed,
2730
* multi-tenant execution and managed secrets are the enterprise tier (ADR-0024

‎packages/connectors/connector-mcp/src/mcp-provider.ts‎

Lines changed: 105 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,9 @@ function isStringRecord(v: unknown): v is Record<string, string> {
7979
* Fold the resolved instance `auth` into an MCP **http** transport's headers
8080
* (ADR-0024 keeps MCP credentials with the transport). `credentialRef` has
8181
* already been resolved upstream, so this only maps the static credential to the
82-
* right header. Not applied to stdio transports — a stdio server receives its
83-
* credentials through `transport.env`.
82+
* right header. Not applied to stdio transports — a stdio server receives the
83+
* instance's credential in its environment, under the variable
84+
* `transport.credentialEnv` names (see {@link credentialEnvOf}).
8485
*/
8586
function applyAuthToHeaders(
8687
auth: ResolvedConnectorAuth | undefined,
@@ -101,11 +102,85 @@ function applyAuthToHeaders(
101102
}
102103
}
103104

104-
/** Validate + normalize `providerConfig.transport`, injecting resolved auth for http. */
105+
/** An environment variable name as POSIX shells spell one. */
106+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
107+
108+
/**
109+
* The secret of a resolved static credential — what a stdio server is handed
110+
* in its environment: a `bearer` token, an `api-key` key, a `basic` password.
111+
* An `api-key`'s `headerName` / `paramName` place the key on an http request
112+
* and are not read here; a `basic` username is not a secret, and a server that
113+
* needs it takes it from `transport.env`.
114+
*/
115+
function secretOf(auth: Exclude<ResolvedConnectorAuth, { type: 'none' }>): string {
116+
switch (auth.type) {
117+
case 'bearer':
118+
return auth.token;
119+
case 'api-key':
120+
return auth.key;
121+
case 'basic':
122+
return auth.password;
123+
}
124+
}
125+
126+
/**
127+
* `transport.credentialEnv` of a stdio transport, judged — the route a stdio
128+
* server's credential takes out of metadata (ADR-0097 §3 at ADR-0024 §3's
129+
* process boundary). The instance's `auth.credentialRef` is resolved by the
130+
* host like any other instance's (the environment, the stored credential, the
131+
* host's resolver; a reference nothing answers degrades the instance naming it
132+
* before this factory runs), and the resolved secret is put into the child's
133+
* environment under the variable `credentialEnv` names. The value is never
134+
* stored, logged, put on the connector `def` or carried in a message: it
135+
* exists only in the environment handed to the spawn.
136+
*
137+
* Refused plain (configuration faults), each naming the key and never a value:
138+
* a `credentialEnv` that is not a variable name; one with no static `auth` to
139+
* place (a grant-bearing kind is refused by the credential-handle check, with
140+
* this route named); one naming a variable `transport.env` also sets, which
141+
* would give that variable two values. `undefined` when the transport names
142+
* none: then `auth` is not applied to the stdio server at all, as before.
143+
*/
144+
function credentialEnvOf(
145+
t: Record<string, unknown>,
146+
env: Record<string, string> | undefined,
147+
connectorName: string,
148+
auth: ResolvedConnectorAuth | undefined,
149+
borrowsCredential: boolean,
150+
): { name: string; value: string } | undefined {
151+
const name = t.credentialEnv;
152+
if (name === undefined) return undefined;
153+
if (typeof name !== 'string' || !ENV_NAME_RE.test(name)) {
154+
throw new Error(
155+
`connector-mcp provider: connector '${connectorName}' providerConfig.transport.credentialEnv must be the name of ` +
156+
`an environment variable (letters, digits and '_', not starting with a digit): the variable the stdio server ` +
157+
`reads the instance's credential from.`,
158+
);
159+
}
160+
if (borrowsCredential) return undefined;
161+
if (!auth || auth.type === 'none') {
162+
throw new Error(
163+
`connector-mcp provider: connector '${connectorName}' names providerConfig.transport.credentialEnv '${name}' but ` +
164+
`declares no credential to place there. Give the entry auth: { type: 'bearer', credentialRef } (or 'api-key' / ` +
165+
`'basic'): the provider passes the resolved credential to the server in '${name}'.`,
166+
);
167+
}
168+
if (env && Object.prototype.hasOwnProperty.call(env, name)) {
169+
throw new Error(
170+
`connector-mcp provider: connector '${connectorName}' sets environment variable '${name}' twice: in ` +
171+
`providerConfig.transport.env and as providerConfig.transport.credentialEnv. Drop the env entry: the provider ` +
172+
`passes the resolved credential to the server in '${name}' itself.`,
173+
);
174+
}
175+
return { name, value: secretOf(auth) };
176+
}
177+
178+
/** Validate + normalize `providerConfig.transport`, injecting resolved auth (http headers, stdio `credentialEnv`). */
105179
function normalizeTransport(
106180
raw: unknown,
107181
connectorName: string,
108182
auth: ResolvedConnectorAuth | undefined,
183+
borrowsCredential: boolean,
109184
): McpTransport {
110185
if (!raw || typeof raw !== 'object') {
111186
throw new Error(
@@ -120,14 +195,23 @@ function normalizeTransport(
120195
`connector-mcp provider: connector '${connectorName}' stdio transport requires a 'command' string.`,
121196
);
122197
}
198+
const env = isStringRecord(t.env) ? t.env : undefined;
199+
const credential = credentialEnvOf(t, env, connectorName, auth, borrowsCredential);
123200
return {
124201
kind: 'stdio',
125202
command: t.command,
126203
args: Array.isArray(t.args) ? t.args.map((a) => String(a)) : undefined,
127-
env: isStringRecord(t.env) ? t.env : undefined,
204+
env: credential ? { ...env, [credential.name]: credential.value } : env,
128205
};
129206
}
130207
if (t.kind === 'http') {
208+
if (t.credentialEnv !== undefined) {
209+
throw new Error(
210+
`connector-mcp provider: connector '${connectorName}' names providerConfig.transport.credentialEnv on an http ` +
211+
`transport. credentialEnv places the instance's credential in a stdio server's environment; over http the ` +
212+
`provider sends the resolved auth as a request header itself. Delete the key.`,
213+
);
214+
}
131215
if (typeof t.url !== 'string' || t.url.length === 0) {
132216
throw new Error(
133217
`connector-mcp provider: connector '${connectorName}' http transport requires a 'url' string.`,
@@ -281,6 +365,13 @@ async function credentialRejectionStatus(err: unknown): Promise<number | undefin
281365
* directory. The policy check runs first and is unchanged: it judges the
282366
* `command` string exactly as written.
283367
*
368+
* A declarative stdio server's credential stays out of metadata the way an
369+
* http one's does: the entry declares `auth: { type, credentialRef }`, the host
370+
* resolves the reference, and `providerConfig.transport.credentialEnv` names
371+
* the environment variable the provider hands the resolved secret to the child
372+
* in (see {@link credentialEnvOf}). A stdio transport naming no
373+
* `credentialEnv` does not apply `auth` at all.
374+
*
284375
* The connection is opened at materialization. Faults are classified (#3017):
285376
* an invalid transport shape is a *configuration* fault and throws plain —
286377
* fatal at boot per the ADR-0097 fail-loud contract — while a connect /
@@ -342,7 +433,7 @@ async function credentialRejectionStatus(err: unknown): Promise<number | undefin
342433
export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorProviderFactory {
343434
return async (ctx) => {
344435
const cfg = (ctx.providerConfig ?? {}) as McpProviderConfig;
345-
let transport = normalizeTransport(cfg.transport, ctx.name, ctx.auth);
436+
let transport = normalizeTransport(cfg.transport, ctx.name, ctx.auth, ctx.credentialHandle !== undefined);
346437
if (transport.kind === 'stdio') {
347438
assertDeclarativeStdioAllowed(deps.declarativeStdio, transport.command, ctx.name);
348439
// One anchor for the app's relative paths (#22423). A resolver that
@@ -370,15 +461,17 @@ export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorP
370461
}
371462
// ADR-0143 D4 — a grant-bearing instance (`oauth2`, `app-credential`)
372463
// borrows its credential per request. Only an http transport carries one
373-
// (a stdio server takes its credentials in `transport.env`), and a
374-
// request is authenticated one way.
464+
// (a stdio server is handed a static credential once, at spawn, in the
465+
// variable `transport.credentialEnv` names), and a request is
466+
// authenticated one way.
375467
const handle = ctx.credentialHandle;
376468
if (handle) {
377469
if (transport.kind !== 'http') {
378470
throw new Error(
379471
`connector-mcp provider: connector '${ctx.name}' borrows its credential per request (a grant-bearing auth kind), ` +
380-
`which only an http transport can carry: a stdio server takes its credentials in transport.env. Declare an ` +
381-
`http transport, or a static credential for the stdio one.`,
472+
`which only an http transport can carry: a stdio server is handed its credential once, at spawn. Declare an ` +
473+
`http transport, or a static credential for the stdio one (auth: { type: 'bearer', credentialRef }) with ` +
474+
`providerConfig.transport.credentialEnv naming the variable the server reads it from.`,
382475
);
383476
}
384477
const ways = [
@@ -504,7 +597,9 @@ export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorP
504597
}
505598
// [#27237] The handshake carried the instance's credential only over
506599
// http, and only as a header (an api-key `paramName` is not sent): then
507-
// the server's answer was given to that credential.
600+
// the server's answer was given to that credential. A stdio server handed
601+
// the credential in `credentialEnv` is not counted: nothing in a stdio
602+
// handshake says the server used, let alone accepted, its environment.
508603
//
509604
// A grant-bearing instance's borrowed token (`ctx.credentialHandle`)
510605
// reaches the server too, and is deliberately not counted here. A 403 the

0 commit comments

Comments
 (0)