@chaindaddy/mcp 0.6.7 → 0.6.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaindaddy/mcp",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "MCP server for Chain Daddy: search tickers, launch a token or verify ownership of one, and run its creator store",
5
5
  "mcpName": "io.chaindaddy/mcp",
6
6
  "homepage": "https://docs.chaindaddy.io/developer/store-and-rewards#tools-for-agents",
@@ -27,7 +27,7 @@ Use discovery: `https://api.chaindaddy.io/.well-known/openid-configuration`
27
27
 
28
28
  Any site can sign people in without registering anything.
29
29
 
30
- Replace `YOUR_SITE` with the site's https origin. Nothing is registered with Chain Daddy. The origin must be public, even while you build (a tunnel or a preview deployment): Chain Daddy's server fetches `client.json`, so `localhost` cannot be a client id.
30
+ Replace `YOUR_SITE` with the site's https origin. Nothing is registered with Chain Daddy. The origin must be public, even while you build (a tunnel such as `cloudflared tunnel --url http://localhost:3000`, which needs no account, or a preview deployment): Chain Daddy's server fetches `client.json`, so `localhost` cannot be a client id.
31
31
 
32
32
  1. Host this at `https://YOUR_SITE/client.json` (served 200, no redirect; `client_id` must equal its own URL):
33
33
 
@@ -47,8 +47,13 @@ Replace `YOUR_SITE` with the site's https origin. Nothing is registered with Cha
47
47
  <cd-signin-button client-id="https://YOUR_SITE/client.json"
48
48
  redirect-uri="https://YOUR_SITE/callback.html"
49
49
  scope="openid wallets"></cd-signin-button>
50
+ <p id="signed-in"></p>
50
51
  <p id="signin-error" role="alert"></p>
51
52
  <script>
53
+ // Show who your server says is signed in.
54
+ fetch('/me').then((r) => (r.ok ? r.json() : null)).then((me) => {
55
+ if (me) document.querySelector('#signed-in').textContent = 'Signed in as ' + me.wallet;
56
+ });
52
57
  document.querySelector('cd-signin-button').addEventListener('cd-signin', async (e) => {
53
58
  const { id_token } = await ChainDaddy.exchangeCode(e.detail);
54
59
  const res = await fetch('/session', { method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -84,17 +89,17 @@ Replace `YOUR_SITE` with the site's https origin. Nothing is registered with Cha
84
89
  </script>
85
90
  ```
86
91
 
87
- 4. On the server, an Express app (`npm install express jose`): verify the ID token, then start your own session (never trust the token in the browser). `POST /session` answers a refusal with a status and a `message`, which both pages show:
92
+ 4. On the server, an Express app in plain JavaScript: verify the ID token, then start your own session (never trust the token in the browser). Save it as `server.mjs` with the three files above in `public/` (the sign-in page as `index.html`), then `npm install express jose` and `node server.mjs`. After sign-in the page shows "Signed in as" and the wallet, from `GET /me`. `POST /session` answers a refusal with a status and a `message`, which both pages show:
88
93
 
89
- ```ts
94
+ ```js
90
95
  import express from 'express';
91
96
  import { randomUUID } from 'node:crypto';
92
97
  import { createRemoteJWKSet, jwtVerify } from 'jose';
93
98
  const JWKS = createRemoteJWKSet(new URL('https://api.chaindaddy.io/api/v2/oauth/jwks'));
94
99
  // ID tokens already accepted, until they expire. A real deployment keeps these in its session store or Redis.
95
- const seen = new Map<string, number>();
100
+ const seen = new Map();
96
101
 
97
- export async function verifyChainDaddy(idToken: string) {
102
+ async function verifyChainDaddy(idToken) {
98
103
  const { payload } = await jwtVerify(idToken, JWKS, {
99
104
  issuer: 'https://api.chaindaddy.io', audience: 'https://YOUR_SITE/client.json', algorithms: ['ES256'],
100
105
  });
@@ -107,12 +112,12 @@ export async function verifyChainDaddy(idToken: string) {
107
112
  }
108
113
 
109
114
  // Your own sessions, by the id in the cookie. A real deployment keeps these in its session store or Redis.
110
- const sessions = new Map<string, { sub: string; wallet?: string }>();
111
- const sessionOf = (req: express.Request) =>
112
- sessions.get(/(?:^|; )session=([^;]+)/.exec(req.headers.cookie ?? '')?.[1] ?? '');
115
+ const sessions = new Map();
116
+ const sessionOf = (req) => sessions.get(/(?:^|; )session=([^;]+)/.exec(req.headers.cookie ?? '')?.[1] ?? '');
113
117
 
114
118
  const app = express();
115
119
  app.use(express.json());
120
+ app.use(express.static('public')); // client.json, the sign-in page (index.html) and callback.html
116
121
 
117
122
  app.post('/session', async (req, res) => {
118
123
  let payload;
@@ -122,13 +127,13 @@ app.post('/session', async (req, res) => {
122
127
  return res.status(401).json({ error: 'sign_in_failed', message: 'That sign-in could not be checked. Sign in again.' });
123
128
  }
124
129
  const id = randomUUID();
125
- sessions.set(id, { sub: payload.sub as string, wallet: (payload.wallets as string[] | undefined)?.[0] });
130
+ sessions.set(id, { sub: payload.sub, wallet: payload.wallets?.[0] });
126
131
  res.cookie('session', id, { httpOnly: true, secure: true, sameSite: 'lax' });
127
132
  res.json({ ok: true });
128
133
  });
129
134
 
130
- // Only a signed-in visitor gets this.
131
- app.get('/members', (req, res) => {
135
+ // Who is signed in. The sign-in page shows it.
136
+ app.get('/me', (req, res) => {
132
137
  const user = sessionOf(req);
133
138
  if (!user) return res.status(401).json({ error: 'signed_out', message: 'Sign in first.' });
134
139
  res.json({ sub: user.sub, wallet: user.wallet });
@@ -137,40 +142,47 @@ app.get('/members', (req, res) => {
137
142
  app.listen(3000);
138
143
  ```
139
144
 
140
- To require a coin, add `"chaindaddy_claims": {"id_token":{"holdings":{"tokens":[{"chain":"base","address":"0xTOKEN","min":"100"}]}}}` to `client.json`. Chain Daddy then answers that request and no other, and refuses a sign-in that asks for anything else; the button needs no `claims`. The server still never reads `payload.holdings[0].held`: it matches each answer against its own constants first.
145
+ To ask about a coin, add `"chaindaddy_claims": {"id_token":{"holdings":{"tokens":[{"chain":"base","address":"0xTOKEN","min":"100"}]}}}` to `client.json`. Chain Daddy then answers that request and no other, and refuses a sign-in that asks for anything else; the button needs no `claims`. Everyone still signs in, and one route is for holders. The server never reads `payload.holdings[0].held`: it matches each answer against its own constants first.
141
146
 
142
- ```ts
147
+ ```js
143
148
  // What this site gates on, as the ID token writes it: token lowercase for an EVM address, min "0" if none was asked.
144
149
  const GATE = [{ chain: 'base', token: '0xtoken', min: '100' }];
145
150
 
146
- export function holdsCoin(payload: Record<string, unknown>) {
151
+ // true or false, or null when the balance could not be read: not a no, so ask them to sign in again.
152
+ export function holdsCoin(payload) {
147
153
  const holdings = Array.isArray(payload.holdings) ? payload.holdings : [];
148
- return GATE.some((want) => holdings.some((h) =>
149
- h?.chain === want.chain && h?.token === want.token && h?.min === want.min && h?.held === true));
154
+ const asked = holdings.filter((h) => GATE.some((want) =>
155
+ h?.chain === want.chain && h?.token === want.token && h?.min === want.min));
156
+ if (asked.some((h) => h.held === true)) return true;
157
+ return asked.some((h) => h.held === null) ? null : false;
150
158
  }
151
159
  ```
152
160
 
153
- In `POST /session`, refuse right after `verifyChainDaddy`, so no session is made and the page shows the `message`:
161
+ In `POST /session`, keep the answer in the session: `sessions.set(id, { sub: payload.sub, wallet: payload.wallets?.[0], holder: holdsCoin(payload) });`. Then add the holders' route:
154
162
 
155
- ```ts
156
- if (!holdsCoin(payload)) {
157
- // held is null when the balance could not be read: not a no, so ask them to sign in again.
158
- const unread = Array.isArray(payload.holdings) && payload.holdings.some((h) => h?.held === null);
159
- return unread
160
- ? res.status(503).json({ error: 'balance_unreadable', message: 'We could not read your balance just now. Sign in again in a moment.' })
161
- : res.status(403).json({ error: 'not_a_holder', message: 'This page is for holders of the coin.' });
162
- }
163
+ ```js
164
+ // Only a holder gets this. Everyone else stays signed in and is told why not.
165
+ app.get('/members', (req, res) => {
166
+ const user = sessionOf(req);
167
+ if (!user) return res.status(401).json({ error: 'signed_out', message: 'Sign in first.' });
168
+ if (user.holder === null) return res.status(503).json({ error: 'balance_unreadable', message: 'We could not read your balance just now. Sign in again in a moment.' });
169
+ if (!user.holder) return res.status(403).json({ error: 'not_a_holder', message: 'This page is for holders of the coin.' });
170
+ res.json({ ok: true }); // your holders-only page
171
+ });
163
172
  ```
164
173
 
165
- The token address is `tokenAddress` in `GET https://api.chaindaddy.io/api/v2/public/coins/{chain}/{symbol}` (keyless; e.g. `/api/v2/public/coins/base/FLUXGP`). `holdsCoin` reads only the payload, so test it without a wallet: call it with `{ holdings: [{ chain, token, min, held }] }`. `held: true` must pass; `held: false`, another token's `held: true` and `held: null` must not.
174
+ To refuse the whole sign-in instead, answer the same way from `POST /session` when `holdsCoin(payload)` is not `true`, before the session is made.
175
+
176
+ The token address is `tokenAddress` in `GET https://api.chaindaddy.io/api/v2/public/coins/{chain}/{symbol}` (keyless; e.g. `/api/v2/public/coins/base/FLUXGP`). `holdsCoin` reads only the payload, so test it without a wallet: call it with `{ holdings: [{ chain, token, min, held }] }`. `held: true` must give `true`, `held: false` and another token's `held: true` must give `false`, and `held: null` must give `null`.
166
177
 
167
178
  `held` is `null` when the balance could not be read: ask again, never treat it as yes. `{"id_token":{"crowns":{"all":true}}}` lists the crowns they hold.
168
179
 
169
180
  ## 1b. Or register a client (the human does this)
170
181
 
171
182
  Only needed for a **server client with a secret**, the consent screen's **Verified** mark for a coin's own
172
- site, or `http://localhost` redirects during development. Needs Developer Beta Program membership (free to
173
- register). Easiest: the developer portal, https://chaindaddy.io/_developer → **Sign-in** → **New client**. Or:
183
+ site, or `http://localhost` redirects during development. Needs Developer Beta Program membership (any paid
184
+ plan and the signed agreement; a client costs nothing more). Easiest: the developer portal,
185
+ https://chaindaddy.io/_developer → **Sign-in** → **New client**. Or:
174
186
 
175
187
  ```bash
176
188
  chaindaddy oauth client create --name "My Game" \
@@ -248,8 +260,9 @@ browser makes the nonce, so comparing it proves nothing: refuse a `jti` you have
248
260
 
249
261
  ## After sign-in
250
262
 
251
- Gate on what the wallet owns live, on your server: your store's
252
- `GET /api/v2/iap/stores/{id}/entitlements?wallet=<wallets[0]>` (holder unlocks included). Never put a balance in
263
+ The `holder` answer in your session is as of sign-in: sign the visitor in again to refresh it (no click while
264
+ your request is unchanged). For what a wallet owns right now, ask on your server: your store's
265
+ `GET /api/v2/iap/stores/{id}/entitlements?wallet=<wallets[0]>` (holder unlocks included). Never keep a balance in
253
266
  your session: it goes stale in minutes.
254
267
 
255
268
  ## Crown Apps use something else
@@ -267,6 +280,10 @@ wallet: the app token's `sub`, the sign-in token's `wallets[0]`. See `verify-rec
267
280
  - Redirect URIs that differ by a slash or a port: they must match exactly, or chaindaddy.io refuses.
268
281
  - Putting the client secret in a browser, game client or repo.
269
282
  - Asking the visitor to sign a second login message in their wallet. This is the sign-in.
283
+ - Calling the token endpoint or the keys on every page view. Exchange the code once, keep your own session,
284
+ and cache the keys. The limits a normal site never meets: 120 token requests a minute for one person at one
285
+ site, 300 a minute per calling address, 30 approvals a minute per person. Past one the answer is `429` with
286
+ `Retry-After: 60` and `{"error":"temporarily_unavailable"}`: wait and retry, never loop.
270
287
 
271
288
  ## Docs
272
289