account-pool-mcp 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/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/bootstrap.d.ts +10 -0
- package/dist/bootstrap.js +28 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +126 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +115 -0
- package/dist/config.js.map +1 -0
- package/dist/db.d.ts +13 -0
- package/dist/db.js +83 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +8 -0
- package/dist/logger.js +44 -0
- package/dist/logger.js.map +1 -0
- package/dist/pool.d.ts +50 -0
- package/dist/pool.js +190 -0
- package/dist/pool.js.map +1 -0
- package/dist/schemas.d.ts +57 -0
- package/dist/schemas.js +43 -0
- package/dist/schemas.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +75 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -0
- package/examples/accounts.example.json +21 -0
- package/examples/claude-mcp-config.json +19 -0
- package/examples/playwright-usage.md +48 -0
- package/package.json +58 -0
package/dist/pool.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Db } from './db.js';
|
|
2
|
+
import { type LeaseResult, type PoolStatus, type ReleaseResult, type RenewResult } from './types.js';
|
|
3
|
+
export interface AccountPoolOptions {
|
|
4
|
+
db: Db;
|
|
5
|
+
defaultTtlSeconds: number;
|
|
6
|
+
/** Injectable clock (epoch SECONDS) so TTL tests don't rely on sleep. */
|
|
7
|
+
now?: () => number;
|
|
8
|
+
/** Environment used to resolve credential `{ env: "X" }` indirection. */
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
}
|
|
11
|
+
export declare class AccountPool {
|
|
12
|
+
private readonly db;
|
|
13
|
+
private readonly defaultTtl;
|
|
14
|
+
private readonly now;
|
|
15
|
+
private readonly env;
|
|
16
|
+
private readonly stmtReclaim;
|
|
17
|
+
private readonly stmtSelectFree;
|
|
18
|
+
private readonly stmtMarkLeased;
|
|
19
|
+
private readonly stmtRelease;
|
|
20
|
+
private readonly stmtByToken;
|
|
21
|
+
private readonly stmtRenew;
|
|
22
|
+
private readonly stmtPoolCounts;
|
|
23
|
+
private readonly stmtStatusRows;
|
|
24
|
+
constructor(opts: AccountPoolOptions);
|
|
25
|
+
/**
|
|
26
|
+
* Single atomic attempt to lease one free account. Returns the lease or `null` if the pool has no
|
|
27
|
+
* free account right now (caller applies the exhaustion policy — see {@link acquire}).
|
|
28
|
+
*/
|
|
29
|
+
lease(pool: string, holder?: string, ttlSeconds?: number): LeaseResult | null;
|
|
30
|
+
/**
|
|
31
|
+
* Lease one account, applying the exhaustion policy. `waitMs === 0` → fail fast with a structured
|
|
32
|
+
* {@link PoolExhaustedError}. `waitMs > 0` → block-and-wait, polling with jittered backoff up to
|
|
33
|
+
* `waitMs` before failing.
|
|
34
|
+
*/
|
|
35
|
+
acquire(args: {
|
|
36
|
+
pool: string;
|
|
37
|
+
holder?: string;
|
|
38
|
+
ttlSeconds?: number;
|
|
39
|
+
waitMs?: number;
|
|
40
|
+
}): Promise<LeaseResult>;
|
|
41
|
+
/** Release a leased account. Idempotent: an unknown/already-free/expired token returns false. */
|
|
42
|
+
release(leaseToken: string): ReleaseResult;
|
|
43
|
+
/** Extend a live lease. A token that is unknown OR already expired returns `renewed: false`. */
|
|
44
|
+
renew(leaseToken: string, ttlSeconds?: number): RenewResult;
|
|
45
|
+
/** Observability. Never returns credential values. */
|
|
46
|
+
status(pool?: string): PoolStatus[];
|
|
47
|
+
private poolCounts;
|
|
48
|
+
private nowMs;
|
|
49
|
+
private validateTtl;
|
|
50
|
+
}
|
package/dist/pool.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Core leasing logic — the part that must be exactly right.
|
|
2
|
+
//
|
|
3
|
+
// The concurrency guarantee comes from running every mutation inside a `BEGIN IMMEDIATE`
|
|
4
|
+
// transaction (better-sqlite3's `.immediate()` variant). IMMEDIATE takes the write lock BEFORE the
|
|
5
|
+
// SELECT that picks an account, so two simultaneous lease attempts can never read the same free row
|
|
6
|
+
// and both claim it (no time-of-check / time-of-use gap). Combined with WAL + busy_timeout, this
|
|
7
|
+
// holds across many independent processes sharing one database file.
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { resolveCredentials } from './config.js';
|
|
10
|
+
import { PoolExhaustedError, } from './types.js';
|
|
11
|
+
const nowSeconds = () => Math.floor(Date.now() / 1000);
|
|
12
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
13
|
+
export class AccountPool {
|
|
14
|
+
db;
|
|
15
|
+
defaultTtl;
|
|
16
|
+
now;
|
|
17
|
+
env;
|
|
18
|
+
// Prepared statements (compiled once).
|
|
19
|
+
stmtReclaim;
|
|
20
|
+
stmtSelectFree;
|
|
21
|
+
stmtMarkLeased;
|
|
22
|
+
stmtRelease;
|
|
23
|
+
stmtByToken;
|
|
24
|
+
stmtRenew;
|
|
25
|
+
stmtPoolCounts;
|
|
26
|
+
stmtStatusRows;
|
|
27
|
+
constructor(opts) {
|
|
28
|
+
this.db = opts.db;
|
|
29
|
+
this.defaultTtl = opts.defaultTtlSeconds;
|
|
30
|
+
this.now = opts.now ?? nowSeconds;
|
|
31
|
+
this.env = opts.env ?? process.env;
|
|
32
|
+
this.stmtReclaim = this.db.prepare(`UPDATE accounts
|
|
33
|
+
SET leased_by = NULL, lease_token = NULL, leased_at = NULL, ttl_seconds = NULL
|
|
34
|
+
WHERE pool = ? AND leased_by IS NOT NULL AND (leased_at + ttl_seconds) < ?`);
|
|
35
|
+
this.stmtSelectFree = this.db.prepare('SELECT id, credentials FROM accounts WHERE pool = ? AND leased_by IS NULL LIMIT 1');
|
|
36
|
+
this.stmtMarkLeased = this.db.prepare(`UPDATE accounts
|
|
37
|
+
SET leased_by = ?, lease_token = ?, leased_at = ?, ttl_seconds = ?,
|
|
38
|
+
lease_count = lease_count + 1
|
|
39
|
+
WHERE id = ?`);
|
|
40
|
+
this.stmtRelease = this.db.prepare(`UPDATE accounts
|
|
41
|
+
SET leased_by = NULL, lease_token = NULL, leased_at = NULL, ttl_seconds = NULL
|
|
42
|
+
WHERE lease_token = ? RETURNING id`);
|
|
43
|
+
this.stmtByToken = this.db.prepare('SELECT id, leased_at, ttl_seconds FROM accounts WHERE lease_token = ?');
|
|
44
|
+
this.stmtRenew = this.db.prepare('UPDATE accounts SET leased_at = ?, ttl_seconds = ? WHERE lease_token = ? RETURNING id');
|
|
45
|
+
this.stmtPoolCounts = this.db.prepare(`SELECT COUNT(*) AS total,
|
|
46
|
+
COALESCE(SUM(CASE WHEN leased_by IS NOT NULL THEN 1 ELSE 0 END), 0) AS leased
|
|
47
|
+
FROM accounts WHERE pool = ?`);
|
|
48
|
+
this.stmtStatusRows = this.db.prepare('SELECT id, pool, leased_by, leased_at, ttl_seconds FROM accounts');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Single atomic attempt to lease one free account. Returns the lease or `null` if the pool has no
|
|
52
|
+
* free account right now (caller applies the exhaustion policy — see {@link acquire}).
|
|
53
|
+
*/
|
|
54
|
+
lease(pool, holder = 'unknown', ttlSeconds) {
|
|
55
|
+
const ttl = this.validateTtl(ttlSeconds ?? this.defaultTtl);
|
|
56
|
+
const now = this.now();
|
|
57
|
+
const txn = this.db.transaction(() => {
|
|
58
|
+
// 1) reclaim anything in this pool whose lease has expired
|
|
59
|
+
this.stmtReclaim.run(pool, now);
|
|
60
|
+
// 2) grab one free account
|
|
61
|
+
const row = this.stmtSelectFree.get(pool);
|
|
62
|
+
if (!row)
|
|
63
|
+
return null;
|
|
64
|
+
// 3) resolve credentials BEFORE committing the claim — a missing env var rolls the whole
|
|
65
|
+
// transaction back so we never leave an account leased-but-unusable.
|
|
66
|
+
const creds = resolveCredentials(JSON.parse(row.credentials), row.id, this.env);
|
|
67
|
+
// 4) mark it leased
|
|
68
|
+
const token = randomBytes(12).toString('hex');
|
|
69
|
+
this.stmtMarkLeased.run(holder, token, now, ttl, row.id);
|
|
70
|
+
return {
|
|
71
|
+
account_id: row.id,
|
|
72
|
+
pool,
|
|
73
|
+
credentials: creds,
|
|
74
|
+
lease_token: token,
|
|
75
|
+
expires_at: now + ttl,
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
// IMMEDIATE: take the write lock before the read so two leases can't pick the same row.
|
|
79
|
+
return txn.immediate();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Lease one account, applying the exhaustion policy. `waitMs === 0` → fail fast with a structured
|
|
83
|
+
* {@link PoolExhaustedError}. `waitMs > 0` → block-and-wait, polling with jittered backoff up to
|
|
84
|
+
* `waitMs` before failing.
|
|
85
|
+
*/
|
|
86
|
+
async acquire(args) {
|
|
87
|
+
const { pool, holder, ttlSeconds } = args;
|
|
88
|
+
const waitMs = args.waitMs ?? 0;
|
|
89
|
+
const deadline = this.nowMs() + waitMs;
|
|
90
|
+
let backoff = 250;
|
|
91
|
+
for (;;) {
|
|
92
|
+
const result = this.lease(pool, holder, ttlSeconds);
|
|
93
|
+
if (result)
|
|
94
|
+
return result;
|
|
95
|
+
if (this.nowMs() >= deadline) {
|
|
96
|
+
const { total, leased } = this.poolCounts(pool);
|
|
97
|
+
throw new PoolExhaustedError(pool, total, leased);
|
|
98
|
+
}
|
|
99
|
+
// jittered backoff, capped, never overshooting the deadline
|
|
100
|
+
const jitter = Math.floor(Math.random() * 100);
|
|
101
|
+
const wait = Math.min(backoff + jitter, Math.max(0, deadline - this.nowMs()));
|
|
102
|
+
await sleep(wait);
|
|
103
|
+
backoff = Math.min(backoff * 2, 2000);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Release a leased account. Idempotent: an unknown/already-free/expired token returns false. */
|
|
107
|
+
release(leaseToken) {
|
|
108
|
+
const txn = this.db.transaction(() => {
|
|
109
|
+
const row = this.stmtRelease.get(leaseToken);
|
|
110
|
+
return row ? { released: true, account_id: row.id } : { released: false };
|
|
111
|
+
});
|
|
112
|
+
return txn.immediate();
|
|
113
|
+
}
|
|
114
|
+
/** Extend a live lease. A token that is unknown OR already expired returns `renewed: false`. */
|
|
115
|
+
renew(leaseToken, ttlSeconds) {
|
|
116
|
+
const ttl = this.validateTtl(ttlSeconds ?? this.defaultTtl);
|
|
117
|
+
const now = this.now();
|
|
118
|
+
const txn = this.db.transaction(() => {
|
|
119
|
+
const row = this.stmtByToken.get(leaseToken);
|
|
120
|
+
const expired = !row || row.leased_at === null || row.ttl_seconds === null
|
|
121
|
+
? true
|
|
122
|
+
: row.leased_at + row.ttl_seconds < now;
|
|
123
|
+
if (!row || expired) {
|
|
124
|
+
return {
|
|
125
|
+
renewed: false,
|
|
126
|
+
message: 'Lease is unknown or already expired and may now be held by another session. ' +
|
|
127
|
+
'Call lease_account again to get a fresh account.',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
this.stmtRenew.run(now, ttl, leaseToken);
|
|
131
|
+
return { renewed: true, expires_at: now + ttl };
|
|
132
|
+
});
|
|
133
|
+
return txn.immediate();
|
|
134
|
+
}
|
|
135
|
+
/** Observability. Never returns credential values. */
|
|
136
|
+
status(pool) {
|
|
137
|
+
const now = this.now();
|
|
138
|
+
const rows = this.stmtStatusRows.all();
|
|
139
|
+
const byPool = new Map();
|
|
140
|
+
for (const r of rows) {
|
|
141
|
+
if (pool && r.pool !== pool)
|
|
142
|
+
continue;
|
|
143
|
+
let ps = byPool.get(r.pool);
|
|
144
|
+
if (!ps) {
|
|
145
|
+
ps = { pool: r.pool, total: 0, available: 0, leased: 0, accounts: [] };
|
|
146
|
+
byPool.set(r.pool, ps);
|
|
147
|
+
}
|
|
148
|
+
ps.total++;
|
|
149
|
+
if (r.leased_by === null) {
|
|
150
|
+
ps.available++;
|
|
151
|
+
ps.accounts.push({ account_id: r.id, state: 'free' });
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const expired = r.leased_at === null || r.ttl_seconds === null || r.leased_at + r.ttl_seconds < now;
|
|
155
|
+
if (expired) {
|
|
156
|
+
ps.available++; // an expired lease is reclaimable on the next lease attempt
|
|
157
|
+
ps.accounts.push({ account_id: r.id, state: 'expired', holder: r.leased_by });
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
ps.leased++;
|
|
161
|
+
ps.accounts.push({
|
|
162
|
+
account_id: r.id,
|
|
163
|
+
state: 'leased',
|
|
164
|
+
holder: r.leased_by,
|
|
165
|
+
expires_at: r.leased_at + r.ttl_seconds,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// An explicit pool filter that matches nothing still yields an (empty) entry for clarity.
|
|
171
|
+
if (pool && !byPool.has(pool)) {
|
|
172
|
+
byPool.set(pool, { pool, total: 0, available: 0, leased: 0, accounts: [] });
|
|
173
|
+
}
|
|
174
|
+
return [...byPool.values()].sort((a, b) => a.pool.localeCompare(b.pool));
|
|
175
|
+
}
|
|
176
|
+
poolCounts(pool) {
|
|
177
|
+
return this.stmtPoolCounts.get(pool);
|
|
178
|
+
}
|
|
179
|
+
nowMs() {
|
|
180
|
+
// Wall-clock for backoff timing; independent of the injectable second-clock used for TTL logic.
|
|
181
|
+
return Date.now();
|
|
182
|
+
}
|
|
183
|
+
validateTtl(ttl) {
|
|
184
|
+
if (!Number.isInteger(ttl) || ttl <= 0) {
|
|
185
|
+
throw new Error(`ttl_seconds must be a positive integer, got ${ttl}.`);
|
|
186
|
+
}
|
|
187
|
+
return ttl;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
//# sourceMappingURL=pool.js.map
|
package/dist/pool.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pool.js","sourceRoot":"","sources":["../src/pool.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const leaseInput: {
|
|
3
|
+
pool: z.ZodString;
|
|
4
|
+
holder: z.ZodOptional<z.ZodString>;
|
|
5
|
+
ttl_seconds: z.ZodOptional<z.ZodNumber>;
|
|
6
|
+
wait_ms: z.ZodOptional<z.ZodNumber>;
|
|
7
|
+
};
|
|
8
|
+
export declare const releaseInput: {
|
|
9
|
+
lease_token: z.ZodString;
|
|
10
|
+
};
|
|
11
|
+
export declare const renewInput: {
|
|
12
|
+
lease_token: z.ZodString;
|
|
13
|
+
ttl_seconds: z.ZodOptional<z.ZodNumber>;
|
|
14
|
+
};
|
|
15
|
+
export declare const statusInput: {
|
|
16
|
+
pool: z.ZodOptional<z.ZodString>;
|
|
17
|
+
};
|
|
18
|
+
export declare const leaseInputSchema: z.ZodObject<{
|
|
19
|
+
pool: z.ZodString;
|
|
20
|
+
holder: z.ZodOptional<z.ZodString>;
|
|
21
|
+
ttl_seconds: z.ZodOptional<z.ZodNumber>;
|
|
22
|
+
wait_ms: z.ZodOptional<z.ZodNumber>;
|
|
23
|
+
}, "strip", z.ZodTypeAny, {
|
|
24
|
+
pool: string;
|
|
25
|
+
holder?: string | undefined;
|
|
26
|
+
ttl_seconds?: number | undefined;
|
|
27
|
+
wait_ms?: number | undefined;
|
|
28
|
+
}, {
|
|
29
|
+
pool: string;
|
|
30
|
+
holder?: string | undefined;
|
|
31
|
+
ttl_seconds?: number | undefined;
|
|
32
|
+
wait_ms?: number | undefined;
|
|
33
|
+
}>;
|
|
34
|
+
export declare const releaseInputSchema: z.ZodObject<{
|
|
35
|
+
lease_token: z.ZodString;
|
|
36
|
+
}, "strip", z.ZodTypeAny, {
|
|
37
|
+
lease_token: string;
|
|
38
|
+
}, {
|
|
39
|
+
lease_token: string;
|
|
40
|
+
}>;
|
|
41
|
+
export declare const renewInputSchema: z.ZodObject<{
|
|
42
|
+
lease_token: z.ZodString;
|
|
43
|
+
ttl_seconds: z.ZodOptional<z.ZodNumber>;
|
|
44
|
+
}, "strip", z.ZodTypeAny, {
|
|
45
|
+
lease_token: string;
|
|
46
|
+
ttl_seconds?: number | undefined;
|
|
47
|
+
}, {
|
|
48
|
+
lease_token: string;
|
|
49
|
+
ttl_seconds?: number | undefined;
|
|
50
|
+
}>;
|
|
51
|
+
export declare const statusInputSchema: z.ZodObject<{
|
|
52
|
+
pool: z.ZodOptional<z.ZodString>;
|
|
53
|
+
}, "strip", z.ZodTypeAny, {
|
|
54
|
+
pool?: string | undefined;
|
|
55
|
+
}, {
|
|
56
|
+
pool?: string | undefined;
|
|
57
|
+
}>;
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Zod input shapes for the four MCP tools. Kept as raw shapes so they can be passed straight to the
|
|
2
|
+
// MCP SDK's tool registration (which derives the JSON Schema the agent sees).
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
export const leaseInput = {
|
|
5
|
+
pool: z.string().min(1).describe('Which pool to lease from, e.g. "realtor" or "admin".'),
|
|
6
|
+
holder: z
|
|
7
|
+
.string()
|
|
8
|
+
.optional()
|
|
9
|
+
.describe('A label for who is holding it (e.g. a Jira ticket id). Observability only.'),
|
|
10
|
+
ttl_seconds: z
|
|
11
|
+
.number()
|
|
12
|
+
.int()
|
|
13
|
+
.positive()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe('Lease lifetime in seconds. Defaults to the server default TTL.'),
|
|
16
|
+
wait_ms: z
|
|
17
|
+
.number()
|
|
18
|
+
.int()
|
|
19
|
+
.nonnegative()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Override the block-and-wait timeout for this call. 0 = fail fast on an empty pool.'),
|
|
22
|
+
};
|
|
23
|
+
export const releaseInput = {
|
|
24
|
+
lease_token: z.string().min(1).describe('The lease_token returned by lease_account.'),
|
|
25
|
+
};
|
|
26
|
+
export const renewInput = {
|
|
27
|
+
lease_token: z.string().min(1).describe('The lease_token returned by lease_account.'),
|
|
28
|
+
ttl_seconds: z
|
|
29
|
+
.number()
|
|
30
|
+
.int()
|
|
31
|
+
.positive()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('New lease lifetime in seconds from now. Defaults to the server default TTL.'),
|
|
34
|
+
};
|
|
35
|
+
export const statusInput = {
|
|
36
|
+
pool: z.string().optional().describe('Limit to one pool. Omit for all pools.'),
|
|
37
|
+
};
|
|
38
|
+
// Re-exported for tests / typing.
|
|
39
|
+
export const leaseInputSchema = z.object(leaseInput);
|
|
40
|
+
export const releaseInputSchema = z.object(releaseInput);
|
|
41
|
+
export const renewInputSchema = z.object(renewInput);
|
|
42
|
+
export const statusInputSchema = z.object(statusInput);
|
|
43
|
+
//# sourceMappingURL=schemas.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA,oGAAoG;AACpG,8EAA8E;AAE9E,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,sDAAsD,CAAC;IACxF,MAAM,EAAE,CAAC;SACN,MAAM,EAAE;SACR,QAAQ,EAAE;SACV,QAAQ,CAAC,4EAA4E,CAAC;IACzF,WAAW,EAAE,CAAC;SACX,MAAM,EAAE;SACR,GAAG,EAAE;SACL,QAAQ,EAAE;SACV,QAAQ,EAAE;SACV,QAAQ,CAAC,gEAAgE,CAAC;IAC7E,OAAO,EAAE,CAAC;SACP,MAAM,EAAE;SACR,GAAG,EAAE;SACL,WAAW,EAAE;SACb,QAAQ,EAAE;SACV,QAAQ,CAAC,oFAAoF,CAAC;CAClG,CAAC;AAEF,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,4CAA4C,CAAC;CACtF,CAAC;AAEF,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,4CAA4C,CAAC;IACrF,WAAW,EAAE,CAAC;SACX,MAAM,EAAE;SACR,GAAG,EAAE;SACL,QAAQ,EAAE;SACV,QAAQ,EAAE;SACV,QAAQ,CAAC,6EAA6E,CAAC;CAC3F,CAAC;AAEF,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,wCAAwC,CAAC;CAC/E,CAAC;AAEF,kCAAkC;AAClC,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;AACrD,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;AACzD,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;AACrD,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC"}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// MCP server: registers the four tools and maps them onto the AccountPool. Tool descriptions are
|
|
2
|
+
// agent-facing — the wording is deliberate because the model reads it to decide how to behave.
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { logger, redact } from './logger.js';
|
|
5
|
+
import { leaseInput, releaseInput, renewInput, statusInput } from './schemas.js';
|
|
6
|
+
import { PoolExhaustedError } from './types.js';
|
|
7
|
+
function ok(data) {
|
|
8
|
+
return {
|
|
9
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
10
|
+
structuredContent: data,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function fail(data) {
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
16
|
+
structuredContent: data,
|
|
17
|
+
isError: true,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function buildServer(pool, config) {
|
|
21
|
+
const server = new McpServer({ name: 'account-pool-mcp', version: '0.1.0' });
|
|
22
|
+
server.tool('lease_account', 'Lease one account from the pool for browser login, EXCLUSIVELY, until you release it or the ' +
|
|
23
|
+
'lease expires. No other session can be handed the same account while you hold it. You MUST ' +
|
|
24
|
+
'call release_account with the returned lease_token when finished. If your task may run ' +
|
|
25
|
+
'longer than the lease TTL, call renew_lease periodically to keep it.', leaseInput, async (args) => {
|
|
26
|
+
try {
|
|
27
|
+
const result = await pool.acquire({
|
|
28
|
+
pool: args.pool,
|
|
29
|
+
holder: args.holder,
|
|
30
|
+
ttlSeconds: args.ttl_seconds,
|
|
31
|
+
waitMs: args.wait_ms ?? config.leaseWaitMs,
|
|
32
|
+
});
|
|
33
|
+
logger.info('leased', {
|
|
34
|
+
account_id: result.account_id,
|
|
35
|
+
pool: result.pool,
|
|
36
|
+
holder: args.holder ?? 'unknown',
|
|
37
|
+
expires_at: result.expires_at,
|
|
38
|
+
});
|
|
39
|
+
return ok(result);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
if (err instanceof PoolExhaustedError) {
|
|
43
|
+
return fail({
|
|
44
|
+
error: 'pool_exhausted',
|
|
45
|
+
pool: err.pool,
|
|
46
|
+
total: err.total,
|
|
47
|
+
leased: err.leased,
|
|
48
|
+
message: err.message,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
logger.error('lease_account failed', { message: err.message });
|
|
52
|
+
return fail({ error: 'lease_failed', message: err.message });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
server.tool('release_account', 'Release a leased account so others can use it. Idempotent: releasing an already-released, ' +
|
|
56
|
+
'expired, or unknown token returns released:false rather than erroring.', releaseInput, async (args) => {
|
|
57
|
+
const result = pool.release(args.lease_token);
|
|
58
|
+
logger.info('released', result);
|
|
59
|
+
return ok(result);
|
|
60
|
+
});
|
|
61
|
+
server.tool('renew_lease', 'Extend (heartbeat) the current lease so long-running work is not reclaimed out from under you. ' +
|
|
62
|
+
'If the lease already expired and was reclaimed (or the token is unknown), returns ' +
|
|
63
|
+
'renewed:false — do NOT assume you still hold the account; call lease_account again.', renewInput, async (args) => {
|
|
64
|
+
const result = pool.renew(args.lease_token, args.ttl_seconds);
|
|
65
|
+
return ok(result);
|
|
66
|
+
});
|
|
67
|
+
server.tool('pool_status', 'Observability for the pools: totals, how many are available vs leased, and per-account state ' +
|
|
68
|
+
'(free / leased / expired). Credential values are NEVER returned.', statusInput, async (args) => {
|
|
69
|
+
const pools = pool.status(args.pool);
|
|
70
|
+
// redact() is belt-and-suspenders: status() already omits credentials.
|
|
71
|
+
return ok({ pools: redact(pools) });
|
|
72
|
+
});
|
|
73
|
+
return server;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A credential value is either a literal (string/number/bool) or an indirection
|
|
3
|
+
* `{ env: "VAR_NAME" }` that is resolved from the process environment **at lease time**,
|
|
4
|
+
* so real secrets never have to live in the seed file in plaintext.
|
|
5
|
+
*/
|
|
6
|
+
export type CredentialValue = string | number | boolean | {
|
|
7
|
+
env: string;
|
|
8
|
+
};
|
|
9
|
+
/** Arbitrary credential blob — not hardwired to username/password. */
|
|
10
|
+
export type Credentials = Record<string, CredentialValue>;
|
|
11
|
+
/** Resolved credentials (after env indirection) handed back to a lease holder. */
|
|
12
|
+
export type ResolvedCredentials = Record<string, string | number | boolean>;
|
|
13
|
+
export interface AccountSeed {
|
|
14
|
+
id: string;
|
|
15
|
+
credentials: Credentials;
|
|
16
|
+
}
|
|
17
|
+
export interface PoolsSeed {
|
|
18
|
+
pools: Record<string, AccountSeed[]>;
|
|
19
|
+
}
|
|
20
|
+
/** A single account row as stored in SQLite. */
|
|
21
|
+
export interface AccountRow {
|
|
22
|
+
id: string;
|
|
23
|
+
pool: string;
|
|
24
|
+
credentials: string;
|
|
25
|
+
leased_by: string | null;
|
|
26
|
+
lease_token: string | null;
|
|
27
|
+
leased_at: number | null;
|
|
28
|
+
ttl_seconds: number | null;
|
|
29
|
+
lease_count: number;
|
|
30
|
+
}
|
|
31
|
+
export type ExhaustionPolicy = 'fail-fast' | 'block-and-wait';
|
|
32
|
+
export interface AppConfig {
|
|
33
|
+
dbPath: string;
|
|
34
|
+
accountsFile: string;
|
|
35
|
+
defaultTtlSeconds: number;
|
|
36
|
+
/** Max time to wait for a free account before failing. 0 = fail-fast. */
|
|
37
|
+
leaseWaitMs: number;
|
|
38
|
+
/** Clear all lease state on startup. */
|
|
39
|
+
resetLeases: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface LeaseResult {
|
|
42
|
+
account_id: string;
|
|
43
|
+
pool: string;
|
|
44
|
+
credentials: ResolvedCredentials;
|
|
45
|
+
lease_token: string;
|
|
46
|
+
expires_at: number;
|
|
47
|
+
}
|
|
48
|
+
export interface ReleaseResult {
|
|
49
|
+
released: boolean;
|
|
50
|
+
account_id?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface RenewResult {
|
|
53
|
+
renewed: boolean;
|
|
54
|
+
expires_at?: number;
|
|
55
|
+
message?: string;
|
|
56
|
+
}
|
|
57
|
+
export type AccountState = 'free' | 'leased' | 'expired';
|
|
58
|
+
export interface AccountStatus {
|
|
59
|
+
account_id: string;
|
|
60
|
+
state: AccountState;
|
|
61
|
+
holder?: string;
|
|
62
|
+
expires_at?: number;
|
|
63
|
+
}
|
|
64
|
+
export interface PoolStatus {
|
|
65
|
+
pool: string;
|
|
66
|
+
total: number;
|
|
67
|
+
available: number;
|
|
68
|
+
leased: number;
|
|
69
|
+
accounts: AccountStatus[];
|
|
70
|
+
}
|
|
71
|
+
/** Thrown when a pool has no free account and policy is fail-fast (or wait timed out). */
|
|
72
|
+
export declare class PoolExhaustedError extends Error {
|
|
73
|
+
readonly pool: string;
|
|
74
|
+
readonly total: number;
|
|
75
|
+
readonly leased: number;
|
|
76
|
+
constructor(pool: string, total: number, leased: number);
|
|
77
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Shared types for account-pool-mcp.
|
|
2
|
+
/** Thrown when a pool has no free account and policy is fail-fast (or wait timed out). */
|
|
3
|
+
export class PoolExhaustedError extends Error {
|
|
4
|
+
pool;
|
|
5
|
+
total;
|
|
6
|
+
leased;
|
|
7
|
+
constructor(pool, total, leased) {
|
|
8
|
+
super(total === 0
|
|
9
|
+
? `Pool "${pool}" does not exist or has no accounts.`
|
|
10
|
+
: `Pool "${pool}" is exhausted: all ${total} account(s) are currently leased (${leased} in use). Wait for a release, raise APM_LEASE_WAIT_MS to block-and-wait, or add more accounts.`);
|
|
11
|
+
this.name = 'PoolExhaustedError';
|
|
12
|
+
this.pool = pool;
|
|
13
|
+
this.total = total;
|
|
14
|
+
this.leased = leased;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,qCAAqC;AAoFrC,0FAA0F;AAC1F,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAClC,IAAI,CAAS;IACb,KAAK,CAAS;IACd,MAAM,CAAS;IACxB,YAAY,IAAY,EAAE,KAAa,EAAE,MAAc;QACrD,KAAK,CACH,KAAK,KAAK,CAAC;YACT,CAAC,CAAC,SAAS,IAAI,sCAAsC;YACrD,CAAC,CAAC,SAAS,IAAI,uBAAuB,KAAK,qCAAqC,MAAM,gGAAgG,CACzL,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;QACjC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;CACF"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Copy to accounts.json (gitignored) and edit. Each pool is a list of accounts; each account has a stable id and an arbitrary credentials blob. A credential value may be a literal, or { \"env\": \"VAR\" } which is resolved from the environment at lease time so real secrets never live in this file.",
|
|
3
|
+
"pools": {
|
|
4
|
+
"realtor": [
|
|
5
|
+
{
|
|
6
|
+
"id": "realtor_01",
|
|
7
|
+
"credentials": { "username": "qa.realtor01@example.com", "password": { "env": "REALTOR_01_PW" } }
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "realtor_02",
|
|
11
|
+
"credentials": { "username": "qa.realtor02@example.com", "password": { "env": "REALTOR_02_PW" } }
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"admin": [
|
|
15
|
+
{
|
|
16
|
+
"id": "admin_01",
|
|
17
|
+
"credentials": { "username": "qa.admin01@example.com", "password": { "env": "ADMIN_01_PW" } }
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Drop the account-pool stanza into your MCP client config (e.g. .mcp.json) alongside your other servers. Every independent agent session that loads this config talks to the SAME database file (APM_DB_PATH), which is how unrelated sessions coordinate. Point APM_DB_PATH at a stable, shared path.",
|
|
3
|
+
"mcpServers": {
|
|
4
|
+
"account-pool": {
|
|
5
|
+
"command": "npx",
|
|
6
|
+
"args": ["-y", "account-pool-mcp"],
|
|
7
|
+
"env": {
|
|
8
|
+
"APM_ACCOUNTS_FILE": "./accounts.json",
|
|
9
|
+
"APM_DB_PATH": "./account-pool.db",
|
|
10
|
+
"APM_DEFAULT_TTL_SECONDS": "1800",
|
|
11
|
+
"APM_LEASE_WAIT_MS": "0"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"playwright": {
|
|
15
|
+
"command": "npx",
|
|
16
|
+
"args": ["@playwright/mcp@latest", "--isolated"]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Using account-pool-mcp with Playwright (agent flow)
|
|
2
|
+
|
|
3
|
+
The pattern any agent (Claude or otherwise) should follow when it needs to log in: **lease → use →
|
|
4
|
+
release**, with a heartbeat for long work. Because the lease is exclusive and atomic, no other
|
|
5
|
+
session can be handed the same account while you hold it.
|
|
6
|
+
|
|
7
|
+
## The flow
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
1. lease_account({ pool: "realtor", holder: "QA-1234" })
|
|
11
|
+
→ { account_id, credentials: { username, password }, lease_token, expires_at }
|
|
12
|
+
|
|
13
|
+
2. Drive Playwright with the leased credentials:
|
|
14
|
+
- log in as `username` / `password`
|
|
15
|
+
- (or load a pre-saved storage state keyed by account_id / username)
|
|
16
|
+
- run your test flow in an ISOLATED browser context
|
|
17
|
+
|
|
18
|
+
3. If the work may outlast the TTL, periodically:
|
|
19
|
+
renew_lease({ lease_token }) # heartbeat — call between test cases / every few minutes
|
|
20
|
+
|
|
21
|
+
4. When finished (success OR failure):
|
|
22
|
+
release_account({ lease_token }) # hand the account back to the pool
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Why each step matters
|
|
26
|
+
|
|
27
|
+
- **Exclusive lease** — two sessions can never drive the same account at once, so they can't corrupt
|
|
28
|
+
each other's server-side state (carts, drafts, folders, search state). This is the whole point.
|
|
29
|
+
- **Heartbeat (`renew_lease`)** — a lease has a TTL so a crashed session can't strand an account
|
|
30
|
+
forever. If your real work runs longer than the TTL, renew to keep your hold. If `renew_lease`
|
|
31
|
+
returns `renewed: false`, the lease already expired and may now be held by someone else — stop and
|
|
32
|
+
`lease_account` again; do not keep using the old account.
|
|
33
|
+
- **Release** — returns the account immediately instead of waiting for the TTL. Always release in a
|
|
34
|
+
finally/teardown step. If your process dies before releasing, the TTL reclaims it automatically —
|
|
35
|
+
that's the crash-safety net, not the happy path.
|
|
36
|
+
|
|
37
|
+
## Empty pool
|
|
38
|
+
|
|
39
|
+
If every account is leased, `lease_account` either fails fast with a structured error (default) or
|
|
40
|
+
blocks-and-waits up to `APM_LEASE_WAIT_MS` before failing. Handle the failure by reporting "no
|
|
41
|
+
accounts available" rather than proceeding without an exclusive account.
|
|
42
|
+
|
|
43
|
+
## Storage-state variant (no live password in the flow)
|
|
44
|
+
|
|
45
|
+
If you pre-bake a Playwright storage state per account, the lease only needs to tell you *which*
|
|
46
|
+
account is yours: lease a pool whose credentials contain just a `username`/account id, then load the
|
|
47
|
+
matching saved storage-state blob and skip the live login entirely. The exclusivity guarantee is the
|
|
48
|
+
same — you just use the lease to pick the storage state instead of to log in.
|