Skip to content
Merged
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-cta-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'incur': patch
---

Delivered CTA suggestions in MCP tool result and error text, keeping `_meta.cta` for structured consumers.
43 changes: 41 additions & 2 deletions src/Mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>()
commands.set('show', {
description: 'Show a record',
Expand All @@ -533,14 +533,53 @@ 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:',
commands: [{ command: 'test-cli list', description: 'List all' }],
})
})

test('tools/call appends cta suggestions to error text', async () => {
const commands = new Map<string, any>()
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(
{
Expand Down
23 changes: 12 additions & 11 deletions src/Mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown> }
: undefined),
Expand Down
8 changes: 8 additions & 0 deletions src/internal/cta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` }
Expand Down
Loading