@capitalthought/fairsies-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.
@@ -0,0 +1,17 @@
1
+ interface ClientConfig {
2
+ baseUrl: string;
3
+ bearer: string;
4
+ }
5
+ export declare class FairsiesClient {
6
+ private cfg;
7
+ constructor(cfg: ClientConfig);
8
+ get<T>(path: string): Promise<T>;
9
+ post<T>(path: string, body: unknown): Promise<T>;
10
+ }
11
+ export declare class ClientError extends Error {
12
+ status: number;
13
+ bodySnippet: string;
14
+ constructor(status: number, bodySnippet: string);
15
+ }
16
+ export declare function fromEnv(): FairsiesClient;
17
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,52 @@
1
+ // Thin HTTP client for the fairsies Worker admin API.
2
+ // Auth: ADMIN_BEARER_TOKEN (env var, sourced from 1P at server boot).
3
+ export class FairsiesClient {
4
+ cfg;
5
+ constructor(cfg) {
6
+ this.cfg = cfg;
7
+ }
8
+ async get(path) {
9
+ const r = await fetch(`${this.cfg.baseUrl}${path}`, {
10
+ headers: { authorization: `Bearer ${this.cfg.bearer}` },
11
+ });
12
+ if (!r.ok) {
13
+ const text = await r.text();
14
+ throw new ClientError(r.status, text.slice(0, 200));
15
+ }
16
+ return (await r.json());
17
+ }
18
+ async post(path, body) {
19
+ const r = await fetch(`${this.cfg.baseUrl}${path}`, {
20
+ method: "POST",
21
+ headers: {
22
+ authorization: `Bearer ${this.cfg.bearer}`,
23
+ "content-type": "application/json",
24
+ },
25
+ body: JSON.stringify(body),
26
+ });
27
+ if (!r.ok) {
28
+ const text = await r.text();
29
+ throw new ClientError(r.status, text.slice(0, 200));
30
+ }
31
+ return (await r.json());
32
+ }
33
+ }
34
+ export class ClientError extends Error {
35
+ status;
36
+ bodySnippet;
37
+ constructor(status, bodySnippet) {
38
+ super(`fairsies admin HTTP ${status}: ${bodySnippet}`);
39
+ this.status = status;
40
+ this.bodySnippet = bodySnippet;
41
+ this.name = "FairsiesClientError";
42
+ }
43
+ }
44
+ export function fromEnv() {
45
+ const baseUrl = process.env.FAIRSIES_BASE_URL ?? "https://my.fairsies.com";
46
+ const bearer = process.env.FAIRSIES_ADMIN_BEARER;
47
+ if (!bearer) {
48
+ throw new Error("FAIRSIES_ADMIN_BEARER not set. Load via:\n" +
49
+ " export FAIRSIES_ADMIN_BEARER=$(~/.claude/bin/op-logged read 'op://Fairsies/clrghn7lxfwh6fyfy5kkmj3t34/credential')");
50
+ }
51
+ return new FairsiesClient({ baseUrl, bearer });
52
+ }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fairsies MCP server.
4
+ *
5
+ * Agent interface to the 7502 Friends week-selection cycle.
6
+ * Transport: stdio. Backs the 10 Phase 3 tools defined in
7
+ * docs/plans/2026-05-16-impl-plan/implementation-plan.md.
8
+ *
9
+ * All tools call the Worker admin API at $FAIRSIES_BASE_URL (default
10
+ * https://my.fairsies.com) authed via $FAIRSIES_ADMIN_BEARER. The Worker
11
+ * owns D1 access; the MCP server is a thin client.
12
+ */
13
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fairsies MCP server.
4
+ *
5
+ * Agent interface to the 7502 Friends week-selection cycle.
6
+ * Transport: stdio. Backs the 10 Phase 3 tools defined in
7
+ * docs/plans/2026-05-16-impl-plan/implementation-plan.md.
8
+ *
9
+ * All tools call the Worker admin API at $FAIRSIES_BASE_URL (default
10
+ * https://my.fairsies.com) authed via $FAIRSIES_ADMIN_BEARER. The Worker
11
+ * owns D1 access; the MCP server is a thin client.
12
+ */
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import { z } from "zod";
16
+ import { fromEnv, ClientError } from "./client.js";
17
+ const server = new McpServer({ name: "fairsies-mcp", version: "0.1.0" }, {
18
+ capabilities: { tools: {} },
19
+ instructions: "fairsies — agent interface to the 7502 Friends week-selection cycle. " +
20
+ "All partner-facing comms go through the Worker (iMessage primary + email backup). " +
21
+ "Call `overview` first when investigating; never invent partner_ids or week dates — " +
22
+ "always pull them from `get_cycle_status` or `get_allocation`.",
23
+ });
24
+ function ok(data) {
25
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
26
+ }
27
+ function err(code, message, extra) {
28
+ return ok({ ok: false, code, message, ...(extra ?? {}) });
29
+ }
30
+ function wrapErr(e) {
31
+ if (e instanceof ClientError) {
32
+ return err("admin_http", e.message, { status: e.status });
33
+ }
34
+ return err("exception", e instanceof Error ? e.message : String(e));
35
+ }
36
+ // ---------- Tool 1: overview (Inspectable State, Agent First §9) ----------
37
+ server.registerTool("overview", {
38
+ description: "Operational snapshot: cycle counts, recent activity tail, channel-success rates, watchdog health. Call this first to orient.",
39
+ inputSchema: {},
40
+ }, async () => {
41
+ try {
42
+ const data = await fromEnv().get("/admin/overview");
43
+ return ok(data);
44
+ }
45
+ catch (e) {
46
+ return wrapErr(e);
47
+ }
48
+ });
49
+ // ---------- Tool 2: get_cycle_status ----------
50
+ server.registerTool("get_cycle_status", {
51
+ description: "Current state of a cycle: status, milestone progress, partner submissions outstanding, recent comms.",
52
+ inputSchema: {
53
+ cycle_id: z
54
+ .string()
55
+ .optional()
56
+ .describe("Cycle ID (e.g. '2026-2H'). Omit for current active cycle."),
57
+ },
58
+ }, async ({ cycle_id }) => {
59
+ try {
60
+ const path = cycle_id
61
+ ? `/admin/cycle-status?cycle_id=${encodeURIComponent(cycle_id)}`
62
+ : "/admin/cycle-status";
63
+ const data = await fromEnv().get(path);
64
+ return ok(data);
65
+ }
66
+ catch (e) {
67
+ return wrapErr(e);
68
+ }
69
+ });
70
+ // ---------- Tool 3: list_pending_swaps ----------
71
+ server.registerTool("list_pending_swaps", {
72
+ description: "Swap proposals awaiting partner confirmation.",
73
+ inputSchema: {
74
+ cycle_id: z.string().optional().describe("Cycle ID; omit for current cycle."),
75
+ },
76
+ }, async ({ cycle_id }) => {
77
+ try {
78
+ const path = cycle_id
79
+ ? `/admin/swaps?status=proposed&cycle_id=${encodeURIComponent(cycle_id)}`
80
+ : "/admin/swaps?status=proposed";
81
+ return ok(await fromEnv().get(path));
82
+ }
83
+ catch (e) {
84
+ return wrapErr(e);
85
+ }
86
+ });
87
+ // ---------- Tool 4: propose_swap ----------
88
+ server.registerTool("propose_swap", {
89
+ description: "Initiate a swap proposal between two partners. Requires confirmation from the counterparty before locking.",
90
+ inputSchema: {
91
+ cycle_id: z.string().describe("Cycle ID (e.g. '2026-2H')."),
92
+ from_partner: z.string().describe("Partner proposing the swap (currently owns week_a)."),
93
+ to_partner: z.string().describe("Counterparty (currently owns week_b)."),
94
+ week_a: z.string().describe("ISO date (YYYY-MM-DD), week the proposer is giving up."),
95
+ week_b: z.string().describe("ISO date (YYYY-MM-DD), week the proposer wants."),
96
+ ttl_seconds: z
97
+ .number()
98
+ .int()
99
+ .positive()
100
+ .optional()
101
+ .describe("Optional TTL override (default 7 days)."),
102
+ },
103
+ }, async ({ cycle_id, from_partner, to_partner, week_a, week_b, ttl_seconds }) => {
104
+ try {
105
+ return ok(await fromEnv().post("/admin/swaps/propose", {
106
+ cycle_id,
107
+ from_partner_id: from_partner,
108
+ to_partner_id: to_partner,
109
+ week_from: week_a,
110
+ week_to: week_b,
111
+ ttl_seconds,
112
+ }));
113
+ }
114
+ catch (e) {
115
+ return wrapErr(e);
116
+ }
117
+ });
118
+ // ---------- Tool 5: confirm_swap ----------
119
+ server.registerTool("confirm_swap", {
120
+ description: "Counterparty confirms a pending swap. Atomically flips picks ownership and locks the trade.",
121
+ inputSchema: {
122
+ swap_id: z.string().describe("Swap proposal ID returned by propose_swap."),
123
+ partner_id: z.string().describe("Confirming partner (must be the counterparty)."),
124
+ },
125
+ }, async ({ swap_id, partner_id }) => {
126
+ try {
127
+ return ok(await fromEnv().post(`/admin/swaps/${encodeURIComponent(swap_id)}/confirm`, {
128
+ confirming_partner_id: partner_id,
129
+ }));
130
+ }
131
+ catch (e) {
132
+ return wrapErr(e);
133
+ }
134
+ });
135
+ // ---------- Tool 6: release_to_rental_pool ----------
136
+ server.registerTool("release_to_rental_pool", {
137
+ description: "Partner releases an assigned week. intent='rental' triggers Schedule II §5 ROFR (offers the week to other partners at 50% net credit for 7 days). intent='gift' or 'donate' is logged but contract-exempt from ROFR per §5(d).",
138
+ inputSchema: {
139
+ cycle_id: z.string().describe("Cycle ID (e.g. '2026-2H')."),
140
+ week_friday: z.string().describe("ISO date of the week (Friday check-in)."),
141
+ offering_partner_id: z.string().describe("Releasing partner (current pick owner)."),
142
+ intent: z.enum(["rental", "gift", "donate"]).describe("rental=ROFR fires; gift/donate=just logged."),
143
+ gross_rate_usd: z
144
+ .number()
145
+ .positive()
146
+ .optional()
147
+ .describe("Required for intent=rental: gross Rosewood Signature Penthouse rate for the full week (USD total). Net credit to claiming partner = gross × 50%. Rosewood's booking engine blocks scraping; rate must come from the partner or Sebastien Dental/Michael Chiche at Rosewood."),
148
+ },
149
+ }, async ({ cycle_id, week_friday, offering_partner_id, intent, gross_rate_usd }) => {
150
+ try {
151
+ return ok(await fromEnv().post("/admin/rental-release", {
152
+ cycle_id,
153
+ week_friday,
154
+ offering_partner_id,
155
+ intent,
156
+ gross_rate_usd,
157
+ }));
158
+ }
159
+ catch (e) {
160
+ return wrapErr(e);
161
+ }
162
+ });
163
+ // ---------- Tool 7: get_allocation ----------
164
+ server.registerTool("get_allocation", {
165
+ description: "Per-partner allocation for a cycle: assigned weeks + priority-vs-fallback reason per pick.",
166
+ inputSchema: {
167
+ cycle_id: z.string().optional(),
168
+ },
169
+ }, async ({ cycle_id }) => {
170
+ try {
171
+ const path = cycle_id
172
+ ? `/admin/allocation?cycle_id=${encodeURIComponent(cycle_id)}`
173
+ : "/admin/allocation";
174
+ const data = await fromEnv().get(path);
175
+ return ok(data);
176
+ }
177
+ catch (e) {
178
+ return wrapErr(e);
179
+ }
180
+ });
181
+ // ---------- Tool 8: list_recent_inbound ----------
182
+ server.registerTool("list_recent_inbound", {
183
+ description: "Recent inbound iMessages with parsed intents. Use to triage 'what did Jeffrey just say'.",
184
+ inputSchema: {
185
+ partner_id: z.string().optional(),
186
+ limit: z.number().int().positive().max(50).default(10),
187
+ },
188
+ }, async ({ partner_id, limit }) => {
189
+ try {
190
+ const params = new URLSearchParams();
191
+ if (partner_id)
192
+ params.set("partner_id", partner_id);
193
+ params.set("limit", String(limit));
194
+ const data = await fromEnv().get(`/admin/inbound?${params.toString()}`);
195
+ return ok(data);
196
+ }
197
+ catch (e) {
198
+ return wrapErr(e);
199
+ }
200
+ });
201
+ // ---------- Tool 9: send_partner_imessage ----------
202
+ server.registerTool("send_partner_imessage", {
203
+ description: "Operator-side: send a partner a one-off iMessage via the mikeybot bridge. Logged to comms_log like any milestone send. Use sparingly — partners expect cycle-driven messages.",
204
+ inputSchema: {
205
+ partner_id: z.string(),
206
+ body: z.string().max(1500),
207
+ milestone: z
208
+ .string()
209
+ .optional()
210
+ .describe("Optional label for comms_log (e.g. 'operator-nudge', 'swap-broker')."),
211
+ },
212
+ }, async ({ partner_id, body, milestone }) => {
213
+ try {
214
+ const data = await fromEnv().post("/admin/send-imessage", {
215
+ partner_id,
216
+ body,
217
+ milestone: milestone ?? "operator-oneoff",
218
+ });
219
+ return ok(data);
220
+ }
221
+ catch (e) {
222
+ return wrapErr(e);
223
+ }
224
+ });
225
+ // ---------- Tool 10: resend_milestone ----------
226
+ server.registerTool("resend_milestone", {
227
+ description: "Replay a specific milestone send to one channel for one partner. Use when iMessage fails but email succeeded and operator wants to retry iMessage.",
228
+ inputSchema: {
229
+ cycle_id: z.string(),
230
+ partner_id: z.string(),
231
+ milestone: z.string(),
232
+ channel: z.enum(["imessage", "email"]),
233
+ },
234
+ }, async ({ cycle_id, partner_id, milestone, channel }) => {
235
+ try {
236
+ const data = await fromEnv().post("/admin/resend", {
237
+ cycle_id,
238
+ partner_id,
239
+ milestone,
240
+ channel,
241
+ });
242
+ return ok(data);
243
+ }
244
+ catch (e) {
245
+ return wrapErr(e);
246
+ }
247
+ });
248
+ // ---------- start stdio transport ----------
249
+ async function main() {
250
+ const transport = new StdioServerTransport();
251
+ await server.connect(transport);
252
+ }
253
+ main().catch((err) => {
254
+ console.error("fairsies-mcp boot error:", err);
255
+ process.exit(1);
256
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@capitalthought/fairsies-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for fairsies — agent interface to the 7502 Friends week-selection cycle. Tools to read cycle state, propose swaps, release weeks to rental pool, and inspect operational state.",
5
+ "license": "MIT",
6
+ "author": "Capital Thought",
7
+ "type": "module",
8
+ "bin": {
9
+ "fairsies-mcp": "dist/index.js"
10
+ },
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "files": [
14
+ "dist",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "start": "node dist/index.js",
20
+ "dev": "tsc --watch",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.29.0",
28
+ "zod": "^3.25.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.0.0",
32
+ "typescript": "^5.6.0"
33
+ }
34
+ }