@chaindaddy/mcp 0.6.8 → 0.6.10

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.8",
3
+ "version": "0.6.10",
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 host, such as `example.com`. Nothing is registered with Chain Daddy. The site must be public, on https, 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
  });
@@ -106,13 +111,19 @@ export async function verifyChainDaddy(idToken: string) {
106
111
  return payload; // sub: the person's id at this site; wallets[0]: the wallet they signed in with
107
112
  }
108
113
 
109
- // 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] ?? '');
114
+ // Your own sessions, by the id in the cookie, for a day. A real deployment keeps these in its session store or Redis.
115
+ const sessions = new Map();
116
+ const DAY = 24 * 60 * 60 * 1000;
117
+ const sessionId = (req) => /(?:^|; )session=([^;]+)/.exec(req.headers.cookie ?? '')?.[1] ?? '';
118
+ const sessionOf = (req) => {
119
+ const user = sessions.get(sessionId(req));
120
+ if (user && user.expires > Date.now()) return user;
121
+ sessions.delete(sessionId(req));
122
+ };
113
123
 
114
124
  const app = express();
115
125
  app.use(express.json());
126
+ app.use(express.static('public')); // client.json, the sign-in page (index.html) and callback.html
116
127
 
117
128
  app.post('/session', async (req, res) => {
118
129
  let payload;
@@ -122,13 +133,13 @@ app.post('/session', async (req, res) => {
122
133
  return res.status(401).json({ error: 'sign_in_failed', message: 'That sign-in could not be checked. Sign in again.' });
123
134
  }
124
135
  const id = randomUUID();
125
- sessions.set(id, { sub: payload.sub as string, wallet: (payload.wallets as string[] | undefined)?.[0] });
126
- res.cookie('session', id, { httpOnly: true, secure: true, sameSite: 'lax' });
136
+ sessions.set(id, { sub: payload.sub, wallet: payload.wallets?.[0], expires: Date.now() + DAY });
137
+ res.cookie('session', id, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: DAY });
127
138
  res.json({ ok: true });
128
139
  });
129
140
 
130
- // Only a signed-in visitor gets this.
131
- app.get('/members', (req, res) => {
141
+ // Who is signed in. The sign-in page shows it.
142
+ app.get('/me', (req, res) => {
132
143
  const user = sessionOf(req);
133
144
  if (!user) return res.status(401).json({ error: 'signed_out', message: 'Sign in first.' });
134
145
  res.json({ sub: user.sub, wallet: user.wallet });
@@ -137,40 +148,47 @@ app.get('/members', (req, res) => {
137
148
  app.listen(3000);
138
149
  ```
139
150
 
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.
151
+ 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
152
 
142
- ```ts
153
+ ```js
143
154
  // What this site gates on, as the ID token writes it: token lowercase for an EVM address, min "0" if none was asked.
144
155
  const GATE = [{ chain: 'base', token: '0xtoken', min: '100' }];
145
156
 
146
- export function holdsCoin(payload: Record<string, unknown>) {
157
+ // true or false, or null when the balance could not be read: not a no, so ask them to sign in again.
158
+ export function holdsCoin(payload) {
147
159
  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));
160
+ const asked = holdings.filter((h) => GATE.some((want) =>
161
+ h?.chain === want.chain && h?.token === want.token && h?.min === want.min));
162
+ if (asked.some((h) => h.held === true)) return true;
163
+ return asked.some((h) => h.held === null) ? null : false;
150
164
  }
151
165
  ```
152
166
 
153
- In `POST /session`, refuse right after `verifyChainDaddy`, so no session is made and the page shows the `message`:
167
+ In `POST /session`, keep the answer in the session: `sessions.set(id, { sub: payload.sub, wallet: payload.wallets?.[0], holder: holdsCoin(payload), expires: Date.now() + DAY });`. Then add the holders' route:
154
168
 
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
- }
169
+ ```js
170
+ // Only a holder gets this. Everyone else stays signed in and is told why not.
171
+ app.get('/members', (req, res) => {
172
+ const user = sessionOf(req);
173
+ if (!user) return res.status(401).json({ error: 'signed_out', message: 'Sign in first.' });
174
+ 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.' });
175
+ if (!user.holder) return res.status(403).json({ error: 'not_a_holder', message: 'This page is for holders of the coin.' });
176
+ res.json({ ok: true }); // your holders-only page
177
+ });
163
178
  ```
164
179
 
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.
180
+ 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.
181
+
182
+ 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
183
 
167
184
  `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
185
 
169
186
  ## 1b. Or register a client (the human does this)
170
187
 
171
188
  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:
189
+ site, or `http://localhost` redirects during development. Needs Developer Beta Program membership (any paid
190
+ plan and the signed agreement; a client costs nothing more). Easiest: the developer portal,
191
+ https://chaindaddy.io/_developer → **Sign-in** → **New client**. Or:
174
192
 
175
193
  ```bash
176
194
  chaindaddy oauth client create --name "My Game" \
@@ -248,8 +266,9 @@ browser makes the nonce, so comparing it proves nothing: refuse a `jti` you have
248
266
 
249
267
  ## After sign-in
250
268
 
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
269
+ The `holder` answer in your session is as of sign-in: sign the visitor in again to refresh it (no click while
270
+ your request is unchanged). For what a wallet owns right now, ask on your server: your store's
271
+ `GET /api/v2/iap/stores/{id}/entitlements?wallet=<wallets[0]>` (holder unlocks included). Never keep a balance in
253
272
  your session: it goes stale in minutes.
254
273
 
255
274
  ## Crown Apps use something else