opedd-mcp 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,419 +0,0 @@
1
- // Unit tests for the opedd-mcp tool dispatcher (extracted 2026-05-24 EEST
2
- // as part of the test cohort ship). Mocks globalThis.fetch + invokes
3
- // dispatchTool() directly; no stdio subprocess required.
4
- //
5
- // Pattern mirrors opedd-python's pytest-httpx mocking approach: per-test
6
- // fetch mock returns a controlled response, assertion verifies (a) the
7
- // outbound URL/method/body composition, (b) the dispatcher's
8
- // JSON-stringified ToolResult shape, (c) error mapping for non-2xx.
9
- //
10
- // Covers the 8 always-available tools (no env-var gate required). The 7
11
- // env-gated tools (article_53_attestation, get_buyer_account, list_feed,
12
- // stream_feed_ndjson, get_audit_events, get_compliance_dossier,
13
- // list_publisher_content) are covered in dispatcher.env-gated.test.ts.
14
-
15
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
-
17
- const TEST_BUYER_TOKEN = "opedd_buyer_test_unit_abc";
18
- const TEST_BUYER_EMAIL = "unit@opedd-test.com";
19
- const TEST_PM_ID = "pm_test_unit";
20
-
21
- // Set the env BEFORE importing the module — fixture-mock pattern.
22
- beforeEach(() => {
23
- vi.stubEnv("OPEDD_BUYER_TOKEN", TEST_BUYER_TOKEN);
24
- vi.stubEnv("OPEDD_BUYER_EMAIL", TEST_BUYER_EMAIL);
25
- vi.stubEnv("OPEDD_PAYMENT_METHOD_ID", TEST_PM_ID);
26
- vi.stubEnv("OPEDD_API_URL", "https://api.opedd.com");
27
- });
28
-
29
- afterEach(() => {
30
- vi.unstubAllEnvs();
31
- vi.restoreAllMocks();
32
- });
33
-
34
- async function loadDispatcher() {
35
- // Fresh import per test so env-var-driven branches re-evaluate.
36
- vi.resetModules();
37
- const mod = await import("../src/index.ts");
38
- return mod;
39
- }
40
-
41
- function mockFetchOk(body: unknown, status = 200): typeof fetch {
42
- const fn = vi.fn(async () =>
43
- new Response(JSON.stringify(body), {
44
- status,
45
- headers: { "content-type": "application/json" },
46
- }),
47
- );
48
- globalThis.fetch = fn as unknown as typeof fetch;
49
- return fn as unknown as typeof fetch;
50
- }
51
-
52
- function mockFetchErr(body: unknown, status: number): typeof fetch {
53
- const fn = vi.fn(async () =>
54
- new Response(JSON.stringify(body), {
55
- status,
56
- headers: { "content-type": "application/json" },
57
- }),
58
- );
59
- globalThis.fetch = fn as unknown as typeof fetch;
60
- return fn as unknown as typeof fetch;
61
- }
62
-
63
- function parsePayload(result: { content: Array<{ text: string }>; isError?: boolean }) {
64
- return JSON.parse(result.content[0].text) as Record<string, unknown>;
65
- }
66
-
67
- // ───────────────────────────── TOOLS array shape ─────────────────────────────
68
-
69
- describe("TOOLS array (metadata)", () => {
70
- it("exports 8 always-available tools (no env-var gate required)", async () => {
71
- // Clear env-gated vars so only the always-available bucket registers
72
- vi.unstubAllEnvs();
73
- vi.stubEnv("OPEDD_BUYER_EMAIL", TEST_BUYER_EMAIL);
74
- const { TOOLS } = await loadDispatcher();
75
- const names = TOOLS.map((t) => t.name);
76
- // Always-available: lookup_content, purchase_license, verify_license,
77
- // browse_registry, publisher_directory, purchase_enterprise_license,
78
- // rsl_get, detect_platform
79
- expect(names).toContain("lookup_content");
80
- expect(names).toContain("purchase_license");
81
- expect(names).toContain("verify_license");
82
- expect(names).toContain("browse_registry");
83
- expect(names).toContain("publisher_directory");
84
- expect(names).toContain("purchase_enterprise_license");
85
- expect(names).toContain("rsl_get");
86
- expect(names).toContain("detect_platform");
87
- expect(names.length).toBeGreaterThanOrEqual(8);
88
- });
89
-
90
- it("every tool carries name + description + inputSchema", async () => {
91
- const { TOOLS } = await loadDispatcher();
92
- for (const tool of TOOLS) {
93
- expect(tool.name).toMatch(/^[a-z_]+$/);
94
- expect(typeof tool.description).toBe("string");
95
- expect(tool.description.length).toBeGreaterThan(20);
96
- expect(tool.inputSchema).toBeDefined();
97
- expect(tool.inputSchema?.type).toBe("object");
98
- }
99
- });
100
-
101
- it("rsl_get advertises publisher_id required + jsonld optional", async () => {
102
- const { TOOLS } = await loadDispatcher();
103
- const tool = TOOLS.find((t) => t.name === "rsl_get");
104
- expect(tool).toBeDefined();
105
- const schema = tool!.inputSchema as { required?: string[]; properties: Record<string, unknown> };
106
- expect(schema.required).toContain("publisher_id");
107
- expect(schema.properties.jsonld).toBeDefined();
108
- });
109
-
110
- it("detect_platform advertises url required", async () => {
111
- const { TOOLS } = await loadDispatcher();
112
- const tool = TOOLS.find((t) => t.name === "detect_platform");
113
- expect(tool).toBeDefined();
114
- const schema = tool!.inputSchema as { required?: string[] };
115
- expect(schema.required).toContain("url");
116
- });
117
-
118
- it("publisher_directory has no required fields (all filters optional)", async () => {
119
- const { TOOLS } = await loadDispatcher();
120
- const tool = TOOLS.find((t) => t.name === "publisher_directory");
121
- expect(tool).toBeDefined();
122
- const schema = tool!.inputSchema as { required?: string[]; properties: Record<string, unknown> };
123
- expect(schema.required ?? []).toEqual([]);
124
- expect(schema.properties.category).toBeDefined();
125
- expect(schema.properties.min_articles).toBeDefined();
126
- });
127
- });
128
-
129
- // ───────────────────────────── lookup_content ─────────────────────────────
130
-
131
- describe("dispatchTool: lookup_content", () => {
132
- it("happy path — sends GET /lookup-article with encoded URL", async () => {
133
- const f = mockFetchOk({ success: true, data: { id: "art-1", title: "X" } });
134
- const { dispatchTool } = await loadDispatcher();
135
- const result = await dispatchTool("lookup_content", {
136
- url: "https://publisher.com/articles/x",
137
- });
138
- expect(f).toHaveBeenCalledOnce();
139
- const call = (f as ReturnType<typeof vi.fn>).mock.calls[0][0];
140
- expect(String(call)).toBe(
141
- "https://api.opedd.com/lookup-article?url=https%3A%2F%2Fpublisher.com%2Farticles%2Fx",
142
- );
143
- const payload = parsePayload(result);
144
- expect((payload.data as Record<string, unknown>).title).toBe("X");
145
- });
146
-
147
- it("rejects missing url with error", async () => {
148
- mockFetchOk({});
149
- const { dispatchTool } = await loadDispatcher();
150
- const result = await dispatchTool("lookup_content", {});
151
- expect(result.isError).toBe(true);
152
- expect(result.content[0].text).toContain("url is required");
153
- });
154
-
155
- it("propagates 404 as error response", async () => {
156
- mockFetchErr({ success: false, error: "Article not found" }, 404);
157
- const { dispatchTool } = await loadDispatcher();
158
- const result = await dispatchTool("lookup_content", { url: "https://no.example" });
159
- expect(result.isError).toBe(true);
160
- expect(result.content[0].text).toContain("Article not found");
161
- });
162
- });
163
-
164
- // ───────────────────────────── verify_license ─────────────────────────────
165
-
166
- describe("dispatchTool: verify_license", () => {
167
- it("happy path — sends GET /verify-license with encoded key", async () => {
168
- const f = mockFetchOk({ success: true, data: { key: "OP-1234-5678", blockchain_status: "confirmed" } });
169
- const { dispatchTool } = await loadDispatcher();
170
- const result = await dispatchTool("verify_license", { license_key: "OP-1234-5678" });
171
- expect(f).toHaveBeenCalledOnce();
172
- const call = (f as ReturnType<typeof vi.fn>).mock.calls[0][0];
173
- expect(String(call)).toContain("/verify-license?key=OP-1234-5678");
174
- const payload = parsePayload(result);
175
- expect((payload.data as Record<string, unknown>).blockchain_status).toBe("confirmed");
176
- });
177
-
178
- it("rejects missing license_key", async () => {
179
- mockFetchOk({});
180
- const { dispatchTool } = await loadDispatcher();
181
- const result = await dispatchTool("verify_license", {});
182
- expect(result.isError).toBe(true);
183
- expect(result.content[0].text).toContain("license_key is required");
184
- });
185
- });
186
-
187
- // ───────────────────────────── browse_registry ─────────────────────────────
188
-
189
- describe("dispatchTool: browse_registry", () => {
190
- it("happy path — sends GET /registry with default limit=10", async () => {
191
- const f = mockFetchOk({ success: true, data: { licenses: [] } });
192
- const { dispatchTool } = await loadDispatcher();
193
- await dispatchTool("browse_registry", {});
194
- const call = String((f as ReturnType<typeof vi.fn>).mock.calls[0][0]);
195
- expect(call).toContain("/registry?limit=10");
196
- });
197
-
198
- it("limit capped at 50", async () => {
199
- const f = mockFetchOk({ success: true, data: {} });
200
- const { dispatchTool } = await loadDispatcher();
201
- await dispatchTool("browse_registry", { limit: 999 });
202
- const call = String((f as ReturnType<typeof vi.fn>).mock.calls[0][0]);
203
- expect(call).toContain("limit=50");
204
- });
205
-
206
- it("publisher_id filter flows through to query", async () => {
207
- const f = mockFetchOk({ success: true, data: {} });
208
- const { dispatchTool } = await loadDispatcher();
209
- await dispatchTool("browse_registry", { publisher_id: "8268c353" });
210
- const call = String((f as ReturnType<typeof vi.fn>).mock.calls[0][0]);
211
- expect(call).toContain("publisher_id=8268c353");
212
- });
213
- });
214
-
215
- // ───────────────────────────── publisher_directory (chip 12) ─────────────────────────────
216
-
217
- describe("dispatchTool: publisher_directory", () => {
218
- it("happy path with all filters — encodes correctly", async () => {
219
- const f = mockFetchOk({ success: true, data: { publishers: [], total: 0 } });
220
- const { dispatchTool } = await loadDispatcher();
221
- await dispatchTool("publisher_directory", {
222
- category: "finance",
223
- min_articles: 5,
224
- verified: "true",
225
- limit: 20,
226
- offset: 0,
227
- });
228
- const call = String((f as ReturnType<typeof vi.fn>).mock.calls[0][0]);
229
- expect(call).toContain("category=finance");
230
- expect(call).toContain("min_articles=5");
231
- expect(call).toContain("verified=true");
232
- expect(call).toContain("limit=20");
233
- });
234
-
235
- it("no filters — bare /publisher-directory call", async () => {
236
- const f = mockFetchOk({ success: true, data: { publishers: [] } });
237
- const { dispatchTool } = await loadDispatcher();
238
- await dispatchTool("publisher_directory", {});
239
- const call = String((f as ReturnType<typeof vi.fn>).mock.calls[0][0]);
240
- expect(call).toMatch(/\/publisher-directory$/);
241
- });
242
- });
243
-
244
- // ───────────────────────────── rsl_get (chip 1) ─────────────────────────────
245
-
246
- describe("dispatchTool: rsl_get", () => {
247
- it("default jsonld=false — sends Accept: application/json", async () => {
248
- const f = mockFetchOk({ rsl_version: "1.0", tdm_reservation: true });
249
- const { dispatchTool } = await loadDispatcher();
250
- await dispatchTool("rsl_get", {
251
- publisher_id: "8268c353-ffa3-4db3-bbb2-90ddbbb43e41",
252
- });
253
- const calls = (f as ReturnType<typeof vi.fn>).mock.calls;
254
- expect(calls[0][0]).toContain("/rsl-manifest?publisher_id=");
255
- const headers = (calls[0][1] as RequestInit).headers as Record<string, string>;
256
- expect(headers["Accept"]).toBe("application/json");
257
- });
258
-
259
- it("jsonld=true — sends Accept: application/ld+json", async () => {
260
- const f = mockFetchOk({ "@type": "opedd:CdsmArticle4Reservation" });
261
- const { dispatchTool } = await loadDispatcher();
262
- await dispatchTool("rsl_get", {
263
- publisher_id: "8268c353-ffa3-4db3-bbb2-90ddbbb43e41",
264
- jsonld: true,
265
- });
266
- const calls = (f as ReturnType<typeof vi.fn>).mock.calls;
267
- const headers = (calls[0][1] as RequestInit).headers as Record<string, string>;
268
- expect(headers["Accept"]).toBe("application/ld+json");
269
- });
270
-
271
- it("rejects missing publisher_id", async () => {
272
- mockFetchOk({});
273
- const { dispatchTool } = await loadDispatcher();
274
- const result = await dispatchTool("rsl_get", {});
275
- expect(result.isError).toBe(true);
276
- expect(result.content[0].text).toContain("publisher_id is required");
277
- });
278
-
279
- it("propagates 404 on unverified publisher", async () => {
280
- mockFetchErr({ success: false, error: "Publisher not found" }, 404);
281
- const { dispatchTool } = await loadDispatcher();
282
- const result = await dispatchTool("rsl_get", {
283
- publisher_id: "00000000-0000-0000-0000-000000000000",
284
- });
285
- expect(result.isError).toBe(true);
286
- expect(result.content[0].text).toContain("Publisher not found");
287
- });
288
- });
289
-
290
- // ───────────────────────────── detect_platform (chip 3) ─────────────────────────────
291
-
292
- describe("dispatchTool: detect_platform", () => {
293
- it("happy path — POSTs JSON body", async () => {
294
- const f = mockFetchOk({
295
- success: true,
296
- data: { platform: "substack", confidence: "high", archive_method: "email" },
297
- });
298
- const { dispatchTool } = await loadDispatcher();
299
- await dispatchTool("detect_platform", { url: "https://noahpinion.substack.com" });
300
- const calls = (f as ReturnType<typeof vi.fn>).mock.calls;
301
- expect(calls[0][0]).toContain("/detect-platform");
302
- const opts = calls[0][1] as RequestInit;
303
- expect(opts.method).toBe("POST");
304
- expect(JSON.parse(opts.body as string)).toEqual({ url: "https://noahpinion.substack.com" });
305
- });
306
-
307
- it("rejects missing url", async () => {
308
- mockFetchOk({});
309
- const { dispatchTool } = await loadDispatcher();
310
- const result = await dispatchTool("detect_platform", {});
311
- expect(result.isError).toBe(true);
312
- expect(result.content[0].text).toContain("url is required");
313
- });
314
- });
315
-
316
- // ───────────────────────────── purchase_license (existing) ─────────────────────────────
317
-
318
- describe("dispatchTool: purchase_license", () => {
319
- it("happy path — POSTs /agent-purchase with buyer_email + payment fallback", async () => {
320
- const f = mockFetchOk({ success: true, data: { license_key: "OP-1234-5678" } });
321
- const { dispatchTool } = await loadDispatcher();
322
- await dispatchTool("purchase_license", {
323
- article_id: "art-1",
324
- license_type: "ai",
325
- });
326
- const calls = (f as ReturnType<typeof vi.fn>).mock.calls;
327
- expect(calls[0][0]).toContain("/agent-purchase");
328
- const opts = calls[0][1] as RequestInit;
329
- const body = JSON.parse(opts.body as string);
330
- // Env fallback supplies buyer_email + payment.payment_method_id
331
- expect(body.buyer_email).toBe(TEST_BUYER_EMAIL);
332
- expect(body.payment.payment_method_id).toBe(TEST_PM_ID);
333
- expect(body.license_type).toBe("ai");
334
- });
335
-
336
- it("rejects missing article_url AND article_id", async () => {
337
- mockFetchOk({});
338
- const { dispatchTool } = await loadDispatcher();
339
- const result = await dispatchTool("purchase_license", { license_type: "ai" });
340
- expect(result.isError).toBe(true);
341
- expect(result.content[0].text).toContain("article_url or article_id");
342
- });
343
-
344
- it("rejects missing buyer_email when env not set", async () => {
345
- vi.unstubAllEnvs();
346
- mockFetchOk({});
347
- const { dispatchTool } = await loadDispatcher();
348
- const result = await dispatchTool("purchase_license", {
349
- article_id: "art-1",
350
- license_type: "ai",
351
- });
352
- expect(result.isError).toBe(true);
353
- expect(result.content[0].text).toContain("buyer_email");
354
- });
355
- });
356
-
357
- // ───────────────────────────── purchase_enterprise_license ─────────────────────────────
358
-
359
- describe("dispatchTool: purchase_enterprise_license", () => {
360
- it("happy path — defaults billing_type=annual, license_tier=rag, scope=custom", async () => {
361
- const f = mockFetchOk({
362
- success: true,
363
- data: { enterprise_license_id: "lic-1", stripe_client_secret: "cs_..." },
364
- });
365
- const { dispatchTool } = await loadDispatcher();
366
- await dispatchTool("purchase_enterprise_license", {
367
- publisher_ids: ["pub-1"],
368
- buyer_email: "eng@yourlab.com",
369
- buyer_org: "AI Lab",
370
- });
371
- const calls = (f as ReturnType<typeof vi.fn>).mock.calls;
372
- expect(calls[0][0]).toContain("/enterprise-license");
373
- const body = JSON.parse((calls[0][1] as RequestInit).body as string);
374
- expect(body.billing_type).toBe("annual");
375
- expect(body.license_tier).toBe("rag");
376
- expect(body.scope).toBe("custom");
377
- });
378
-
379
- it("requires publisher_ids for scope='custom'", async () => {
380
- mockFetchOk({});
381
- const { dispatchTool } = await loadDispatcher();
382
- const result = await dispatchTool("purchase_enterprise_license", {
383
- buyer_email: "x",
384
- buyer_org: "y",
385
- scope: "custom",
386
- });
387
- expect(result.isError).toBe(true);
388
- expect(result.content[0].text).toContain("publisher_ids");
389
- });
390
- });
391
-
392
- // ───────────────────────────── error mapping + unknown tool ─────────────────────────────
393
-
394
- describe("dispatchTool: shared error paths", () => {
395
- it("unknown tool name returns isError", async () => {
396
- const { dispatchTool } = await loadDispatcher();
397
- const result = await dispatchTool("definitely_not_a_tool", {});
398
- expect(result.isError).toBe(true);
399
- expect(result.content[0].text).toContain("Unknown tool");
400
- });
401
-
402
- it("fetch throw (network failure) surfaces as Request failed", async () => {
403
- globalThis.fetch = vi.fn(async () => {
404
- throw new Error("ECONNREFUSED");
405
- }) as unknown as typeof fetch;
406
- const { dispatchTool } = await loadDispatcher();
407
- const result = await dispatchTool("lookup_content", { url: "https://x.example" });
408
- expect(result.isError).toBe(true);
409
- expect(result.content[0].text).toContain("Request failed");
410
- });
411
-
412
- it("500 error response surfaces error message", async () => {
413
- mockFetchErr({ error: "Internal" }, 500);
414
- const { dispatchTool } = await loadDispatcher();
415
- const result = await dispatchTool("lookup_content", { url: "https://x.example" });
416
- expect(result.isError).toBe(true);
417
- expect(result.content[0].text).toContain("Internal");
418
- });
419
- });
package/tsconfig.json DELETED
@@ -1,15 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "Node16",
5
- "moduleResolution": "Node16",
6
- "outDir": "dist",
7
- "rootDir": "src",
8
- "strict": true,
9
- "esModuleInterop": true,
10
- "skipLibCheck": true,
11
- "resolveJsonModule": true
12
- },
13
- "include": ["src"],
14
- "exclude": ["node_modules", "dist"]
15
- }