From 095b098ae2e7cebb5e0e73b8a4929c61858d64fc Mon Sep 17 00:00:00 2001 From: Krasimir Kralev <263465593+KrasimirKralev@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:45:35 +0300 Subject: [PATCH 1/2] fix(list): show server instructions without --no-oauth Named listings discover tools on an uncached connection that closes before getInstructions runs, so instructions were only shown with --no-oauth. Keep a lastInstructions snapshot next to lastConnectionInfo, recorded under the same generation/abort guard and cleared on blank instructions or supersedeDefinition. Fixes #455 --- CHANGELOG.md | 1 + src/runtime/connection-cache.ts | 26 +++++++++++---- tests/e2e-fixture-servers.test.ts | 17 ++++++++++ tests/runtime-skip-cache-close.test.ts | 46 ++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f66430..0b485d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Update Rolldown to 1.2.14 for generated CLI bundling, preserving Node 24 support and the 48-hour dependency cooldown. - Add opt-in OAuth secret encryption with `MCPORTER_VAULT_PASSWORD`, a required policy, persistent encryption markers, migration on write, and vault/token-cache diagnostics. Older binaries must use a separate state directory after opt-in; see the downgrade instructions. Thanks @alexminza (#425, #408). +- Show server instructions in `mcporter list ` text and JSON output again without `--no-oauth`; the uncached discovery connection closed before the instructions were read (#455). ## [0.15.0] - 2026-10-16 diff --git a/src/runtime/connection-cache.ts b/src/runtime/connection-cache.ts index ad496eef..8392ef56 100644 --- a/src/runtime/connection-cache.ts +++ b/src/runtime/connection-cache.ts @@ -36,6 +36,7 @@ export class RuntimeConnectionCache { private readonly serverGenerations = new Map(); private readonly retirementPromises = new Map>>(); private readonly lastConnectionInfo = new Map(); + private readonly lastInstructions = new Map(); private readonly uncachedInFlight = new Set(); public constructor( @@ -66,24 +67,22 @@ export class RuntimeConnectionCache { public supersedeDefinition(server: string, replace: () => void): void { this.bumpServerGeneration(server); this.lastConnectionInfo.delete(server.trim()); + this.lastInstructions.delete(server.trim()); replace(); this.retireCachedEntriesForServer(server); } public async getInstructions(server: string): Promise { + const normalized = server.trim(); const active = this.activeClientForServer(server); const fallbackEntries = active ? [] : this.cachedEntriesForServer(server); const cached = active ?? (fallbackEntries.length === 1 ? fallbackEntries[0] : undefined); - if (!cached) return undefined; + if (!cached) return this.lastInstructions.get(normalized); try { const context = await cached.promise; - const instructions = - typeof context.client.getInstructions === 'function' ? context.client.getInstructions() : undefined; - if (typeof instructions !== 'string') return undefined; - const trimmed = instructions.trim(); - return trimmed.length > 0 ? trimmed : undefined; + return instructionsFromClient(context.client); } catch { - return undefined; + return this.lastInstructions.get(normalized); } } @@ -332,6 +331,12 @@ export class RuntimeConnectionCache { contextRef.current = context; if (this.serverGeneration(normalized) === generation && !abortController.signal.aborted) { this.lastConnectionInfo.set(normalized, connectionInfoFromClient(context.client)); + const instructions = instructionsFromClient(context.client); + if (instructions === undefined) { + this.lastInstructions.delete(normalized); + } else { + this.lastInstructions.set(normalized, instructions); + } } return context; }); @@ -713,6 +718,13 @@ export class RuntimeConnectionCache { } } +function instructionsFromClient(client: ClientContext['client']): string | undefined { + const instructions = typeof client.getInstructions === 'function' ? client.getInstructions() : undefined; + if (typeof instructions !== 'string') return undefined; + const trimmed = instructions.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function connectionInfoFromClient(client: ClientContext['client']): ConnectionInfo { return { protocolVersion: diff --git a/tests/e2e-fixture-servers.test.ts b/tests/e2e-fixture-servers.test.ts index 146ddf26..70b4f9fe 100644 --- a/tests/e2e-fixture-servers.test.ts +++ b/tests/e2e-fixture-servers.test.ts @@ -97,6 +97,23 @@ describe.each(fixtureKinds)('%s fixture through the real CLI', (fixture) => { budget(20_000) ); + it( + 'shows server instructions in named listings without --no-oauth', + async () => { + await withConfig({ fixture: configFor(fixture, transport) }, async (configPath, env) => { + const instructions = fixture === 'legacy' ? 'MCP 2025-11-25 fixture' : 'MCP 2026-07-28 fixture'; + const json = await runCli(['list', 'fixture', '--json'], configPath, env); + expect(json.exitCode, json.stderr).toBe(0); + expect(parseJson<{ instructions?: string }>(json.stdout).instructions).toContain(instructions); + + const text = await runCli(['list', 'fixture'], configPath, env); + expect(text.exitCode, text.stderr).toBe(0); + expect(text.stdout).toContain(instructions); + }); + }, + budget(20_000) + ); + it( 'calls tools and reports structured output', async () => { diff --git a/tests/runtime-skip-cache-close.test.ts b/tests/runtime-skip-cache-close.test.ts index 1566acf0..475c2f6c 100644 --- a/tests/runtime-skip-cache-close.test.ts +++ b/tests/runtime-skip-cache-close.test.ts @@ -58,9 +58,55 @@ it.each(['close', 'supersede'])( expect(transport.close).toHaveBeenCalledOnce(); expect(clientClose).toHaveBeenCalledOnce(); await expect(cache.getConnectionInfo(definition.name)).resolves.toBeUndefined(); + await expect(cache.getInstructions(definition.name)).resolves.toBeUndefined(); } finally { pending.resolve(context); await Promise.allSettled([closing, connecting]); } } ); + +it('keeps instructions from an uncached connection until the next connect or definition change', async () => { + const definition: ServerDefinition = { + name: 'guided', + command: { kind: 'stdio', command: 'unused', args: [], cwd: process.cwd() }, + }; + const cache = new RuntimeConnectionCache(new Map([[definition.name, definition]]), { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + clientInfo: { name: 'test', version: '1' }, + oauthTimeoutMs: 1000, + elicitationHandler: async () => ({ action: 'decline' }), + }); + const connectWithInstructions = async (instructions: string | undefined) => { + const client = new Client({ name: 'test', version: '1' }); + vi.spyOn(client, 'getInstructions').mockReturnValue(instructions); + vi.spyOn(client, 'close').mockResolvedValue(undefined); + const transport = { start: vi.fn(async () => {}), send: vi.fn(async () => {}), close: vi.fn(async () => {}) }; + createClientContext.mockResolvedValueOnce({ definition, client, transport } satisfies ClientContext); + const context = await cache.connect(definition.name, { skipCache: true }); + await context.client.close(); + }; + + await connectWithInstructions(' Call lookup first. '); + await expect(cache.getInstructions(' guided ')).resolves.toBe('Call lookup first.'); + + const active = new Client({ name: 'test', version: '1' }); + vi.spyOn(active, 'getInstructions').mockReturnValue('Active instructions.'); + cache.clients.set('guided:active', { + server: 'guided', + promise: Promise.resolve({ definition, client: active, transport: { close: vi.fn(async () => {}) } } as never), + allowCachedAuth: true, + disableOAuth: false, + }); + cache.activeClientKeys.set('guided', 'guided:active'); + await expect(cache.getInstructions('guided')).resolves.toBe('Active instructions.'); + cache.clients.delete('guided:active'); + cache.activeClientKeys.delete('guided'); + + await connectWithInstructions(' '); + await expect(cache.getInstructions('guided')).resolves.toBeUndefined(); + + await connectWithInstructions('Call lookup first.'); + cache.supersedeDefinition(definition.name, () => {}); + await expect(cache.getInstructions('guided')).resolves.toBeUndefined(); +}); From 1e9ed12c641cff70af714a41be2b2802645fc0e7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Oct 2026 12:53:56 -0700 Subject: [PATCH 2/2] test(runtime): cover instruction snapshot invalidation --- CHANGELOG.md | 2 +- tests/runtime-skip-cache-close.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b485d5e..c8971e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Update Rolldown to 1.2.14 for generated CLI bundling, preserving Node 24 support and the 48-hour dependency cooldown. - Add opt-in OAuth secret encryption with `MCPORTER_VAULT_PASSWORD`, a required policy, persistent encryption markers, migration on write, and vault/token-cache diagnostics. Older binaries must use a separate state directory after opt-in; see the downgrade instructions. Thanks @alexminza (#425, #408). -- Show server instructions in `mcporter list ` text and JSON output again without `--no-oauth`; the uncached discovery connection closed before the instructions were read (#455). +- Show server instructions in `mcporter list ` text and JSON output again without `--no-oauth`; the uncached discovery connection closed before the instructions were read. Thanks @KrasimirKralev (#456, #455). ## [0.15.0] - 2026-10-16 diff --git a/tests/runtime-skip-cache-close.test.ts b/tests/runtime-skip-cache-close.test.ts index 475c2f6c..f673077a 100644 --- a/tests/runtime-skip-cache-close.test.ts +++ b/tests/runtime-skip-cache-close.test.ts @@ -31,6 +31,7 @@ it.each(['close', 'supersede'])( elicitationHandler: async () => ({ action: 'decline' }), }); const client = new Client({ name: 'test', version: '1' }); + vi.spyOn(client, 'getInstructions').mockReturnValue('Superseded instructions.'); const clientClose = vi.spyOn(client, 'close').mockResolvedValue(undefined); const transport = { start: vi.fn(async () => {}), send: vi.fn(async () => {}), close: vi.fn(async () => {}) }; const context: ClientContext = { definition, client, transport }; @@ -106,6 +107,10 @@ it('keeps instructions from an uncached connection until the next connect or def await connectWithInstructions(' '); await expect(cache.getInstructions('guided')).resolves.toBeUndefined(); + await connectWithInstructions('Call lookup first.'); + await connectWithInstructions(undefined); + await expect(cache.getInstructions('guided')).resolves.toBeUndefined(); + await connectWithInstructions('Call lookup first.'); cache.supersedeDefinition(definition.name, () => {}); await expect(cache.getInstructions('guided')).resolves.toBeUndefined();