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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <server>` 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

Expand Down
26 changes: 19 additions & 7 deletions src/runtime/connection-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class RuntimeConnectionCache {
private readonly serverGenerations = new Map<string, number>();
private readonly retirementPromises = new Map<string, Set<Promise<void>>>();
private readonly lastConnectionInfo = new Map<string, ConnectionInfo>();
private readonly lastInstructions = new Map<string, string>();
private readonly uncachedInFlight = new Set<CachedClientEntry>();

public constructor(
Expand Down Expand Up @@ -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<string | undefined> {
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);
}
}

Expand Down Expand Up @@ -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;
});
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions tests/e2e-fixture-servers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
51 changes: 51 additions & 0 deletions tests/runtime-skip-cache-close.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -58,9 +59,59 @@ 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.');
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();
});
Loading