@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.
|
|
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
|
|
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
|
|
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
|
});
|
|
@@ -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
|
|
111
|
-
const
|
|
112
|
-
|
|
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
|
|
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
|
-
//
|
|
131
|
-
app.get('/
|
|
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
|
|
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
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
h?.chain === want.chain && h?.token === want.token && h?.min === want.min
|
|
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`,
|
|
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
|
-
```
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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 (
|
|
173
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|