Skip to content

Commit 2f91439

Browse files
author
David Ruzicka
committed
feat: support upstream MCP proxy profiles without openapi_spec_path
ResolvedProfile.specPath is now string | undefined. Profiles with upstream_mcp and no openapi_spec_path resolve specPath=undefined instead of throwing ConfigurationError. MCPServer gains initializeWithoutSpec() and a shared private initializeProfile(); MCPServerManager branches on specPath != null. generic-profile.test runner detects upstream proxy profiles and skips spec loading, calls initializeWithoutSpec, guards beforeEach mockEngine access. New test profile tests/profiles/upstream-mcp-proxy/ exercises the full path.
1 parent 3f50317 commit 2f91439

8 files changed

Lines changed: 122 additions & 30 deletions

File tree

src/mcp/mcp-server-manager.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,12 @@ export class MCPServerManager {
6868
const resolved = await this.registry.resolveProfile(profileId);
6969
const server = new MCPServer(this.logger);
7070
server.setGlobalFiltering(this.globalFiltering);
71-
await server.initialize(resolved.specPath, resolved.profilePath);
71+
72+
if (resolved.specPath != null) {
73+
await server.initialize(resolved.specPath, resolved.profilePath);
74+
} else {
75+
await server.initializeWithoutSpec(resolved.profilePath);
76+
}
7277
if (this.httpTransport) {
7378
server.attachHttpTransport(this.httpTransport);
7479
}

src/mcp/mcp-server.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,30 @@ paths:
337337
});
338338
});
339339

340+
describe('initializeWithoutSpec', () => {
341+
it('initializes successfully with upstream_mcp proxy profile', async () => {
342+
const profilePath = path.join(os.tmpdir(), `proxy-profile-${Date.now()}-${Math.random()}.json`);
343+
344+
const profile = {
345+
profile_name: 'proxy-profile',
346+
description: 'Pure upstream proxy',
347+
tools: [],
348+
upstream_mcp: [{
349+
name: 'example',
350+
transport: { type: 'http-streamable', url: 'https://example.com/mcp' },
351+
}],
352+
};
353+
354+
await fs.writeFile(profilePath, JSON.stringify(profile));
355+
try {
356+
await expect(server.initializeWithoutSpec(profilePath)).resolves.toBeUndefined();
357+
expect(server['profile']!.tools).toHaveLength(0);
358+
} finally {
359+
await fs.unlink(profilePath);
360+
}
361+
});
362+
});
363+
340364
describe('runStdio', () => {
341365
it('should connect MCP server via StdioServerTransport', async () => {
342366
const logger = {

src/mcp/mcp-server.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -504,11 +504,16 @@ export class MCPServer {
504504
}
505505

506506
async initialize(specPath: string, profilePath?: string): Promise<void> {
507-
// Load OpenAPI spec
508507
await this.parser.load(specPath);
509508
this.logger.info('Loaded OpenAPI spec', { specPath });
509+
await this.initializeProfile(profilePath);
510+
}
511+
512+
async initializeWithoutSpec(profilePath: string): Promise<void> {
513+
await this.initializeProfile(profilePath);
514+
}
510515

511-
// Load or create MCP profile
516+
private async initializeProfile(profilePath?: string): Promise<void> {
512517
this.appsModel = undefined;
513518
this.appsFetchCache.clear();
514519
if (profilePath) {
@@ -526,34 +531,26 @@ export class MCPServer {
526531
profile: this.profile.profile_name,
527532
toolCount: this.profile.tools.length,
528533
});
529-
530-
// Check if we should warn about long names
531534
this.checkToolNameLengths();
532535
}
533536

534537
this.applyGlobalToolFiltering();
535538

536-
// Re-create logger with auth config for token redaction
537539
const authConfigs = this.getAuthConfigs();
538540
if (authConfigs.length > 0) {
539-
// Use first auth config for logger (primary)
540541
this.logger = this.createLoggerWithAuth(authConfigs[0]);
541542
this.logger.info('Logger re-configured with auth token redaction', {
542543
authMethods: authConfigs.length,
543544
});
544545
}
545546

546-
// Setup HTTP client with interceptors
547-
// For stdio transport, create client with env token
548-
// For HTTP transport, clients are created per-session with user's token
549547
const baseUrl = this.getBaseUrl();
550548
const envAuthConfig = this.getEnvBackedAuthConfig();
551549
const primaryRuntimeAuthConfig = authConfigs.find(config => config.type !== 'oauth');
552550
const envVarName = envAuthConfig?.value_from_env;
553551
const envToken = envVarName ? process.env[envVarName] : undefined;
554552

555553
if ((envAuthConfig && envToken) || authConfigs.length === 0 || primaryRuntimeAuthConfig?.type === 'session-cookie') {
556-
// Token available in env (stdio) or no auth required - create global client
557554
const httpClient = this.httpClientFactory.createGlobalClient({
558555
profile: this.profile,
559556
baseUrl,
@@ -562,10 +559,9 @@ export class MCPServer {
562559
});
563560
this.compositeExecutor = new CompositeExecutor(this.parser, httpClient, this.profile.parameter_aliases);
564561
} else {
565-
// No env token or no auth - will use per-session clients (HTTP transport)
566562
this.compositeExecutor = new CompositeExecutor(this.parser, undefined, this.profile.parameter_aliases);
567563
}
568-
564+
569565
this.logger.info('MCP server initialized', {
570566
baseUrl,
571567
toolCount: this.profile.tools.length,

src/profile/profile-resolver.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,22 @@ describe('profile-resolver', () => {
7474
await expect(resolveProfileById('missing', profilesDir)).rejects.toThrow('openapi_spec_path');
7575
});
7676

77+
it('returns specPath=undefined for upstream_mcp proxy profile with no openapi_spec_path', async () => {
78+
const root = await createTempDir();
79+
const profilesDir = path.join(root, 'profiles');
80+
const profilePath = path.join(profilesDir, 'proxy.json');
81+
82+
await writeJson(profilePath, {
83+
profile_name: 'proxy-profile',
84+
profile_id: 'proxy',
85+
tools: [],
86+
upstream_mcp: [{ server_url: 'https://example.com/mcp' }],
87+
});
88+
89+
const resolved = await resolveProfileById('proxy', profilesDir);
90+
expect(resolved.specPath).toBeUndefined();
91+
});
92+
7793
it('extracts env vars and auth methods for profile index', async () => {
7894
const root = await createTempDir();
7995
const profilesDir = path.join(root, 'profiles');

src/profile/profile-resolver.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export interface ResolvedProfile {
1717
profileName: string;
1818
profileAliases?: string[];
1919
profilePath: string;
20-
specPath: string;
20+
specPath: string | undefined;
2121
}
2222

2323
export interface ListedProfile {
@@ -81,6 +81,7 @@ interface ProfileIndexEntry {
8181
aliases: string[];
8282
profilePath: string;
8383
specPathRaw?: string;
84+
hasUpstreamMcp: boolean;
8485
}
8586

8687
const DEFAULT_PROFILES_DIR = 'profiles';
@@ -379,13 +380,21 @@ function normalizeSpecPath(value?: string): string | undefined {
379380
return trimmed.length > 0 ? trimmed : undefined;
380381
}
381382

382-
function resolveSpecPath(profilePath: string, specPathRaw?: string, overrideSpecPath?: string): string {
383+
function resolveSpecPath(
384+
profilePath: string,
385+
specPathRaw?: string,
386+
overrideSpecPath?: string,
387+
isUpstreamMcpProxy = false,
388+
): string | undefined {
383389
const trimmed = normalizeSpecPath(specPathRaw);
384390
if (!trimmed) {
385391
const override = normalizeSpecPath(overrideSpecPath);
386392
if (override) {
387393
return override;
388394
}
395+
if (isUpstreamMcpProxy) {
396+
return undefined;
397+
}
389398
throw new ConfigurationError('Profile is missing openapi_spec_path', { profilePath });
390399
}
391400
if (isHttpUrl(trimmed)) {
@@ -425,6 +434,7 @@ async function loadProfileIndexEntry(profilePath: string): Promise<ProfileIndexE
425434
aliases,
426435
profilePath,
427436
specPathRaw: typeof profile.openapi_spec_path === 'string' ? profile.openapi_spec_path : undefined,
437+
hasUpstreamMcp: Array.isArray(profile.upstream_mcp) && (profile.upstream_mcp as unknown[]).length > 0,
428438
};
429439
}
430440

@@ -536,7 +546,7 @@ export async function resolveProfileById(
536546
}
537547

538548
const match = matches[0];
539-
const specPath = resolveSpecPath(match.profilePath, match.specPathRaw, options?.specPathOverride);
549+
const specPath = resolveSpecPath(match.profilePath, match.specPathRaw, options?.specPathOverride, match.hasUpstreamMcp);
540550

541551
return {
542552
profileId: match.profileId,
@@ -594,7 +604,7 @@ export async function resolveProfileFromPath(
594604
throw new ConfigurationError('Profile file does not look like a valid profile', { profilePath: resolvedPath });
595605
}
596606

597-
const specPath = resolveSpecPath(resolvedPath, entry.specPathRaw, options?.specPathOverride);
607+
const specPath = resolveSpecPath(resolvedPath, entry.specPathRaw, options?.specPathOverride, entry.hasUpstreamMcp);
598608

599609
return {
600610
profileId: entry.profileId,

src/testing/generic-profile.test.ts

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -240,23 +240,34 @@ testFiles.forEach(testFile => {
240240
const profileLoader = new ProfileLoader();
241241
profile = await profileLoader.load(fullProfilePath);
242242
validateTestAgainstProfile(testDef, profile);
243-
const fullSpecPath = resolveOpenApiSpecPath(testDir, files, profile);
244243

245-
parser = new OpenAPIParser();
246-
await parser.load(fullSpecPath);
244+
const isUpstreamProxy =
245+
Array.isArray(profile.upstream_mcp) &&
246+
(profile.upstream_mcp as unknown[]).length > 0 &&
247+
!profile.openapi_spec_path;
247248

248-
const baseUrl = `https://mock-api-${profileName.replace(/[^a-zA-Z0-9-]/g, '-')}.com`;
249-
mockEngine = new DynamicMockEngine(parser, baseUrl);
250-
mockEngine.start();
249+
server = new MCPServer();
251250

252-
process.env.MCP4_API_TOKEN = 'test-token';
253-
process.env.MCP4_API_BASE_URL = baseUrl;
254-
originalAllowPrivateNetwork = process.env.MCP4_SSRF_ALLOW_PRIVATE_NETWORK;
255-
process.env.MCP4_SSRF_ALLOW_PRIVATE_NETWORK = 'true';
256-
configureProfileEnv(profile, baseUrl);
251+
if (isUpstreamProxy) {
252+
await server.initializeWithoutSpec(fullProfilePath);
253+
} else {
254+
const fullSpecPath = resolveOpenApiSpecPath(testDir, files, profile);
257255

258-
server = new MCPServer();
259-
await server.initialize(fullSpecPath, fullProfilePath);
256+
parser = new OpenAPIParser();
257+
await parser.load(fullSpecPath);
258+
259+
const baseUrl = `https://mock-api-${profileName.replace(/[^a-zA-Z0-9-]/g, '-')}.com`;
260+
mockEngine = new DynamicMockEngine(parser, baseUrl);
261+
mockEngine.start();
262+
263+
process.env.MCP4_API_TOKEN = 'test-token';
264+
process.env.MCP4_API_BASE_URL = baseUrl;
265+
originalAllowPrivateNetwork = process.env.MCP4_SSRF_ALLOW_PRIVATE_NETWORK;
266+
process.env.MCP4_SSRF_ALLOW_PRIVATE_NETWORK = 'true';
267+
configureProfileEnv(profile, baseUrl);
268+
269+
await server.initialize(fullSpecPath, fullProfilePath);
270+
}
260271
}, 30000);
261272

262273
afterAll(() => {
@@ -269,6 +280,7 @@ testFiles.forEach(testFile => {
269280
});
270281

271282
beforeEach(() => {
283+
if (!mockEngine) return;
272284
mockEngine.reset();
273285
if (testDef.global_mocks) {
274286
const context = { ...testDef.variables };
@@ -277,6 +289,10 @@ testFiles.forEach(testFile => {
277289
}
278290
});
279291

292+
it('server initializes successfully', () => {
293+
expect(server['profile']).toBeDefined();
294+
});
295+
280296
testDef.scenarios.forEach(scenario => {
281297
it(scenario.name, async () => {
282298
// Prepare context
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"$schema": "../../profile-schema.json",
3+
"profile_name": "upstream-mcp-proxy",
4+
"profile_id": "upstream-mcp-proxy",
5+
"description": "Generic test profile for pure upstream MCP proxy — no local OpenAPI tools",
6+
"tools": [],
7+
"upstream_mcp": [
8+
{
9+
"name": "test-upstream",
10+
"transport": {
11+
"type": "http-streamable",
12+
"url": "https://upstream.example.com/mcp"
13+
}
14+
}
15+
]
16+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"$schema": "../../../src/testing/test-schema.ts",
3+
"profile_name": "upstream-mcp-proxy",
4+
"scenarios": [],
5+
"coverage": {
6+
"require_all_actions": false,
7+
"skip_actions": {}
8+
}
9+
}

0 commit comments

Comments
 (0)