From f63d32c3ddda3a3140e546926474e06cc150578a Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:45:15 +1000 Subject: [PATCH] feat(mcp): render cta suggestions in tool result and error text --- .changeset/mcp-cta-delivery.md | 5 ++++ src/Mcp.test.ts | 43 ++++++++++++++++++++++++++++++++-- src/Mcp.ts | 23 +++++++++--------- src/internal/cta.ts | 8 +++++++ 4 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 .changeset/mcp-cta-delivery.md diff --git a/.changeset/mcp-cta-delivery.md b/.changeset/mcp-cta-delivery.md new file mode 100644 index 00000000..71a95d35 --- /dev/null +++ b/.changeset/mcp-cta-delivery.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Delivered CTA suggestions in MCP tool result and error text, keeping `_meta.cta` for structured consumers. diff --git a/src/Mcp.test.ts b/src/Mcp.test.ts index d81d6b91..fcf3f246 100644 --- a/src/Mcp.test.ts +++ b/src/Mcp.test.ts @@ -510,7 +510,7 @@ describe('Mcp', () => { expect(callRes.result.structuredContent).toBeUndefined() }) - test('tools/call surfaces cta metadata without changing structured content', async () => { + test('tools/call appends cta suggestions to result text', async () => { const commands = new Map() commands.set('show', { description: 'Show a record', @@ -533,7 +533,12 @@ describe('Mcp', () => { { id: 2, method: 'tools/call', params: { name: 'show', arguments: {} } }, ]) - expect(res.result.content).toEqual([{ type: 'text', text: '{"id":"foo"}' }]) + expect(res.result.content[0].text).toMatchInlineSnapshot(` + "{"id":"foo"} + + Next: + test-cli list - List all" + `) expect(res.result.structuredContent).toEqual({ id: 'foo' }) expect(res.result._meta?.cta).toEqual({ description: 'Next:', @@ -541,6 +546,40 @@ describe('Mcp', () => { }) }) + test('tools/call appends cta suggestions to error text', async () => { + const commands = new Map() + commands.set('deploy', { + description: 'Deploy a thing', + run(c: any) { + return c.error({ + code: 'NOT_AUTHENTICATED', + message: 'not signed in', + cta: { + description: 'Next:', + commands: [{ command: 'login', description: 'Sign in' }], + }, + }) + }, + }) + + const [, res] = await mcpSession(commands, [ + { id: 1, method: 'initialize', params: initParams }, + { id: 2, method: 'tools/call', params: { name: 'deploy', arguments: {} } }, + ]) + + expect(res.result.isError).toBe(true) + expect(res.result.content[0].text).toMatchInlineSnapshot(` + "not signed in + + Next: + test-cli login - Sign in" + `) + expect(res.result._meta?.cta).toEqual({ + description: 'Next:', + commands: [{ command: 'test-cli login', description: 'Sign in' }], + }) + }) + test('callTool serializes bigint values as strings', async () => { const result = await Mcp.callTool( { diff --git a/src/Mcp.ts b/src/Mcp.ts index 875024a5..95a5e760 100644 --- a/src/Mcp.ts +++ b/src/Mcp.ts @@ -3,7 +3,7 @@ import type { Readable, Writable } from 'node:stream' import { z } from 'zod' import * as Command from './internal/command.js' -import { formatCtaBlock, type FormattedCtaBlock } from './internal/cta.js' +import { formatCtaBlock, type FormattedCtaBlock, renderCtaText } from './internal/cta.js' import * as Json from './internal/json.js' import type { Handler as MiddlewareHandler } from './middleware.js' import * as Schema from './Schema.js' @@ -167,24 +167,25 @@ export async function callTool( return { content: [{ type: 'text', text: Json.stringify(chunks) }] } } - if (!result.ok) + if (!result.ok) { + const cta = formatCtaBlock(options.name ?? tool.name, result.cta) + const text = result.error.fieldErrors + ? JSON.stringify(result.error) + : (result.error.message ?? 'Command failed') return { - content: [ - { - type: 'text', - text: result.error.fieldErrors - ? JSON.stringify(result.error) - : (result.error.message ?? 'Command failed'), - }, - ], + content: [{ type: 'text', text: cta ? `${text}\n\n${renderCtaText(cta)}` : text }], + ...(cta ? { _meta: { cta } } : undefined), isError: true, } + } const data = result.data ?? null const jsonData = Json.normalize(data) const cta = formatCtaBlock(options.name ?? tool.name, result.cta as Command.CtaBlock | undefined) + const text = Json.stringify(jsonData) return { - content: [{ type: 'text', text: Json.stringify(jsonData) }], + // Append rendered suggestions to the text so models see them (most clients drop _meta). + content: [{ type: 'text', text: cta ? `${text}\n\n${renderCtaText(cta)}` : text }], ...(data !== null && tool.outputSchema ? { structuredContent: jsonData as Record } : undefined), diff --git a/src/internal/cta.ts b/src/internal/cta.ts index 73d2be50..4c65faa0 100644 --- a/src/internal/cta.ts +++ b/src/internal/cta.ts @@ -43,6 +43,14 @@ export function formatCtaBlock( } } +/** @internal Renders a formatted CTA block as plain text for inline tool output. */ +export function renderCtaText(block: FormattedCtaBlock): string { + const lines = [block.description] + for (const c of block.commands) + lines.push(` ${c.command}${c.description ? ` - ${c.description}` : ''}`) + return lines.join('\n') +} + /** @internal Formats a CTA by prefixing the CLI name. */ function formatCta(name: string, cta: Cta): FormattedCta { if (typeof cta === 'string') return { command: `${name} ${cta}` }