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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
},
"dependencies": {
"@contextvm/mcp-sdk": "^1.30.0",
"@contextvm/sdk": "^0.13.6",
"@contextvm/sdk": "^0.13.7",
"@sjsf/ajv8-validator": "^3.3.2",
"@sjsf/form": "^3.3.2",
"@sjsf/shadcn4-theme": "^3.3.2",
Expand Down
19 changes: 19 additions & 0 deletions src/lib/components/PromptGetForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
import { activeAccount } from '$lib/services/accountManager.svelte';
import * as Alert from '$lib/components/ui/alert/index.js';
import PaymentStatusPanel from '$lib/components/PaymentStatusPanel.svelte';
import PaymentErrorCard from '$lib/components/chat/PaymentErrorCard.svelte';
import {
extractExplicitGatingError,
type ExplicitGatingError
} from '$lib/services/payments/payment-errors';
import { paymentNotificationsService } from '$lib/services/payments/payment-notifications.svelte';
import {
findCapTagForPrompt,
Expand All @@ -43,6 +48,7 @@
// Form state
let formResult = $state<GetPromptResult | null>(null);
let formError = $state<string | null>(null);
let gatingError = $state<ExplicitGatingError | null>(null);
let showResult = $state(false);

// Collapsible state
Expand Down Expand Up @@ -98,6 +104,7 @@
// Ensure payment panel is visible for subsequent calls.
paymentOpen = true;
paymentNotificationsService.clearServer(serverPubkey);
gatingError = null;
open = true;
try {
if (!connectionState.connected) {
Expand All @@ -111,6 +118,12 @@
formResult = result;
showResult = true;
} catch (error) {
const explicitGatingError = extractExplicitGatingError(error);
if (explicitGatingError) {
gatingError = explicitGatingError;
return;
}

formError = error instanceof Error ? error.message : 'Failed to get prompt';
} finally {
loading = false;
Expand All @@ -123,6 +136,7 @@
form.reset(new Event('reset'));
formResult = null;
formError = null;
gatingError = null;
showResult = false;
paymentNotificationsService.clearServer(serverPubkey);
paymentOpen = true;
Expand Down Expand Up @@ -160,6 +174,11 @@
class="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 overflow-hidden"
>
<div class="border-t bg-muted/50 p-4">
{#if gatingError}
<div class="mb-4">
<PaymentErrorCard error={gatingError} />
</div>
{/if}
{#if paymentState}
<div class="mb-4">
<PaymentStatusPanel payment={paymentState} open={paymentOpen} />
Expand Down
19 changes: 19 additions & 0 deletions src/lib/components/ResourceReadForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
import { activeAccount } from '$lib/services/accountManager.svelte';
import * as Alert from '$lib/components/ui/alert/index.js';
import PaymentStatusPanel from '$lib/components/PaymentStatusPanel.svelte';
import PaymentErrorCard from '$lib/components/chat/PaymentErrorCard.svelte';
import {
extractExplicitGatingError,
type ExplicitGatingError
} from '$lib/services/payments/payment-errors';
import { paymentNotificationsService } from '$lib/services/payments/payment-notifications.svelte';
import {
findCapTagForResource,
Expand All @@ -36,6 +41,7 @@
// Form state
let formResult = $state<ReadResourceResult | null>(null);
let formError = $state<string | null>(null);
let gatingError = $state<ExplicitGatingError | null>(null);
let showResult = $state(false);

// Collapsible state
Expand Down Expand Up @@ -80,6 +86,7 @@
// Ensure payment panel is visible for subsequent reads.
paymentOpen = true;
paymentNotificationsService.clearServer(serverPubkey);
gatingError = null;
open = true;
try {
if (!connectionState.connected) {
Expand All @@ -93,6 +100,12 @@
formResult = result;
showResult = true;
} catch (error) {
const explicitGatingError = extractExplicitGatingError(error);
if (explicitGatingError) {
gatingError = explicitGatingError;
return;
}

formError = error instanceof Error ? error.message : 'Failed to read resource';
} finally {
loading = false;
Expand All @@ -106,6 +119,7 @@
form.reset(new Event('reset'));
formResult = null;
formError = null;
gatingError = null;
showResult = false;
paymentNotificationsService.clearServer(serverPubkey);
paymentOpen = true;
Expand Down Expand Up @@ -140,6 +154,11 @@
class="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 overflow-hidden"
>
<div class="border-t bg-muted/50 p-4">
{#if gatingError}
<div class="mb-4">
<PaymentErrorCard error={gatingError} />
</div>
{/if}
{#if paymentState}
<div class="mb-4">
<PaymentStatusPanel payment={paymentState} open={paymentOpen} />
Expand Down
21 changes: 21 additions & 0 deletions src/lib/components/ToolCallForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
import { activeAccount } from '$lib/services/accountManager.svelte';
import * as Alert from '$lib/components/ui/alert/index.js';
import PaymentStatusPanel from '$lib/components/PaymentStatusPanel.svelte';
import PaymentErrorCard from '$lib/components/chat/PaymentErrorCard.svelte';
import {
extractExplicitGatingError,
type ExplicitGatingError
} from '$lib/services/payments/payment-errors';
import { paymentNotificationsService } from '$lib/services/payments/payment-notifications.svelte';
import {
findCapTagForTool,
Expand All @@ -36,6 +41,7 @@
// Form state
let formResult = $state<CallToolResult | null>(null);
let formError = $state<string | null>(null);
let gatingError = $state<ExplicitGatingError | null>(null);
let showResult = $state(false);
let paymentState = $derived.by(() =>
paymentNotificationsService.getLatestForServer(serverPubkey)
Expand Down Expand Up @@ -73,6 +79,7 @@
// Ensure the payment UI is visible for subsequent calls.
paymentOpen = true;
paymentNotificationsService.clearServer(serverPubkey);
gatingError = null;
try {
if (!connectionState.connected) {
await mcpClientService.getClient(serverPubkey);
Expand All @@ -92,6 +99,14 @@
showResult = true;
loading = false;
} catch (error) {
const explicitGatingError = extractExplicitGatingError(error);
if (explicitGatingError) {
gatingError = explicitGatingError;
open = true;
loading = false;
return;
}

formError = error instanceof Error ? error.message : 'Failed to call tool';
loading = false;
}
Expand All @@ -104,6 +119,7 @@
form.reset(new Event('reset'));
formResult = null;
formError = null;
gatingError = null;
showResult = false;
paymentNotificationsService.clearServer(serverPubkey);
// Keep payment panel visible for the next invocation.
Expand Down Expand Up @@ -165,6 +181,11 @@
class="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 overflow-hidden"
>
<div class="border-t bg-muted/50 p-4">
{#if gatingError}
<div class="mb-4">
<PaymentErrorCard error={gatingError} />
</div>
{/if}
{#if paymentState}
<div class="mb-4">
<PaymentStatusPanel payment={paymentState} open={paymentOpen} />
Expand Down
113 changes: 113 additions & 0 deletions src/lib/components/chat/PaymentErrorCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<script lang="ts">
import QrCode from '$lib/components/QrCode.svelte';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import {
PAYMENT_PENDING_ERROR_CODE,
PAYMENT_REQUIRED_ERROR_CODE,
isPaymentRequiredError,
type ExplicitGatingError
} from '$lib/services/payments/payment-errors';
import { copyToClipboard } from '$lib/utils';
import CopyIcon from '@lucide/svelte/icons/copy';

let { error }: { error: ExplicitGatingError } = $props();

const paymentOptions = $derived.by(() => {
if (!isPaymentRequiredError(error) || !Array.isArray(error.data.payment_options)) {
return [];
}

return error.data.payment_options.filter(
(option): option is Record<string, unknown> => typeof option === 'object' && option !== null
);
});
const instructions = $derived(
typeof error.data.instructions === 'string' ? error.data.instructions : null
);
const retryAfter = $derived(
typeof error.data.retry_after === 'number' ? error.data.retry_after : null
);
const title = $derived(
error.code === PAYMENT_REQUIRED_ERROR_CODE ? 'Payment required' : 'Payment pending'
);
</script>

<div
class="rounded-lg border border-amber-500/30 bg-amber-50/70 p-3 text-amber-950 shadow-sm dark:bg-amber-950/20 dark:text-amber-50"
>
<div class="mb-2 flex flex-wrap items-center gap-2">
<span
class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-800 dark:text-amber-200"
>
{title}
</span>
<span class="font-mono text-[10px] text-amber-800/70 dark:text-amber-200/70">
{error.code === PAYMENT_REQUIRED_ERROR_CODE
? PAYMENT_REQUIRED_ERROR_CODE
: PAYMENT_PENDING_ERROR_CODE}
</span>
{#if retryAfter !== null}
<span class="font-mono text-[10px] text-amber-800/70 dark:text-amber-200/70">
Retry after {retryAfter}s
</span>
{/if}
</div>

{#if instructions}
<p class="mb-2 text-sm leading-5">{instructions}</p>
{:else if error.message}
<p class="mb-2 text-sm leading-5">{error.message}</p>
{/if}

{#if paymentOptions.length > 0}
<div class="rounded-md bg-background/70 px-3 py-2 text-foreground">
{#each paymentOptions as option, i (i)}
{@const amount = option.amount}
{@const pmi = typeof option.pmi === 'string' ? option.pmi : null}
{@const payReq = typeof option.pay_req === 'string' ? option.pay_req : null}

<div class={i > 0 ? 'mt-3 border-t border-border/50 pt-3' : ''}>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
{#if amount !== undefined}
<span class="text-xs text-muted-foreground">Amount</span>
<span class="font-mono text-xs">{amount}</span>
{/if}
{#if pmi}
<span class="text-xs text-muted-foreground">PMI</span>
<span class="min-w-0 font-mono text-xs break-all">{pmi}</span>
{/if}
</div>

{#if payReq}
<Tabs.Root value="invoice" class="mt-3 w-full">
<Tabs.List class="grid w-full grid-cols-2">
<Tabs.Trigger value="invoice">Payment Request</Tabs.Trigger>
<Tabs.Trigger value="qr">QR</Tabs.Trigger>
</Tabs.List>

<Tabs.Content value="invoice" class="mt-2">
<div class="relative rounded-md bg-muted p-2">
<pre class="max-h-44 overflow-auto pr-8 text-xs whitespace-pre-wrap">{payReq}</pre>
<button
type="button"
onclick={() => copyToClipboard(payReq)}
class="absolute top-1.5 right-1.5 rounded p-1 text-muted-foreground transition-colors hover:bg-background hover:text-primary"
aria-label="Copy payment request"
>
<CopyIcon class="h-3.5 w-3.5" />
</button>
</div>
</Tabs.Content>

<Tabs.Content value="qr" class="mt-2">
<div class="flex justify-center">
<QrCode data={payReq} size={180} />
</div>
</Tabs.Content>
</Tabs.Root>
{/if}
</div>
{/each}
</div>
{/if}
</div>
Loading