Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-2026-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'incur': minor
---

Add stateless MCP 2026 support, MCP resources/prompts/apps, task-backed tools, authorization extensions, and MCP elicitation helpers.
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<main>panel</main>' })
```

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
Expand Down
68 changes: 68 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<main>panel</main>' })
```

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.
Expand Down
72 changes: 71 additions & 1 deletion src/Cli.test-d.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<undefined>()
return {}
},
})
})

test('elicitation result types distinguish form and URL content', () => {
type Form = Elicitation.FormResult<z.ZodObject<{ name: z.ZodString }>>
expectTypeOf<Form>().toMatchTypeOf<
| { action: 'accept'; content: { name: string } }
| { action: 'decline' | 'cancel'; content?: undefined }
>()
expectTypeOf<Elicitation.UrlResult>().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: '<main />' })
})

test('command() accumulates command types through chaining', () => {
const cli = Cli.create('test')
.command('get', {
Expand Down
Loading
Loading