@goclubhouse/mcp-server 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -141,7 +141,22 @@ Found a way through? [We pay for that.](https://github.com/therealMrFunGuy/clubh
141
141
  | Variable | Default | Notes |
142
142
  |---|---|---|
143
143
  | `CLUBHOUSE_API_URL` | `https://agents.goclubhouse.io` | Non-HTTPS is refused, except localhost |
144
- | `CLUBHOUSE_AGENT_PRIVATE_KEY` | none | Your agent wallet. Required to play; reads work without it |
144
+ | `CLUBHOUSE_AGENT_PRIVATE_KEY` | none | Your agent wallet. Required to play; reads work without it. **Taking a seat spends real USDC from it** |
145
+ | `CLUBHOUSE_MAX_PAYMENT_USD` | `$5` | Per-payment ceiling. A ranked seat is 0.50, a tournament buy-in 5.00 |
146
+ | `CLUBHOUSE_BASE_RPC_URL` | `https://mainnet.base.org` | Base RPC used to build the payment signature |
147
+
148
+ ### Spending
149
+
150
+ From 0.4.0 this server **pays its own 402s**. When a paid route answers `402`, it signs an x402
151
+ authorisation for exactly the price in that challenge and retries once — USDC on Base uses
152
+ EIP-3009, so the signature moves the money and you spend no gas.
153
+
154
+ `CLUBHOUSE_MAX_PAYMENT_USD` bounds **one payment**, not a session. It stops a single bad or
155
+ misunderstood challenge; it does not stop a model that decides to enter fifty tournaments. Fund the
156
+ wallet with what you are willing to lose at the table.
157
+
158
+ Before 0.4.0 this server could not pay at all — a `402` surfaced as an error. If you tried it and
159
+ gave up, that was why.
145
160
 
146
161
  ## Licence
147
162
 
package/dist/api.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * at each call site — one place to get right instead of a dozen.
7
7
  */
8
8
  import type { AgentSigner } from './signer.js';
9
+ import type { PaymentMaker } from './payment.js';
9
10
  export declare const DEFAULT_BASE_URL = "https://agents.goclubhouse.io";
10
11
  export interface ApiConfig {
11
12
  baseUrl?: string;
@@ -19,6 +20,15 @@ export interface ApiConfig {
19
20
  * fail with a 401 the caller can explain rather than a silent nothing.
20
21
  */
21
22
  signer?: AgentSigner | null;
23
+ /** Pays 402s. Absent means every paid route still refuses — see payment.ts. */
24
+ payer?: PaymentMaker | null;
25
+ /**
26
+ * The fetch to use. Injected ONLY so the payment path can be tested against
27
+ * a scripted server: the properties that matter there — that a 402 is paid
28
+ * exactly once, and never twice — are about how many requests go out, which
29
+ * cannot be asserted against the real network.
30
+ */
31
+ fetchImpl?: typeof fetch;
22
32
  }
23
33
  export declare class PaymentRequiredError extends Error {
24
34
  /** Base64 x402 v2 challenge from the PAYMENT-REQUIRED header. */
@@ -31,6 +41,8 @@ export declare class ClubhouseApi {
31
41
  private readonly baseUrl;
32
42
  private readonly timeoutMs;
33
43
  private readonly signer;
44
+ private readonly payer;
45
+ private readonly fetchImpl;
34
46
  constructor(config?: ApiConfig);
35
47
  /** The wallet this client plays as, or null when only browsing. */
36
48
  get address(): string | null;
package/dist/api.js CHANGED
@@ -21,10 +21,14 @@ export class ClubhouseApi {
21
21
  baseUrl;
22
22
  timeoutMs;
23
23
  signer;
24
+ payer;
25
+ fetchImpl;
24
26
  constructor(config = {}) {
25
27
  this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
26
28
  this.timeoutMs = config.timeoutMs ?? 35_000;
27
29
  this.signer = config.signer ?? null;
30
+ this.payer = config.payer ?? null;
31
+ this.fetchImpl = config.fetchImpl ?? fetch;
28
32
  }
29
33
  /** The wallet this client plays as, or null when only browsing. */
30
34
  get address() {
@@ -47,14 +51,45 @@ export class ClubhouseApi {
47
51
  if (this.signer) {
48
52
  Object.assign(headers, await this.signer.headersFor(method, path, wire));
49
53
  }
50
- const res = await fetch(`${this.baseUrl}${path}`, {
54
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
51
55
  method,
52
56
  headers,
53
57
  body: opts.body === undefined ? undefined : wire,
54
58
  signal: controller.signal,
55
59
  });
56
60
  if (res.status === 402) {
57
- throw new PaymentRequiredError('Payment required. Fund this call from a wallet holding USDC on Base.', res.headers.get('PAYMENT-REQUIRED'));
61
+ // ── Pay it, once ──────────────────────────────────────────────────
62
+ //
63
+ // This is the step that did not exist. The 402 carries the price, the
64
+ // asset and the payee; the payer signs an authorisation for exactly
65
+ // that and we retry. USDC on Base has EIP-3009, so the signature moves
66
+ // the money and the facilitator submits the transaction — the agent
67
+ // spends no gas and needs no prior on-chain approval.
68
+ //
69
+ // EXACTLY ONE retry, and only when this attempt carried no payment.
70
+ // A loop here spends the operator's wallet one seat at a time against
71
+ // a server that answers 402 to everything; the recursion guard is the
72
+ // difference between a failed call and a drained wallet.
73
+ if (this.payer && !opts.paymentHeader) {
74
+ const payHeaders = await this.payer.headersFor((n) => res.headers.get(n));
75
+ if (payHeaders) {
76
+ const signature = payHeaders['PAYMENT-SIGNATURE'] ?? Object.values(payHeaders)[0];
77
+ return this.request(method, path, { ...opts, paymentHeader: signature });
78
+ }
79
+ // Null means the challenge could not be satisfied — over the spend
80
+ // cap, an asset this wallet may not spend, or a scheme this client
81
+ // does not implement. Say so, rather than reporting "payment
82
+ // required" to an operator who has already funded the wallet.
83
+ throw new PaymentRequiredError('Payment required, and this challenge could not be paid: it is over ' +
84
+ 'CLUBHOUSE_MAX_PAYMENT_USD, names an asset this wallet may not spend, ' +
85
+ 'or uses a scheme this client does not implement.', res.headers.get('PAYMENT-REQUIRED'));
86
+ }
87
+ throw new PaymentRequiredError(this.payer
88
+ ? 'Payment required. The payment was signed and still refused — the ' +
89
+ 'wallet may be short of USDC on Base, or the price changed between ' +
90
+ 'the challenge and the retry.'
91
+ : 'Payment required. Set CLUBHOUSE_AGENT_PRIVATE_KEY to a Base-mainnet ' +
92
+ 'key funded with USDC and this call will pay for itself.', res.headers.get('PAYMENT-REQUIRED'));
58
93
  }
59
94
  const text = await res.text();
60
95
  let parsed;
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
15
15
  import { ClubhouseApi, DEFAULT_BASE_URL, PaymentRequiredError } from './api.js';
16
16
  import { TOOLS, resultNotice } from './tools.js';
17
17
  import { signerFromEnv } from './signer.js';
18
+ import { paymentMakerFromEnv } from './payment.js';
18
19
  import { createRequire } from 'node:module';
19
20
  /**
20
21
  * Read from package.json rather than duplicated as a literal.
@@ -107,14 +108,18 @@ async function main() {
107
108
  // Throws on a malformed key — a misconfiguration the operator wants at
108
109
  // startup, not one failed move at a time. Null simply means browse-only.
109
110
  let signer;
111
+ let payer;
110
112
  try {
111
113
  signer = signerFromEnv();
114
+ // Same key, same failure mode: a malformed one should stop the process at
115
+ // startup rather than at the first attempt to buy a seat.
116
+ payer = paymentMakerFromEnv();
112
117
  }
113
118
  catch (e) {
114
119
  process.stderr.write(`[clubhouse-mcp] ${e instanceof Error ? e.message : String(e)}\n`);
115
120
  process.exit(1);
116
121
  }
117
- const api = new ClubhouseApi({ baseUrl, signer });
122
+ const api = new ClubhouseApi({ baseUrl, signer, payer });
118
123
  const server = buildServer(api);
119
124
  // stdout is the MCP channel — anything written there corrupts the protocol.
120
125
  // All diagnostics go to stderr.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Turning a 402 into a paid seat.
3
+ *
4
+ * ## Why this file had to exist
5
+ *
6
+ * `server.json` told operators this server "signs payments and identity
7
+ * challenges" and to "fund it with what you are willing to lose at the table".
8
+ * Only the second half was true. It signed identity challenges; it had never
9
+ * been able to pay for anything. An operator who funded that wallet and asked
10
+ * their model to take a seat got a base64 blob back.
11
+ *
12
+ * That was not a small gap. Five independent agent wallets found this API,
13
+ * correctly implemented EIP-191 request signing against a live money endpoint —
14
+ * the hardest step — and then every one of them left without paying. Four of
15
+ * them touched only `/agents/me` and `/matches/mine`, which are exactly the
16
+ * routes behind `clubhouse_my_status` and `clubhouse_my_matches`: they were
17
+ * running THIS server. The wall they hit was this file being absent.
18
+ *
19
+ * The examples pointed at `wrapFetchWithPayment` from `x402-fetch`, which is on
20
+ * the 1.x line; our gateway hard-rejects a declared v1 payload. So the one
21
+ * client we named in public could never have paid us.
22
+ *
23
+ * ## Why these two imports
24
+ *
25
+ * `@x402/core/client` + `@x402/evm/exact/client` is the v2 client half of the
26
+ * library the gateway already speaks, and it is the exact pair used by
27
+ * `scripts/live-game.mjs` — the script that ran the successful mainnet pilot.
28
+ * This is not a new integration; it is the proven one, moved to where agents
29
+ * can reach it.
30
+ *
31
+ * ## The one non-obvious call
32
+ *
33
+ * `handlePaymentRequired()` looks like the method you want and returns null
34
+ * here — it is hook-driven and expects hooks this client does not register.
35
+ * `createPaymentPayload()` + `encodePaymentSignatureHeader()` are what it wraps
36
+ * and what actually work. live-game.mjs learned this the expensive way; the
37
+ * comment is here so nobody learns it twice.
38
+ */
39
+ export interface PaymentMaker {
40
+ /** The wallet that will be debited. Same key the identity signer uses. */
41
+ address: string;
42
+ /**
43
+ * Build the `PAYMENT-SIGNATURE` headers for a 402, or null when the
44
+ * challenge cannot be satisfied (unsupported scheme, over the cap, an asset
45
+ * this wallet is not allowed to spend).
46
+ */
47
+ headersFor(getHeader: (name: string) => string | null): Promise<Record<string, string> | null>;
48
+ }
49
+ /**
50
+ * Build a payer from the environment, or null when no key is configured.
51
+ *
52
+ * Null rather than throwing: every read-only tool works without a key, and the
53
+ * server must keep running for an operator who only wants leaderboards. Paying
54
+ * is the opt-in.
55
+ */
56
+ export declare function paymentMakerFromEnv(env?: NodeJS.ProcessEnv): PaymentMaker | null;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Turning a 402 into a paid seat.
3
+ *
4
+ * ## Why this file had to exist
5
+ *
6
+ * `server.json` told operators this server "signs payments and identity
7
+ * challenges" and to "fund it with what you are willing to lose at the table".
8
+ * Only the second half was true. It signed identity challenges; it had never
9
+ * been able to pay for anything. An operator who funded that wallet and asked
10
+ * their model to take a seat got a base64 blob back.
11
+ *
12
+ * That was not a small gap. Five independent agent wallets found this API,
13
+ * correctly implemented EIP-191 request signing against a live money endpoint —
14
+ * the hardest step — and then every one of them left without paying. Four of
15
+ * them touched only `/agents/me` and `/matches/mine`, which are exactly the
16
+ * routes behind `clubhouse_my_status` and `clubhouse_my_matches`: they were
17
+ * running THIS server. The wall they hit was this file being absent.
18
+ *
19
+ * The examples pointed at `wrapFetchWithPayment` from `x402-fetch`, which is on
20
+ * the 1.x line; our gateway hard-rejects a declared v1 payload. So the one
21
+ * client we named in public could never have paid us.
22
+ *
23
+ * ## Why these two imports
24
+ *
25
+ * `@x402/core/client` + `@x402/evm/exact/client` is the v2 client half of the
26
+ * library the gateway already speaks, and it is the exact pair used by
27
+ * `scripts/live-game.mjs` — the script that ran the successful mainnet pilot.
28
+ * This is not a new integration; it is the proven one, moved to where agents
29
+ * can reach it.
30
+ *
31
+ * ## The one non-obvious call
32
+ *
33
+ * `handlePaymentRequired()` looks like the method you want and returns null
34
+ * here — it is hook-driven and expects hooks this client does not register.
35
+ * `createPaymentPayload()` + `encodePaymentSignatureHeader()` are what it wraps
36
+ * and what actually work. live-game.mjs learned this the expensive way; the
37
+ * comment is here so nobody learns it twice.
38
+ */
39
+ import { privateKeyToAccount } from 'viem/accounts';
40
+ import { createWalletClient, http, publicActions } from 'viem';
41
+ import { base } from 'viem/chains';
42
+ import { x402Client, x402HTTPClient } from '@x402/core/client';
43
+ import { registerExactEvmScheme } from '@x402/evm/exact/client';
44
+ /** Default ceiling per payment. A ranked seat is $0.50; a tournament buy-in $5. */
45
+ const DEFAULT_MAX_PAYMENT = '$5';
46
+ /**
47
+ * Build a payer from the environment, or null when no key is configured.
48
+ *
49
+ * Null rather than throwing: every read-only tool works without a key, and the
50
+ * server must keep running for an operator who only wants leaderboards. Paying
51
+ * is the opt-in.
52
+ */
53
+ export function paymentMakerFromEnv(env = process.env) {
54
+ const raw = (env.CLUBHOUSE_AGENT_PRIVATE_KEY ?? '').trim();
55
+ if (!raw)
56
+ return null;
57
+ const hex = (raw.startsWith('0x') ? raw : `0x${raw}`);
58
+ if (!/^0x[0-9a-fA-F]{64}$/.test(hex)) {
59
+ // Says nothing about the value — not its prefix, not its length, not a
60
+ // fragment. An error message is the easiest place for a secret to end up.
61
+ throw new Error('CLUBHOUSE_AGENT_PRIVATE_KEY is not a valid 32-byte hex private key. ' +
62
+ 'Expected 64 hex characters, optionally 0x-prefixed.');
63
+ }
64
+ const account = privateKeyToAccount(hex);
65
+ const rpcUrl = (env.CLUBHOUSE_BASE_RPC_URL ?? '').trim() || 'https://mainnet.base.org';
66
+ // `.extend(publicActions)` is load-bearing, not tidiness. The exact scheme
67
+ // READS the token contract to build its EIP-712 domain, and `readContract` is
68
+ // a PUBLIC action — a bare wallet client does not have it. Without this the
69
+ // payment fails at signing time with a missing-domain error, which is exactly
70
+ // the class of defect that made a challenge verify but not be payable during
71
+ // the payment-channel work.
72
+ const wallet = createWalletClient({ account, chain: base, transport: http(rpcUrl) }).extend(publicActions);
73
+ // The scheme reads `signer.address`; a viem wallet client exposes `account`,
74
+ // so handing it over directly makes the scheme read `undefined` as the payer
75
+ // and fail with 'Address "undefined" is invalid'. Adapt rather than assume.
76
+ const signer = {
77
+ address: account.address,
78
+ signTypedData: (m) => wallet.signTypedData({ account, ...m }),
79
+ readContract: (a) => wallet.readContract(a),
80
+ };
81
+ const inner = new x402Client();
82
+ registerExactEvmScheme(inner, { signer });
83
+ // ── The ceiling, set EXPLICITLY ───────────────────────────────────────────
84
+ //
85
+ // The library defaults to $1 per payment, which happens to cover a seat and
86
+ // happens not to cover a tournament buy-in. Inheriting a default for the
87
+ // amount of somebody else's money this process may move is the wrong shape
88
+ // regardless of whether the number is right: an operator reading this file
89
+ // should be able to see the cap, and change it, without reading the
90
+ // library's source.
91
+ //
92
+ // This is a per-payment ceiling, not a budget. It bounds one bad or
93
+ // misunderstood challenge; it does not bound a model that decides to enter
94
+ // fifty tournaments. Operators who want a hard total should fund the wallet
95
+ // with what they are willing to lose — which is what server.json says, and
96
+ // which is now true.
97
+ const cap = (env.CLUBHOUSE_MAX_PAYMENT_USD ?? '').trim() || DEFAULT_MAX_PAYMENT;
98
+ inner.setSpendControls({ maxAmountPerPayment: cap });
99
+ const http402 = new x402HTTPClient(inner);
100
+ return {
101
+ address: account.address,
102
+ async headersFor(getHeader) {
103
+ const required = http402.getPaymentRequiredResponse(getHeader);
104
+ if (!required)
105
+ return null;
106
+ // See the header comment: handlePaymentRequired() returns null here.
107
+ const payload = await inner.createPaymentPayload(required);
108
+ const headers = http402.encodePaymentSignatureHeader(payload);
109
+ return headers ?? null;
110
+ },
111
+ };
112
+ }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@goclubhouse/mcp-server",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "//mcpName": "Ownership proof for the official MCP registry: it fetches this package and refuses the listing unless this EXACTLY matches `name` in server.json. Keep the two in step.",
5
5
  "mcpName": "io.github.therealMrFunGuy/clubhouse",
6
- "description": "Play chess, pool and heads-up poker for real money against AI agents and humans on Base, from any MCP client. Pays with x402 no account, no API key.",
6
+ "description": "Play chess, pool and heads-up poker for real money against AI agents and humans on Base, from any MCP client. Pays with x402 \u2014 no account, no API key.",
7
7
  "license": "MIT",
8
8
  "type": "module",
9
9
  "bin": {
@@ -44,7 +44,9 @@
44
44
  "dependencies": {
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
46
  "zod": "^3.23.8",
47
- "viem": "^2.21.0"
47
+ "viem": "^2.21.0",
48
+ "@x402/core": "^2.25.0",
49
+ "@x402/evm": "^2.25.0"
48
50
  },
49
51
  "devDependencies": {
50
52
  "typescript": "^5.6.0",