Skip to content

Commit c0df049

Browse files
authored
Merge pull request #16 from Affitor/feat/recipe-registry-mcp-plan
feat(recipes): canonical recipe registry + MCP get_integration_plan (#5+#7)
2 parents e6f77fd + 97e3705 commit c0df049

11 files changed

Lines changed: 571 additions & 40 deletions

File tree

package-lock.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"description": "Affitor CLI (affitor). The SDK now ships separately as @affitor/sdk.",
66
"type": "module",
77
"workspaces": [
8+
"packages/recipes",
89
"packages/cli",
910
"packages/mcp"
1011
],

packages/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
"@inquirer/prompts": "^7.5.0",
4545
"picocolors": "^1.1.1",
4646
"open": "^10.1.0",
47-
"stripe": "^17.7.0"
47+
"stripe": "^17.7.0",
48+
"@affitor/recipes": "*"
4849
},
4950
"devDependencies": {
5051
"typescript": "^5.7.0",

packages/cli/src/lib/server-tracking.ts

Lines changed: 25 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { readFileSync } from "node:fs";
22
import { join } from "node:path";
3+
import { getRecipe, type Provider as RecipeProvider } from "@affitor/recipes";
34

45
/**
56
* Server-side conversion tracking helpers for the wizard (@affitor/sdk/server path).
67
* Pure functions (detection + generated source/snippets) — easy to unit test.
78
* The wizard installs @affitor/sdk + scaffolds the client file, then PRINTS the
89
* trackLead/trackSale snippets — it never edits auth/payment code (per PR-4 §9.3).
10+
*
11+
* The per-provider `sale` snippet is SOURCED FROM `@affitor/recipes` (the canonical
12+
* single source of truth read by CLI + MCP + docs). Do NOT re-inline the snippet
13+
* strings here — edit them in `@affitor/recipes` so all three surfaces stay in sync.
914
*/
1015

1116
export type PaymentProvider = "stripe" | "polar" | "lemonsqueezy" | "paddle" | "unknown";
@@ -52,6 +57,19 @@ export interface TrackingSnippets {
5257
refund: string;
5358
}
5459

60+
/**
61+
* The trackSale snippet body for a provider, sourced from the canonical
62+
* `@affitor/recipes` registry. We use framework "unknown" (the snippet body is
63+
* framework-independent) + mode "s2s" (so a sale snippet exists, not the
64+
* Connect/metadata-only path), then strip the leading `@affitor/sdk/server`
65+
* import + its blank line — the wizard prints that import via the lead snippet.
66+
*/
67+
function recipeSaleBody(provider: PaymentProvider): string {
68+
const recipe = getRecipe("unknown", provider as RecipeProvider, "s2s");
69+
const snippet = recipe.sale?.snippet ?? "";
70+
return snippet.replace(/^import \{ Affitor \} from '@affitor\/sdk\/server';\n\n/, "");
71+
}
72+
5573
/**
5674
* Tailored trackLead + trackSale snippets. The sale snippet is provider-specific
5775
* (event name + field paths from the verified provider research). Printed, not
@@ -67,60 +85,30 @@ export function serverTrackingSnippets(
6785
`await affitor.trackLead({ customerExternalId: user.id, clickId: cookies.affitor_click_id });`,
6886
].join("\n");
6987

88+
// The trackSale snippet body is canonical in @affitor/recipes. We pull it from
89+
// there (framework "unknown" — the body is framework-independent; only the
90+
// inject_target differs, which the wizard doesn't print) and strip the SDK
91+
// import line the recipe prepends (the wizard already prints the import as part
92+
// of the lead snippet, via `importPath`).
93+
const sale = recipeSaleBody(provider);
94+
7095
let saleContext: string;
71-
let sale: string;
7296
switch (provider) {
7397
case "polar":
7498
saleContext = "in your Polar webhook handler (event 'order.paid')";
75-
sale = [
76-
`await affitor.trackSale({`,
77-
` customerExternalId: order.metadata.user_id ?? order.customer_id,`,
78-
` amount: order.total_amount, // integer cents`,
79-
` invoiceId: order.id,`,
80-
` saleType: order.subscription_id ? 'subscription' : 'payment',`,
81-
`});`,
82-
].join("\n");
8399
break;
84100
case "lemonsqueezy":
85101
saleContext = "in your Lemon Squeezy webhook handler (event 'order_created')";
86-
sale = [
87-
`await affitor.trackSale({`,
88-
` customerExternalId: payload.meta.custom_data?.user_id ?? data.attributes.user_email,`,
89-
` amount: data.attributes.total, // integer cents`,
90-
` invoiceId: String(data.id),`,
91-
`});`,
92-
].join("\n");
93102
break;
94103
case "paddle":
95104
saleContext = "in your Paddle webhook handler (event 'transaction.completed')";
96-
sale = [
97-
`await affitor.trackSale({`,
98-
` customerExternalId: data.custom_data?.user_id,`,
99-
` amount: Number(data.details.totals.total), // cents`,
100-
` invoiceId: data.id,`,
101-
`});`,
102-
].join("\n");
103105
break;
104106
case "stripe":
105107
saleContext =
106108
"Stripe: prefer Connect autocapture (`affitor stripe connect`). To track manually, in your checkout.session.completed handler";
107-
sale = [
108-
`await affitor.trackSale({`,
109-
` customerExternalId: session.client_reference_id,`,
110-
` amount: session.amount_total, // cents`,
111-
` invoiceId: session.id,`,
112-
`});`,
113-
].join("\n");
114109
break;
115110
default:
116111
saleContext = "in your payment provider's webhook handler, when a payment succeeds";
117-
sale = [
118-
`await affitor.trackSale({`,
119-
` customerExternalId: user.id,`,
120-
` amount: amountInCents,`,
121-
` invoiceId: transactionId,`,
122-
`});`,
123-
].join("\n");
124112
}
125113

126114
const refund = [

packages/mcp/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"dependencies": {
5050
"@modelcontextprotocol/sdk": "^1.29.0",
5151
"@affitor/sdk": "^2.1.0",
52+
"@affitor/recipes": "*",
5253
"zod": "^3.23.8"
5354
},
5455
"devDependencies": {

packages/mcp/src/index.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
2929
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3030
import { z } from 'zod';
3131
import { Affitor } from '@affitor/sdk/server';
32+
import { getIntegrationPlan } from '@affitor/recipes';
3233

3334
/**
3435
* The subset of the `Affitor` client the MCP tools depend on. Declaring it here
@@ -77,8 +78,12 @@ async function runCall(fn: () => Promise<unknown>): Promise<CallToolResult> {
7778
}
7879

7980
/**
80-
* Register the 5 Affitor tools on an MCP server. Exported for unit testing the
81+
* Register the 6 Affitor tools on an MCP server. Exported for unit testing the
8182
* handlers with a stubbed `Affitor` client.
83+
*
84+
* Five tools wrap the `Affitor` client (readiness + track*). The sixth,
85+
* `affitor_get_integration_plan`, is PURE — it reads the canonical recipe
86+
* registry (`@affitor/recipes`) and never touches the client or the network.
8287
*/
8388
export function registerTools(server: McpServer, affitor: AffitorLike): void {
8489
server.registerTool(
@@ -217,6 +222,39 @@ export function registerTools(server: McpServer, affitor: AffitorLike): void {
217222
}),
218223
),
219224
);
225+
226+
server.registerTool(
227+
'affitor_get_integration_plan',
228+
{
229+
description:
230+
'Return the deterministic Affitor payment-tracking integration plan for a given stack — the install, the checkout-metadata snippet, the trackSale snippet + where to inject it, and the self-verify step. The agent follows this instead of guessing a contract.',
231+
inputSchema: {
232+
framework: z
233+
.enum(['next-app', 'next-pages', 'fastify', 'express', 'node', 'unknown'])
234+
.describe('The detected app framework. Determines where trackSale is injected.'),
235+
provider: z
236+
.enum(['stripe', 'polar', 'lemonsqueezy', 'paddle', 'unknown'])
237+
.default('stripe')
238+
.describe('The detected payment provider.'),
239+
mode: z
240+
.enum(['stripe_connect', 's2s'])
241+
.optional()
242+
.default('stripe_connect')
243+
.describe(
244+
"Payment-tracking mode. 'stripe_connect' (default) = Connect autocaptures the sale (metadata only, no trackSale); 's2s' = inject trackSale in your webhook.",
245+
),
246+
},
247+
},
248+
async (args) => {
249+
// Pure: reads the canonical recipe registry. No SDK client, no network.
250+
const plan = getIntegrationPlan({
251+
framework: args.framework,
252+
provider: args.provider,
253+
mode: args.mode,
254+
});
255+
return ok(plan);
256+
},
257+
);
220258
}
221259

222260
/** Read the server version from this package's package.json at runtime. */

packages/mcp/tests/server.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const EXPECTED_TOOLS = [
1717
'affitor_track_sale',
1818
'affitor_track_refund',
1919
'affitor_track_click',
20+
'affitor_get_integration_plan',
2021
] as const;
2122

2223
// ── 1. Integration: spawn the BUILT server over stdio, drive with MCP Client ──
@@ -44,7 +45,7 @@ describe('@affitor/mcp stdio server (spawned, built dist)', () => {
4445
expect(info?.name).toBe('affitor');
4546
});
4647

47-
it('tools/list returns the 5 tools with input schemas', async () => {
48+
it('tools/list returns the 6 tools with input schemas', async () => {
4849
const { tools } = await client.listTools();
4950
const names = tools.map((t) => t.name).sort();
5051
expect(names).toEqual([...EXPECTED_TOOLS].sort());
@@ -191,6 +192,48 @@ describe('@affitor/mcp tool handlers (stubbed client)', () => {
191192
await client.close();
192193
});
193194

195+
it('affitor_get_integration_plan returns a pure plan (no client call)', async () => {
196+
const stub: AffitorLike = {
197+
readiness: async () => {
198+
throw new Error('readiness should not be called');
199+
},
200+
trackLead: async () => {
201+
throw new Error('trackLead should not be called');
202+
},
203+
trackSale: async () => {
204+
throw new Error('trackSale should not be called');
205+
},
206+
trackRefund: async () => {
207+
throw new Error('trackRefund should not be called');
208+
},
209+
trackClick: async () => {
210+
throw new Error('trackClick should not be called');
211+
},
212+
};
213+
214+
const server = new McpServer({ name: 'affitor', version: '0.1.0' });
215+
registerTools(server, stub);
216+
const [ct, st] = InMemoryTransport.createLinkedPair();
217+
const client = new Client({ name: 'test', version: '0.0.0' });
218+
await Promise.all([server.connect(st), client.connect(ct)]);
219+
220+
const res = await client.callTool({
221+
name: 'affitor_get_integration_plan',
222+
arguments: { framework: 'fastify', provider: 'stripe', mode: 's2s' },
223+
});
224+
expect(res.isError).toBeFalsy();
225+
const text = (res.content as { type: string; text: string }[])[0].text;
226+
const plan = JSON.parse(text) as {
227+
steps: string[];
228+
recipe: { sale_path: string; sale: { inject_target: string } | null };
229+
};
230+
expect(plan.recipe.sale_path).toBe('webhook_sdk');
231+
expect(plan.recipe.sale?.inject_target).toContain("fastify.post('/webhooks/stripe'");
232+
expect(plan.steps).toHaveLength(5);
233+
234+
await client.close();
235+
});
236+
194237
it('affitor_track_lead rejects when neither customerExternalId nor clickId is given', async () => {
195238
const stub: AffitorLike = {
196239
readiness: async () => ({}) as never,

packages/recipes/package.json

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"name": "@affitor/recipes",
3+
"version": "0.1.0",
4+
"description": "Canonical per-stack payment-tracking recipe registry for Affitor — the single source of truth read by the CLI, the MCP server, and the docs. Pure data, no runtime deps.",
5+
"type": "module",
6+
"main": "./dist/index.js",
7+
"types": "./dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.js"
12+
}
13+
},
14+
"files": [
15+
"dist"
16+
],
17+
"scripts": {
18+
"build": "tsc",
19+
"lint": "tsc --noEmit",
20+
"test": "vitest run --passWithNoTests"
21+
},
22+
"keywords": [
23+
"affitor",
24+
"recipes",
25+
"affiliate",
26+
"tracking",
27+
"payment",
28+
"integration",
29+
"agent"
30+
],
31+
"author": "Affitor <hello@affitor.com>",
32+
"license": "MIT",
33+
"repository": {
34+
"type": "git",
35+
"url": "https://github.com/Affitor/cli"
36+
},
37+
"homepage": "https://affitor.com",
38+
"engines": {
39+
"node": ">=18"
40+
},
41+
"devDependencies": {
42+
"typescript": "^5.7.0",
43+
"vitest": "^3.1.0",
44+
"@types/node": "^22.0.0"
45+
}
46+
}

0 commit comments

Comments
 (0)