opedd-mcp 0.1.2 → 0.3.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.
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  ListToolsRequestSchema,
8
8
  type Tool,
9
9
  } from "@modelcontextprotocol/sdk/types.js";
10
+ import { pathToFileURL } from "node:url";
10
11
 
11
12
  // ─── Configuration ────────────────────────────────────────────────────────────
12
13
 
@@ -17,7 +18,9 @@ const API_BASE =
17
18
  const BUYER_EMAIL = process.env.OPEDD_BUYER_EMAIL;
18
19
  const PAYMENT_METHOD_ID = process.env.OPEDD_PAYMENT_METHOD_ID;
19
20
  const API_KEY = process.env.OPEDD_API_KEY; // publisher API key (op_...)
20
- const BUYER_TOKEN = process.env.OPEDD_BUYER_TOKEN; // buyer API token (bk_live_...)
21
+ const BUYER_TOKEN = process.env.OPEDD_BUYER_TOKEN; // buyer API token (opedd_buyer_live_... or opedd_buyer_test_...)
22
+ const ACCESS_KEY = process.env.OPEDD_ACCESS_KEY; // enterprise access key (ent_*); for /enterprise-license GET feed
23
+ const BUYER_JWT = process.env.OPEDD_BUYER_JWT; // Supabase JWT; for /buyer-audit + /buyer-compliance-report
21
24
 
22
25
  // ─── HTTP helpers ─────────────────────────────────────────────────────────────
23
26
 
@@ -37,6 +40,46 @@ async function opeddFetch(path: string, options: RequestInit = {}): Promise<unkn
37
40
  return body;
38
41
  }
39
42
 
43
+ // Fetch an NDJSON endpoint and collect parsed lines into an array.
44
+ // Last line is typically `{"_meta": {...}}`; surfaced as `meta` field on the
45
+ // returned object so the MCP tool result has both shape pieces inline.
46
+ async function opeddFetchNdjson(
47
+ path: string,
48
+ options: RequestInit = {},
49
+ ): Promise<{ articles: unknown[]; meta: unknown }> {
50
+ const url = `${API_BASE}${path}`;
51
+ const headers: Record<string, string> = {
52
+ Accept: "application/x-ndjson",
53
+ ...(API_KEY ? { "X-API-Key": API_KEY } : {}),
54
+ ...(options.headers as Record<string, string> ?? {}),
55
+ };
56
+ const res = await fetch(url, { ...options, headers });
57
+ if (!res.ok) {
58
+ let msg = `HTTP ${res.status}`;
59
+ try {
60
+ const errBody = await res.json();
61
+ msg = (errBody as any)?.error || (errBody as any)?.message || msg;
62
+ } catch {
63
+ // non-JSON error body — keep the HTTP-status default
64
+ }
65
+ throw new Error(msg);
66
+ }
67
+ const text = await res.text();
68
+ const articles: unknown[] = [];
69
+ let meta: unknown = null;
70
+ for (const raw of text.split("\n")) {
71
+ const line = raw.trim();
72
+ if (!line) continue;
73
+ const row = JSON.parse(line) as { _meta?: unknown };
74
+ if (row._meta !== undefined) {
75
+ meta = row._meta;
76
+ } else {
77
+ articles.push(row);
78
+ }
79
+ }
80
+ return { articles, meta };
81
+ }
82
+
40
83
  // ─── MCP response helpers ─────────────────────────────────────────────────────
41
84
 
42
85
  type TextContent = { type: "text"; text: string };
@@ -52,7 +95,7 @@ function err(msg: string): ToolResult {
52
95
 
53
96
  // ─── Tool definitions ─────────────────────────────────────────────────────────
54
97
 
55
- const TOOLS: Tool[] = [
98
+ export const TOOLS: Tool[] = [
56
99
  {
57
100
  name: "lookup_content",
58
101
  description:
@@ -165,6 +208,155 @@ const TOOLS: Tool[] = [
165
208
  },
166
209
  },
167
210
  },
211
+ // ─── Public buyer-discovery — catalog browse ──────────────────────────────
212
+ {
213
+ name: "publisher_directory",
214
+ description:
215
+ "Browse the public Opedd publisher catalog via GET /publisher-directory. " +
216
+ "Returns paginated publishers with article counts, pricing (per-article + annual + monthly-forward-feed), " +
217
+ "plan, and sample articles (RAG-extended metadata). " +
218
+ "**The primary discovery surface for AI labs to find Opedd-licensable publishers** — distinct from " +
219
+ "`browse_registry` (which lists issued LICENSES, not publishers). " +
220
+ "Filter by category (case-insensitive substring), min_articles, or verified status. " +
221
+ "Public no-auth — useful pre-purchase scoping before buyers commit to enterprise-license POST.",
222
+ inputSchema: {
223
+ type: "object",
224
+ properties: {
225
+ category: {
226
+ type: "string",
227
+ description: "Case-insensitive substring filter on publisher category (e.g. 'finance', 'AI').",
228
+ },
229
+ min_articles: {
230
+ type: "number",
231
+ description: "Filter to publishers with at least this many licensable articles.",
232
+ },
233
+ verified: {
234
+ type: "string",
235
+ description: "'true' to show only verified publishers (default), 'false' for unverified.",
236
+ },
237
+ limit: {
238
+ type: "number",
239
+ description: "Page size cap.",
240
+ },
241
+ offset: {
242
+ type: "number",
243
+ description: "Pagination offset.",
244
+ },
245
+ },
246
+ },
247
+ },
248
+ // ─── Phase 12 Wave 3 W3.1 — onboarding helpers ────────────────────────────
249
+ {
250
+ name: "detect_platform",
251
+ description:
252
+ "Detect the content platform behind a URL via POST /detect-platform (Phase 12 Wave 3 W3.1). " +
253
+ "Public no-auth lookup. Given a URL, identifies what platform powers it " +
254
+ "(Substack / Beehiiv / Ghost / Medium / Brevo / custom) and returns the suggested onboarding workflow. " +
255
+ "Hostname-detectable platforms (Substack subdomain, Beehiiv suffix, etc.) resolve in milliseconds; " +
256
+ "custom domains may take ~few seconds while the detector probes well-known platform endpoints in parallel. " +
257
+ "Returns: {platform, confidence, archive_method, forward_method, required_credentials, instructions}. " +
258
+ "The archive_method + forward_method fields are the two onboarding-workflow inputs Opedd's setup wizard reads " +
259
+ "(one for historical content backfill, one for new-content forward stream). " +
260
+ "instructions is human-readable operator copy explaining the inferred path.",
261
+ inputSchema: {
262
+ type: "object",
263
+ required: ["url"],
264
+ properties: {
265
+ url: {
266
+ type: "string",
267
+ description: "Publisher URL to inspect (any well-formed URL works; hostname-match short-circuits the probe path).",
268
+ },
269
+ },
270
+ },
271
+ },
272
+ // ─── Phase 12 Wave 1 W1.1 — public RSL Standard manifest ─────────────────
273
+ {
274
+ name: "rsl_get",
275
+ description:
276
+ "Fetch a publisher's RSL Standard manifest via GET /rsl-manifest (Phase 12 Wave 1 W1.1). " +
277
+ "Public no-auth endpoint — discovery surface for AI agents/crawlers wanting to know what's licensable " +
278
+ "from a publisher BEFORE going through the buyer-account signup flow. " +
279
+ "Returns the 4 canonical license types (ai_retrieval, ai_training, human_per_article, human_full_archive) " +
280
+ "the publisher has opted into, plus the EU CDSM Article 4(3) opt-out posture (`tdm_reservation`). " +
281
+ "Set `jsonld: true` to request the JSON-LD shape with embedded HMAC-SHA256 signed receipt over the " +
282
+ "CDSM Article 4(3) reservation state + `tdm:reservationSignedAt` timestamp — regulators can post-hoc " +
283
+ "verify the reservation was the claimed value at the claimed time. Default `jsonld: false` returns " +
284
+ "the raw RSL Standard JSON manifest. " +
285
+ "Per INVARIANTS.md W1.6: this is the PUBLISHER-side CDSM Article 4(3) declaration surface. It is NOT " +
286
+ "an EU AI Act Article 53 attestation (which is buyer-side, JWT-auth, via article_53_attestation tool).",
287
+ inputSchema: {
288
+ type: "object",
289
+ required: ["publisher_id"],
290
+ properties: {
291
+ publisher_id: {
292
+ type: "string",
293
+ description: "UUID of the publisher whose RSL manifest to fetch. Publisher must be verified.",
294
+ },
295
+ jsonld: {
296
+ type: "boolean",
297
+ description:
298
+ "If true, request JSON-LD shape (Accept: application/ld+json) with embedded HMAC-SHA256 signed receipt. " +
299
+ "Default false returns raw RSL Standard JSON shape.",
300
+ },
301
+ },
302
+ },
303
+ },
304
+ // ─── Phase 10 + 11 buyer-side surfaces (M6.4) ─────────────────────────────
305
+ {
306
+ name: "purchase_enterprise_license",
307
+ description:
308
+ "Purchase a bulk enterprise license covering multiple publishers (Phase 10). " +
309
+ "Returns a Stripe client_secret for payment completion + the enterprise_license_id. " +
310
+ "After payment, an ent_* access key is emailed to buyer_email. " +
311
+ "Scopes: 'custom' (pass-through publisher_ids), 'platform_wide' (auto-resolve all opted-in publishers), 'filtered' (Phase 10 filter_rules). " +
312
+ "License tiers: 'rag' (= ai_retrieval), 'training' (= ai_training, flat-fee not metered), 'inference' (= ai_retrieval), 'full_ai' (writes both retrieval + training records).",
313
+ inputSchema: {
314
+ type: "object",
315
+ required: ["publisher_ids", "buyer_email", "buyer_org"],
316
+ properties: {
317
+ publisher_ids: {
318
+ type: "array",
319
+ items: { type: "string" },
320
+ description: "Array of publisher UUIDs. Required for scope='custom'; ignored for platform_wide/filtered (resolved server-side).",
321
+ },
322
+ buyer_email: {
323
+ type: "string",
324
+ description: "Email to deliver the access key after payment",
325
+ },
326
+ buyer_org: {
327
+ type: "string",
328
+ description: "Buyer organization name (for billing + audit ledger)",
329
+ },
330
+ billing_type: {
331
+ type: "string",
332
+ enum: ["annual", "monthly", "annual_plus_monthly"],
333
+ description: "Billing cadence (default: annual)",
334
+ },
335
+ license_tier: {
336
+ type: "string",
337
+ enum: ["rag", "training", "inference", "full_ai"],
338
+ description: "License tier (default: rag)",
339
+ },
340
+ duration_months: {
341
+ type: "number",
342
+ description: "License duration in months (default: 12)",
343
+ },
344
+ scope: {
345
+ type: "string",
346
+ enum: ["custom", "platform_wide", "filtered"],
347
+ description: "Coverage scope (default: custom)",
348
+ },
349
+ filter_rules: {
350
+ type: "object",
351
+ description: "Required when scope='filtered'. See Phase 10 docs for shape: excluded_publisher_ids / direct_license_carveouts / categories / max_price_per_event.",
352
+ },
353
+ buyer_webhook_url: {
354
+ type: "string",
355
+ description: "Optional HMAC-signed webhook for content.published events on covered publishers",
356
+ },
357
+ },
358
+ },
359
+ },
168
360
  ];
169
361
 
170
362
  // If a buyer token is configured, expose content delivery tooling
@@ -172,10 +364,13 @@ if (BUYER_TOKEN) {
172
364
  TOOLS.push({
173
365
  name: "get_content",
174
366
  description:
175
- "Retrieve the full body of a licensed article using a buyer API token (bk_live_...). " +
367
+ "Retrieve the full body of a licensed article using a buyer API token (opedd_buyer_live_* canonical; opedd_buyer_test_* for sandbox). " +
176
368
  "Requires OPEDD_BUYER_TOKEN env var (create one at opedd.com/licenses after purchasing). " +
177
369
  "Works for per-article licenses (token scoped to that article) and archive licenses (token covers all publisher content). " +
178
- "The publisher must have content delivery enabled and must have pushed content for the article.",
370
+ "The publisher must have content delivery enabled and must have pushed content for the article. " +
371
+ "Phase 11 M2 RAG-extended shape: response includes 7 RAG-essential metadata fields — author, language, word_count, content_hash, image_urls, canonical_url, tags. " +
372
+ "On pre-2026-05-14 historical articles, optional fields (author/language/image_urls/canonical_url/tags) may be NULL. " +
373
+ "NULL means 'data unavailable for this article', NOT 'explicitly empty' — treat as data-missing when filtering; do not interpret as anti-match.",
179
374
  inputSchema: {
180
375
  type: "object",
181
376
  required: ["article_id"],
@@ -186,7 +381,188 @@ if (BUYER_TOKEN) {
186
381
  },
187
382
  buyer_token: {
188
383
  type: "string",
189
- description: "Buyer API token (bk_live_...). Falls back to OPEDD_BUYER_TOKEN env var.",
384
+ description: "Buyer API token (opedd_buyer_live_* or opedd_buyer_test_*). Falls back to OPEDD_BUYER_TOKEN env var.",
385
+ },
386
+ },
387
+ },
388
+ });
389
+ }
390
+
391
+ // If an enterprise access key is configured, expose feed tools
392
+ if (ACCESS_KEY) {
393
+ TOOLS.push({
394
+ name: "list_feed",
395
+ description:
396
+ "List articles from a buyer's licensed catalog via GET /enterprise-license (Phase 10 + 11). " +
397
+ "Returns JSON-format response with paginated articles. " +
398
+ "Use `since` (ISO 8601) for delta-feed polling — only articles published after the timestamp. " +
399
+ "Use `cursor` for pagination across pages. " +
400
+ "Requires OPEDD_ACCESS_KEY (ent_* enterprise access key). " +
401
+ "For larger bulk corpus pulls, use stream_feed_ndjson (up to 1000 articles per call vs 200 here).",
402
+ inputSchema: {
403
+ type: "object",
404
+ properties: {
405
+ since: {
406
+ type: "string",
407
+ description: "ISO 8601 timestamp — return only articles with published_at > since",
408
+ },
409
+ cursor: {
410
+ type: "string",
411
+ description: "Opaque cursor from prior response's _meta.next_cursor",
412
+ },
413
+ limit: {
414
+ type: "number",
415
+ description: "Max articles per response (default: 50, max: 200)",
416
+ },
417
+ },
418
+ },
419
+ });
420
+ TOOLS.push({
421
+ name: "stream_feed_ndjson",
422
+ description:
423
+ "Bulk-export a buyer's licensed catalog via GET /enterprise-license?format=ndjson (Phase 11 M3). " +
424
+ "Returns up to 1000 articles per call (collected from line-delimited JSON wire format). " +
425
+ "Each article emits one usage_records row (analytics-only sentinel 'bulk-export:<request_id>:<article_id>' — not metered-billable per the revenue-model bifurcation invariant). " +
426
+ "Use `since` (ISO 8601) for delta-feed. Use `cursor` to paginate beyond 1000. " +
427
+ "Backend supports 5000 articles per call; the MCP cap is 1000 for transport reasonability. " +
428
+ "Real bulk-ingest pipelines should use the Python SDK (pip install opedd) directly — not via MCP. " +
429
+ "Requires OPEDD_ACCESS_KEY (ent_*).",
430
+ inputSchema: {
431
+ type: "object",
432
+ properties: {
433
+ since: {
434
+ type: "string",
435
+ description: "ISO 8601 timestamp — return only articles with published_at > since",
436
+ },
437
+ cursor: {
438
+ type: "string",
439
+ description: "Opaque cursor from prior response's _meta.next_cursor",
440
+ },
441
+ limit: {
442
+ type: "number",
443
+ description: "Max articles per response (default: 200, max: 1000)",
444
+ },
445
+ },
446
+ },
447
+ });
448
+ }
449
+
450
+ // If a Supabase buyer JWT is configured, expose audit + compliance tools
451
+ if (BUYER_JWT) {
452
+ TOOLS.push({
453
+ name: "get_audit_events",
454
+ description:
455
+ "Browse per-event audit rows for the authenticated buyer via GET /buyer-audit (Phase 9.x). " +
456
+ "Each row carries license_terms + Tempo on-chain attestation (merkle_root + inclusion_proof when blockchain_status='confirmed'). " +
457
+ "Optional filter by event_type ('content_access', 'bulk_content_access', 'compliance_report_generated'). " +
458
+ "Window cap 30 days (vs 90-day cap on get_compliance_dossier). " +
459
+ "Attestation inclusion proof is included on every row by default — no separate flag needed (M6.4 consolidation per founder ratification: tools 4 + 6 merged into one cleaner mental model). " +
460
+ "Requires OPEDD_BUYER_JWT (Supabase session JWT from the buyer portal).",
461
+ inputSchema: {
462
+ type: "object",
463
+ properties: {
464
+ from: {
465
+ type: "string",
466
+ description: "ISO 8601 timestamp lower bound (inclusive)",
467
+ },
468
+ to: {
469
+ type: "string",
470
+ description: "ISO 8601 timestamp upper bound (inclusive)",
471
+ },
472
+ event_type: {
473
+ type: "string",
474
+ enum: ["content_access", "bulk_content_access", "compliance_report_generated"],
475
+ description: "Optional event-class filter",
476
+ },
477
+ cursor: {
478
+ type: "string",
479
+ description: "Opaque cursor for pagination",
480
+ },
481
+ limit: {
482
+ type: "number",
483
+ description: "Max events per response (default: 50, max: 200)",
484
+ },
485
+ },
486
+ },
487
+ });
488
+ TOOLS.push({
489
+ name: "get_buyer_account",
490
+ description:
491
+ "Fetch the authenticated buyer's account profile + masked API key list via GET /buyer-account. " +
492
+ "Returns the enterprise_buyers row (contact_email, buyer_org, created_at, etc.) plus a list of all " +
493
+ "buyer-side API keys with masked prefixes (NEVER plaintext post-issuance — only the 12-char " +
494
+ "key_prefix is returned, e.g. 'opedd_buyer_'). " +
495
+ "Use cases: post-signup verification ('what was just issued to me?'), buyer dashboard mental model " +
496
+ "('what licenses do I currently hold?'), audit prep ('show me the key list before rotation'). " +
497
+ "For full mid-lifecycle license details (filter_rules, billing, payouts), buyers consult the buyer portal at opedd.com/buyer. " +
498
+ "Requires OPEDD_BUYER_JWT.",
499
+ inputSchema: {
500
+ type: "object",
501
+ properties: {},
502
+ },
503
+ });
504
+ TOOLS.push({
505
+ name: "article_53_attestation",
506
+ description:
507
+ "Issue a signed JWT attesting to EU AI Act Article 53 compliance for a specific license via " +
508
+ "GET /eu-ai-act/article-53-attestation (Phase 12 Wave 1 W1.4). " +
509
+ "Returns a freshly-signed HS256 JWT regulators can verify offline against the canonical signing key. " +
510
+ "Embeds: license context, usage-count over the attestation window, the most-recent Tempo Merkle root, " +
511
+ "and canonical claims (iss/sub/iat/exp/jti/aud). " +
512
+ "**The artifact AI labs hand to legal/procurement for EU AI Act Article 53(1)(d) transparency-obligation evidence.** " +
513
+ "Per INVARIANTS.md W1.6: this attests to EU AI Act Article 53 ONLY (buyer-side GPAI-model-provider " +
514
+ "transparency obligation). It does NOT discharge a publisher's CDSM Article 4(3) reservation obligation — " +
515
+ "that lives on the rsl_get tool (jsonld=true variant). Never conflate. " +
516
+ "Optional `content_id` scopes the attestation to one article; default is license-wide. " +
517
+ "Window cap: 365 days. Requires OPEDD_BUYER_JWT.",
518
+ inputSchema: {
519
+ type: "object",
520
+ required: ["license_id"],
521
+ properties: {
522
+ license_id: {
523
+ type: "string",
524
+ description: "UUID of the enterprise_license OR legacy individual license to attest. Buyer must own it.",
525
+ },
526
+ content_id: {
527
+ type: "string",
528
+ description: "Optional UUID of a specific article to scope the attestation. Default: license-wide.",
529
+ },
530
+ window_start: {
531
+ type: "string",
532
+ description: "ISO 8601 lower bound of the attestation window. Default: now - 90 days.",
533
+ },
534
+ window_end: {
535
+ type: "string",
536
+ description: "ISO 8601 upper bound. Default: now. Window may not exceed 365 days (hard cap).",
537
+ },
538
+ },
539
+ },
540
+ });
541
+ TOOLS.push({
542
+ name: "get_compliance_dossier",
543
+ description:
544
+ "Generate a procurement-defense compliance dossier via GET /buyer-compliance-report (Phase 11 M4). " +
545
+ "Per-row dossier shape: 25+ fields including 17 RAG-essential article fields + full license_terms + on_chain_attestation block. " +
546
+ "Bulk envelopes fan out into per-article rows by iterating metadata.article_ids[]. " +
547
+ "Self-audit invariant: every successful call writes one license_events row with event_type='compliance_report_generated' BEFORE returning. " +
548
+ "Window cap: 90 days per call (vs 30-day cap on get_audit_events). For annual audits, paginate via _meta.next_cursor across 4 quarterly windows. " +
549
+ "Compliance framework anchors (boolean flags) map to EU AI Act Article 53, CDSM Article 4(3), on-chain attestation, TDM reservation. " +
550
+ "Requires OPEDD_BUYER_JWT.",
551
+ inputSchema: {
552
+ type: "object",
553
+ required: ["from", "to"],
554
+ properties: {
555
+ from: {
556
+ type: "string",
557
+ description: "ISO 8601 timestamp lower bound (inclusive)",
558
+ },
559
+ to: {
560
+ type: "string",
561
+ description: "ISO 8601 timestamp upper bound (inclusive). Window cap 90 days.",
562
+ },
563
+ cursor: {
564
+ type: "string",
565
+ description: "Opaque cursor for pagination",
190
566
  },
191
567
  },
192
568
  },
@@ -225,15 +601,28 @@ if (API_KEY) {
225
601
  // ─── MCP Server ───────────────────────────────────────────────────────────────
226
602
 
227
603
  const server = new Server(
228
- { name: "opedd-mcp", version: "0.1.2" },
604
+ { name: "opedd-mcp", version: "0.3.0" },
229
605
  { capabilities: { tools: {} } }
230
606
  );
231
607
 
232
608
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
233
609
 
234
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
235
- const { name, arguments: args = {} } = request.params;
610
+ server.setRequestHandler(CallToolRequestSchema, async (request) =>
611
+ dispatchTool(request.params.name, (request.params.arguments ?? {}) as Record<string, unknown>),
612
+ );
236
613
 
614
+ // ─── Tool dispatcher (exported for unit tests) ────────────────────────────────
615
+ //
616
+ // Extracted from the inline handler 2026-05-24 EEST as part of the opedd-mcp
617
+ // test cohort ship. Behavior-preserving: the server.setRequestHandler above
618
+ // is a thin delegation to dispatchTool. Exporting allows unit tests to mock
619
+ // global fetch + invoke each tool's handler directly without spawning a
620
+ // stdio subprocess.
621
+
622
+ export async function dispatchTool(
623
+ name: string,
624
+ args: Record<string, unknown>,
625
+ ): Promise<ToolResult> {
237
626
  try {
238
627
  switch (name) {
239
628
  // ── lookup_content ─────────────────────────────────────────────────────
@@ -354,6 +743,238 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
354
743
  return ok(data);
355
744
  }
356
745
 
746
+ // ── publisher_directory (buyer-discovery catalog browse) ───────────────
747
+ case "publisher_directory": {
748
+ const { category, min_articles, verified, limit, offset } = args as {
749
+ category?: string;
750
+ min_articles?: number;
751
+ verified?: string;
752
+ limit?: number;
753
+ offset?: number;
754
+ };
755
+ const params = new URLSearchParams();
756
+ if (category) params.set("category", category);
757
+ if (min_articles !== undefined) params.set("min_articles", String(min_articles));
758
+ if (verified !== undefined) params.set("verified", verified);
759
+ if (limit !== undefined) params.set("limit", String(limit));
760
+ if (offset !== undefined) params.set("offset", String(offset));
761
+
762
+ const query = params.toString();
763
+ const data = await opeddFetch(`/publisher-directory${query ? `?${query}` : ""}`);
764
+ return ok(data);
765
+ }
766
+
767
+ // ── detect_platform (Phase 12 Wave 3 W3.1) ─────────────────────────────
768
+ case "detect_platform": {
769
+ const { url } = args as { url: string };
770
+ if (!url) return err("url is required");
771
+
772
+ const data = await opeddFetch("/detect-platform", {
773
+ method: "POST",
774
+ body: JSON.stringify({ url }),
775
+ });
776
+ return ok(data);
777
+ }
778
+
779
+ // ── rsl_get (Phase 12 Wave 1 W1.1) ─────────────────────────────────────
780
+ case "rsl_get": {
781
+ const { publisher_id, jsonld = false } = args as {
782
+ publisher_id: string;
783
+ jsonld?: boolean;
784
+ };
785
+ if (!publisher_id) return err("publisher_id is required");
786
+
787
+ const accept = jsonld ? "application/ld+json" : "application/json";
788
+ const data = await opeddFetch(
789
+ `/rsl-manifest?publisher_id=${encodeURIComponent(publisher_id)}`,
790
+ { headers: { Accept: accept } },
791
+ );
792
+ return ok(data);
793
+ }
794
+
795
+ // ── purchase_enterprise_license (Phase 10) ─────────────────────────────
796
+ case "purchase_enterprise_license": {
797
+ const {
798
+ publisher_ids,
799
+ buyer_email: pelEmail,
800
+ buyer_org,
801
+ billing_type = "annual",
802
+ license_tier = "rag",
803
+ duration_months = 12,
804
+ scope = "custom",
805
+ filter_rules,
806
+ buyer_webhook_url,
807
+ } = args as {
808
+ publisher_ids?: string[];
809
+ buyer_email?: string;
810
+ buyer_org?: string;
811
+ billing_type?: string;
812
+ license_tier?: string;
813
+ duration_months?: number;
814
+ scope?: string;
815
+ filter_rules?: Record<string, unknown>;
816
+ buyer_webhook_url?: string;
817
+ };
818
+
819
+ if (!Array.isArray(publisher_ids) || publisher_ids.length === 0) {
820
+ if (scope === "custom") {
821
+ return err("publisher_ids array is required for scope='custom'");
822
+ }
823
+ }
824
+ if (!pelEmail) return err("buyer_email is required");
825
+ if (!buyer_org) return err("buyer_org is required");
826
+
827
+ const body: Record<string, unknown> = {
828
+ publisher_ids: publisher_ids ?? [],
829
+ buyer_email: pelEmail,
830
+ buyer_org,
831
+ billing_type,
832
+ license_tier,
833
+ duration_months,
834
+ scope,
835
+ ...(filter_rules ? { filter_rules } : {}),
836
+ ...(buyer_webhook_url ? { buyer_webhook_url } : {}),
837
+ };
838
+
839
+ const data = await opeddFetch("/enterprise-license", {
840
+ method: "POST",
841
+ body: JSON.stringify(body),
842
+ });
843
+ return ok(data);
844
+ }
845
+
846
+ // ── list_feed (Phase 10 + 11) ──────────────────────────────────────────
847
+ case "list_feed": {
848
+ if (!ACCESS_KEY) {
849
+ return err("OPEDD_ACCESS_KEY env var is required for this tool (ent_* enterprise access key)");
850
+ }
851
+ const { since, cursor, limit = 50 } = args as {
852
+ since?: string;
853
+ cursor?: string;
854
+ limit?: number;
855
+ };
856
+ const params = new URLSearchParams({
857
+ access_key: ACCESS_KEY,
858
+ format: "json",
859
+ limit: String(Math.min(Number(limit) || 50, 200)),
860
+ });
861
+ if (since) params.set("since", since);
862
+ if (cursor) params.set("cursor", cursor);
863
+
864
+ const data = await opeddFetch(`/enterprise-license?${params.toString()}`);
865
+ return ok(data);
866
+ }
867
+
868
+ // ── stream_feed_ndjson (Phase 11 M3) ───────────────────────────────────
869
+ case "stream_feed_ndjson": {
870
+ if (!ACCESS_KEY) {
871
+ return err("OPEDD_ACCESS_KEY env var is required for this tool (ent_* enterprise access key)");
872
+ }
873
+ const { since, cursor, limit = 200 } = args as {
874
+ since?: string;
875
+ cursor?: string;
876
+ limit?: number;
877
+ };
878
+ const params = new URLSearchParams({
879
+ access_key: ACCESS_KEY,
880
+ format: "ndjson",
881
+ limit: String(Math.min(Number(limit) || 200, 1000)),
882
+ });
883
+ if (since) params.set("since", since);
884
+ if (cursor) params.set("cursor", cursor);
885
+
886
+ const data = await opeddFetchNdjson(`/enterprise-license?${params.toString()}`);
887
+ return ok(data);
888
+ }
889
+
890
+ // ── get_audit_events (Phase 9.x + 10 M5 attestation) ───────────────────
891
+ case "get_audit_events": {
892
+ if (!BUYER_JWT) {
893
+ return err("OPEDD_BUYER_JWT env var is required for this tool (Supabase session JWT)");
894
+ }
895
+ const { from, to, event_type, cursor, limit = 50 } = args as {
896
+ from?: string;
897
+ to?: string;
898
+ event_type?: string;
899
+ cursor?: string;
900
+ limit?: number;
901
+ };
902
+ const params = new URLSearchParams({
903
+ limit: String(Math.min(Number(limit) || 50, 200)),
904
+ });
905
+ if (from) params.set("from", from);
906
+ if (to) params.set("to", to);
907
+ if (event_type) params.set("event_type", event_type);
908
+ if (cursor) params.set("cursor", cursor);
909
+
910
+ const data = await opeddFetch(`/buyer-audit?${params.toString()}`, {
911
+ headers: { Authorization: `Bearer ${BUYER_JWT}` },
912
+ });
913
+ return ok(data);
914
+ }
915
+
916
+ // ── get_buyer_account (buyer profile + masked key list) ────────────────
917
+ case "get_buyer_account": {
918
+ if (!BUYER_JWT) {
919
+ return err("OPEDD_BUYER_JWT env var is required for this tool (Supabase session JWT)");
920
+ }
921
+ const data = await opeddFetch("/buyer-account", {
922
+ headers: { Authorization: `Bearer ${BUYER_JWT}` },
923
+ });
924
+ return ok(data);
925
+ }
926
+
927
+ // ── article_53_attestation (Phase 12 Wave 1 W1.4) ──────────────────────
928
+ case "article_53_attestation": {
929
+ if (!BUYER_JWT) {
930
+ return err("OPEDD_BUYER_JWT env var is required for this tool (Supabase session JWT)");
931
+ }
932
+ const { license_id, content_id, window_start, window_end } = args as {
933
+ license_id: string;
934
+ content_id?: string;
935
+ window_start?: string;
936
+ window_end?: string;
937
+ };
938
+ if (!license_id) return err("license_id is required");
939
+
940
+ const params = new URLSearchParams({ license_id });
941
+ if (content_id) params.set("content_id", content_id);
942
+ if (window_start) params.set("window_start", window_start);
943
+ if (window_end) params.set("window_end", window_end);
944
+
945
+ const data = await opeddFetch(
946
+ `/eu-ai-act/article-53-attestation?${params.toString()}`,
947
+ { headers: { Authorization: `Bearer ${BUYER_JWT}` } },
948
+ );
949
+ return ok(data);
950
+ }
951
+
952
+ // ── get_compliance_dossier (Phase 11 M4) ───────────────────────────────
953
+ case "get_compliance_dossier": {
954
+ if (!BUYER_JWT) {
955
+ return err("OPEDD_BUYER_JWT env var is required for this tool (Supabase session JWT)");
956
+ }
957
+ const { from, to, cursor } = args as {
958
+ from?: string;
959
+ to?: string;
960
+ cursor?: string;
961
+ };
962
+ if (!from) return err("from (ISO 8601 timestamp) is required");
963
+ if (!to) return err("to (ISO 8601 timestamp) is required");
964
+
965
+ const params = new URLSearchParams({
966
+ from,
967
+ to,
968
+ format: "json",
969
+ });
970
+ if (cursor) params.set("cursor", cursor);
971
+
972
+ const data = await opeddFetch(`/buyer-compliance-report?${params.toString()}`, {
973
+ headers: { Authorization: `Bearer ${BUYER_JWT}` },
974
+ });
975
+ return ok(data);
976
+ }
977
+
357
978
  // ── list_publisher_content ─────────────────────────────────────────────
358
979
  case "list_publisher_content": {
359
980
  if (!API_KEY) {
@@ -382,10 +1003,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
382
1003
  const msg = e instanceof Error ? e.message : String(e);
383
1004
  return err(`Request failed: ${msg}`);
384
1005
  }
385
- });
1006
+ }
386
1007
 
387
1008
  // ─── Start ────────────────────────────────────────────────────────────────────
388
1009
 
389
- const transport = new StdioServerTransport();
390
- await server.connect(transport);
391
- console.error("[opedd-mcp] Server running on stdio");
1010
+ // Skip stdio bootstrap when imported as a module (unit tests need TOOLS +
1011
+ // dispatchTool without the server.connect side-effect). Detect via
1012
+ // `import.meta.url === pathToFileURL(process.argv[1]).href` the canonical
1013
+ // "am I the entry point" check for ESM Node modules.
1014
+ if (
1015
+ typeof process !== "undefined" &&
1016
+ process.argv?.[1] &&
1017
+ import.meta.url === pathToFileURL(process.argv[1]).href
1018
+ ) {
1019
+ const transport = new StdioServerTransport();
1020
+ await server.connect(transport);
1021
+ console.error("[opedd-mcp] Server running on stdio");
1022
+ }