@chaindaddy/mcp 0.6.5 → 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.5",
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",
@@ -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.
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.
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,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)
@@ -136,24 +194,27 @@ With a registered client, or with your `client.json` URL as a public client id.
136
194
  ```ts
137
195
  import * as client from 'openid-client';
138
196
 
197
+ // A client.json site: a public client. No secret; PKCE is the proof.
139
198
  const config = await client.discovery(
140
- new URL('https://api.chaindaddy.io'), process.env.CD_CLIENT_ID!,
199
+ new URL('https://api.chaindaddy.io'), 'https://YOUR_SITE/client.json',
141
200
  { id_token_signed_response_alg: 'ES256' },
142
- client.ClientSecretPost(process.env.CD_CLIENT_SECRET!), // client.None() for a public client
201
+ client.None(),
143
202
  );
203
+ // A registered client with a secret, instead: pass its cdc_… id and
204
+ // client.ClientSecretPost(process.env.CD_CLIENT_SECRET!) in place of client.None().
144
205
 
145
206
  // Sign-in: keep verifier, state and nonce in the visitor's session
146
207
  const verifier = client.randomPKCECodeVerifier();
147
208
  const state = client.randomState();
148
209
  const nonce = client.randomNonce();
149
210
  const url = client.buildAuthorizationUrl(config, {
150
- redirect_uri: 'https://example.com/auth/callback', scope: 'openid wallets',
211
+ redirect_uri: 'https://YOUR_SITE/auth/callback', scope: 'openid wallets',
151
212
  code_challenge: await client.calculatePKCECodeChallenge(verifier), code_challenge_method: 'S256',
152
213
  state, nonce,
153
214
  });
154
215
 
155
216
  // Callback
156
- const tokens = await client.authorizationCodeGrant(config, new URL(req.url, 'https://example.com'), {
217
+ const tokens = await client.authorizationCodeGrant(config, new URL(req.url, 'https://YOUR_SITE'), {
157
218
  pkceCodeVerifier: verifier, expectedState: state, expectedNonce: nonce,
158
219
  });
159
220
  const claims = tokens.claims()!;
@@ -163,8 +224,12 @@ const wallet = (claims.wallets as string[])[0]; // the wallet they signed in wit
163
224
  ```
164
225
 
165
226
  Auth.js: a provider with `type: 'oidc'`, `issuer: 'https://api.chaindaddy.io'`,
166
- `checks: ['pkce', 'state', 'nonce']`, `client: { id_token_signed_response_alg: 'ES256' }`, and a `profile()`
167
- that throws unless `typ === 'signin+v1'`.
227
+ `checks: ['pkce', 'state', 'nonce']`, and a `profile()` that throws unless `typ === 'signin+v1'`. For a
228
+ `client.json` site (a public client): `clientId` is the `client.json` URL, no `clientSecret`, and
229
+ `client: { token_endpoint_auth_method: 'none', id_token_signed_response_alg: 'ES256' }`; without `none` Auth.js
230
+ sends client authentication anyway and the token call answers `401 invalid_client`. List Auth.js's own callback
231
+ (`https://YOUR_SITE/api/auth/callback/chaindaddy` in Next.js) in `redirect_uris`. For a registered client with a
232
+ secret: its `cdc_…` id, `clientSecret`, and `client: { id_token_signed_response_alg: 'ES256' }`.
168
233
 
169
234
  ## Scopes and claims
170
235