feat: surface server instructions in list

This commit is contained in:
Peter Steinberger 2026-05-04 06:22:18 +01:00
parent a64bdda3f7
commit 5d8e64d5d5
No known key found for this signature in database
5 changed files with 76 additions and 2 deletions

View File

@ -17,6 +17,7 @@
- Let non-interactive `mcporter list` use existing OAuth token caches for HTTP servers even when older configs are missing `auth: "oauth"`. (Issue #137)
- Fail OAuth flows immediately when the server never creates an authorization URL, instead of waiting for a browser callback that cannot arrive. (Issue #115)
- Support `mcporter list server.tool --schema` to print a single tool's schema instead of the whole server. (Issue #116)
- Surface MCP server `instructions` from the initialize response in single-server `mcporter list` text and JSON output. (Issue #76)
### Config

View File

@ -14,7 +14,8 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- Without arguments, lists every configured server (with live discovery + brief
status).
- With a server name, prints TypeScript-style signatures for each tool, doc
comments, and optional summaries.
comments, optional summaries, and any server `instructions` returned during
MCP initialization.
- With `server.tool`, prints just that tool; combine with `--schema` for a single
tool schema.
- Hidden alias: `list-tools` (kept for muscle memory; not advertised in help output).

View File

@ -273,12 +273,14 @@ export async function handleList(
printMissingToolJson(definition, requestedTool, durationMs, transportSummary, flags);
return;
}
const instructions = await loadServerInstructions(runtime, target);
const payload = {
mode: 'server',
name: definition.name,
status: 'ok' as StatusCategory,
durationMs,
description: definition.description,
instructions,
transport: transportSummary,
source: definition.source,
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
@ -327,6 +329,7 @@ export async function handleList(
printMissingToolText(definition, requestedTool, durationMs, transportSummary, sourcePath);
return;
}
const instructions = await loadServerInstructions(runtime, target);
const summaryLine = printSingleServerHeader(
definition,
metadataEntries.length,
@ -335,6 +338,7 @@ export async function handleList(
sourcePath,
{
printSummaryNow: false,
instructions,
}
);
if (metadataEntries.length === 0) {
@ -528,3 +532,24 @@ function printMissingToolJson(
);
process.exitCode = 1;
}
async function loadServerInstructions(
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
serverName: string
): Promise<string | undefined> {
if (typeof runtime.connect !== 'function') {
return undefined;
}
try {
const context = await runtime.connect(serverName);
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;
} catch {
return undefined;
}
}

View File

@ -18,6 +18,7 @@ export interface ListJsonServerEntry {
status: StatusCategory;
durationMs: number;
description?: string;
instructions?: string;
transport?: string;
source?: ServerDefinition['source'];
sources?: ServerDefinition['sources'];
@ -38,7 +39,7 @@ export function printSingleServerHeader(
durationMs: number | undefined,
transportSummary: string,
sourcePath: string | undefined,
options?: { printSummaryNow?: boolean }
options?: { printSummaryNow?: boolean; instructions?: string }
): string {
const prefix = boldText(definition.name);
if (definition.description) {
@ -46,6 +47,11 @@ export function printSingleServerHeader(
} else {
console.log(prefix);
}
if (options?.instructions) {
for (const line of formatInstructionLines(options.instructions)) {
console.log(` ${extraDimText(line)}`);
}
}
const summaryParts: string[] = [];
summaryParts.push(
extraDimText(typeof toolCount === 'number' ? `${toolCount} tool${toolCount === 1 ? '' : 's'}` : 'tools unavailable')
@ -69,6 +75,16 @@ export function printSingleServerHeader(
return summaryLine;
}
function formatInstructionLines(instructions: string): string[] {
const normalized = instructions.replace(/\s+/g, ' ').trim();
if (!normalized) {
return [];
}
const maxLength = 500;
const clipped = normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1)}` : normalized;
return [`Instructions: ${clipped}`];
}
export function printToolDetail(
definition: ReturnType<Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>['getDefinition']>,
metadata: ToolMetadata,

View File

@ -148,6 +148,37 @@ describe('CLI list formatting', () => {
metadataSpy.mockRestore();
});
it('surfaces initialize instructions in single server text and JSON output', async () => {
const { handleList } = await cliModulePromise;
const definition: ServerDefinition = {
name: 'immich',
description: 'Immich MCP',
command: { kind: 'http', url: new URL('https://example.com/mcp') },
};
const runtime = {
getDefinitions: () => [definition],
getDefinition: () => definition,
listTools: vi.fn().mockResolvedValue([{ name: 'search_assets' }]),
connect: vi.fn().mockResolvedValue({
client: {
getInstructions: () => 'Use asset IDs from search results when calling mutation tools.',
},
}),
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleList(runtime, ['immich']);
let lines = logSpy.mock.calls.map((call) => stripAnsi(call.join(' ')));
expect(lines.some((line) => line.includes('Instructions: Use asset IDs from search results'))).toBe(true);
logSpy.mockClear();
await handleList(runtime, ['--json', 'immich']);
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '{}');
expect(payload.instructions).toBe('Use asset IDs from search results when calling mutation tools.');
logSpy.mockRestore();
});
it('prints only the selected tool when listing server.tool with schemas', async () => {
const { handleList } = await cliModulePromise;
const listToolsSpy = vi.fn((_name: string, options?: { includeSchema?: boolean }) =>