Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
420 changes: 282 additions & 138 deletions apps/e2e/demo-e2e-cli-exec/e2e/cli-skilled-openapi.e2e.spec.ts

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions apps/e2e/demo-e2e-cli-exec/e2e/helpers/exec-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,26 +138,33 @@ 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<string, string>,
stdin?: string,
): Promise<CliResult> {
return runNodeAsync([bundle, ...args], path.dirname(bundle), 30000, extraEnv);
return runNodeAsync([bundle, ...args], path.dirname(bundle), 30000, extraEnv, stdin);
}

function runNodeAsync(
nodeArgs: string[],
cwd: string,
timeoutMs: number,
extraEnv?: Record<string, string>,
stdin?: string,
): Promise<CliResult> {
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) => {
Expand Down
36 changes: 29 additions & 7 deletions apps/e2e/demo-e2e-cli-exec/e2e/helpers/skilled-cli-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,15 @@ 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;
appName: string;
serverConfig?: string;
cli?: Record<string, unknown>;
serviceBaseUrl?: string;
ownTool?: boolean;
}): Promise<SkilledCliProject> {
const bundle = options.serviceBaseUrl
? { ...SKILLED_BUNDLE, services: [{ id: 'billing', baseUrl: options.serviceBaseUrl }] }
Expand Down Expand Up @@ -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);
Expand All @@ -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: [
Expand All @@ -141,7 +163,7 @@ import { FrontMcp, LogLevel } from '@frontmcp/sdk';
confirm: { methods: ['DELETE'] },${outbound}
}),
],
${serverConfig}
${options.serverConfig}
})
export default class Server {}
`;
Expand Down
15 changes: 14 additions & 1 deletion libs/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text|json>` sets the output format (the default comes from `cli.outputDefault`);
`--verbose` and `--log-dir <path>` control logging.
`--yes` answers the server's yes/no questions with yes; `--verbose` and `--log-dir <path>` 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`).

Expand Down
73 changes: 72 additions & 1 deletion libs/cli/src/commands/build/exec/__tests__/daemon-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,16 @@ interface DaemonClient {
close(): Promise<void>;
}

interface ElicitationAnswer {
action: string;
content?: Record<string, unknown>;
}

interface DaemonClientModule {
createDaemonClient(socketPath: string, options?: { authToken?: string; entryPath?: string }): DaemonClient;
createDaemonClient(
socketPath: string,
options?: { authToken?: string; entryPath?: string; onElicitation?: (request: unknown) => Promise<ElicitationAnswer> },
): DaemonClient;
}

interface ReceivedRequest {
Expand All @@ -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);
Expand Down Expand Up @@ -57,6 +76,13 @@ const replies: Record<string, Reply> = {
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;
Expand Down Expand Up @@ -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);
});
});
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>): ExtractedSchema {
return {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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);');
Expand Down Expand Up @@ -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<CliEntryOptions>): 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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading