Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/29341-connector-credential-literal.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/lint': patch
---
Expand All @@ -11,6 +11,6 @@
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:

- 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;
- 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.
- 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.

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.
18 changes: 18 additions & 0 deletions .changeset/29434-mcp-stdio-credential-env.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/connector-mcp': minor
'@objectstack/lint': patch
---

Clause-②: yes (widening)
Metadata-protocol: added

`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.

```ts
providerConfig: { transport: { kind: 'stdio', command: 'repo-mcp-server', credentialEnv: 'REPO_MCP_TOKEN' } },
auth: { type: 'bearer', credentialRef: 'REPO_MCP_TOKEN' },
```

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`.

`@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.
55 changes: 50 additions & 5 deletions content/docs/automation/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,10 @@ allowlist — only the listed tools become actions) and **`toolsSnapshot`**
HTTP. Resolved `auth` is folded into the request headers; a grant-bearing
`auth` (`oauth2`, `app-credential`) is asked for on every request instead
(see *Grant-bearing kinds* below).
- `{ kind: 'stdio', command, args?, env? }` — a **local child process**,
policy-gated (below). Credentials for a stdio server ride `transport.env`,
not `auth`.
- `{ kind: 'stdio', command, args?, env?, credentialEnv? }` — a **local child
process**, policy-gated (below). Resolved `auth` is handed to the server in
its environment, under the variable `credentialEnv` names (see
[*A stdio server's credential*](#a-stdio-servers-credential) below).

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

#### A stdio server's credential

A stdio MCP server reads its credential from an environment variable, and the
value stays out of metadata the way an http server's does: declare `auth` with
a `credentialRef`, and name the variable in `transport.credentialEnv`. The
platform resolves the reference like any instance's (the environment, then a
credential set under Setup → Connector credentials, then the host's resolver)
and the provider hands the resolved secret to the child in that variable — the
token of a `bearer`, the key of an `api-key`, the password of a `basic` (whose
username, not a secret, goes in `env` if the server reads it).

{/* os:check */}
```typescript
import { defineConnector } from '@objectstack/spec/integration';

export const RepoToolsConnector = defineConnector({
name: 'repo_tools',
label: 'Repository Tools (MCP, stdio)',
type: 'api',
provider: 'mcp',
providerConfig: {
transport: {
kind: 'stdio',
command: 'repo-mcp-server',
env: { LOG_LEVEL: 'info' },
credentialEnv: 'REPO_MCP_TOKEN',
},
},
auth: { type: 'bearer', credentialRef: 'REPO_MCP_TOKEN' },
});
```

The value is never stored, logged, served on a metadata read (which shows
`credentialRef` and `credentialEnv`, two names) or carried in a message; it
exists only in the environment the server is started with. A reference that
resolves to nothing never starts the server: the instance is degraded naming
the reference, and a credential set later starts it. These fail the boot:
a `credentialEnv` that is not a variable name, one on an `http` transport,
one with no `bearer` / `api-key` / `basic` `auth` to place, and one naming a
variable `env` also sets. A stdio transport that names no `credentialEnv`
does not apply `auth`. A literal credential typed into `env` instead is
reported by the `connector-credential-literal` advisory, which points here.

<Callout type="warn">
**Declarative stdio transports are denied by default.** Materializing one
spawns a local process *from metadata* — which a runtime Studio publish can
Expand Down Expand Up @@ -581,8 +625,9 @@ platform's to fill, so it is left out of the action's input schema, as
`Authorization` is.

An **`mcp`** instance carries a grant-bearing credential only over an `http`
transport (a stdio server takes its credentials in `transport.env`; a handle
on a stdio transport, or beside a static credential, fails the boot). Every
transport (a stdio server is handed a static credential once, at spawn, in
`transport.credentialEnv`; a handle on a stdio transport, or beside a static
credential, fails the boot). Every
HTTP request of its MCP session asks the handle. Each organization a dispatch
runs for opens its own session with its own token, so organizations never
share one. The tools are listed at materialization, outside any run, so the
Expand Down
5 changes: 4 additions & 1 deletion packages/connectors/connector-mcp/src/mcp-connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import type { Connector } from '@objectstack/spec/integration';
* **Credentials live with the MCP server, not in `ConnectorSchema`** (ADR-0024
* §3). The operator supplies `env` (stdio) / `headers` (http) which we pass
* straight to the transport; they are never copied into the serialized `def`
* (which is exposed via discovery) and must never be logged.
* (which is exposed via discovery) and must never be logged. A declarative
* instance's resolved `auth` reaches them through the `mcp` provider factory:
* as a header over http, and over stdio in the variable its
* `transport.credentialEnv` names.
*
* **Trust:** launching a stdio server runs a local process. Sandboxed,
* multi-tenant execution and managed secrets are the enterprise tier (ADR-0024
Expand Down
115 changes: 105 additions & 10 deletions packages/connectors/connector-mcp/src/mcp-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ function isStringRecord(v: unknown): v is Record<string, string> {
* Fold the resolved instance `auth` into an MCP **http** transport's headers
* (ADR-0024 keeps MCP credentials with the transport). `credentialRef` has
* already been resolved upstream, so this only maps the static credential to the
* right header. Not applied to stdio transports — a stdio server receives its
* credentials through `transport.env`.
* right header. Not applied to stdio transports — a stdio server receives the
* instance's credential in its environment, under the variable
* `transport.credentialEnv` names (see {@link credentialEnvOf}).
*/
function applyAuthToHeaders(
auth: ResolvedConnectorAuth | undefined,
Expand All @@ -101,11 +102,85 @@ function applyAuthToHeaders(
}
}

/** Validate + normalize `providerConfig.transport`, injecting resolved auth for http. */
/** An environment variable name as POSIX shells spell one. */
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;

/**
* The secret of a resolved static credential — what a stdio server is handed
* in its environment: a `bearer` token, an `api-key` key, a `basic` password.
* An `api-key`'s `headerName` / `paramName` place the key on an http request
* and are not read here; a `basic` username is not a secret, and a server that
* needs it takes it from `transport.env`.
*/
function secretOf(auth: Exclude<ResolvedConnectorAuth, { type: 'none' }>): string {
switch (auth.type) {
case 'bearer':
return auth.token;
case 'api-key':
return auth.key;
case 'basic':
return auth.password;
}
}

/**
* `transport.credentialEnv` of a stdio transport, judged — the route a stdio
* server's credential takes out of metadata (ADR-0097 §3 at ADR-0024 §3's
* process boundary). The instance's `auth.credentialRef` is resolved by the
* host like any other instance's (the environment, the stored credential, the
* host's resolver; a reference nothing answers degrades the instance naming it
* before this factory runs), and the resolved secret is put into the child's
* environment under the variable `credentialEnv` names. The value is never
* stored, logged, put on the connector `def` or carried in a message: it
* exists only in the environment handed to the spawn.
*
* Refused plain (configuration faults), each naming the key and never a value:
* a `credentialEnv` that is not a variable name; one with no static `auth` to
* place (a grant-bearing kind is refused by the credential-handle check, with
* this route named); one naming a variable `transport.env` also sets, which
* would give that variable two values. `undefined` when the transport names
* none: then `auth` is not applied to the stdio server at all, as before.
*/
function credentialEnvOf(
t: Record<string, unknown>,
env: Record<string, string> | undefined,
connectorName: string,
auth: ResolvedConnectorAuth | undefined,
borrowsCredential: boolean,
): { name: string; value: string } | undefined {
const name = t.credentialEnv;
if (name === undefined) return undefined;
if (typeof name !== 'string' || !ENV_NAME_RE.test(name)) {
throw new Error(
`connector-mcp provider: connector '${connectorName}' providerConfig.transport.credentialEnv must be the name of ` +
`an environment variable (letters, digits and '_', not starting with a digit): the variable the stdio server ` +
`reads the instance's credential from.`,
);
}
if (borrowsCredential) return undefined;
if (!auth || auth.type === 'none') {
throw new Error(
`connector-mcp provider: connector '${connectorName}' names providerConfig.transport.credentialEnv '${name}' but ` +
`declares no credential to place there. Give the entry auth: { type: 'bearer', credentialRef } (or 'api-key' / ` +
`'basic'): the provider passes the resolved credential to the server in '${name}'.`,
);
}
if (env && Object.prototype.hasOwnProperty.call(env, name)) {
throw new Error(
`connector-mcp provider: connector '${connectorName}' sets environment variable '${name}' twice: in ` +
`providerConfig.transport.env and as providerConfig.transport.credentialEnv. Drop the env entry: the provider ` +
`passes the resolved credential to the server in '${name}' itself.`,
);
}
return { name, value: secretOf(auth) };
}

/** Validate + normalize `providerConfig.transport`, injecting resolved auth (http headers, stdio `credentialEnv`). */
function normalizeTransport(
raw: unknown,
connectorName: string,
auth: ResolvedConnectorAuth | undefined,
borrowsCredential: boolean,
): McpTransport {
if (!raw || typeof raw !== 'object') {
throw new Error(
Expand All @@ -120,14 +195,23 @@ function normalizeTransport(
`connector-mcp provider: connector '${connectorName}' stdio transport requires a 'command' string.`,
);
}
const env = isStringRecord(t.env) ? t.env : undefined;
const credential = credentialEnvOf(t, env, connectorName, auth, borrowsCredential);
return {
kind: 'stdio',
command: t.command,
args: Array.isArray(t.args) ? t.args.map((a) => String(a)) : undefined,
env: isStringRecord(t.env) ? t.env : undefined,
env: credential ? { ...env, [credential.name]: credential.value } : env,
};
}
if (t.kind === 'http') {
if (t.credentialEnv !== undefined) {
throw new Error(
`connector-mcp provider: connector '${connectorName}' names providerConfig.transport.credentialEnv on an http ` +
`transport. credentialEnv places the instance's credential in a stdio server's environment; over http the ` +
`provider sends the resolved auth as a request header itself. Delete the key.`,
);
}
if (typeof t.url !== 'string' || t.url.length === 0) {
throw new Error(
`connector-mcp provider: connector '${connectorName}' http transport requires a 'url' string.`,
Expand Down Expand Up @@ -281,6 +365,13 @@ async function credentialRejectionStatus(err: unknown): Promise<number | undefin
* directory. The policy check runs first and is unchanged: it judges the
* `command` string exactly as written.
*
* A declarative stdio server's credential stays out of metadata the way an
* http one's does: the entry declares `auth: { type, credentialRef }`, the host
* resolves the reference, and `providerConfig.transport.credentialEnv` names
* the environment variable the provider hands the resolved secret to the child
* in (see {@link credentialEnvOf}). A stdio transport naming no
* `credentialEnv` does not apply `auth` at all.
*
* The connection is opened at materialization. Faults are classified (#3017):
* an invalid transport shape is a *configuration* fault and throws plain —
* fatal at boot per the ADR-0097 fail-loud contract — while a connect /
Expand Down Expand Up @@ -342,7 +433,7 @@ async function credentialRejectionStatus(err: unknown): Promise<number | undefin
export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorProviderFactory {
return async (ctx) => {
const cfg = (ctx.providerConfig ?? {}) as McpProviderConfig;
let transport = normalizeTransport(cfg.transport, ctx.name, ctx.auth);
let transport = normalizeTransport(cfg.transport, ctx.name, ctx.auth, ctx.credentialHandle !== undefined);
if (transport.kind === 'stdio') {
assertDeclarativeStdioAllowed(deps.declarativeStdio, transport.command, ctx.name);
// One anchor for the app's relative paths (#22423). A resolver that
Expand Down Expand Up @@ -370,15 +461,17 @@ export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorP
}
// ADR-0143 D4 — a grant-bearing instance (`oauth2`, `app-credential`)
// borrows its credential per request. Only an http transport carries one
// (a stdio server takes its credentials in `transport.env`), and a
// request is authenticated one way.
// (a stdio server is handed a static credential once, at spawn, in the
// variable `transport.credentialEnv` names), and a request is
// authenticated one way.
const handle = ctx.credentialHandle;
if (handle) {
if (transport.kind !== 'http') {
throw new Error(
`connector-mcp provider: connector '${ctx.name}' borrows its credential per request (a grant-bearing auth kind), ` +
`which only an http transport can carry: a stdio server takes its credentials in transport.env. Declare an ` +
`http transport, or a static credential for the stdio one.`,
`which only an http transport can carry: a stdio server is handed its credential once, at spawn. Declare an ` +
`http transport, or a static credential for the stdio one (auth: { type: 'bearer', credentialRef }) with ` +
`providerConfig.transport.credentialEnv naming the variable the server reads it from.`,
);
}
const ways = [
Expand Down Expand Up @@ -504,7 +597,9 @@ export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorP
}
// [#27237] The handshake carried the instance's credential only over
// http, and only as a header (an api-key `paramName` is not sent): then
// the server's answer was given to that credential.
// the server's answer was given to that credential. A stdio server handed
// the credential in `credentialEnv` is not counted: nothing in a stdio
// handshake says the server used, let alone accepted, its environment.
//
// A grant-bearing instance's borrowed token (`ctx.credentialHandle`)
// reaches the server too, and is deliberately not counted here. A 403 the
Expand Down
Loading
Loading