opedd-mcp 0.2.0 → 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/.github/workflows/ci.yml +19 -2
- package/README.md +36 -7
- package/dist/index.js +239 -17
- package/package.json +9 -6
- package/src/index.ts +272 -17
- package/tests/dispatcher.env-gated.test.ts +278 -0
- package/tests/dispatcher.test.ts +419 -0
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,8 +18,8 @@ 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 (opedd_buyer_live_... or
|
|
21
|
-
const ACCESS_KEY = process.env.OPEDD_ACCESS_KEY; // enterprise access key (
|
|
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
|
|
22
23
|
const BUYER_JWT = process.env.OPEDD_BUYER_JWT; // Supabase JWT; for /buyer-audit + /buyer-compliance-report
|
|
23
24
|
|
|
24
25
|
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
@@ -94,7 +95,7 @@ function err(msg: string): ToolResult {
|
|
|
94
95
|
|
|
95
96
|
// ─── Tool definitions ─────────────────────────────────────────────────────────
|
|
96
97
|
|
|
97
|
-
const TOOLS: Tool[] = [
|
|
98
|
+
export const TOOLS: Tool[] = [
|
|
98
99
|
{
|
|
99
100
|
name: "lookup_content",
|
|
100
101
|
description:
|
|
@@ -207,13 +208,106 @@ const TOOLS: Tool[] = [
|
|
|
207
208
|
},
|
|
208
209
|
},
|
|
209
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
|
+
},
|
|
210
304
|
// ─── Phase 10 + 11 buyer-side surfaces (M6.4) ─────────────────────────────
|
|
211
305
|
{
|
|
212
306
|
name: "purchase_enterprise_license",
|
|
213
307
|
description:
|
|
214
308
|
"Purchase a bulk enterprise license covering multiple publishers (Phase 10). " +
|
|
215
309
|
"Returns a Stripe client_secret for payment completion + the enterprise_license_id. " +
|
|
216
|
-
"After payment, an
|
|
310
|
+
"After payment, an ent_* access key is emailed to buyer_email. " +
|
|
217
311
|
"Scopes: 'custom' (pass-through publisher_ids), 'platform_wide' (auto-resolve all opted-in publishers), 'filtered' (Phase 10 filter_rules). " +
|
|
218
312
|
"License tiers: 'rag' (= ai_retrieval), 'training' (= ai_training, flat-fee not metered), 'inference' (= ai_retrieval), 'full_ai' (writes both retrieval + training records).",
|
|
219
313
|
inputSchema: {
|
|
@@ -270,7 +364,7 @@ if (BUYER_TOKEN) {
|
|
|
270
364
|
TOOLS.push({
|
|
271
365
|
name: "get_content",
|
|
272
366
|
description:
|
|
273
|
-
"Retrieve the full body of a licensed article using a buyer API token (opedd_buyer_live_* canonical
|
|
367
|
+
"Retrieve the full body of a licensed article using a buyer API token (opedd_buyer_live_* canonical; opedd_buyer_test_* for sandbox). " +
|
|
274
368
|
"Requires OPEDD_BUYER_TOKEN env var (create one at opedd.com/licenses after purchasing). " +
|
|
275
369
|
"Works for per-article licenses (token scoped to that article) and archive licenses (token covers all publisher content). " +
|
|
276
370
|
"The publisher must have content delivery enabled and must have pushed content for the article. " +
|
|
@@ -287,7 +381,7 @@ if (BUYER_TOKEN) {
|
|
|
287
381
|
},
|
|
288
382
|
buyer_token: {
|
|
289
383
|
type: "string",
|
|
290
|
-
description: "Buyer API token (opedd_buyer_live_* or
|
|
384
|
+
description: "Buyer API token (opedd_buyer_live_* or opedd_buyer_test_*). Falls back to OPEDD_BUYER_TOKEN env var.",
|
|
291
385
|
},
|
|
292
386
|
},
|
|
293
387
|
},
|
|
@@ -303,7 +397,7 @@ if (ACCESS_KEY) {
|
|
|
303
397
|
"Returns JSON-format response with paginated articles. " +
|
|
304
398
|
"Use `since` (ISO 8601) for delta-feed polling — only articles published after the timestamp. " +
|
|
305
399
|
"Use `cursor` for pagination across pages. " +
|
|
306
|
-
"Requires OPEDD_ACCESS_KEY (
|
|
400
|
+
"Requires OPEDD_ACCESS_KEY (ent_* enterprise access key). " +
|
|
307
401
|
"For larger bulk corpus pulls, use stream_feed_ndjson (up to 1000 articles per call vs 200 here).",
|
|
308
402
|
inputSchema: {
|
|
309
403
|
type: "object",
|
|
@@ -332,7 +426,7 @@ if (ACCESS_KEY) {
|
|
|
332
426
|
"Use `since` (ISO 8601) for delta-feed. Use `cursor` to paginate beyond 1000. " +
|
|
333
427
|
"Backend supports 5000 articles per call; the MCP cap is 1000 for transport reasonability. " +
|
|
334
428
|
"Real bulk-ingest pipelines should use the Python SDK (pip install opedd) directly — not via MCP. " +
|
|
335
|
-
"Requires OPEDD_ACCESS_KEY (
|
|
429
|
+
"Requires OPEDD_ACCESS_KEY (ent_*).",
|
|
336
430
|
inputSchema: {
|
|
337
431
|
type: "object",
|
|
338
432
|
properties: {
|
|
@@ -391,6 +485,59 @@ if (BUYER_JWT) {
|
|
|
391
485
|
},
|
|
392
486
|
},
|
|
393
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
|
+
});
|
|
394
541
|
TOOLS.push({
|
|
395
542
|
name: "get_compliance_dossier",
|
|
396
543
|
description:
|
|
@@ -454,15 +601,28 @@ if (API_KEY) {
|
|
|
454
601
|
// ─── MCP Server ───────────────────────────────────────────────────────────────
|
|
455
602
|
|
|
456
603
|
const server = new Server(
|
|
457
|
-
{ name: "opedd-mcp", version: "0.
|
|
604
|
+
{ name: "opedd-mcp", version: "0.3.0" },
|
|
458
605
|
{ capabilities: { tools: {} } }
|
|
459
606
|
);
|
|
460
607
|
|
|
461
608
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
462
609
|
|
|
463
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) =>
|
|
464
|
-
|
|
610
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) =>
|
|
611
|
+
dispatchTool(request.params.name, (request.params.arguments ?? {}) as Record<string, unknown>),
|
|
612
|
+
);
|
|
465
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> {
|
|
466
626
|
try {
|
|
467
627
|
switch (name) {
|
|
468
628
|
// ── lookup_content ─────────────────────────────────────────────────────
|
|
@@ -583,6 +743,55 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
583
743
|
return ok(data);
|
|
584
744
|
}
|
|
585
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
|
+
|
|
586
795
|
// ── purchase_enterprise_license (Phase 10) ─────────────────────────────
|
|
587
796
|
case "purchase_enterprise_license": {
|
|
588
797
|
const {
|
|
@@ -637,7 +846,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
637
846
|
// ── list_feed (Phase 10 + 11) ──────────────────────────────────────────
|
|
638
847
|
case "list_feed": {
|
|
639
848
|
if (!ACCESS_KEY) {
|
|
640
|
-
return err("OPEDD_ACCESS_KEY env var is required for this tool (
|
|
849
|
+
return err("OPEDD_ACCESS_KEY env var is required for this tool (ent_* enterprise access key)");
|
|
641
850
|
}
|
|
642
851
|
const { since, cursor, limit = 50 } = args as {
|
|
643
852
|
since?: string;
|
|
@@ -659,7 +868,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
659
868
|
// ── stream_feed_ndjson (Phase 11 M3) ───────────────────────────────────
|
|
660
869
|
case "stream_feed_ndjson": {
|
|
661
870
|
if (!ACCESS_KEY) {
|
|
662
|
-
return err("OPEDD_ACCESS_KEY env var is required for this tool (
|
|
871
|
+
return err("OPEDD_ACCESS_KEY env var is required for this tool (ent_* enterprise access key)");
|
|
663
872
|
}
|
|
664
873
|
const { since, cursor, limit = 200 } = args as {
|
|
665
874
|
since?: string;
|
|
@@ -704,6 +913,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
704
913
|
return ok(data);
|
|
705
914
|
}
|
|
706
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
|
+
|
|
707
952
|
// ── get_compliance_dossier (Phase 11 M4) ───────────────────────────────
|
|
708
953
|
case "get_compliance_dossier": {
|
|
709
954
|
if (!BUYER_JWT) {
|
|
@@ -758,10 +1003,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
758
1003
|
const msg = e instanceof Error ? e.message : String(e);
|
|
759
1004
|
return err(`Request failed: ${msg}`);
|
|
760
1005
|
}
|
|
761
|
-
}
|
|
1006
|
+
}
|
|
762
1007
|
|
|
763
1008
|
// ─── Start ────────────────────────────────────────────────────────────────────
|
|
764
1009
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Env-gated tool tests — covers the 7 tools that register conditionally
|
|
2
|
+
// based on env-var presence:
|
|
3
|
+
//
|
|
4
|
+
// article_53_attestation, get_buyer_account, get_audit_events,
|
|
5
|
+
// get_compliance_dossier (require OPEDD_BUYER_JWT)
|
|
6
|
+
// list_feed, stream_feed_ndjson (require OPEDD_ACCESS_KEY)
|
|
7
|
+
// list_publisher_content (requires OPEDD_API_KEY)
|
|
8
|
+
//
|
|
9
|
+
// Separate file from dispatcher.test.ts because the TOOLS array push +
|
|
10
|
+
// CallTool switch cases evaluate env-var presence at module-import time;
|
|
11
|
+
// mixing env-set and env-unset tests in the same file requires vitest
|
|
12
|
+
// resetModules() round-trips that compound state-leak risk. One file =
|
|
13
|
+
// one env-vector for cleaner test isolation.
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
16
|
+
|
|
17
|
+
const TEST_BUYER_TOKEN = "opedd_buyer_test_env_gated_xxx";
|
|
18
|
+
const TEST_BUYER_JWT = "eyJhbGc.env-gated-test.sig";
|
|
19
|
+
const TEST_ACCESS_KEY = "ent_env_gated_test";
|
|
20
|
+
const TEST_API_KEY = "op_env_gated_test_pub";
|
|
21
|
+
const TEST_BUYER_EMAIL = "env-gated@opedd-test.com";
|
|
22
|
+
|
|
23
|
+
// Stub ALL env vars before any import — guarantees every conditional
|
|
24
|
+
// TOOLS push registers + every env-gated switch case is callable.
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
vi.stubEnv("OPEDD_BUYER_TOKEN", TEST_BUYER_TOKEN);
|
|
27
|
+
vi.stubEnv("OPEDD_BUYER_JWT", TEST_BUYER_JWT);
|
|
28
|
+
vi.stubEnv("OPEDD_ACCESS_KEY", TEST_ACCESS_KEY);
|
|
29
|
+
vi.stubEnv("OPEDD_API_KEY", TEST_API_KEY);
|
|
30
|
+
vi.stubEnv("OPEDD_BUYER_EMAIL", TEST_BUYER_EMAIL);
|
|
31
|
+
vi.stubEnv("OPEDD_API_URL", "https://api.opedd.com");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
async function loadDispatcher() {
|
|
39
|
+
vi.resetModules();
|
|
40
|
+
return await import("../src/index.ts");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mockFetchOk(body: unknown, status = 200) {
|
|
44
|
+
const fn = vi.fn(async () =>
|
|
45
|
+
new Response(JSON.stringify(body), {
|
|
46
|
+
status,
|
|
47
|
+
headers: { "content-type": "application/json" },
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
globalThis.fetch = fn as unknown as typeof fetch;
|
|
51
|
+
return fn;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mockFetchNdjson(lines: unknown[]): ReturnType<typeof vi.fn> {
|
|
55
|
+
const body = lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
|
|
56
|
+
const fn = vi.fn(async () =>
|
|
57
|
+
new Response(body, {
|
|
58
|
+
status: 200,
|
|
59
|
+
headers: { "content-type": "application/x-ndjson" },
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
globalThis.fetch = fn as unknown as typeof fetch;
|
|
63
|
+
return fn;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ───────────────────────────── all env-gated tools register ─────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe("TOOLS array with all env vars set", () => {
|
|
69
|
+
it("exposes the 7 env-gated tools alongside the 8 always-available", async () => {
|
|
70
|
+
const { TOOLS } = await loadDispatcher();
|
|
71
|
+
const names = TOOLS.map((t) => t.name);
|
|
72
|
+
// BUYER_TOKEN-gated
|
|
73
|
+
expect(names).toContain("get_content");
|
|
74
|
+
// BUYER_JWT-gated
|
|
75
|
+
expect(names).toContain("get_buyer_account");
|
|
76
|
+
expect(names).toContain("article_53_attestation");
|
|
77
|
+
expect(names).toContain("get_audit_events");
|
|
78
|
+
expect(names).toContain("get_compliance_dossier");
|
|
79
|
+
// ACCESS_KEY-gated
|
|
80
|
+
expect(names).toContain("list_feed");
|
|
81
|
+
expect(names).toContain("stream_feed_ndjson");
|
|
82
|
+
// API_KEY-gated
|
|
83
|
+
expect(names).toContain("list_publisher_content");
|
|
84
|
+
expect(names.length).toBe(16);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ───────────────────────────── BUYER_TOKEN-gated tool ─────────────────────────────
|
|
89
|
+
|
|
90
|
+
describe("dispatchTool: get_content (BUYER_TOKEN gated)", () => {
|
|
91
|
+
it("sends GET /content-delivery with bearer auth", async () => {
|
|
92
|
+
const f = mockFetchOk({ success: true, data: { id: "art-1", content: "..." } });
|
|
93
|
+
const { dispatchTool } = await loadDispatcher();
|
|
94
|
+
await dispatchTool("get_content", { article_id: "art-1" });
|
|
95
|
+
const call = f.mock.calls[0];
|
|
96
|
+
expect(String(call[0])).toContain("/content-delivery?article_id=art-1");
|
|
97
|
+
const headers = (call[1] as RequestInit).headers as Record<string, string>;
|
|
98
|
+
expect(headers["Authorization"]).toBe(`Bearer ${TEST_BUYER_TOKEN}`);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ───────────────────────────── BUYER_JWT-gated tools ─────────────────────────────
|
|
103
|
+
|
|
104
|
+
describe("dispatchTool: get_buyer_account (BUYER_JWT gated, chip 13)", () => {
|
|
105
|
+
it("sends GET /buyer-account with JWT auth", async () => {
|
|
106
|
+
const f = mockFetchOk({ success: true, data: { buyer: { contact_email: "x" } } });
|
|
107
|
+
const { dispatchTool } = await loadDispatcher();
|
|
108
|
+
await dispatchTool("get_buyer_account", {});
|
|
109
|
+
const call = f.mock.calls[0];
|
|
110
|
+
expect(String(call[0])).toContain("/buyer-account");
|
|
111
|
+
const headers = (call[1] as RequestInit).headers as Record<string, string>;
|
|
112
|
+
expect(headers["Authorization"]).toBe(`Bearer ${TEST_BUYER_JWT}`);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("dispatchTool: article_53_attestation (BUYER_JWT gated, chip 2)", () => {
|
|
117
|
+
it("happy path — license_id only", async () => {
|
|
118
|
+
const f = mockFetchOk({
|
|
119
|
+
success: true,
|
|
120
|
+
data: { jwt: "eyJ.test.sig", claims: { eu_ai_act_article: 53, aud: "eu-ai-act-article-53" } },
|
|
121
|
+
});
|
|
122
|
+
const { dispatchTool } = await loadDispatcher();
|
|
123
|
+
await dispatchTool("article_53_attestation", {
|
|
124
|
+
license_id: "11111111-2222-3333-4444-555555555555",
|
|
125
|
+
});
|
|
126
|
+
const call = f.mock.calls[0];
|
|
127
|
+
expect(String(call[0])).toContain(
|
|
128
|
+
"/eu-ai-act/article-53-attestation?license_id=11111111-2222-3333-4444-555555555555",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("with all optional params (content_id + window_start + window_end)", async () => {
|
|
133
|
+
const f = mockFetchOk({ success: true, data: {} });
|
|
134
|
+
const { dispatchTool } = await loadDispatcher();
|
|
135
|
+
await dispatchTool("article_53_attestation", {
|
|
136
|
+
license_id: "11111111-2222-3333-4444-555555555555",
|
|
137
|
+
content_id: "22222222-3333-4444-5555-666666666666",
|
|
138
|
+
window_start: "2026-02-22T00:00:00Z",
|
|
139
|
+
window_end: "2026-05-22T00:00:00Z",
|
|
140
|
+
});
|
|
141
|
+
const call = String(f.mock.calls[0][0]);
|
|
142
|
+
expect(call).toContain("content_id=22222222");
|
|
143
|
+
expect(call).toContain("window_start=2026-02-22T00%3A00%3A00Z");
|
|
144
|
+
expect(call).toContain("window_end=2026-05-22T00%3A00%3A00Z");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("rejects missing license_id", async () => {
|
|
148
|
+
mockFetchOk({});
|
|
149
|
+
const { dispatchTool } = await loadDispatcher();
|
|
150
|
+
const result = await dispatchTool("article_53_attestation", {});
|
|
151
|
+
expect(result.isError).toBe(true);
|
|
152
|
+
expect(result.content[0].text).toContain("license_id is required");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("dispatchTool: get_audit_events (BUYER_JWT gated)", () => {
|
|
157
|
+
it("happy path with all filters", async () => {
|
|
158
|
+
const f = mockFetchOk({ success: true, events: [] });
|
|
159
|
+
const { dispatchTool } = await loadDispatcher();
|
|
160
|
+
await dispatchTool("get_audit_events", {
|
|
161
|
+
from: "2026-05-01",
|
|
162
|
+
to: "2026-05-15",
|
|
163
|
+
event_type: "content_access",
|
|
164
|
+
limit: 50,
|
|
165
|
+
});
|
|
166
|
+
const call = String(f.mock.calls[0][0]);
|
|
167
|
+
expect(call).toContain("from=2026-05-01");
|
|
168
|
+
expect(call).toContain("event_type=content_access");
|
|
169
|
+
expect(call).toContain("limit=50");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("limit capped at 200", async () => {
|
|
173
|
+
const f = mockFetchOk({ success: true, events: [] });
|
|
174
|
+
const { dispatchTool } = await loadDispatcher();
|
|
175
|
+
await dispatchTool("get_audit_events", { limit: 999 });
|
|
176
|
+
const call = String(f.mock.calls[0][0]);
|
|
177
|
+
expect(call).toContain("limit=200");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("dispatchTool: get_compliance_dossier (BUYER_JWT gated)", () => {
|
|
182
|
+
it("happy path — from + to required", async () => {
|
|
183
|
+
const f = mockFetchOk({
|
|
184
|
+
success: true,
|
|
185
|
+
dossier_metadata: { summary: { total_retrievals: 100 } },
|
|
186
|
+
});
|
|
187
|
+
const { dispatchTool } = await loadDispatcher();
|
|
188
|
+
await dispatchTool("get_compliance_dossier", {
|
|
189
|
+
from: "2026-04-01",
|
|
190
|
+
to: "2026-04-30",
|
|
191
|
+
});
|
|
192
|
+
const call = String(f.mock.calls[0][0]);
|
|
193
|
+
expect(call).toContain("from=2026-04-01");
|
|
194
|
+
expect(call).toContain("to=2026-04-30");
|
|
195
|
+
expect(call).toContain("format=json");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("rejects missing from", async () => {
|
|
199
|
+
mockFetchOk({});
|
|
200
|
+
const { dispatchTool } = await loadDispatcher();
|
|
201
|
+
const result = await dispatchTool("get_compliance_dossier", { to: "2026-04-30" });
|
|
202
|
+
expect(result.isError).toBe(true);
|
|
203
|
+
expect(result.content[0].text).toContain("from");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ───────────────────────────── ACCESS_KEY-gated tools ─────────────────────────────
|
|
208
|
+
|
|
209
|
+
describe("dispatchTool: list_feed (ACCESS_KEY gated)", () => {
|
|
210
|
+
it("happy path — sends access_key + format=json", async () => {
|
|
211
|
+
const f = mockFetchOk({ success: true, data: { articles: [] } });
|
|
212
|
+
const { dispatchTool } = await loadDispatcher();
|
|
213
|
+
await dispatchTool("list_feed", { limit: 50 });
|
|
214
|
+
const call = String(f.mock.calls[0][0]);
|
|
215
|
+
expect(call).toContain("access_key=" + TEST_ACCESS_KEY);
|
|
216
|
+
expect(call).toContain("format=json");
|
|
217
|
+
expect(call).toContain("limit=50");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("limit capped at 200", async () => {
|
|
221
|
+
const f = mockFetchOk({ success: true, data: {} });
|
|
222
|
+
const { dispatchTool } = await loadDispatcher();
|
|
223
|
+
await dispatchTool("list_feed", { limit: 5000 });
|
|
224
|
+
const call = String(f.mock.calls[0][0]);
|
|
225
|
+
expect(call).toContain("limit=200");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("dispatchTool: stream_feed_ndjson (ACCESS_KEY gated)", () => {
|
|
230
|
+
it("parses NDJSON lines + extracts _meta", async () => {
|
|
231
|
+
const f = mockFetchNdjson([
|
|
232
|
+
{ id: "art-1", title: "A" },
|
|
233
|
+
{ id: "art-2", title: "B" },
|
|
234
|
+
{ _meta: { count: 2, truncated: false } },
|
|
235
|
+
]);
|
|
236
|
+
const { dispatchTool } = await loadDispatcher();
|
|
237
|
+
const result = await dispatchTool("stream_feed_ndjson", {});
|
|
238
|
+
const call = String(f.mock.calls[0][0]);
|
|
239
|
+
expect(call).toContain("format=ndjson");
|
|
240
|
+
const payload = JSON.parse(result.content[0].text) as {
|
|
241
|
+
articles: Array<Record<string, unknown>>;
|
|
242
|
+
meta: Record<string, unknown>;
|
|
243
|
+
};
|
|
244
|
+
expect(payload.articles.length).toBe(2);
|
|
245
|
+
expect(payload.articles[0].id).toBe("art-1");
|
|
246
|
+
expect(payload.meta.count).toBe(2);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("limit capped at 1000", async () => {
|
|
250
|
+
const f = mockFetchNdjson([{ _meta: {} }]);
|
|
251
|
+
const { dispatchTool } = await loadDispatcher();
|
|
252
|
+
await dispatchTool("stream_feed_ndjson", { limit: 99999 });
|
|
253
|
+
const call = String(f.mock.calls[0][0]);
|
|
254
|
+
expect(call).toContain("limit=1000");
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ───────────────────────────── API_KEY-gated tool ─────────────────────────────
|
|
259
|
+
|
|
260
|
+
describe("dispatchTool: list_publisher_content (API_KEY gated)", () => {
|
|
261
|
+
it("happy path — sends /api?action=articles + X-API-Key header", async () => {
|
|
262
|
+
const f = mockFetchOk({ success: true, data: { articles: [] } });
|
|
263
|
+
const { dispatchTool } = await loadDispatcher();
|
|
264
|
+
await dispatchTool("list_publisher_content", { limit: 20 });
|
|
265
|
+
const call = f.mock.calls[0];
|
|
266
|
+
expect(String(call[0])).toContain("/api?action=articles");
|
|
267
|
+
const headers = (call[1] as RequestInit).headers as Record<string, string>;
|
|
268
|
+
expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("limit capped at 100", async () => {
|
|
272
|
+
const f = mockFetchOk({ success: true, data: {} });
|
|
273
|
+
const { dispatchTool } = await loadDispatcher();
|
|
274
|
+
await dispatchTool("list_publisher_content", { limit: 999 });
|
|
275
|
+
const call = String(f.mock.calls[0][0]);
|
|
276
|
+
expect(call).toContain("limit=100");
|
|
277
|
+
});
|
|
278
|
+
});
|