From 117cba2a4f55bfee6e4d8c8a16b95cc8ebe8b0a9 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Wed, 4 Nov 2026 23:22:45 +0200 Subject: [PATCH 1/4] chore: yarn.lock lists the Skilled OpenAPI plugin's optional @enclave-vm/browser peer The plugin's package.json gained the peer, but the lockfile's workspace entry did not, so yarn install --frozen-lockfile refused to run. --- yarn.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/yarn.lock b/yarn.lock index 8f16d31dc..271e9df05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2659,6 +2659,7 @@ __metadata: reflect-metadata: "npm:^0.2.2" peerDependencies: "@enclave-vm/ast": "*" + "@enclave-vm/browser": "*" "@enclave-vm/core": "*" "@frontmcp/lazy-zod": 1.11.0 "@frontmcp/sdk": 1.11.0 @@ -2666,6 +2667,8 @@ __metadata: peerDependenciesMeta: "@enclave-vm/ast": optional: true + "@enclave-vm/browser": + optional: true "@enclave-vm/core": optional: true undici: From 20bd0b14f3c753390797cda289887176cf26937e Mon Sep 17 00:00:00 2001 From: David Antoon Date: Wed, 4 Nov 2026 23:35:55 +0200 Subject: [PATCH 2/4] fix(sdk): connect() in cli mode asks the onElicitation handler a tool's questions A CLI-mode scope opened no elicitation store, so once a client could answer, this.elicit() failed with "ElicitationStore not initialized". In CLI mode the store now stays in memory: the process asks its own user and ends with the command. Persistent questions and the sendElicitationResult fallback tool stay off there, as before. The browser bundles of create() grow by 83 and 81 bytes; their budgets take it. --- .../e2e/browser-bundle-size.e2e.spec.ts | 4 ++-- .../src/direct/__tests__/connect-client.spec.ts | 14 ++++++++++++++ libs/sdk/src/scope/scope.instance.ts | 11 ++++++----- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts index a4a607082..6ba8b5a0f 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts +++ b/apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts @@ -21,8 +21,8 @@ import { } from './helpers/browser-bundle'; const BUDGETS: Array<{ entry: string; label: string; maxBytes: number }> = [ - { entry: 'create-tool.ts', label: 'create() + one tool', maxBytes: 1_288_882 }, - { entry: 'create-webmcp-tool.ts', label: 'create() + WebMcpPlugin + one tool', maxBytes: 1_296_074 }, + { entry: 'create-tool.ts', label: 'create() + one tool', maxBytes: 1_288_965 }, + { entry: 'create-webmcp-tool.ts', label: 'create() + WebMcpPlugin + one tool', maxBytes: 1_296_155 }, ]; const VITE_INITIAL_CHUNK_MAX_BYTES = 763_249; diff --git a/libs/sdk/src/direct/__tests__/connect-client.spec.ts b/libs/sdk/src/direct/__tests__/connect-client.spec.ts index 925317554..26fd2b53d 100644 --- a/libs/sdk/src/direct/__tests__/connect-client.spec.ts +++ b/libs/sdk/src/direct/__tests__/connect-client.spec.ts @@ -85,6 +85,20 @@ describe('connect() clients', () => { expect(result.structuredContent).toEqual({ action: 'accept', confirmed: true }); }); + it("asks the onElicitation handler in cli mode, which keeps the questions' state in memory", async () => { + const client = await connect(config(), { + mode: 'cli', + onElicitation: async () => ({ action: 'accept', content: { confirmed: true } }), + }); + + const result = (await client.callTool('confirm_delete', { path: 'notes.txt' })) as { + structuredContent?: unknown; + }; + await client.close(); + + expect(result.structuredContent).toEqual({ action: 'accept', confirmed: true }); + }); + it('sends the questions to a handler registered later when the client declares elicitation', async () => { const client = await connect(config(), { capabilities: { elicitation: { form: {} } } }); client.onElicitation(async () => ({ action: 'accept', content: { confirmed: true } })); diff --git a/libs/sdk/src/scope/scope.instance.ts b/libs/sdk/src/scope/scope.instance.ts index 26e1250a1..db8bd9a36 100644 --- a/libs/sdk/src/scope/scope.instance.ts +++ b/libs/sdk/src/scope/scope.instance.ts @@ -384,17 +384,18 @@ export class Scope extends ScopeEntry { }); } - // Elicitation store (conditional, skipped in CLI mode) - const elicitationEnabled = this.metadata.elicitation?.enabled === true && !this.cliMode; - const elicitationPromise = elicitationEnabled + // Elicitation store: in CLI mode it stays in memory, since the process asks its own user and ends with the command + const elicitationConfigured = this.metadata.elicitation?.enabled === true; + const elicitationEnabled = elicitationConfigured && !this.cliMode; + const elicitationPromise = elicitationConfigured ? (async () => { const inheritedRedis = this.metadata.elicitation?.redis ?? this.metadata.redis; // `elicitation.store: false` keeps the elicitation state in memory; persistent questions follow their own storage - const elicitationInMemory = this.metadata.elicitation?.store === false; + const elicitationInMemory = this.cliMode || this.metadata.elicitation?.store === false; const elicitationRedis = elicitationInMemory ? undefined : inheritedRedis; // Top-level `sqlite` is the fallback when no Redis source is configured (issue #401). const elicitationSqlite = elicitationInMemory || elicitationRedis ? undefined : resolvedTopLevelSqlite; - const persistent = this.metadata.elicitation?.persistent; + const persistent = this.cliMode ? undefined : this.metadata.elicitation?.persistent; const questionsHelper = persistent ? await import('../elicitation/questions/question-scope.helper.js') : undefined; From fb90737d964516420011c3cd8aa1cf701838c22a Mon Sep 17 00:00:00 2001 From: David Antoon Date: Wed, 4 Nov 2026 23:36:06 +0200 Subject: [PATCH 3/4] feat(cli): Skilled OpenAPI commands in the generated CLI, and terminal confirmation When the server has the Skilled OpenAPI plugin's four tools, the build generates commands for them in place of the standard skills commands, which showed no actions for bundle skills: - skills search, load, execute, run, read and list, always; - search_skill, load_skill, execute and run_workflow at the top level too, on a server whose only tools are the plugin's. They answer to their kebab-case names and keep the 1.11.0 --query, --skill-id and --script flags. load prints each action with a ready execute command. execute takes --input, --input - or --input-file; run_workflow takes the script, --file or -. The plugin's tools are no longer plain tool commands. --output json prints the tool's structured content; a tool error exits 1, a usage error 2. The CLI now answers a server's elicitation, in-process and through the daemon, which reads the SSE stream as it arrives: a yes/no question, such as the confirmation of a destructive action, reads y/N on a terminal, the global --yes answers yes, and with no terminal the CLI declines. The sendElicitationResult fallback tool is no longer a command. --- libs/cli/README.md | 15 +- .../exec/__tests__/daemon-client.spec.ts | 73 ++- .../exec/__tests__/generate-cli-entry.spec.ts | 86 ++- .../__tests__/generated-cli-smoke.spec.ts | 12 + .../__tests__/skilled-commands.spec.ts | 517 ++++++++++++++++++ .../__tests__/terminal-elicitation.spec.ts | 115 ++++ .../build/exec/cli-runtime/daemon-client.ts | 86 ++- .../exec/cli-runtime/generate-cli-entry.ts | 61 ++- .../exec/cli-runtime/skilled-commands.ts | 464 ++++++++++++++++ .../exec/cli-runtime/terminal-elicitation.ts | 80 +++ libs/cli/src/commands/build/exec/index.ts | 20 +- .../references/configure-elicitation.md | 5 +- .../references/build-for-cli.md | 97 +++- plugins/plugin-skilled-openapi/README.md | 10 +- 14 files changed, 1558 insertions(+), 83 deletions(-) create mode 100644 libs/cli/src/commands/build/exec/cli-runtime/__tests__/skilled-commands.spec.ts create mode 100644 libs/cli/src/commands/build/exec/cli-runtime/__tests__/terminal-elicitation.spec.ts create mode 100644 libs/cli/src/commands/build/exec/cli-runtime/skilled-commands.ts create mode 100644 libs/cli/src/commands/build/exec/cli-runtime/terminal-elicitation.ts diff --git a/libs/cli/README.md b/libs/cli/README.md index 37209c2be..94bd6bf8c 100644 --- a/libs/cli/README.md +++ b/libs/cli/README.md @@ -410,10 +410,23 @@ An object field takes a JSON string; an array field takes the flag once per valu A server with skills also gets `skills list`, `skills search`, `skills read` and `skills load`; `skills read` and `skills load` exit 1 when a skill is not found. +**Skilled OpenAPI:** on a server that uses the Skilled OpenAPI plugin, the bundle's skills are tools, and the `skills` commands run the plugin's tools instead: `skills search`, `skills load`, `skills execute`, `skills run`, `skills read` and `skills list`. On a server whose only tools are the plugin's, the same commands are also top-level, and the plugin's tools are never plain tool commands: + +```bash +./myapp search_skill "refund an invoice" --limit 5 # Skills, with their scores +./myapp load_skill invoices # Instructions, and a ready execute line per action +./myapp execute getInvoice --input '{"id":"INV-1001"}' # One action (--input -, or --input-file f.json) +./myapp run_workflow --file refund.js # A script (as an argument, --file, or - for stdin) +``` + +They answer to `search-skill`, `load-skill`, `execute_action` and `run-workflow` too. `--output json` prints the tool's structured content. They exit 1 when the tool fails or a skill is not found, and 2 for a usage error, with the error on stderr. + +**Questions from the server:** a tool's `this.elicit()` question, such as the confirmation of a destructive action, is asked in the terminal (`y/N`), in-process and through the daemon. `--yes` answers yes without asking; with no terminal and no `--yes`, the CLI declines. + **Daemon:** while `daemon start`'s server runs, every command goes to it over its Unix socket (at the server's `http.entryPath`) instead of starting the server in-process. A daemon that does not answer is reported on stderr, and the command runs in-process. **Global options:** `--output ` sets the output format (the default comes from `cli.outputDefault`); -`--verbose` and `--log-dir ` control logging. +`--yes` answers the server's yes/no questions with yes; `--verbose` and `--log-dir ` control logging. **Tool name conflict resolution:** if a tool name collides with a built-in command (e.g., `login`, `serve`), the tool subcommand is automatically suffixed with `-tool` (e.g., `login-tool`). diff --git a/libs/cli/src/commands/build/exec/__tests__/daemon-client.spec.ts b/libs/cli/src/commands/build/exec/__tests__/daemon-client.spec.ts index 31542a84c..bef5aff15 100644 --- a/libs/cli/src/commands/build/exec/__tests__/daemon-client.spec.ts +++ b/libs/cli/src/commands/build/exec/__tests__/daemon-client.spec.ts @@ -15,8 +15,16 @@ interface DaemonClient { close(): Promise; } +interface ElicitationAnswer { + action: string; + content?: Record; +} + interface DaemonClientModule { - createDaemonClient(socketPath: string, options?: { authToken?: string; entryPath?: string }): DaemonClient; + createDaemonClient( + socketPath: string, + options?: { authToken?: string; entryPath?: string; onElicitation?: (request: unknown) => Promise }, + ): DaemonClient; } interface ReceivedRequest { @@ -30,6 +38,17 @@ type Reply = (res: http.ServerResponse, message: { id?: number; method?: string; const SESSION_ID = 'session-1'; +/** The question the `confirm` tool asks; its id is the call's own, which only the method tells apart. */ +const CONFIRM_QUESTION = { + jsonrpc: '2.0', + id: 2, + method: 'elicitation/create', + params: { message: 'Run voidInvoice?', requestedSchema: { type: 'object', properties: { confirmed: { type: 'boolean' } } } }, +}; + +/** Ends the `confirm` call with the answer the client sent to its question. */ +let finishConfirm: ((answer: unknown) => void) | undefined; + function loadDaemonClient(): DaemonClientModule { const loadedModule = { exports: {} as DaemonClientModule }; new Function('require', 'exports', 'module', generateDaemonClientSource())(require, loadedModule.exports, loadedModule); @@ -57,6 +76,13 @@ const replies: Record = { replyJson(res, { jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: '{"jobs":[],"count":0}' }] } }); return; } + if (message.params?.['name'] === 'confirm') { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Mcp-Session-Id': SESSION_ID }); + res.write(`event: message\ndata: ${JSON.stringify(CONFIRM_QUESTION)}\n\n`); + finishConfirm = (answer) => + res.end(`event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { answer } })}\n\n`); + return; + } if (message.params?.['name'] === 'broken') { replyJson(res, { jsonrpc: '2.0', id: message.id, error: { code: -32602, message: 'Invalid arguments' } }); return; @@ -103,6 +129,12 @@ describe('generateDaemonClientSource', () => { res.end(); return; } + if (body.method === undefined) { + finishConfirm?.(body); + res.writeHead(202); + res.end(); + return; + } replies[body.method](res, body); }); }); @@ -189,6 +221,45 @@ describe('generateDaemonClientSource', () => { ]); }); + it('answers the question the server asks during a call, then reads the call result', async () => { + const questions: unknown[] = []; + const client = createDaemonClient(socketPath, { + entryPath: '/mcp', + onElicitation: async (question) => { + questions.push(question); + return { action: 'accept', content: { confirmed: true } }; + }, + }); + await client.connect(); + + await expect(client.callTool('confirm', {})).resolves.toEqual({ + answer: { jsonrpc: '2.0', id: 2, result: { action: 'accept', content: { confirmed: true } } }, + }); + expect(questions).toEqual([{ mode: 'form', ...CONFIRM_QUESTION.params }]); + expect(received[0].body?.params?.['capabilities']).toEqual({ elicitation: { form: {} } }); + expect(received[3].headers['mcp-session-id']).toBe(SESSION_ID); + }); + + it('declines a question its handler fails to answer', async () => { + const client = createDaemonClient(socketPath, { + entryPath: '/mcp', + onElicitation: () => Promise.reject(new Error('no terminal')), + }); + await client.connect(); + + await expect(client.callTool('confirm', {})).resolves.toMatchObject({ answer: { result: { action: 'decline' } } }); + }); + + it('declares no elicitation without a handler, and answers a question with method not found', async () => { + const client = createDaemonClient(socketPath, { entryPath: '/mcp' }); + await client.connect(); + + await expect(client.callTool('confirm', {})).resolves.toMatchObject({ + answer: { id: 2, error: { code: -32601, message: 'Method not found: elicitation/create' } }, + }); + expect(received[0].body?.params?.['capabilities']).toEqual({}); + }); + it('ends the session on close', async () => { const client = createDaemonClient(socketPath, { entryPath: '/mcp' }); await client.connect(); diff --git a/libs/cli/src/commands/build/exec/__tests__/generate-cli-entry.spec.ts b/libs/cli/src/commands/build/exec/__tests__/generate-cli-entry.spec.ts index 85e597aac..bbcfccb90 100644 --- a/libs/cli/src/commands/build/exec/__tests__/generate-cli-entry.spec.ts +++ b/libs/cli/src/commands/build/exec/__tests__/generate-cli-entry.spec.ts @@ -2,10 +2,11 @@ import { generateCliEntry, type CliEntryOptions, resolveToolCommandName, + skilledOpenApiToolNames, extractTemplateParams, RESERVED_COMMANDS, } from '../cli-runtime/generate-cli-entry'; -import { type ExtractedSchema } from '../cli-runtime/schema-extractor'; +import { type ExtractedSchema, type ExtractedTool } from '../cli-runtime/schema-extractor'; function makeSchema(overrides?: Partial): ExtractedSchema { return { @@ -583,7 +584,7 @@ describe('generateCliEntry', () => { const source = generateCliEntry(makeOptions({ authRequired: false })); expect(source).not.toContain('credBlob'); expect(source).not.toContain('Not logged in'); - expect(source).toContain("return { mode: 'cli' };"); + expect(source).toContain("return Object.assign({ mode: 'cli' }, QUESTION_OPTIONS);"); }); it('refuses to connect without a stored credential, before trying the daemon', () => { @@ -600,7 +601,7 @@ describe('generateCliEntry', () => { it('sends the stored token on the daemon path and the in-process path alike', () => { const source = generateCliEntry(makeOptions({ authRequired: true })); - expect(source).toContain("return { authToken: credBlob.token, mode: 'cli' };"); + expect(source).toContain("return Object.assign({ authToken: credBlob.token, mode: 'cli' }, QUESTION_OPTIONS);"); expect(source).toContain('authToken: connectOpts.authToken,'); expect(source).toContain('_client = await connectInProcess(connectOpts);'); expect(source).toContain('return connect(configOrClass, connectOpts);'); @@ -822,6 +823,85 @@ describe('generateCliEntry', () => { }); }); + describe('Skilled OpenAPI commands', () => { + const metaTools: ExtractedTool[] = [ + { name: 'search_skill', description: 'Search skills', inputSchema: { type: 'object', properties: { query: { type: 'string' } } } }, + { name: 'load_skill', description: 'Load skills', inputSchema: { type: 'object', properties: { skillId: { type: 'string' } } } }, + { name: 'execute_action', description: 'Run one action', inputSchema: { type: 'object', properties: { actionId: { type: 'string' } } } }, + { name: 'run_workflow', description: 'Run a script', inputSchema: { type: 'object', properties: { script: { type: 'string' } } } }, + ]; + const ownTool: ExtractedTool = { name: 'create_invoice', description: 'Create an invoice', inputSchema: { type: 'object', properties: {} } }; + const fallbackTool: ExtractedTool = { name: 'sendElicitationResult', description: 'Answer a question', inputSchema: { type: 'object', properties: {} } }; + const skillCapabilities = { skills: true, jobs: false, workflows: false }; + + function sourceFor(tools: ExtractedTool[], overrides?: Partial): string { + return generateCliEntry(makeOptions({ schema: makeSchema({ tools, capabilities: skillCapabilities }), ...overrides })); + } + + it('adds the commands at the top level and under skills for a server whose only tools are the plugin\'s', () => { + const source = sourceFor([...metaTools, fallbackTool]); + + expect(source).toContain(`require('./skilled-commands').registerSkilledCommands(program, { + getClient: getClient, + toolNames: ["search_skill","load_skill","execute_action","run_workflow"], + topLevel: true +});`); + expect(source).toContain(`else if (["search_skill","load_skill","execute","run_workflow","skills"].indexOf(name) !== -1) groups['Skills'].push(sub);`); + expect(source).toContain('program._toolCommandNames = [];'); + expect(source).not.toContain('searchSkills'); + }); + + it('adds them only under skills for a server with tools of its own, which keep their commands', () => { + const source = sourceFor([ownTool, ...metaTools]); + + expect(source).toContain('topLevel: false'); + expect(source).toContain(`else if (["skills"].indexOf(name) !== -1) groups['Skills'].push(sub);`); + expect(source).toContain('program._toolCommandNames = ["create-invoice"];'); + expect(source).not.toContain('.command("search-skill")'); + expect(source).not.toContain('callTool("run_workflow"'); + }); + + it('keeps the standard skills commands for a server without the plugin', () => { + const source = sourceFor([ownTool, metaTools[0]]); + + expect(source).not.toContain('registerSkilledCommands'); + expect(source).toContain('searchSkills'); + expect(source).toContain('.command("search-skill")'); + }); + + it('leaves out the command of a meta-tool cli.excludeTools names', () => { + const source = sourceFor(metaTools, { excludeTools: ['run_workflow'] }); + + expect(source).toContain('toolNames: ["search_skill","load_skill","execute_action"],'); + expect(source).toContain(`["search_skill","load_skill","execute","skills"].indexOf(name)`); + }); + + it('finds the plugin when its four meta-tools are listed', () => { + expect(skilledOpenApiToolNames(metaTools, [])).toEqual(['search_skill', 'load_skill', 'execute_action', 'run_workflow']); + expect(skilledOpenApiToolNames(metaTools.slice(0, 3), [])).toBeUndefined(); + }); + }); + + describe('questions from the server', () => { + it('answers them in the terminal, with --yes for every yes/no one, in-process and through the daemon', () => { + const source = generateCliEntry(makeOptions()); + + expect(source).toContain("var elicitation = require('./terminal-elicitation');"); + expect(source).toContain("onElicitation: elicitation.createElicitationHandler({ assumeYes: function() { return !!program.opts().yes; } }),"); + expect(source).toContain('capabilities: { elicitation: { form: {} } }'); + expect(source).toContain('onElicitation: connectOpts.onElicitation'); + expect(source).toContain(".option('--yes', 'Answer yes to the questions the server asks, such as confirming a destructive action')"); + }); + + it('has no command for the tool a server lists for clients that cannot answer', () => { + const source = generateCliEntry(makeOptions({ + schema: makeSchema({ tools: [{ name: 'sendElicitationResult', description: 'Answer', inputSchema: { type: 'object' } }] }), + })); + + expect(source).toContain('// No tools extracted'); + }); + }); + describe('job commands', () => { it('should generate job commands when jobs capability is true', () => { const source = generateCliEntry(makeOptions({ diff --git a/libs/cli/src/commands/build/exec/__tests__/generated-cli-smoke.spec.ts b/libs/cli/src/commands/build/exec/__tests__/generated-cli-smoke.spec.ts index fd4af786c..b52528904 100644 --- a/libs/cli/src/commands/build/exec/__tests__/generated-cli-smoke.spec.ts +++ b/libs/cli/src/commands/build/exec/__tests__/generated-cli-smoke.spec.ts @@ -89,6 +89,18 @@ describe('generated CLI smoke tests', () => { expect(() => new Function(stripShebang(source))).not.toThrow(); }); + it('should produce valid JavaScript for a Skilled OpenAPI server', () => { + const metaTool = (name: string) => ({ name, description: name, inputSchema: { type: 'object', properties: {} } }); + const source = generateCliEntry(makeOptions({ + schema: makeSchema({ + tools: ['search_skill', 'load_skill', 'execute_action', 'run_workflow'].map(metaTool), + capabilities: { skills: true, jobs: false, workflows: false }, + }), + })); + + expect(() => new Function(stripShebang(source))).not.toThrow(); + }); + it('should produce valid JavaScript with prompts', () => { const source = generateCliEntry(makeOptions({ schema: makeSchema({ diff --git a/libs/cli/src/commands/build/exec/cli-runtime/__tests__/skilled-commands.spec.ts b/libs/cli/src/commands/build/exec/cli-runtime/__tests__/skilled-commands.spec.ts new file mode 100644 index 000000000..4281c35be --- /dev/null +++ b/libs/cli/src/commands/build/exec/cli-runtime/__tests__/skilled-commands.spec.ts @@ -0,0 +1,517 @@ +import * as os from 'os'; +import * as path from 'path'; +import { Readable } from 'stream'; + +import { Command, CommanderError } from 'commander'; + +import { mkdtemp, rm, writeFile } from '@frontmcp/utils'; + +import { registerSkilledCommands, SKILLED_OPENAPI_TOOLS, type SkilledClient } from '../skilled-commands'; + +type ToolAnswer = Awaited>; + +const ALL_TOOLS = Object.values(SKILLED_OPENAPI_TOOLS); + +const SEARCH_ANSWER: ToolAnswer = { + content: [{ type: 'text', text: '{}' }], + structuredContent: { + skills: [{ skillId: 'invoices', name: 'Invoices', description: 'Look up invoices.', score: 0.8312 }], + }, +}; + +const INVOICES_SKILL = { + id: 'invoices', + name: 'Invoices', + description: 'Look up invoices.', + instructions: '# Invoices\n\nFind an invoice by its id.', + actions: [ + { + actionId: 'getInvoice', + summary: 'Get an invoice by id.', + inputJsonSchema: { + type: 'object', + properties: { id: { type: 'string' }, expand: { type: 'boolean' } }, + required: ['id'], + }, + }, + { + actionId: 'listInvoices', + summary: 'List invoices.', + inputJsonSchema: { type: 'object', properties: { status: { type: 'string', enum: ['paid', 'open'] } } }, + }, + { + actionId: 'createInvoice', + summary: 'Create an invoice.', + inputJsonSchema: { + type: 'object', + properties: { + customer: { type: 'string', examples: ["O'Brien"] }, + amount: { type: ['integer', 'null'] }, + lines: { type: 'array' }, + address: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] }, + currency: { type: 'string', default: 'EUR' }, + }, + required: ['customer', 'amount', 'lines', 'address', 'currency'], + }, + }, + ], +}; + +const INVOICE = { id: 'INV-1001', status: 'paid' }; + +interface Run { + stdout: string; + stderr: string; + exitCode: number; + calls: Array<{ name: string; args: Record }>; + reads: string[]; + program: Command; +} + +/** Run `mymcp ` against a client that answers each tool and resource with what the test gives. */ +async function run( + args: string[], + setup: { + answers?: Partial>; + resources?: Record; + topLevel?: boolean; + toolNames?: string[]; + stdin?: string; + } = {}, +): Promise { + const calls: Run['calls'] = []; + const reads: string[] = []; + const client: SkilledClient = { + callTool: async (name, toolArgs) => { + calls.push({ name, args: toolArgs }); + const answer = setup.answers?.[name] ?? { content: [{ type: 'text', text: 'ok' }] }; + if (answer instanceof Error) throw answer; + return answer; + }, + readResource: async (uri) => { + reads.push(uri); + const text = setup.resources?.[uri]; + if (text === undefined) throw new Error(`Resource not found: ${uri}`); + return { contents: [{ text }] }; + }, + }; + const program = new Command('mymcp') + .exitOverride() + .configureOutput({ writeOut: () => undefined, writeErr: () => undefined }) + .option('--output ', 'Output format', 'text') + .option('--yes', 'Answer yes'); + registerSkilledCommands(program, { + getClient: async () => client, + toolNames: setup.toolNames ?? ALL_TOOLS, + topLevel: setup.topLevel ?? true, + stdin: Readable.from([setup.stdin ?? '']), + }); + + const stdout: string[] = []; + const stderr: string[] = []; + const log = jest.spyOn(console, 'log').mockImplementation((line: string) => void stdout.push(line)); + const error = jest.spyOn(console, 'error').mockImplementation((line: string) => void stderr.push(line)); + process.exitCode = undefined; + try { + await program.parseAsync(['node', 'mymcp', ...args]); + } catch (thrown) { + if (!(thrown instanceof CommanderError)) throw thrown; + stderr.push(thrown.code); + process.exitCode = 2; + } finally { + log.mockRestore(); + error.mockRestore(); + } + const exitCode = typeof process.exitCode === 'number' ? process.exitCode : 0; + process.exitCode = undefined; + return { stdout: stdout.join('\n'), stderr: stderr.join('\n'), exitCode, calls, reads, program }; +} + +describe('registerSkilledCommands', () => { + let dir: string; + + beforeAll(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), 'frontmcp-skilled-')); + }); + + afterAll(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + describe('search', () => { + it('searches with the query and its options, and lists the skills with their scores', async () => { + const { stdout, exitCode, calls } = await run( + ['search_skill', 'refund', 'an', 'invoice', '--limit', '5', '--tags', 'billing, refunds', '--not-query', 'void'], + { answers: { search_skill: SEARCH_ANSWER } }, + ); + + expect(exitCode).toBe(0); + expect(calls).toEqual([ + { + name: 'search_skill', + args: { query: 'refund an invoice', limit: 5, tags: ['billing', 'refunds'], notQuery: 'void' }, + }, + ]); + expect(stdout).toBe('Invoices (invoices) score 0.83\n Look up invoices.\n\nLoad one: mymcp load_skill '); + }); + + it('answers to its kebab-case name and the 1.11.0 --query flag', async () => { + const { calls } = await run(['search-skill', '--query', 'refunds'], { answers: { search_skill: SEARCH_ANSWER } }); + + expect(calls[0].args).toEqual({ query: 'refunds' }); + }); + + it("prints the tool's structured content with --output json", async () => { + const { stdout } = await run(['--output', 'json', 'search_skill', 'refunds'], { + answers: { search_skill: SEARCH_ANSWER }, + }); + + expect(JSON.parse(stdout)).toEqual(SEARCH_ANSWER.structuredContent); + }); + + it('says when no skill matches', async () => { + const { stdout } = await run(['search_skill', 'refunds'], { + answers: { search_skill: { structuredContent: { skills: [] } } }, + }); + + expect(stdout).toBe('No skills found.'); + }); + + it.each([ + [[], 'say what you want to do, as in: mymcp search_skill "refund an invoice"'], + [['refunds', '--limit', '0'], '--limit takes a positive whole number, not "0"'], + ])('exits 2 for %j, sending nothing', async (args, message) => { + const { stderr, exitCode, calls } = await run(['search_skill', ...args]); + + expect(exitCode).toBe(2); + expect(stderr).toBe(`Error: ${message}`); + expect(calls).toEqual([]); + }); + }); + + describe('load', () => { + it("loads one skill by skillId, and prints its instructions and actions with a ready command for each", async () => { + const { stdout, exitCode, calls } = await run(['load_skill', 'invoices'], { + answers: { load_skill: { structuredContent: { skill: INVOICES_SKILL, isComplete: true } } }, + }); + + expect(exitCode).toBe(0); + expect(calls).toEqual([{ name: 'load_skill', args: { skillId: 'invoices' } }]); + expect(stdout).toBe( + [ + 'Invoices (invoices)', + 'Look up invoices.', + '', + '# Invoices\n\nFind an invoice by its id.', + '', + 'Actions (3):', + '', + ' getInvoice: Get an invoice by id.', + ` mymcp execute getInvoice --input '{"id":""}'`, + ' optional input: expand', + '', + ' listInvoices: List invoices.', + ' mymcp execute listInvoices', + ' optional input: status', + '', + ' createInvoice: Create an invoice.', + ` mymcp execute createInvoice --input '{"customer":"O'\\''Brien","amount":0,"lines":[],"address":{"city":""},"currency":"EUR"}'`, + ].join('\n'), + ); + }); + + it('loads several skills by skillIds, with the warning of an incomplete one', async () => { + const admin = { id: 'admin', name: 'Admin', description: 'Billing administration.', instructions: '# Admin' }; + const { stdout, calls } = await run(['load-skill', 'invoices', 'admin'], { + answers: { + load_skill: { + structuredContent: { + skills: [ + { skill: { ...INVOICES_SKILL, actions: [] }, isComplete: true }, + { skill: admin, isComplete: false, warning: 'some actions are unavailable' }, + ], + }, + }, + }, + }); + + expect(calls[0].args).toEqual({ skillIds: ['invoices', 'admin'] }); + expect(stdout).toContain('Invoices (invoices)'); + expect(stdout).toContain('Admin (admin)\nBilling administration.\nWarning: some actions are unavailable\n\n# Admin'); + }); + + it('takes the 1.11.0 --skill-id flag', async () => { + const { calls } = await run(['load_skill', '--skill-id', 'invoices'], { + answers: { load_skill: { structuredContent: { skill: INVOICES_SKILL } } }, + }); + + expect(calls[0].args).toEqual({ skillId: 'invoices' }); + }); + + it('exits 1 for a skill it does not find, with the error on stderr', async () => { + const notFound: ToolAnswer = { content: [{ type: 'text', text: 'Skill "refunds" not found' }], isError: true }; + const { stdout, stderr, exitCode } = await run(['load_skill', 'refunds'], { answers: { load_skill: notFound } }); + + expect(exitCode).toBe(1); + expect(stdout).toBe(''); + expect(stderr).toBe('Error: Skill "refunds" not found'); + }); + + it('prints the raw result of a failed call with --output json when it has no structured content', async () => { + const notFound: ToolAnswer = { content: [{ type: 'text', text: 'Skill "refunds" not found' }], isError: true }; + const { stdout, exitCode } = await run(['--output', 'json', 'load_skill', 'refunds'], { + answers: { load_skill: notFound }, + }); + + expect(exitCode).toBe(1); + expect(JSON.parse(stdout)).toEqual(notFound); + }); + + it('exits 2 without a skill to load', async () => { + expect(await run(['load_skill'])).toMatchObject({ exitCode: 2, calls: [] }); + expect(await run(['skills', 'load'])).toMatchObject({ exitCode: 2, stderr: 'commander.missingArgument' }); + }); + }); + + describe('execute', () => { + const succeeded: ToolAnswer = { + content: [{ type: 'text', text: JSON.stringify(INVOICE) }], + structuredContent: INVOICE, + }; + + it("runs the action with its input and prints the action's data", async () => { + const { stdout, exitCode, calls } = await run(['execute', 'getInvoice', '--input', '{"id":"INV-1001"}'], { + answers: { execute_action: succeeded }, + }); + + expect(exitCode).toBe(0); + expect(calls).toEqual([{ name: 'execute_action', args: { actionId: 'getInvoice', input: { id: 'INV-1001' } } }]); + expect(stdout).toBe(JSON.stringify(INVOICE, null, 2)); + }); + + it('runs an action that takes no input, printing text data as it is', async () => { + const { stdout, calls } = await run(['execute_action', 'ping'], { + answers: { execute_action: { content: [{ type: 'text', text: 'pong' }], structuredContent: { value: 'pong' } } }, + }); + + expect(calls[0].args).toEqual({ actionId: 'ping' }); + expect(stdout).toBe('pong'); + }); + + it('reads the input from standard input with --input -', async () => { + const { calls } = await run(['execute', 'getInvoice', '--input', '-'], { + answers: { execute_action: succeeded }, + stdin: '{"id":"INV-2"}\n', + }); + + expect(calls[0].args).toEqual({ actionId: 'getInvoice', input: { id: 'INV-2' } }); + }); + + it('reads the input from a file with --input-file', async () => { + const inputFile = path.join(dir, 'input.json'); + await writeFile(inputFile, '{"id":"INV-3"}'); + + const { calls } = await run(['execute', 'getInvoice', '--input-file', inputFile], { + answers: { execute_action: succeeded }, + }); + + expect(calls[0].args).toEqual({ actionId: 'getInvoice', input: { id: 'INV-3' } }); + }); + + it('exits 1 for an action that fails, with its error on stderr and its outcome with --output json', async () => { + const failed: ToolAnswer = { + content: [{ type: 'text', text: 'action "getInvoice" failed with status 404: {"error":"not found"}' }], + structuredContent: { ok: false, status: 404, data: { error: 'not found' } }, + isError: true, + }; + const { stdout, stderr, exitCode } = await run( + ['--output', 'json', 'execute', 'getInvoice', '--input', '{"id":"nope"}'], + { answers: { execute_action: failed } }, + ); + + expect(exitCode).toBe(1); + expect(JSON.parse(stdout)).toEqual(failed.structuredContent); + expect(stderr).toBe('Error: action "getInvoice" failed with status 404: {"error":"not found"}'); + }); + + it('exits 2 for input the server finds invalid', async () => { + const invalid: ToolAnswer = { + content: [{ type: 'text', text: 'Invalid input' }], + isError: true, + _meta: { code: 'INVALID_INPUT' }, + }; + + expect(await run(['execute', 'getInvoice'], { answers: { execute_action: invalid } })).toMatchObject({ + exitCode: 2, + }); + }); + + it.each([ + [['--input', '{"id"'], '--input is not JSON'], + [['--input', '[1]'], `--input must be a JSON object, such as '{"id":"INV-1001"}'`], + [['--input', '{}', '--input-file', 'input.json'], 'pass the input with --input or --input-file, not both'], + [['--input-file', '/nowhere/input.json'], "can't read /nowhere/input.json"], + ])('exits 2 for %j, sending nothing', async (flags, message) => { + const { stderr, exitCode, calls } = await run(['execute', 'getInvoice', ...flags]); + + expect(exitCode).toBe(2); + expect(stderr).toContain(`Error: ${message}`); + expect(calls).toEqual([]); + }); + + it('takes the global --yes after the command too', async () => { + const { program, exitCode } = await run(['execute', 'voidInvoice', '--input', '{"id":"inv_1"}', '--yes'], { + answers: { execute_action: succeeded }, + }); + + expect(exitCode).toBe(0); + expect(program.opts()['yes']).toBe(true); + }); + + it('exits 1 with the message when the call itself fails', async () => { + const { stderr, exitCode } = await run(['execute', 'getInvoice'], { + answers: { execute_action: new Error('Not logged in. Run "mymcp login" first.') }, + }); + + expect(exitCode).toBe(1); + expect(stderr).toBe('Error: Not logged in. Run "mymcp login" first.'); + }); + }); + + describe('run', () => { + const ran = (value: unknown): ToolAnswer => ({ + content: [{ type: 'text', text: '{}' }], + structuredContent: { success: true, value, stats: { durationMs: 1, toolCalls: 0, steps: 3 } }, + }); + const script = 'return 1 + 1;'; + + it('runs the script given as an argument and prints its value', async () => { + const { stdout, exitCode, calls } = await run(['run_workflow', script], { answers: { run_workflow: ran({ two: 2 }) } }); + + expect(exitCode).toBe(0); + expect(calls).toEqual([{ name: 'run_workflow', args: { script } }]); + expect(stdout).toBe(JSON.stringify({ two: 2 }, null, 2)); + }); + + it('reads the script from a file with --file', async () => { + const scriptFile = path.join(dir, 'refund.js'); + await writeFile(scriptFile, script); + + const { calls } = await run(['run-workflow', '--file', scriptFile], { answers: { run_workflow: ran('done') } }); + + expect(calls[0].args).toEqual({ script }); + }); + + it('reads the script from standard input for -', async () => { + const { stdout, calls } = await run(['run_workflow', '-'], { answers: { run_workflow: ran('done') }, stdin: script }); + + expect(calls[0].args).toEqual({ script }); + expect(stdout).toBe('done'); + }); + + it('takes the 1.11.0 --script flag, and prints nothing for a script that returns no value', async () => { + const { stdout, calls } = await run(['run_workflow', '--script', script], { answers: { run_workflow: ran(undefined) } }); + + expect(calls[0].args).toEqual({ script }); + expect(stdout).toBe(''); + }); + + it('exits 1 for a workflow that fails, with its error on stderr', async () => { + const failed: ToolAnswer = { + content: [{ type: 'text', text: '{"success":false,"error":"unknown action \\"refundInvoice\\""}' }], + structuredContent: { success: false, error: 'unknown action "refundInvoice"' }, + isError: true, + }; + const { stderr, exitCode } = await run(['run_workflow', script], { answers: { run_workflow: failed } }); + + expect(exitCode).toBe(1); + expect(stderr).toBe('Error: unknown action "refundInvoice"'); + }); + + it.each([[[]], [[script, '--file', 'refund.js']]])('exits 2 for %j: the script must come once', async (args) => { + const { stderr, exitCode, calls } = await run(['run_workflow', ...args]); + + expect(exitCode).toBe(2); + expect(stderr).toBe( + 'Error: give the script once: as an argument, with --file , or - for standard input, as in: mymcp run_workflow --file refund.js', + ); + expect(calls).toEqual([]); + }); + }); + + describe('the skills group', () => { + it('serves the same commands, and hints at its own command lines when there are no top-level ones', async () => { + const { stdout, calls } = await run(['skills', 'search', 'refunds'], { + topLevel: false, + answers: { search_skill: SEARCH_ANSWER }, + }); + + expect(calls[0]).toEqual({ name: 'search_skill', args: { query: 'refunds' } }); + expect(stdout).toContain('Load one: mymcp skills load '); + }); + + it('runs actions and workflows', async () => { + const execute = await run(['skills', 'execute', 'getInvoice', '--input', '{"id":"INV-1"}'], { topLevel: false }); + const runWorkflow = await run(['skills', 'run', 'return 1;'], { topLevel: false }); + + expect(execute.calls[0]).toEqual({ name: 'execute_action', args: { actionId: 'getInvoice', input: { id: 'INV-1' } } }); + expect(runWorkflow.calls[0]).toEqual({ name: 'run_workflow', args: { script: 'return 1;' } }); + }); + + it('has no top-level commands on a server with tools of its own', async () => { + const { exitCode, stderr, calls } = await run(['search_skill', 'refunds'], { topLevel: false }); + + expect(exitCode).toBe(2); + expect(stderr).toBe('commander.unknownCommand'); + expect(calls).toEqual([]); + }); + + it("reads a skill's SKILL.md by its name, encoded", async () => { + const uri = 'skill://Billing%20administration/SKILL.md'; + const { stdout, reads } = await run(['skills', 'read', 'Billing administration'], { + resources: { [uri]: '---\nname: Billing administration\n---\n# Admin' }, + }); + + expect(reads).toEqual([uri]); + expect(stdout).toBe('---\nname: Billing administration\n---\n# Admin'); + }); + + it('exits 1 when the skill to read is not found', async () => { + const { stderr, exitCode } = await run(['skills', 'read', 'refunds']); + + expect(exitCode).toBe(1); + expect(stderr).toBe('Error: Resource not found: skill://refunds/SKILL.md'); + }); + + it('lists the skills the caller can use, from skill://index.json', async () => { + const index = { + skills: [ + { type: 'skill-md', name: 'Invoices', description: 'Look up invoices.', url: 'skill://Invoices/SKILL.md' }, + { type: 'mcp-resource-template', description: 'More', url: 'skill://{name}/SKILL.md' }, + ], + }; + const resources = { 'skill://index.json': JSON.stringify(index) }; + + const { stdout } = await run(['skills', 'list'], { resources }); + const json = await run(['--output', 'json', 'skills', 'list'], { resources }); + + expect(stdout).toBe('Invoices\n Look up invoices.\n\nLoad one: mymcp load_skill '); + expect(JSON.parse(json.stdout)).toEqual(index); + }); + + it('says when there are no skills to list', async () => { + const { stdout } = await run(['skills', 'list'], { resources: { 'skill://index.json': '{"skills":[]}' } }); + + expect(stdout).toBe('No skills found.'); + }); + + it('leaves out the command of a meta-tool the CLI does not offer', async () => { + const program = (await run([], { toolNames: ['search_skill', 'load_skill', 'execute_action'] })).program; + const group = program.commands.find((command) => command.name() === 'skills'); + + expect(program.commands.map((command) => command.name())).toEqual(['search_skill', 'load_skill', 'execute', 'skills']); + expect(group?.commands.map((command) => command.name())).toEqual(['search', 'load', 'execute', 'read', 'list']); + expect(group?.description()).toBe('Find, load and run skills: search, load, execute, read, list'); + }); + }); +}); diff --git a/libs/cli/src/commands/build/exec/cli-runtime/__tests__/terminal-elicitation.spec.ts b/libs/cli/src/commands/build/exec/cli-runtime/__tests__/terminal-elicitation.spec.ts new file mode 100644 index 000000000..cf6be8b61 --- /dev/null +++ b/libs/cli/src/commands/build/exec/cli-runtime/__tests__/terminal-elicitation.spec.ts @@ -0,0 +1,115 @@ +import { PassThrough } from 'stream'; + +import { generateTerminalElicitationSource } from '../terminal-elicitation'; + +interface ElicitationRequest { + message: string; + requestedSchema: { type: 'object'; properties: Record }; +} + +interface ElicitationAnswer { + action: string; + content?: Record; +} + +interface TerminalElicitationModule { + createElicitationHandler(options: { + assumeYes: () => boolean; + input: NodeJS.ReadableStream; + output: NodeJS.WritableStream; + }): (request: ElicitationRequest) => Promise; +} + +const CONFIRMATION: ElicitationRequest = { + message: 'Run voidInvoice: Void an invoice. (DELETE /invoices/{id}) with {"id":"inv_1"}?', + requestedSchema: { type: 'object', properties: { confirmed: { type: 'boolean' } } }, +}; + +const FORM: ElicitationRequest = { + message: 'Who is this for?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string', title: 'Name' }, seats: { type: 'integer' }, notify: { type: 'boolean' } }, + }, +}; + +function loadTerminalElicitation(): TerminalElicitationModule { + const loadedModule = { exports: {} as TerminalElicitationModule }; + new Function('require', 'exports', 'module', generateTerminalElicitationSource())( + require, + loadedModule.exports, + loadedModule, + ); + return loadedModule.exports; +} + +/** A terminal: answers typed one line at a time, and what the handler printed. */ +function terminal(options: { isTTY: boolean; assumeYes?: boolean; lines?: string[] }) { + const input = Object.assign(new PassThrough(), { isTTY: options.isTTY }); + const output = new PassThrough(); + let printed = ''; + const lines = [...(options.lines ?? [])]; + output.on('data', (chunk: Buffer) => { + printed += chunk.toString(); + const nextLine = lines.shift(); + if (nextLine !== undefined) input.write(`${nextLine}\n`); + }); + const answer = loadTerminalElicitation().createElicitationHandler({ + assumeYes: () => options.assumeYes ?? false, + input, + output, + }); + return { answer, printed: () => printed }; +} + +describe('generateTerminalElicitationSource', () => { + it('accepts a yes/no question without asking when --yes is set', async () => { + const { answer, printed } = terminal({ isTTY: false, assumeYes: true }); + + await expect(answer(CONFIRMATION)).resolves.toEqual({ action: 'accept', content: { confirmed: true } }); + expect(printed()).toBe(''); + }); + + it('declines with the question and how to confirm when there is no terminal to ask on', async () => { + const { answer, printed } = terminal({ isTTY: false }); + + await expect(answer(CONFIRMATION)).resolves.toEqual({ action: 'decline' }); + expect(printed()).toBe( + `${CONFIRMATION.message}\nDeclined: there is no terminal to ask on. Run the command again with --yes to confirm.\n`, + ); + }); + + it('asks a yes/no question in the terminal and accepts a yes', async () => { + const { answer, printed } = terminal({ isTTY: true, lines: ['y'] }); + + await expect(answer(CONFIRMATION)).resolves.toEqual({ action: 'accept', content: { confirmed: true } }); + expect(printed()).toContain(`${CONFIRMATION.message} [y/N] `); + }); + + it.each(['n', '', 'maybe'])('declines a yes/no question answered %j', async (line) => { + const { answer } = terminal({ isTTY: true, lines: [line] }); + + await expect(answer(CONFIRMATION)).resolves.toEqual({ action: 'decline' }); + }); + + it('asks each field of another question in the terminal, which --yes does not answer', async () => { + const { answer, printed } = terminal({ isTTY: true, assumeYes: true, lines: ['Ada', '3', 'yes'] }); + + await expect(answer(FORM)).resolves.toEqual({ action: 'accept', content: { name: 'Ada', seats: 3, notify: true } }); + expect(printed()).toContain('Who is this for?\n'); + expect(printed()).toContain(' Name: '); + }); + + it('leaves out a field answered with nothing', async () => { + const { answer } = terminal({ isTTY: true, lines: ['Ada', '', ''] }); + + await expect(answer(FORM)).resolves.toEqual({ action: 'accept', content: { name: 'Ada' } }); + }); + + it('declines another question without a terminal, even with --yes', async () => { + const { answer, printed } = terminal({ isTTY: false, assumeYes: true }); + + await expect(answer(FORM)).resolves.toEqual({ action: 'decline' }); + expect(printed()).toBe('Who is this for?\nDeclined: there is no terminal to ask on.\n'); + }); +}); diff --git a/libs/cli/src/commands/build/exec/cli-runtime/daemon-client.ts b/libs/cli/src/commands/build/exec/cli-runtime/daemon-client.ts index 75319dfb7..8083ba21b 100644 --- a/libs/cli/src/commands/build/exec/cli-runtime/daemon-client.ts +++ b/libs/cli/src/commands/build/exec/cli-runtime/daemon-client.ts @@ -3,7 +3,7 @@ * * Talks MCP streamable HTTP to the daemon over its Unix socket with Node's built-in http module, so a command skips * loading and starting the server in-process. It serves the methods the generated CLI calls, with the results the - * in-process `connect()` client returns. + * in-process `connect()` client returns, and answers the questions (`elicitation/create`) the server asks during a call. * * NOTE: This file is used as a CJS runtime module — it gets bundled into the CLI * output by esbuild alongside the generated CLI entry. It must remain free of @@ -23,7 +23,17 @@ var PROTOCOL_VERSION = '2025-11-25'; var CLIENT_INFO = { name: 'mcp-client', version: '1.0.0' }; var REQUEST_TIMEOUT_MS = 60000; -function sendHttp(target, method, headers, body) { +function eventMessage(event) { + var data = event.split(/\\r?\\n/) + .filter(function(line) { return line.indexOf('data:') === 0; }) + .map(function(line) { return line.slice(5).trim(); }) + .join('\\n'); + if (!data) return undefined; + try { return JSON.parse(data); } catch (_) { return undefined; } +} + +// Read as it streams: a request the server sends during a call is answered before the call can end +function sendHttp(target, method, headers, body, onServerRequest) { return new Promise(function(resolve, reject) { var req = http.request({ socketPath: target.socketPath, @@ -32,15 +42,31 @@ function sendHttp(target, method, headers, body) { headers: headers, timeout: REQUEST_TIMEOUT_MS }, function(res) { - var chunks = []; - res.on('data', function(chunk) { chunks.push(chunk); }); - res.on('end', function() { - resolve({ - status: res.statusCode, - sessionId: res.headers['mcp-session-id'], - contentType: res.headers['content-type'] || '', - text: Buffer.concat(chunks).toString() + res.setEncoding('utf8'); + var isEventStream = (res.headers['content-type'] || '').indexOf('text/event-stream') !== -1; + var unread = ''; + var messages = []; + function takeEvents(events) { + events.map(eventMessage).filter(Boolean).forEach(function(message) { + messages.push(message); + if (message.method && message.id !== undefined && onServerRequest) { + // The server waits for the user's answer: no timeout while it is asked + req.setTimeout(0); + onServerRequest(message).then(function() { req.setTimeout(REQUEST_TIMEOUT_MS); }, function(err) { req.destroy(err); }); + } }); + } + res.on('data', function(chunk) { + unread += chunk; + if (!isEventStream) return; + var events = unread.split(/\\r?\\n\\r?\\n/); + unread = events.pop(); + takeEvents(events); + }); + res.on('end', function() { + if (isEventStream) takeEvents([unread]); + else { try { messages.push(JSON.parse(unread)); } catch (_) { /* not JSON-RPC: reported by status */ } } + resolve({ status: res.statusCode, sessionId: res.headers['mcp-session-id'], messages: messages }); }); }); req.on('error', reject); @@ -51,23 +77,8 @@ function sendHttp(target, method, headers, body) { }); } -function eventStreamMessages(text) { - return text.split(/\\r?\\n\\r?\\n/).map(function(event) { - return event.split(/\\r?\\n/) - .filter(function(line) { return line.indexOf('data:') === 0; }) - .map(function(line) { return line.slice(5).trim(); }) - .join('\\n'); - }).filter(Boolean).map(function(data) { return JSON.parse(data); }); -} - function responseMessage(response, id) { - var messages = []; - try { - messages = response.contentType.indexOf('text/event-stream') !== -1 - ? eventStreamMessages(response.text) - : [JSON.parse(response.text)]; - } catch (_) { /* not JSON-RPC: reported by status below */ } - var message = messages.find(function(m) { return m && m.id === id; }); + var message = response.messages.find(function(m) { return m && m.id === id && !m.method; }); if (!message) throw new Error('the daemon answered HTTP ' + response.status); if (message.error) { var err = new Error(message.error.message || 'RPC error'); @@ -81,13 +92,15 @@ function responseMessage(response, id) { /** * Create a daemon client that serves the methods the generated CLI calls, as the in-process client does. * @param {string} socketPath - Path to the Unix socket file. - * @param {{ authToken?: string, entryPath?: string }} [options] - authToken is sent as a Bearer token with every - * request; entryPath is the MCP endpoint path the daemon serves (the server's http.entryPath, '/' by default). + * @param {{ authToken?: string, entryPath?: string, onElicitation?: Function }} [options] - authToken is sent as a + * Bearer token with every request; entryPath is the MCP endpoint path the daemon serves (the server's + * http.entryPath, '/' by default); onElicitation answers the server's questions, as connect()'s does. * @returns {object} Client object with MCP methods; call connect() before the others. */ function createDaemonClient(socketPath, options) { var target = { socketPath: socketPath, path: (options && options.entryPath) || '/' }; var authToken = options && options.authToken; + var onElicitation = options && options.onElicitation; var sessionId; var lastId = 0; @@ -109,12 +122,24 @@ function createDaemonClient(socketPath, options) { lastId += 1; var id = lastId; var body = JSON.stringify({ jsonrpc: '2.0', id: id, method: method, params: params || {} }); - return sendHttp(target, 'POST', headersFor(body), body).then(function(response) { + return sendHttp(target, 'POST', headersFor(body), body, answerServerRequest).then(function(response) { if (response.sessionId) sessionId = response.sessionId; return responseMessage(response, id); }); } + function answerServerRequest(message) { + var reply = message.method === 'elicitation/create' && onElicitation + ? Promise.resolve(onElicitation(Object.assign({ mode: 'form' }, message.params))) + .catch(function() { return { action: 'decline' }; }) + .then(function(result) { return { jsonrpc: '2.0', id: message.id, result: result }; }) + : Promise.resolve({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found: ' + message.method } }); + return reply.then(function(answer) { + var body = JSON.stringify(answer); + return sendHttp(target, 'POST', headersFor(body), body); + }); + } + function notify(method) { var body = JSON.stringify({ jsonrpc: '2.0', method: method }); return sendHttp(target, 'POST', headersFor(body), body).then(function(response) { @@ -147,7 +172,8 @@ function createDaemonClient(socketPath, options) { return { _isDaemon: true, connect: function() { - return call('initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO }) + var capabilities = onElicitation ? { elicitation: { form: {} } } : {}; + return call('initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: capabilities, clientInfo: CLIENT_INFO }) .then(function() { return notify('notifications/initialized'); }); }, callTool: function(name, args) { diff --git a/libs/cli/src/commands/build/exec/cli-runtime/generate-cli-entry.ts b/libs/cli/src/commands/build/exec/cli-runtime/generate-cli-entry.ts index 34b500115..093d10786 100644 --- a/libs/cli/src/commands/build/exec/cli-runtime/generate-cli-entry.ts +++ b/libs/cli/src/commands/build/exec/cli-runtime/generate-cli-entry.ts @@ -9,6 +9,7 @@ import { type CliConfig, type OAuthConfig } from '../config'; import { EXTRACT_PUBLIC_MESSAGE_SNIPPET } from './extract-public-message.snippet'; import { type ExtractedSchema, type ExtractedTool, type ExtractedPrompt, type ExtractedResourceTemplate, type ExtractedCapabilities, type ExtractedJob, SYSTEM_TOOL_NAMES } from './schema-extractor'; import { schemaToCommander, generateOptionCode, camelToKebab } from './schema-to-commander'; +import { SKILLED_OPENAPI_TOOLS, TOP_LEVEL_SKILL_COMMANDS } from './skilled-commands'; import { envDefaultStatements } from '../../../../config/deployment-env'; import { getSelfNodeRange, minNodeMajorOf } from '../../../../core/version'; @@ -41,6 +42,17 @@ export interface CliEntryOptions { nodeVersion?: string; } +/** The tool a server lists for clients that can't answer its questions: the CLI answers them itself. */ +const ELICITATION_FALLBACK_TOOL = 'sendElicitationResult'; + +/** The Skilled OpenAPI meta-tools the CLI offers, when the server has all four of the plugin's; undefined otherwise. */ +export function skilledOpenApiToolNames(tools: ExtractedTool[], excludeTools: string[]): string[] | undefined { + const serverToolNames = new Set(tools.map((tool) => tool.name)); + const metaToolNames: string[] = Object.values(SKILLED_OPENAPI_TOOLS); + if (!metaToolNames.every((name) => serverToolNames.has(name))) return undefined; + return metaToolNames.filter((name) => !excludeTools.includes(name)); +} + /** * Resolve tool command name, appending '-tool' suffix if it conflicts with a built-in command. * Returns { cmdName, wasRenamed } so the caller can log a warning at build time. @@ -72,20 +84,26 @@ export function generateCliEntry(options: CliEntryOptions): string { const minNodeMajor = minNodeMajorOf(nodeVersion ?? getSelfNodeRange()); const capabilities = schema.capabilities; + const skilledToolNames = skilledOpenApiToolNames(schema.tools, excludeTools); const filteredTools = schema.tools.filter( - (t) => !excludeTools.includes(t.name) && !SYSTEM_TOOL_NAMES.has(t.name), + (t) => + !excludeTools.includes(t.name) && + !SYSTEM_TOOL_NAMES.has(t.name) && + t.name !== ELICITATION_FALLBACK_TOOL && + !(skilledToolNames && Object.values(SKILLED_OPENAPI_TOOLS).includes(t.name)), ); + const skilledTopLevel = !!skilledToolNames && filteredTools.length === 0; const selfContained = !!options.selfContained; const sections: string[] = [ - generateHeader(appName, appVersion, description, serverBundleFilename, outputDefault, authRequired, capabilities, oauthConfig, selfContained, options.env, schema.httpEntryPath), + generateHeader(appName, appVersion, description, serverBundleFilename, outputDefault, authRequired, capabilities, skillCommandNames(skilledToolNames, skilledTopLevel, capabilities), oauthConfig, selfContained, options.env, schema.httpEntryPath), generateToolCommands(filteredTools, appName), generateResourceCommands(schema), generateTemplateCommands(schema.resourceTemplates), generatePromptCommands(schema.prompts), - capabilities.skills ? generateSkillsCommands() : '', + skilledToolNames ? generateSkilledCommands(skilledToolNames, skilledTopLevel) : capabilities.skills ? generateSkillsCommands() : '', capabilities.jobs ? generateJobCommands(schema.jobs) : '', capabilities.workflows ? generateWorkflowCommands() : '', generateSubscribeCommands(), @@ -104,6 +122,23 @@ export function generateCliEntry(options: CliEntryOptions): string { return sections.filter(Boolean).join('\n\n'); } +/** The root commands `--help` lists under Skills. */ +function skillCommandNames(skilledToolNames: string[] | undefined, skilledTopLevel: boolean, capabilities: ExtractedCapabilities): string[] { + if (!skilledToolNames) return capabilities.skills ? ['skills'] : []; + const topLevelNames = (Object.keys(SKILLED_OPENAPI_TOOLS) as Array) + .filter((verb) => skilledTopLevel && skilledToolNames.includes(SKILLED_OPENAPI_TOOLS[verb])) + .map((verb) => TOP_LEVEL_SKILL_COMMANDS[verb].name); + return [...topLevelNames, 'skills']; +} + +function generateSkilledCommands(toolNames: string[], topLevel: boolean): string { + return `require('./skilled-commands').registerSkilledCommands(program, { + getClient: getClient, + toolNames: ${JSON.stringify(toolNames)}, + topLevel: ${topLevel} +});`; +} + function generateHeader( appName: string, appVersion: string, @@ -112,6 +147,7 @@ function generateHeader( outputDefault: string, authRequired: boolean, capabilities: ExtractedCapabilities, + skillCommands: string[], oauthConfig?: OAuthConfig, selfContained?: boolean, env?: Record, @@ -120,7 +156,7 @@ function generateHeader( const hasOAuth = !!oauthConfig; // Build the group routing map dynamically - const skillsRouting = capabilities.skills ? `\n else if (name === 'skills') groups['Skills'].push(sub);` : ''; + const skillsRouting = skillCommands.length > 0 ? `\n else if (${JSON.stringify(skillCommands)}.indexOf(name) !== -1) groups['Skills'].push(sub);` : ''; const jobsRouting = capabilities.jobs ? `\n else if (name === 'job') groups['Jobs'].push(sub);` : ''; const workflowsRouting = capabilities.workflows ? `\n else if (name === 'workflow') groups['Workflows'].push(sub);` : ''; const authRouting = authRequired ? `\n else if (['login', 'logout', 'sessions', 'connect'].indexOf(name) !== -1) groups['Auth'].push(sub);` : ''; @@ -130,7 +166,7 @@ function generateHeader( ` 'Tools': []`, ` 'Resources & Prompts': []`, ]; - if (capabilities.skills) groupEntries.push(` 'Skills': []`); + if (skillCommands.length > 0) groupEntries.push(` 'Skills': []`); if (capabilities.jobs) groupEntries.push(` 'Jobs': []`); if (capabilities.workflows) groupEntries.push(` 'Workflows': []`); if (authRequired) groupEntries.push(` 'Auth': []`); @@ -196,6 +232,7 @@ var path = require('path'); var fs = require('fs'); var os = require('os'); var fmt = require('./output-formatter'); +var elicitation = require('./terminal-elicitation'); ${authRequired ? "var sessions = require('./session-manager');\nvar creds = require('./credential-store');" : ''} ${hasOAuth ? "var oauthHelper = require('./oauth-helper');" : ''} @@ -216,13 +253,19 @@ function daemonEntryPath() { return BUILT_ENTRY_PATH !== null ? BUILT_ENTRY_PATH : (process.env.FRONTMCP_HTTP_ENTRY_PATH || ''); } +// The server's questions are asked in the terminal; --yes answers the yes/no ones without asking +var QUESTION_OPTIONS = { + onElicitation: elicitation.createElicitationHandler({ assumeYes: function() { return !!program.opts().yes; } }), + capabilities: { elicitation: { form: {} } } +}; + async function connectOptions() {${authRequired ? ` var credBlob = await creds.createCredentialStore().get(sessions.getActiveSessionName()); if (!credBlob || !credBlob.token) { throw new Error('Not logged in. Run "' + APP_NAME + ' login" or "' + APP_NAME + ' connect --token " first.'); } - return { authToken: credBlob.token, mode: 'cli' };` : ` - return { mode: 'cli' };`} + return Object.assign({ authToken: credBlob.token, mode: 'cli' }, QUESTION_OPTIONS);` : ` + return Object.assign({ mode: 'cli' }, QUESTION_OPTIONS);`} } async function connectInProcess(connectOpts) { @@ -245,7 +288,8 @@ async function getClient() { if (fs.existsSync(socketPath)) { var dc = require('./daemon-client').createDaemonClient(socketPath, { authToken: connectOpts.authToken, - entryPath: '/' + daemonEntryPath().replace(/^\\/+|\\/+$/g, '') + entryPath: '/' + daemonEntryPath().replace(/^\\/+|\\/+$/g, ''), + onElicitation: connectOpts.onElicitation }); try { await dc.connect(); @@ -300,6 +344,7 @@ program .version(${JSON.stringify(appVersion)}) .description(${JSON.stringify(description || `${appName} CLI`)}) .option('--output ', 'Output format: text or json', ${JSON.stringify(outputDefault)}) + .option('--yes', 'Answer yes to the questions the server asks, such as confirming a destructive action') .option('--verbose', 'Enable verbose console logging (logs always go to ~/.frontmcp/logs/)') .option('--log-dir ', 'Directory for log files (default: ~/.frontmcp/logs/)'); diff --git a/libs/cli/src/commands/build/exec/cli-runtime/skilled-commands.ts b/libs/cli/src/commands/build/exec/cli-runtime/skilled-commands.ts new file mode 100644 index 000000000..ce83139b8 --- /dev/null +++ b/libs/cli/src/commands/build/exec/cli-runtime/skilled-commands.ts @@ -0,0 +1,464 @@ +/** + * The generated CLI's Skilled OpenAPI commands: search, load, read and run a server's bundle skills through the + * plugin's meta-tools. Compiled with the CLI, then bundled into each generated CLI whose server uses the plugin. + */ +import type { Command } from 'commander'; + +import { readFile } from '@frontmcp/utils'; + +/** The Skilled OpenAPI plugin's meta-tools, by the command each one serves. */ +export const SKILLED_OPENAPI_TOOLS = { + search: 'search_skill', + load: 'load_skill', + execute: 'execute_action', + run: 'run_workflow', +} as const; + +type Verb = keyof typeof SKILLED_OPENAPI_TOOLS; + +/** The top-level name of each command, on a CLI whose only tools are the plugin's, with the names it also answers to. */ +export const TOP_LEVEL_SKILL_COMMANDS: Record = { + search: { name: 'search_skill', aliases: ['search-skill'] }, + load: { name: 'load_skill', aliases: ['load-skill'] }, + execute: { name: 'execute', aliases: ['execute_action', 'execute-action'] }, + run: { name: 'run_workflow', aliases: ['run-workflow'] }, +}; + +interface ToolCallResult { + content?: Array<{ type: string; text?: string }>; + structuredContent?: Record; + isError?: boolean; + _meta?: Record; +} + +interface ResourceReadResult { + contents?: Array<{ text?: string }>; +} + +/** The calls these commands make, which the CLI's in-process and daemon clients both serve. */ +export interface SkilledClient { + callTool(name: string, args: Record): Promise; + readResource(uri: string): Promise; +} + +export interface SkilledCommandsOptions { + getClient: () => Promise; + /** The plugin's meta-tools the CLI offers: a command whose tool is left out isn't added. */ + toolNames: readonly string[]; + /** Also add the commands at the top level: for a server whose only tools are the plugin's. */ + topLevel: boolean; + /** Where `-` reads from: standard input by default. */ + stdin?: NodeJS.ReadableStream; +} + +interface JsonSchema { + type?: string | string[]; + properties?: Record; + required?: string[]; + enum?: unknown[]; + default?: unknown; + examples?: unknown[]; +} + +interface SkillMatch { + skillId: string; + name: string; + description: string; + score: number; +} + +interface SkillAction { + actionId: string; + summary: string; + inputJsonSchema: JsonSchema; +} + +interface LoadedSkill { + id: string; + name: string; + description: string; + instructions: string; + actions?: SkillAction[]; +} + +interface LoadSkillResult { + skill: LoadedSkill; + warning?: string; +} + +type LoadSkillOutput = Partial & { skills?: LoadSkillResult[] }; + +interface SkillIndex { + skills?: Array<{ type?: string; name?: string; description?: string }>; +} + +/** Where a command is added: its name, the names it also answers to, and whether it takes the 1.11.0 flags. */ +interface Placement { + name: string; + aliases: string[]; + acceptsLegacyFlags: boolean; +} + +/** A mistake on the command line, which exits 2. */ +class UsageError extends Error {} + +interface CommandContext { + program: Command; + options: SkilledCommandsOptions; + /** The command line that runs a verb, for the hints a command prints. */ + commandLine: (verb: Verb) => string; +} + +/** Add the Skilled OpenAPI commands: always under `skills`, and also at the top level when `topLevel` is set. */ +export function registerSkilledCommands(program: Command, options: SkilledCommandsOptions): void { + const offered = (Object.keys(SKILLED_OPENAPI_TOOLS) as Verb[]).filter((verb) => + options.toolNames.includes(SKILLED_OPENAPI_TOOLS[verb]), + ); + const context: CommandContext = { + program, + options, + commandLine: (verb) => + options.topLevel + ? `${program.name()} ${TOP_LEVEL_SKILL_COMMANDS[verb].name}` + : `${program.name()} skills ${verb}`, + }; + + if (options.topLevel) { + for (const verb of offered) { + ADD_COMMAND[verb](program, { ...TOP_LEVEL_SKILL_COMMANDS[verb], acceptsLegacyFlags: true }, context); + } + } + + const group = program + .command('skills') + .description(`Find, load and run skills: ${[...offered, 'read', 'list'].join(', ')}`); + group.addHelpText('after', `\nThe skills you can use: ${program.name()} skills list`); + for (const verb of offered) { + ADD_COMMAND[verb](group, { name: verb, aliases: [], acceptsLegacyFlags: false }, context); + } + addReadCommand(group, context); + addListCommand(group, context); +} + +const ADD_COMMAND: Record void> = { + search: addSearchCommand, + load: addLoadCommand, + execute: addExecuteCommand, + run: addRunCommand, +}; + +function addSearchCommand(parent: Command, placement: Placement, context: CommandContext): void { + const command = parent + .command(`${placement.name} [query...]`) + .aliases(placement.aliases) + .description('Find the skills for what you want to do') + .option('--limit ', 'At most this many skills (default 20)') + .option('--tags ', 'Only skills with these tags, comma-separated') + .option('--not-query ', 'Rank the skills matching this lower'); + if (placement.acceptsLegacyFlags) command.addOption(command.createOption('--query ').hideHelp()); + + command.action((words: string[], flags: { limit?: string; tags?: string; notQuery?: string; query?: string }) => + runCommand(async () => { + const query = words.join(' ') || flags.query; + if (!query) throw new UsageError(`say what you want to do, as in: ${context.commandLine('search')} "refund an invoice"`); + const args: Record = { query }; + if (flags.limit !== undefined) args['limit'] = positiveInteger(flags.limit, '--limit'); + if (flags.tags !== undefined) args['tags'] = commaSeparated(flags.tags); + if (flags.notQuery !== undefined) args['notQuery'] = flags.notQuery; + await callAndReport(context, SKILLED_OPENAPI_TOOLS.search, args, (output) => + renderSkillMatches(output as { skills?: SkillMatch[] }, context.commandLine('load')), + ); + }), + ); +} + +function addLoadCommand(parent: Command, placement: Placement, context: CommandContext): void { + const skillsArgument = placement.acceptsLegacyFlags ? '[skills...]' : ''; + const command = parent + .command(`${placement.name} ${skillsArgument}`) + .aliases(placement.aliases) + .description("Show skills' instructions, and their actions with a ready command for each"); + if (placement.acceptsLegacyFlags) command.addOption(command.createOption('--skill-id ').hideHelp()); + + command.action((names: string[], flags: { skillId?: string }) => + runCommand(async () => { + const skillIds = flags.skillId === undefined ? names : [...names, flags.skillId]; + if (skillIds.length === 0) throw new UsageError(`name a skill, as in: ${context.commandLine('load')} invoices`); + const args = skillIds.length === 1 ? { skillId: skillIds[0] } : { skillIds }; + await callAndReport(context, SKILLED_OPENAPI_TOOLS.load, args, (output) => + renderLoadedSkills(output as LoadSkillOutput, context.commandLine('execute')), + ); + }), + ); +} + +function addExecuteCommand(parent: Command, placement: Placement, context: CommandContext): void { + parent + .command(`${placement.name} `) + .aliases(placement.aliases) + .description('Run one action of a skill, with its input as JSON (a destructive one asks first; --yes confirms)') + .option('--input ', "The action's input as a JSON object, or - to read it from standard input") + .option('--input-file ', "Read the action's input from a JSON file") + .action((actionId: string, flags: { input?: string; inputFile?: string }) => + runCommand(async () => { + const args: Record = { actionId }; + const input = await actionInput(flags, context.options); + if (input !== undefined) args['input'] = input; + await callAndReport(context, SKILLED_OPENAPI_TOOLS.execute, args, renderActionData); + }), + ); +} + +function addRunCommand(parent: Command, placement: Placement, context: CommandContext): void { + const command = parent + .command(`${placement.name} [script]`) + .aliases(placement.aliases) + .description('Run a workflow script that chains actions: the script, --file, or - for standard input') + .option('--file ', 'Read the script from a file'); + if (placement.acceptsLegacyFlags) command.addOption(command.createOption('--script ').hideHelp()); + + command.action((scriptArgument: string | undefined, flags: { file?: string; script?: string }) => + runCommand(async () => { + const script = await workflowScript(scriptArgument, flags, context); + await callAndReport(context, SKILLED_OPENAPI_TOOLS.run, { script }, renderWorkflowValue); + }), + ); +} + +function addReadCommand(group: Command, context: CommandContext): void { + group + .command('read ') + .description("Show a skill's SKILL.md, by its name or id") + .action((skill: string) => + runCommand(async () => { + const skillPath = skill.split('/').map(encodeURIComponent).join('/'); + const result = await (await context.options.getClient()).readResource(`skill://${skillPath}/SKILL.md`); + if (outputMode(context.program) === 'json') console.log(JSON.stringify(result, null, 2)); + else console.log(resourceText(result)); + }), + ); +} + +function addListCommand(group: Command, context: CommandContext): void { + group + .command('list') + .description('List the skills you can use') + .action(() => + runCommand(async () => { + const result = await (await context.options.getClient()).readResource('skill://index.json'); + const index = JSON.parse(resourceText(result)) as SkillIndex; + if (outputMode(context.program) === 'json') { + console.log(JSON.stringify(index, null, 2)); + return; + } + const skills = (index.skills ?? []).filter((entry) => entry.type === 'skill-md'); + if (skills.length === 0) { + console.log('No skills found.'); + return; + } + console.log(skills.map((skill) => `${skill.name}\n ${skill.description ?? ''}`).join('\n\n')); + console.log(`\nLoad one: ${context.commandLine('load')} `); + }), + ); +} + +async function runCommand(work: () => Promise): Promise { + try { + await work(); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = error instanceof UsageError ? 2 : 1; + } +} + +/** Call the tool and print its answer: the structured content with `--output json`, else the text the tool's answer reads as. */ +async function callAndReport( + context: CommandContext, + toolName: string, + args: Record, + renderText: (output: unknown, result: ToolCallResult) => string | undefined, +): Promise { + const result = await (await context.options.getClient()).callTool(toolName, args); + const json = outputMode(context.program) === 'json'; + if (json) console.log(JSON.stringify(result.structuredContent ?? result, null, 2)); + if (result.isError) { + console.error(`Error: ${toolErrorText(result)}`); + process.exitCode = result._meta?.['code'] === 'INVALID_INPUT' ? 2 : 1; + return; + } + const text = json ? undefined : renderText(result.structuredContent ?? {}, result); + if (text !== undefined) console.log(text); +} + +function outputMode(program: Command): 'text' | 'json' { + return program.opts()['output'] === 'json' ? 'json' : 'text'; +} + +function textOf(result: ToolCallResult): string { + return (result.content ?? []) + .filter((block) => block.type === 'text') + .map((block) => block.text ?? '') + .join('\n'); +} + +function toolErrorText(result: ToolCallResult): string { + const error = result.structuredContent?.['error']; + return typeof error === 'string' ? error : textOf(result) || 'the call failed'; +} + +function resourceText(result: ResourceReadResult): string { + return (result.contents ?? []).map((content) => content.text ?? '').join('\n'); +} + +function positiveInteger(value: string, flag: string): number { + const number = Number(value); + if (!Number.isInteger(number) || number < 1) throw new UsageError(`${flag} takes a positive whole number, not "${value}"`); + return number; +} + +function commaSeparated(value: string): string[] { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +async function readStandardInput(options: SkilledCommandsOptions): Promise { + const chunks: string[] = []; + for await (const chunk of options.stdin ?? process.stdin) chunks.push(chunk.toString()); + return chunks.join(''); +} + +/** Read a file, or standard input for `-`. */ +async function readSource(path: string, options: SkilledCommandsOptions): Promise { + if (path === '-') return readStandardInput(options); + try { + return await readFile(path); + } catch (error) { + throw new UsageError(`can't read ${path}: ${error instanceof Error ? error.message : String(error)}`); + } +} + +async function actionInput( + flags: { input?: string; inputFile?: string }, + options: SkilledCommandsOptions, +): Promise | undefined> { + if (flags.input !== undefined && flags.inputFile !== undefined) { + throw new UsageError('pass the input with --input or --input-file, not both'); + } + if (flags.inputFile !== undefined) return jsonObject(await readSource(flags.inputFile, options), flags.inputFile); + if (flags.input === '-') return jsonObject(await readStandardInput(options), 'standard input'); + return flags.input === undefined ? undefined : jsonObject(flags.input, '--input'); +} + +function jsonObject(text: string, source: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new UsageError(`${source} is not JSON: ${error instanceof Error ? error.message : String(error)}`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new UsageError(`${source} must be a JSON object, such as '{"id":"INV-1001"}'`); + } + return parsed as Record; +} + +async function workflowScript( + scriptArgument: string | undefined, + flags: { file?: string; script?: string }, + context: CommandContext, +): Promise { + const sources = [scriptArgument, flags.file, flags.script].filter((source): source is string => source !== undefined); + if (sources.length !== 1) { + throw new UsageError( + `give the script once: as an argument, with --file , or - for standard input, as in: ` + + `${context.commandLine('run')} --file refund.js`, + ); + } + if (scriptArgument === '-') return readStandardInput(context.options); + return flags.file === undefined ? sources[0] : readSource(flags.file, context.options); +} + +function renderSkillMatches(output: { skills?: SkillMatch[] }, loadCommand: string): string { + const skills = output.skills ?? []; + if (skills.length === 0) return 'No skills found.'; + const matches = skills.map( + (skill) => `${skill.name} (${skill.skillId}) score ${skill.score.toFixed(2)}\n ${skill.description}`, + ); + return `${matches.join('\n\n')}\n\nLoad one: ${loadCommand} `; +} + +function renderLoadedSkills(output: LoadSkillOutput, executeCommand: string): string { + const results = output.skills ?? (output.skill ? [{ skill: output.skill, warning: output.warning }] : []); + return results.map((result) => renderLoadedSkill(result, executeCommand)).join('\n\n'); +} + +function renderLoadedSkill({ skill, warning }: LoadSkillResult, executeCommand: string): string { + const lines = [`${skill.name} (${skill.id})`, skill.description]; + if (warning) lines.push(`Warning: ${warning}`); + lines.push('', skill.instructions); + const actions = skill.actions ?? []; + if (actions.length > 0) { + lines.push('', `Actions (${actions.length}):`); + for (const action of actions) lines.push('', ...renderAction(action, executeCommand)); + } + return lines.join('\n'); +} + +function renderAction(action: SkillAction, executeCommand: string): string[] { + const schema = action.inputJsonSchema; + const example = exampleObject(schema); + const inputFlag = Object.keys(example).length > 0 ? ` --input ${shellQuoted(JSON.stringify(example))}` : ''; + const lines = [` ${action.actionId}: ${action.summary}`, ` ${executeCommand} ${action.actionId}${inputFlag}`]; + const required = schema.required ?? []; + const optional = Object.keys(schema.properties ?? {}).filter((name) => !required.includes(name)); + if (optional.length > 0) lines.push(` optional input: ${optional.join(', ')}`); + return lines; +} + +/** An input with each required field, set to its example, default or first allowed value, else a placeholder. */ +function exampleObject(schema: JsonSchema): Record { + const properties = schema.properties ?? {}; + const required = (schema.required ?? []).filter((name) => properties[name] !== undefined); + return Object.fromEntries(required.map((name) => [name, exampleValue(name, properties[name])])); +} + +function exampleValue(name: string, schema: JsonSchema): unknown { + if (schema.examples && schema.examples.length > 0) return schema.examples[0]; + if (schema.default !== undefined) return schema.default; + if (schema.enum && schema.enum.length > 0) return schema.enum[0]; + const type = Array.isArray(schema.type) ? schema.type.find((candidate) => candidate !== 'null') : schema.type; + switch (type) { + case 'object': + return exampleObject(schema); + case 'array': + return []; + case 'integer': + case 'number': + return 0; + case 'boolean': + return false; + default: + return `<${name}>`; + } +} + +function shellQuoted(text: string): string { + return `'${text.replace(/'/g, `'\\''`)}'`; +} + +function renderActionData(_output: unknown, result: ToolCallResult): string { + const text = textOf(result); + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } +} + +function renderWorkflowValue(output: unknown): string | undefined { + const { value } = output as { value?: unknown }; + if (value === undefined) return undefined; + return typeof value === 'string' ? value : JSON.stringify(value, null, 2); +} diff --git a/libs/cli/src/commands/build/exec/cli-runtime/terminal-elicitation.ts b/libs/cli/src/commands/build/exec/cli-runtime/terminal-elicitation.ts new file mode 100644 index 000000000..ca9b45a8d --- /dev/null +++ b/libs/cli/src/commands/build/exec/cli-runtime/terminal-elicitation.ts @@ -0,0 +1,80 @@ +/** + * How the generated CLI answers a server's questions (MCP elicitation), such as confirming a destructive action: in + * the terminal, yes for every yes/no question with `--yes`, and declined when there is no terminal to ask on. + * + * NOTE: This file is used as a CJS runtime module, bundled into the CLI output beside the generated CLI entry; the + * source it returns must stay plain JavaScript. + */ + +/** The `terminal-elicitation` module's source: `createElicitationHandler({ assumeYes, input?, output? })`. */ +export function generateTerminalElicitationSource(): string { + return `'use strict'; + +var readline = require('readline'); + +function isYesNoQuestion(fields) { + return Object.keys(fields).every(function(name) { return fields[name].type === 'boolean'; }); +} + +function allYes(fields) { + var content = {}; + Object.keys(fields).forEach(function(name) { content[name] = true; }); + return { action: 'accept', content: content }; +} + +function fieldValue(field, answer) { + if (field.type === 'boolean') return /^(y|yes|true)$/i.test(answer); + if (field.type === 'number' || field.type === 'integer') return Number(answer); + return answer; +} + +/** + * The CLI's elicitation handler, as \`connect({ onElicitation })\` and the daemon client take it. + * @param {{ assumeYes: () => boolean, input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream }} options + */ +function createElicitationHandler(options) { + var input = options.input || process.stdin; + var output = options.output || process.stderr; + + function ask(question) { + return new Promise(function(resolve) { + var prompt = readline.createInterface({ input: input, output: output }); + prompt.question(question, function(answer) { + prompt.close(); + resolve(answer.trim()); + }); + }); + } + + async function askEachField(fields) { + var content = {}; + var names = Object.keys(fields); + for (var i = 0; i < names.length; i++) { + var field = fields[names[i]]; + var answer = await ask(' ' + (field.title || names[i]) + (field.description ? ' (' + field.description + ')' : '') + ': '); + if (answer !== '') content[names[i]] = fieldValue(field, answer); + } + return { action: 'accept', content: content }; + } + + return async function answerElicitation(request) { + var fields = (request.requestedSchema && request.requestedSchema.properties) || {}; + var yesNo = isYesNoQuestion(fields); + if (yesNo && options.assumeYes()) return allYes(fields); + if (!input.isTTY) { + var retry = yesNo ? ' Run the command again with --yes to confirm.' : ''; + output.write(request.message + '\\nDeclined: there is no terminal to ask on.' + retry + '\\n'); + return { action: 'decline' }; + } + if (!yesNo) { + output.write(request.message + '\\n'); + return askEachField(fields); + } + var answer = await ask(request.message + ' [y/N] '); + return /^y(es)?$/i.test(answer) ? allYes(fields) : { action: 'decline' }; + }; +} + +exports.createElicitationHandler = createElicitationHandler; +`; +} diff --git a/libs/cli/src/commands/build/exec/index.ts b/libs/cli/src/commands/build/exec/index.ts index 716463e4c..830cc12a0 100644 --- a/libs/cli/src/commands/build/exec/index.ts +++ b/libs/cli/src/commands/build/exec/index.ts @@ -218,6 +218,7 @@ export async function buildExec( const { generateCredentialStoreSource } = await import('./cli-runtime/credential-store.js'); const { generateOAuthHelperSource } = await import('./cli-runtime/oauth-helper.js'); const { generateDaemonClientSource } = await import('./cli-runtime/daemon-client.js'); + const { generateTerminalElicitationSource } = await import('./cli-runtime/terminal-elicitation.js'); const { bundleCliWithEsbuild } = await import('./cli-runtime/cli-bundler.js'); // Reuse the schema extracted in step 6 (single extraction per build). @@ -301,6 +302,10 @@ export async function buildExec( path.join(tempDir, 'daemon-client.js'), generateDaemonClientSource(), ); + fs.writeFileSync( + path.join(tempDir, 'terminal-elicitation.js'), + generateTerminalElicitationSource(), + ); // Issue #411 — copy the compiled plugin-emitter so the per-bin // ` install -p claude|codex` can `require('./plugin-emitter')`. @@ -308,14 +313,13 @@ export async function buildExec( // final CLI bundle. The emitter imports `./skill-md-compose`, so the // sibling file has to land in __cli_temp/ too — otherwise the esbuild // step that produces the bin's CLI bundle fails with a missing-module - // error. Tolerate absence in test/source-only contexts. - const pluginEmitterSrc = path.join(__dirname, 'cli-runtime', 'plugin-emitter.js'); - if (fs.existsSync(pluginEmitterSrc)) { - fs.copyFileSync(pluginEmitterSrc, path.join(tempDir, 'plugin-emitter.js')); - } - const skillMdComposeSrc = path.join(__dirname, 'cli-runtime', 'skill-md-compose.js'); - if (fs.existsSync(skillMdComposeSrc)) { - fs.copyFileSync(skillMdComposeSrc, path.join(tempDir, 'skill-md-compose.js')); + // error. The Skilled OpenAPI commands (`./skilled-commands`) are a + // compiled module as well. Tolerate absence in test/source-only contexts. + for (const compiledModule of ['plugin-emitter.js', 'skill-md-compose.js', 'skilled-commands.js']) { + const compiledModulePath = path.join(__dirname, 'cli-runtime', compiledModule); + if (fs.existsSync(compiledModulePath)) { + fs.copyFileSync(compiledModulePath, path.join(tempDir, compiledModule)); + } } // Generate CLI entry diff --git a/libs/skills/catalog/frontmcp-config/references/configure-elicitation.md b/libs/skills/catalog/frontmcp-config/references/configure-elicitation.md index 826bc8437..18e8f6775 100644 --- a/libs/skills/catalog/frontmcp-config/references/configure-elicitation.md +++ b/libs/skills/catalog/frontmcp-config/references/configure-elicitation.md @@ -248,8 +248,9 @@ class Server {} - A decline sends nothing and fails the call with `the user declined: `. - A client without the elicitation capability, or a server with `enabled: false`, can't be asked: the action is refused (`whenUnsupported: 'refuse'`, the default) or runs unasked (`'allow'`). The plugin doesn't use the - elicitation fallback, where the model would answer for the user. The generated CLI declares no elicitation - capability, so it is refused too. + elicitation fallback, where the model would answer for the user. +- The generated CLI (`frontmcp build --target cli`) asks in the terminal (`y/N`), in-process and through its daemon; + `--yes` answers yes, and with no terminal and no `--yes` it declines. See `build-for-cli`. ## Notes diff --git a/libs/skills/catalog/frontmcp-deployment/references/build-for-cli.md b/libs/skills/catalog/frontmcp-deployment/references/build-for-cli.md index 1d8fc74da..a1fcb61b8 100644 --- a/libs/skills/catalog/frontmcp-deployment/references/build-for-cli.md +++ b/libs/skills/catalog/frontmcp-deployment/references/build-for-cli.md @@ -111,30 +111,74 @@ daemon needs `MCP_SESSION_SECRET` like any server. Up to 1.11.0, the commands posted to `/mcp` without a session, so they never reached the daemon and always ran in-process. -` skills read ` and ` skills load ` exit 1 when a skill -is not found, and name it on stderr; `skills load` prints how many of the -requested skills it loaded. Up to 1.11.0 both exited 0. - -On a Skilled OpenAPI server, ` run-workflow --script ''` -exits 1 when the workflow fails (`success: false`), as for any tool that -answers `isError: true`, and `--output json` prints the result with its -`structuredContent` (`success`, `error`, `stats`). The error names a failed -action's status and the API's error body. Up to 1.11.0 a failed workflow -exited 0. - -` execute-action --action-id --input ''` runs one action -with no script (`--input` can be left out for an action that takes none). It -prints the action's data and exits 0, or exits 1 when the action fails: an -authority denial, a failed input or output check, a timeout, an unknown action, -or an error status, whose text names the API's status and error body. With -`--output json`, a failure's `structuredContent` is `{ ok: false, status, -data?, error? }`, `data` being the API's error body. - -An action the plugin's `confirm` option names asks the user through an MCP -elicitation, which the CLI can't show, in-process or through the daemon: its -`execute-action` and `run-workflow` exit 1 with `the user's confirmation is -needed for , and this client can't ask for it`, and send nothing, -unless the server sets `confirm.whenUnsupported: 'allow'`. +On a server without the Skilled OpenAPI plugin, ` skills read ` and +` skills load ` exit 1 when a skill is not found, and name it on +stderr; `skills load` prints how many of the requested skills it loaded. Up to +1.11.0 both exited 0. + +### Skilled OpenAPI commands + +When the server uses the Skilled OpenAPI plugin (its tools include +`search_skill`, `load_skill`, `execute_action` and `run_workflow`), the bundle's +skills are tools, not standard skills. The build detects the plugin's four +tools and generates these commands in place of the standard `skills` commands: + +| Command | What it does | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `skills search ` | `search_skill`, with `--limit `, `--tags a,b` and `--not-query `: each skill's name, id, score and description | +| `skills load ` | `load_skill`: the instructions, then each action with its summary, a ready ` execute --input ''` line (its required fields) and its optional fields | +| `skills execute ` | `execute_action`, with `--input ''`, `--input -` (standard input) or `--input-file `: the action's data | +| `skills run [script]` | `run_workflow`, with the script as the argument, `--file `, or `-` for standard input: the value the script returns | +| `skills read ` | The skill's `skill:///SKILL.md` resource | +| `skills list` | The skills the caller can use, from `skill://index.json` | + +```bash +mymcp search_skill "refund an invoice" +mymcp load_skill invoices +mymcp execute getInvoice --input '{"id":"INV-1001"}' +mymcp run_workflow --file refund.js +``` + +- On a server whose only tools are the plugin's, the commands are also + top-level: `search_skill`, `load_skill`, `execute` and `run_workflow`. They + answer to `search-skill`, `load-skill`, `execute_action` / `execute-action` + and `run-workflow` too, and take the 1.11.0 flags `--query`, `--skill-id` and + `--script`. +- A server with tools of its own has them only under `skills`. Scripts that + use `skills ...` keep working if the server later gains tools. +- The plugin's tools are never generated as tool commands. `cli.excludeTools` + naming one of them leaves its command out. +- `--output json` prints the tool's structured content (the whole result when + it has none). +- Exit codes: 0 on success; 1 when the tool fails (a failed action or + workflow, a skill not found, an authority denial); 2 for a usage error (no + query or script, an input that is not a JSON object, two script sources). + The error goes to stderr. + +Up to 1.11.0, the plugin's tools were plain tool commands +(`search-skill --query`, `load-skill --skill-id`, `run-workflow --script`), +`skills search` and `skills load` showed no actions, and nothing ran one +action. + +### Questions from the server + +The CLI answers a tool's `this.elicit()` questions, in-process and through the +daemon. The server needs `elicitation: { enabled: true }`. + +- A yes/no question, such as the Skilled OpenAPI plugin's confirmation of an + action its `confirm` option names, is printed on stderr and answered `y/N` + when standard input is a terminal. +- The global `--yes`, before or after the command, answers every yes/no + question with yes, without asking. +- With no terminal and no `--yes`, the CLI declines and prints the question and + `Declined: there is no terminal to ask on. Run the command again with --yes +to confirm.` The action is not sent, and the command exits 1 with + `the user declined: `. +- A question with other fields asks each one in the terminal. + +Up to 1.11.0, the CLI could not answer: a confirmation was refused with +`this client can't ask for it`, and the server's `sendElicitationResult` +fallback tool was a command. That tool is no longer one. ## Requirements @@ -276,8 +320,9 @@ frontmcp service uninstall my-server | Binary too large | All dependencies bundled including dev deps | Review dependencies and remove unused packages from bundle | | Permission denied on binary | Missing execute permission | Run `chmod +x dist/my-server` | | Binary fails on different OS | SEA binaries are platform-specific | Build on the target OS or use CI matrix builds | -| `run-workflow` exits 1 | The workflow failed (`success: false`) | Read `error` (`--output json`); it names the API's status and error body | -| `execute-action` exits 1 | The action failed | Read the text, or `structuredContent` (`--output json`): status and body | +| `run_workflow` exits 1 | The workflow failed (`success: false`) | Read the error on stderr; it names the API's status and error body | +| `execute` exits 1 | The action failed, or the user declined it | Read the error on stderr; `--output json` prints `{ ok, status, data? }` | +| `execute` exits 1, declined | No terminal to confirm a destructive action | Run it again with `--yes` | ## Examples diff --git a/plugins/plugin-skilled-openapi/README.md b/plugins/plugin-skilled-openapi/README.md index 6344d65fc..7300b8e2e 100644 --- a/plugins/plugin-skilled-openapi/README.md +++ b/plugins/plugin-skilled-openapi/README.md @@ -115,7 +115,7 @@ return { invoice: invoice.data }; ``` A workflow that fails (`success: false`) is an error result: `isError: true`, with the same `structuredContent`, so -an MCP client sees a failed tool call and the generated CLI's `run-workflow` exits 1. Up to 1.11.0 the result had no +an MCP client sees a failed tool call and the generated CLI's `run_workflow` exits 1. Up to 1.11.0 the result had no `isError`, and the CLI exited 0. When a script ends, whatever ends it, the plugin aborts the requests it still has in flight and sends none after, so a @@ -135,7 +135,7 @@ still lists such an action, and the call can be refused once its input is known. so it works without `@enclave-vm/core`. It runs the same checks as a script's `callTool`. On success its structured content is the operation's data: a JSON object as it is, anything else under `value`; a bodyless answer, such as a 204, has no structured content and a text that names the status. A failure is an error result, so an MCP client sees a -failed call and the generated CLI's `execute-action` exits 1: +failed call and the generated CLI's `execute` exits 1: ```jsonc // execute_action { "actionId": "refundInvoice", "input": { "id": "INV-1", "amount": 500 } } @@ -210,8 +210,10 @@ yes/no form: it: it doesn't support elicitation; nothing was sent`. With `whenUnsupported: 'allow'` the action runs unasked there instead. The plugin doesn't use the SDK's elicitation fallback, in which the model would answer for the user. -The generated CLI can't show the question, in-process or through its daemon, so its `execute-action` and -`run-workflow` exit 1 for such an action, with that refusal. +The generated CLI (`frontmcp build --target cli`) asks the question in the terminal, in-process and through its daemon: +it reads `y/N` when standard input is a terminal, and `--yes` answers yes without asking. With no terminal and no +`--yes` it declines, so `execute` and `run_workflow` exit 1 with `the user declined: voidInvoice`. Up to 1.11.0 the +CLI couldn't ask, and the action was refused. Operation tags come from the bundle: `compileSkilledBundleFromOpenApi()` keeps each operation's OpenAPI `tags` and its `x-frontmcp.tags`. Skill tags are the bundle skill's `tags`. From 38945380e20d97f544af48b443e99e8c77eca4dc Mon Sep 17 00:00:00 2001 From: David Antoon Date: Wed, 4 Nov 2026 23:36:13 +0200 Subject: [PATCH 4/4] test(e2e): the generated CLI's Skilled OpenAPI commands and confirmation, end to end A CLI built for a server with only the plugin's tools: help, search, load with its execute lines, execute (data, --input-file, --input -, failure, unknown action), run_workflow (argument, --file, -, failure), --output json, the 1.11.0 flags, and a destructive action declined with no terminal and run with --yes, in-process and through the daemon. A second fixture with a tool of its own has the commands only under skills. --- .../e2e/cli-skilled-openapi.e2e.spec.ts | 420 ++++++++++++------ .../demo-e2e-cli-exec/e2e/helpers/exec-cli.ts | 11 +- .../e2e/helpers/skilled-cli-project.ts | 36 +- 3 files changed, 320 insertions(+), 147 deletions(-) diff --git a/apps/e2e/demo-e2e-cli-exec/e2e/cli-skilled-openapi.e2e.spec.ts b/apps/e2e/demo-e2e-cli-exec/e2e/cli-skilled-openapi.e2e.spec.ts index 79b190e97..7f2b3e8e1 100644 --- a/apps/e2e/demo-e2e-cli-exec/e2e/cli-skilled-openapi.e2e.spec.ts +++ b/apps/e2e/demo-e2e-cli-exec/e2e/cli-skilled-openapi.e2e.spec.ts @@ -1,14 +1,14 @@ /** - * E2E: a CLI built for a server whose skills a plugin registers at runtime (a Skilled OpenAPI bundle). A one-shot - * command sees those skills, `skills read` / `skills load` exit 1 for a skill they do not find, `run-workflow` and - * `execute-action` exit 1 for a call that fails, an action that needs the user's confirmation is refused, since the - * CLI can't ask, and a running daemon serves the commands from the bundle it loaded once. + * E2E: a CLI built for a server that serves a Skilled OpenAPI bundle. On a server whose only tools are the plugin's, + * `search_skill`, `load_skill`, `execute` and `run_workflow` are top-level commands, and the same commands live under + * `skills`; on a server with tools of its own, only under `skills`. A destructive action asks for confirmation, which + * `--yes` gives and a command with no terminal declines, in-process and through the daemon. */ import { createServer } from 'node:http'; import * as os from 'node:os'; import * as path from 'node:path'; -import { mkdtemp, rename, rm } from '@frontmcp/utils'; +import { mkdtemp, rename, rm, writeFile } from '@frontmcp/utils'; import { removeScratchProject } from './helpers/dev-cli'; import { runCliBundle, runCliBundleAsync, type CliResult } from './helpers/exec-cli'; @@ -16,13 +16,14 @@ import { buildSkilledCliProject, type SkilledCliProject } from './helpers/skille const APP_NAME = 'skilled-cli-demo'; const INVOICE = { id: 'inv_1', status: 'paid' }; +const CONFIRM_DECLINED = 'Declined: there is no terminal to ask on. Run the command again with --yes to confirm.'; -describe('a CLI whose skills a plugin registers at runtime', () => { +describe("a CLI for a server whose only tools are the Skilled OpenAPI plugin's", () => { let project: SkilledCliProject; let homeDir: string; const apiRequests: string[] = []; const invoiceApi = createServer((req, res) => { - apiRequests.push(req.url ?? ''); + apiRequests.push(`${req.method} ${req.url}`); const found = req.url === '/v1/invoices/inv_1'; res.writeHead(found ? 200 : 404, { 'content-type': 'application/json' }); res.end(JSON.stringify(found ? INVOICE : { error: 'invoice not found' })); @@ -33,8 +34,8 @@ describe('a CLI whose skills a plugin registers at runtime', () => { } // The command calls the API this process serves, so it must not block this process - function runReachingTheApi(args: string[]): Promise { - return runCliBundleAsync(project.cliBundle, args, { FRONTMCP_HOME: homeDir }); + function runReachingTheApi(args: string[], stdin?: string): Promise { + return runCliBundleAsync(project.cliBundle, args, { FRONTMCP_HOME: homeDir }, stdin); } beforeAll(async () => { @@ -62,159 +63,253 @@ describe('a CLI whose skills a plugin registers at runtime', () => { apiRequests.length = 0; }); - it('lists the bundle skills', () => { - const { stdout, exitCode } = run(['skills', 'list']); + describe('help', () => { + it('lists the skill commands at the top level, and no tool commands', () => { + const { stdout, exitCode } = run(['--help']); + + expect(exitCode).toBe(0); + const skillsSection = stdout.slice(stdout.indexOf('Skills:')); + expect(skillsSection).toMatch(/search_skill\|search-skill \[options\] \[query\.\.\.\]\s+Find the skills/); + expect(skillsSection).toMatch(/load_skill\|load-skill \[options\] \[skills\.\.\.\]\s+Show skills' instructions/); + expect(skillsSection).toMatch(/execute\|execute_action \[options\] \s+Run one action/); + expect(skillsSection).toMatch(/run_workflow\|run-workflow \[options\] \[script\]\s+Run a workflow script/); + expect(skillsSection).toMatch(/skills\s+Find, load and run skills/); + expect(stdout).not.toContain('Tools:'); + expect(stdout).not.toContain('--query'); + expect(stdout).toContain('--yes'); + }); - expect(exitCode).toBe(0); - expect(stdout).toContain('Invoices'); + it('lists the skills group commands, and how to see the skills', () => { + const { stdout, exitCode } = run(['skills', '--help']); + + expect(exitCode).toBe(0); + for (const command of [ + 'search [options] [query...]', + 'load ', + 'execute [options] ', + 'run [options] [script]', + 'read ', + 'list', + ]) { + expect(stdout).toContain(command); + } + expect(stdout).toContain(`The skills you can use: ${APP_NAME} skills list`); + }); }); - it('searches the bundle skills', () => { - const { stdout, exitCode } = run(['skills', 'search', 'invoices']); + describe('search', () => { + it('lists the matching skills with their scores, and how to load one', () => { + const { stdout, exitCode } = run(['search_skill', 'look up an invoice']); - expect(exitCode).toBe(0); - expect(stdout).toContain('Invoices'); - }); + expect(exitCode).toBe(0); + expect(stdout).toMatch(/Invoices \(invoices\) {2}score \d\.\d\d\n {2}Look up invoices\./); + expect(stdout).toContain(`Load one: ${APP_NAME} load_skill `); + }); - it("reads a bundle skill's SKILL.md resource", () => { - const { stdout, exitCode } = run(['resource', 'read', 'skill://Invoices/SKILL.md']); + it("prints the tool's structured content with --output json", () => { + const { stdout, exitCode } = run(['--output', 'json', 'search-skill', 'invoices', '--limit', '1']); - expect(exitCode).toBe(0); - expect(stdout).toContain('name: Invoices'); - expect(stdout).toContain('Find an invoice by its id.'); - }); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toEqual({ + skills: [expect.objectContaining({ skillId: 'invoices', name: 'Invoices', bundleVersion: '1.0.0' })], + }); + }); - it('loads a bundle skill', () => { - const { stdout, exitCode } = run(['skills', 'load', 'invoices']); + it('serves skills search and the 1.11.0 search-skill --query alike', () => { + expect(run(['skills', 'search', 'invoices']).stdout).toContain('Invoices (invoices)'); + expect(run(['search-skill', '--query', 'invoices']).stdout).toContain('Invoices (invoices)'); + }); - expect(exitCode).toBe(0); - expect(stdout).toContain('Loaded 1 of 1 skill(s).'); + it('exits 2 without a query', () => { + expect(run(['search_skill'])).toMatchObject({ + exitCode: 2, + stderr: expect.stringContaining('say what you want to do'), + }); + }); }); - it('exits 1 from skills load when a skill is not found, and names it', () => { - const { stdout, stderr, exitCode } = run(['skills', 'load', 'invoices', 'refunds']); + describe('load', () => { + it('prints the instructions, then each action with a ready execute command', () => { + const { stdout, exitCode } = run(['load_skill', 'invoices']); + + expect(exitCode).toBe(0); + expect(stdout).toContain('Invoices (invoices)\nLook up invoices.\n\n# Invoices\n\nFind an invoice by its id.'); + expect(stdout).toContain('Actions (2):'); + expect(stdout).toContain( + ` getInvoice: Get an invoice by id.\n ${APP_NAME} execute getInvoice --input '{"id":""}'`, + ); + expect(stdout).toContain( + ` voidInvoice: Void an invoice.\n ${APP_NAME} execute voidInvoice --input '{"id":""}'`, + ); + }); - expect(exitCode).toBe(1); - expect(stdout).toContain('Loaded 1 of 2 skill(s).'); - expect(stderr).toContain('Skill "refunds" not found'); - }); + it('gives the actions with their schemas with --output json', () => { + const { stdout } = run(['--output', 'json', 'load_skill', 'invoices']); - it('exits 1 from skills read when the skill is not found', () => { - const { stdout, stderr, exitCode } = run(['skills', 'read', 'refunds']); + expect(JSON.parse(stdout)).toMatchObject({ + skill: { + id: 'invoices', + actions: [{ actionId: 'getInvoice', inputJsonSchema: { required: ['id'] } }, { actionId: 'voidInvoice' }], + }, + }); + }); - expect(exitCode).toBe(1); - expect(stdout).not.toContain('Loaded'); - expect(stderr).toContain('Skill "refunds" not found.'); - }); + it('exits 1 when a skill is not found, and names it', () => { + const { stderr, exitCode } = run(['load_skill', 'invoices', 'refunds']); - it('exits 1 from skills read --output json when the skill is not found', () => { - const { stdout, exitCode } = run(['--output', 'json', 'skills', 'read', 'refunds']); + expect(exitCode).toBe(1); + expect(stderr).toContain('Skill "refunds" not found'); + }); - expect(exitCode).toBe(1); - expect(JSON.parse(stdout)).toMatchObject({ skills: [] }); - }); + it("reads a skill's SKILL.md with skills read, and exits 1 for one it does not find", () => { + expect(run(['skills', 'read', 'Invoices'])).toMatchObject({ + exitCode: 0, + stdout: expect.stringContaining('name: Invoices'), + }); + expect(run(['skills', 'read', 'refunds'])).toMatchObject({ exitCode: 1 }); + }); - it('exits 0 from run-workflow for a workflow that succeeds', () => { - const { stdout, exitCode } = run(['--output', 'json', 'run-workflow', '--script', 'return 1 + 1;']); + it('lists the skills the caller can use', () => { + const { stdout, exitCode } = run(['skills', 'list']); - expect(exitCode).toBe(0); - expect(JSON.parse(stdout)).toMatchObject({ structuredContent: { success: true, value: 2 } }); + expect(exitCode).toBe(0); + expect(stdout).toContain('Invoices\n Look up invoices.'); + expect(stdout).not.toContain('Admin'); + }); }); - it('exits 1 from run-workflow for a workflow that fails, with its error', () => { - const { stdout, exitCode } = run([ - '--output', - 'json', - 'run-workflow', - '--script', - "return await callTool('refundInvoice', { id: 'inv_1' });", - ]); + describe('execute', () => { + it('runs one action and prints its data', async () => { + const { stdout, exitCode } = await runReachingTheApi(['execute', 'getInvoice', '--input', '{"id":"inv_1"}']); - expect(exitCode).toBe(1); - expect(JSON.parse(stdout)).toMatchObject({ - isError: true, - structuredContent: { success: false, error: expect.stringContaining('unknown action "refundInvoice"') }, + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toEqual(INVOICE); + expect(apiRequests).toEqual(['GET /v1/invoices/inv_1']); }); - }); - it('runs one action with execute-action and exits 0, with its data', async () => { - const { stdout, exitCode } = await runReachingTheApi([ - '--output', - 'json', - 'execute-action', - '--action-id', - 'getInvoice', - '--input', - '{"id":"inv_1"}', - ]); - - expect(exitCode).toBe(0); - expect(JSON.parse(stdout)).toMatchObject({ structuredContent: INVOICE }); - expect(apiRequests).toEqual(['/v1/invoices/inv_1']); - }); + it('reads the input from a file with --input-file, and from standard input with --input -', async () => { + const inputFile = path.join(project.dir, 'input.json'); + await writeFile(inputFile, '{"id":"inv_1"}'); + + const fromFile = await runReachingTheApi([ + '--output', + 'json', + 'execute', + 'getInvoice', + '--input-file', + inputFile, + ]); + const fromStdin = await runReachingTheApi( + ['--output', 'json', 'execute', 'getInvoice', '--input', '-'], + '{"id":"inv_1"}', + ); + + expect(JSON.parse(fromFile.stdout)).toEqual(INVOICE); + expect(JSON.parse(fromStdin.stdout)).toEqual(INVOICE); + expect(apiRequests).toEqual(['GET /v1/invoices/inv_1', 'GET /v1/invoices/inv_1']); + }); - it("exits 1 from execute-action for an action that fails, with the API's error body", async () => { - const { stdout, exitCode } = await runReachingTheApi([ - '--output', - 'json', - 'execute-action', - '--action-id', - 'getInvoice', - '--input', - '{"id":"inv_missing"}', - ]); - - expect(exitCode).toBe(1); - expect(JSON.parse(stdout)).toMatchObject({ - isError: true, - structuredContent: { ok: false, status: 404, data: { error: 'invoice not found' } }, + it("exits 1 for an action that fails, with the API's error body", async () => { + const { stdout, stderr, exitCode } = await runReachingTheApi([ + '--output', + 'json', + 'execute', + 'getInvoice', + '--input', + '{"id":"inv_missing"}', + ]); + + expect(exitCode).toBe(1); + expect(JSON.parse(stdout)).toEqual({ ok: false, status: 404, data: { error: 'invoice not found' } }); + expect(stderr).toContain('Error: action "getInvoice" failed with status 404: {"error":"invoice not found"}'); + }); + + it('exits 1 for an unknown action, sending nothing', async () => { + const { stderr, exitCode } = await runReachingTheApi(['execute', 'refundInvoice']); + + expect(exitCode).toBe(1); + expect(stderr).toContain('unknown action "refundInvoice"'); + expect(apiRequests).toEqual([]); + }); + + it('exits 2 for input that is not JSON', async () => { + expect(await runReachingTheApi(['execute', 'getInvoice', '--input', '{"id"'])).toMatchObject({ exitCode: 2 }); }); }); - it('exits 1 from execute-action for an unknown action, sending nothing', async () => { - const { stdout, exitCode } = await runReachingTheApi([ - '--output', - 'json', - 'execute-action', - '--action-id', - 'refundInvoice', - ]); - - expect(exitCode).toBe(1); - expect(JSON.parse(stdout)).toMatchObject({ - isError: true, - structuredContent: { ok: false, status: 0, error: expect.stringContaining('unknown action "refundInvoice"') }, - }); - expect(apiRequests).toEqual([]); + describe('run_workflow', () => { + const script = "const invoice = await callTool('getInvoice', { id: 'inv_1' });\nreturn invoice.status;"; + + it('runs a script given as an argument, and prints its value', async () => { + const { stdout, exitCode } = await runReachingTheApi(['run_workflow', script]); + + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe('paid'); + }); + + it('reads the script from --file and from standard input', async () => { + const scriptFile = path.join(project.dir, 'status.js'); + await writeFile(scriptFile, script); + + const fromFile = await runReachingTheApi(['--output', 'json', 'run_workflow', '--file', scriptFile]); + const fromStdin = await runReachingTheApi(['--output', 'json', 'run-workflow', '-'], script); + + expect(JSON.parse(fromFile.stdout)).toMatchObject({ success: true, value: 'paid' }); + expect(JSON.parse(fromStdin.stdout)).toMatchObject({ success: true, value: 'paid' }); + }); + + it('takes the 1.11.0 run-workflow --script', () => { + expect(run(['run-workflow', '--script', 'return 1 + 1;'])).toMatchObject({ exitCode: 0, stdout: '2\n' }); + }); + + it('exits 1 for a workflow that fails, with its error', () => { + const { stdout, stderr, exitCode } = run([ + '--output', + 'json', + 'run_workflow', + "return await callTool('refundInvoice', { id: 'inv_1' });", + ]); + + expect(exitCode).toBe(1); + expect(JSON.parse(stdout)).toMatchObject({ + success: false, + error: expect.stringContaining('unknown action "refundInvoice"'), + }); + expect(stderr).toContain('Error: unknown action "refundInvoice"'); + }); }); - async function voidInvoice(): Promise { - return runReachingTheApi([ - '--output', - 'json', - 'execute-action', - '--action-id', - 'voidInvoice', - '--input', - '{"id":"inv_1"}', - ]); - } + describe('confirming a destructive action', () => { + const voidInvoice = ['execute', 'voidInvoice', '--input', '{"id":"inv_1"}']; - function expectConfirmationRefused({ stdout, exitCode }: CliResult): void { - expect(exitCode).toBe(1); - expect(JSON.parse(stdout)).toMatchObject({ - isError: true, - structuredContent: { - ok: false, - status: 0, - error: expect.stringContaining("needed for voidInvoice, and this client can't ask for it"), - }, - }); - expect(apiRequests).toEqual([]); - } + it('declines it when there is no terminal to ask on, sending nothing', async () => { + const { stderr, exitCode } = await runReachingTheApi(voidInvoice); - it("exits 1 from execute-action for an action that needs the user's confirmation, which the CLI can't ask for", async () => { - expectConfirmationRefused(await voidInvoice()); + expect(exitCode).toBe(1); + expect(stderr).toContain('Run voidInvoice: Void an invoice. (DELETE /invoices/{id}) with {"id":"inv_1"}?'); + expect(stderr).toContain(CONFIRM_DECLINED); + expect(stderr).toContain('Error: the user declined: voidInvoice'); + expect(apiRequests).toEqual([]); + }); + + it('runs it with --yes, before or after the command', async () => { + const after = await runReachingTheApi([...voidInvoice, '--yes']); + const before = await runReachingTheApi(['--yes', ...voidInvoice]); + + expect([after.exitCode, before.exitCode]).toEqual([0, 0]); + expect(apiRequests).toEqual(['DELETE /v1/invoices/inv_1', 'DELETE /v1/invoices/inv_1']); + }); + + it('asks once for a workflow that names it, which --yes confirms', async () => { + const voidScript = "await callTool('voidInvoice', { id: 'inv_1' });\nreturn 'voided';"; + + const declined = await runReachingTheApi(['run_workflow', voidScript]); + const confirmed = await runReachingTheApi(['--yes', 'run_workflow', voidScript]); + + expect(declined).toMatchObject({ exitCode: 1, stderr: expect.stringContaining(CONFIRM_DECLINED) }); + expect(confirmed).toMatchObject({ exitCode: 0, stdout: 'voided\n' }); + expect(apiRequests).toEqual(['DELETE /v1/invoices/inv_1']); + }); }); describe('with its daemon running', () => { @@ -224,23 +319,32 @@ describe('a CLI whose skills a plugin registers at runtime', () => { expect(run(['daemon', 'start'])).toMatchObject({ exitCode: 0 }); }); - it("refuses an action that needs the user's confirmation through the daemon too", async () => { - expectConfirmationRefused(await voidInvoice()); - }); - afterAll(async () => { run(['daemon', 'stop']); await rename(movedBundlePath(), project.bundlePath).catch(() => undefined); }); + it("asks the daemon's confirmation question in the CLI: declined without a terminal, given by --yes", async () => { + const voidInvoice = ['execute', 'voidInvoice', '--input', '{"id":"inv_1"}']; + + const declined = await runReachingTheApi(voidInvoice); + const confirmed = await runReachingTheApi(['--yes', ...voidInvoice]); + + expect(declined).toMatchObject({ exitCode: 1, stderr: expect.stringContaining(CONFIRM_DECLINED) }); + expect(confirmed.exitCode).toBe(0); + expect(`${declined.stderr}${confirmed.stderr}`).not.toContain('running the command in-process'); + expect(apiRequests).toEqual(['DELETE /v1/invoices/inv_1']); + }); + it('serves the commands from the bundle it loaded, after the file is gone', async () => { expect(run(['skills', 'list']).stdout).toContain('Invoices'); // An in-process command reads the bundle file at start; only the daemon still has the bundle await rename(project.bundlePath, movedBundlePath()); expect(run(['skills', 'list'])).toMatchObject({ exitCode: 0, stdout: expect.stringContaining('Invoices') }); - expect(run(['resource', 'read', 'skill://Invoices/SKILL.md']).stdout).toContain('name: Invoices'); - expect(run(['search-skill', '--query', 'invoices']).stdout).toContain('invoices'); + expect(run(['skills', 'read', 'Invoices']).stdout).toContain('name: Invoices'); + expect(run(['search_skill', 'invoices']).stdout).toContain('Invoices (invoices)'); + expect(run(['load_skill', 'invoices']).stdout).toContain(`${APP_NAME} execute getInvoice`); }); it('leaves a command without the daemon to read the bundle itself', () => { @@ -250,3 +354,43 @@ describe('a CLI whose skills a plugin registers at runtime', () => { }); }); }); + +describe('a CLI for a server with tools of its own beside the Skilled OpenAPI plugin', () => { + let project: SkilledCliProject; + let homeDir: string; + + function run(args: string[]): CliResult { + return runCliBundle(project.cliBundle, args, { FRONTMCP_HOME: homeDir }); + } + + beforeAll(async () => { + project = await buildSkilledCliProject({ prefix: 'skilled-mixed', appName: 'skilled-mixed-demo', ownTool: true }); + homeDir = await mkdtemp(path.join(os.tmpdir(), 'fmcp-skm-')); + }, 180_000); + + afterAll(async () => { + await rm(homeDir, { recursive: true, force: true }); + await removeScratchProject(project.dir); + }); + + it('has the skill commands only under skills, beside its own tool commands', () => { + const { stdout } = run(['--help']); + + expect(stdout.slice(stdout.indexOf('Tools:'), stdout.indexOf('Resources & Prompts:'))).toMatch( + /^Tools:\n\s+ping\s+Answer pong\n/, + ); + expect(stdout).toMatch(/Skills:\n\s+skills\s+Find, load and run skills/); + expect(stdout).not.toContain('search_skill'); + expect(stdout).not.toContain('run-workflow'); + expect(run(['search_skill', 'invoices'])).toMatchObject({ exitCode: 2 }); + }); + + it('serves the skills group and its own tools', () => { + expect(run(['skills', 'search', 'invoices'])).toMatchObject({ + exitCode: 0, + stdout: expect.stringContaining('Invoices (invoices)'), + }); + expect(run(['skills', 'load', 'invoices']).stdout).toContain('skilled-mixed-demo skills execute getInvoice'); + expect(run(['ping'])).toMatchObject({ exitCode: 0, stdout: expect.stringContaining('pong') }); + }); +}); diff --git a/apps/e2e/demo-e2e-cli-exec/e2e/helpers/exec-cli.ts b/apps/e2e/demo-e2e-cli-exec/e2e/helpers/exec-cli.ts index a057c5aa5..81bedde70 100644 --- a/apps/e2e/demo-e2e-cli-exec/e2e/helpers/exec-cli.ts +++ b/apps/e2e/demo-e2e-cli-exec/e2e/helpers/exec-cli.ts @@ -138,13 +138,18 @@ export function runFrontmcpCliAsync( return runNodeAsync([FRONTMCP_BIN, ...args], cwd, timeoutMs, extraEnv); } -/** `runCliBundle()` without blocking, for a command that reaches a server the test process runs. */ +/** + * `runCliBundle()` without blocking, for a command that reaches a server the test process runs. + * + * @param stdin - What the command reads from standard input, which is then closed; a pipe, so never a terminal + */ export function runCliBundleAsync( bundle: string, args: string[], extraEnv?: Record, + stdin?: string, ): Promise { - return runNodeAsync([bundle, ...args], path.dirname(bundle), 30000, extraEnv); + return runNodeAsync([bundle, ...args], path.dirname(bundle), 30000, extraEnv, stdin); } function runNodeAsync( @@ -152,12 +157,14 @@ function runNodeAsync( cwd: string, timeoutMs: number, extraEnv?: Record, + stdin?: string, ): Promise { return new Promise((resolve) => { const child = spawn('node', nodeArgs, { cwd, env: { ...process.env, NODE_ENV: 'test', ...extraEnv }, }); + if (stdin !== undefined) child.stdin.end(stdin); let stdout = ''; let stderr = ''; child.stdout.on('data', (data: Buffer) => { diff --git a/apps/e2e/demo-e2e-cli-exec/e2e/helpers/skilled-cli-project.ts b/apps/e2e/demo-e2e-cli-exec/e2e/helpers/skilled-cli-project.ts index a7695d200..d1e7ddb1f 100644 --- a/apps/e2e/demo-e2e-cli-exec/e2e/helpers/skilled-cli-project.ts +++ b/apps/e2e/demo-e2e-cli-exec/e2e/helpers/skilled-cli-project.ts @@ -75,6 +75,7 @@ export interface SkilledCliProject { * @param options.serverConfig - Extra `@FrontMcp()` fields, as TypeScript source (such as an `auth` block) * @param options.cli - The cli deployment's `cli` block * @param options.serviceBaseUrl - The base URL of a plain-HTTP API on loopback the bundle's service points to + * @param options.ownTool - Give the server a tool of its own, `ping`, beside the plugin's */ export async function buildSkilledCliProject(options: { prefix: string; @@ -82,6 +83,7 @@ export async function buildSkilledCliProject(options: { serverConfig?: string; cli?: Record; serviceBaseUrl?: string; + ownTool?: boolean; }): Promise { const bundle = options.serviceBaseUrl ? { ...SKILLED_BUNDLE, services: [{ id: 'billing', baseUrl: options.serviceBaseUrl }] } @@ -113,7 +115,11 @@ export async function buildSkilledCliProject(options: { const bundlePath = path.join(dir, 'bundle.json'); await writeFile( path.join(dir, 'src', 'main.ts'), - serverSource(bundlePath, options.serverConfig ?? "auth: { mode: 'public' },", options.serviceBaseUrl !== undefined), + serverSource(bundlePath, { + serverConfig: options.serverConfig ?? "auth: { mode: 'public' },", + loopbackService: options.serviceBaseUrl !== undefined, + ownTool: options.ownTool ?? false, + }), ); const build = await runFrontmcpCliAsync(['build', '--target', 'cli', '--js'], dir, BUILD_TIMEOUT_MS); @@ -123,15 +129,31 @@ export async function buildSkilledCliProject(options: { return { dir, bundlePath, cliBundle: path.join(dir, 'dist', 'cli', `${options.appName}-cli.bundle.js`) }; } -function serverSource(bundlePath: string, serverConfig: string, loopbackService: boolean): string { - const outbound = loopbackService ? '\n outbound: { allowHttp: true, allowPrivateNetworks: true },' : ''; +const OWN_TOOL_APP = ` +@Tool({ name: 'ping', description: 'Answer pong', inputSchema: {} }) +class PingTool extends ToolContext { + async execute() { + return 'pong'; + } +} + +@App({ name: 'ops', tools: [PingTool] }) +class OpsApp {} +`; + +function serverSource( + bundlePath: string, + options: { serverConfig: string; loopbackService: boolean; ownTool: boolean }, +): string { + const outbound = options.loopbackService ? '\n outbound: { allowHttp: true, allowPrivateNetworks: true },' : ''; + const sdkImports = options.ownTool ? 'App, FrontMcp, LogLevel, Tool, ToolContext' : 'FrontMcp, LogLevel'; return `import 'reflect-metadata'; import SkilledOpenApiPlugin from '@frontmcp/plugin-skilled-openapi'; -import { FrontMcp, LogLevel } from '@frontmcp/sdk'; - +import { ${sdkImports} } from '@frontmcp/sdk'; +${options.ownTool ? OWN_TOOL_APP : ''} @FrontMcp({ info: { name: 'Billing CLI', version: '1.0.0' }, - apps: [], + apps: [${options.ownTool ? 'OpsApp' : ''}], logging: { level: LogLevel.Warn, enableConsole: false }, authorities: { claimsMapping: { roles: 'roles' } }, plugins: [ @@ -141,7 +163,7 @@ import { FrontMcp, LogLevel } from '@frontmcp/sdk'; confirm: { methods: ['DELETE'] },${outbound} }), ], - ${serverConfig} + ${options.serverConfig} }) export default class Server {} `;