Skip to content

Commit ad33e7d

Browse files
authored
Merge pull request #17 from Affitor/feat/mcp-run-verification
feat(mcp): affitor_run_verification — fire the synthetic chain (G3, #8)
2 parents c0df049 + 432e8e5 commit ad33e7d

2 files changed

Lines changed: 124 additions & 4 deletions

File tree

packages/mcp/src/index.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,60 @@ async function runCall(fn: () => Promise<unknown>): Promise<CallToolResult> {
7878
}
7979

8080
/**
81-
* Register the 6 Affitor tools on an MCP server. Exported for unit testing the
81+
* Fires Affitor's synthetic verification chain and resolves to the parsed body.
82+
* Injected into `registerTools` so the `affitor_run_verification` handler stays
83+
* unit-testable without a real network call.
84+
*/
85+
export type RunVerification = () => Promise<unknown>;
86+
87+
/**
88+
* POST `{base}/api/v1/cli/test-event` with `{ type: 'chain' }` to fire the
89+
* synthetic click→lead→sale verification chain through Affitor's REAL
90+
* attribution pipeline (isolated `is_test` rows).
91+
*
92+
* Returns the parsed JSON body for a 2xx response. For a non-2xx (including a
93+
* 429 rate-limit), returns the parsed error body with the HTTP status merged in
94+
* (`{ http_status, ...body }`) so the agent can read `retry_after_seconds` and
95+
* back off — it does NOT throw on 429. Throws only on a network or JSON-parse
96+
* failure, which `runCall` converts into an MCP error result.
97+
*/
98+
export async function fireVerificationChain(apiKey: string, apiUrl: string): Promise<unknown> {
99+
const res = await fetch(`${apiUrl}/api/v1/cli/test-event`, {
100+
method: 'POST',
101+
headers: {
102+
Authorization: `Bearer ${apiKey}`,
103+
'Content-Type': 'application/json',
104+
},
105+
body: JSON.stringify({ type: 'chain' }),
106+
});
107+
108+
const body = (await res.json()) as Record<string, unknown>;
109+
110+
if (res.ok) {
111+
return body;
112+
}
113+
114+
// Non-2xx (incl. 429): merge the HTTP status in so the agent can read
115+
// retry_after_seconds and back off rather than crash.
116+
return { http_status: res.status, ...body };
117+
}
118+
119+
/**
120+
* Register the 7 Affitor tools on an MCP server. Exported for unit testing the
82121
* handlers with a stubbed `Affitor` client.
83122
*
84123
* Five tools wrap the `Affitor` client (readiness + track*). The sixth,
85124
* `affitor_get_integration_plan`, is PURE — it reads the canonical recipe
86125
* registry (`@affitor/recipes`) and never touches the client or the network.
126+
* The seventh, `affitor_run_verification`, fires the synthetic verification
127+
* chain via the injected `runVerification` (omitted → the tool degrades to a
128+
* "not configured" failure).
87129
*/
88-
export function registerTools(server: McpServer, affitor: AffitorLike): void {
130+
export function registerTools(
131+
server: McpServer,
132+
affitor: AffitorLike,
133+
runVerification?: RunVerification,
134+
): void {
89135
server.registerTool(
90136
'affitor_readiness',
91137
{
@@ -255,6 +301,21 @@ export function registerTools(server: McpServer, affitor: AffitorLike): void {
255301
return ok(plan);
256302
},
257303
);
304+
305+
server.registerTool(
306+
'affitor_run_verification',
307+
{
308+
description:
309+
"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.",
310+
inputSchema: {},
311+
},
312+
async () => {
313+
if (!runVerification) {
314+
return fail('affitor_run_verification is not configured in this MCP build');
315+
}
316+
return runCall(() => runVerification());
317+
},
318+
);
258319
}
259320

260321
/** Read the server version from this package's package.json at runtime. */
@@ -284,8 +345,13 @@ async function main(): Promise<void> {
284345
const apiUrl = process.env.AFFITOR_API_URL;
285346
const affitor = new Affitor({ apiKey, ...(apiUrl ? { apiUrl } : {}) });
286347

348+
// Chain-firing isn't exposed by the SDK client (its `post` is private), so we
349+
// fire the endpoint via a small authenticated fetch — injected for testability.
350+
const runVerification: RunVerification = () =>
351+
fireVerificationChain(apiKey, apiUrl ?? 'https://api.affitor.com');
352+
287353
const server = new McpServer({ name: 'affitor', version: SERVER_VERSION });
288-
registerTools(server, affitor);
354+
registerTools(server, affitor, runVerification);
289355

290356
const transport = new StdioServerTransport();
291357
await server.connect(transport);

packages/mcp/tests/server.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const EXPECTED_TOOLS = [
1818
'affitor_track_refund',
1919
'affitor_track_click',
2020
'affitor_get_integration_plan',
21+
'affitor_run_verification',
2122
] as const;
2223

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

48-
it('tools/list returns the 6 tools with input schemas', async () => {
49+
it('tools/list returns the 7 tools with input schemas', async () => {
4950
const { tools } = await client.listTools();
5051
const names = tools.map((t) => t.name).sort();
5152
expect(names).toEqual([...EXPECTED_TOOLS].sort());
@@ -234,6 +235,59 @@ describe('@affitor/mcp tool handlers (stubbed client)', () => {
234235
await client.close();
235236
});
236237

238+
it('affitor_run_verification returns the injected runVerification payload (no network)', async () => {
239+
const stub: AffitorLike = {
240+
readiness: async () => ({}) as never,
241+
trackLead: async () => ({ ok: true, status: 200, data: null }),
242+
trackSale: async () => ({ ok: true, status: 200, data: null }),
243+
trackRefund: async () => ({ ok: true, status: 200, data: null }),
244+
trackClick: async () => ({ ok: true, status: 200, data: null }),
245+
};
246+
const payload = {
247+
data: {
248+
type: 'chain',
249+
verdict: { click: 'attributed', lead: 'attributed', sale: 'attributed' },
250+
attributed: true,
251+
},
252+
};
253+
254+
const server = new McpServer({ name: 'affitor', version: '0.1.0' });
255+
registerTools(server, stub, async () => payload);
256+
const [ct, st] = InMemoryTransport.createLinkedPair();
257+
const client = new Client({ name: 'test', version: '0.0.0' });
258+
await Promise.all([server.connect(st), client.connect(ct)]);
259+
260+
const res = await client.callTool({ name: 'affitor_run_verification', arguments: {} });
261+
expect(res.isError).toBeFalsy();
262+
const text = (res.content as { type: string; text: string }[])[0].text;
263+
expect(JSON.parse(text)).toEqual(payload);
264+
265+
await client.close();
266+
});
267+
268+
it('affitor_run_verification degrades to a not-configured failure when runVerification is absent', async () => {
269+
const stub: AffitorLike = {
270+
readiness: async () => ({}) as never,
271+
trackLead: async () => ({ ok: true, status: 200, data: null }),
272+
trackSale: async () => ({ ok: true, status: 200, data: null }),
273+
trackRefund: async () => ({ ok: true, status: 200, data: null }),
274+
trackClick: async () => ({ ok: true, status: 200, data: null }),
275+
};
276+
277+
const server = new McpServer({ name: 'affitor', version: '0.1.0' });
278+
registerTools(server, stub);
279+
const [ct, st] = InMemoryTransport.createLinkedPair();
280+
const client = new Client({ name: 'test', version: '0.0.0' });
281+
await Promise.all([server.connect(st), client.connect(ct)]);
282+
283+
const res = await client.callTool({ name: 'affitor_run_verification', arguments: {} });
284+
expect(res.isError).toBe(true);
285+
const text = (res.content as { type: string; text: string }[])[0].text;
286+
expect(text).toBe('affitor_run_verification is not configured in this MCP build');
287+
288+
await client.close();
289+
});
290+
237291
it('affitor_track_lead rejects when neither customerExternalId nor clickId is given', async () => {
238292
const stub: AffitorLike = {
239293
readiness: async () => ({}) as never,

0 commit comments

Comments
 (0)