Skip to content

Commit 71b42ef

Browse files
author
David Ruzicka
committed
feat(upstream-auth): inherit auth format from interceptors.auth when upstream_mcp.auth omitted
When upstream_mcp.auth is absent, the gateway now derives the outbound auth format from interceptors.auth using the same priority-based selection as outbound OpenAPI calls (sort by priority, first non-oauth/session-cookie entry). Only bearer/query/custom-header types are inherited. On HTTP transport the downstream client's session token is always the credential; the inherited config only controls the format (Bearer header, custom header, or query param). On stdio, value_from_env from the effective auth (upstream_mcp.auth or inherited from interceptors.auth) is used as the service-account credential. The security gate (reject anonymous HTTP sessions when auth is configured) now triggers on any effective auth — direct or inherited. This eliminates the need to duplicate auth configuration when the upstream MCP server accepts the same format as inbound clients (the common case). Agent authored
1 parent 00672f8 commit 71b42ef

2 files changed

Lines changed: 86 additions & 46 deletions

File tree

docs/PROFILE-GUIDE.md

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -104,52 +104,63 @@ When `enterprise_authorization.mode` is `required`, HTTP initialization accepts
104104

105105
- `transport.type` must be `"http-streamable"`
106106
- `transport.url` must be an absolute `http` or `https` URL without inline credentials
107-
- `auth.type` may be `bearer`, `query`, or `custom-header`
108-
- `auth.value_from_env` names the env variable holding the credential — **stdio transport only**. On HTTP transport the downstream client's session Bearer token is always forwarded directly to the upstream; `value_from_env` is never read and may be omitted. Inline secrets are not supported for any auth type.
107+
- `auth` is **optional**. When omitted, the auth format is inherited from `interceptors.auth` (see below).
108+
- `auth.type` may be `bearer`, `query`, or `custom-header`. Set explicitly only when the upstream expects a different format than inbound clients use.
109+
- `auth.value_from_env` names the env variable holding the credential — **stdio transport only**. On HTTP transport the downstream client's session token is always forwarded directly; `value_from_env` is never read.
109110
- `upstream_mcp_from_env` must point to a single JSON object and takes precedence over static `upstream_mcp`
110111
- `stdio` upstream definitions are intentionally rejected in this iteration so the later feature-gated implementation can add process lifecycle hardening separately
111112

112-
Example:
113+
#### Auth inheritance from `interceptors.auth`
114+
115+
When `upstream_mcp.auth` is omitted, the gateway inherits the auth format from `interceptors.auth` using the same priority-based selection as outbound OpenAPI calls. Only `bearer`, `query`, and `custom-header` types are inherited — `oauth` and `session-cookie` are not forwarded.
116+
117+
**Common case — client Bearer token forwarded as Bearer to upstream (zero config):**
113118

114119
```json
115120
{
116-
"upstream_mcp_from_env": "MCP4_UPSTREAM_MCP_JSON",
121+
"interceptors": {
122+
"auth": { "type": "bearer", "value_from_env": "MY_API_TOKEN" }
123+
},
117124
"upstream_mcp": {
118125
"name": "remote-mcp",
119-
"transport": {
120-
"type": "http-streamable",
121-
"url": "https://remote-mcp.example/mcp"
122-
},
123-
"auth": {
124-
"type": "bearer",
125-
"value_from_env": "REMOTE_MCP_TOKEN"
126-
},
127-
"tool_prefix": "remote",
128-
"tools": {
129-
"allow": ["github_*"],
130-
"deny": ["admin_*"]
131-
},
132-
"timeout_ms": 30000
126+
"transport": { "type": "http-streamable", "url": "https://remote-mcp.example/mcp" }
133127
}
134128
}
135129
```
136130

137-
For HTTP session-passthrough deployments (client's own token forwarded upstream), omit `value_from_env`:
131+
The client's `Authorization: Bearer <token>` is extracted from the inbound request and forwarded as-is to the upstream. If the inbound request carries no token, the upstream connection is refused. On stdio, `value_from_env` from `interceptors.auth` is used as the service-account credential.
132+
133+
**Override — upstream expects a different format than inbound clients:**
138134

139135
```json
140136
{
137+
"interceptors": {
138+
"auth": { "type": "custom-header", "header_name": "X-Client-Key", "value_from_env": "CLIENT_KEY" }
139+
},
141140
"upstream_mcp": {
142141
"name": "remote-mcp",
143-
"transport": {
144-
"type": "http-streamable",
145-
"url": "https://remote-mcp.example/mcp"
146-
},
142+
"transport": { "type": "http-streamable", "url": "https://remote-mcp.example/mcp" },
147143
"auth": { "type": "bearer" }
148144
}
149145
}
150146
```
151147

152-
The client's `Authorization: Bearer <token>` sent to the gateway is forwarded as-is to the upstream. If the inbound request carries no token, the upstream connection is refused.
148+
Clients authenticate with `X-Client-Key`; gateway forwards to upstream as `Authorization: Bearer`.
149+
150+
**Explicit `value_from_env` on `upstream_mcp.auth` (stdio only):**
151+
152+
```json
153+
{
154+
"upstream_mcp": {
155+
"name": "remote-mcp",
156+
"transport": { "type": "http-streamable", "url": "https://remote-mcp.example/mcp" },
157+
"auth": { "type": "bearer", "value_from_env": "UPSTREAM_TOKEN" },
158+
"tool_prefix": "remote",
159+
"tools": { "allow": ["github_*"], "deny": ["admin_*"] },
160+
"timeout_ms": 30000
161+
}
162+
}
163+
```
153164

154165
**Profile selection**: If you set `profile_id` (or `profile_aliases`) and `openapi_spec_path`, you can launch the server with `--profile <id>` or `MCP4_PROFILE=<id>` without setting `--openapi-spec-path` or `MCP4_OPENAPI_SPEC_PATH`.
155166

src/mcp/mcp-server.ts

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import { OAUTH_RATE_LIMIT } from '../core/constants.js';
5050
import { HttpClient } from '../transport/interceptors.js';
5151
import { HttpClientFactory } from '../transport/http-client-factory.js';
5252
import { SchemaValidator } from '../validation/schema-validator.js';
53-
import type { Profile, ToolDefinition, AuthInterceptor, OAuthConfig, ProxyDownloadOperation, UpstreamMcpServerConfig } from '../types/profile.js';
53+
import type { Profile, ToolDefinition, AuthInterceptor, OAuthConfig, ProxyDownloadOperation, UpstreamMcpServerConfig, UpstreamMcpAuthConfig } from '../types/profile.js';
5454
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
5555
import { sanitizeToolList, isValidUpstreamToolName, applyProviderToolPolicy, isToolAllowedByProviderPolicy } from '../upstream/upstream-tool-sanitizer.js';
5656
import { UpstreamConnectionManager } from '../upstream/upstream-connection-manager.js';
@@ -1834,37 +1834,62 @@ export class MCPServer {
18341834
return this.profile?.upstream_mcp;
18351835
}
18361836

1837+
/**
1838+
* Resolve the effective upstream auth config for a provider.
1839+
* If upstream_mcp.auth is explicitly set, use it as-is.
1840+
* Otherwise inherit from interceptors.auth using the same selection logic as
1841+
* AuthStrategyRegistry (priority sort, first non-oauth/session-cookie entry).
1842+
* Only bearer/query/custom-header types are inherited — oauth and session-cookie
1843+
* cannot be meaningfully forwarded to an upstream MCP server.
1844+
*/
1845+
private getEffectiveUpstreamAuth(provider: UpstreamMcpServerConfig): UpstreamMcpAuthConfig | undefined {
1846+
if (provider.auth) return provider.auth;
1847+
const raw = this.profile?.interceptors?.auth;
1848+
if (!raw) return undefined;
1849+
const configs = Array.isArray(raw) ? raw : [raw];
1850+
const sorted = [...configs].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
1851+
const selected = sorted.find(c => !['oauth', 'session-cookie'].includes(c.type)) ?? sorted[0];
1852+
if (!selected || !['bearer', 'query', 'custom-header'].includes(selected.type)) return undefined;
1853+
return {
1854+
type: selected.type as UpstreamMcpAuthConfig['type'],
1855+
header_name: selected.header_name,
1856+
query_param: selected.query_param,
1857+
value_from_env: selected.value_from_env,
1858+
};
1859+
}
1860+
18371861
/**
18381862
* Extract the auth token to use for upstream MCP calls.
1839-
* Downstream client token takes precedence; value_from_env acts as local fallback
1840-
* only for non-HTTP contexts (stdio) where there is no session concept.
1863+
* On HTTP transport the downstream client's session token is always forwarded directly.
1864+
* On stdio, value_from_env from the effective auth config (upstream_mcp.auth or inherited
1865+
* from interceptors.auth) is used as the service-account credential.
18411866
*
1842-
* Security invariant: for HTTP transport, value_from_env (server-held upstream credential)
1843-
* is NEVER used — an HTTP session with no verified client token is an anonymous session
1844-
* (e.g. allowed by hasServerEnvAuthToken on the inbound side) and must not receive
1845-
* privileged upstream access. This closes the open-proxy escalation path regardless of
1846-
* whether inbound auth is configured.
1867+
* Security invariant: if any auth is configured (directly or inherited), an HTTP session
1868+
* with no verified client token is rejected — prevents anonymous clients from reaching
1869+
* privileged upstream resources.
18471870
*/
1848-
private getUpstreamToken(sessionId: string | undefined, profileId: string | undefined, provider: UpstreamMcpServerConfig): string | undefined {
1871+
private getUpstreamToken(
1872+
sessionId: string | undefined,
1873+
profileId: string | undefined,
1874+
provider: UpstreamMcpServerConfig,
1875+
effectiveAuth: UpstreamMcpAuthConfig | undefined,
1876+
): string | undefined {
18491877
if (this.httpTransport && sessionId && profileId) {
18501878
const sessionToken = this.httpTransport.getSessionToken(profileId, sessionId);
18511879
if (sessionToken) return sessionToken;
1852-
// HTTP session carries no verified client token — refuse to connect upstream
1853-
// on behalf of an anonymous caller. value_from_env is stdio-only and is never
1854-
// read on HTTP transport; its presence here signals an explicit auth requirement.
1855-
if (provider.auth?.value_from_env) {
1880+
// Reject anonymous HTTP sessions when auth is configured (directly or inherited)
1881+
if (effectiveAuth) {
18561882
throw new UpstreamConnectionError(
18571883
'upstream_mcp proxy requires an authenticated HTTP session — ' +
1858-
'the inbound client must supply a Bearer token ' +
1859-
'(value_from_env has no effect on HTTP transport).',
1884+
'the inbound client must supply a verified token.',
18601885
provider.name,
18611886
);
18621887
}
18631888
return undefined;
18641889
}
1865-
// Non-HTTP path (stdio): no session concept; value_from_env allowed for service-account use.
1866-
if (provider.auth?.value_from_env) {
1867-
return process.env[provider.auth.value_from_env];
1890+
// Non-HTTP path (stdio): use value_from_env from effective auth (may come from interceptors.auth)
1891+
if (effectiveAuth?.value_from_env) {
1892+
return process.env[effectiveAuth.value_from_env];
18681893
}
18691894
return undefined;
18701895
}
@@ -1892,8 +1917,10 @@ export class MCPServer {
18921917
});
18931918
}
18941919
try {
1895-
const token = this.getUpstreamToken(sessionId, profileId, provider);
1896-
const client = await this.getUpstreamClientFn!(sessionId, provider, token);
1920+
const effectiveAuth = this.getEffectiveUpstreamAuth(provider);
1921+
const effectiveProvider = effectiveAuth !== provider.auth ? { ...provider, auth: effectiveAuth } : provider;
1922+
const token = this.getUpstreamToken(sessionId, profileId, provider, effectiveAuth);
1923+
const client = await this.getUpstreamClientFn!(sessionId, effectiveProvider, token);
18971924
const result = await client.listTools();
18981925
if (!result || typeof result !== 'object') {
18991926
throw new UpstreamMalformedResponseError(
@@ -1986,8 +2013,10 @@ export class MCPServer {
19862013
}
19872014

19882015
try {
1989-
const token = this.getUpstreamToken(sessionId, profileId, provider);
1990-
const client = await this.getUpstreamClientFn!(sessionId, provider, token);
2016+
const effectiveAuth = this.getEffectiveUpstreamAuth(provider);
2017+
const effectiveProvider = effectiveAuth !== provider.auth ? { ...provider, auth: effectiveAuth } : provider;
2018+
const token = this.getUpstreamToken(sessionId, profileId, provider, effectiveAuth);
2019+
const client = await this.getUpstreamClientFn!(sessionId, effectiveProvider, token);
19912020
const result = await (provider.timeout_ms !== undefined
19922021
? client.callTool({ name: toolName, arguments: args }, undefined, { timeout: provider.timeout_ms })
19932022
: client.callTool({ name: toolName, arguments: args }));

0 commit comments

Comments
 (0)