diff --git a/package-lock.json b/package-lock.json index 155b9c4..3ab8a21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3432,7 +3432,7 @@ }, "packages/mcp": { "name": "@affitor/mcp", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", "dependencies": { "@affitor/recipes": "^0.1.0", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index f4a5312..e1ef7ac 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog — @affitor/mcp +## 0.2.4 +- Coupon tools: `affitor_create_coupon`, `affitor_lock_coupon` and `affitor_get_coupon` call `POST /api/v1/coupons`, `POST /api/v1/coupons/:couponId/lock` and `GET /api/v1/coupons/:couponId`. Create and lock take a required `idempotency_key`, sent as the `Idempotency-Key` header; retry with the same key and the same payload. Inputs are strict and carry no program, account, key or permission field. +- New environment variables: `AFFITOR_AGENT_KEY` (an agent key with `coupons:write`, used only by the coupon tools) and `AFFITOR_MCP_COUPONS=1` (adds the coupon tools; off by default). The coupon tools register only when both are set. +- `AFFITOR_API_KEY` is now optional: the server starts with only the agent key and `AFFITOR_MCP_COUPONS=1`. The tracking tools still need `AFFITOR_API_KEY`, and `AFFITOR_MCP_WRITE` works as before. Neither key is ever used in place of the other. +- A pending or held coupon (HTTP 202) comes back as a pending result, not an error and not an active code. Other API errors come back with only the HTTP status, the API's `error.code` and retry info. +- New export `registerCouponTools(server, couponClient)` (plus `createCouponClient`). `registerTools(server, affitor, runVerification?, options?)` is unchanged. +- `server.json`: the npm package entry is 0.2.4 and lists the two new variables; `AFFITOR_API_KEY` is no longer required. + ## 0.2.3 (registry 0.2.4) - Tool and input descriptions rewritten: each tool says when to use it and what it returns; each input says what it means, where the value comes from, an example and what leaving it out does. No change to tools, schemas or behaviour. - A full refund leaves `refundAmountCents` out; sending 0 is refused for newer sales. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 1b8f4af..dbbbcf1 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -30,11 +30,13 @@ Restart your client and the `affitor` tools will be available to the agent. | Variable | Required | Description | | ------------------ | -------- | --------------------------------------------------------------------------- | -| `AFFITOR_API_KEY` | Yes | Your Affitor **program API key** (Bearer). Server-side only — never ship it to a browser. | +| `AFFITOR_API_KEY` | For tracking tools | Your Affitor **program API key** (Bearer). Server-side only — never ship it to a browser. | | `AFFITOR_API_URL` | No | API base override. Defaults to `https://api.affitor.com`. | | `AFFITOR_MCP_WRITE` | No | Set to `1` to register the two tools that move money: `affitor_track_sale` and `affitor_track_refund`. Off by default, so an agent exploring tools cannot create a real sale or reverse a commission. | +| `AFFITOR_AGENT_KEY` | For coupon tools | An Affitor **agent key** with the `coupons:write` scope. Used only by the coupon tools, never by the tracking tools. Server-side only. | +| `AFFITOR_MCP_COUPONS` | No | Set to `1` to add the coupon tools. Needs `AFFITOR_AGENT_KEY`. Off by default. | -If `AFFITOR_API_KEY` is not set, the server prints a clear message to stderr and exits. +The two tool families are independent: the tracking tools need `AFFITOR_API_KEY`, the coupon tools need `AFFITOR_AGENT_KEY` plus `AFFITOR_MCP_COUPONS=1`, and neither key is ever used in place of the other. If neither family is configured, the server prints a clear message to stderr and exits. ## Tools @@ -95,6 +97,52 @@ Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`, `idempote Each tool returns the Affitor API's JSON payload as text content. On a failed request (a thrown error or an `{ ok: false }` envelope) the tool returns an MCP error result with the message. +## Coupon tools + +`affitor_create_coupon`, `affitor_lock_coupon` and `affitor_get_coupon` let an agent issue and stop one-time discount codes tied to a partner's referral link. Coupons are not on for every program: Affitor enables them per program, and they need an agent key that a program Owner has granted `coupons:write`. The program comes from that key; no tool takes a program, account, key or permission argument, and unknown fields are refused. + +```json +{ + "mcpServers": { + "affitor": { + "command": "npx", + "args": ["-y", "@affitor/mcp"], + "env": { + "AFFITOR_AGENT_KEY": "your_agent_key", + "AFFITOR_MCP_COUPONS": "1" + } + } + } +} +``` + +| Tool | Calls | Description | +| ---- | ----- | ----------- | +| `affitor_create_coupon` | `POST /api/v1/coupons` | Create a one-time code for one referral link. Marked destructive. | +| `affitor_lock_coupon` | `POST /api/v1/coupons/:couponId/lock` | Stop new redemptions of one code. It does not delete the coupon, undo applied discounts or refund anyone. Marked destructive. | +| `affitor_get_coupon` | `GET /api/v1/coupons/:couponId` | Read a coupon's status, version and, once active, its code. Read-only. | + +**`affitor_create_coupon`** +- `partner_program_id: number`, `referral_link_id: number`: positive ids (1 to 10 digits). +- `product_id: string`: the program's catalogue key, such as `replay`. +- `discount`: exactly one of `{ "kind": "percent", "percent_off_bps": 1..10000, "duration": "once" }` or `{ "kind": "fixed", "amount_off_minor": , "currency": "usd", "duration": "once" }`. +- `prefix?: string`: up to 12 letters or digits; omit for the default. +- `max_redemptions?: number`: omit for no redemption limit. +- `expires_at?: string`: future UTC ISO time; omit for no expiry. +- `idempotency_key: string`: 8 to 128 of `A-Z a-z 0-9 : _ -`. + +**`affitor_lock_coupon`** +- `coupon_id: number`, `expected_version: number`, `idempotency_key: string`. + +**`affitor_get_coupon`** +- `coupon_id: number`. + +`idempotency_key` is sent as the `Idempotency-Key` header, never in the body. On a timeout or error, retry with the **same key and the same payload**. HTTP 409 means the link already has an existing or pending coupon, the version is stale, or the key was used with another payload: it is never a reason to try a new key. + +A pending or held coupon (HTTP 202, or a `pending`, `held` or `lock_pending` status) comes back as a normal result with `"pending": true`: the code is not active yet, so poll `affitor_get_coupon` instead of creating again. Other API errors come back as an MCP error with only `http_status`, the API's `error_code` and `retry_after_seconds` when given. + +For your own server, `registerCouponTools(server, createCouponClient(agentKey, apiUrl))` adds the same tools; `registerTools` is unchanged. + ## License MIT diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 119d144..673eb23 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,7 +1,7 @@ { "name": "@affitor/mcp", "mcpName": "com.affitor/mcp", - "version": "0.2.3", + "version": "0.2.4", "description": "Model Context Protocol (MCP) stdio server for Affitor. Lets AI agents (Claude Desktop, Cursor and more) call Affitor affiliate-tracking tools.", "type": "module", "main": "./dist/index.js", diff --git a/packages/mcp/server.json b/packages/mcp/server.json index 40f5664..95eab2f 100644 --- a/packages/mcp/server.json +++ b/packages/mcp/server.json @@ -14,7 +14,7 @@ { "registryType": "npm", "identifier": "@affitor/mcp", - "version": "0.2.3", + "version": "0.2.4", "transport": { "type": "stdio" }, @@ -22,9 +22,20 @@ { "name": "AFFITOR_API_KEY", "description": "Your Affitor program API key. Server-side only.", - "isRequired": true, + "isRequired": false, "isSecret": true }, + { + "name": "AFFITOR_AGENT_KEY", + "description": "An Affitor agent key with the coupons:write scope, for the coupon tools. Used only by the coupon tools. Server-side only.", + "isRequired": false, + "isSecret": true + }, + { + "name": "AFFITOR_MCP_COUPONS", + "description": "Set to 1 to add the coupon tools (create, lock, get). Needs AFFITOR_AGENT_KEY. Off by default.", + "isRequired": false + }, { "name": "AFFITOR_API_URL", "description": "API base override. Defaults to https://api.affitor.com.", diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 66aa7ea..26b7191 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -14,6 +14,11 @@ * not be able to create a real sale or reverse a commission by accident. Every * tool carries MCP annotations so clients can ask before a write. * + * The coupon tools (`affitor_create_coupon`, `affitor_lock_coupon`, + * `affitor_get_coupon`) are a separate family: they use only `AFFITOR_AGENT_KEY` + * and are registered only when `AFFITOR_MCP_COUPONS=1`. Either family can run + * without the other's key. + * * { * "mcpServers": { * "affitor": { @@ -424,6 +429,201 @@ function registerWriteTools(server: McpServer, affitor: AffitorLike, trackTestSa ); } +/** One coupon API call: the route, an optional JSON body and the caller's Idempotency-Key. */ +export interface CouponRequest { + method: 'GET' | 'POST'; + path: string; + body?: Record; + idempotencyKey?: string; +} + +/** The HTTP status, the parsed JSON body (null if none) and the Retry-After header if sent. */ +export interface CouponResponse { + status: number; + body: unknown; + retryAfter?: string; +} + +/** Sends one coupon API call. Injected into `registerCouponTools` so it stays unit-testable. */ +export type CouponClient = (request: CouponRequest) => Promise; + +/** + * A coupon client bound to the agent key (`AFFITOR_AGENT_KEY`) and the API base. + * It never sees the program key, and the Idempotency-Key goes only in the header. + */ +export function createCouponClient(agentKey: string, apiUrl: string): CouponClient { + return async ({ method, path, body, idempotencyKey }) => { + const headers: Record = { Authorization: `Bearer ${agentKey}`, Accept: 'application/json' }; + if (body) headers['Content-Type'] = 'application/json'; + if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey; + const res = await fetch(`${apiUrl}${path}`, { method, headers, ...(body ? { body: JSON.stringify(body) } : {}) }); + const parsed: unknown = await res.json().catch(() => null); + const retryAfter = res.headers.get('Retry-After'); + return { status: res.status, body: parsed, ...(retryAfter ? { retryAfter } : {}) }; + }; +} + +const PENDING_STATUSES = new Set(['pending', 'held', 'lock_pending']); +const SAME_KEY_HINT = + 'Retry only with the same idempotency_key and the same payload. A 409 means an existing or pending assignment for that link, a stale version, or a key already used with another payload; it is never a reason to try a new key.'; + +/** + * Map a coupon API response to an MCP result. A 2xx body is returned as JSON text; + * a 202 or a pending/held/lock_pending body is marked pending (not an error, not an + * active code). Any other status becomes an error carrying only the HTTP status, the + * API's stable error.code and retry info: no API message, request args or key. + */ +function couponResult(res: CouponResponse): CallToolResult { + const body = (res.body && typeof res.body === 'object' ? res.body : {}) as Record; + const data = body.data as { status?: unknown } | undefined; + if (res.status === 202 || (res.status >= 200 && res.status < 300 && PENDING_STATUSES.has(String(data?.status)))) { + return ok({ + pending: true, + http_status: res.status, + next_action: 'Not an active code yet. Poll affitor_get_coupon with this coupon_id; do not send a new create or lock.', + ...body, + }); + } + if (res.status >= 200 && res.status < 300) return ok(res.body); + + const error = body.error as { code?: unknown; retry_after_seconds?: unknown } | undefined; + const code = typeof error?.code === 'string' && /^[a-z0-9_]{1,64}$/.test(error.code) ? error.code : 'unknown_error'; + const retryAfter = typeof error?.retry_after_seconds === 'number' ? error.retry_after_seconds : Number(res.retryAfter); + return { + content: [ + { + type: 'text', + text: asText({ + http_status: res.status, + error_code: code, + ...(Number.isFinite(retryAfter) ? { retry_after_seconds: retryAfter } : {}), + ...(res.status === 409 ? { hint: SAME_KEY_HINT } : {}), + }), + }, + ], + isError: true, + }; +} + +/** Run a coupon call; a network failure becomes a generic error with no request details. */ +async function runCouponCall(client: CouponClient, request: CouponRequest): Promise { + try { + return couponResult(await client(request)); + } catch { + return fail(asText({ error_code: 'request_failed', ...(request.idempotencyKey ? { hint: SAME_KEY_HINT } : {}) })); + } +} + +// v1 resource ids: positive integers of 1..10 digits. +const v1Id = () => z.number().int().min(1).max(9_999_999_999); +const safePositiveInt = () => z.number().int().positive().max(Number.MAX_SAFE_INTEGER); +const idempotencyKey = () => + z + .string() + .regex(/^[A-Za-z0-9:_-]{8,128}$/) + .describe( + 'A key you choose for this one request, 8 to 128 of A-Z a-z 0-9 : _ -, such as cpn_link82_v1. Sent as the Idempotency-Key header. Keep it, and on any retry send the same key with the same payload.', + ); + +/** + * Register the coupon tools (create, lock, get) on an MCP server. They act only in + * the program of the agent key behind `couponClient`; no tool takes a program, + * account, key or permission argument. Every input object is strict. + */ +export function registerCouponTools(server: McpServer, couponClient: CouponClient): void { + server.registerTool( + 'affitor_create_coupon', + { + description: + "Create a one-time discount code for one partner's referral link in this agent key's program. Use it when a partner needs a coupon for their link. Retry with the same idempotency_key and the same payload. HTTP 409 means the link has an existing or pending coupon, or the key was used with another payload; never try a new key. Returns the coupon status; pending means poll affitor_get_coupon.", + inputSchema: z + .object({ + partner_program_id: v1Id().describe("The partner's enrollment id in this program, from Affitor. It must own referral_link_id."), + referral_link_id: v1Id().describe("The id of the partner's referral link the code is tied to, from Affitor. A link holds at most one coupon."), + product_id: z + .string() + .min(1) + .describe("The program's catalogue key for the product the discount applies to, such as replay. Affitor maps it to the real product; never a Stripe prod_ or price_ id."), + discount: z + .discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('percent'), + percent_off_bps: z.number().int().min(1).max(10000).describe('The percent off in basis points, 1 to 10000, such as 1000 for 10%.'), + duration: z.literal('once').describe('Always once: the discount applies to one checkout.'), + }) + .strict(), + z + .object({ + kind: z.literal('fixed'), + amount_off_minor: safePositiveInt().describe('The amount off in cents, such as 500 for $5.00.'), + currency: z.literal('usd').describe('Always usd.'), + duration: z.literal('once').describe('Always once: the discount applies to one checkout.'), + }) + .strict(), + ]) + .describe('Exactly one discount: kind percent with percent_off_bps, or kind fixed with amount_off_minor and currency usd.'), + prefix: z + .string() + .regex(/^[A-Za-z0-9]{0,12}$/) + .optional() + .describe('Up to 12 letters or digits that start the code, such as BTO. Leave it out for the default prefix.'), + max_redemptions: safePositiveInt() + .optional() + .describe('How many checkouts may use the code in total. Leave it out for no redemption limit.'), + expires_at: z + .string() + .datetime() + .optional() + .describe('When the code stops working, a future UTC ISO time such as 2026-11-30T00:00:00Z. Leave it out for no expiry.'), + idempotency_key: idempotencyKey(), + }) + .strict(), + annotations: { title: 'Create a coupon', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }, + }, + async ({ idempotency_key, ...body }) => + runCouponCall(couponClient, { method: 'POST', path: '/api/v1/coupons', body, idempotencyKey: idempotency_key }), + ); + + server.registerTool( + 'affitor_lock_coupon', + { + description: + 'Stop new redemptions of one coupon code. It does not delete the coupon, undo discounts already applied or refund anyone. Retry with the same idempotency_key and the same payload. HTTP 409 means a stale expected_version, an existing or pending assignment, or the key was used with another payload; never try a new key. Returns the coupon status; lock_pending means poll affitor_get_coupon.', + inputSchema: z + .object({ + coupon_id: v1Id().describe('The coupon_id returned by affitor_create_coupon or affitor_get_coupon.'), + expected_version: safePositiveInt().describe('The version from the latest affitor_get_coupon or create result. A stale version answers 409.'), + idempotency_key: idempotencyKey(), + }) + .strict(), + annotations: { title: 'Lock a coupon', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }, + }, + async ({ coupon_id, expected_version, idempotency_key }) => + runCouponCall(couponClient, { + method: 'POST', + path: `/api/v1/coupons/${coupon_id}/lock`, + body: { expected_version }, + idempotencyKey: idempotency_key, + }), + ); + + server.registerTool( + 'affitor_get_coupon', + { + description: + "Read one coupon's status (pending, active, lock_pending, locked, held or failed), version and, once active, its code. Use it to poll after create or lock answers pending, instead of calling them again. Returns the coupon status from the API; a code works only when status is active.", + inputSchema: z + .object({ + coupon_id: v1Id().describe('The coupon_id returned by affitor_create_coupon.'), + }) + .strict(), + annotations: { title: 'Get a coupon', readOnlyHint: true, openWorldHint: true }, + }, + async ({ coupon_id }) => runCouponCall(couponClient, { method: 'GET', path: `/api/v1/coupons/${coupon_id}` }), + ); +} + /** Read the server version from this package's package.json at runtime. */ function readVersion(): string { try { @@ -439,31 +639,41 @@ function readVersion(): string { const SERVER_VERSION = readVersion(); async function main(): Promise { + // Two independent families, each with its own key; neither key ever falls back to the other. const apiKey = process.env.AFFITOR_API_KEY; - if (!apiKey) { + const agentKey = process.env.AFFITOR_AGENT_KEY; + const couponsOn = process.env.AFFITOR_MCP_COUPONS === '1'; + if (!apiKey && !(agentKey && couponsOn)) { process.stderr.write( - '@affitor/mcp: AFFITOR_API_KEY is required. Set it in your MCP client config, e.g.\n' + + '@affitor/mcp: set AFFITOR_API_KEY (program key, tracking tools), or AFFITOR_AGENT_KEY with AFFITOR_MCP_COUPONS=1 (coupon tools), in your MCP client config, e.g.\n' + ' "env": { "AFFITOR_API_KEY": "your_program_key" }\n', ); process.exit(1); } + if (couponsOn && !agentKey) { + process.stderr.write('@affitor/mcp: AFFITOR_MCP_COUPONS=1 needs AFFITOR_AGENT_KEY; coupon tools are not added.\n'); + } const apiUrl = process.env.AFFITOR_API_URL; - const affitor = new Affitor({ apiKey, ...(apiUrl ? { apiUrl } : {}) }); + const baseUrl = apiUrl ?? 'https://api.affitor.com'; + const server = new McpServer({ name: 'affitor', version: SERVER_VERSION }); - // Chain-firing isn't exposed by the SDK client (its `post` is private), so we - // fire the endpoint via a small authenticated fetch — injected for testability. - const runVerification: RunVerification = () => - fireVerificationChain(apiKey, apiUrl ?? 'https://api.affitor.com'); + if (apiKey) { + const affitor = new Affitor({ apiKey, ...(apiUrl ? { apiUrl } : {}) }); - // AFFITOR_MCP_WRITE=1 registers the tools that move money (track_sale, track_refund). + // Chain-firing isn't exposed by the SDK client (its `post` is private), so we + // fire the endpoint via a small authenticated fetch, injected for testability. + const runVerification: RunVerification = () => fireVerificationChain(apiKey, baseUrl); - const baseUrl = apiUrl ?? 'https://api.affitor.com'; - const server = new McpServer({ name: 'affitor', version: SERVER_VERSION }); - registerTools(server, affitor, runVerification, { - allowWrites: process.env.AFFITOR_MCP_WRITE === '1', - trackTestSale: (input) => postTestSale(apiKey, baseUrl, input), - }); + // AFFITOR_MCP_WRITE=1 registers the tools that move money (track_sale, track_refund). + registerTools(server, affitor, runVerification, { + allowWrites: process.env.AFFITOR_MCP_WRITE === '1', + trackTestSale: (input) => postTestSale(apiKey, baseUrl, input), + }); + } + + // AFFITOR_MCP_COUPONS=1 with AFFITOR_AGENT_KEY registers the coupon tools. + if (agentKey && couponsOn) registerCouponTools(server, createCouponClient(agentKey, baseUrl)); const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index d9ad94f..86ea2b4 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -1,12 +1,22 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; import { dirname, resolve } from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { Affitor } from '@affitor/sdk/server'; -import { registerTools, postTestSale, type AffitorLike } from '../src/index.js'; +import { + registerTools, + registerCouponTools, + createCouponClient, + postTestSale, + type AffitorLike, + type CouponClient, + type CouponRequest, +} from '../src/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SERVER_ENTRY = resolve(__dirname, '../dist/index.js'); @@ -486,3 +496,320 @@ describe('@affitor/mcp idempotency keys', () => { await client.close(); }); }); + +// ── 5. Coupon tools: separate key, separate flag, strict inputs ── +const COUPON_TOOLS = ['affitor_create_coupon', 'affitor_lock_coupon', 'affitor_get_coupon'] as const; +const sorted = (names: readonly string[]) => [...names].sort(); + +async function spawnedTools(env: Record): Promise { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [SERVER_ENTRY], + env: { AFFITOR_API_URL: 'http://127.0.0.1:1', ...env }, + stderr: 'ignore', + }); + const client = new Client({ name: 'test-client', version: '0.0.0' }); + await client.connect(transport); + try { + return (await client.listTools()).tools.map((t) => t.name).sort(); + } finally { + await client.close(); + } +} + +function spawnedExit(env: Record) { + return spawnSync(process.execPath, [SERVER_ENTRY], { env, input: '', encoding: 'utf8', timeout: 10000 }); +} + +describe('@affitor/mcp tool families by configuration (spawned, built dist)', () => { + it.each([ + ['program key only, WRITE off', { AFFITOR_API_KEY: 'prog_k' }, DEFAULT_TOOLS], + ['program key only, WRITE on (COUPONS=1 without agent key)', { AFFITOR_API_KEY: 'prog_k', AFFITOR_MCP_WRITE: '1', AFFITOR_MCP_COUPONS: '1' }, [...DEFAULT_TOOLS, ...WRITE_TOOLS]], + ['agent key only, COUPONS=1', { AFFITOR_AGENT_KEY: 'agt_k', AFFITOR_MCP_COUPONS: '1' }, COUPON_TOOLS], + ['agent key only, COUPONS=1, WRITE=1 (no program client)', { AFFITOR_AGENT_KEY: 'agt_k', AFFITOR_MCP_COUPONS: '1', AFFITOR_MCP_WRITE: '1' }, COUPON_TOOLS], + ['both keys, COUPONS=1, WRITE off', { AFFITOR_API_KEY: 'prog_k', AFFITOR_AGENT_KEY: 'agt_k', AFFITOR_MCP_COUPONS: '1' }, [...DEFAULT_TOOLS, ...COUPON_TOOLS]], + ['both keys, both flags on', { AFFITOR_API_KEY: 'prog_k', AFFITOR_AGENT_KEY: 'agt_k', AFFITOR_MCP_COUPONS: '1', AFFITOR_MCP_WRITE: '1' }, [...DEFAULT_TOOLS, ...WRITE_TOOLS, ...COUPON_TOOLS]], + ['both keys, COUPONS off', { AFFITOR_API_KEY: 'prog_k', AFFITOR_AGENT_KEY: 'agt_k' }, DEFAULT_TOOLS], + ['missing coupon key, program key present, COUPONS=1', { AFFITOR_API_KEY: 'prog_k', AFFITOR_MCP_COUPONS: '1' }, DEFAULT_TOOLS], + ] as const)('%s', async (_label, env, expected) => { + expect(await spawnedTools(env)).toEqual(sorted(expected)); + }, 20000); + + it.each([ + ['agent key only, COUPONS off', { AFFITOR_AGENT_KEY: 'agt_secret_value' }], + ['agent key only, COUPONS off, WRITE=1', { AFFITOR_AGENT_KEY: 'agt_secret_value', AFFITOR_MCP_WRITE: '1' }], + ['missing coupon key, no program key, COUPONS=1', { AFFITOR_MCP_COUPONS: '1' }], + ['nothing set', {}], + ] as const)('%s: no tools, exits with a setup hint naming both keys', (_label, env) => { + const out = spawnedExit(env); + expect(out.status).toBe(1); + expect(out.stderr).toContain('AFFITOR_API_KEY'); + expect(out.stderr).toContain('AFFITOR_AGENT_KEY'); + expect(out.stderr).not.toContain('agt_secret_value'); + }); + + it('coupon tools carry the required annotations and short descriptions', async () => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [SERVER_ENTRY], + env: { AFFITOR_AGENT_KEY: 'agt_k', AFFITOR_MCP_COUPONS: '1', AFFITOR_API_URL: 'http://127.0.0.1:1' }, + }); + const client = new Client({ name: 'test-client', version: '0.0.0' }); + await client.connect(transport); + const { tools } = await client.listTools(); + await client.close(); + const byName = Object.fromEntries(tools.map((t) => [t.name, t])); + for (const name of ['affitor_create_coupon', 'affitor_lock_coupon']) { + expect(byName[name].annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }); + expect(byName[name].description).toContain('same idempotency_key and the same payload'); + expect(byName[name].description).toContain('409'); + expect(byName[name].description).toMatch(/never try a new key/); + } + expect(byName.affitor_get_coupon.annotations).toMatchObject({ readOnlyHint: true, openWorldHint: true }); + for (const name of COUPON_TOOLS) { + const tool = byName[name]; + expect(tool.description!.length, `${name} description length`).toBeLessThanOrEqual(400); + expect(tool.description).not.toContain('—'); + expect(tool.inputSchema.additionalProperties).toBe(false); + const props = tool.inputSchema.properties as Record; + for (const [key, prop] of Object.entries(props)) expect(prop.description, `${name}.${key} description`).toBeTruthy(); + for (const banned of ['program_id', 'account_id', 'api_key', 'agent_key', 'actor', 'permission', 'scope']) { + expect(Object.keys(props)).not.toContain(banned); + } + } + expect(byName.affitor_create_coupon.inputSchema.required).toEqual( + expect.arrayContaining(['partner_program_id', 'referral_link_id', 'product_id', 'discount', 'idempotency_key']), + ); + }, 20000); +}); + +describe('@affitor/mcp coupon tools → wire contract (mocked fetch)', () => { + const CREATE_ARGS = { + partner_program_id: 31, + referral_link_id: 82, + product_id: 'replay', + discount: { kind: 'percent', percent_off_bps: 1000, duration: 'once' }, + prefix: 'BTO', + max_redemptions: 20, + expires_at: '2026-11-30T00:00:00Z', + idempotency_key: 'cpn_link82_v1', + }; + const activeBody = { data: { coupon_id: 501, program_id: 1115, status: 'active', version: 1, code: 'BTO10ABC', tracking_ready: true } }; + + async function withFetch( + respond: (url: string, init: RequestInit) => Response, + run: (client: Client) => Promise, + ): Promise<{ url: string; init: RequestInit }[]> { + const calls: { url: string; init: RequestInit }[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url: String(url), init }); + return respond(String(url), init); + }) as unknown as typeof fetch; + const server = new McpServer({ name: 'affitor', version: '0.1.0' }); + registerCouponTools(server, createCouponClient('agt_secret', 'https://api.affitor.com')); + const [ct, st] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test', version: '0.0.0' }); + await Promise.all([server.connect(st), client.connect(ct)]); + try { + await run(client); + } finally { + globalThis.fetch = realFetch; + await client.close(); + } + return calls; + } + + const json = (status: number, body: unknown, headers: Record = {}) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } }); + const text = (res: unknown) => (res as { content: { text: string }[] }).content[0].text; + const call = (client: Client, name: string, args: Record) => + client + .callTool({ name, arguments: args }) + .catch((err: Error) => ({ isError: true, content: [{ type: 'text', text: err.message }] })); + + it('create sends Idempotency-Key from idempotency_key, the agent key only, and a body without idempotency_key', async () => { + let res: unknown; + const calls = await withFetch(() => json(201, activeBody), async (client) => { + res = await call(client, 'affitor_create_coupon', CREATE_ARGS); + }); + expect((res as { isError?: boolean }).isError).toBeFalsy(); + expect(JSON.parse(text(res))).toEqual(activeBody); + expect(calls).toHaveLength(1); + const [{ url, init }] = calls; + expect(url).toBe('https://api.affitor.com/api/v1/coupons'); + expect(init.method).toBe('POST'); + const headers = init.headers as Record; + expect(headers.Authorization).toBe('Bearer agt_secret'); + expect(headers['Idempotency-Key']).toBe('cpn_link82_v1'); + const { idempotency_key: _omit, ...expectedBody } = CREATE_ARGS; + expect(JSON.parse(String(init.body))).toEqual(expectedBody); + }); + + it('lock posts expected_version only, with the Idempotency-Key header', async () => { + const calls = await withFetch(() => json(200, { data: { ...activeBody.data, status: 'locked', version: 2 } }), async (client) => { + const res = await call(client, 'affitor_lock_coupon', { coupon_id: 501, expected_version: 1, idempotency_key: 'lock_501_v1' }); + expect(res.isError).toBeFalsy(); + }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://api.affitor.com/api/v1/coupons/501/lock'); + expect(calls[0].init.method).toBe('POST'); + expect((calls[0].init.headers as Record)['Idempotency-Key']).toBe('lock_501_v1'); + expect((calls[0].init.headers as Record).Authorization).toBe('Bearer agt_secret'); + expect(JSON.parse(String(calls[0].init.body))).toEqual({ expected_version: 1 }); + }); + + it('get sends GET with no body and no Idempotency-Key', async () => { + const calls = await withFetch(() => json(200, activeBody), async (client) => { + const res = await call(client, 'affitor_get_coupon', { coupon_id: 501 }); + expect(res.isError).toBeFalsy(); + expect(JSON.parse(text(res))).toEqual(activeBody); + }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://api.affitor.com/api/v1/coupons/501'); + expect(calls[0].init.method).toBe('GET'); + expect(calls[0].init.body).toBeUndefined(); + expect('Idempotency-Key' in (calls[0].init.headers as Record)).toBe(false); + }); + + it('a 202 pending body is a non-error pending result, never an active code', async () => { + const pendingBody = { data: { coupon_id: 501, program_id: 1115, status: 'pending', version: 1, code: null, tracking_ready: false, reason: 'provider_confirmation_pending', status_url: '/api/v1/coupons/501' } }; + await withFetch(() => json(202, pendingBody), async (client) => { + const res = await call(client, 'affitor_create_coupon', CREATE_ARGS); + expect(res.isError).toBeFalsy(); + const out = JSON.parse(text(res)); + expect(out).toMatchObject({ pending: true, http_status: 202, data: pendingBody.data }); + expect(out.next_action).toContain('affitor_get_coupon'); + expect(out.next_action).toContain('Not an active code'); + }); + }); + + it('a 200 get with status held is marked pending, not an error', async () => { + await withFetch(() => json(200, { data: { coupon_id: 501, status: 'held', code: null } }), async (client) => { + const res = await call(client, 'affitor_get_coupon', { coupon_id: 501 }); + expect(res.isError).toBeFalsy(); + expect(JSON.parse(text(res))).toMatchObject({ pending: true, http_status: 200 }); + }); + }); + + it.each([ + [409, 'idempotency_conflict', true], + [403, 'insufficient_scope', false], + [404, 'not_found', false], + [401, 'unauthorized', false], + ])('HTTP %i returns isError with only the status and error.code', async (status, code, hint) => { + await withFetch( + () => json(status, { error: { code, message: 'raw provider text sk_live_leak', details: { args: CREATE_ARGS } } }), + async (client) => { + const res = await call(client, 'affitor_create_coupon', CREATE_ARGS); + expect(res.isError).toBe(true); + const out = JSON.parse(text(res)); + expect(out.http_status).toBe(status); + expect(out.error_code).toBe(code); + expect(Object.keys(out).sort()).toEqual(hint ? ['error_code', 'hint', 'http_status'] : ['error_code', 'http_status']); + if (hint) expect(out.hint).toContain('same idempotency_key and the same payload'); + const raw = text(res); + for (const leak of ['sk_live_leak', 'raw provider text', 'agt_secret', 'cpn_link82_v1', 'replay']) expect(raw).not.toContain(leak); + }, + ); + }); + + it('a 429 keeps retry info from Retry-After', async () => { + await withFetch(() => json(429, { error: { code: 'rate_limited' } }, { 'Retry-After': '30' }), async (client) => { + const res = await call(client, 'affitor_get_coupon', { coupon_id: 501 }); + expect(res.isError).toBe(true); + expect(JSON.parse(text(res))).toEqual({ http_status: 429, error_code: 'rate_limited', retry_after_seconds: 30 }); + }); + }); + + it.each([ + ['an extra program_id', { ...CREATE_ARGS, program_id: 1115 }], + ['an extra api_key', { ...CREATE_ARGS, api_key: 'x' }], + ['a mixed discount variant', { ...CREATE_ARGS, discount: { kind: 'percent', percent_off_bps: 1000, amount_off_minor: 500, duration: 'once' } }], + ['a fixed discount in eur', { ...CREATE_ARGS, discount: { kind: 'fixed', amount_off_minor: 500, currency: 'eur', duration: 'once' } }], + ['a float percent', { ...CREATE_ARGS, discount: { kind: 'percent', percent_off_bps: 10.5, duration: 'once' } }], + ['a forever duration', { ...CREATE_ARGS, discount: { kind: 'percent', percent_off_bps: 1000, duration: 'forever' } }], + ['an 11-digit id', { ...CREATE_ARGS, referral_link_id: 12345678901 }], + ['a short idempotency_key', { ...CREATE_ARGS, idempotency_key: 'short' }], + ['a 13-char prefix', { ...CREATE_ARGS, prefix: 'ABCDEFGHIJKLM' }], + ['no idempotency_key', (({ idempotency_key: _k, ...rest }) => rest)(CREATE_ARGS)], + ])('create rejects %s before any call', async (_label, args) => { + const calls = await withFetch(() => json(201, activeBody), async (client) => { + const res = await call(client, 'affitor_create_coupon', args as Record); + expect(res.isError).toBe(true); + }); + expect(calls).toHaveLength(0); + }); + + it.each([ + ['affitor_lock_coupon', { coupon_id: 501, expected_version: 1, idempotency_key: 'lock_501_v1', program_id: 1 }], + ['affitor_lock_coupon', { coupon_id: 501, expected_version: 0, idempotency_key: 'lock_501_v1' }], + ['affitor_get_coupon', { coupon_id: 501, program_id: 1 }], + ['affitor_get_coupon', { coupon_id: 0 }], + ])('%s rejects %j before any call', async (name, args) => { + const calls = await withFetch(() => json(200, activeBody), async (client) => { + const res = await call(client, name, args); + expect(res.isError).toBe(true); + }); + expect(calls).toHaveLength(0); + }); + + it('registerCouponTools uses only the injected client and registers exactly the coupon tools', async () => { + const seen: CouponRequest[] = []; + const stub: CouponClient = async (request) => { + seen.push(request); + return { status: 200, body: activeBody }; + }; + const server = new McpServer({ name: 'affitor', version: '0.1.0' }); + registerCouponTools(server, stub); + const [ct, st] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test', version: '0.0.0' }); + await Promise.all([server.connect(st), client.connect(ct)]); + expect((await client.listTools()).tools.map((t) => t.name).sort()).toEqual(sorted(COUPON_TOOLS)); + await client.callTool({ name: 'affitor_lock_coupon', arguments: { coupon_id: 7, expected_version: 3, idempotency_key: 'lock_7_v3_ab' } }); + expect(seen).toEqual([{ method: 'POST', path: '/api/v1/coupons/7/lock', body: { expected_version: 3 }, idempotencyKey: 'lock_7_v3_ab' }]); + await client.close(); + }); +}); + +describe('@affitor/mcp public surface and registry metadata', () => { + const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8')); + const serverJson = JSON.parse(readFileSync(resolve(__dirname, '../server.json'), 'utf8')); + const npmEntry = serverJson.packages.find((p: { registryType: string }) => p.registryType === 'npm'); + const env = Object.fromEntries(npmEntry.environmentVariables.map((v: { name: string }) => [v.name, v])); + + it('registerTools keeps its (server, affitor, runVerification?, options?) shape and registers no coupon tools', async () => { + expect(registerTools.length).toBe(3); + const stub: AffitorLike = { + readiness: async () => ({}) as never, + trackLead: async () => ({ ok: true, status: 200, data: null }), + trackSale: async () => ({ ok: true, status: 200, data: null }), + trackRefund: async () => ({ ok: true, status: 200, data: null }), + trackClick: async () => ({ ok: true, status: 200, data: null }), + }; + const server = new McpServer({ name: 'affitor', version: '0.1.0' }); + registerTools(server, stub, undefined, { allowWrites: true }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test', version: '0.0.0' }); + await Promise.all([server.connect(st), client.connect(ct)]); + expect((await client.listTools()).tools.map((t) => t.name).sort()).toEqual(sorted([...DEFAULT_TOOLS, ...WRITE_TOOLS])); + await client.close(); + }); + + it('server.json npm version equals package.json version, and server.json ships in the package', () => { + expect(npmEntry.identifier).toBe(pkg.name); + expect(npmEntry.version).toBe(pkg.version); + expect(pkg.files).toContain('server.json'); + }); + + it('server.json lists the env vars with the right required/secret flags', () => { + expect(env.AFFITOR_API_KEY).toMatchObject({ isRequired: false, isSecret: true }); + expect(env.AFFITOR_AGENT_KEY).toMatchObject({ isRequired: false, isSecret: true }); + expect(env.AFFITOR_AGENT_KEY.description).toBeTruthy(); + expect(env.AFFITOR_MCP_COUPONS).toMatchObject({ isRequired: false }); + expect(env.AFFITOR_MCP_COUPONS.description).toMatch(/1/); + expect(env.AFFITOR_MCP_COUPONS.description).toMatch(/[Oo]ff by default/); + expect(env.AFFITOR_MCP_WRITE).toMatchObject({ isRequired: false }); + expect(env.AFFITOR_API_URL).toMatchObject({ isRequired: false }); + }); +});