@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.
|
|
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,
|
|
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'),
|
|
199
|
+
new URL('https://api.chaindaddy.io'), 'https://YOUR_SITE/client.json',
|
|
141
200
|
{ id_token_signed_response_alg: 'ES256' },
|
|
142
|
-
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://
|
|
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://
|
|
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']`, `
|
|
167
|
-
|
|
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
|
|