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/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 {} `; 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/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; 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 0935c38da..7ad4ee1b7 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`.