@chaindaddy/mcp 0.6.6 → 0.6.7

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.6",
3
+ "version": "0.6.7",
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",
@@ -47,11 +47,17 @@ 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="signin-error" role="alert"></p>
50
51
  <script>
51
52
  document.querySelector('cd-signin-button').addEventListener('cd-signin', async (e) => {
52
53
  const { id_token } = await ChainDaddy.exchangeCode(e.detail);
53
- await fetch('/session', { method: 'POST', headers: { 'Content-Type': 'application/json' },
54
+ const res = await fetch('/session', { method: 'POST', headers: { 'Content-Type': 'application/json' },
54
55
  body: JSON.stringify({ id_token }) });
56
+ if (!res.ok) {
57
+ const { message } = await res.json().catch(() => ({}));
58
+ document.querySelector('#signin-error').textContent = message || 'Sign-in did not work. Try again.';
59
+ return;
60
+ }
55
61
  location.reload();
56
62
  });
57
63
  </script>
@@ -61,20 +67,28 @@ Replace `YOUR_SITE` with the site's https origin. Nothing is registered with Cha
61
67
 
62
68
  ```html
63
69
  <script src="https://js.chaindaddy.io/v1/cd.js"></script>
70
+ <p id="signin-error" role="alert"></p>
64
71
  <script>
65
72
  ChainDaddy.handleCallback().then(async (result) => {
66
73
  if (!result) return; // popup: handed back and closed
67
74
  const { id_token } = await ChainDaddy.exchangeCode(result); // popup blocked: finish here
68
- await fetch('/session', { method: 'POST', headers: { 'Content-Type': 'application/json' },
75
+ const res = await fetch('/session', { method: 'POST', headers: { 'Content-Type': 'application/json' },
69
76
  body: JSON.stringify({ id_token }) });
77
+ if (!res.ok) {
78
+ const { message } = await res.json().catch(() => ({}));
79
+ document.querySelector('#signin-error').textContent = message || 'Sign-in did not work. Try again.';
80
+ return;
81
+ }
70
82
  location.replace('/');
71
83
  });
72
84
  </script>
73
85
  ```
74
86
 
75
- 4. On the server, behind `POST /session` (never trust the token in the browser):
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:
76
88
 
77
89
  ```ts
90
+ import express from 'express';
91
+ import { randomUUID } from 'node:crypto';
78
92
  import { createRemoteJWKSet, jwtVerify } from 'jose';
79
93
  const JWKS = createRemoteJWKSet(new URL('https://api.chaindaddy.io/api/v2/oauth/jwks'));
80
94
  // ID tokens already accepted, until they expire. A real deployment keeps these in its session store or Redis.
@@ -91,6 +105,36 @@ export async function verifyChainDaddy(idToken: string) {
91
105
  seen.set(payload.jti, payload.exp ?? now + 300);
92
106
  return payload; // sub: the person's id at this site; wallets[0]: the wallet they signed in with
93
107
  }
108
+
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] ?? '');
113
+
114
+ const app = express();
115
+ app.use(express.json());
116
+
117
+ app.post('/session', async (req, res) => {
118
+ let payload;
119
+ try {
120
+ payload = await verifyChainDaddy(req.body.id_token);
121
+ } catch {
122
+ return res.status(401).json({ error: 'sign_in_failed', message: 'That sign-in could not be checked. Sign in again.' });
123
+ }
124
+ 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' });
127
+ res.json({ ok: true });
128
+ });
129
+
130
+ // Only a signed-in visitor gets this.
131
+ app.get('/members', (req, res) => {
132
+ const user = sessionOf(req);
133
+ if (!user) return res.status(401).json({ error: 'signed_out', message: 'Sign in first.' });
134
+ res.json({ sub: user.sub, wallet: user.wallet });
135
+ });
136
+
137
+ app.listen(3000);
94
138
  ```
95
139
 
96
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.
@@ -106,6 +150,20 @@ export function holdsCoin(payload: Record<string, unknown>) {
106
150
  }
107
151
  ```
108
152
 
153
+ In `POST /session`, refuse right after `verifyChainDaddy`, so no session is made and the page shows the `message`:
154
+
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
+ ```
164
+
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.
166
+
109
167
  `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.
110
168
 
111
169
  ## 1b. Or register a client (the human does this)