Skip to content

Commit 511e042

Browse files
author
Růžička, David
committed
Merge branch 'dr-upstream-auth-value-from-env-optional' into 'main'
fix(upstream-auth): inherit interceptors.auth format + fix OAuth-only gate bypass See merge request ai-adoption/mcp/mcp4openapi!13
2 parents 2aadcdb + f44c4b0 commit 511e042

9 files changed

Lines changed: 304 additions & 42 deletions

docs/PROFILE-GUIDE.md

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -104,31 +104,59 @@ 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 that holds the credential (token, header value, or query param value); inline secrets are not supported for any auth type. The downstream client token always takes precedence - `value_from_env` is used only as a local fallback when the client sends no token (e.g. server-side deployments sharing a fixed env secret)
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-
},
126+
"transport": { "type": "http-streamable", "url": "https://remote-mcp.example/mcp" }
127+
}
128+
}
129+
```
130+
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:**
134+
135+
```json
136+
{
137+
"interceptors": {
138+
"auth": { "type": "custom-header", "header_name": "X-Client-Key", "value_from_env": "CLIENT_KEY" }
139+
},
140+
"upstream_mcp": {
141+
"name": "remote-mcp",
142+
"transport": { "type": "http-streamable", "url": "https://remote-mcp.example/mcp" },
143+
"auth": { "type": "bearer" }
144+
}
145+
}
146+
```
147+
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" },
127158
"tool_prefix": "remote",
128-
"tools": {
129-
"allow": ["github_*"],
130-
"deny": ["admin_*"]
131-
},
159+
"tools": { "allow": ["github_*"], "deny": ["admin_*"] },
132160
"timeout_ms": 30000
133161
}
134162
}

src/generated-schemas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const authTokenConfigSchema = z.object({
3535
});
3636

3737
export const upstreamMcpAuthConfigSchema = authTokenConfigSchema.extend({
38-
value_from_env: z.string()
38+
value_from_env: z.string().optional()
3939
});
4040

4141
export const resourceFetchDefinitionSchema = z.object({

src/mcp/mcp-server.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4837,6 +4837,106 @@ paths:
48374837
});
48384838
});
48394839

4840+
// -------------------------------------------------------------------------
4841+
describe('getEffectiveUpstreamAuth', () => {
4842+
it('skips oauth and returns bearer when interceptors.auth = [oauth, bearer]', async () => {
4843+
const provider = { ...upstreamProvider }; // no provider.auth
4844+
(upstreamServer as any).profile.interceptors = {
4845+
auth: [
4846+
{ type: 'oauth' },
4847+
{ type: 'bearer', value_from_env: 'BEARER_TOKEN' },
4848+
],
4849+
};
4850+
(upstreamServer as any).httpTransport = {
4851+
...(upstreamServer as any).httpTransport,
4852+
getUpstreamMcpConfig: () => provider,
4853+
getSessionToken: () => 'client-token',
4854+
};
4855+
try {
4856+
await (upstreamServer as any).handleOtherRequest(
4857+
{ jsonrpc: '2.0', id: '1', method: 'tools/list', params: {} },
4858+
'session-123',
4859+
'upstream-profile',
4860+
);
4861+
// effectiveAuth should be bearer; effectiveProvider passed to client should have auth.type === 'bearer'
4862+
expect(mockGetUpstreamClient).toHaveBeenCalledWith(
4863+
'session-123',
4864+
expect.objectContaining({ auth: expect.objectContaining({ type: 'bearer' }) }),
4865+
'client-token',
4866+
);
4867+
} finally {
4868+
delete (upstreamServer as any).profile.interceptors;
4869+
}
4870+
});
4871+
4872+
it('returns undefined when interceptors.auth = [session-cookie] only', () => {
4873+
const server = new MCPServer();
4874+
(server as any).profile = {
4875+
profile_name: 'p',
4876+
tools: [],
4877+
interceptors: { auth: [{ type: 'session-cookie' }] },
4878+
};
4879+
const result = (server as any).getEffectiveUpstreamAuth({ name: 'x', transport: { type: 'http-streamable', url: 'https://example.com' } });
4880+
expect(result).toBeUndefined();
4881+
});
4882+
4883+
it('priority sort: lower priority value wins — query(priority:1) before bearer(priority:5)', () => {
4884+
const server = new MCPServer();
4885+
(server as any).profile = {
4886+
profile_name: 'p',
4887+
tools: [],
4888+
interceptors: {
4889+
auth: [
4890+
{ type: 'bearer', value_from_env: 'T', priority: 5 },
4891+
{ type: 'query', value_from_env: 'T', query_param: 'token', priority: 1 },
4892+
],
4893+
},
4894+
};
4895+
const result = (server as any).getEffectiveUpstreamAuth({ name: 'x', transport: { type: 'http-streamable', url: 'https://example.com' } });
4896+
expect(result?.type).toBe('query');
4897+
});
4898+
4899+
it('uses provider.auth directly and ignores interceptors.auth when provider.auth set', () => {
4900+
const server = new MCPServer();
4901+
(server as any).profile = {
4902+
profile_name: 'p',
4903+
tools: [],
4904+
interceptors: { auth: [{ type: 'bearer', value_from_env: 'INTERCEPTOR_TOKEN' }] },
4905+
};
4906+
const providerAuth = { type: 'bearer' as const, value_from_env: 'PROVIDER_TOKEN' };
4907+
const result = (server as any).getEffectiveUpstreamAuth({
4908+
name: 'x',
4909+
transport: { type: 'http-streamable', url: 'https://example.com' },
4910+
auth: providerAuth,
4911+
});
4912+
expect(result).toBe(providerAuth);
4913+
});
4914+
4915+
it('blocks anonymous HTTP session when interceptors.auth contains only oauth (gate bypass fix)', async () => {
4916+
const provider = { ...upstreamProvider }; // no provider.auth
4917+
(upstreamServer as any).profile.interceptors = {
4918+
auth: [{ type: 'oauth' }],
4919+
};
4920+
(upstreamServer as any).httpTransport = {
4921+
...(upstreamServer as any).httpTransport,
4922+
getUpstreamMcpConfig: () => provider,
4923+
getSessionToken: () => undefined, // anonymous HTTP session
4924+
};
4925+
try {
4926+
const response = await (upstreamServer as any).handleOtherRequest(
4927+
{ jsonrpc: '2.0', id: '1', method: 'tools/list', params: {} },
4928+
'session-123',
4929+
'upstream-profile',
4930+
) as any;
4931+
expect(mockGetUpstreamClient).not.toHaveBeenCalled();
4932+
expect(response.error).toBeDefined();
4933+
expect(response.error.code).toBe(-32603);
4934+
} finally {
4935+
delete (upstreamServer as any).profile.interceptors;
4936+
}
4937+
});
4938+
});
4939+
48404940
// -------------------------------------------------------------------------
48414941
describe('tool_prefix warning', () => {
48424942
it('emits a warning when tool_prefix is configured', async () => {

src/mcp/mcp-server.ts

Lines changed: 58 additions & 21 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,35 +1834,68 @@ 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 => ['bearer', 'query', 'custom-header'].includes(c.type));
1852+
if (!selected) 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 forward server-held
1853-
// upstream credentials to an anonymous caller.
1854-
if (provider.auth?.value_from_env) {
1880+
// Reject anonymous HTTP sessions when ANY auth is configured (directly or inherited).
1881+
// effectiveAuth is undefined for oauth/session-cookie-only interceptors (those types can't
1882+
// be forwarded), but the presence of auth config still signals the endpoint must be protected.
1883+
const interceptorsAuth = this.profile?.interceptors?.auth;
1884+
const hasAnyInterceptorsAuth = Array.isArray(interceptorsAuth)
1885+
? interceptorsAuth.length > 0
1886+
: !!interceptorsAuth;
1887+
if (provider.auth || hasAnyInterceptorsAuth) {
18551888
throw new UpstreamConnectionError(
1856-
'upstream_mcp.auth.value_from_env requires an authenticated HTTP session — ' +
1857-
'the inbound caller must supply a verified identity token.',
1889+
'upstream_mcp proxy requires an authenticated HTTP session — ' +
1890+
'the inbound client must supply a verified token.',
18581891
provider.name,
18591892
);
18601893
}
18611894
return undefined;
18621895
}
1863-
// Non-HTTP path (stdio): no session concept; value_from_env allowed for service-account use.
1864-
if (provider.auth?.value_from_env) {
1865-
return process.env[provider.auth.value_from_env];
1896+
// Non-HTTP path (stdio): use value_from_env from effective auth (may come from interceptors.auth)
1897+
if (effectiveAuth?.value_from_env) {
1898+
return process.env[effectiveAuth.value_from_env];
18661899
}
18671900
return undefined;
18681901
}
@@ -1890,8 +1923,10 @@ export class MCPServer {
18901923
});
18911924
}
18921925
try {
1893-
const token = this.getUpstreamToken(sessionId, profileId, provider);
1894-
const client = await this.getUpstreamClientFn!(sessionId, provider, token);
1926+
const effectiveAuth = this.getEffectiveUpstreamAuth(provider);
1927+
const effectiveProvider = effectiveAuth !== provider.auth ? { ...provider, auth: effectiveAuth } : provider;
1928+
const token = this.getUpstreamToken(sessionId, profileId, provider, effectiveAuth);
1929+
const client = await this.getUpstreamClientFn!(sessionId, effectiveProvider, token);
18951930
const result = await client.listTools();
18961931
if (!result || typeof result !== 'object') {
18971932
throw new UpstreamMalformedResponseError(
@@ -1984,8 +2019,10 @@ export class MCPServer {
19842019
}
19852020

19862021
try {
1987-
const token = this.getUpstreamToken(sessionId, profileId, provider);
1988-
const client = await this.getUpstreamClientFn!(sessionId, provider, token);
2022+
const effectiveAuth = this.getEffectiveUpstreamAuth(provider);
2023+
const effectiveProvider = effectiveAuth !== provider.auth ? { ...provider, auth: effectiveAuth } : provider;
2024+
const token = this.getUpstreamToken(sessionId, profileId, provider, effectiveAuth);
2025+
const client = await this.getUpstreamClientFn!(sessionId, effectiveProvider, token);
19892026
const result = await (provider.timeout_ms !== undefined
19902027
? client.callTool({ name: toolName, arguments: args }, undefined, { timeout: provider.timeout_ms })
19912028
: client.callTool({ name: toolName, arguments: args }));

src/profile/profile-resolver.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,27 @@ describe('profile-resolver', () => {
193193
expect(profiles[0].envVars).toContain('LEGACY_UPSTREAM_TOKEN');
194194
});
195195

196+
it('reports authMethods=[] and envVars=[] for upstream_mcp proxy with no auth anywhere', async () => {
197+
const root = await createTempDir();
198+
const profilesDir = path.join(root, 'profiles');
199+
200+
await writeJson(path.join(profilesDir, 'no-auth-proxy.json'), {
201+
profile_name: 'seznam-scif',
202+
profile_id: 'seznam-scif',
203+
tools: [],
204+
upstream_mcp: {
205+
name: 'scif',
206+
transport: { type: 'http-streamable', url: 'https://scif.example.com/mcp' },
207+
},
208+
});
209+
210+
const profiles = await listProfilesDetailed(profilesDir);
211+
expect(profiles).toHaveLength(1);
212+
expect(profiles[0].authMethods).toEqual([]);
213+
expect(profiles[0].envVars).toEqual([]);
214+
expect(profiles[0].oauthEnvVars).toEqual([]);
215+
});
216+
196217
it('returns specPath=undefined for upstream_mcp proxy profile with no openapi_spec_path', async () => {
197218
const root = await createTempDir();
198219
const profilesDir = path.join(root, 'profiles');

src/profile/upstream-mcp-config.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ describe('resolveUpstreamMcpConfig – validator error branches', () => {
9898
expect(() => resolveUpstreamMcpConfig(profile)).toThrow(/value_from_env must not be empty/);
9999
});
100100

101+
it('accepts bearer auth without value_from_env (HTTP session-passthrough)', () => {
102+
const profile = makeProfile({
103+
name: 'p1',
104+
transport: { type: 'http-streamable', url: 'https://example.com/mcp' },
105+
auth: { type: 'bearer' },
106+
});
107+
expect(() => resolveUpstreamMcpConfig(profile)).not.toThrow();
108+
});
109+
101110
it('rejects custom-header auth with unsafe header_name', () => {
102111
const profile = makeProfile({
103112
name: 'p1',

src/profile/upstream-mcp-config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ function validateUpstreamAuth(auth: UpstreamMcpAuthConfig | undefined, path: str
108108
);
109109
}
110110

111-
if (!auth.value_from_env.trim()) {
112-
throw new ValidationError(`${path}.value_from_env must not be empty`, { path: `${path}.value_from_env` });
111+
if (auth.value_from_env !== undefined && !auth.value_from_env.trim()) {
112+
throw new ValidationError(`${path}.value_from_env must not be empty when provided`, { path: `${path}.value_from_env` });
113113
}
114114

115115
if (auth.type === 'custom-header') {
@@ -263,7 +263,7 @@ export function resolveUpstreamMcpConfig(
263263
tool_prefix: provider.tool_prefix?.trim(),
264264
auth: provider.auth ? {
265265
...provider.auth,
266-
value_from_env: provider.auth.value_from_env.trim(),
266+
value_from_env: provider.auth.value_from_env?.trim(),
267267
header_name: provider.auth.header_name?.trim(),
268268
query_param: provider.auth.query_param?.trim(),
269269
} : undefined,

0 commit comments

Comments
 (0)