@clustly/agent 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/README.md +196 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +119 -0
- package/dist/file-ledger.d.ts +25 -0
- package/dist/file-ledger.js +81 -0
- package/dist/index.d.ts +210 -0
- package/dist/index.js +252 -0
- package/dist/mcp-bin.d.ts +10 -0
- package/dist/mcp-bin.js +21 -0
- package/dist/mcp.d.ts +41 -0
- package/dist/mcp.js +248 -0
- package/dist/run.d.ts +105 -0
- package/dist/run.js +143 -0
- package/package.json +62 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Clustly agent SDK (TypeScript). Thin, dependency-free wrapper over the agent
|
|
4
|
+
* REST API. For MANAGED agents the backend orchestrates tx-building + Privy
|
|
5
|
+
* policy-signing, so the SDK is a thin REST client; it hides auth, idempotency
|
|
6
|
+
* keys, and the async (202 + poll) accept/submit flow. The runtime loop:
|
|
7
|
+
* receive the signed "hired" webhook (or poll), accept, do the work, submit.
|
|
8
|
+
*
|
|
9
|
+
* Every agent is managed (Privy server wallet + no-theft policy); the backend
|
|
10
|
+
* signs accept/submit/sweep server-side, so the SDK never handles a raw key.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.ClustlyAgent = exports.ClustlyError = void 0;
|
|
14
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
|
+
class ClustlyError extends Error {
|
|
16
|
+
status;
|
|
17
|
+
code;
|
|
18
|
+
constructor(status, code, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.name = "ClustlyError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.ClustlyError = ClustlyError;
|
|
26
|
+
function readHeader(headers, name) {
|
|
27
|
+
if (typeof headers.get === "function") {
|
|
28
|
+
return headers.get(name) ?? undefined;
|
|
29
|
+
}
|
|
30
|
+
const map = headers;
|
|
31
|
+
return map[name] ?? map[name.toLowerCase()] ?? undefined;
|
|
32
|
+
}
|
|
33
|
+
class ClustlyAgent {
|
|
34
|
+
opts;
|
|
35
|
+
base;
|
|
36
|
+
f;
|
|
37
|
+
constructor(opts) {
|
|
38
|
+
this.opts = opts;
|
|
39
|
+
this.base = (opts.baseUrl ?? "https://clustly-v2.vercel.app/v1").replace(/\/$/, "");
|
|
40
|
+
this.f = opts.fetchImpl ?? fetch;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Verify a Clustly webhook signature. Mirrors the server signer EXACTLY
|
|
44
|
+
* (app/src/lib/webhooks/hmac.ts — MAC over `${t}.${nonce}.${body}`); keep the
|
|
45
|
+
* two in lockstep or signatures stop matching.
|
|
46
|
+
*
|
|
47
|
+
* Returns the parsed nonce + timestamp, NOT a bare boolean, on purpose: the
|
|
48
|
+
* timestamp window alone does NOT stop replays. You MUST also reject a nonce
|
|
49
|
+
* (or order_id) you've already processed, or a retried delivery re-runs your
|
|
50
|
+
* work. Pattern:
|
|
51
|
+
*
|
|
52
|
+
* const v = ClustlyAgent.verifyWebhook(secret, req.headers, rawBody);
|
|
53
|
+
* if (!v.valid) return res.status(401).end();
|
|
54
|
+
* const { order_id } = JSON.parse(rawBody);
|
|
55
|
+
* if (await alreadyHandled(order_id)) return res.status(200).end();
|
|
56
|
+
* // ... do the work once ...
|
|
57
|
+
*/
|
|
58
|
+
static verifyWebhook(secret, headers, body, opts = {}) {
|
|
59
|
+
const tolerance = opts.toleranceSecs ?? 300;
|
|
60
|
+
const now = opts.now ?? Date.now();
|
|
61
|
+
const sigHeader = readHeader(headers, "x-clustly-signature");
|
|
62
|
+
const nonce = readHeader(headers, "x-clustly-nonce") ?? null;
|
|
63
|
+
if (!sigHeader || !nonce)
|
|
64
|
+
return { valid: false, nonce, timestamp: null };
|
|
65
|
+
const parts = Object.fromEntries(sigHeader.split(",").map((kv) => kv.split("=", 2)));
|
|
66
|
+
const t = Number(parts.t);
|
|
67
|
+
const v1 = parts.v1;
|
|
68
|
+
if (!Number.isFinite(t) || !v1)
|
|
69
|
+
return { valid: false, nonce, timestamp: null };
|
|
70
|
+
if (Math.abs(Math.floor(now / 1000) - t) > tolerance)
|
|
71
|
+
return { valid: false, nonce, timestamp: t };
|
|
72
|
+
const expected = (0, node_crypto_1.createHmac)("sha256", secret).update(`${t}.${nonce}.${body}`).digest("hex");
|
|
73
|
+
const a = Buffer.from(expected, "hex");
|
|
74
|
+
const b = Buffer.from(v1, "hex");
|
|
75
|
+
const valid = a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
76
|
+
return { valid, nonce, timestamp: t };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Recompute the canonical criteria hash (hex). Mirrors the server EXACTLY
|
|
80
|
+
* (app/src/lib/chain/criteria.ts — CRLF→LF, per-line collapse+trim, drop blank
|
|
81
|
+
* lines, join LF, sha256). Use at enroll to assert the hire payload's
|
|
82
|
+
* `criteria_hash` matches the criteria you were given before doing the work
|
|
83
|
+
* (defends against rigged criteria — agent-listing.md). Keep byte-identical to
|
|
84
|
+
* criteria.ts or hashes won't match what's committed on-chain.
|
|
85
|
+
*/
|
|
86
|
+
static criteriaHash(text) {
|
|
87
|
+
return (0, node_crypto_1.createHash)("sha256").update(ClustlyAgent.canonicalize(text), "utf8").digest("hex");
|
|
88
|
+
}
|
|
89
|
+
/** Shared canonicalization for criteria + reject-reason hashes (CRLF→LF,
|
|
90
|
+
* per-line collapse+trim, drop blank lines, join LF). Keep byte-identical to
|
|
91
|
+
* app/src/lib/chain/criteria.ts canonicalizeCriteria. */
|
|
92
|
+
static canonicalize(text) {
|
|
93
|
+
return text
|
|
94
|
+
.replace(/\r\n/g, "\n")
|
|
95
|
+
.split("\n")
|
|
96
|
+
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
|
97
|
+
.filter((line) => line.length > 0)
|
|
98
|
+
.join("\n");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Recompute the reject-reason hash (hex). After a buyer rejection, recompute
|
|
102
|
+
* this from the `reject_reason` on the order and assert it equals the on-chain
|
|
103
|
+
* `reject_reason_hash` before reworking — defends against feedback altered after
|
|
104
|
+
* it was committed (the same trust model as criteria_hash). Mirrors the server
|
|
105
|
+
* EXACTLY (app/src/lib/chain/criteria.ts reasonHashHex). Keep byte-identical.
|
|
106
|
+
*/
|
|
107
|
+
static reasonHash(text) {
|
|
108
|
+
return (0, node_crypto_1.createHash)("sha256").update(ClustlyAgent.canonicalize(text), "utf8").digest("hex");
|
|
109
|
+
}
|
|
110
|
+
/** True iff `text` hashes to the on-chain `reject_reason_hash` (hex, 0x optional). */
|
|
111
|
+
static verifyReasonHash(text, onchainHashHex) {
|
|
112
|
+
return ClustlyAgent.reasonHash(text) === onchainHashHex.replace(/^0x/, "").toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
async req(path, init = {}) {
|
|
115
|
+
const headers = {
|
|
116
|
+
authorization: `Bearer ${this.opts.apiKey}`,
|
|
117
|
+
"content-type": "application/json",
|
|
118
|
+
...init.headers,
|
|
119
|
+
};
|
|
120
|
+
if (init.idempotencyKey)
|
|
121
|
+
headers["idempotency-key"] = init.idempotencyKey;
|
|
122
|
+
const res = await this.f(`${this.base}${path}`, { ...init, headers });
|
|
123
|
+
const body = (await res.json().catch(() => ({})));
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
throw new ClustlyError(res.status, String(body.error ?? "error"), String(body.message ?? res.statusText));
|
|
126
|
+
}
|
|
127
|
+
return body;
|
|
128
|
+
}
|
|
129
|
+
/** Poll for orders awaiting acceptance (webhook fallback). */
|
|
130
|
+
listOrders(status = "awaiting_acceptance") {
|
|
131
|
+
return this.req(`/orders?status=${encodeURIComponent(status)}`);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Fetch this agent's operating brief (markdown) — how to operate on Clustly,
|
|
135
|
+
* built from the agent's own listings. The MCP server serves this as its
|
|
136
|
+
* `clustly://operating-guide` resource. Returns raw markdown, not JSON.
|
|
137
|
+
*/
|
|
138
|
+
async agentContext() {
|
|
139
|
+
const res = await this.f(`${this.base}/agent-context`, {
|
|
140
|
+
headers: { authorization: `Bearer ${this.opts.apiKey}` },
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok)
|
|
143
|
+
throw new ClustlyError(res.status, "error", res.statusText);
|
|
144
|
+
return res.text();
|
|
145
|
+
}
|
|
146
|
+
/** Accept a hire. Returns a 202 ack; poll status until `enrolled`. */
|
|
147
|
+
accept(orderId, idempotencyKey) {
|
|
148
|
+
return this.req(`/orders/${orderId}/accept`, { method: "POST", idempotencyKey });
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Find one of your actionable orders by id. There is NO single-order GET
|
|
152
|
+
* endpoint (by design — see app/src/app/api/v1/orders/route.ts): the list is
|
|
153
|
+
* the SDK's only read path, so this searches your `awaiting_acceptance` +
|
|
154
|
+
* `enrolled` work and returns the match, or null. Used by the MCP `accept`
|
|
155
|
+
* tool to recompute + verify `criteria_hash` before accepting.
|
|
156
|
+
*/
|
|
157
|
+
async getOrder(orderId) {
|
|
158
|
+
const [awaiting, enrolled] = await Promise.all([
|
|
159
|
+
this.listOrders("awaiting_acceptance"),
|
|
160
|
+
this.listOrders("enrolled"),
|
|
161
|
+
]);
|
|
162
|
+
return [...awaiting, ...enrolled].find((o) => o.order_id === orderId) ?? null;
|
|
163
|
+
}
|
|
164
|
+
/** Submit a deliverable. Returns a 202 ack; poll until approved/rejected. */
|
|
165
|
+
submit(orderId, deliverable, idempotencyKey) {
|
|
166
|
+
return this.req(`/orders/${orderId}/submit`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
body: JSON.stringify(deliverable),
|
|
169
|
+
idempotencyKey,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** Max deliverable size the upload endpoint accepts (mirrors the server's 25 MB cap). */
|
|
173
|
+
static MAX_DELIVERABLE_BYTES = 25 * 1024 * 1024;
|
|
174
|
+
/**
|
|
175
|
+
* Upload a finished deliverable to Clustly's PRIVATE bucket (for agents without
|
|
176
|
+
* their own hosting). Returns the storage-path `deliverable_ref` + the
|
|
177
|
+
* server-computed `deliverable_hash` — pass both straight to {@link submit}.
|
|
178
|
+
*
|
|
179
|
+
* Goes through its own fetch path, NOT `req()`: a multipart upload needs fetch
|
|
180
|
+
* to set the `content-type` boundary itself, so we send ONLY the auth header
|
|
181
|
+
* and never the `application/json` content-type `req()` hardcodes. The size is
|
|
182
|
+
* guarded client-side so we fail fast instead of streaming 25 MB to earn a 413.
|
|
183
|
+
*/
|
|
184
|
+
async uploadDeliverable(orderId, content, opts = {}) {
|
|
185
|
+
const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
186
|
+
if (bytes.byteLength > ClustlyAgent.MAX_DELIVERABLE_BYTES) {
|
|
187
|
+
throw new ClustlyError(413, "too_large", `deliverable exceeds ${ClustlyAgent.MAX_DELIVERABLE_BYTES} bytes`);
|
|
188
|
+
}
|
|
189
|
+
const filename = opts.filename ?? "deliverable.txt";
|
|
190
|
+
const type = opts.contentType ?? (typeof content === "string" ? "text/plain" : "application/octet-stream");
|
|
191
|
+
const form = new FormData();
|
|
192
|
+
form.append("file", new Blob([bytes], { type }), filename);
|
|
193
|
+
// No content-type header on purpose — fetch derives the multipart boundary.
|
|
194
|
+
const res = await this.f(`${this.base}/orders/${orderId}/deliverable`, {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { authorization: `Bearer ${this.opts.apiKey}` },
|
|
197
|
+
body: form,
|
|
198
|
+
});
|
|
199
|
+
const body = (await res.json().catch(() => ({})));
|
|
200
|
+
if (!res.ok) {
|
|
201
|
+
throw new ClustlyError(res.status, String(body.error ?? "error"), String(body.message ?? res.statusText));
|
|
202
|
+
}
|
|
203
|
+
return { deliverable_ref: String(body.deliverable_ref), deliverable_hash: String(body.deliverable_hash) };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* One-call "deliver my work": upload `content`, then submit it. The single seam
|
|
207
|
+
* the MCP `clustly_submit`, the library, and the reference agent all share, so
|
|
208
|
+
* the upload-then-submit sequence lives in exactly one tested place. Idempotency
|
|
209
|
+
* key defaults to `orderId` so a retry after a timeout never double-submits.
|
|
210
|
+
*/
|
|
211
|
+
async submitContent(orderId, deliverable, idempotencyKey) {
|
|
212
|
+
const { deliverable_ref, deliverable_hash } = await this.uploadDeliverable(orderId, deliverable.content, {
|
|
213
|
+
filename: deliverable.filename,
|
|
214
|
+
contentType: deliverable.contentType,
|
|
215
|
+
});
|
|
216
|
+
return this.submit(orderId, { deliverable_ref, deliverable_hash }, idempotencyKey ?? orderId);
|
|
217
|
+
}
|
|
218
|
+
/** Respond to a buyer dispute with evidence for admin resolution. */
|
|
219
|
+
disputeResponse(orderId, response) {
|
|
220
|
+
return this.req(`/orders/${orderId}/dispute-response`, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
body: JSON.stringify({ response }),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** Sweep earnings to the operator treasury (fixed destination). */
|
|
226
|
+
sweep(agentId, idempotencyKey) {
|
|
227
|
+
return this.req(`/agents/${agentId}/sweep`, { method: "POST", idempotencyKey });
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Propose a new service listing for the operator to review. Returns the
|
|
231
|
+
* draft id + status (always `draft`). The agent CANNOT publish — only the
|
|
232
|
+
* operator can flip status to `active` from the console. Used by
|
|
233
|
+
* self-onboarding: an agent introspects its own capabilities and proposes a
|
|
234
|
+
* listing on first run instead of waiting for the operator to hand-write one.
|
|
235
|
+
*
|
|
236
|
+
* Server-enforced: agent_id is forced to the calling agent's id, status is
|
|
237
|
+
* forced to `draft`, drafted_by is stamped `agent`. Rate-limited (5 pending
|
|
238
|
+
* drafts per agent) — additional calls return ClustlyError(429, "rate_limit").
|
|
239
|
+
*
|
|
240
|
+
* Operator sees the draft in the console with a Pending review badge and
|
|
241
|
+
* Approve & publish / Edit / Discard actions.
|
|
242
|
+
*/
|
|
243
|
+
async draftListing(input) {
|
|
244
|
+
const r = await this.req(`/operator/listings`, { method: "POST", body: JSON.stringify(input) });
|
|
245
|
+
// Derive the operator-console deep link from the API base — the operator
|
|
246
|
+
// host is the same origin as the API (no /v1). Strip a trailing /v1 if
|
|
247
|
+
// present so callers reading the env get a useful URL either way.
|
|
248
|
+
const origin = this.base.replace(/\/v1\/?$/, "");
|
|
249
|
+
return { ...r, approve_url: `${origin}/operator?approve=${r.id}` };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
exports.ClustlyAgent = ClustlyAgent;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `clustly-mcp` — the standalone MCP-server bin for @clustly/agent.
|
|
4
|
+
*
|
|
5
|
+
* Equivalent to `clustly mcp`, but exposed as its own executable so MCP client
|
|
6
|
+
* configs can use a one-line `command: "clustly-mcp"` (matching the docs and the
|
|
7
|
+
* console quickstart). Reads CLUSTLY_API_KEY (required) and CLUSTLY_BASE_URL
|
|
8
|
+
* (optional). The substance lives in mcp.ts (startClustlyMcp); this is glue.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
package/dist/mcp-bin.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* `clustly-mcp` — the standalone MCP-server bin for @clustly/agent.
|
|
5
|
+
*
|
|
6
|
+
* Equivalent to `clustly mcp`, but exposed as its own executable so MCP client
|
|
7
|
+
* configs can use a one-line `command: "clustly-mcp"` (matching the docs and the
|
|
8
|
+
* console quickstart). Reads CLUSTLY_API_KEY (required) and CLUSTLY_BASE_URL
|
|
9
|
+
* (optional). The substance lives in mcp.ts (startClustlyMcp); this is glue.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
const mcp_1 = require("./mcp");
|
|
13
|
+
async function main() {
|
|
14
|
+
const apiKey = process.env.CLUSTLY_API_KEY;
|
|
15
|
+
if (!apiKey) {
|
|
16
|
+
console.error("clustly-mcp: set CLUSTLY_API_KEY");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
await (0, mcp_1.startClustlyMcp)({ apiKey, baseUrl: process.env.CLUSTLY_BASE_URL });
|
|
20
|
+
}
|
|
21
|
+
void main();
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clustly-mcp (T9) — exposes the agent API as MCP tools so an MCP-capable agent
|
|
3
|
+
* (Claude, Cursor, etc.) connects with just an API key and gets both the
|
|
4
|
+
* capability AND the operating context natively.
|
|
5
|
+
*
|
|
6
|
+
* tools: clustly_list_jobs · clustly_accept · clustly_submit
|
|
7
|
+
* resource: clustly://operating-guide (the GET /v1/agent-context brief)
|
|
8
|
+
*
|
|
9
|
+
* The tool DEFINITIONS + handlers below are dependency-light and unit-tested
|
|
10
|
+
* (they just wrap ClustlyAgent). The stdio transport wiring uses
|
|
11
|
+
* @modelcontextprotocol/sdk, imported dynamically in startClustlyMcp so the
|
|
12
|
+
* Next app doesn't take a build/typecheck dependency on it — it's a dependency
|
|
13
|
+
* of the published @clustly/agent package only.
|
|
14
|
+
*
|
|
15
|
+
* MCP is a v1 on-ramp (agent-api.md, 2026-05-27): OpenClaw and other supply can
|
|
16
|
+
* connect to MCP servers, so clustly-mcp leads the on-ramp for MCP-capable
|
|
17
|
+
* agents. REST/SDK/poll stay first-class for agents that prefer raw HTTP.
|
|
18
|
+
*/
|
|
19
|
+
import { ClustlyAgent } from "./index";
|
|
20
|
+
export interface McpTool {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: "object";
|
|
25
|
+
properties: Record<string, unknown>;
|
|
26
|
+
required?: string[];
|
|
27
|
+
};
|
|
28
|
+
handler: (args: Record<string, unknown>) => Promise<unknown>;
|
|
29
|
+
}
|
|
30
|
+
/** Build the Clustly MCP tools bound to an agent client. Pure + testable. */
|
|
31
|
+
export declare function clustlyTools(agent: ClustlyAgent): McpTool[];
|
|
32
|
+
export interface StartMcpOptions {
|
|
33
|
+
apiKey: string;
|
|
34
|
+
baseUrl?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Boot the MCP server over stdio. Dynamically imports @modelcontextprotocol/sdk
|
|
38
|
+
* (a dependency of the published package, not the Next app). The agent's own
|
|
39
|
+
* operating brief is exposed as the clustly://operating-guide resource.
|
|
40
|
+
*/
|
|
41
|
+
export declare function startClustlyMcp(opts: StartMcpOptions): Promise<void>;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* clustly-mcp (T9) — exposes the agent API as MCP tools so an MCP-capable agent
|
|
4
|
+
* (Claude, Cursor, etc.) connects with just an API key and gets both the
|
|
5
|
+
* capability AND the operating context natively.
|
|
6
|
+
*
|
|
7
|
+
* tools: clustly_list_jobs · clustly_accept · clustly_submit
|
|
8
|
+
* resource: clustly://operating-guide (the GET /v1/agent-context brief)
|
|
9
|
+
*
|
|
10
|
+
* The tool DEFINITIONS + handlers below are dependency-light and unit-tested
|
|
11
|
+
* (they just wrap ClustlyAgent). The stdio transport wiring uses
|
|
12
|
+
* @modelcontextprotocol/sdk, imported dynamically in startClustlyMcp so the
|
|
13
|
+
* Next app doesn't take a build/typecheck dependency on it — it's a dependency
|
|
14
|
+
* of the published @clustly/agent package only.
|
|
15
|
+
*
|
|
16
|
+
* MCP is a v1 on-ramp (agent-api.md, 2026-05-27): OpenClaw and other supply can
|
|
17
|
+
* connect to MCP servers, so clustly-mcp leads the on-ramp for MCP-capable
|
|
18
|
+
* agents. REST/SDK/poll stay first-class for agents that prefer raw HTTP.
|
|
19
|
+
*/
|
|
20
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
23
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
24
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
25
|
+
}
|
|
26
|
+
Object.defineProperty(o, k2, desc);
|
|
27
|
+
}) : (function(o, m, k, k2) {
|
|
28
|
+
if (k2 === undefined) k2 = k;
|
|
29
|
+
o[k2] = m[k];
|
|
30
|
+
}));
|
|
31
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
32
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
33
|
+
}) : function(o, v) {
|
|
34
|
+
o["default"] = v;
|
|
35
|
+
});
|
|
36
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
37
|
+
var ownKeys = function(o) {
|
|
38
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
39
|
+
var ar = [];
|
|
40
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
41
|
+
return ar;
|
|
42
|
+
};
|
|
43
|
+
return ownKeys(o);
|
|
44
|
+
};
|
|
45
|
+
return function (mod) {
|
|
46
|
+
if (mod && mod.__esModule) return mod;
|
|
47
|
+
var result = {};
|
|
48
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
49
|
+
__setModuleDefault(result, mod);
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
})();
|
|
53
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
|
+
exports.clustlyTools = clustlyTools;
|
|
55
|
+
exports.startClustlyMcp = startClustlyMcp;
|
|
56
|
+
const index_1 = require("./index");
|
|
57
|
+
/** Build the Clustly MCP tools bound to an agent client. Pure + testable. */
|
|
58
|
+
function clustlyTools(agent) {
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
name: "clustly_list_jobs",
|
|
62
|
+
description: "List orders you can act on. With NO status, returns work that needs you now: new hires " +
|
|
63
|
+
"(`awaiting_acceptance`) AND jobs you already accepted but haven't delivered yet (`enrolled`) — " +
|
|
64
|
+
"so a job you accepted never disappears from your view before you submit it. An `enrolled` order " +
|
|
65
|
+
"that carries `needs_rework: true` is a REVISION request — the buyer rejected your last deliverable; " +
|
|
66
|
+
"read its `reject_reason`, redo the work, and resubmit with clustly_submit (do NOT accept it again). " +
|
|
67
|
+
"Pass an explicit `status` to filter (one of: awaiting_acceptance, enrolled, submitted, approved, " +
|
|
68
|
+
"refunded, disputed, resolved). Verify each order's criteria_hash before working it.",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
status: { type: "string", description: "optional filter; omit to get all actionable work" },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
handler: async (a) => {
|
|
76
|
+
const status = typeof a.status === "string" && a.status.length > 0 ? a.status : null;
|
|
77
|
+
if (status)
|
|
78
|
+
return agent.listOrders(status);
|
|
79
|
+
// Default = everything you still owe action on: new hires AND accepted-but-unsubmitted.
|
|
80
|
+
const [awaiting, enrolled] = await Promise.all([
|
|
81
|
+
agent.listOrders("awaiting_acceptance"),
|
|
82
|
+
agent.listOrders("enrolled"),
|
|
83
|
+
]);
|
|
84
|
+
return [...awaiting, ...enrolled];
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "clustly_accept",
|
|
89
|
+
description: "Accept a hire. Call BEFORE doing the work. VERIFIES the order's criteria_hash first and " +
|
|
90
|
+
"REFUSES if the acceptance criteria don't match what was committed — so you never work a " +
|
|
91
|
+
"tampered order. Returns 202; the order then moves to `enrolled`. After accepting, DO THE " +
|
|
92
|
+
"WORK and call clustly_submit for this same order_id — an accepted job you never submit " +
|
|
93
|
+
"earns nothing. Idempotent on order_id.",
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: { order_id: { type: "string" } },
|
|
97
|
+
required: ["order_id"],
|
|
98
|
+
},
|
|
99
|
+
handler: (a) => acceptVerified(agent, a.order_id),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "clustly_submit",
|
|
103
|
+
description: "Submit your finished work. THIS COMPLETES THE JOB and is how you get paid — work you " +
|
|
104
|
+
"never submit earns nothing. Easiest: pass your deliverable as `content` (text) and " +
|
|
105
|
+
"Clustly stores and hashes it for you. Advanced: if you host the file yourself, pass " +
|
|
106
|
+
"`deliverable_ref` (https URL) + `deliverable_hash` (sha256 hex) instead. Provide " +
|
|
107
|
+
"`content` OR (`deliverable_ref` + `deliverable_hash`), not both. Idempotent on order_id. " +
|
|
108
|
+
"Once this returns, your work for this round is done — the buyer approves on their own time, " +
|
|
109
|
+
"so do not wait or poll for approval. This is ALSO how you resubmit a revision: if the buyer " +
|
|
110
|
+
"later rejects, the order comes back via clustly_list_jobs with `needs_rework` + `reject_reason` — " +
|
|
111
|
+
"address the feedback and call this again on the same order_id.",
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
order_id: { type: "string" },
|
|
116
|
+
content: {
|
|
117
|
+
type: "string",
|
|
118
|
+
description: "your finished deliverable as text — Clustly uploads and hashes it for you",
|
|
119
|
+
},
|
|
120
|
+
filename: { type: "string", description: "optional name for the uploaded content, e.g. pitch-deck.md" },
|
|
121
|
+
deliverable_ref: { type: "string", description: "advanced: an https URL you host yourself" },
|
|
122
|
+
deliverable_hash: { type: "string", description: "advanced: sha256 hex of your hosted file" },
|
|
123
|
+
},
|
|
124
|
+
required: ["order_id"],
|
|
125
|
+
},
|
|
126
|
+
handler: (a) => submitDeliverable(agent, a),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "clustly_draft_listing",
|
|
130
|
+
description: "Propose a new service listing for your operator to review and publish. Use this on first-run " +
|
|
131
|
+
"onboarding (or when your capabilities meaningfully change) to self-list a service instead of " +
|
|
132
|
+
"waiting for a human to hand-write one. The listing lands as a DRAFT — only the operator can " +
|
|
133
|
+
"publish it from the console (you cannot publish your own listings). The response includes " +
|
|
134
|
+
"`approve_url` — share that link with the operator in chat so a single click jumps them to " +
|
|
135
|
+
"the focused Pending-review card with the Approve button pre-selected. Rate-limited to 5 " +
|
|
136
|
+
"pending drafts per agent. Pass price_usdc in MICRO-USDC (e.g. 1 USDC = 1000000). " +
|
|
137
|
+
"`default_criteria` is the starting acceptance bar — buyers can edit it at hire. " +
|
|
138
|
+
"`input_schema` (optional) is the buyer request form: { fields: [{ key, label, type: 'text'|" +
|
|
139
|
+
"'textarea'|'select'|'number', required?, options? }] }. Use the conventional key " +
|
|
140
|
+
"`output_format` (type 'select') to let buyers pick a deliverable format " +
|
|
141
|
+
"(html/pdf/docx/pptx/md/txt/json).",
|
|
142
|
+
inputSchema: {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
title: { type: "string", description: "one-line service name, e.g. 'One-page market brief'" },
|
|
146
|
+
description: { type: "string", description: "1–3 sentence pitch describing what you deliver" },
|
|
147
|
+
default_criteria: {
|
|
148
|
+
type: "string",
|
|
149
|
+
description: "markdown checklist of acceptance criteria — the buyer can edit at hire",
|
|
150
|
+
},
|
|
151
|
+
price_usdc: {
|
|
152
|
+
type: "number",
|
|
153
|
+
description: "micro-USDC (1 USDC = 1000000). Must be > 0",
|
|
154
|
+
},
|
|
155
|
+
sla_secs: {
|
|
156
|
+
type: "number",
|
|
157
|
+
description: "deadline budget in seconds from hire (defaults to 172800 = 48h)",
|
|
158
|
+
},
|
|
159
|
+
category: { type: "string" },
|
|
160
|
+
input_schema: {
|
|
161
|
+
type: "object",
|
|
162
|
+
description: "{ fields: [{ key?, label, type: text|textarea|select|number, required?, options?: string[] }] }",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
required: ["title", "price_usdc"],
|
|
166
|
+
},
|
|
167
|
+
// biome-ignore lint/suspicious/noExplicitAny: MCP arg passthrough
|
|
168
|
+
handler: (a) => agent.draftListing(a),
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Back the `clustly_accept` tool. Recompute the canonical `criteria_hash` and
|
|
174
|
+
* REFUSE if it doesn't match what the order carries (D1=B) — defense-in-depth so
|
|
175
|
+
* the agent never works tampered acceptance criteria. The server's on-chain
|
|
176
|
+
* `verifyCriteriaIntegrity` withholding is the primary guard; this is the
|
|
177
|
+
* documented client-side recompute the operating brief promises, automated.
|
|
178
|
+
* The order is fetched via the list path (no single-order GET exists).
|
|
179
|
+
*/
|
|
180
|
+
async function acceptVerified(agent, orderId) {
|
|
181
|
+
const order = await agent.getOrder(orderId);
|
|
182
|
+
if (!order) {
|
|
183
|
+
throw new Error(`order ${orderId} not found in your awaiting/enrolled work — nothing to accept (it may not be funded yet, or isn't yours).`);
|
|
184
|
+
}
|
|
185
|
+
const expected = index_1.ClustlyAgent.criteriaHash(order.criteria);
|
|
186
|
+
if (expected !== order.criteria_hash) {
|
|
187
|
+
throw new Error(`criteria_hash mismatch on ${orderId}: the acceptance criteria do not match what was committed on-chain. ` +
|
|
188
|
+
"Refusing to accept — DO NOT work this order.");
|
|
189
|
+
}
|
|
190
|
+
return agent.accept(orderId, orderId);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Back the `clustly_submit` tool. One tool, two paths, so an agent can't stall
|
|
194
|
+
* between "upload" and "submit": pass `content` and we upload+submit in one shot
|
|
195
|
+
* (the common case), or pass a self-hosted `deliverable_ref`+`deliverable_hash`.
|
|
196
|
+
* Reject both/neither with an actionable message instead of guessing.
|
|
197
|
+
*/
|
|
198
|
+
async function submitDeliverable(agent, a) {
|
|
199
|
+
const orderId = a.order_id;
|
|
200
|
+
const hasContent = typeof a.content === "string" && a.content.length > 0;
|
|
201
|
+
const hasRef = typeof a.deliverable_ref === "string" && typeof a.deliverable_hash === "string";
|
|
202
|
+
if (hasContent && hasRef) {
|
|
203
|
+
throw new Error("Pass either `content` OR (`deliverable_ref` + `deliverable_hash`), not both.");
|
|
204
|
+
}
|
|
205
|
+
if (!hasContent && !hasRef) {
|
|
206
|
+
throw new Error("Provide `content` (your work as text), or a hosted `deliverable_ref` + `deliverable_hash`.");
|
|
207
|
+
}
|
|
208
|
+
if (hasContent) {
|
|
209
|
+
return agent.submitContent(orderId, { content: a.content, filename: a.filename }, orderId);
|
|
210
|
+
}
|
|
211
|
+
return agent.submit(orderId, { deliverable_ref: a.deliverable_ref, deliverable_hash: a.deliverable_hash }, orderId);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Boot the MCP server over stdio. Dynamically imports @modelcontextprotocol/sdk
|
|
215
|
+
* (a dependency of the published package, not the Next app). The agent's own
|
|
216
|
+
* operating brief is exposed as the clustly://operating-guide resource.
|
|
217
|
+
*/
|
|
218
|
+
// react-doctor-disable-next-line deslop/unused-export -- entry for the clustly-mcp CLI bin (sdk/cli.ts)
|
|
219
|
+
async function startClustlyMcp(opts) {
|
|
220
|
+
const agent = new index_1.ClustlyAgent({ apiKey: opts.apiKey, baseUrl: opts.baseUrl });
|
|
221
|
+
// Dynamic import keeps the MCP SDK out of the Next app's dependency graph.
|
|
222
|
+
const { Server } = await Promise.resolve(`${"@modelcontextprotocol/sdk/server/index.js"}`).then(s => __importStar(require(s)));
|
|
223
|
+
const { StdioServerTransport } = await Promise.resolve(`${"@modelcontextprotocol/sdk/server/stdio.js"}`).then(s => __importStar(require(s)));
|
|
224
|
+
const { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } = await Promise.resolve(`${"@modelcontextprotocol/sdk/types.js"}`).then(s => __importStar(require(s)));
|
|
225
|
+
const tools = clustlyTools(agent);
|
|
226
|
+
// biome-ignore lint/suspicious/noExplicitAny: MCP SDK types resolved at publish time
|
|
227
|
+
const server = new Server({ name: "clustly", version: "0.1.0" }, { capabilities: { tools: {}, resources: {} } });
|
|
228
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
229
|
+
tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
230
|
+
}));
|
|
231
|
+
// biome-ignore lint/suspicious/noExplicitAny: MCP request shape resolved at publish time
|
|
232
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
233
|
+
const tool = tools.find((t) => t.name === req.params.name);
|
|
234
|
+
if (!tool)
|
|
235
|
+
throw new Error(`unknown tool ${req.params.name}`);
|
|
236
|
+
const result = await tool.handler(req.params.arguments ?? {});
|
|
237
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
238
|
+
});
|
|
239
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
240
|
+
resources: [{ uri: "clustly://operating-guide", name: "How to operate on Clustly", mimeType: "text/markdown" }],
|
|
241
|
+
}));
|
|
242
|
+
server.setRequestHandler(ReadResourceRequestSchema, async () => ({
|
|
243
|
+
contents: [{ uri: "clustly://operating-guide", mimeType: "text/markdown", text: await agent.agentContext() }],
|
|
244
|
+
}));
|
|
245
|
+
// biome-ignore lint/suspicious/noExplicitAny: MCP transport type resolved at publish time
|
|
246
|
+
const transport = new StdioServerTransport();
|
|
247
|
+
await server.connect(transport);
|
|
248
|
+
}
|