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
16 changes: 16 additions & 0 deletions .changeset/26702-connector-mcp-oauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/connector-mcp': minor
'@objectstack/service-automation': minor
---

feat(connector-mcp): an OAuth-protected MCP server is connected through the MCP authorization flow and dispatched with each organization's token (ADR-0143 S2, part of #26702)

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

ADR-0143 lets a `provider: 'mcp'` instance over an http transport declare `auth: { type: 'oauth2', grant: 'authorization_code' }` and leave its endpoints, and its client, to discovery. Until now such an instance was held degraded, and the `mcp` provider did not ask the credential handle. Now:

- **The connect runs the MCP authorization flow** (`@objectstack/service-automation`). When an administrator begins a connect, the server reads the MCP server's protected-resource metadata (RFC 9728), then its authorization server's metadata (RFC 8414, or OpenID Connect discovery). The `issuer` must be the one the server named, and `S256` must be listed. A declared `clientId` is used as is (the pre-registered form). Otherwise a public client is registered (RFC 7591) for the deployment's redirect URI. Every authorization and token request carries the server's resource indicator (RFC 8707), PKCE stays on, and a callback that names another issuer is refused before its code is used (RFC 9207). Every URL on the way must be https (plain http only to a loopback host). No request follows a redirect.
- **The connection keeps its client.** `sys_connector_connection` gains `registered_client`, an encrypted, masked field holding the discovered endpoints, the client id, and any client secret the registration issued (a declared client's secret stays behind its `credentialRef`). Refreshes use it, and a reconnect reuses it instead of registering again.
- **The `mcp` provider asks the credential handle** (`@objectstack/connector-mcp`). The plugin registers it with `{ credentialHandle: true }`. Over http, every request of the MCP session goes through a fetch that asks the handle. A `401` refreshes once and is sent once more; a second `401` fails the call with the `503` naming the instance. Each organization a dispatch runs for gets its own MCP session and token. The tools are listed at materialization, outside any run, with the connection connected earliest (`connected_at`, ties by organization id), so an instance several organizations connected still lists; when that connection is revoked, refused or connected again, the instance materializes again and lists with the next one. A dispatch whose run names no organization is answered as before: from the only connection, or refused with `422` when there are several. A handle on a stdio transport, or beside a static credential, is refused.
- An `oauth2` instance on `mcp` whose transport URL is not https is held degraded, and a `client_credentials` one still needs its declared endpoints.
57 changes: 46 additions & 11 deletions content/docs/automation/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,9 @@ Config: **`transport`** (required) and **`include`** (optional tool-name
allowlist — only the listed tools become actions):

- `{ kind: 'http', url, headers? }` — a remote MCP server over streamable
HTTP. Resolved `auth` is folded into the request headers.
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`.
Expand Down Expand Up @@ -471,15 +473,29 @@ What the publish door checks, each refusal located at its key under `auth`:
and a required `credentialRef`. An inline `appSecret` is refused by name.

**How the server serves them.** Both kinds are served through a
**credential handle** the provider asks on every request. The built-in `rest`
and `openapi` providers ask it; a provider says it does by registering with
**credential handle** the provider asks on every request. The built-in `rest`,
`openapi` and `mcp` providers ask it; a provider says it does by registering with
`registerConnectorProvider(…, { credentialHandle: true })`. For an `openapi`
instance the document's `security` decides only where the token is *not*
sent: an operation whose effective `security` is `[]` is anonymous and never
carries it, and a header or query parameter an `apiKey` scheme names is the
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
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
listing names no organization: on an `authorization_code` instance it uses
the connection connected **earliest** (`connected_at`, ties by organization
id), so an instance several organizations connected still lists. When that
connection is revoked, refused or connected again, the instance materializes
again and lists with the next one. A dispatch whose run names no organization
is answered as below: from the only connection, refused when there are
several.

An **`app-credential`** instance runs its vendor's exchange from the `appId`
and the app secret its `credentialRef` names. The exchange is fixed per
`vendor`, and nothing about it is authored:
Expand Down Expand Up @@ -538,7 +554,24 @@ a rotating refresh token is never spent twice.
edited or moved there opens as nothing and its code is never exchanged.
Write access to the cluster KV stays inside the platform's trust boundary,
as it is for the cluster lock: such a writer can still delete a waiting
connect. The redirect URI is the deployment's
connect. On an `mcp` instance over `http` that leaves its endpoints or its
client to discovery, the connect runs the **MCP authorization flow**: the
server reads the MCP server's protected-resource metadata (RFC 9728), then
its authorization server's metadata (RFC 8414, or OpenID Connect
discovery). That metadata's `issuer` must be the one the MCP server named,
and `S256` must be listed. A declared `clientId` is used as is; otherwise a
public client is registered (RFC 7591) for the deployment's redirect URI, and
an authorization server with no registration endpoint is refused, naming the
`clientId` to declare. Every authorization and token request carries the MCP
server's resource indicator (RFC 8707), and a callback that names another
issuer is refused before its code is exchanged (RFC 9207). Every URL on the
way must be `https://` (plain `http://` only to a loopback host), and no
request follows a redirect. An MCP or authorization server that does not
answer (or answers `429` / `5xx`) refuses the connect with `503`; metadata
that is missing or fails a check above, with `400`. The discovered endpoints and client, including any
client secret the registration issued, are kept encrypted in the
connection's `registered_client`; refreshes use them, and a reconnect reuses
them instead of registering again. The redirect URI is the deployment's
(`OS_BASE_URL` or `OS_AUTH_URL` plus `/api/v1/automation/connectors/oauth/callback`,
or the automation plugin's `connectorOAuthRedirectUri`), never the
request's; register that URI with the vendor. The refresh token is kept
Expand All @@ -553,17 +586,19 @@ a rotating refresh token is never spent twice.
stale one never overwrites a newer connect on any replica. A revoke deletes
the stored connection only: the server does not revoke the refresh token at
the vendor (RFC 7009), because no revocation endpoint is known for the
instance yet — the auth declares none, and the discovery that would supply
one is ADR-0143 S2. The refresh token stays valid at the vendor until it
instance — the auth declares none, and the MCP authorization flow does not
read one. The refresh token stays valid at the vendor until it
expires or is revoked in the vendor's console; revoke it there too when the
disconnect is a response to a leak.

What is not served yet is held **degraded** with a reason that names it, and
never called without its credential: an `oauth2` or `app-credential`
instance on a provider that does not ask the credential handle (the built-in
`mcp` provider does not yet, nor does a plugin's provider registered without
`{ credentialHandle: true }`), an `mcp` instance that left its endpoints to
discovery, and an `oauth2`
instance on a provider that does not ask the credential handle (a plugin's
provider registered without `{ credentialHandle: true }`), an `oauth2`
instance without its endpoints or client that the MCP authorization flow
cannot discover them for (only an `authorization_code` instance on
`provider: 'mcp'` over an `http` transport does), an `mcp` instance left to
discovery whose transport `url` is not `https://`, and an `oauth2`
instance whose `tokenUrl` or `authorizationUrl` is not `https://` (plain
`http://` is accepted only to `localhost`, a `127.0.0.0/8` address or
`[::1]` — not a `*.localhost` name, which the system resolver sends to DNS —
Expand Down Expand Up @@ -696,7 +731,7 @@ retry):
|:---|:---|
| `provider` names no installed factory | **Hard boot error** naming the entry, the provider, and the plugin that supplies it — add the executor to `plugins:` |
| Invalid `providerConfig` (missing `baseUrl` / `spec` / `transport`, wrong shape) | **Hard boot error** from the provider factory |
| `auth` is a grant-bearing kind (`oauth2`, `app-credential`) on a provider that does not ask the credential handle (the built-in `mcp` provider, for now) | **Not fatal**: the instance registers **degraded**, naming the kind, and the upstream is never called without its credential; a datasource borrowing it is refused with `422` |
| `auth` is a grant-bearing kind (`oauth2`, `app-credential`) on a provider that does not ask the credential handle (a plugin's provider registered without `{ credentialHandle: true }`) | **Not fatal**: the instance registers **degraded**, naming the kind, and the upstream is never called without its credential; a datasource borrowing it is refused with `422` |
| An `authorization_code` instance nobody connected, or whose grant the vendor refused | **Not fatal**: the instance registers **degraded**, "not connected" or "reconnect required" — connect it (`POST …/connectors/:name/connection`), and it materializes without a restart |
| The token endpoint is unreachable or answers `5xx` / `429` | **Not fatal**: the dispatch fails `CONNECTOR_UPSTREAM_UNAVAILABLE` and is retried on the existing backoff; the connection stays connected |
| `auth` is `signed` on a provider that does not say it applies the request signer | **Hard boot error** naming the instance, the scheme and the provider: its calls would go out unsigned — declare it through `rest` or `openapi` |
Expand Down
3 changes: 2 additions & 1 deletion packages/connectors/connector-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.1",
"@objectstack/core": "workspace:*",
"@objectstack/spec": "workspace:*"
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*"
},
"devDependencies": {
"@objectstack/service-automation": "workspace:*",
Expand Down
15 changes: 14 additions & 1 deletion packages/connectors/connector-mcp/src/connector-mcp-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,18 @@ export interface ConnectorRegistrySurface {
>,
): void;
unregisterConnector(name: string): void;
registerConnectorProvider(providerKey: string, factory: ConnectorProviderFactory): void;
/**
* `options.credentialHandle: true` says the factory asks
* `ConnectorProviderContext.credentialHandle` on every request, so the host
* serves grant-bearing instances (`oauth2`, `app-credential`) through it
* (ADR-0143 D4). An older host ignores the third argument and keeps
* holding those instances degraded.
*/
registerConnectorProvider(
providerKey: string,
factory: ConnectorProviderFactory,
options?: { readonly credentialHandle?: boolean },
): void;
}

/**
Expand Down Expand Up @@ -89,6 +100,8 @@ export class ConnectorMcpPlugin implements Plugin {
clientFactory: this.options.clientFactory,
declarativeStdio: this.options.declarativeStdio,
}),
// ADR-0143 D4 — an http transport asks the credential handle per request.
{ credentialHandle: true },
);
ctx.logger.info("ConnectorMcpPlugin: registered 'mcp' connector provider");
}
Expand Down
11 changes: 7 additions & 4 deletions packages/connectors/connector-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@
* doubles as an AI tool under ADR-0011.
*
* Open-source scope: the MCP client adapter (stdio + http transports),
* `tools/list` → actions, `tools/call` dispatch, and operator-supplied static
* credentials passed through the transport. A curated server registry, managed
* secrets, per-tenant lifecycle, and sandboxed stdio execution are the
* enterprise tier (ADR-0024 §4).
* `tools/list` → actions, `tools/call` dispatch, operator-supplied static
* credentials passed through the transport, and — over http — an OAuth-protected
* server's per-dispatch token through the host's credential handle, one MCP
* session per organization (ADR-0143 D4; the host runs the MCP authorization
* flow when an administrator connects the instance). A curated server
* registry, sandboxed stdio execution and the rest of ADR-0024 §4 stay the
* enterprise tier.
*/

export {
Expand Down
29 changes: 24 additions & 5 deletions packages/connectors/connector-mcp/src/mcp-connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ export type McpTransport =
url: string;
/** Headers sent on every request — carries credentials (e.g. a bearer token). */
headers?: Record<string, string>;
/**
* The fetch every request of the transport goes through, when the
* credential is runtime state rather than a header: the `mcp`
* provider's credential handle (ADR-0143 D4) applies the token
* per request through it. Default the global `fetch`.
*/
fetch?: typeof fetch;
};

/** A tool as advertised by an MCP server's `tools/list`. */
Expand Down Expand Up @@ -111,6 +118,14 @@ export interface McpConnectorOptions {
* configured transport and returns a connected {@link McpClientLike}.
*/
clientFactory?: (transport: McpTransport, clientInfo: { name: string; version: string }) => Promise<McpClientLike>;
/**
* The client a tool call goes to, chosen per call from the dispatch's
* context; `undefined` (or no member) sends it through the client the
* tools were listed with. The `mcp` provider answers one client per
* organization when the instance's credential is a per-organization
* connection (ADR-0143 D4, Q1).
*/
clientFor?: (ctx: unknown) => McpClientLike | undefined | Promise<McpClientLike | undefined>;
}

/**
Expand All @@ -127,7 +142,7 @@ export interface McpConnectorBundle {
close(): Promise<void>;
}

const DEFAULT_CLIENT_INFO = { name: 'objectstack-connector-mcp', version: '1.0.0' } as const;
export const DEFAULT_MCP_CLIENT_INFO = { name: 'objectstack-connector-mcp', version: '1.0.0' } as const;

/** Slugify a label into a valid connector `name` (`/^[a-z_][a-z0-9_]*$/`). */
function slugify(input: string): string {
Expand Down Expand Up @@ -187,7 +202,7 @@ export const MCP_TOOLS_LIST_MAX_PAGES = 100;
* The default {@link McpClientLike} — lazily imports the official MCP SDK so it
* is only loaded when a real connection is made (tests inject their own client).
*/
async function defaultClientFactory(
export async function defaultMcpClientFactory(
transport: McpTransport,
clientInfo: { name: string; version: string },
): Promise<McpClientLike> {
Expand All @@ -212,6 +227,7 @@ async function defaultClientFactory(
await client.connect(
new StreamableHTTPClientTransport(new URL(transport.url), {
requestInit: transport.headers ? { headers: transport.headers } : undefined,
...(transport.fetch ? { fetch: transport.fetch } : {}),
}),
);
}
Expand Down Expand Up @@ -250,8 +266,8 @@ async function defaultClientFactory(
* tear it down.
*/
export async function createMcpConnector(opts: McpConnectorOptions): Promise<McpConnectorBundle> {
const clientInfo = opts.clientInfo ?? DEFAULT_CLIENT_INFO;
const factory = opts.clientFactory ?? defaultClientFactory;
const clientInfo = opts.clientInfo ?? DEFAULT_MCP_CLIENT_INFO;
const factory = opts.clientFactory ?? defaultMcpClientFactory;

const client = await factory(opts.transport, clientInfo);

Expand Down Expand Up @@ -306,7 +322,10 @@ export async function createMcpConnector(opts: McpConnectorOptions): Promise<Mcp
};

for (const tool of selected) {
handlers[tool.name] = async (input) => normalizeResult(await client.callTool(tool.name, input));
handlers[tool.name] = async (input, ctx) => {
const target = (opts.clientFor ? await opts.clientFor(ctx) : undefined) ?? client;
return normalizeResult(await target.callTool(tool.name, input));
};
}

return {
Expand Down
Loading
Loading