diff --git a/.changeset/mcp-2026-support.md b/.changeset/mcp-2026-support.md new file mode 100644 index 0000000..ff7a77b --- /dev/null +++ b/.changeset/mcp-2026-support.md @@ -0,0 +1,5 @@ +--- +'incur': minor +--- + +Add stateless MCP 2026 support, MCP resources/prompts/apps, task-backed tools, authorization extensions, and MCP elicitation helpers. diff --git a/README.md b/README.md index 3f6fac5..8994643 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,74 @@ POST /mcp { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "user Non-`/mcp` paths continue routing to the command API as usual. +`/mcp` also supports the MCP 2026 release-candidate stateless flow. Clients can call `server/discover` without initialization, then send each request with `MCP-Protocol-Version: DRAFT-2026-v1` or `_meta.io.modelcontextprotocol/protocolVersion`. Legacy initialized sessions still work. + +```ts +import { Cli, Mcp, z } from 'incur' + +const cli = Cli.create('ops', { + mcpServer: { + cache: { ttlMs: 300000, cacheScope: 'public' }, + }, +}) + +cli + .command('deploy', { + description: 'Deploy the app', + mcpTool: { + title: 'Deploy', + annotations: { destructiveHint: true, openWorldHint: true }, + headers: { token: 'Authorization' }, + task: { required: true, pollIntervalMs: 5000 }, + }, + args: z.object({ token: z.string() }), + run() { + return { ok: true } + }, + }) + .resource('config', { + uri: 'file:///config.json', + read: () => ({ uri: 'file:///config.json', text: '{"ok":true}' }), + }) + .prompt('review', { + args: z.object({ language: z.string().describe('Language') }), + get: (args) => [{ role: 'user', content: Mcp.text(`Review this ${args.language} code`) }], + }) + .app('panel', { resourceUri: 'ui://panel', html: '
panel
' }) +``` + +The stateless handler advertises tool metadata, output schemas, `x-mcp-header`, cache hints, resources, resource templates, prompts, completion, MCP Apps UI resources, MRTR elicitation, `subscriptions/listen`, task-backed tools, and authorization extensions. Apps are served as `text/html;profile=mcp-app` resources. Task-backed tools return `resultType: "task"` only when the client declares `io.modelcontextprotocol/tasks`. + +#### MCP elicitation + +Commands running as MCP tools can request additional user input through `c.elicit`: + +```ts +cli.command('connect', { + async run(c) { + const profile = await c.elicit.form({ + message: 'Choose the workspace to connect.', + schema: z.object({ + workspace: z.string().describe('Workspace slug'), + }), + }) + + if (profile.action !== 'accept') return { connected: false } + + const consent = await c.elicit.url({ + message: 'Authorize access in your browser.', + url: 'https://example.com/oauth/start', + }) + + return { connected: consent.action === 'accept', workspace: profile.content.workspace } + }, +}) +``` + +Pass `key` to `form()` or `url()` when a command may request multiple inputs and needs a stable MCP 2026 MRTR response key. + +Use form mode only for non-sensitive structured input. Use URL mode for secrets, API keys, OAuth, payment, and other interactions that must not pass through the MCP client. Outside MCP, `c.elicit` returns an `ELICITATION_UNSUPPORTED` command error. + ## Walkthrough ### Agent discovery diff --git a/SKILL.md b/SKILL.md index 6bb33bf..d2ec1aa 100644 --- a/SKILL.md +++ b/SKILL.md @@ -273,6 +273,74 @@ POST /mcp { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "user The MCP server is initialized lazily on the first `/mcp` request. Non-`/mcp` paths route to the command API as usual. +`/mcp` also supports the MCP 2026 release-candidate stateless flow. Clients can call `server/discover` without initialization, then send each request with `MCP-Protocol-Version: DRAFT-2026-v1` or `_meta.io.modelcontextprotocol/protocolVersion`. Legacy initialized sessions still work. + +```ts +import { Cli, Mcp, z } from 'incur' + +const cli = Cli.create('ops', { + mcpServer: { + cache: { ttlMs: 300000, cacheScope: 'public' }, + }, +}) + +cli + .command('deploy', { + description: 'Deploy the app', + mcpTool: { + title: 'Deploy', + annotations: { destructiveHint: true, openWorldHint: true }, + headers: { token: 'Authorization' }, + task: { required: true, pollIntervalMs: 5000 }, + }, + args: z.object({ token: z.string() }), + run() { + return { ok: true } + }, + }) + .resource('config', { + uri: 'file:///config.json', + read: () => ({ uri: 'file:///config.json', text: '{"ok":true}' }), + }) + .prompt('review', { + args: z.object({ language: z.string().describe('Language') }), + get: (args) => [{ role: 'user', content: Mcp.text(`Review this ${args.language} code`) }], + }) + .app('panel', { resourceUri: 'ui://panel', html: '
panel
' }) +``` + +The stateless handler advertises tool metadata, output schemas, `x-mcp-header`, cache hints, resources, resource templates, prompts, completion, MCP Apps UI resources, MRTR elicitation, `subscriptions/listen`, task-backed tools, and authorization extensions. Apps are served as `text/html;profile=mcp-app` resources. Task-backed tools return `resultType: "task"` only when the client declares `io.modelcontextprotocol/tasks`. + +#### MCP elicitation + +Commands running as MCP tools can request additional user input through `c.elicit`: + +```ts +cli.command('connect', { + async run(c) { + const profile = await c.elicit.form({ + message: 'Choose the workspace to connect.', + schema: z.object({ + workspace: z.string().describe('Workspace slug'), + }), + }) + + if (profile.action !== 'accept') return { connected: false } + + const consent = await c.elicit.url({ + message: 'Authorize access in your browser.', + url: 'https://example.com/oauth/start', + }) + + return { connected: consent.action === 'accept', workspace: profile.content.workspace } + }, +}) +``` + +Pass `key` to `form()` or `url()` when a command may request multiple inputs and needs a stable MCP 2026 MRTR response key. + +Use form mode only for non-sensitive structured input. Use URL mode for secrets, API keys, OAuth, payment, and other interactions that must not pass through the MCP client. Outside MCP, `c.elicit` returns an `ELICITATION_UNSUPPORTED` command error. + ## Arguments & Options All schemas use Zod. Arguments are positional (assigned by schema key order). Options are named flags. diff --git a/src/Cli.test-d.ts b/src/Cli.test-d.ts index ee46568..4b1ccd6 100644 --- a/src/Cli.test-d.ts +++ b/src/Cli.test-d.ts @@ -1,4 +1,4 @@ -import { Cli, Fetch, middleware, z } from 'incur' +import { Cli, Elicitation, Fetch, middleware, z } from 'incur' import type { MiddlewareHandler } from 'incur' import { expectTypeOf, test } from 'vitest' @@ -208,6 +208,76 @@ test('without vars, c.var is empty object', () => { }) }) +test('form elicitation content infers from schema', () => { + Cli.create('test').command('ask', { + async run(c) { + const result = await c.elicit.form({ + key: 'profile', + message: 'Need profile', + schema: z.object({ + name: z.string(), + count: z.number().default(0), + }), + }) + if (result.action === 'accept') + expectTypeOf(result.content).toEqualTypeOf<{ name: string; count: number }>() + else expectTypeOf(result.content).toEqualTypeOf() + return {} + }, + }) +}) + +test('elicitation result types distinguish form and URL content', () => { + type Form = Elicitation.FormResult> + expectTypeOf
().toMatchTypeOf< + | { action: 'accept'; content: { name: string } } + | { action: 'decline' | 'cancel'; content?: undefined } + >() + expectTypeOf().toMatchTypeOf<{ + action: 'accept' | 'decline' | 'cancel' + content?: undefined + }>() +}) + +test('mcp metadata and registrations type check', () => { + Cli.create('test', { + mcpServer: { + authorization: { + oauthClientCredentials: { scopes: ['read:tools'] }, + enterpriseManagedAuthorization: true, + authorize: ({ bearerToken }) => bearerToken === 'ok', + }, + cache: { ttlMs: 1000, cacheScope: 'private' }, + }, + }) + .command('meta', { + mcpTool: { + title: 'Meta', + icons: [{ src: 'https://example.com/icon.svg', mimeType: 'image/svg+xml' }], + annotations: { readOnlyHint: true }, + headers: { token: 'Authorization' }, + task: { required: true, ttlMs: 1000, pollIntervalMs: 100 }, + }, + run() { + return {} + }, + }) + .resource('config', { + uri: 'file:///config.json', + read: () => ({ uri: 'file:///config.json', text: '{}' }), + }) + .resourceTemplate('user', { + uriTemplate: 'file:///users/{id}.json', + complete: { id: (value) => [value] }, + }) + .prompt('review', { + args: z.object({ language: z.string() }), + complete: { language: (value) => [value] }, + get: (args) => [{ role: 'user', content: { type: 'text', text: String(args.language) } }], + }) + .app('panel', { resourceUri: 'ui://panel', html: '
' }) +}) + test('command() accumulates command types through chaining', () => { const cli = Cli.create('test') .command('get', { diff --git a/src/Cli.ts b/src/Cli.ts index d8efef9..b1120e8 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -6,6 +6,7 @@ import { parse as yamlParse, stringify as yamlStringify } from 'yaml' import { z } from 'zod' import * as Completions from './Completions.js' +import type * as Elicitation from './Elicitation.js' import type { FieldError } from './Errors.js' import { IncurError, ParseError, ValidationError } from './Errors.js' import * as Fetch from './Fetch.js' @@ -95,6 +96,26 @@ export type Cli< name: string /** Handles an incoming HTTP request, resolves the matching command, and returns a JSON Response. */ fetch(req: Request): Promise + /** Registers an MCP resource exposed by the `/mcp` endpoint. */ + resource( + nameOrDefinition: string | Mcp.ResourceDefinition, + definition?: Omit, + ): Cli + /** Registers an MCP resource template exposed by the `/mcp` endpoint. */ + resourceTemplate( + nameOrDefinition: string | Mcp.ResourceTemplateDefinition, + definition?: Omit, + ): Cli + /** Registers an MCP prompt exposed by the `/mcp` endpoint. */ + prompt( + nameOrDefinition: string | Mcp.PromptDefinition, + definition?: Omit, + ): Cli + /** Registers an MCP App UI resource exposed by the `/mcp` endpoint. */ + app( + nameOrDefinition: string | Mcp.AppDefinition, + definition?: Omit, + ): Cli /** Parses argv, runs the matched command, and writes the output envelope to stdout. */ serve(argv?: string[], options?: serve.Options): Promise /** Registers middleware that runs around every command. */ @@ -212,6 +233,10 @@ export function create( const rootFetchBaseUrl = rootFetchSource === undefined ? undefined : fetchBaseUrl(rootFetchSource) const commands = new Map() + const resources: Mcp.ResourceDefinition[] = [] + const resourceTemplates: Mcp.ResourceTemplateDefinition[] = [] + const prompts: Mcp.PromptDefinition[] = [] + const apps: Mcp.AppDefinition[] = [] const middlewares: MiddlewareHandler[] = [] const pending: Promise[] = [] const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0') @@ -301,6 +326,11 @@ export function create( description: def.description, envSchema: def.env, mcpHandler, + mcpResources: resources, + mcpResourceTemplates: resourceTemplates, + mcpPrompts: prompts, + mcpApps: apps, + mcpServer: def.mcpServer, middlewares, name, rootCommand: rootDef, @@ -333,6 +363,38 @@ export function create( middlewares.push(handler) return cli }, + + resource( + nameOrDefinition: string | Mcp.ResourceDefinition, + definition?: Omit, + ) { + resources.push(named(nameOrDefinition, definition) as Mcp.ResourceDefinition) + return cli + }, + + resourceTemplate( + nameOrDefinition: string | Mcp.ResourceTemplateDefinition, + definition?: Omit, + ) { + resourceTemplates.push(named(nameOrDefinition, definition) as Mcp.ResourceTemplateDefinition) + return cli + }, + + prompt( + nameOrDefinition: string | Mcp.PromptDefinition, + definition?: Omit, + ) { + prompts.push(named(nameOrDefinition, definition) as Mcp.PromptDefinition) + return cli + }, + + app( + nameOrDefinition: string | Mcp.AppDefinition, + definition?: Omit, + ) { + apps.push(named(nameOrDefinition, definition) as Mcp.AppDefinition) + return cli + }, } if (rootDef) toRootDefinition.set(cli as unknown as Root, rootDef) @@ -345,6 +407,15 @@ export function create( return cli } +function named( + nameOrDefinition: string | definition, + definition?: Omit, +): definition { + if (typeof nameOrDefinition === 'string') + return { name: nameOrDefinition, ...definition } as definition + return nameOrDefinition +} + export declare namespace create { /** Options for creating a CLI. Provide `run` for a leaf CLI, omit it for a router. */ type Options< @@ -422,6 +493,8 @@ export declare namespace create { displayName: string /** Parsed environment variables. */ env: InferOutput + /** Request additional user input through MCP elicitation. */ + elicit: Elicitation.Client /** Return an error result with optional CTAs. */ error: (options: { code: string @@ -455,6 +528,13 @@ export declare namespace create { command?: string | undefined } | undefined + /** Options for the built-in MCP server endpoint. */ + mcpServer?: + | { + authorization?: Mcp.AuthorizationOptions | undefined + cache?: Mcp.CacheOptions | undefined + } + | undefined /** Options for the built-in `skills add` command. */ sync?: | { @@ -1565,12 +1645,28 @@ declare namespace fetchImpl { req: Request, commands: Map, mcpOptions?: { + apps?: Mcp.AppDefinition[] | undefined + authorization?: Mcp.AuthorizationOptions | undefined + cache?: Mcp.CacheOptions | undefined middlewares?: MiddlewareHandler[] | undefined env?: z.ZodObject | undefined + prompts?: Mcp.PromptDefinition[] | undefined + resources?: Mcp.ResourceDefinition[] | undefined + resourceTemplates?: Mcp.ResourceTemplateDefinition[] | undefined vars?: z.ZodObject | undefined }, ) => Promise) | undefined + mcpApps?: Mcp.AppDefinition[] | undefined + mcpPrompts?: Mcp.PromptDefinition[] | undefined + mcpResources?: Mcp.ResourceDefinition[] | undefined + mcpResourceTemplates?: Mcp.ResourceTemplateDefinition[] | undefined + mcpServer?: + | { + authorization?: Mcp.AuthorizationOptions | undefined + cache?: Mcp.CacheOptions | undefined + } + | undefined middlewares?: MiddlewareHandler[] | undefined /** CLI name. */ name?: string | undefined @@ -1589,11 +1685,30 @@ function createMcpHttpHandler(name: string, version: string) { req: Request, commands: Map, mcpOptions?: { + apps?: Mcp.AppDefinition[] | undefined + authorization?: Mcp.AuthorizationOptions | undefined + cache?: Mcp.CacheOptions | undefined middlewares?: MiddlewareHandler[] | undefined env?: z.ZodObject | undefined + prompts?: Mcp.PromptDefinition[] | undefined + resources?: Mcp.ResourceDefinition[] | undefined + resourceTemplates?: Mcp.ResourceTemplateDefinition[] | undefined vars?: z.ZodObject | undefined }, ): Promise => { + if (await Mcp.is2026HttpRequest(req)) + return Mcp.handle2026Http(req, name, version, commands, { + apps: mcpOptions?.apps, + authorization: mcpOptions?.authorization, + cache: mcpOptions?.cache, + env: mcpOptions?.env, + middlewares: mcpOptions?.middlewares, + prompts: mcpOptions?.prompts, + resources: mcpOptions?.resources, + resourceTemplates: mcpOptions?.resourceTemplates, + vars: mcpOptions?.vars, + }) + if (!transport) { const { McpServer, WebStandardStreamableHTTPServerTransport } = await import('@modelcontextprotocol/server') @@ -1615,7 +1730,10 @@ function createMcpHttpHandler(name: string, version: string) { }, async (...callArgs: any[]) => { const params = hasInput ? (callArgs[0] as Record) : {} + const extra = hasInput ? callArgs[1] : callArgs[0] return Mcp.callTool(tool, params, { + clientCapabilities: server.server.getClientCapabilities(), + extra, name, version, middlewares: mcpOptions?.middlewares, @@ -1689,8 +1807,14 @@ async function fetchImpl( // MCP over HTTP: route /mcp to the MCP transport if (segments[0] === 'mcp' && segments.length === 1 && options.mcpHandler) return options.mcpHandler(req, commands, { + apps: options.mcpApps, + authorization: options.mcpServer?.authorization, + cache: options.mcpServer?.cache, middlewares: options.middlewares, env: options.envSchema, + prompts: options.mcpPrompts, + resources: options.mcpResources, + resourceTemplates: options.mcpResourceTemplates, vars: options.vars, }) @@ -3140,6 +3264,8 @@ type CommandDefinition< outputPolicy?: OutputPolicy | undefined /** Middleware that runs only for this command, after root and group middleware. */ middleware?: MiddlewareHandler[] | undefined + /** MCP metadata for this command when exposed as a tool. */ + mcpTool?: Mcp.ToolMetadata | undefined /** Alternative usage patterns shown in help output. */ usage?: Usage[] | undefined /** The command handler. Return a value for single-return, or use `async *run` to stream chunks. */ @@ -3152,6 +3278,8 @@ type CommandDefinition< displayName: string /** Parsed environment variables. */ env: InferOutput + /** Request additional user input through MCP elicitation. */ + elicit: Elicitation.Client /** Return an error result with optional CTAs. */ error: (options: { code: string diff --git a/src/Elicitation.ts b/src/Elicitation.ts new file mode 100644 index 0000000..10226a0 --- /dev/null +++ b/src/Elicitation.ts @@ -0,0 +1,224 @@ +import { z } from 'zod' + +import { IncurError } from './Errors.js' +import * as Schema from './Schema.js' + +/** User action returned by an MCP elicitation request. */ +export type Action = 'accept' | 'decline' | 'cancel' + +/** Primitive value that MCP form elicitation can return. */ +export type ContentValue = boolean | number | string | string[] + +/** Result returned by a form elicitation request. */ +export type FormResult> = + | { + /** User accepted and submitted form content. */ + action: 'accept' + /** Submitted content parsed by the requested schema. */ + content: z.output + } + | { + /** User explicitly declined or dismissed the request. */ + action: 'decline' | 'cancel' + /** Form content is omitted unless the user accepts. */ + content?: undefined + } + +/** Result returned by a URL elicitation request. */ +export type UrlResult = + | { + /** User consented to opening the URL. */ + action: 'accept' + /** URL mode does not return submitted content through MCP. */ + content?: undefined + } + | { + /** User explicitly declined or dismissed the request. */ + action: 'decline' | 'cancel' + /** URL mode does not return submitted content through MCP. */ + content?: undefined + } + +/** Options for requesting non-sensitive structured input through MCP form mode. */ +export type FormOptions> = { + /** Stable request key used to match 2026 MRTR input responses. */ + key?: string | undefined + /** Human-readable explanation of why the input is needed. */ + message: string + /** Flat object schema describing the requested form content. */ + schema: schema +} + +/** Options for requesting user consent to open an external URL through MCP URL mode. */ +export type UrlOptions = { + /** Unique elicitation identifier. Generated automatically when omitted. */ + elicitationId?: string | undefined + /** Stable request key used to match 2026 MRTR input responses. */ + key?: string | undefined + /** Human-readable explanation of why the URL interaction is needed. */ + message: string + /** URL to show to the user. */ + url: string | URL +} + +/** API exposed to command handlers as `c.elicit`. */ +export type Client = { + /** Request non-sensitive structured input from the user through MCP form mode. */ + form: >( + options: FormOptions, + ) => Promise> + /** Request consent to open an external URL through MCP URL mode. */ + url: (options: UrlOptions) => Promise + /** Return a URL elicitation required error for clients that retry after completion. */ + requireUrl: (options: UrlOptions) => never +} + +/** Wire-shape for MCP form mode request params. */ +export type FormRequestParams = { + /** Elicitation mode. */ + mode: 'form' + /** Human-readable explanation of why the input is needed. */ + message: string + /** Restricted flat JSON Schema for the expected response. */ + requestedSchema: RequestedSchema +} + +/** Wire-shape for MCP URL mode request params. */ +export type UrlRequestParams = { + /** Elicitation mode. */ + mode: 'url' + /** Unique elicitation identifier. */ + elicitationId: string + /** Human-readable explanation of why the URL interaction is needed. */ + message: string + /** Valid URL string. */ + url: string +} + +/** Adapter used by transports that can send MCP elicitation requests. */ +export type Adapter = { + /** Send a form mode elicitation request. */ + form: (params: FormRequestParams, options?: { key?: string | undefined }) => Promise + /** Throw or send a URL elicitation required error. */ + requireUrl: (params: UrlRequestParams, options?: { key?: string | undefined }) => never + /** Send a URL mode elicitation request. */ + url: (params: UrlRequestParams, options?: { key?: string | undefined }) => Promise +} + +/** JSON Schema accepted by MCP form elicitation. */ +export type RequestedSchema = { + /** Schema type, always object. */ + type: 'object' + /** Flat primitive properties. */ + properties: Record + /** Required property names. */ + required?: string[] | undefined +} + +type RawResult = { + action: Action + content?: Record | undefined +} + +/** Creates a command-context elicitation client from a transport adapter. */ +export function create(adapter?: Adapter | undefined): Client { + return { + async form(options) { + const requestedSchema = toRequestedSchema(options.schema) + const result = await supported(adapter).form( + { + mode: 'form', + message: options.message, + requestedSchema, + }, + { key: options.key }, + ) + if (result.action !== 'accept') return { action: result.action } + return { action: 'accept', content: options.schema.parse(result.content ?? {}) } + }, + requireUrl(options) { + supported(adapter).requireUrl(toUrlParams(options), { key: options.key }) + throw new IncurError({ + code: 'ELICITATION_UNREACHABLE', + message: 'URL elicitation did not throw as expected.', + }) + }, + async url(options) { + const result = await supported(adapter).url(toUrlParams(options), { key: options.key }) + return { action: result.action } + }, + } +} + +function supported(adapter?: Adapter | undefined): Adapter { + if (adapter) return adapter + throw new IncurError({ + code: 'ELICITATION_UNSUPPORTED', + message: 'Elicitation is only available when this command is running as an MCP tool.', + }) +} + +function toUrlParams(options: UrlOptions): UrlRequestParams { + let url: string + try { + url = new URL(String(options.url)).toString() + } catch (cause) { + throw new IncurError({ + code: 'INVALID_ELICITATION_URL', + message: 'URL elicitation requires a valid URL.', + cause: cause instanceof Error ? cause : undefined, + }) + } + return { + mode: 'url', + elicitationId: options.elicitationId ?? crypto.randomUUID(), + message: options.message, + url, + } +} + +function toRequestedSchema(schema: z.ZodObject): RequestedSchema { + const json = Schema.toJsonSchema(schema) + if (json.type !== 'object' || typeof json.properties !== 'object' || json.properties === null) + throw unsupportedSchema('Form elicitation schemas must be flat objects.') + + const properties = json.properties as Record + for (const [key, property] of Object.entries(properties)) validateProperty(key, property) + + const required = Array.isArray(json.required) ? (json.required as string[]) : undefined + if (required) return { type: 'object', properties, required } + return { type: 'object', properties } +} + +function validateProperty(key: string, property: unknown) { + if (!isObject(property)) throw unsupportedSchema(`Property "${key}" must be a JSON object.`) + if ('properties' in property) throw unsupportedSchema(`Property "${key}" must not be nested.`) + + const type = property.type + if (type === 'string' || type === 'number' || type === 'integer' || type === 'boolean') return + if (type === 'array') { + validateArrayProperty(key, property) + return + } + throw unsupportedSchema(`Property "${key}" must be a primitive or enum field.`) +} + +function validateArrayProperty(key: string, property: Record) { + const items = property.items + if (!isObject(items)) throw unsupportedSchema(`Property "${key}" must define array items.`) + if (items.type === 'string' && Array.isArray(items.enum)) return + if (Array.isArray(items.anyOf) && items.anyOf.every(isConstOption)) return + throw unsupportedSchema(`Property "${key}" arrays must be string enum multi-selects.`) +} + +function isConstOption(value: unknown) { + return isObject(value) && typeof value.const === 'string' +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function unsupportedSchema(message: string) { + return new IncurError({ code: 'UNSUPPORTED_ELICITATION_SCHEMA', message }) +} diff --git a/src/Mcp.test.ts b/src/Mcp.test.ts index b43caf5..76fd0e1 100644 --- a/src/Mcp.test.ts +++ b/src/Mcp.test.ts @@ -1,4 +1,4 @@ -import { Mcp, z } from 'incur' +import { Cli, Mcp, z } from 'incur' import { PassThrough } from 'node:stream' function createTestCommands() { @@ -60,6 +60,121 @@ function createTestCommands() { return commands } +function createElicitationCommands() { + const commands = new Map() + + commands.set('ask-name', { + description: 'Ask for a name', + async run(c: any) { + const result = await c.elicit.form({ + message: 'Please provide your profile.', + schema: z.object({ + name: z.string().describe('Display name'), + age: z.number().optional().describe('Age'), + }), + }) + if (result.action !== 'accept') return { action: result.action } + return { action: result.action, name: result.content.name } + }, + }) + + commands.set('ask-keyed', { + description: 'Ask with a stable key', + async run(c: any) { + const result = await c.elicit.form({ + key: 'profile', + message: 'Please provide your profile.', + schema: z.object({ name: z.string() }), + }) + if (result.action !== 'accept') return { action: result.action } + return { name: result.content.name } + }, + }) + + commands.set('open-url', { + description: 'Open a URL', + async run(c: any) { + const result = await c.elicit.url({ + elicitationId: 'auth-1', + message: 'Connect your account.', + url: 'https://example.com/connect', + }) + return { action: result.action } + }, + }) + + commands.set('require-url', { + description: 'Require a URL', + run(c: any) { + c.elicit.requireUrl({ + elicitationId: 'auth-2', + message: 'Connect your account.', + url: 'https://example.com/connect', + }) + }, + }) + + commands.set('nested-form', { + description: 'Ask for nested input', + async run(c: any) { + await c.elicit.form({ + message: 'Nested input.', + schema: z.object({ profile: z.object({ name: z.string() }) }), + }) + return {} + }, + }) + + commands.set('bad-url', { + description: 'Ask for a bad URL', + async run(c: any) { + await c.elicit.url({ message: 'Bad URL.', url: 'not a url' }) + return {} + }, + }) + + return commands +} + +function create2026Commands() { + const commands = createTestCommands() + commands.set('tasked', { + description: 'Task backed command', + mcpTool: { title: 'Tasked', task: { required: true, ttlMs: 300000, pollIntervalMs: 250 } }, + run() { + return { done: true } + }, + }) + commands.set('task-input', { + description: 'Task with input', + mcpTool: { title: 'Task Input', task: { required: true, ttlMs: 300000 } }, + async run(c: any) { + const result = await c.elicit.form({ + key: 'profile', + message: 'Need profile.', + schema: z.object({ name: z.string() }), + }) + if (result.action !== 'accept') return { action: result.action } + return { name: result.content.name } + }, + }) + commands.set('meta', { + description: 'Metadata command', + args: z.object({ token: z.string() }), + output: z.object({ ok: z.boolean() }), + mcpTool: { + title: 'Metadata', + icons: [{ src: 'https://example.com/icon.svg', mimeType: 'image/svg+xml' }], + annotations: { readOnlyHint: true }, + headers: { token: 'Authorization' }, + }, + run() { + return { ok: true } + }, + }) + return commands +} + /** Standard initialize params for MCP protocol. */ const initParams = { protocolVersion: '2024-11-05', @@ -92,6 +207,63 @@ async function mcpSession( return chunks.map((c) => JSON.parse(c.trim())) } +function mcpHarness(commands: Map) { + const input = new PassThrough() + const output = new PassThrough() + const messages: any[] = [] + const waiters: { predicate: (message: any) => boolean; resolve: (message: any) => void }[] = [] + let buffer = '' + + output.on('data', (chunk) => { + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (!line.trim()) continue + const message = JSON.parse(line) + messages.push(message) + const i = waiters.findIndex((w) => w.predicate(message)) + if (i !== -1) waiters.splice(i, 1)[0]!.resolve(message) + } + }) + + const done = Mcp.serve('test-cli', '1.0.0', commands, { input, output }) + + return { + async close() { + input.end() + await done + }, + next(predicate: (message: any) => boolean) { + const found = messages.find(predicate) + if (found) return Promise.resolve(found) + return new Promise((resolve) => waiters.push({ predicate, resolve })) + }, + send(message: { method?: string; params?: unknown; id?: number | string; result?: unknown }) { + input.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`) + }, + } +} + +async function mcp2026( + body: Record, + options: Mcp.handle2026Http.Options = {}, + headers: Record = {}, +) { + const req = new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': Mcp.DRAFT_PROTOCOL_VERSION, + ...headers, + }, + body: JSON.stringify({ jsonrpc: '2.0', ...body }), + }) + const res = await Mcp.handle2026Http(req, 'test-cli', '1.0.0', create2026Commands(), options) + const text = await res.text() + return { res, body: text ? JSON.parse(text) : undefined } +} + describe('Mcp', () => { test('initialize responds with server info', async () => { const [res] = await mcpSession(createTestCommands(), [ @@ -410,4 +582,606 @@ describe('Mcp', () => { expect(progress[0].params.progress).toBe(1) expect(progress[1].params.progress).toBe(2) }) + + test('tools/call can request form elicitation', async () => { + const session = mcpHarness(createElicitationCommands()) + session.send({ + id: 1, + method: 'initialize', + params: { + ...initParams, + capabilities: { elicitation: { form: {} } }, + }, + }) + await session.next((m) => m.id === 1) + + session.send({ + id: 2, + method: 'tools/call', + params: { name: 'ask-name', arguments: {} }, + }) + const request = await session.next((m) => m.method === 'elicitation/create') + expect(request.params).toMatchObject({ + mode: 'form', + message: 'Please provide your profile.', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name'], + }, + }) + + session.send({ + id: request.id, + result: { action: 'accept', content: { name: 'octocat', age: 30 } }, + }) + const response = await session.next((m) => m.id === 2) + expect(JSON.parse(response.result.content[0].text)).toEqual({ + action: 'accept', + name: 'octocat', + }) + await session.close() + }) + + test('empty elicitation capability supports form mode', async () => { + const session = mcpHarness(createElicitationCommands()) + session.send({ + id: 1, + method: 'initialize', + params: { + ...initParams, + capabilities: { elicitation: {} }, + }, + }) + await session.next((m) => m.id === 1) + + session.send({ + id: 2, + method: 'tools/call', + params: { name: 'ask-name', arguments: {} }, + }) + const request = await session.next((m) => m.method === 'elicitation/create') + session.send({ id: request.id, result: { action: 'decline' } }) + const response = await session.next((m) => m.id === 2) + expect(JSON.parse(response.result.content[0].text)).toEqual({ action: 'decline' }) + await session.close() + }) + + test('tools/call can request URL elicitation', async () => { + const session = mcpHarness(createElicitationCommands()) + session.send({ + id: 1, + method: 'initialize', + params: { + ...initParams, + capabilities: { elicitation: { url: {} } }, + }, + }) + await session.next((m) => m.id === 1) + + session.send({ + id: 2, + method: 'tools/call', + params: { name: 'open-url', arguments: {} }, + }) + const request = await session.next((m) => m.method === 'elicitation/create') + expect(request.params).toMatchObject({ + mode: 'url', + elicitationId: 'auth-1', + message: 'Connect your account.', + url: 'https://example.com/connect', + }) + + session.send({ id: request.id, result: { action: 'accept' } }) + const response = await session.next((m) => m.id === 2) + expect(JSON.parse(response.result.content[0].text)).toEqual({ action: 'accept' }) + await session.close() + }) + + test('unsupported URL elicitation returns a tool error', async () => { + const [, response] = await mcpSession(createElicitationCommands(), [ + { + id: 1, + method: 'initialize', + params: { + ...initParams, + capabilities: { elicitation: { form: {} } }, + }, + }, + { id: 2, method: 'tools/call', params: { name: 'open-url', arguments: {} } }, + ]) + expect(response.result.isError).toBe(true) + expect(response.result.content[0].text).toContain('url elicitation') + }) + + test('requireUrl returns URL elicitation required JSON-RPC error', async () => { + const session = mcpHarness(createElicitationCommands()) + session.send({ + id: 1, + method: 'initialize', + params: { + ...initParams, + capabilities: { elicitation: { url: {} } }, + }, + }) + await session.next((m) => m.id === 1) + + session.send({ + id: 2, + method: 'tools/call', + params: { name: 'require-url', arguments: {} }, + }) + const response = await session.next((m) => m.id === 2) + expect(response.error.code).toBe(-32042) + expect(response.error.data.elicitations).toEqual([ + { + mode: 'url', + elicitationId: 'auth-2', + message: 'Connect your account.', + url: 'https://example.com/connect', + }, + ]) + await session.close() + }) + + test('invalid form elicitation schema returns a tool error', async () => { + const [, response] = await mcpSession(createElicitationCommands(), [ + { + id: 1, + method: 'initialize', + params: { + ...initParams, + capabilities: { elicitation: { form: {} } }, + }, + }, + { id: 2, method: 'tools/call', params: { name: 'nested-form', arguments: {} } }, + ]) + expect(response.result.isError).toBe(true) + expect(response.result.content[0].text).toContain('must not be nested') + }) + + test('invalid URL elicitation input returns a tool error', async () => { + const [, response] = await mcpSession(createElicitationCommands(), [ + { + id: 1, + method: 'initialize', + params: { + ...initParams, + capabilities: { elicitation: { url: {} } }, + }, + }, + { id: 2, method: 'tools/call', params: { name: 'bad-url', arguments: {} } }, + ]) + expect(response.result.isError).toBe(true) + expect(response.result.content[0].text).toContain('URL elicitation requires a valid URL.') + }) + + test('2026 server/discover advertises stateless capabilities', async () => { + const { body } = await mcp2026({ id: 1, method: 'server/discover' }) + expect(body.result.resultType).toBe('complete') + expect(body.result.supportedVersions).toContain(Mcp.DRAFT_PROTOCOL_VERSION) + expect(body.result.serverInfo).toEqual({ name: 'test-cli', version: '1.0.0' }) + expect(body.result.capabilities.tools).toBeDefined() + expect(body.result.capabilities.extensions[Mcp.TASKS_EXTENSION_ID]).toEqual({}) + }) + + test('2026 rejects unsupported protocol versions', async () => { + const { res, body } = await mcp2026( + { id: 1, method: 'tools/list', params: {} }, + {}, + { 'MCP-Protocol-Version': '1999-01-01' }, + ) + expect(res.status).toBe(400) + expect(body.error.code).toBe(-32001) + expect(body.error.data.supportedVersions).toContain(Mcp.DRAFT_PROTOCOL_VERSION) + }) + + test('2026 validates method and name routing headers', async () => { + const wrongMethod = await mcp2026( + { id: 1, method: 'tools/list', params: {} }, + {}, + { 'Mcp-Method': 'tools/call' }, + ) + expect(wrongMethod.body.error.code).toBe(-32600) + expect(wrongMethod.body.error.message).toContain('Mcp-Method') + + const wrongTool = await mcp2026( + { + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { message: 'hi' } }, + }, + {}, + { 'Mcp-Method': 'tools/call', 'Mcp-Name': 'ping' }, + ) + expect(wrongTool.body.error.code).toBe(-32600) + expect(wrongTool.body.error.message).toContain('Mcp-Name') + }) + + test('stdio routes 2026 server/discover through the stateless dispatcher', async () => { + const [res] = await mcpSession(create2026Commands(), [ + { id: 1, method: 'server/discover', params: {} }, + ]) + expect(res.result.resultType).toBe('complete') + expect(res.result.supportedVersions).toContain(Mcp.DRAFT_PROTOCOL_VERSION) + }) + + test('2026 tools/list includes metadata, headers, output schemas, and cache hints', async () => { + const { body } = await mcp2026( + { + id: 1, + method: 'tools/list', + params: { + _meta: { 'io.modelcontextprotocol/protocolVersion': Mcp.DRAFT_PROTOCOL_VERSION }, + }, + }, + { cache: { ttlMs: 1000, cacheScope: 'private' } }, + ) + const tool = body.result.tools.find((t: any) => t.name === 'meta') + expect(body.result.resultType).toBe('complete') + expect(body.result.ttlMs).toBe(1000) + expect(tool.title).toBe('Metadata') + expect(tool.icons[0].src).toBe('https://example.com/icon.svg') + expect(tool.annotations.readOnlyHint).toBe(true) + expect(tool.inputSchema.properties.token['x-mcp-header']).toBe('Authorization') + expect(tool.outputSchema.properties.ok).toBeDefined() + }) + + test('2026 tools/call executes without initialize', async () => { + const { body } = await mcp2026( + { + id: 1, + method: 'tools/call', + params: { + name: 'echo', + arguments: { message: 'hi', upper: true }, + _meta: { 'io.modelcontextprotocol/protocolVersion': Mcp.DRAFT_PROTOCOL_VERSION }, + }, + }, + {}, + { 'Mcp-Method': 'tools/call', 'Mcp-Name': 'echo' }, + ) + expect(body.result.resultType).toBe('complete') + expect(JSON.parse(body.result.content[0].text)).toEqual({ result: 'HI' }) + }) + + test('2026 tools/call uses MRTR elicitation input requests and responses', async () => { + const req = new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': Mcp.DRAFT_PROTOCOL_VERSION, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'ask-name', arguments: {} }, + }), + }) + const first = await Mcp.handle2026Http(req, 'test-cli', '1.0.0', createElicitationCommands()) + const firstBody = (await first.json()) as any + expect(firstBody.result.resultType).toBe('input_required') + expect(firstBody.result.inputRequests.input_1.params.mode).toBe('form') + + const retry = new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': Mcp.DRAFT_PROTOCOL_VERSION, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'ask-name', + arguments: {}, + inputResponses: { input_1: { action: 'accept', content: { name: 'octocat', age: 30 } } }, + requestState: firstBody.result.requestState, + }, + }), + }) + const second = await Mcp.handle2026Http(retry, 'test-cli', '1.0.0', createElicitationCommands()) + const secondBody = (await second.json()) as any + expect(secondBody.result.resultType).toBe('complete') + expect(JSON.parse(secondBody.result.content[0].text)).toEqual({ + action: 'accept', + name: 'octocat', + }) + }) + + test('2026 MRTR elicitation supports stable keys', async () => { + const first = await Mcp.handle2026Http( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': Mcp.DRAFT_PROTOCOL_VERSION, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'ask-keyed', arguments: {} }, + }), + }), + 'test-cli', + '1.0.0', + createElicitationCommands(), + ) + const firstBody = (await first.json()) as any + expect(firstBody.result.inputRequests.profile.params.mode).toBe('form') + + const second = await Mcp.handle2026Http( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': Mcp.DRAFT_PROTOCOL_VERSION, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'ask-keyed', + arguments: {}, + inputResponses: { profile: { action: 'accept', content: { name: 'octocat' } } }, + }, + }), + }), + 'test-cli', + '1.0.0', + createElicitationCommands(), + ) + const secondBody = (await second.json()) as any + expect(JSON.parse(secondBody.result.content[0].text)).toEqual({ name: 'octocat' }) + }) + + test('2026 resources, prompts, apps, and completion are exposed through Cli.fetch', async () => { + const cli = Cli.create('tool') + .resource('config', { + uri: 'file:///config.json', + title: 'Config', + read: () => ({ + uri: 'file:///config.json', + mimeType: 'application/json', + text: '{"ok":true}', + }), + }) + .resourceTemplate('user', { + uriTemplate: 'file:///users/{id}.json', + complete: { id: (value) => ['one', 'two'].filter((id) => id.startsWith(value)) }, + }) + .prompt('review', { + args: z.object({ language: z.string().describe('Language') }), + complete: { + language: (value) => ['typescript', 'rust'].filter((lang) => lang.startsWith(value)), + }, + get: (args) => [{ role: 'user', content: Mcp.text(`Review ${args.language}`) }], + }) + .app('panel', { resourceUri: 'ui://panel', html: '
panel
' }) + + async function request(method: string, params: Record = {}) { + const res = await cli.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': Mcp.DRAFT_PROTOCOL_VERSION, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }), + ) + return (await res.json()) as any + } + + expect( + (await request('resources/list')).result.resources.map((r: any) => r.uri).sort(), + ).toEqual(['file:///config.json', 'ui://panel']) + expect( + (await request('resources/templates/list')).result.resourceTemplates[0].uriTemplate, + ).toBe('file:///users/{id}.json') + const panel = (await request('resources/read', { uri: 'ui://panel' })).result.contents[0] + expect(panel.text).toContain('panel') + expect(panel.mimeType).toBe(Mcp.APP_RESOURCE_MIME_TYPE) + const missing = await request('resources/read', { uri: 'file:///missing.json' }) + expect(missing.error.code).toBe(-32602) + expect(missing.error.data.uri).toBe('file:///missing.json') + expect((await request('prompts/list')).result.prompts[0].name).toBe('review') + expect( + (await request('prompts/get', { name: 'review', arguments: { language: 'typescript' } })) + .result.messages[0].content.text, + ).toBe('Review typescript') + expect( + ( + await request('completion/complete', { + ref: { type: 'ref/prompt', name: 'review' }, + argument: { name: 'language', value: 't' }, + }) + ).result.completion.values, + ).toEqual(['typescript']) + expect( + ( + await request('completion/complete', { + ref: { type: 'ref/resource', uri: 'file:///users/{id}.json' }, + argument: { name: 'id', value: 'o' }, + }) + ).result.completion.values, + ).toEqual(['one']) + + const discover = await request('server/discover') + expect(discover.result.capabilities.extensions[Mcp.APPS_EXTENSION_ID].mimeTypes).toEqual([ + Mcp.APP_RESOURCE_MIME_TYPE, + ]) + expect(discover.result.capabilities.extensions[Mcp.APPS_EXTENSION_ALIAS].mimeTypes).toEqual([ + Mcp.APP_RESOURCE_MIME_TYPE, + ]) + }) + + test('2026 task-backed tools can be polled and cancelled', async () => { + const created = await mcp2026({ + id: 1, + method: 'tools/call', + params: { + name: 'tasked', + arguments: {}, + _meta: { + 'io.modelcontextprotocol/clientCapabilities': { + extensions: { [Mcp.TASKS_EXTENSION_ID]: {} }, + }, + }, + }, + }) + expect(created.body.result.resultType).toBe('task') + const taskId = created.body.result.taskId + expect(taskId).toEqual(expect.any(String)) + expect(created.body.result.ttlMs).toBe(300000) + expect(created.body.result.pollIntervalMs).toBe(250) + + await new Promise((resolve) => setTimeout(resolve, 10)) + const polled = await mcp2026({ id: 2, method: 'tasks/get', params: { taskId } }) + expect(polled.body.result.status).toBe('completed') + expect(JSON.parse(polled.body.result.result.content[0].text)).toEqual({ done: true }) + + const cancelled = await mcp2026({ id: 3, method: 'tasks/cancel', params: { taskId } }) + expect(cancelled.body.result).toEqual({ resultType: 'complete' }) + const afterCancel = await mcp2026({ id: 4, method: 'tasks/get', params: { taskId } }) + expect(afterCancel.body.result.status).toBe('cancelled') + }) + + test('2026 task methods validate Mcp-Name against taskId', async () => { + const created = await mcp2026({ + id: 1, + method: 'tools/call', + params: { + name: 'tasked', + arguments: {}, + _meta: { + 'io.modelcontextprotocol/clientCapabilities': { + extensions: { [Mcp.TASKS_EXTENSION_ID]: {} }, + }, + }, + }, + }) + const wrongName = await mcp2026( + { id: 2, method: 'tasks/get', params: { taskId: created.body.result.taskId } }, + {}, + { 'Mcp-Method': 'tasks/get', 'Mcp-Name': 'not-the-task' }, + ) + expect(wrongName.body.error.code).toBe(-32600) + expect(wrongName.body.error.message).toContain('taskId') + }) + + test('2026 task tools require client task extension support', async () => { + const created = await mcp2026({ + id: 1, + method: 'tools/call', + params: { name: 'tasked', arguments: {} }, + }) + expect(created.body.error.code).toBe(-32003) + expect(created.body.error.data.requiredCapabilities.extensions[Mcp.TASKS_EXTENSION_ID]).toEqual( + {}, + ) + }) + + test('2026 task tools can enter input_required and resume through tasks/update', async () => { + const created = await mcp2026({ + id: 1, + method: 'tools/call', + params: { + name: 'task-input', + arguments: {}, + _meta: { + 'io.modelcontextprotocol/clientCapabilities': { + extensions: { [Mcp.TASKS_EXTENSION_ID]: {} }, + }, + }, + }, + }) + const taskId = created.body.result.taskId + + await new Promise((resolve) => setTimeout(resolve, 10)) + const waiting = await mcp2026({ id: 2, method: 'tasks/get', params: { taskId } }) + expect(waiting.body.result.status).toBe('input_required') + expect(waiting.body.result.inputRequests.profile.params.mode).toBe('form') + + const updated = await mcp2026({ + id: 3, + method: 'tasks/update', + params: { + taskId, + inputResponses: { profile: { action: 'accept', content: { name: 'octocat' } } }, + }, + }) + expect(updated.body.result).toEqual({ resultType: 'complete' }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + const completed = await mcp2026({ id: 4, method: 'tasks/get', params: { taskId } }) + expect(completed.body.result.status).toBe('completed') + expect(JSON.parse(completed.body.result.result.content[0].text)).toEqual({ name: 'octocat' }) + }) + + test('2026 authorization extensions advertise and enforce an authorization hook', async () => { + const cli = Cli.create('secure', { + mcpServer: { + authorization: { + oauthClientCredentials: { scopes: ['read:tools'] }, + enterpriseManagedAuthorization: true, + authorize: ({ bearerToken }) => bearerToken === 'good', + }, + }, + }).command('ping', { run: () => ({ pong: true }) }) + + async function request(token?: string) { + const headers: Record = { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': Mcp.DRAFT_PROTOCOL_VERSION, + } + if (token) headers.Authorization = `Bearer ${token}` + const res = await cli.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }), + }), + ) + return (await res.json()) as any + } + + const denied = await request('bad') + expect(denied.error.code).toBe(-32004) + expect(denied.error.data.extensions[Mcp.OAUTH_CLIENT_CREDENTIALS_EXTENSION_ID]).toEqual({ + scopes: ['read:tools'], + }) + + const allowed = await request('good') + expect(allowed.result.tools[0].name).toBe('ping') + + const discover = await cli.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'server/discover' }), + }), + ) + const discoverBody = (await discover.json()) as any + expect( + discoverBody.result.capabilities.extensions[Mcp.OAUTH_CLIENT_CREDENTIALS_EXTENSION_ID], + ).toEqual({ scopes: ['read:tools'] }) + expect( + discoverBody.result.capabilities.extensions[ + Mcp.ENTERPRISE_MANAGED_AUTHORIZATION_EXTENSION_ID + ], + ).toEqual({}) + }) }) diff --git a/src/Mcp.ts b/src/Mcp.ts index c622a02..01e3380 100644 --- a/src/Mcp.ts +++ b/src/Mcp.ts @@ -1,11 +1,52 @@ -import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server' -import type { Readable, Writable } from 'node:stream' +import { + McpServer, + StdioServerTransport, + UrlElicitationRequiredError, +} from '@modelcontextprotocol/server' +import { PassThrough, type Readable, type Writable } from 'node:stream' import { z } from 'zod' +import * as Elicitation from './Elicitation.js' import * as Command from './internal/command.js' import type { Handler as MiddlewareHandler } from './middleware.js' import * as Schema from './Schema.js' +/** MCP 2026 release-candidate protocol version advertised by incur. */ +export const DRAFT_PROTOCOL_VERSION = 'DRAFT-2026-v1' + +/** MCP 2026 final protocol version planned by the release candidate. */ +export const PROTOCOL_VERSION_2026 = '2026-07-28' + +/** Protocol versions supported by incur's MCP server implementation. */ +export const SUPPORTED_PROTOCOL_VERSIONS = [ + DRAFT_PROTOCOL_VERSION, + PROTOCOL_VERSION_2026, + '2025-11-25', + '2025-06-18', + '2025-03-26', + '2024-11-05', +] + +/** Canonical MCP Apps extension identifier. */ +export const APPS_EXTENSION_ID = 'io.modelcontextprotocol/ui' + +/** MCP Apps compatibility extension identifier used by the draft lifecycle examples. */ +export const APPS_EXTENSION_ALIAS = 'io.modelcontextprotocol/apps' + +/** MCP Tasks extension identifier. */ +export const TASKS_EXTENSION_ID = 'io.modelcontextprotocol/tasks' + +/** OAuth client credentials authorization extension identifier. */ +export const OAUTH_CLIENT_CREDENTIALS_EXTENSION_ID = + 'io.modelcontextprotocol/oauth-client-credentials' + +/** Enterprise-managed authorization extension identifier. */ +export const ENTERPRISE_MANAGED_AUTHORIZATION_EXTENSION_ID = + 'io.modelcontextprotocol/enterprise-managed-authorization' + +/** MCP Apps HTML resource MIME type. */ +export const APP_RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app' + /** Starts a stdio MCP server that exposes commands as tools. */ export async function serve( name: string, @@ -35,6 +76,7 @@ export async function serve( const extra = hasInput ? callArgs[1] : callArgs[0] return callTool(tool, params, { extra, + clientCapabilities: server.server.getClientCapabilities(), sendNotification: (n) => server.server.notification(n), name, version, @@ -48,7 +90,12 @@ export async function serve( const input = options.input ?? process.stdin const output = options.output ?? process.stdout - const transport = new StdioServerTransport(input as any, output as any) + const routed = await routeStdio(input as Readable) + if (routed.modern) { + await serve2026Stdio(routed.input, output as Writable, name, version, commands, options) + return + } + const transport = new StdioServerTransport(routed.input as any, output as any) await server.connect(transport) } @@ -70,15 +117,113 @@ export declare namespace serve { } } +async function routeStdio(input: Readable): Promise<{ input: Readable; modern: boolean }> { + const routed = await replayFirstLine(input) + let message: JsonRpcRequest | undefined + try { + message = JSON.parse(routed.firstLine) as JsonRpcRequest + } catch { + return { input: routed.input, modern: false } + } + return { input: routed.input, modern: is2026Message(message) } +} + +async function replayFirstLine(input: Readable) { + return new Promise<{ firstLine: string; input: Readable }>((resolve) => { + const buffers: Buffer[] = [] + const replay = new PassThrough() + + function done(buffer: Buffer, newline: number) { + input.off('data', onData) + input.off('end', onEnd) + const first = newline === -1 ? buffer : buffer.subarray(0, newline + 1) + const rest = newline === -1 ? Buffer.alloc(0) : buffer.subarray(newline + 1) + replay.write(first) + if (rest.length > 0) replay.write(rest) + input.pipe(replay) + resolve({ firstLine: first.toString('utf8').trim(), input: replay }) + } + + function onData(chunk: Buffer | string) { + buffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + const buffer = Buffer.concat(buffers) + const newline = buffer.indexOf('\n') + if (newline !== -1) done(buffer, newline) + } + + function onEnd() { + const buffer = Buffer.concat(buffers) + replay.end(buffer) + resolve({ firstLine: buffer.toString('utf8').trim(), input: replay }) + } + + input.on('data', onData) + input.on('end', onEnd) + }) +} + +async function serve2026Stdio( + input: Readable, + output: Writable, + name: string, + version: string, + commands: Map, + options: serve.Options, +) { + let buffer = '' + for await (const chunk of input) { + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) + await handle2026StdioLine(line, output, name, version, commands, options) + } + if (buffer.trim()) await handle2026StdioLine(buffer, output, name, version, commands, options) +} + +async function handle2026StdioLine( + line: string, + output: Writable, + name: string, + version: string, + commands: Map, + options: serve.Options, +) { + if (!line.trim()) return + const message = JSON.parse(line) as JsonRpcRequest + const protocolVersion = + message.method === 'server/discover' + ? DRAFT_PROTOCOL_VERSION + : String( + metaFrom(message)?.['io.modelcontextprotocol/protocolVersion'] ?? DRAFT_PROTOCOL_VERSION, + ) + const response = await handle2026Http( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': protocolVersion, + }, + body: JSON.stringify(message), + }), + name, + version, + commands, + { env: options.env, middlewares: options.middlewares, vars: options.vars }, + ) + const text = await response.text() + if (text) output.write(`${text}\n`) +} + /** @internal Executes a tool call and returns a CallToolResult. */ export async function callTool( tool: ToolEntry, params: Record, options: { - extra?: { - mcpReq?: { _meta?: { progressToken?: string | number } } - } - sendNotification?: (n: ProgressNotification) => Promise + clientCapabilities?: ClientCapabilities | undefined + elicitation?: Elicitation.Adapter | undefined + extra?: Extra | undefined + sendNotification?: ((n: ProgressNotification) => Promise) | undefined name?: string | undefined version?: string | undefined middlewares?: MiddlewareHandler[] | undefined @@ -100,6 +245,8 @@ export async function callTool( agent: true, argv: [], env: options.env, + elicitation: + options.elicitation ?? createElicitationAdapter(options.extra, options.clientCapabilities), format: 'json', formatExplicit: true, inputOptions: params, @@ -107,6 +254,7 @@ export async function callTool( name: options.name ?? tool.name, parseMode: 'flat', path: tool.name, + rethrowErrors: (error) => isUrlElicitationRequiredError(error) || isInputRequiredError(error), vars: options.vars, version: options.version, }) @@ -149,12 +297,1113 @@ export async function callTool( } } +/** Handles a stateless MCP 2026 Streamable HTTP request. */ +export async function handle2026Http( + req: Request, + name: string, + version: string, + commands: Map, + options: handle2026Http.Options = {}, +): Promise { + let message: JsonRpcRequest + try { + message = (await req.json()) as JsonRpcRequest + } catch { + return json(error(null, -32700, 'Parse error'), 400) + } + + if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') + return json(error(message?.id ?? null, -32600, 'Invalid Request'), 400) + + if (message.method !== 'server/discover') { + const protocolVersion = protocolVersionFrom(req, message) + if (!SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) + return json( + error(message.id, -32001, `Unsupported protocol version: ${protocolVersion}`, { + supportedVersions: SUPPORTED_PROTOCOL_VERSIONS, + }), + 400, + ) + + const headerMethod = req.headers.get('Mcp-Method') + if (headerMethod && headerMethod !== message.method) + return json( + error(message.id, -32600, 'Mcp-Method header does not match JSON-RPC method.'), + 400, + ) + + const headerName = req.headers.get('Mcp-Name') + if (headerName && message.method === 'tools/call' && headerName !== toolName(message.params)) + return json(error(message.id, -32600, 'Mcp-Name header does not match tool name.'), 400) + if (headerName && isTaskMethod(message.method) && headerName !== taskIdFrom(message.params)) + return json(error(message.id, -32600, 'Mcp-Name header does not match taskId.'), 400) + + if (options.authorization?.authorize) { + const authorized = await options.authorization.authorize({ + request: req, + bearerToken: bearerToken(req), + method: message.method, + params: isObject(message.params) ? message.params : undefined, + }) + if (!authorized) + return json( + error(message.id, -32004, 'Unauthorized', { + extensions: advertisedAuthorizationExtensions(options.authorization), + }), + 401, + ) + } + } + + try { + const result = await handle2026Message(message, name, version, commands, options) + if (result instanceof Response) return result + if (message.id === undefined) return new Response(null, { status: 202 }) + return json({ jsonrpc: '2.0', id: message.id, result }) + } catch (err) { + if (err instanceof InputRequiredError) + return json({ + jsonrpc: '2.0', + id: message.id, + result: { + resultType: 'input_required', + inputRequests: err.inputRequests, + requestState: err.requestState, + }, + }) + if (err instanceof JsonRpcError) + return json(error(message.id, err.code, err.message, err.data), err.status) + return json(error(message.id, -32603, err instanceof Error ? err.message : String(err)), 500) + } +} + +/** Returns true when a request should use incur's stateless MCP 2026 dispatcher. */ +export async function is2026HttpRequest(req: Request): Promise { + const version = req.headers.get('MCP-Protocol-Version') ?? req.headers.get('mcp-protocol-version') + if (version === DRAFT_PROTOCOL_VERSION || version === PROTOCOL_VERSION_2026) return true + + try { + const message = (await req.clone().json()) as JsonRpcRequest + return is2026Message(message) + } catch { + return false + } +} + +function is2026Message(message: JsonRpcRequest) { + if (message.method === 'server/discover') return true + const meta = metaFrom(message) + return ( + meta?.['io.modelcontextprotocol/protocolVersion'] === DRAFT_PROTOCOL_VERSION || + meta?.['io.modelcontextprotocol/protocolVersion'] === PROTOCOL_VERSION_2026 + ) +} + +export declare namespace handle2026Http { + /** Options passed to the stateless MCP 2026 handler. */ + type Options = { + /** Cache hints for cacheable list/read results. */ + cache?: CacheOptions | undefined + /** MCP Apps registered by the CLI. */ + apps?: AppDefinition[] | undefined + /** Optional authorization extensions and request validator. */ + authorization?: AuthorizationOptions | undefined + /** CLI-level env schema. */ + env?: z.ZodObject | undefined + /** Middleware handlers registered on the root CLI. */ + middlewares?: MiddlewareHandler[] | undefined + /** MCP prompts registered by the CLI. */ + prompts?: PromptDefinition[] | undefined + /** MCP resources registered by the CLI. */ + resources?: ResourceDefinition[] | undefined + /** MCP resource templates registered by the CLI. */ + resourceTemplates?: ResourceTemplateDefinition[] | undefined + /** Vars schema for middleware variables. */ + vars?: z.ZodObject | undefined + } +} + +/** Authorization extension options for remote MCP deployments. */ +export type AuthorizationOptions = { + /** Advertise and accept OAuth client credentials bearer-token authentication. */ + oauthClientCredentials?: ExtensionSettings | undefined + /** Advertise and accept enterprise-managed authorization bearer-token authentication. */ + enterpriseManagedAuthorization?: ExtensionSettings | undefined + /** Validate a request before MCP handling. */ + authorize?: ((context: AuthorizationContext) => boolean | Promise) | undefined +} + +/** Extension settings object advertised in MCP capabilities. */ +export type ExtensionSettings = boolean | Record + +/** Context supplied to the MCP authorization hook. */ +export type AuthorizationContext = { + /** Incoming HTTP request. */ + request: Request + /** Bearer token from the Authorization header, if present. */ + bearerToken?: string | undefined + /** MCP method being handled. */ + method: string + /** Parsed JSON-RPC params. */ + params?: Record | undefined +} + +/** Cache hint fields required on MCP 2026 cacheable results. */ +export type CacheOptions = { + /** Freshness hint in milliseconds. */ + ttlMs: number + /** Whether the result may be cached across users. */ + cacheScope: 'public' | 'private' +} + +/** Icon metadata for MCP tools, prompts, resources, and apps. */ +export type Icon = { + /** Icon URL. */ + src: string + /** Optional MIME type, such as `image/svg+xml`. */ + mimeType?: string | undefined + /** Optional size hints, such as `48x48` or `any`. */ + sizes?: string[] | undefined +} + +/** MCP content annotations shared by resources and tool results. */ +export type Annotations = { + /** Intended audience for this content. */ + audience?: ('user' | 'assistant')[] | undefined + /** Relative priority from 0 to 1. */ + priority?: number | undefined + /** ISO timestamp for the last modification time. */ + lastModified?: string | undefined +} + +/** MCP tool behavior annotations. */ +export type ToolAnnotations = { + /** Human-readable title. */ + title?: string | undefined + /** Whether the tool only reads state. */ + readOnlyHint?: boolean | undefined + /** Whether the tool may modify state. */ + destructiveHint?: boolean | undefined + /** Whether repeated calls with the same input are expected to be idempotent. */ + idempotentHint?: boolean | undefined + /** Whether the tool interacts with open external systems. */ + openWorldHint?: boolean | undefined +} + +/** MCP tool metadata supplied by a command definition. */ +export type ToolMetadata = { + /** Human-readable display title. */ + title?: string | undefined + /** Tool icons. */ + icons?: Icon[] | undefined + /** Tool behavior annotations. */ + annotations?: ToolAnnotations | undefined + /** HTTP header mappings keyed by input property name. */ + headers?: Record | undefined + /** MCP Apps UI resource for this tool. */ + app?: { resourceUri: string } | undefined + /** Cache hints for list results involving this tool. */ + cache?: CacheOptions | undefined + /** Task execution options for long-running tools. */ + task?: TaskOptions | undefined +} + +/** MCP task execution options. */ +export type TaskOptions = { + /** Whether the tool should always return a task handle. */ + required?: boolean | undefined + /** Time-to-live for task state in milliseconds. */ + ttlMs?: number | undefined + /** Suggested polling interval in milliseconds. */ + pollIntervalMs?: number | undefined +} + +/** Text resource content. */ +export type TextResourceContent = { + /** Resource URI. */ + uri: string + /** MIME type. */ + mimeType?: string | undefined + /** Text content. */ + text: string + /** Optional annotations. */ + annotations?: Annotations | undefined +} + +/** Binary resource content. */ +export type BlobResourceContent = { + /** Resource URI. */ + uri: string + /** MIME type. */ + mimeType?: string | undefined + /** Base64-encoded binary content. */ + blob: string + /** Optional annotations. */ + annotations?: Annotations | undefined +} + +/** MCP resource content. */ +export type ResourceContent = TextResourceContent | BlobResourceContent + +/** MCP resource definition. */ +export type ResourceDefinition = { + /** Programmatic name. */ + name: string + /** Resource URI. */ + uri: string + /** Human-readable title. */ + title?: string | undefined + /** Description. */ + description?: string | undefined + /** MIME type. */ + mimeType?: string | undefined + /** Resource size in bytes. */ + size?: number | undefined + /** Icons. */ + icons?: Icon[] | undefined + /** Annotations. */ + annotations?: Annotations | undefined + /** Cache hints for reads. */ + cache?: CacheOptions | undefined + /** Reads resource contents. */ + read: () => ResourceContent | ResourceContent[] | Promise +} + +/** MCP resource template definition. */ +export type ResourceTemplateDefinition = { + /** Programmatic name. */ + name: string + /** URI template. */ + uriTemplate: string + /** Human-readable title. */ + title?: string | undefined + /** Description. */ + description?: string | undefined + /** MIME type. */ + mimeType?: string | undefined + /** Icons. */ + icons?: Icon[] | undefined + /** Annotations. */ + annotations?: Annotations | undefined + /** Completion handlers keyed by template variable. */ + complete?: + | Record string[] | Promise> + | undefined +} + +/** Context supplied to MCP completion callbacks. */ +export type CompletionContext = { + /** Already resolved variables or arguments. */ + arguments?: Record | undefined +} + +/** MCP prompt message. */ +export type PromptMessage = { + /** Message role. */ + role: 'user' | 'assistant' + /** Message content block. */ + content: ContentBlock +} + +/** MCP prompt definition. */ +export type PromptDefinition = { + /** Programmatic name. */ + name: string + /** Human-readable title. */ + title?: string | undefined + /** Description. */ + description?: string | undefined + /** Arguments schema. */ + args?: z.ZodObject | undefined + /** Icons. */ + icons?: Icon[] | undefined + /** Completion handlers keyed by argument name. */ + complete?: + | Record string[] | Promise> + | undefined + /** Renders prompt messages. */ + get: (args: Record) => PromptMessage[] | Promise +} + +/** MCP App definition. */ +export type AppDefinition = { + /** Programmatic app name. */ + name: string + /** UI resource URI, typically `ui://...`. */ + resourceUri: string + /** HTML text served as the app resource. */ + html: string | (() => string | Promise) + /** Display title. */ + title?: string | undefined + /** Description. */ + description?: string | undefined + /** Icons. */ + icons?: Icon[] | undefined +} + +/** MCP content block returned by tools and prompts. */ +export type ContentBlock = + | { type: 'text'; text: string; annotations?: Annotations | undefined } + | { type: 'image'; data: string; mimeType: string; annotations?: Annotations | undefined } + | { type: 'audio'; data: string; mimeType: string; annotations?: Annotations | undefined } + | { + type: 'resource_link' + uri: string + name: string + description?: string | undefined + mimeType?: string | undefined + annotations?: Annotations | undefined + } + | { type: 'resource'; resource: ResourceContent } + +/** Creates a text MCP content block. */ +export function text(text: string, annotations?: Annotations | undefined): ContentBlock { + return annotations ? { type: 'text', text, annotations } : { type: 'text', text } +} + +/** Creates an image MCP content block. */ +export function image( + data: string, + mimeType: string, + annotations?: Annotations | undefined, +): ContentBlock { + return annotations + ? { type: 'image', data, mimeType, annotations } + : { type: 'image', data, mimeType } +} + +/** Creates an audio MCP content block. */ +export function audio( + data: string, + mimeType: string, + annotations?: Annotations | undefined, +): ContentBlock { + return annotations + ? { type: 'audio', data, mimeType, annotations } + : { type: 'audio', data, mimeType } +} + +/** Creates a resource link MCP content block. */ +export function resourceLink( + uri: string, + name: string, + options: { + description?: string | undefined + mimeType?: string | undefined + annotations?: Annotations | undefined + } = {}, +): ContentBlock { + return { type: 'resource_link', uri, name, ...options } +} + +/** Creates an embedded resource MCP content block. */ +export function embeddedResource(resource: ResourceContent): ContentBlock { + return { type: 'resource', resource } +} + +async function handle2026Message( + message: JsonRpcRequest, + name: string, + version: string, + commands: Map, + options: handle2026Http.Options, +): Promise | Response> { + if (message.method === 'server/discover') + return complete({ + supportedVersions: SUPPORTED_PROTOCOL_VERSIONS, + capabilities: capabilities(commands, options), + serverInfo: { name, version }, + }) + + if (message.method === 'tools/list') + return withCache( + { + tools: collectTools(commands, []).map(toolDescriptor), + }, + options.cache, + ) + + if (message.method === 'tools/call') + return call2026Tool(message, name, version, commands, options) + + if (message.method === 'resources/list') + return withCache({ resources: resources(options).map(resourceDescriptor) }, options.cache) + + if (message.method === 'resources/templates/list') + return withCache( + { resourceTemplates: (options.resourceTemplates ?? []).map(resourceTemplateDescriptor) }, + options.cache, + ) + + if (message.method === 'resources/read') return read2026Resource(message, options) + + if (message.method === 'prompts/list') + return withCache({ prompts: (options.prompts ?? []).map(promptDescriptor) }, options.cache) + + if (message.method === 'prompts/get') return get2026Prompt(message, options) + + if (message.method === 'completion/complete') return complete2026(message, options) + + if (message.method === 'subscriptions/listen') return subscriptionResponse(message) + + if (message.method === 'tasks/get') return getTask(message) + + if (message.method === 'tasks/update') return updateTask(message) + + if (message.method === 'tasks/cancel') return cancelTask(message) + + throw new JsonRpcError(-32601, `Method not found: ${message.method}`, 404) +} + +async function call2026Tool( + message: JsonRpcRequest, + name: string, + version: string, + commands: Map, + options: handle2026Http.Options, +) { + const params = objectParams(message) + const nameParam = params.name + if (typeof nameParam !== 'string') throw new JsonRpcError(-32602, 'Tool name is required.') + + const tool = collectTools(commands, []).find((t) => t.name === nameParam) + if (!tool) throw new JsonRpcError(-32602, `Unknown tool: ${nameParam}`) + + const args = isObject(params.arguments) ? params.arguments : {} + const meta = tool.command.mcpTool as ToolMetadata | undefined + if (meta?.task?.required) { + if (!hasClientExtension(message, TASKS_EXTENSION_ID)) + throw missingRequiredClientCapability(TASKS_EXTENSION_ID) + return createTask(tool, args, name, version, options, meta.task) + } + + const inputResponses = isObject(params.inputResponses) ? params.inputResponses : {} + const result = await callTool(tool, args, { + elicitation: createMrtrAdapter(inputResponses), + env: options.env, + middlewares: options.middlewares, + name, + vars: options.vars, + version, + }) + return complete(result as unknown as Record) +} + +function createMrtrAdapter(inputResponses: Record): Elicitation.Adapter { + let i = 0 + function respond( + key: string, + params: Elicitation.FormRequestParams | Elicitation.UrlRequestParams, + ) { + const existing = inputResponses[key] + if (isObject(existing)) + return existing as { + action: Elicitation.Action + content?: Record + } + throw new InputRequiredError( + { [key]: { method: 'elicitation/create', params } }, + encodeState({ key }), + ) + } + return { + async form(params, options) { + return respond(options?.key ?? `input_${++i}`, params) + }, + requireUrl(params, options) { + respond(options?.key ?? `input_${++i}`, params) + throw new Error('unreachable') + }, + async url(params, options) { + return respond(options?.key ?? `input_${++i}`, params) + }, + } +} + +function capabilities(commands: Map, options: handle2026Http.Options) { + const result: Record = { + tools: { listChanged: false }, + extensions: {}, + } + if (resources(options).length > 0 || (options.resourceTemplates?.length ?? 0) > 0) + result.resources = { listChanged: false, subscribe: true } + if ((options.prompts?.length ?? 0) > 0) result.prompts = { listChanged: false } + if (hasCompletions(options)) result.completions = {} + if ((options.apps?.length ?? 0) > 0) + result.extensions = { + ...(result.extensions as Record), + [APPS_EXTENSION_ID]: { mimeTypes: [APP_RESOURCE_MIME_TYPE] }, + [APPS_EXTENSION_ALIAS]: { mimeTypes: [APP_RESOURCE_MIME_TYPE] }, + } + if (hasTaskTools(commands)) { + result.extensions = { + ...(result.extensions as Record), + [TASKS_EXTENSION_ID]: {}, + } + } + result.extensions = { + ...(result.extensions as Record), + ...advertisedAuthorizationExtensions(options.authorization), + } + return result +} + +function advertisedAuthorizationExtensions(options: AuthorizationOptions | undefined) { + const extensions: Record = {} + if (options?.oauthClientCredentials) + extensions[OAUTH_CLIENT_CREDENTIALS_EXTENSION_ID] = extensionSettings( + options.oauthClientCredentials, + ) + if (options?.enterpriseManagedAuthorization) + extensions[ENTERPRISE_MANAGED_AUTHORIZATION_EXTENSION_ID] = extensionSettings( + options.enterpriseManagedAuthorization, + ) + return extensions +} + +function extensionSettings(settings: ExtensionSettings) { + return settings === true ? {} : settings +} + +function toolDescriptor(tool: ToolEntry) { + const meta = tool.command.mcpTool as ToolMetadata | undefined + const inputSchema = addHeaders(tool.inputSchema, meta?.headers) + return { + name: tool.name, + ...(meta?.title ? { title: meta.title } : undefined), + ...(tool.description ? { description: tool.description } : undefined), + inputSchema, + ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : undefined), + ...(meta?.icons ? { icons: meta.icons } : undefined), + ...(meta?.annotations ? { annotations: meta.annotations } : undefined), + ...(meta?.app ? { _meta: { ui: { resourceUri: meta.app.resourceUri } } } : undefined), + ...(meta?.task + ? { execution: { taskSupport: meta.task.required ? 'required' : 'optional' } } + : undefined), + } +} + +function addHeaders( + schema: { type: 'object'; properties: Record; required?: string[] }, + headers?: Record | undefined, +) { + if (!headers) return schema + const properties = { ...schema.properties } + for (const [key, value] of Object.entries(headers)) { + const property = properties[key] + if (isObject(property)) properties[key] = { ...property, 'x-mcp-header': value } + } + return { ...schema, properties } +} + +function resources(options: handle2026Http.Options): ResourceDefinition[] { + const apps = (options.apps ?? []).map( + (app): ResourceDefinition => ({ + name: app.name, + uri: app.resourceUri, + title: app.title, + description: app.description, + mimeType: APP_RESOURCE_MIME_TYPE, + icons: app.icons, + async read() { + const html = typeof app.html === 'function' ? await app.html() : app.html + return { uri: app.resourceUri, mimeType: APP_RESOURCE_MIME_TYPE, text: html } + }, + }), + ) + return [...(options.resources ?? []), ...apps] +} + +function resourceDescriptor(resource: ResourceDefinition) { + return { + uri: resource.uri, + name: resource.name, + ...(resource.title ? { title: resource.title } : undefined), + ...(resource.description ? { description: resource.description } : undefined), + ...(resource.mimeType ? { mimeType: resource.mimeType } : undefined), + ...(resource.size !== undefined ? { size: resource.size } : undefined), + ...(resource.icons ? { icons: resource.icons } : undefined), + ...(resource.annotations ? { annotations: resource.annotations } : undefined), + } +} + +function resourceTemplateDescriptor(template: ResourceTemplateDefinition) { + return { + uriTemplate: template.uriTemplate, + name: template.name, + ...(template.title ? { title: template.title } : undefined), + ...(template.description ? { description: template.description } : undefined), + ...(template.mimeType ? { mimeType: template.mimeType } : undefined), + ...(template.icons ? { icons: template.icons } : undefined), + ...(template.annotations ? { annotations: template.annotations } : undefined), + } +} + +async function read2026Resource(message: JsonRpcRequest, options: handle2026Http.Options) { + const uri = objectParams(message).uri + if (typeof uri !== 'string') throw new JsonRpcError(-32602, 'Resource uri is required.') + const resource = resources(options).find((r) => r.uri === uri) + if (!resource) throw new JsonRpcError(-32602, 'Resource not found', 400, { uri }) + const contents = await resource.read() + return withCache( + { contents: Array.isArray(contents) ? contents : [contents] }, + resource.cache ?? options.cache, + ) +} + +function promptDescriptor(prompt: PromptDefinition) { + const args = prompt.args ? Schema.toJsonSchema(prompt.args) : undefined + const properties = isObject(args?.properties) ? args.properties : {} + const required = new Set(Array.isArray(args?.required) ? (args.required as string[]) : []) + return { + name: prompt.name, + ...(prompt.title ? { title: prompt.title } : undefined), + ...(prompt.description ? { description: prompt.description } : undefined), + arguments: Object.entries(properties).map(([name, schema]) => ({ + name, + ...(isObject(schema) && typeof schema.description === 'string' + ? { description: schema.description } + : undefined), + required: required.has(name), + })), + ...(prompt.icons ? { icons: prompt.icons } : undefined), + } +} + +async function get2026Prompt(message: JsonRpcRequest, options: handle2026Http.Options) { + const params = objectParams(message) + const name = params.name + if (typeof name !== 'string') throw new JsonRpcError(-32602, 'Prompt name is required.') + const prompt = (options.prompts ?? []).find((p) => p.name === name) + if (!prompt) throw new JsonRpcError(-32602, `Unknown prompt: ${name}`) + const rawArgs = isObject(params.arguments) ? params.arguments : {} + const parsed = prompt.args ? prompt.args.parse(rawArgs) : rawArgs + return complete({ + ...(prompt.description ? { description: prompt.description } : undefined), + messages: await prompt.get(parsed as Record), + }) +} + +async function complete2026(message: JsonRpcRequest, options: handle2026Http.Options) { + const params = objectParams(message) + const argument = isObject(params.argument) ? params.argument : {} + const ref = isObject(params.ref) ? params.ref : {} + const name = typeof argument.name === 'string' ? argument.name : '' + const value = typeof argument.value === 'string' ? argument.value : '' + const context = + isObject(params.context) && isObject(params.context.arguments) + ? { arguments: params.context.arguments as Record } + : {} + + let values: string[] = [] + if (ref.type === 'ref/prompt' && typeof ref.name === 'string') { + const prompt = (options.prompts ?? []).find((p) => p.name === ref.name) + values = prompt?.complete?.[name] ? await prompt.complete[name]!(value, context) : [] + } else if (ref.type === 'ref/resource' && typeof ref.uri === 'string') { + const template = (options.resourceTemplates ?? []).find((t) => t.uriTemplate === ref.uri) + values = template?.complete?.[name] ? await template.complete[name]!(value, context) : [] + } + + return complete({ + completion: { + values: values.slice(0, 100), + total: values.length, + hasMore: values.length > 100, + }, + }) +} + +function subscriptionResponse(message: JsonRpcRequest) { + const body = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder() + controller.enqueue( + encoder.encode( + `${JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { subscriptionId: String(message.id ?? crypto.randomUUID()) }, + })}\n`, + ), + ) + if (message.id !== undefined) + controller.enqueue( + encoder.encode( + `${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: complete({}) })}\n`, + ), + ) + controller.close() + }, + }) + return new Response(body, { + headers: { 'Content-Type': 'application/json-seq' }, + }) +} + +async function createTask( + tool: ToolEntry, + args: Record, + name: string, + version: string, + options: handle2026Http.Options, + taskOptions: TaskOptions, +) { + const taskId = crypto.randomUUID() + const ttlMs = taskOptions.ttlMs ?? 300000 + const now = new Date().toISOString() + const task: TaskState = { + id: taskId, + status: 'working', + createdAt: now, + lastUpdatedAt: now, + ttlMs, + pollIntervalMs: taskOptions.pollIntervalMs ?? 5000, + expiresAt: Date.now() + ttlMs, + inputRequests: {}, + waiters: new Map(), + } + tasks.set(taskId, task) + void (async () => { + try { + task.result = await callTool(tool, args, { + elicitation: createTaskElicitationAdapter(task), + env: options.env, + middlewares: options.middlewares, + name, + vars: options.vars, + version, + }) + task.status = 'completed' + task.inputRequests = {} + touchTask(task) + } catch (error) { + task.status = 'failed' + task.error = { + code: -32603, + message: error instanceof Error ? error.message : String(error), + } + touchTask(task) + } + })() + return { resultType: 'task', ...taskResult(task) } +} + +function getTask(message: JsonRpcRequest) { + const task = taskFrom(message) + return complete(taskResult(task)) +} + +function updateTask(message: JsonRpcRequest) { + const task = taskFrom(message) + const inputResponses = objectParams(message).inputResponses + if (isObject(inputResponses)) + for (const [key, value] of Object.entries(inputResponses)) { + const waiter = task.waiters.get(key) + if (!waiter || !isObject(value)) continue + task.waiters.delete(key) + delete task.inputRequests[key] + waiter( + value as { action: Elicitation.Action; content?: Record }, + ) + } + if (Object.keys(task.inputRequests).length === 0 && task.status === 'input_required') { + task.status = 'working' + touchTask(task) + } + return complete({}) +} + +function cancelTask(message: JsonRpcRequest) { + const task = taskFrom(message) + task.status = 'cancelled' + touchTask(task) + return complete({}) +} + +function taskFrom(message: JsonRpcRequest) { + pruneTasks() + const taskId = objectParams(message).taskId + if (typeof taskId !== 'string') throw new JsonRpcError(-32602, 'taskId is required.') + const task = tasks.get(taskId) + if (!task) throw new JsonRpcError(-32602, 'Task not found.', 400, { taskId }) + return task +} + +function taskResult(task: TaskState) { + return { + taskId: task.id, + status: task.status, + createdAt: task.createdAt, + lastUpdatedAt: task.lastUpdatedAt, + ttlMs: task.ttlMs, + pollIntervalMs: task.pollIntervalMs, + ...(task.status === 'input_required' ? { inputRequests: task.inputRequests } : undefined), + ...(task.result ? { result: task.result } : undefined), + ...(task.error ? { error: task.error } : undefined), + } +} + +function createTaskElicitationAdapter(task: TaskState): Elicitation.Adapter { + let i = 0 + function wait(key: string, params: Elicitation.FormRequestParams | Elicitation.UrlRequestParams) { + task.status = 'input_required' + task.inputRequests[key] = { method: 'elicitation/create', params } + touchTask(task) + return new Promise<{ + action: Elicitation.Action + content?: Record | undefined + }>((resolve) => { + task.waiters.set(key, resolve) + }) + } + return { + form(params, options) { + return wait(options?.key ?? `input_${++i}`, params) + }, + requireUrl(params, options) { + throw new InputRequiredError( + { [options?.key ?? `input_${++i}`]: { method: 'elicitation/create', params } }, + encodeState({ taskId: task.id }), + ) + }, + url(params, options) { + return wait(options?.key ?? `input_${++i}`, params) + }, + } +} + +function touchTask(task: TaskState) { + task.lastUpdatedAt = new Date().toISOString() +} + +function pruneTasks() { + const now = Date.now() + for (const [id, task] of tasks) if (task.expiresAt < now) tasks.delete(id) +} + +function hasCompletions(options: handle2026Http.Options) { + return ( + (options.prompts ?? []).some((p) => p.complete && Object.keys(p.complete).length > 0) || + (options.resourceTemplates ?? []).some((t) => t.complete && Object.keys(t.complete).length > 0) + ) +} + +function hasTaskTools(commands: Map) { + return collectTools(commands, []).some((tool) => + Boolean((tool.command.mcpTool as ToolMetadata | undefined)?.task), + ) +} + +function withCache(fields: Record, cache: CacheOptions | undefined) { + return complete({ ...fields, ...(cache ?? defaultCache) }) +} + +function complete(fields: Record) { + return { resultType: 'complete', ...fields } +} + +function objectParams(message: JsonRpcRequest) { + return isObject(message.params) ? message.params : {} +} + +function protocolVersionFrom(req: Request, message: JsonRpcRequest) { + return ( + req.headers.get('MCP-Protocol-Version') ?? + req.headers.get('mcp-protocol-version') ?? + String(metaFrom(message)?.['io.modelcontextprotocol/protocolVersion'] ?? '') + ) +} + +function metaFrom(message: JsonRpcRequest) { + return isObject(message.params) && isObject(message.params._meta) + ? message.params._meta + : undefined +} + +function toolName(params: unknown) { + return isObject(params) && typeof params.name === 'string' ? params.name : '' +} + +function isTaskMethod(method: string) { + return method === 'tasks/get' || method === 'tasks/update' || method === 'tasks/cancel' +} + +function taskIdFrom(params: unknown) { + return isObject(params) && typeof params.taskId === 'string' ? params.taskId : '' +} + +function bearerToken(req: Request) { + const value = req.headers.get('Authorization') ?? req.headers.get('authorization') + if (!value?.startsWith('Bearer ')) return undefined + return value.slice('Bearer '.length) +} + +function hasClientExtension(message: JsonRpcRequest, extensionId: string) { + const capabilities = metaFrom(message)?.['io.modelcontextprotocol/clientCapabilities'] + if (!isObject(capabilities) || !isObject(capabilities.extensions)) return false + return isObject(capabilities.extensions[extensionId]) +} + +function missingRequiredClientCapability(extensionId: string) { + return new JsonRpcError(-32003, 'Missing required client capability', 400, { + requiredCapabilities: { extensions: { [extensionId]: {} } }, + }) +} + +function encodeState(value: Record) { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url') +} + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function error( + id: JsonRpcRequest['id'] | null | undefined, + code: number, + message: string, + data?: unknown, +) { + return { + jsonrpc: '2.0', + id: id ?? null, + error: { code, message, ...(data ? { data } : undefined) }, + } +} + +function isInputRequiredError(error: unknown) { + return error instanceof InputRequiredError +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +class JsonRpcError extends Error { + code: number + data?: unknown | undefined + status: number + + constructor(code: number, message: string, status = 400, data?: unknown | undefined) { + super(message) + this.code = code + this.status = status + if (data !== undefined) this.data = data + } +} + +class InputRequiredError extends Error { + inputRequests: Record< + string, + { method: string; params: Elicitation.FormRequestParams | Elicitation.UrlRequestParams } + > + requestState: string + + constructor( + inputRequests: Record< + string, + { method: string; params: Elicitation.FormRequestParams | Elicitation.UrlRequestParams } + >, + requestState: string, + ) { + super('Input required') + this.inputRequests = inputRequests + this.requestState = requestState + } +} + +type JsonRpcRequest = { + jsonrpc: '2.0' + id?: string | number | undefined + method: string + params?: Record | undefined +} + +type TaskState = { + id: string + status: 'working' | 'input_required' | 'completed' | 'failed' | 'cancelled' + createdAt: string + lastUpdatedAt: string + ttlMs: number | null + pollIntervalMs: number + expiresAt: number + inputRequests: Record< + string, + { + method: 'elicitation/create' + params: Elicitation.FormRequestParams | Elicitation.UrlRequestParams + } + > + waiters: Map< + string, + (result: { + action: Elicitation.Action + content?: Record | undefined + }) => void + > + result?: unknown | undefined + error?: { code: number; message: string } | undefined +} + +const defaultCache: CacheOptions = { ttlMs: 300000, cacheScope: 'public' } +const tasks = new Map() + +function createElicitationAdapter( + extra: Extra | undefined, + clientCapabilities: ClientCapabilities | undefined, +): Elicitation.Adapter | undefined { + const elicitInput = extra?.mcpReq?.elicitInput + if (!elicitInput) return undefined + return { + form(params) { + return elicitInput(params) as Promise + }, + requireUrl(params) { + if (!clientCapabilities?.elicitation?.url) + throw new Error('Client does not support url elicitation.') + throw new UrlElicitationRequiredError([params]) + }, + url(params) { + return elicitInput(params) as Promise + }, + } +} + +function isUrlElicitationRequiredError(error: unknown) { + return ( + error instanceof UrlElicitationRequiredError || (error as { code?: unknown })?.code === -32042 + ) +} + /** @internal A progress notification sent during streaming tool calls. */ type ProgressNotification = { method: 'notifications/progress' params: { progressToken: string | number; progress: number; message: string } } +/** @internal MCP SDK callback context fields used by incur. */ +type Extra = { + mcpReq?: + | { + _meta?: { progressToken?: string | number } | undefined + elicitInput?: ((params: unknown) => Promise) | undefined + } + | undefined +} + +/** @internal Client capability subset used by elicitation. */ +type ClientCapabilities = { + elicitation?: + | { + form?: object | undefined + url?: object | undefined + } + | undefined +} + /** @internal A resolved tool entry from the command tree. */ export type ToolEntry = { name: string @@ -165,6 +1414,16 @@ export type ToolEntry = { middlewares?: MiddlewareHandler[] | undefined } +export declare namespace callTool { + /** Options passed through from MCP tool callbacks. */ + type Options = { + /** MCP client capability subset. */ + clientCapabilities?: ClientCapabilities | undefined + /** MCP SDK callback context. */ + extra?: Extra | undefined + } +} + /** @internal Recursively collects leaf commands as tool entries. */ export function collectTools( commands: Map, diff --git a/src/index.ts b/src/index.ts index c622838..59de4c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { z } from 'zod' export * as Cli from './Cli.js' export * as Completions from './Completions.js' +export * as Elicitation from './Elicitation.js' export { default as middleware } from './middleware.js' export type { Handler as MiddlewareHandler, Context as MiddlewareContext } from './middleware.js' export * as Errors from './Errors.js' diff --git a/src/internal/command.ts b/src/internal/command.ts index 3dc0c6f..af4b30d 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -1,5 +1,6 @@ import { z } from 'zod' +import * as Elicitation from '../Elicitation.js' import type { FieldError } from '../Errors.js' import { IncurError, ValidationError } from '../Errors.js' import type { Context as MiddlewareContext, Handler as MiddlewareHandler } from '../middleware.js' @@ -109,6 +110,7 @@ export async function execute(command: any, options: execute.Options): Promise | undefined + /** MCP elicitation adapter. */ + elicitation?: Elicitation.Adapter | undefined /** Source for environment variables. Defaults to `process.env`. */ envSource?: Record | undefined /** The resolved output format. */ @@ -302,6 +307,8 @@ export declare namespace execute { path: string /** Vars schema for middleware variables. */ vars?: z.ZodObject | undefined + /** Returns true when an error should propagate instead of becoming an incur result. */ + rethrowErrors?: ((error: unknown) => boolean) | undefined /** CLI version string. */ version: string | undefined }