diff --git a/package-lock.json b/package-lock.json index 620958b..3c368a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3407,10 +3407,10 @@ }, "packages/cli": { "name": "affitor", - "version": "0.3.1", + "version": "0.4.2", "license": "MIT", "dependencies": { - "@affitor/recipes": "*", + "@affitor/recipes": "^0.1.0", "@inquirer/prompts": "^7.5.0", "commander": "^13.1.0", "open": "^10.1.0", @@ -3431,10 +3431,10 @@ }, "packages/mcp": { "name": "@affitor/mcp", - "version": "0.1.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@affitor/recipes": "*", + "@affitor/recipes": "^0.1.0", "@affitor/sdk": "^2.1.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^3.23.8" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md new file mode 100644 index 0000000..0ae87e1 --- /dev/null +++ b/packages/cli/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog — affitor (CLI) + +## 0.4.2 +- Security: the secrets file is written with owner-only permissions, and login URLs are opened safely (#26). +- API key rotation: warnings for a key that must be replaced, a clear error for a retired key, and the `Deprecation`/`Sunset`/`Link` headers are read so a retired key is never silent (#27, #30). A refused key points to Regenerate, not to `affitor init` (#28). +- Exit codes: `affitor onboard` exits 0 only when the integration is verified; `affitor whoami --json` exits 1 when logged out, like the human output (#29). +- Docs links point at affitor.com/docs (#32). + +## 0.4.1 +- Published 2026-06-15. diff --git a/packages/cli/package.json b/packages/cli/package.json index 3f00982..ed27916 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "affitor", - "version": "0.4.1", + "version": "0.4.2", "description": "CLI-native affiliate tracking. Connect Stripe, track commissions, manage partners — all from your terminal.", "type": "module", "bin": { diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md new file mode 100644 index 0000000..fef934f --- /dev/null +++ b/packages/mcp/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog — @affitor/mcp + +## 0.2.1 +- `affitor_track_sale` and `affitor_track_refund` are only registered when `AFFITOR_MCP_WRITE=1`. Without it the server offers 5 tools, none of which can create a sale or reverse a commission. **If you call `affitor_track_sale` from an agent today, add `AFFITOR_MCP_WRITE=1` to the server's `env`.** +- Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`); the two money tools are marked destructive. +- `testMode` on `affitor_track_sale` records an `is_test` sale with no commission, under the transaction id `test_` so it never blocks the real sale with the same invoice; on `affitor_track_lead` it records a test lead; on `affitor_track_refund` it is a dry run that calls nothing. +- `invoiceId` must not be empty on `affitor_track_sale` and `affitor_track_refund`; `affitor_readiness` is marked `openWorldHint` because the check fetches the brand's site. +- README: onboard, MCP tools and recipes documentation backfilled after 0.2.0. + +## 0.2.0 +- First public release: 7 tools over stdio. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 73f34e9..1b8f4af 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -32,17 +32,20 @@ Restart your client and the `affitor` tools will be available to the agent. | ------------------ | -------- | --------------------------------------------------------------------------- | | `AFFITOR_API_KEY` | Yes | 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. | If `AFFITOR_API_KEY` is not set, the server prints a clear message to stderr and exits. ## Tools +Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`), so a client can ask before a write. `affitor_track_sale` and `affitor_track_refund` are marked destructive and only exist with `AFFITOR_MCP_WRITE=1`. + | Tool | Description | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `affitor_readiness` | Check this program's integration/onboarding readiness — returns a 5-gate verdict + blocker + next_action. Poll until `integration_verified` is true. | | `affitor_track_lead` | Report a lead/signup. Binds the customer to the click so later sales attribute by `customerExternalId` alone. | -| `affitor_track_sale` | Report a sale. Resolves attribution by `customerExternalId` (bound at lead time). | -| `affitor_track_refund` | Report a refund (omit amount = full → commission reversed; partial → refunded). Idempotent by `invoiceId`. | +| `affitor_track_sale` | Needs `AFFITOR_MCP_WRITE=1`. Report a sale: creates a real sale and commission unless `testMode` is true. Resolves attribution by `customerExternalId` (bound at lead time). | +| `affitor_track_refund` | Needs `AFFITOR_MCP_WRITE=1`. Report a refund (omit amount = full → commission reversed; partial → refunded). Idempotent by `invoiceId`. `testMode` is a dry run. | | `affitor_track_click` | Report a click (usually browser-side; public, no customer needed). | | `affitor_get_integration_plan` | Return the deterministic payment-tracking integration plan for a stack — install, checkout-metadata snippet, `trackSale` snippet + where to inject it, and the verify step. **Pure** — reads the canonical [`@affitor/recipes`](https://www.npmjs.com/package/@affitor/recipes) registry; no SDK client, no network, no side-effects. The agent follows this instead of guessing a contract. | | `affitor_run_verification` | Fire the synthetic click → lead → sale verification chain through Affitor's REAL attribution pipeline (isolated `is_test` rows). The agent's proof step: run it, then poll `affitor_readiness` until `integration_verified` is true. Rate-limited to **10/program/hour** — on a `rate_limited` result, wait `retry_after_seconds`, don't hammer. | @@ -56,6 +59,7 @@ If `AFFITOR_API_KEY` is not set, the server prints a clear message to stderr and - `customerExternalId?: string` — your own user id; binds this customer to the click. - `clickId?: string` — Affitor click id (from the `affitor_click_id` cookie). - `email?: string` +- `testMode?: boolean` — record a test lead (`is_test`). **`affitor_track_sale`** (one of `customerExternalId` / `clickId` is required) - `customerExternalId?: string` — your own user id; resolves attribution (no `clickId` needed once bound at lead time). @@ -67,11 +71,13 @@ If `AFFITOR_API_KEY` is not set, the server prints a clear message to stderr and - `isRecurring?: boolean` - `subscriptionId?: string` - `subscriptionInterval?: "monthly" | "quarterly" | "annual"` +- `testMode?: boolean` — record a test sale (`is_test`, no commission), sent as transaction `test_` so it never blocks the real sale with the same invoice. **`affitor_track_refund`** - `invoiceId: string` — the sale's idempotency key (the `invoiceId` you passed to `affitor_track_sale`). - `refundAmountCents?: number` — integer cents. Omit (or `0`) = full refund → commission reversed; partial → refunded. - `refundReason?: string` +- `testMode?: boolean` — dry run: returns the refund that would be sent and calls nothing (the API has no test refunds). **`affitor_track_click`** (all optional) - `affiliateUrl?: string` diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 3c3882b..5f3a957 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@affitor/mcp", - "version": "0.2.0", + "version": "0.2.1", "description": "Model Context Protocol (MCP) stdio server for Affitor — lets AI agents (Claude Desktop, Cursor, …) call Affitor affiliate-tracking capabilities as tools.", "type": "module", "main": "./dist/index.js", diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index bec47a6..382b5b0 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -9,6 +9,11 @@ * It wraps the consolidated server client `@affitor/sdk/server` (the `Affitor` * class). Auth is the program API key, read from the `AFFITOR_API_KEY` env var. * + * The two tools that move money (`affitor_track_sale`, `affitor_track_refund`) + * are only registered when `AFFITOR_MCP_WRITE=1`: an agent exploring tools must + * 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. + * * { * "mcpServers": { * "affitor": { @@ -116,21 +121,81 @@ export async function fireVerificationChain(apiKey: string, apiUrl: string): Pro return { http_status: res.status, ...body }; } +/** The sale fields a test sale sends; same names as `affitor_track_sale`. */ +export interface TestSaleInput { + customerExternalId?: string; + clickId?: string; + amount: number; + invoiceId: string; + currency?: string; + saleType?: 'payment' | 'subscription'; + isRecurring?: boolean; + subscriptionId?: string; + subscriptionInterval?: 'monthly' | 'quarterly' | 'annual'; +} + +/** Records a test sale (`is_test`, no commission). Injected so it stays unit-testable. */ +export type TrackTestSale = (input: TestSaleInput) => Promise; + +export interface RegisterOptions { + /** Register the money-moving tools (`affitor_track_sale`, `affitor_track_refund`). */ + allowWrites?: boolean; + /** Used by `affitor_track_sale` when `testMode` is true. */ + trackTestSale?: TrackTestSale; +} + +/** + * POST `{base}/api/v1/track/sale` with `additional_data.test_mode`, which the API + * records as an `is_test` sale with no commission. The SDK's `trackSale` has no + * test-mode field, so this call is made directly, like `fireVerificationChain`. + * Resolves to the SDK-shaped `{ ok, status, data }` so `runCall` treats both alike. + * + * The transaction id is sent as `test_`: the API's duplicate check does + * not skip test rows, so a test sale under the real id would make the real sale + * with that invoice answer 409 and never create its commission. + */ +export const TEST_TRANSACTION_PREFIX = 'test_'; + +export async function postTestSale(apiKey: string, apiUrl: string, input: TestSaleInput): Promise { + const body: Record = { + customer_key: input.customerExternalId, + click_id: input.clickId, + amount_cents: input.amount, + transaction_id: `${TEST_TRANSACTION_PREFIX}${input.invoiceId}`, + currency: input.currency, + sale_type: input.saleType, + is_recurring: input.isRecurring, + subscription_id: input.subscriptionId, + subscription_interval: input.subscriptionInterval, + additional_data: { test_mode: true }, + }; + for (const key of Object.keys(body)) if (body[key] === undefined) delete body[key]; + + const res = await fetch(`${apiUrl}/api/v1/track/sale`, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = (await res.json().catch(() => null)) as Record | null; + const error = res.ok ? undefined : String((data && (data.error as string)) || `HTTP ${res.status}`); + return { ok: res.ok, status: res.status, data, ...(error ? { error } : {}) }; +} + /** - * Register the 7 Affitor tools on an MCP server. Exported for unit testing the + * Register the Affitor tools on an MCP server. Exported for unit testing the * handlers with a stubbed `Affitor` client. * - * Five tools wrap the `Affitor` client (readiness + track*). The sixth, - * `affitor_get_integration_plan`, is PURE — it reads the canonical recipe - * registry (`@affitor/recipes`) and never touches the client or the network. - * The seventh, `affitor_run_verification`, fires the synthetic verification - * chain via the injected `runVerification` (omitted → the tool degrades to a - * "not configured" failure). + * Always registered (5): readiness, track_lead, track_click, the PURE + * `affitor_get_integration_plan` (reads `@affitor/recipes`, no client, no + * network) and `affitor_run_verification`, which fires the synthetic + * verification chain via the injected `runVerification` (omitted → a "not + * configured" failure). With `allowWrites` (2 more): track_sale and track_refund. */ export function registerTools( server: McpServer, affitor: AffitorLike, runVerification?: RunVerification, + options: RegisterOptions = {}, ): void { server.registerTool( 'affitor_readiness', @@ -143,6 +208,8 @@ export function registerTools( .optional() .describe('Force a fresh server-side recheck instead of returning the cached verdict.'), }, + // Reads only, but the server-side check fetches the brand's own site to look for the tracker. + annotations: { title: 'Check readiness', readOnlyHint: true, idempotentHint: true, openWorldHint: true }, }, async (args) => runCall(() => affitor.readiness({ forceRecheck: args.forceRecheck })), ); @@ -162,7 +229,12 @@ export function registerTools( .optional() .describe('Affitor click id (from the `affitor_click_id` cookie). One of clickId / customerExternalId is required.'), email: z.string().optional().describe("The lead's email address."), + testMode: z + .boolean() + .optional() + .describe('Record a test lead (is_test) instead of a real one.'), }, + annotations: { title: 'Report a lead', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, }, async (args) => { if (!args.customerExternalId && !args.clickId) { @@ -173,79 +245,13 @@ export function registerTools( customerExternalId: args.customerExternalId, clickId: args.clickId, email: args.email, + ...(args.testMode ? { additionalData: { test_mode: true } } : {}), }), ); }, ); - server.registerTool( - 'affitor_track_sale', - { - description: - 'Report a sale. Resolves attribution by customerExternalId (bound at lead time).', - inputSchema: { - customerExternalId: z - .string() - .optional() - .describe("Advertiser's own user id — resolves attribution (no clickId needed once bound at lead time). One of customerExternalId / clickId is required."), - clickId: z.string().optional().describe('Affitor click id. One of clickId / customerExternalId is required.'), - amount: z.number().int().positive().describe('Sale amount in integer cents (e.g. 4999 = $49.99).'), - invoiceId: z.string().describe('Idempotency key — dedups retries (your invoice/transaction id).'), - currency: z.string().optional().describe('ISO currency code (default USD).'), - saleType: z.enum(['payment', 'subscription']).optional().describe('Whether this is a one-off payment or a subscription.'), - isRecurring: z.boolean().optional().describe('Whether this sale recurs (subscription renewal).'), - subscriptionId: z.string().optional().describe('Provider subscription id, if applicable.'), - subscriptionInterval: z - .enum(['monthly', 'quarterly', 'annual']) - .optional() - .describe('Billing interval for a subscription sale.'), - }, - }, - async (args) => { - if (!args.customerExternalId && !args.clickId) { - return fail('affitor_track_sale: `customerExternalId` or `clickId` is required'); - } - return runCall(() => - affitor.trackSale({ - customerExternalId: args.customerExternalId, - clickId: args.clickId, - amount: args.amount, - invoiceId: args.invoiceId, - currency: args.currency, - saleType: args.saleType, - isRecurring: args.isRecurring, - subscriptionId: args.subscriptionId, - subscriptionInterval: args.subscriptionInterval, - }), - ); - }, - ); - - server.registerTool( - 'affitor_track_refund', - { - description: - 'Report a refund (omit amount = full → commission reversed; partial → refunded). Idempotent by invoiceId.', - inputSchema: { - invoiceId: z.string().describe('The sale\'s idempotency key (the `invoiceId` you passed to affitor_track_sale).'), - refundAmountCents: z - .number() - .int() - .nonnegative() - .optional() - .describe('Refund amount in integer cents. Omit (or 0) = full refund → commission reversed; partial → refunded.'), - refundReason: z.string().optional().describe('Optional human-readable refund reason.'), - }, - }, - async (args) => - runCall(() => - affitor.trackRefund({ - invoiceId: args.invoiceId, - refundAmountCents: args.refundAmountCents, - refundReason: args.refundReason, - }), - ), - ); + if (options.allowWrites) registerWriteTools(server, affitor, options.trackTestSale); server.registerTool( 'affitor_track_click', @@ -257,6 +263,7 @@ export function registerTools( referrerUrl: z.string().optional().describe('The HTTP referrer URL, if any.'), existingClickId: z.string().optional().describe('Reuse an existing Affitor click id instead of minting a new one.'), }, + annotations: { title: 'Report a click', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, }, async (args) => runCall(() => @@ -268,7 +275,6 @@ export function registerTools( }), ), ); - server.registerTool( 'affitor_get_integration_plan', { @@ -290,6 +296,7 @@ export function registerTools( "Payment-tracking mode. 'stripe_connect' (default) = Connect autocaptures the sale (metadata only, no trackSale); 's2s' = inject trackSale in your webhook.", ), }, + annotations: { title: 'Get the integration plan', readOnlyHint: true, idempotentHint: true, openWorldHint: false }, }, async (args) => { // Pure: reads the canonical recipe registry. No SDK client, no network. @@ -308,6 +315,7 @@ export function registerTools( description: "Fire the synthetic click->lead->sale verification chain through Affitor's REAL attribution pipeline (isolated is_test rows). This is the agent's PROOF step: run it, then poll affitor_readiness until integration_verified:true. Rate-limited to 10/program/hour — on a rate_limited result, wait retry_after_seconds, don't hammer.", inputSchema: {}, + annotations: { title: 'Run the verification chain', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, }, async () => { if (!runVerification) { @@ -318,6 +326,97 @@ export function registerTools( ); } +/** + * The two tools that move money. Registered only with `allowWrites` + * (`AFFITOR_MCP_WRITE=1`). `testMode` records an `is_test` sale with no + * commission; for refunds the API has no test mode, so `testMode` returns what + * would be sent without calling it. + */ +function registerWriteTools(server: McpServer, affitor: AffitorLike, trackTestSale?: TrackTestSale): void { + server.registerTool( + 'affitor_track_sale', + { + description: + 'Report a sale. Creates a REAL sale and commission unless testMode is true. Resolves attribution by customerExternalId (bound at lead time).', + inputSchema: { + customerExternalId: z + .string() + .optional() + .describe("Advertiser's own user id — resolves attribution (no clickId needed once bound at lead time). One of customerExternalId / clickId is required."), + clickId: z.string().optional().describe('Affitor click id. One of clickId / customerExternalId is required.'), + amount: z.number().int().positive().describe('Sale amount in integer cents (e.g. 4999 = $49.99).'), + invoiceId: z.string().min(1).describe('Idempotency key — dedups retries (your invoice/transaction id).'), + currency: z.string().optional().describe('ISO currency code (default USD).'), + saleType: z.enum(['payment', 'subscription']).optional().describe('Whether this is a one-off payment or a subscription.'), + isRecurring: z.boolean().optional().describe('Whether this sale recurs (subscription renewal).'), + subscriptionId: z.string().optional().describe('Provider subscription id, if applicable.'), + subscriptionInterval: z + .enum(['monthly', 'quarterly', 'annual']) + .optional() + .describe('Billing interval for a subscription sale.'), + testMode: z + .boolean() + .optional() + .describe('Record a test sale (is_test, no commission) instead of a real one.'), + }, + annotations: { title: 'Report a sale', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + }, + async (args) => { + if (!args.customerExternalId && !args.clickId) { + return fail('affitor_track_sale: `customerExternalId` or `clickId` is required'); + } + const input: TestSaleInput = { + customerExternalId: args.customerExternalId, + clickId: args.clickId, + amount: args.amount, + invoiceId: args.invoiceId, + currency: args.currency, + saleType: args.saleType, + isRecurring: args.isRecurring, + subscriptionId: args.subscriptionId, + subscriptionInterval: args.subscriptionInterval, + }; + if (args.testMode) { + if (!trackTestSale) return fail('affitor_track_sale: testMode is not configured in this MCP build'); + return runCall(() => trackTestSale(input)); + } + return runCall(() => affitor.trackSale(input)); + }, + ); + + server.registerTool( + 'affitor_track_refund', + { + description: + 'Report a refund (omit amount = full → commission reversed; partial → refunded). Idempotent by invoiceId. With testMode, returns what would be sent and calls nothing.', + inputSchema: { + invoiceId: z.string().min(1).describe('The sale\'s idempotency key (the `invoiceId` you passed to affitor_track_sale).'), + refundAmountCents: z + .number() + .int() + .nonnegative() + .optional() + .describe('Refund amount in integer cents. Omit (or 0) = full refund → commission reversed; partial → refunded.'), + refundReason: z.string().optional().describe('Optional human-readable refund reason.'), + testMode: z + .boolean() + .optional() + .describe('Dry run: return the refund that would be sent, without calling the API.'), + }, + annotations: { title: 'Report a refund', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + }, + async (args) => { + const input = { + invoiceId: args.invoiceId, + refundAmountCents: args.refundAmountCents, + refundReason: args.refundReason, + }; + if (args.testMode) return ok({ dry_run: true, would_send: input }); + return runCall(() => affitor.trackRefund(input)); + }, + ); +} + /** Read the server version from this package's package.json at runtime. */ function readVersion(): string { try { @@ -350,8 +449,14 @@ async function main(): Promise { const runVerification: RunVerification = () => fireVerificationChain(apiKey, apiUrl ?? 'https://api.affitor.com'); + // AFFITOR_MCP_WRITE=1 registers the tools that move money (track_sale, track_refund). + + const baseUrl = apiUrl ?? 'https://api.affitor.com'; const server = new McpServer({ name: 'affitor', version: SERVER_VERSION }); - registerTools(server, affitor, runVerification); + registerTools(server, affitor, runVerification, { + allowWrites: process.env.AFFITOR_MCP_WRITE === '1', + trackTestSale: (input) => postTestSale(apiKey, baseUrl, input), + }); 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 674a5d7..d9ad94f 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -6,20 +6,20 @@ 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, type AffitorLike } from '../src/index.js'; +import { registerTools, postTestSale, type AffitorLike } from '../src/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SERVER_ENTRY = resolve(__dirname, '../dist/index.js'); -const EXPECTED_TOOLS = [ +// Registered by default. The two money tools need AFFITOR_MCP_WRITE=1. +const DEFAULT_TOOLS = [ 'affitor_readiness', 'affitor_track_lead', - 'affitor_track_sale', - 'affitor_track_refund', 'affitor_track_click', 'affitor_get_integration_plan', 'affitor_run_verification', ] as const; +const WRITE_TOOLS = ['affitor_track_sale', 'affitor_track_refund'] as const; // ── 1. Integration: spawn the BUILT server over stdio, drive with MCP Client ── describe('@affitor/mcp stdio server (spawned, built dist)', () => { @@ -30,7 +30,7 @@ describe('@affitor/mcp stdio server (spawned, built dist)', () => { transport = new StdioClientTransport({ command: process.execPath, // node args: [SERVER_ENTRY], - env: { AFFITOR_API_KEY: 'test_key', AFFITOR_API_URL: 'http://127.0.0.1:1' }, + env: { AFFITOR_API_KEY: 'test_key', AFFITOR_API_URL: 'http://127.0.0.1:1', AFFITOR_MCP_WRITE: '1' }, }); client = new Client({ name: 'test-client', version: '0.0.0' }); // connect() performs the MCP `initialize` handshake. @@ -46,16 +46,23 @@ describe('@affitor/mcp stdio server (spawned, built dist)', () => { expect(info?.name).toBe('affitor'); }); - it('tools/list returns the 7 tools with input schemas', async () => { + it('with AFFITOR_MCP_WRITE=1, tools/list returns all 7 tools, each annotated', async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name).sort(); - expect(names).toEqual([...EXPECTED_TOOLS].sort()); + expect(names).toEqual([...DEFAULT_TOOLS, ...WRITE_TOOLS].sort()); for (const tool of tools) { expect(tool.description, `${tool.name} description`).toBeTruthy(); expect(tool.inputSchema, `${tool.name} inputSchema`).toBeTruthy(); expect(tool.inputSchema.type).toBe('object'); + expect(typeof tool.annotations?.readOnlyHint, `${tool.name} readOnlyHint`).toBe('boolean'); + } + for (const name of WRITE_TOOLS) { + const tool = tools.find((t) => t.name === name)!; + expect(tool.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true }); } + expect(tools.find((t) => t.name === 'affitor_readiness')!.annotations?.readOnlyHint).toBe(true); + expect(tools.find((t) => t.name === 'affitor_get_integration_plan')!.annotations?.readOnlyHint).toBe(true); // Spot-check that affitor_track_sale exposes its key fields in the schema. const sale = tools.find((t) => t.name === 'affitor_track_sale')!; @@ -67,6 +74,36 @@ describe('@affitor/mcp stdio server (spawned, built dist)', () => { }); }); +describe('@affitor/mcp stdio server without AFFITOR_MCP_WRITE', () => { + let client: Client; + + beforeAll(async () => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [SERVER_ENTRY], + env: { AFFITOR_API_KEY: 'test_key', AFFITOR_API_URL: 'http://127.0.0.1:1' }, + }); + client = new Client({ name: 'test-client', version: '0.0.0' }); + await client.connect(transport); + }, 20000); + + afterAll(async () => { + await client?.close(); + }); + + it('does not offer the money tools', async () => { + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name).sort()).toEqual([...DEFAULT_TOOLS].sort()); + }); + + it('a call to affitor_track_sale is refused, not executed', async () => { + const res = await client + .callTool({ name: 'affitor_track_sale', arguments: { clickId: 'c', amount: 100, invoiceId: 'i' } }) + .catch((err: Error) => ({ isError: true, content: [{ type: 'text', text: err.message }] })); + expect(res.isError).toBe(true); + }); +}); + // ── 2. tools/call against a MOCKED fetch — asserts wire contract ── describe('@affitor/mcp tools/call → wire contract (mocked fetch)', () => { it('affitor_track_sale hits /api/v1/track/sale with Bearer + snake_case body', async () => { @@ -88,7 +125,7 @@ describe('@affitor/mcp tools/call → wire contract (mocked fetch)', () => { // Register tools on an in-memory MCP server, call through a connected client. const server = new McpServer({ name: 'affitor', version: '0.1.0' }); - registerTools(server, affitor); + registerTools(server, affitor, undefined, { allowWrites: true }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: 'test', version: '0.0.0' }); await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); @@ -177,7 +214,7 @@ describe('@affitor/mcp tool handlers (stubbed client)', () => { }; const server = new McpServer({ name: 'affitor', version: '0.1.0' }); - registerTools(server, stub); + 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)]); @@ -311,3 +348,141 @@ describe('@affitor/mcp tool handlers (stubbed client)', () => { await client.close(); }); }); + +// ── 4. Test mode and dry run ── +describe('@affitor/mcp test mode', () => { + const noCalls: AffitorLike = { + readiness: async () => ({}) as never, + trackLead: async () => ({ ok: true, status: 200, data: null }), + trackSale: async () => { + throw new Error('a test sale must not call the real trackSale'); + }, + trackRefund: async () => { + throw new Error('a dry-run refund must not call the API'); + }, + trackClick: async () => ({ ok: true, status: 200, data: null }), + }; + + async function connect(affitor: AffitorLike, trackTestSale?: (input: unknown) => Promise) { + const server = new McpServer({ name: 'affitor', version: '0.1.0' }); + registerTools(server, affitor, undefined, { allowWrites: true, trackTestSale: trackTestSale as never }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test', version: '0.0.0' }); + await Promise.all([server.connect(st), client.connect(ct)]); + return client; + } + + it('affitor_track_sale with testMode goes to the test-sale path, never the real one', async () => { + const seen: unknown[] = []; + const client = await connect(noCalls, async (input) => { + seen.push(input); + return { ok: true, status: 200, data: { test_mode: true } }; + }); + const res = await client.callTool({ + name: 'affitor_track_sale', + arguments: { clickId: 'clk_1', amount: 1000, invoiceId: 'inv_t', testMode: true }, + }); + expect(res.isError).toBeFalsy(); + expect(seen).toEqual([expect.objectContaining({ clickId: 'clk_1', amount: 1000, invoiceId: 'inv_t' })]); + await client.close(); + }); + + it('affitor_track_refund with testMode is a dry run', async () => { + const client = await connect(noCalls); + const res = await client.callTool({ + name: 'affitor_track_refund', + arguments: { invoiceId: 'inv_t', refundAmountCents: 500, testMode: true }, + }); + expect(res.isError).toBeFalsy(); + const text = (res.content as { type: string; text: string }[])[0].text; + expect(JSON.parse(text)).toEqual({ dry_run: true, would_send: { invoiceId: 'inv_t', refundAmountCents: 500 } }); + await client.close(); + }); + + it('affitor_track_lead with testMode sends test_mode', async () => { + const seen: unknown[] = []; + const client = await connect({ + ...noCalls, + trackLead: async (input) => { + seen.push(input); + return { ok: true, status: 200, data: null }; + }, + }); + await client.callTool({ name: 'affitor_track_lead', arguments: { clickId: 'clk_1', testMode: true } }); + expect(seen[0]).toMatchObject({ clickId: 'clk_1', additionalData: { test_mode: true } }); + await client.close(); + }); + + it('postTestSale posts the snake_case body with additional_data.test_mode and no undefined fields', async () => { + const calls: { url: string; init: RequestInit }[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ success: true, data: { test_mode: true } }), { status: 200 }); + }) as unknown as typeof fetch; + try { + const out = await postTestSale('prog_secret', 'https://api.affitor.com', { clickId: 'clk_1', amount: 1000, invoiceId: 'inv_t' }); + expect(out).toMatchObject({ ok: true, status: 200 }); + } finally { + globalThis.fetch = realFetch; + } + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://api.affitor.com/api/v1/track/sale'); + expect((calls[0].init.headers as Record).Authorization).toBe('Bearer prog_secret'); + const body = JSON.parse(String(calls[0].init.body)); + // test_ prefix: a test sale must never take the transaction id a real sale will use. + expect(body).toEqual({ click_id: 'clk_1', amount_cents: 1000, transaction_id: 'test_inv_t', additional_data: { test_mode: true } }); + }); + + it('postTestSale turns an API error into { ok: false, error }', async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(JSON.stringify({ error: 'Invalid API token' }), { status: 401 })) as unknown as typeof fetch; + try { + const out = await postTestSale('bad', 'https://api.affitor.com', { clickId: 'c', amount: 1, invoiceId: 'i' }); + expect(out).toMatchObject({ ok: false, status: 401, error: 'Invalid API token' }); + } finally { + globalThis.fetch = realFetch; + } + }); +}); + +describe('@affitor/mcp idempotency keys', () => { + it.each([ + ['affitor_track_sale', { clickId: 'c', amount: 100, invoiceId: '' }], + ['affitor_track_sale', { clickId: 'c', amount: 100, invoiceId: '', testMode: true }], + ['affitor_track_refund', { invoiceId: '' }], + ['affitor_track_refund', { invoiceId: '', testMode: true }], + ])('%s refuses an empty invoiceId before any call (%j)', async (name, args) => { + const calls = { trackSale: 0, trackRefund: 0, trackTestSale: 0 }; + const stub: AffitorLike = { + readiness: async () => ({}) as never, + trackLead: async () => ({ ok: true, status: 200, data: null }), + trackSale: async () => { + calls.trackSale++; + return { ok: true, status: 200, data: null }; + }, + trackRefund: async () => { + calls.trackRefund++; + return { 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, + trackTestSale: async () => { + calls.trackTestSale++; + return { ok: true, status: 200, data: null }; + }, + }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test', version: '0.0.0' }); + await Promise.all([server.connect(st), client.connect(ct)]); + const res = await client + .callTool({ name, arguments: args }) + .catch((err: Error) => ({ isError: true, content: [{ type: 'text', text: err.message }] })); + expect(res.isError).toBe(true); + expect(calls).toEqual({ trackSale: 0, trackRefund: 0, trackTestSale: 0 }); + await client.close(); + }); +});