-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproxy.ts
More file actions
150 lines (137 loc) · 6.92 KB
/
Copy pathproxy.ts
File metadata and controls
150 lines (137 loc) · 6.92 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
import { AsyncLocalStorage } from "node:async_hooks";
import type { NextRequest } from "next/server";
import { paymentProxy, x402ResourceServer } from "@x402/next";
import { HTTPFacilitatorClient } from "@x402/core/server";
import { ExactEvmScheme } from "@x402/evm/exact/server";
import { facilitator as cdpFacilitator } from "@coinbase/x402";
import {
declareDiscoveryExtension,
bazaarResourceServerExtension,
} from "@x402/extensions/bazaar";
import { reportUsage } from "yeetful/server";
import { config as appConfig, priceString } from "@/lib/config";
/**
* x402 **v2** payment gating for the MCP endpoint (migrated off x402-next v1,
* which only spoke v2's predecessor and failed agentic.market / Bazaar
* discovery validation).
*
* v2 emits: x402Version 2, a top-level `resource` object, `amount` (not
* `maxAmountRequired`), the challenge in a base64 `PAYMENT-REQUIRED` response
* header, and — because the route declares `extensions.bazaar` — a top-level
* `extensions.bazaar` discovery block.
*
* Bazaar only indexes services settled through the CDP facilitator on Base
* mainnet, so we use `@coinbase/x402`'s `facilitator` (reads CDP_API_KEY_ID /
* CDP_API_KEY_SECRET) when configured, and fall back to the public x402.org
* facilitator (testnet, not indexed) for local dev.
*/
const description =
"Yeetful — Anthropic Claude Haiku 4.5 inference over MCP Streamable HTTP, hosted at anthropic.yeetful.com. Exposes ask_claude (single-prompt completion) and claude_chat (multi-turn) tools, capped at 256 output tokens per call. Pay-per-call in USDC on Base. Operated by yeetful.com. Keywords: yeetful, anthropic, claude, haiku, mcp, x402, inference, llm.";
// Bazaar discovery block — built with the SDK's canonical MCP shape
// (declareDiscoveryExtension → { bazaar: { info: { input: { type: "mcp",
// toolName, inputSchema, … } }, schema } }).
//
// The previous hand-rolled version used the HTTP/body discovery shape
// (type:"http", method, body) for an MCP resource: the Bazaar indexer didn't
// recognize it, rendered the extension as `{}`, and flagged
// "INPUT SCHEMA PRESENT: no". `inputSchema` here is the JSON Schema of the
// TOOL'S arguments — exactly the field the validator checks. One tool per
// discovery block; ask_claude is the primary surface (claude_chat is
// described in `description`). Model + max_tokens stay server-controlled.
// Returns the already-keyed record: { bazaar: { info, schema } }.
const discovery = declareDiscoveryExtension({
toolName: "ask_claude",
description:
"Single-prompt Claude Haiku 4.5 completion (≤256 output tokens). A multi-turn claude_chat tool is also exposed on the same endpoint. Model and max_tokens are server-controlled.",
transport: "streamable-http",
inputSchema: {
type: "object",
properties: {
prompt: { type: "string", description: "The user prompt to complete." },
system: { type: "string", description: "Optional system prompt." },
},
required: ["prompt"],
additionalProperties: false,
},
example: { prompt: "What is the capital of France?" },
output: {
example: {
jsonrpc: "2.0",
id: 1,
result: { content: [{ type: "text", text: "The capital of France is Paris." }] },
},
},
});
const routes = {
// Named param (matches the Next [transport] segment) → cleaner Bazaar
// discovery metadata than a bare wildcard.
"/api/mcp/:transport": {
accepts: {
scheme: "exact",
price: priceString(),
network: appConfig.network,
payTo: appConfig.paymentAddress,
maxTimeoutSeconds: 60,
},
description,
mimeType: "application/json",
extensions: discovery,
},
};
const cdpReady = !!appConfig.cdpApiKeyId && !!appConfig.cdpApiKeySecret;
const facilitatorClient = new HTTPFacilitatorClient(
cdpReady ? cdpFacilitator : { url: "https://x402.org/facilitator" },
);
const server = new x402ResourceServer(facilitatorClient)
.register(appConfig.network, new ExactEvmScheme())
// Processes + validates the bazaar discovery payload on the way out — without
// this the extension may be emitted unrecognized (or stripped to `{}`).
.registerExtension(bazaarResourceServerExtension);
/* ───────────────────────── Yeetful earn-tracking ─────────────────────────
* Report every settled payment to your Yeetful dashboard so earnings show up.
* Active only when both YEETFUL_API_KEY and YEETFUL_MCP_SLUG are set.
*
* CORE: one `onAfterSettle` hook + one fire-and-forget `reportUsage(...)`.
* `reportUsage` never throws, self-times-out, and is never awaited — so
* telemetry can't slow, block, or break a settlement.
* ───────────────────────────────────────────────────────────────────────── */
const earnTrackingEnabled = !!appConfig.yeetfulApiKey && !!appConfig.yeetfulMcpSlug;
if (earnTrackingEnabled) {
server.onAfterSettle(async ({ result }) => {
reportUsage({
apiKey: appConfig.yeetfulApiKey!,
mcp: appConfig.yeetfulMcpSlug!,
amountUsd: Number(appConfig.priceUsd), // your list price, in USD
payer: result.payer, // paying agent's wallet
txHash: result.transaction,
network: appConfig.networkName,
tool: await currentTool(), // optional — see below; resolves instantly
});
});
}
/* OPTIONAL: per-tool breakdown ──────────────────────────────────────────────
* The MCP tool name (`params.name`) is in the JSON-RPC body, which the settle
* hook doesn't receive — and the handler consumes the original body, so we
* must NOT read it. Instead we parse a CLONE at proxy entry and pass the
* already-pending promise through AsyncLocalStorage to the hook. Drop this
* whole block (and the `tool` line above) if you don't want per-tool data. */
const toolNameStore = new AsyncLocalStorage<Promise<string | undefined>>();
const currentTool = () => toolNameStore.getStore() ?? Promise.resolve(undefined);
async function extractTool(req: NextRequest): Promise<string | undefined> {
try {
const body = (await req.clone().json()) as { params?: { name?: unknown } };
return typeof body?.params?.name === "string" ? body.params.name : undefined;
} catch {
return undefined; // no/invalid JSON body (e.g. the 402 probe) — fine.
}
}
// syncFacilitatorOnStart=true (default): v2 fetches the facilitator's supported
// kinds via initialize() before it can emit the challenge for exact/<network>.
const paymentGate = paymentProxy(routes, server);
export const proxy = earnTrackingEnabled
? (req: NextRequest) => toolNameStore.run(extractTool(req), () => paymentGate(req))
: paymentGate;
// Gate only the MCP transport routes; homepage and /api/info stay free.
export const config = {
matcher: ["/api/mcp/:path*"],
};