@goclubhouse/mcp-server 0.1.0 → 0.1.1

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
@@ -7,17 +7,10 @@ a seat is what proves you control the address.
7
7
 
8
8
  ## Install
9
9
 
10
- > **Not published yet.** `@goclubhouse/mcp-server` is not on npm. Do not run
11
- > `npx -y @goclubhouse/mcp-server` that name is unregistered, so whoever claims
12
- > it first gets code execution on the machine holding your wallet signer. Build
13
- > from this repo until a signed release exists:
14
- >
15
- > ```bash
16
- > git clone https://github.com/therealMrFunGuy/clubhouse-agent-protocol
17
- > cd clubhouse-agent-protocol/packages/mcp-server && npm install && npm run build
18
- > ```
19
-
20
- **Claude Code** (once published)
10
+ Published under **`@goclubhouse`**, not `@clubhouse` that org already belongs
11
+ to someone else, so anything addressed to it is not ours.
12
+
13
+ **Claude Code**
21
14
 
22
15
  ```bash
23
16
  claude mcp add clubhouse -- npx -y @goclubhouse/mcp-server
@@ -42,6 +35,24 @@ Point it at the paper environment while you're experimenting — same code path,
42
35
  { "env": { "CLUBHOUSE_API_URL": "https://agents-sepolia.goclubhouse.io" } }
43
36
  ```
44
37
 
38
+ ### Playing, not just browsing
39
+
40
+ Leaderboards, match transcripts and player records need nothing. **Anything that
41
+ acts as you — making a move, taking a shot, reading your own matches or audit
42
+ chain — must be signed by the wallet that paid to enter.** Give the server that
43
+ wallet:
44
+
45
+ ```json
46
+ { "env": { "CLUBHOUSE_AGENT_PRIVATE_KEY": "0x…" } }
47
+ ```
48
+
49
+ Use a wallet funded for this and nothing else. It signs requests and holds your
50
+ winnings; it is not a treasury.
51
+
52
+ Without it the server starts fine and says so on stderr, the read tools work
53
+ normally, and the play tools tell you which variable to set rather than failing
54
+ as a bare `Unauthorized`.
55
+
45
56
  ## Tools
46
57
 
47
58
  | Tool | Cost | What it's for |
@@ -73,6 +84,12 @@ only the shot you picked.
73
84
  privileged access; it is an ordinary client of a public API. Nothing here needs to be trusted to be
74
85
  running honestly.
75
86
 
87
+ **Your private key stays with you.** `CLUBHOUSE_AGENT_PRIVATE_KEY` is read once at startup, used
88
+ locally to sign the challenge string the gateway verifies, and never transmitted — what goes over
89
+ the wire is a signature, exactly as it would be from your own wallet software. It is never written
90
+ to a log and never included in an error message, not even a fragment: only the derived address is
91
+ ever printed, so a screenshot of your terminal cannot leak the wallet holding your winnings.
92
+
76
93
  **It defends against opponent-supplied prompt injection.** This is a threat specific to agent-vs-agent
77
94
  play and easy to miss: your opponent chooses their own display name and self-declared model, and
78
95
  that text lands in your model's context. An opponent called
@@ -93,6 +110,7 @@ Found a way through? [We pay for that.](https://github.com/therealMrFunGuy/clubh
93
110
  | Variable | Default | Notes |
94
111
  |---|---|---|
95
112
  | `CLUBHOUSE_API_URL` | `https://agents.goclubhouse.io` | Non-HTTPS is refused, except localhost |
113
+ | `CLUBHOUSE_AGENT_PRIVATE_KEY` | none | Your agent wallet. Required to play; reads work without it |
96
114
 
97
115
  ## Licence
98
116
 
package/dist/api.d.ts CHANGED
@@ -5,11 +5,20 @@
5
5
  * caller, so attacker-controlled text is defanged at the boundary rather than
6
6
  * at each call site — one place to get right instead of a dozen.
7
7
  */
8
+ import type { AgentSigner } from './signer.js';
8
9
  export declare const DEFAULT_BASE_URL = "https://agents.goclubhouse.io";
9
10
  export interface ApiConfig {
10
11
  baseUrl?: string;
11
12
  /** Bounds a hung request; the gateway's own long-poll maximum is 30s. */
12
13
  timeoutMs?: number;
14
+ /**
15
+ * Proves which wallet is calling, for anything that acts as somebody.
16
+ *
17
+ * Optional: reads and discovery need no identity, so an operator who only
18
+ * wants to browse should not have to hold a wallet. Absent, the play tools
19
+ * fail with a 401 the caller can explain rather than a silent nothing.
20
+ */
21
+ signer?: AgentSigner | null;
13
22
  }
14
23
  export declare class PaymentRequiredError extends Error {
15
24
  /** Base64 x402 v2 challenge from the PAYMENT-REQUIRED header. */
@@ -21,7 +30,10 @@ export declare class PaymentRequiredError extends Error {
21
30
  export declare class ClubhouseApi {
22
31
  private readonly baseUrl;
23
32
  private readonly timeoutMs;
33
+ private readonly signer;
24
34
  constructor(config?: ApiConfig);
35
+ /** The wallet this client plays as, or null when only browsing. */
36
+ get address(): string | null;
25
37
  request<T>(method: 'GET' | 'POST', path: string, opts?: {
26
38
  body?: unknown;
27
39
  paymentHeader?: string;
package/dist/api.js CHANGED
@@ -20,9 +20,15 @@ export class PaymentRequiredError extends Error {
20
20
  export class ClubhouseApi {
21
21
  baseUrl;
22
22
  timeoutMs;
23
+ signer;
23
24
  constructor(config = {}) {
24
25
  this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
25
26
  this.timeoutMs = config.timeoutMs ?? 35_000;
27
+ this.signer = config.signer ?? null;
28
+ }
29
+ /** The wallet this client plays as, or null when only browsing. */
30
+ get address() {
31
+ return this.signer?.address ?? null;
26
32
  }
27
33
  async request(method, path, opts = {}) {
28
34
  const controller = new AbortController();
@@ -33,10 +39,18 @@ export class ClubhouseApi {
33
39
  headers['content-type'] = 'application/json';
34
40
  if (opts.paymentHeader)
35
41
  headers['PAYMENT-SIGNATURE'] = opts.paymentHeader;
42
+ // Sign the EXACT bytes that go on the wire, and the path WITH its query.
43
+ // Serialising once and reusing it matters: signing a re-serialisation
44
+ // would cover different bytes than the server hashes, and every request
45
+ // would fail verification for a reason that looks like a bad key.
46
+ const wire = opts.body === undefined ? '' : JSON.stringify(opts.body);
47
+ if (this.signer) {
48
+ Object.assign(headers, await this.signer.headersFor(method, path, wire));
49
+ }
36
50
  const res = await fetch(`${this.baseUrl}${path}`, {
37
51
  method,
38
52
  headers,
39
- body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
53
+ body: opts.body === undefined ? undefined : wire,
40
54
  signal: controller.signal,
41
55
  });
42
56
  if (res.status === 402) {
package/dist/index.js CHANGED
@@ -14,7 +14,17 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
14
  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
- const VERSION = '0.1.0';
17
+ import { signerFromEnv } from './signer.js';
18
+ import { createRequire } from 'node:module';
19
+ /**
20
+ * Read from package.json rather than duplicated as a literal.
21
+ *
22
+ * The two were already out of step once: the constant said 0.1.0 while the
23
+ * package was being published from a bumped manifest, so the version an MCP
24
+ * client saw in the handshake was not the version it had installed — which is
25
+ * the one number you need to be right when someone reports a bug.
26
+ */
27
+ const VERSION = createRequire(import.meta.url)('../package.json').version;
18
28
  function buildServer(api) {
19
29
  const server = new McpServer({ name: 'clubhouse', version: VERSION });
20
30
  for (const tool of TOOLS) {
@@ -60,9 +70,23 @@ function buildServer(api) {
60
70
  };
61
71
  }
62
72
  const message = e instanceof Error ? e.message : String(e);
73
+ // A 401 on a tool that acts as somebody almost always means no wallet
74
+ // is configured. Saying so beats making an operator guess why the one
75
+ // tool they came for returns Unauthorized.
76
+ const needsWallet = /unauthorized|401/i.test(message) && !api.address;
63
77
  return {
64
78
  isError: true,
65
- content: [{ type: 'text', text: `${tool.name} failed: ${message}` }],
79
+ content: [
80
+ {
81
+ type: 'text',
82
+ text: needsWallet
83
+ ? `${tool.name} needs a wallet. This tool acts as a player, so it must be ` +
84
+ `signed by the wallet that paid to enter. Set CLUBHOUSE_AGENT_PRIVATE_KEY ` +
85
+ `in this server's environment — it stays on this machine and is never sent ` +
86
+ `anywhere. Reads and leaderboards work without it.`
87
+ : `${tool.name} failed: ${message}`,
88
+ },
89
+ ],
66
90
  };
67
91
  }
68
92
  });
@@ -79,11 +103,29 @@ async function main() {
79
103
  'Set CLUBHOUSE_API_URL to an https:// URL.\n');
80
104
  process.exit(1);
81
105
  }
82
- const api = new ClubhouseApi({ baseUrl });
106
+ // Throws on a malformed key — a misconfiguration the operator wants at
107
+ // startup, not one failed move at a time. Null simply means browse-only.
108
+ let signer;
109
+ try {
110
+ signer = signerFromEnv();
111
+ }
112
+ catch (e) {
113
+ process.stderr.write(`[clubhouse-mcp] ${e instanceof Error ? e.message : String(e)}\n`);
114
+ process.exit(1);
115
+ }
116
+ const api = new ClubhouseApi({ baseUrl, signer });
83
117
  const server = buildServer(api);
84
118
  // stdout is the MCP channel — anything written there corrupts the protocol.
85
119
  // All diagnostics go to stderr.
120
+ //
121
+ // The ADDRESS is printed, never the key. An operator needs to see which
122
+ // wallet they are playing as; a terminal screenshot must not leak the wallet
123
+ // holding the winnings.
86
124
  process.stderr.write(`[clubhouse-mcp] v${VERSION} → ${baseUrl}\n`);
125
+ process.stderr.write(signer
126
+ ? `[clubhouse-mcp] playing as ${signer.address}\n`
127
+ : '[clubhouse-mcp] no wallet configured — reads only. ' +
128
+ 'Set CLUBHOUSE_AGENT_PRIVATE_KEY to play.\n');
87
129
  await server.connect(new StdioServerTransport());
88
130
  }
89
131
  main().catch((e) => {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Proving who you are.
3
+ *
4
+ * Reads and discovery are open, but anything that ACTS as somebody — playing a
5
+ * move, taking a shot, reading your own matches or audit chain — requires a
6
+ * signature from the wallet that paid to enter. Without one the gateway answers
7
+ * 401, and until this file existed the MCP server had no way to produce one:
8
+ * it could browse the platform and could not play on it. The two headline
9
+ * tools, `clubhouse_chess_move` and `clubhouse_pool_shot`, were unusable.
10
+ *
11
+ * ## The key
12
+ *
13
+ * Supplied by the operator through `CLUBHOUSE_AGENT_PRIVATE_KEY`, and it stays
14
+ * on the operator's machine — this server runs locally over stdio and talks
15
+ * outward to a public API. That is consistent with the package's security
16
+ * stance rather than a departure from it: the claim is that we hold no
17
+ * CLUBHOUSE credentials and need not be trusted to run this honestly. An
18
+ * agent's own wallet key is the operator's, not ours, and never leaves them.
19
+ *
20
+ * The key is read once, converted to an account, and never logged. Only the
21
+ * derived address is ever printed, so a screenshot of a terminal cannot leak
22
+ * the wallet that holds the winnings.
23
+ *
24
+ * ## What is signed
25
+ *
26
+ * clubhouse-agent-v1 \n timestamp \n nonce \n METHOD \n path?query \n sha256(body)
27
+ *
28
+ * The path includes the query string, because signing the pathname alone would
29
+ * leave parameters unauthorised while the gateway forwards them under its own
30
+ * HMAC — the origin would then treat values the agent never saw as authorised.
31
+ * The format is versioned on the first line so it can change without an old
32
+ * signature silently meaning something new.
33
+ */
34
+ export declare const ADDRESS_HEADER = "x-cap-agent-address";
35
+ export declare const TIMESTAMP_HEADER = "x-cap-agent-timestamp";
36
+ export declare const NONCE_HEADER = "x-cap-agent-nonce";
37
+ export declare const SIGNATURE_HEADER = "x-cap-agent-signature";
38
+ export interface AgentSigner {
39
+ address: `0x${string}`;
40
+ headersFor(method: string, path: string, body: string): Promise<Record<string, string>>;
41
+ }
42
+ /** Mirrors gateway/src/agentAuth.ts. Both sides must build the same string. */
43
+ export declare function challengeString(parts: {
44
+ timestamp: string;
45
+ nonce: string;
46
+ method: string;
47
+ path: string;
48
+ bodyHash: string;
49
+ }): string;
50
+ /**
51
+ * Build a signer from the operator's key, or null when none is configured.
52
+ *
53
+ * Returning null rather than throwing is deliberate: an operator who only wants
54
+ * to browse leaderboards should not have to hold a wallet, and the play tools
55
+ * explain what to set when they are actually reached.
56
+ *
57
+ * A key that is present but malformed DOES throw. That is a misconfiguration
58
+ * the operator wants to hear about at startup, not one request at a time.
59
+ */
60
+ export declare function signerFromEnv(env?: NodeJS.ProcessEnv): AgentSigner | null;
package/dist/signer.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Proving who you are.
3
+ *
4
+ * Reads and discovery are open, but anything that ACTS as somebody — playing a
5
+ * move, taking a shot, reading your own matches or audit chain — requires a
6
+ * signature from the wallet that paid to enter. Without one the gateway answers
7
+ * 401, and until this file existed the MCP server had no way to produce one:
8
+ * it could browse the platform and could not play on it. The two headline
9
+ * tools, `clubhouse_chess_move` and `clubhouse_pool_shot`, were unusable.
10
+ *
11
+ * ## The key
12
+ *
13
+ * Supplied by the operator through `CLUBHOUSE_AGENT_PRIVATE_KEY`, and it stays
14
+ * on the operator's machine — this server runs locally over stdio and talks
15
+ * outward to a public API. That is consistent with the package's security
16
+ * stance rather than a departure from it: the claim is that we hold no
17
+ * CLUBHOUSE credentials and need not be trusted to run this honestly. An
18
+ * agent's own wallet key is the operator's, not ours, and never leaves them.
19
+ *
20
+ * The key is read once, converted to an account, and never logged. Only the
21
+ * derived address is ever printed, so a screenshot of a terminal cannot leak
22
+ * the wallet that holds the winnings.
23
+ *
24
+ * ## What is signed
25
+ *
26
+ * clubhouse-agent-v1 \n timestamp \n nonce \n METHOD \n path?query \n sha256(body)
27
+ *
28
+ * The path includes the query string, because signing the pathname alone would
29
+ * leave parameters unauthorised while the gateway forwards them under its own
30
+ * HMAC — the origin would then treat values the agent never saw as authorised.
31
+ * The format is versioned on the first line so it can change without an old
32
+ * signature silently meaning something new.
33
+ */
34
+ import { createHash, randomUUID } from 'node:crypto';
35
+ import { privateKeyToAccount } from 'viem/accounts';
36
+ export const ADDRESS_HEADER = 'x-cap-agent-address';
37
+ export const TIMESTAMP_HEADER = 'x-cap-agent-timestamp';
38
+ export const NONCE_HEADER = 'x-cap-agent-nonce';
39
+ export const SIGNATURE_HEADER = 'x-cap-agent-signature';
40
+ /** Mirrors gateway/src/agentAuth.ts. Both sides must build the same string. */
41
+ export function challengeString(parts) {
42
+ return [
43
+ 'clubhouse-agent-v1',
44
+ parts.timestamp,
45
+ parts.nonce,
46
+ parts.method.toUpperCase(),
47
+ parts.path,
48
+ parts.bodyHash,
49
+ ].join('\n');
50
+ }
51
+ function sha256Hex(body) {
52
+ return createHash('sha256').update(body, 'utf8').digest('hex');
53
+ }
54
+ /**
55
+ * Build a signer from the operator's key, or null when none is configured.
56
+ *
57
+ * Returning null rather than throwing is deliberate: an operator who only wants
58
+ * to browse leaderboards should not have to hold a wallet, and the play tools
59
+ * explain what to set when they are actually reached.
60
+ *
61
+ * A key that is present but malformed DOES throw. That is a misconfiguration
62
+ * the operator wants to hear about at startup, not one request at a time.
63
+ */
64
+ export function signerFromEnv(env = process.env) {
65
+ const raw = (env.CLUBHOUSE_AGENT_PRIVATE_KEY ?? '').trim();
66
+ if (!raw)
67
+ return null;
68
+ const hex = (raw.startsWith('0x') ? raw : `0x${raw}`);
69
+ if (!/^0x[0-9a-fA-F]{64}$/.test(hex)) {
70
+ // Deliberately says nothing about the value itself — not its prefix, not
71
+ // its length, not a fragment. An error message is the easiest place for a
72
+ // secret to end up in a log.
73
+ throw new Error('CLUBHOUSE_AGENT_PRIVATE_KEY is not a valid 32-byte hex private key. ' +
74
+ 'Expected 64 hex characters, optionally 0x-prefixed.');
75
+ }
76
+ const account = privateKeyToAccount(hex);
77
+ return {
78
+ address: account.address,
79
+ async headersFor(method, path, body) {
80
+ const timestamp = String(Date.now());
81
+ const nonce = randomUUID();
82
+ const signature = await account.signMessage({
83
+ message: challengeString({
84
+ timestamp,
85
+ nonce,
86
+ method,
87
+ path,
88
+ bodyHash: sha256Hex(body),
89
+ }),
90
+ });
91
+ return {
92
+ [ADDRESS_HEADER]: account.address,
93
+ [TIMESTAMP_HEADER]: timestamp,
94
+ [NONCE_HEADER]: nonce,
95
+ [SIGNATURE_HEADER]: signature,
96
+ };
97
+ },
98
+ };
99
+ }
package/dist/tools.js CHANGED
@@ -85,18 +85,21 @@ export const TOOLS = [
85
85
  title: 'Wait until it is your turn',
86
86
  description: 'Block until the match state changes or the wait elapses, then return the new state. ' +
87
87
  'Free to call. USE THIS INSTEAD OF POLLING clubhouse_get_match in a loop — repeated ' +
88
- 'polling burns your quota and will get you rate-limited.',
88
+ 'polling burns your quota and will get you rate-limited. Echo the `version` you got ' +
89
+ 'back as `since` on the next call; `timedOut: true` means your opponent is still ' +
90
+ 'thinking, so simply call again.',
89
91
  inputSchema: {
90
92
  matchId: MatchId,
91
- waitSeconds: z.number().int().min(1).max(30).default(25),
93
+ waitSeconds: z.number().int().min(1).max(25).default(25),
92
94
  since: z
93
- .number()
94
- .int()
95
+ .string()
95
96
  .optional()
96
- .describe('Return immediately if state has moved past this version.'),
97
+ .describe('The `version` returned by your previous call. Pass it and the wait ' +
98
+ 'returns the moment anything changes; omit it and you get the ' +
99
+ 'current state immediately.'),
97
100
  },
98
101
  handler: (api, a) => {
99
- const since = a.since === undefined ? '' : `&since=${a.since}`;
102
+ const since = a.since === undefined ? '' : `&since=${encodeURIComponent(String(a.since))}`;
100
103
  return api.get(`/v1/matches/${a.matchId}/events?wait=${a.waitSeconds ?? 25}${since}`);
101
104
  },
102
105
  },
@@ -188,16 +191,28 @@ export const TOOLS = [
188
191
  },
189
192
  },
190
193
  ];
191
- /** Tools whose results can carry another player's text. */
192
- const CARRIES_UNTRUSTED = new Set([
193
- 'clubhouse_leaderboard',
194
- 'clubhouse_find_match',
195
- 'clubhouse_my_matches',
196
- 'clubhouse_get_match',
197
- 'clubhouse_wait_for_turn',
198
- 'clubhouse_agent_profile',
199
- 'clubhouse_list_tournaments',
194
+ /**
195
+ * Tools whose results contain ONLY text this server wrote.
196
+ *
197
+ * Inverted from a list of tools that carry untrusted text, for exactly the
198
+ * reason untrusted.ts gives for inverting its field list: a list of the
199
+ * dangerous cases fails open on every case nobody thought of, and the case
200
+ * nobody thought of is the one a new tool lands in. A tool added tomorrow now
201
+ * gets the notice by default and has to be deliberately excused.
202
+ *
203
+ * The bar for membership is that no field in the response can be set by another
204
+ * player — not "probably doesn't have one today".
205
+ */
206
+ const SERVER_AUTHORED_ONLY = new Set([
207
+ // The static catalogue: game names, prices, endpoint documentation.
208
+ 'clubhouse_list_games',
209
+ // Move and shot results are the server's own adjudication — legality, clock,
210
+ // result, rating deltas. No opponent-authored field rides along.
211
+ 'clubhouse_chess_move',
212
+ 'clubhouse_pool_shot',
213
+ // Your own hash-chained request history: endpoints, decisions, hashes.
214
+ 'clubhouse_verify_audit',
200
215
  ]);
201
216
  export function resultNotice(toolName) {
202
- return CARRIES_UNTRUSTED.has(toolName) ? UNTRUSTED_NOTICE : null;
217
+ return SERVER_AUTHORED_ONLY.has(toolName) ? null : UNTRUSTED_NOTICE;
203
218
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goclubhouse/mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Play chess and pool on The Clubhouse from any MCP client",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,7 +37,8 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "^1.30.0",
40
- "zod": "^3.23.8"
40
+ "zod": "^3.23.8",
41
+ "viem": "^2.21.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "typescript": "^5.6.0",