@mysten-incubation/sui-agent-pass 0.0.0 → 0.1.0

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/dist/plugin.js ADDED
@@ -0,0 +1,795 @@
1
+ // Copyright (c) Mysten Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * The Claude Code plugin's MCP: one stdio process that proposes passes for the owner to sign
5
+ * in the browser, reports their state, signs the agent's transactions and opens the owner
6
+ * page for revocation. Owner consent stays in the browser; this process never builds an
7
+ * agent transaction and never holds an owner key.
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ import { randomUUID } from 'node:crypto';
11
+ import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
12
+ import { createServer } from 'node:http';
13
+ import { homedir } from 'node:os';
14
+ import { basename, dirname, join, resolve } from 'node:path';
15
+ import { setTimeout as sleep } from 'node:timers/promises';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
20
+ import { Transaction } from '@mysten/sui/transactions';
21
+ import { fromBase64, normalizeSuiAddress } from '@mysten/sui/utils';
22
+ import { z } from 'zod';
23
+ import { clientFor, deepbookSizes, readStatus, withMarketFacts } from './chain.js';
24
+ import { describePolicy, describeSetup, renderRows } from './describe.js';
25
+ import { address, coinBySymbol, coinInfo, DAY_MS, defaultGasDaily, DeploymentSchema, display, poolsFor, priceUnits, recipientName, sameCoin, SetupSchema, showCoin, units, VenueSchema, } from './domain.js';
26
+ import { passesByAgent } from './explorer.js';
27
+ import { passEnded, returnGasFloat } from './float.js';
28
+ import { failure, INSTRUCTIONS, registerDocs, result, sessionInfo, SIGN_ANNOTATIONS, SIGN_DESCRIPTION, } from './mcp.js';
29
+ import { authenticated, body, ownerPageUrl, startServer } from './server.js';
30
+ import { SessionSigner } from './signer.js';
31
+ import { SessionStore, token } from './storage.js';
32
+ export const defaultRoot = () => join(homedir(), '.sui-agent-pass');
33
+ export function openBrowser(url) {
34
+ spawn(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], {
35
+ stdio: 'ignore',
36
+ }).on('error', () => { });
37
+ }
38
+ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
39
+ /**
40
+ * How to start this CLI again from another process: the compiled entry under node, or, when
41
+ * running from source through tsx, the source entry through tsx.
42
+ */
43
+ export function cliInvocation() {
44
+ const entry = fileURLToPath(new URL('./cli.js', import.meta.url));
45
+ return entry.endsWith('.js') && !import.meta.url.endsWith('.ts')
46
+ ? [process.execPath, entry]
47
+ : [join(repoRoot, 'node_modules/.bin/tsx'), entry.replace(/\.js$/, '.ts')];
48
+ }
49
+ const exists = (path) => access(path).then(() => true, () => false);
50
+ /** Every session directory under the root, opened lazily and kept for the process lifetime. */
51
+ class Registry {
52
+ options;
53
+ sessions = new Map();
54
+ constructor(options) {
55
+ this.options = options;
56
+ }
57
+ get sessionsDir() {
58
+ return join(this.options.root, 'sessions');
59
+ }
60
+ /** Sessions on disk this code cannot open, with what their files still say about the pass. */
61
+ unreadable = new Map();
62
+ async all() {
63
+ await mkdir(this.sessionsDir, { recursive: true, mode: 0o700 });
64
+ for (const id of (await readdir(this.sessionsDir)).sort()) {
65
+ if (this.sessions.has(id))
66
+ continue;
67
+ try {
68
+ this.sessions.set(id, {
69
+ id,
70
+ store: await SessionStore.open(join(this.sessionsDir, id), (network, packageId) => this.current(network, packageId)),
71
+ });
72
+ this.unreadable.delete(id);
73
+ }
74
+ catch (error) {
75
+ // An earlier state version or a damaged file: listed as unreadable, never fatal, since
76
+ // its pass may still be live on chain and the site can close it from there.
77
+ this.unreadable.set(id, {
78
+ id,
79
+ problem: error.message,
80
+ ...(await SessionStore.peek(join(this.sessionsDir, id))),
81
+ });
82
+ }
83
+ }
84
+ return [...this.sessions.values()];
85
+ }
86
+ /** By id prefix, pass object id prefix, or the only sensible candidate. */
87
+ async find(selector) {
88
+ const all = await this.all();
89
+ if (selector) {
90
+ const wanted = selector.toLowerCase();
91
+ const hits = all.filter((s) => s.id.startsWith(wanted) || s.store.state.active?.passId.startsWith(wanted));
92
+ if (hits.length === 1)
93
+ return hits[0];
94
+ throw new Error(hits.length
95
+ ? `Several passes match ${selector}; use a longer id`
96
+ : `No pass matches ${selector}; call list_passes`);
97
+ }
98
+ if (all.length === 1)
99
+ return all[0];
100
+ const active = all.filter((s) => s.store.state.active);
101
+ if (active.length === 1)
102
+ return active[0];
103
+ throw new Error(all.length
104
+ ? 'Several passes exist; pass the pass id from list_passes'
105
+ : 'No pass yet; call propose_authorization first');
106
+ }
107
+ async create(deployment, setup) {
108
+ await mkdir(this.sessionsDir, { recursive: true, mode: 0o700 });
109
+ const id = randomUUID();
110
+ const session = {
111
+ id,
112
+ store: await SessionStore.create(join(this.sessionsDir, id), deployment, setup),
113
+ };
114
+ this.sessions.set(id, session);
115
+ return session;
116
+ }
117
+ /** The owner page and per-session HTTP signer, started on first use. */
118
+ async server(session) {
119
+ if (!session.server) {
120
+ session.server = await startServer(session.store);
121
+ // A watcher (a recorder, a second pane) can point at the review page without the token
122
+ // passing through the model. Same directory and mode as mcp.token.
123
+ await writeFile(join(session.store.directory, 'owner-url.txt'), session.server.ownerUrl + '\n', { mode: 0o600 });
124
+ }
125
+ return session.server;
126
+ }
127
+ async signer(session) {
128
+ session.signer ??= new SessionSigner(await session.store.key(), session.store.state.deployment.chainIdentifier);
129
+ return session.signer;
130
+ }
131
+ async close() {
132
+ await Promise.all([...this.sessions.values()].map((s) => s.server?.close()));
133
+ }
134
+ deploymentPath(override) {
135
+ return resolve(override ?? this.options.deployment ?? join(this.options.root, 'deployment.json'));
136
+ }
137
+ /** The networks this package ships a deployment for, in the order a new pass defaults to. */
138
+ async bundledNetworks() {
139
+ const all = ['mainnet', 'testnet', 'devnet'];
140
+ const present = await Promise.all(all.map((n) => exists(this.bundledPath(n))));
141
+ return all.filter((_, i) => present[i]);
142
+ }
143
+ bundledPath(network) {
144
+ return fileURLToPath(new URL(`../deployments/${network}.json`, import.meta.url));
145
+ }
146
+ /** Today's deployment for a network when it is the same package: the private file first, else the bundled one. */
147
+ async current(network, packageId) {
148
+ const candidates = [
149
+ () => this.deployment().catch(() => null),
150
+ () => this.bundled(network).catch(() => null),
151
+ ];
152
+ for (const read of candidates) {
153
+ const d = await read();
154
+ if (d &&
155
+ d.network === network &&
156
+ normalizeSuiAddress(d.packageId) === normalizeSuiAddress(packageId))
157
+ return d;
158
+ }
159
+ return null;
160
+ }
161
+ /**
162
+ * Retires a session directory once its pass is over: refuses while the pass is live, returns
163
+ * the key's gas float to the owner first, then deletes the directory and its key. Works on a
164
+ * session this code cannot open (an earlier state version), from what its file still says.
165
+ */
166
+ async forget(id) {
167
+ const dir = join(this.sessionsDir, id);
168
+ const session = this.sessions.get(id);
169
+ const peek = await SessionStore.peek(dir);
170
+ const d = session?.store.state.deployment ??
171
+ (peek.network && peek.packageId ? await this.current(peek.network, peek.packageId) : null);
172
+ if (peek.passId) {
173
+ if (!d)
174
+ throw new Error('No deployment known for this session; cannot tell whether its pass is live');
175
+ if (!(await passEnded(clientFor(d), d, peek.passId)))
176
+ throw new Error('This pass is live; stop or close it from its page first');
177
+ }
178
+ let swept = null;
179
+ if (peek.passId && peek.owner && peek.sessionAddress && d)
180
+ swept = await returnGasFloat(clientFor(d), dir, peek.sessionAddress, peek.owner);
181
+ await session?.server?.close();
182
+ this.sessions.delete(id);
183
+ this.unreadable.delete(id);
184
+ await SessionStore.forget(dir);
185
+ return { swept };
186
+ }
187
+ async bundled(network) {
188
+ return DeploymentSchema.parse(JSON.parse(await readFile(this.bundledPath(network), 'utf8')));
189
+ }
190
+ /**
191
+ * Where a new pass goes: an explicit file, else the private `deployment.json` under the root
192
+ * (a publish of one's own), else the deployment bundled for `network`, else the first bundled
193
+ * one. A user who installed the package has only the bundled ones and needs no file.
194
+ */
195
+ async deployment(override, network) {
196
+ const configured = this.deploymentPath(override);
197
+ if (override || this.options.deployment || (await exists(configured)))
198
+ return DeploymentSchema.parse(JSON.parse(await readFile(configured, 'utf8')));
199
+ const bundled = await this.bundledNetworks();
200
+ const chosen = network ?? bundled[0];
201
+ if (!chosen || !(network === undefined || bundled.includes(network)))
202
+ throw new Error(`No deployment for ${network ?? 'any network'}: this package ships ${bundled.join(', ') || 'none'}, and there is no ${configured}.`);
203
+ return this.bundled(chosen);
204
+ }
205
+ }
206
+ const decimal = z.string().regex(/^[0-9]+(\.[0-9]+)?$/, 'decimal number');
207
+ const symbol = z
208
+ .string()
209
+ .min(1)
210
+ .max(120)
211
+ .describe('a coin: its symbol when list_passes lists it for the network (e.g. SUI), otherwise its full type (0x…::module::NAME); the page then reads its metadata from chain');
212
+ const rulesInput = {
213
+ transfers: z
214
+ .array(z
215
+ .object({
216
+ coin: symbol,
217
+ recipients: z.array(z.object({ name: recipientName, address }).strict()).min(1),
218
+ perTransfer: decimal.describe('coin per transfer'),
219
+ daily: decimal.describe('coin per UTC day'),
220
+ })
221
+ .strict())
222
+ .default([])
223
+ .describe('one rule per coin the agent may pay out; empty forbids transfers'),
224
+ swaps: z
225
+ .array(z
226
+ .object({
227
+ sell: symbol,
228
+ buy: symbol,
229
+ venues: z
230
+ .array(VenueSchema)
231
+ .optional()
232
+ .describe('which listed pools for the pair; omit for every pool the deployment lists'),
233
+ pools: z
234
+ .array(address)
235
+ .optional()
236
+ .describe('pool object ids for a pair the deployment does not list (a DeepBook or Cetus pool holding exactly these two coins); verified on chain'),
237
+ perTrade: decimal.describe('sold coin per trade'),
238
+ daily: decimal.describe('sold coin per UTC day'),
239
+ minPrice: decimal.describe('lowest price accepted, in bought coin per one sold coin; required, above 0'),
240
+ })
241
+ .strict())
242
+ .default([])
243
+ .describe('one rule per direction the agent may trade; empty forbids swaps'),
244
+ lending: z
245
+ .array(z
246
+ .object({
247
+ coin: symbol,
248
+ allowBorrow: z
249
+ .boolean()
250
+ .describe('may also borrow and repay this coin, not only supply and withdraw it'),
251
+ perAction: decimal.describe('coin moved per action'),
252
+ daily: decimal.describe('coin moved per UTC day'),
253
+ minHealthFactor: z
254
+ .number()
255
+ .min(1)
256
+ .max(100)
257
+ .nullable()
258
+ .describe('withdraws and borrows keep the NAVI health factor above this; null for no floor'),
259
+ })
260
+ .strict())
261
+ .default([])
262
+ .describe('one rule per coin the agent may lend on NAVI; empty forbids lending'),
263
+ staking: z
264
+ .object({
265
+ validators: z
266
+ .array(address)
267
+ .describe('validator addresses the agent may stake with; empty = any active validator'),
268
+ perStake: decimal.describe('SUI per stake; the system minimum is 1'),
269
+ daily: decimal.describe('SUI staked per UTC day'),
270
+ })
271
+ .strict()
272
+ .nullable()
273
+ .default(null)
274
+ .describe('null forbids staking'),
275
+ caps: z
276
+ .record(symbol, decimal)
277
+ .describe("lifetime cap per coin the rules spend, by symbol: the most the agent may ever withdraw of it from the owner's address balance; the review page converts the owner's coins into that balance as far as the caps need. Exactly the spent coins, no others"),
278
+ };
279
+ /**
280
+ * Human amounts and coin names from the conversation become the exact base units and types the
281
+ * chain will store. A coin outside the deployment's list is named by type and its decimals come
282
+ * from chain metadata; a pool outside the list is named by id and its venue and coin order from
283
+ * its object. Returns the deployment extended with those facts, which the session keeps.
284
+ */
285
+ async function toSetup(client, d0, input) {
286
+ const isType = (s) => /::/.test(s);
287
+ const named = [
288
+ ...input.transfers.map((t) => t.coin),
289
+ ...input.swaps.flatMap((sw) => [sw.sell, sw.buy]),
290
+ ...input.lending.map((l) => l.coin),
291
+ ...Object.keys(input.caps),
292
+ ];
293
+ const d = await withMarketFacts(client, d0, {
294
+ coins: named.filter(isType),
295
+ pools: input.swaps.flatMap((sw) => sw.pools ?? []),
296
+ });
297
+ const coin = (s) => (isType(s) ? coinInfo(d, s) : coinBySymbol(d, s)), inUnits = (s, v) => units(v, coin(s).decimals), sui = (v) => units(v, 9);
298
+ const ttlMs = input.days * DAY_MS, gasCap = sui(input.gasCapSui);
299
+ const setup = SetupSchema.parse({
300
+ name: input.name,
301
+ policy: {
302
+ transfers: input.transfers.map((t) => ({
303
+ coin: coin(t.coin).type,
304
+ recipients: t.recipients,
305
+ perTransfer: inUnits(t.coin, t.perTransfer),
306
+ daily: inUnits(t.coin, t.daily),
307
+ })),
308
+ swaps: input.swaps.map((sw) => {
309
+ const coinIn = coin(sw.sell).type, coinOut = coin(sw.buy).type;
310
+ const pools = poolsFor(d, coinIn, coinOut).filter((p) => (!sw.venues || sw.venues.includes(p.venue)) &&
311
+ (!sw.pools || sw.pools.some((id) => normalizeSuiAddress(id) === p.poolId)));
312
+ if (!pools.length)
313
+ throw new Error(`No pool trades ${sw.sell} for ${sw.buy}${sw.venues ? ` on ${sw.venues.join(', ')}` : ''}${sw.pools ? ` among ${sw.pools.join(', ')}` : ''}; name a DeepBook or Cetus pool id for this pair in pools`);
314
+ return {
315
+ coinIn,
316
+ coinOut,
317
+ pools: pools.map((p) => p.poolId),
318
+ perTrade: inUnits(sw.sell, sw.perTrade),
319
+ daily: inUnits(sw.sell, sw.daily),
320
+ minPrice: priceUnits(d, coinIn, coinOut, sw.minPrice),
321
+ };
322
+ }),
323
+ lending: input.lending.map((l) => ({
324
+ coin: coin(l.coin).type,
325
+ allowBorrow: l.allowBorrow,
326
+ perAction: inUnits(l.coin, l.perAction),
327
+ daily: inUnits(l.coin, l.daily),
328
+ minHealthFactor: l.minHealthFactor,
329
+ })),
330
+ staking: input.staking && {
331
+ validators: input.staking.validators,
332
+ perStake: sui(input.staking.perStake),
333
+ daily: sui(input.staking.daily),
334
+ },
335
+ },
336
+ ttlMs,
337
+ expiresMs: Date.now() + ttlMs,
338
+ allowances: Object.fromEntries(Object.entries(input.caps).map(([s, v]) => [coin(s).type, inUnits(s, v)])),
339
+ gasAmount: sui(input.gasSui),
340
+ gasCap,
341
+ gasDaily: input.gasDailySui ? sui(input.gasDailySui) : defaultGasDaily(gasCap),
342
+ });
343
+ return { setup, d };
344
+ }
345
+ function summary(s) {
346
+ const { state } = s.store;
347
+ return {
348
+ pass: s.id,
349
+ name: state.setup.name,
350
+ network: state.deployment.network,
351
+ sessionAddress: state.sessionAddress,
352
+ authorized: !!state.active,
353
+ passId: state.active?.passId ?? null,
354
+ directory: s.store.directory,
355
+ };
356
+ }
357
+ /**
358
+ * The summary plus what the chain says about the key: every pass created for it. A key is
359
+ * meant to sign one authorization, so a second pass here is a proposal signed twice, live on
360
+ * chain with its own allowances, which the session file knows nothing about.
361
+ */
362
+ async function summaryWithChain(s) {
363
+ const base = summary(s), { state } = s.store, client = clientFor(state.deployment);
364
+ const created = await passesByAgent(client, state.deployment, state.sessionAddress).catch(() => null);
365
+ // A creation record outlives the pass; only an existing object is a live pass.
366
+ const onChain = created
367
+ ? await Promise.all(created.map(async (c) => ({
368
+ id: c.id,
369
+ live: await client.getObject({ objectId: c.id }).then(() => true, () => false),
370
+ })))
371
+ : null;
372
+ const own = onChain?.find((c) => c.id === state.active?.passId);
373
+ const closed = !!state.active && !!onChain && !own?.live;
374
+ const extra = onChain?.filter((c) => c.live && c.id !== state.active?.passId) ?? [];
375
+ return {
376
+ ...base,
377
+ ...(closed
378
+ ? {
379
+ status: 'closed',
380
+ note: 'The owner closed this pass; its key has nothing left to act on.',
381
+ }
382
+ : {}),
383
+ passesOnChain: onChain,
384
+ ...(extra.length
385
+ ? {
386
+ warning: `This key is the agent of ${extra.length + (own?.live ? 1 : 0)} live passes, not one: ${extra
387
+ .map((c) => c.id)
388
+ .join(', ')} were created by signing the same proposal again. Each is live with its own allowances and gas until the owner stops or closes it from its pass page (#/pass/<id>).`,
389
+ }
390
+ : {}),
391
+ };
392
+ }
393
+ /** `forget_pass` for one session directory, from outside the MCP: the CLI and tests. */
394
+ export async function forgetSession(directory) {
395
+ const registry = new Registry({ root: dirname(dirname(resolve(directory))) });
396
+ await registry.all();
397
+ return registry.forget(basename(resolve(directory)));
398
+ }
399
+ export function createPluginMcp(registry) {
400
+ const server = new McpServer({ name: 'agent-pass', version: '0.1.0' }, {
401
+ instructions: `${INSTRUCTIONS} This server also runs the owner flow: propose_authorization turns reviewed rules into a pass proposal and opens the owner's wallet page; session_info (with wait) reports when the owner has signed and the on-chain policies match; revoke_pass reopens the owner page for revocation. Show the owner the exact "rules" text returned by propose_authorization before they sign; never paraphrase amounts.`,
402
+ });
403
+ registerDocs(server);
404
+ server.registerTool('list_passes', {
405
+ description: 'List the pass sessions on this machine (id, name, network, whether the owner has authorized it on chain) and the networks a new pass can live on, each with the coins and pools its rules may name.',
406
+ inputSchema: {},
407
+ annotations: { readOnlyHint: true },
408
+ }, async () => {
409
+ const path = registry.deploymentPath();
410
+ const markets = async (d) => ({
411
+ network: d.network,
412
+ coins: d.coins,
413
+ pools: d.pools.map((p) => ({
414
+ venue: p.venue,
415
+ pair: `${coinInfo(d, p.coinA).symbol}/${coinInfo(d, p.coinB).symbol}`,
416
+ })),
417
+ lending: Object.keys(d.protocols.navi.reserves).map((c) => coinInfo(d, c).symbol),
418
+ });
419
+ const own = (await exists(path)) ? [await markets(await registry.deployment())] : [];
420
+ const bundled = await Promise.all((await registry.bundledNetworks()).map(async (n) => markets(await registry.bundled(n))));
421
+ const passes = await Promise.all((await registry.all()).map(summaryWithChain));
422
+ const unreadable = [...registry.unreadable.values()];
423
+ return result({
424
+ passes,
425
+ ...(unreadable.length
426
+ ? {
427
+ unreadable,
428
+ unreadableNote: 'These sessions were written by an earlier version of this package and cannot be used from here. Their passes may still be live: tell the owner, and have them stop or close each one from the pass page of the site (#/pass/<passId>), which reads the chain and needs no session file.',
429
+ }
430
+ : {}),
431
+ deployment: { path, exists: own.length > 0 },
432
+ networks: [...own, ...bundled.filter((b) => !own.some((o) => o.network === b.network))],
433
+ });
434
+ });
435
+ server.registerTool('propose_authorization', {
436
+ description: "Create a new pass proposal from rules in human units (coins by symbol when list_passes lists them for the network, else by full type, with pool ids for a pair the network list lacks; amounts as decimal strings) and open the owner's review page, where they connect Slush, check the same rules and sign one transaction. That transaction creates the pass, converts the owner's coins into their own address balance as far as the caps need, issues the allowances the agent will draw on, installs the policies and gives the new agent key its gas; the owner's coins never enter the pass. Returns the exact rules text to show the owner, the review URL and the session address. Nothing is on chain until the owner signs; poll session_info with wait afterwards.",
437
+ inputSchema: {
438
+ name: z
439
+ .string()
440
+ .regex(/^[\x20-\x7e]{1,64}$/)
441
+ .default('Agent session'),
442
+ ...rulesInput,
443
+ days: z.number().int().min(1).max(30).describe('agent term'),
444
+ gasSui: decimal
445
+ .default('0.05')
446
+ .describe('gas stipend to the agent address, outside the rules'),
447
+ gasCapSui: decimal
448
+ .default('0.1')
449
+ .describe('lifetime SUI the agent may pull to top up its gas, a separate allowance'),
450
+ gasDailySui: decimal
451
+ .optional()
452
+ .describe('SUI the agent may pull for gas per UTC day; defaults to a fifth of gasCapSui'),
453
+ network: z
454
+ .enum(['mainnet', 'testnet', 'devnet', 'localnet'])
455
+ .optional()
456
+ .describe('which network the pass lives on, from the deployments this package ships; omit for the default (a private deployment.json under the root, else the first shipped network)'),
457
+ deployment: z
458
+ .string()
459
+ .optional()
460
+ .describe('path of a private deployment.json; overrides network'),
461
+ },
462
+ annotations: { readOnlyHint: false, destructiveHint: false },
463
+ }, async ({ deployment, network, ...input }) => {
464
+ try {
465
+ const d0 = await registry.deployment(deployment, network);
466
+ const { setup, d } = await toSetup(clientFor(d0), d0, input);
467
+ const session = await registry.create(d, setup);
468
+ const { ownerUrl } = await registry.server(session);
469
+ if (registry.options.openBrowser !== false)
470
+ openBrowser(ownerUrl);
471
+ const rows = describeSetup(d, setup);
472
+ const warnings = [];
473
+ for (const sw of setup.policy.swaps) {
474
+ const pools = d.pools.filter((p) => sw.pools.includes(p.poolId));
475
+ if (pools.some((p) => p.venue === 'cetus'))
476
+ continue;
477
+ for (const pool of pools.filter((p) => p.venue === 'deepbook')) {
478
+ if (!sameCoin(pool.coinA, sw.coinIn))
479
+ continue;
480
+ // The fee comes off the input before the lot rounding, so the usable minimum is one
481
+ // lot above the pool's.
482
+ const floor = await deepbookSizes(clientFor(d), d, pool)
483
+ .then((x) => (BigInt(x.minSize) + BigInt(x.lotSize)).toString())
484
+ .catch(() => null);
485
+ if (floor && BigInt(sw.perTrade) < BigInt(floor))
486
+ warnings.push(`DeepBook takes its fee from the input and fills nothing under its minimum order, so a sale needs at least ${showCoin(d, sw.coinIn, floor)} and this pass allows ${showCoin(d, sw.coinIn, sw.perTrade)}: no sale could ever happen. Propose again with Cetus added to venues or a larger perTrade, unless the owner wants exactly this.`);
487
+ }
488
+ }
489
+ return result({
490
+ pass: session.id,
491
+ sessionAddress: session.store.state.sessionAddress,
492
+ reviewUrl: ownerUrl,
493
+ network: d.network,
494
+ rules: renderRows(rows),
495
+ rows,
496
+ warnings,
497
+ next: "The review page opened in the owner's browser (open reviewUrl if it did not). The owner connects Slush, compares the rules with the text above and signs. Then call session_info with this pass id and wait until it reports authorized.",
498
+ });
499
+ }
500
+ catch (error) {
501
+ return failure(error, 'Proposal failed');
502
+ }
503
+ });
504
+ server.registerTool('session_info', {
505
+ description: "Read one pass session: before authorization, the pending rules and review URL (pass wait to block up to 120 seconds for the owner's signature); afterwards the deployment, session address, installed policies with today's usage, the owner's balances and allowances, agent term, venue directions, the session directory and the SDK install command. Act with a Node script against the SDK (Agent.open(directory), then submit a builder's transaction), or build your own PTB and call sign_transaction, then submit to Sui yourself.",
506
+ inputSchema: {
507
+ pass: z
508
+ .string()
509
+ .optional()
510
+ .describe('pass id prefix from list_passes; omit if there is one'),
511
+ wait: z
512
+ .number()
513
+ .int()
514
+ .min(0)
515
+ .max(120)
516
+ .default(0)
517
+ .describe('seconds to wait for authorization'),
518
+ },
519
+ annotations: { readOnlyHint: true },
520
+ }, async ({ pass, wait }) => {
521
+ try {
522
+ const session = await registry.find(pass);
523
+ const { store } = session, { setup, deployment: d } = store.state;
524
+ const deadline = Date.now() + wait * 1000;
525
+ for (;;) {
526
+ await store.refresh();
527
+ if ((store.state.active && !store.state.pendingChange) || Date.now() >= deadline)
528
+ break;
529
+ await sleep(1000);
530
+ }
531
+ if (!store.state.active) {
532
+ const { ownerUrl } = await registry.server(session);
533
+ return result({
534
+ ...summary(session),
535
+ status: 'awaiting_authorization',
536
+ reviewUrl: ownerUrl,
537
+ rules: renderRows(describeSetup(d, setup)),
538
+ });
539
+ }
540
+ const { mcpUrl } = await registry.server(session);
541
+ const exists = await clientFor(d)
542
+ .getObject({ objectId: store.state.active.passId })
543
+ .then(() => true, () => false);
544
+ if (!exists)
545
+ return result({
546
+ ...summary(session),
547
+ status: 'closed',
548
+ note: 'The owner closed this pass: the object, its policies and allowances are gone and the storage deposit was refunded. The key has nothing left to act on.',
549
+ });
550
+ const info = await sessionInfo({
551
+ deployment: d,
552
+ sessionAddress: store.state.sessionAddress,
553
+ active: store.state.active,
554
+ }, store.directory);
555
+ const expired = Number(info.control.agentExpiresMs) <= info.control.chainTimeMs;
556
+ const pending = store.state.pendingChange;
557
+ return result({
558
+ ...summary(session),
559
+ status: info.revoked ? 'revoked' : expired ? 'expired' : pending ? 'changing' : 'active',
560
+ rules: renderRows(describePolicy(d, store.state.setup.policy, info.control.policies)),
561
+ ...(pending
562
+ ? {
563
+ proposedRules: renderRows(describeSetup(d, pending)),
564
+ note: 'A rule change awaits the owner on the review page; the rules above are the ones in force.',
565
+ }
566
+ : {}),
567
+ ...info,
568
+ mcpUrl,
569
+ });
570
+ }
571
+ catch (error) {
572
+ return failure(error, 'Status unavailable');
573
+ }
574
+ });
575
+ server.registerTool('sign_transaction', {
576
+ description: `${SIGN_DESCRIPTION} The signing key is chosen by the transaction's sender, which must be the session address of an authorized pass.`,
577
+ inputSchema: { transactionBytes: z.string().min(1).max(180_000) },
578
+ annotations: SIGN_ANNOTATIONS,
579
+ }, async ({ transactionBytes }) => {
580
+ try {
581
+ const sender = Transaction.from(fromBase64(transactionBytes)).getData().sender;
582
+ if (!sender)
583
+ throw new Error('Transaction has no sender');
584
+ const session = (await registry.all()).find((s) => s.store.state.sessionAddress === normalizeSuiAddress(sender));
585
+ if (!session)
586
+ throw new Error('Sender is not the session address of any pass');
587
+ if (!session.store.state.active)
588
+ throw new Error('The owner has not authorized this pass yet');
589
+ return result(await (await registry.signer(session)).sign(transactionBytes));
590
+ }
591
+ catch (error) {
592
+ return failure(error, 'Signing failed');
593
+ }
594
+ });
595
+ server.registerTool('propose_change', {
596
+ description: "Propose new rules for an existing, authorized pass and open the owner's review page, which shows the current rules beside the proposed ones. One owner signature replaces the policies and reissues only the allowances whose cap or term changed; the agent key, its gas and anything the pass holds stay. Give the complete new rule set, not a diff. Nothing changes until the owner signs; poll session_info with wait until status is active again.",
597
+ inputSchema: {
598
+ pass: z
599
+ .string()
600
+ .optional()
601
+ .describe('pass id prefix from list_passes; omit if there is one'),
602
+ ...rulesInput,
603
+ days: z.number().int().min(1).max(30).describe('agent term from now'),
604
+ gasCapSui: decimal.describe('lifetime SUI the agent may pull to top up its gas'),
605
+ gasDailySui: decimal.optional().describe('per UTC day; defaults to a fifth of gasCapSui'),
606
+ },
607
+ annotations: { readOnlyHint: false, destructiveHint: false },
608
+ }, async ({ pass, ...input }) => {
609
+ try {
610
+ const session = await registry.find(pass);
611
+ const { store } = session;
612
+ if (!store.state.active)
613
+ throw new Error('This pass is not authorized yet; propose_authorization is still open');
614
+ const d0 = store.state.deployment;
615
+ const { setup, d } = await toSetup(clientFor(d0), d0, {
616
+ ...input,
617
+ name: store.state.setup.name,
618
+ gasSui: display(store.state.setup.gasAmount, 9),
619
+ });
620
+ await store.proposeChange(setup);
621
+ const { origin } = await registry.server(session);
622
+ const reviewUrl = ownerPageUrl(d, origin, {
623
+ v: 1,
624
+ sessionAddress: store.state.sessionAddress,
625
+ setup,
626
+ passId: store.state.active.passId,
627
+ });
628
+ if (registry.options.openBrowser !== false)
629
+ openBrowser(reviewUrl);
630
+ const before = describeSetup(d, store.state.setup), after = describeSetup(d, setup);
631
+ const changes = after
632
+ .filter((r) => before.find((b) => b.label === r.label)?.value !== r.value)
633
+ .concat(before
634
+ .filter((b) => !after.some((r) => r.label === b.label))
635
+ .map((b) => ({ label: b.label, value: `removed (was ${b.value})` })));
636
+ return result({
637
+ pass: session.id,
638
+ reviewUrl,
639
+ rules: renderRows(after),
640
+ changes: renderRows(changes),
641
+ next: 'Show the owner the rules text and what changes, send them to the review page, and poll session_info with wait until status is active again. Until then the old rules are in force.',
642
+ });
643
+ }
644
+ catch (error) {
645
+ return failure(error, 'Cannot propose the change');
646
+ }
647
+ });
648
+ server.registerTool('forget_pass', {
649
+ description: "Retire a session whose pass is over: refuses while the pass is live, returns the key's unspent gas float to the owner, then deletes the session directory and its key. Works on sessions an earlier version wrote (list_passes shows them as unreadable). Offer this for every pass list_passes reports as closed or stopped, and for proposals the owner never signed.",
650
+ inputSchema: {
651
+ pass: z
652
+ .string()
653
+ .describe('session id (or prefix) from list_passes, readable or unreadable'),
654
+ },
655
+ annotations: { readOnlyHint: false, destructiveHint: true },
656
+ }, async ({ pass }) => {
657
+ try {
658
+ await registry.all();
659
+ const ids = [...registry.unreadable.keys(), ...(await registry.all()).map((s) => s.id)];
660
+ const hits = ids.filter((id) => id.startsWith(pass));
661
+ if (hits.length !== 1)
662
+ throw new Error(hits.length ? `${hits.length} sessions match ${pass}` : `No session matches ${pass}`);
663
+ const { swept } = await registry.forget(hits[0]);
664
+ return result({
665
+ forgotten: hits[0],
666
+ swept,
667
+ note: swept
668
+ ? `Returned ${swept.amount} MIST of gas to the owner (${swept.digest}) and deleted the session.`
669
+ : 'Nothing left to return; the session is deleted.',
670
+ });
671
+ }
672
+ catch (error) {
673
+ return failure(error, 'Cannot forget this session');
674
+ }
675
+ });
676
+ server.registerTool('revoke_pass', {
677
+ description: "Open a pass's page so the owner can connect their wallet and end it: Stop the agent now (revoke, instant, keeps the pass on chain) or Close the pass (revoke, hand back held objects, delete everything and refund the storage deposit). Nothing happens until the owner signs; this call only opens the page. Confirm afterwards with session_info, which reports status revoked.",
678
+ inputSchema: {
679
+ pass: z
680
+ .string()
681
+ .optional()
682
+ .describe('pass id prefix from list_passes; omit to open the owner page listing every pass of the owner, where they can close several in one signature'),
683
+ },
684
+ annotations: { readOnlyHint: true },
685
+ }, async ({ pass }) => {
686
+ try {
687
+ if (!pass) {
688
+ const owners = new Map();
689
+ for (const s of await registry.all())
690
+ if (s.store.state.active)
691
+ owners.set(s.store.state.active.owner, s);
692
+ if (owners.size !== 1)
693
+ throw new Error(owners.size
694
+ ? 'Passes here belong to several owners; name the pass'
695
+ : 'No authorized pass on this machine');
696
+ const [owner, s] = [...owners.entries()][0];
697
+ const d = s.store.state.deployment;
698
+ const { origin } = await registry.server(s);
699
+ const ownerUrl = `${(d.siteUrl ?? origin).replace(/\/$/, '')}/?network=${d.network}#/owner/${owner}`;
700
+ if (registry.options.openBrowser !== false)
701
+ openBrowser(ownerUrl);
702
+ return result({
703
+ ownerUrl,
704
+ next: 'Ask the owner to connect the wallet that created the passes, pick the ones to close and sign once; one transaction closes them all and refunds their storage. To stop one pass without closing it, open that pass by naming it here.',
705
+ });
706
+ }
707
+ const session = await registry.find(pass);
708
+ if (!session.store.state.active)
709
+ throw new Error('This pass was never authorized; nothing to revoke');
710
+ const { passUrl } = await registry.server(session);
711
+ if (!passUrl)
712
+ throw new Error('This pass was never authorized; nothing to revoke');
713
+ if (registry.options.openBrowser !== false)
714
+ openBrowser(passUrl);
715
+ const d = session.store.state.deployment, active = session.store.state.active;
716
+ const status = await readStatus(clientFor(d), d, active, session.store.state.sessionAddress);
717
+ return result({
718
+ ...summary(session),
719
+ alreadyRevoked: status.revoked,
720
+ passUrl,
721
+ next: status.revoked
722
+ ? 'Already revoked on chain. Close the pass from the same page to get the storage deposit back.'
723
+ : 'Ask the owner to connect the wallet that created the pass and pick an ending: Stop the agent now (instant; the pass and its history stay on chain) or Close the pass (also hands back any NAVI position and stakes, deletes the pass and refunds its storage deposit). Their coins never left their address. Then sign.',
724
+ });
725
+ }
726
+ catch (error) {
727
+ return failure(error, 'Cannot open the owner page');
728
+ }
729
+ });
730
+ return server;
731
+ }
732
+ /** Entry point for `agent-pass mcp`: speaks MCP on stdio until the client disconnects. */
733
+ export async function servePlugin(options) {
734
+ await mkdir(dirname(options.root), { recursive: true });
735
+ const registry = new Registry(options);
736
+ const server = createPluginMcp(registry);
737
+ const transport = new StdioServerTransport();
738
+ const stop = () => {
739
+ void registry.close().then(() => process.exit(0));
740
+ };
741
+ process.once('SIGINT', stop);
742
+ process.once('SIGTERM', stop);
743
+ transport.onclose = stop;
744
+ await server.connect(transport);
745
+ }
746
+ /**
747
+ * Entry point for `agent-pass mcp --http`: the same tools on a local HTTP endpoint for agents
748
+ * that start a fresh process on every wake-up. The registry, and with it each owner page and
749
+ * its wallet origin, outlives any single agent run.
750
+ */
751
+ export async function servePluginHttp(options, port) {
752
+ await mkdir(options.root, { recursive: true, mode: 0o700 });
753
+ const registry = new Registry(options), bearer = token();
754
+ const server = createServer(async (request, response) => {
755
+ try {
756
+ if (new URL(request.url ?? '/', 'http://localhost').pathname !== '/mcp')
757
+ return void response.writeHead(404).end();
758
+ if (request.headers.origin || !authenticated(request, bearer))
759
+ return void response.writeHead(401).end();
760
+ const mcp = createPluginMcp(registry), transport = new StreamableHTTPServerTransport({
761
+ sessionIdGenerator: undefined,
762
+ enableJsonResponse: true,
763
+ });
764
+ response.on('close', () => {
765
+ void transport.close();
766
+ void mcp.close();
767
+ });
768
+ await mcp.connect(transport);
769
+ await transport.handleRequest(request, response, request.method === 'POST' ? await body(request) : undefined);
770
+ }
771
+ catch {
772
+ if (!response.headersSent)
773
+ response.writeHead(400);
774
+ response.end();
775
+ }
776
+ });
777
+ await new Promise((resolve, reject) => {
778
+ server.once('error', reject);
779
+ server.listen(port, '127.0.0.1', resolve);
780
+ });
781
+ const bound = server.address();
782
+ if (!bound || typeof bound === 'string')
783
+ throw new Error('No listening address');
784
+ const endpoint = { url: `http://127.0.0.1:${bound.port}/mcp`, token: bearer };
785
+ await writeFile(join(options.root, 'mcp-http.json'), JSON.stringify(endpoint, null, 2) + '\n', {
786
+ mode: 0o600,
787
+ });
788
+ const stop = () => {
789
+ server.close();
790
+ void registry.close().then(() => process.exit(0));
791
+ };
792
+ process.once('SIGINT', stop);
793
+ process.once('SIGTERM', stop);
794
+ return endpoint;
795
+ }