@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.
|
|
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
|
|
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
|
-
```
|
|
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
|
|
100
|
+
const seen = new Map();
|
|
96
101
|
|
|
97
|
-
|
|
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
|
|
111
|
-
const sessionOf = (req
|
|
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
|
|
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
|
-
//
|
|
131
|
-
app.get('/
|
|
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
|
|
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
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
h?.chain === want.chain && h?.token === want.token && h?.min === want.min
|
|
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`,
|
|
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
|
-
```
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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 (
|
|
173
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
|