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-client-approval-callbacks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added MCP client payment approval callbacks before credential creation.
7 changes: 7 additions & 0 deletions src/mcp-sdk/client/McpClient.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,15 @@ describe('McpClient.wrap', () => {
})

expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' })
expectTypeOf(wrapped.callTool).toBeCallableWith(null, { name: 'tool' })
expectTypeOf(wrapped.callTool).toBeCallableWith(() => true, { name: 'tool' })
expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }, {})
expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }, { timeout: 5000 })
expectTypeOf(wrapped.callTool).toBeCallableWith(
async (challenge) => challenge.intent === 'charge',
{ name: 'tool' },
{ timeout: 5000 },
)
})

test('callTool result includes receipt', () => {
Expand Down
166 changes: 99 additions & 67 deletions src/mcp-sdk/client/McpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import type * as z from '../../zod.js'

type AnyClient = Method.Client<any, any>

export type CallToolParameters = {
name: string
arguments?: Record<string, unknown>
_meta?: Record<string, unknown>
}

export type OnPaymentRequired = (challenge: Challenge.Challenge) => boolean | Promise<boolean>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


/**
* Result of a tool call with payment handling.
* Extends the SDK's callTool return type with an optional payment receipt.
Expand Down Expand Up @@ -55,69 +63,91 @@ export function wrap<
const { methods } = config
const paymentPreferences = AcceptPayment.resolve(methods)

return {
...client,
async callTool(params, options) {
const context = options?.context
const timeout = options?.timeout

try {
const result = await client.callTool(
params,
undefined,
timeout !== undefined ? { timeout } : undefined,
const callTool = (async (
first: CallToolParameters | OnPaymentRequired | null | undefined,
second?: CallToolParameters | wrap.CallToolOptions<methods>,
third?: wrap.CallToolOptions<methods>,
) => {
const hasApprovalArgument = typeof first === 'function' || first === null || first === undefined
const params = (hasApprovalArgument ? second : first) as CallToolParameters
const options = (hasApprovalArgument ? third : second) as
| wrap.CallToolOptions<methods>
| undefined
const onPaymentRequired =
first === null
? undefined
: hasApprovalArgument
? ((first as OnPaymentRequired | undefined) ?? config.onPaymentRequired)
: config.onPaymentRequired
const context = options?.context
const timeout = options?.timeout

try {
const result = await client.callTool(
params,
undefined,
timeout !== undefined ? { timeout } : undefined,
)

return {
...result,
receipt: result._meta?.[core_Mcp.receiptMetaKey] as core_Mcp.Receipt | undefined,
}
} catch (error) {
// Check if this is a payment required error
if (!isPaymentRequiredError(error)) throw error

const challenges = (error.data as { challenges?: Challenge.Challenge[] })?.challenges
if (!challenges?.length) throw error

const selected = AcceptPayment.selectChallenge(
challenges,
methods,
paymentPreferences.entries,
)
if (!selected) {
const available = challenges.map((c) => `${c.method}.${c.intent}`).join(', ')
const installed = methods.map((m) => `${m.name}.${m.intent}`).join(', ')
throw new Error(
`No compatible payment method. Server offers: ${available}. Client has: ${installed}`,
{ cause: error },
)
}

return {
...result,
receipt: result._meta?.[core_Mcp.receiptMetaKey] as core_Mcp.Receipt | undefined,
}
} catch (error) {
// Check if this is a payment required error
if (!isPaymentRequiredError(error)) throw error

const challenges = (error.data as { challenges?: Challenge.Challenge[] })?.challenges
if (!challenges?.length) throw error

const selected = AcceptPayment.selectChallenge(
challenges,
methods,
paymentPreferences.entries,
)
if (!selected) {
const available = challenges.map((c) => `${c.method}.${c.intent}`).join(', ')
const installed = methods.map((m) => `${m.name}.${m.intent}`).join(', ')
throw new Error(
`No compatible payment method. Server offers: ${available}. Client has: ${installed}`,
{ cause: error },
)
}

const credential = await createCredential(selected.challenge, {
context,
methods,
})
const parsed = Credential.deserialize(credential)

const retryResult = await client.callTool(
{
...params,
_meta: {
...params._meta,
[core_Mcp.credentialMetaKey]: parsed,
},
},
undefined,
timeout !== undefined ? { timeout } : undefined,
)
if (selected.challenge.expires)
Expires.assert(selected.challenge.expires, selected.challenge.id)

return {
...retryResult,
receipt: retryResult._meta?.[core_Mcp.receiptMetaKey] as core_Mcp.Receipt | undefined,
}
if (onPaymentRequired) {
const approved = await onPaymentRequired(selected.challenge)
if (!approved) throw new Error('Payment declined.', { cause: error })
}
},
}

const credential = await createCredential(selected.challenge, {
context,
methods,
})
const parsed = Credential.deserialize(credential)

const retryResult = await client.callTool(
{
...params,
_meta: {
...params._meta,
[core_Mcp.credentialMetaKey]: parsed,
},
},
undefined,
timeout !== undefined ? { timeout } : undefined,
)

return {
...retryResult,
receipt: retryResult._meta?.[core_Mcp.receiptMetaKey] as core_Mcp.Receipt | undefined,
}
}
}) as wrap.McpClient<client, methods>['callTool']

return { ...client, callTool } as wrap.McpClient<client, methods>
}

/** Union of all context types from all methods that have context schemas. */
Expand All @@ -133,21 +163,23 @@ export declare namespace wrap {
type Config<methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[]> = {
/** Array of methods to use. */
methods: methods
/** Optional approval hook called before creating a payment credential. */
onPaymentRequired?: OnPaymentRequired
}

type McpClient<
client extends Pick<Client, 'callTool'> = Pick<Client, 'callTool'>,
methods extends readonly AnyClient[] = readonly AnyClient[],
> = Omit<client, 'callTool'> & {
/** Call a tool with automatic payment handling. */
callTool: (
params: {
name: string
arguments?: Record<string, unknown>
_meta?: Record<string, unknown>
},
options?: CallToolOptions<methods>,
) => Promise<CallToolResult>
callTool: {
(params: CallToolParameters, options?: CallToolOptions<methods>): Promise<CallToolResult>
(
onPaymentRequired: OnPaymentRequired | null | undefined,
params: CallToolParameters,
options?: CallToolOptions<methods>,
): Promise<CallToolResult>
}
}

type CallToolOptions<methods extends readonly AnyClient[] = readonly AnyClient[]> = {
Expand Down
131 changes: 131 additions & 0 deletions src/mcp-sdk/client/McpClient.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { McpError } from '@modelcontextprotocol/sdk/types.js'
import { Challenge, Credential, Mcp as core_Mcp, Method } from 'mppx'
import { Methods } from 'mppx/tempo'
import { describe, expect, test, vi } from 'vp/test'

import * as McpClient from './McpClient.js'

describe('MCP client payment approval', () => {
test('calls an approval hook before creating a credential', async () => {
const challenge = Challenge.from({
id: 'approval-test',
intent: 'charge',
method: 'tempo',
realm: 'api.example.com',
request: {},
})
const calls: unknown[] = []
const client = {
async callTool(params: unknown) {
calls.push(params)
if (calls.length === 1)
throw new McpError(core_Mcp.paymentRequiredCode, 'Payment Required', {
challenges: [challenge],
httpStatus: 402,
})
return {
_meta: {
[core_Mcp.receiptMetaKey]: {
method: 'tempo',
reference: 'test',
status: 'success',
timestamp: new Date().toISOString(),
},
},
content: [{ type: 'text', text: 'ok' }],
}
},
}
const createCredential = vi.fn(async ({ challenge }: { challenge: Challenge.Challenge }) =>
Credential.serialize({
challenge,
payload: { signature: '0xsignature', type: 'transaction' },
}),
)
const onPaymentRequired = vi.fn(() => true)
const mcp = McpClient.wrap(client as unknown as Pick<Client, 'callTool'>, {
methods: [Method.toClient(Methods.charge, { createCredential })],
})

const result = await mcp.callTool(onPaymentRequired, { name: 'paid_tool', arguments: {} })

expect(result.content).toEqual([{ type: 'text', text: 'ok' }])
expect(onPaymentRequired).toHaveBeenCalledWith(challenge)
expect(createCredential).toHaveBeenCalledOnce()
expect(calls).toHaveLength(2)
})

test('does not create a credential when approval is denied', async () => {
const challenge = Challenge.from({
id: 'denied-test',
intent: 'charge',
method: 'tempo',
realm: 'api.example.com',
request: {},
})
const client = {
async callTool() {
throw new McpError(core_Mcp.paymentRequiredCode, 'Payment Required', {
challenges: [challenge],
httpStatus: 402,
})
},
}
const createCredential = vi.fn(async ({ challenge }: { challenge: Challenge.Challenge }) =>
Credential.serialize({
challenge,
payload: { signature: '0xsignature', type: 'transaction' },
}),
)
const mcp = McpClient.wrap(client as unknown as Pick<Client, 'callTool'>, {
methods: [Method.toClient(Methods.charge, { createCredential })],
})

await expect(mcp.callTool(() => false, { name: 'paid_tool' })).rejects.toThrow(
'Payment declined.',
)
expect(createCredential).not.toHaveBeenCalled()
})

test('allows null to bypass a config approval hook', async () => {
const challenge = Challenge.from({
id: 'null-bypass-test',
intent: 'charge',
method: 'tempo',
realm: 'api.example.com',
request: {},
})
let calls = 0
const client = {
async callTool() {
calls += 1
if (calls === 1)
throw new McpError(core_Mcp.paymentRequiredCode, 'Payment Required', {
challenges: [challenge],
httpStatus: 402,
})
return {
content: [{ type: 'text', text: 'ok' }],
}
},
}
const createCredential = vi.fn(async ({ challenge }: { challenge: Challenge.Challenge }) =>
Credential.serialize({
challenge,
payload: { signature: '0xsignature', type: 'transaction' },
}),
)
const onPaymentRequired = vi.fn(() => false)
const mcp = McpClient.wrap(client as unknown as Pick<Client, 'callTool'>, {
methods: [Method.toClient(Methods.charge, { createCredential })],
onPaymentRequired,
})

await expect(mcp.callTool(null, { name: 'paid_tool' })).resolves.toMatchObject({
content: [{ type: 'text', text: 'ok' }],
})
expect(onPaymentRequired).not.toHaveBeenCalled()
expect(createCredential).toHaveBeenCalledOnce()
})
})