From 26e04dc4ea05d298033195b5f7b5531dd113d160 Mon Sep 17 00:00:00 2001
From: douglance <4741454+douglance@users.noreply.github.com>
Date: Fri, 5 Jun 2026 14:52:29 -0400
Subject: [PATCH] feat: add mcp 2026 support
---
.changeset/mcp-2026-support.md | 5 +
README.md | 68 ++
SKILL.md | 68 ++
src/Cli.test-d.ts | 72 +-
src/Cli.ts | 128 ++++
src/Elicitation.ts | 224 ++++++
src/Mcp.test.ts | 776 ++++++++++++++++++-
src/Mcp.ts | 1273 +++++++++++++++++++++++++++++++-
src/index.ts | 1 +
src/internal/command.ts | 7 +
10 files changed, 2613 insertions(+), 9 deletions(-)
create mode 100644 .changeset/mcp-2026-support.md
create mode 100644 src/Elicitation.ts
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