diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index a7ee9824..9567f4fa 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -39,6 +39,8 @@
* [Launch an IPO](build-a-dkg-node-ai-agent/advanced-features-and-toolkits/dkg-paranets/initial-paranet-offerings-ipos/launching-your-ipo.md)
* [Incentives pool](build-a-dkg-node-ai-agent/advanced-features-and-toolkits/dkg-paranets/initial-paranet-offerings-ipos/paranets-incentives-pool.md)
* [IPO voting](build-a-dkg-node-ai-agent/advanced-features-and-toolkits/dkg-paranets/initial-paranet-offerings-ipos/ipo-voting.md)
+* [Plugins](build-a-dkg-node-ai-agent/plugins/README.md)
+ * [EPCIS Plugin](build-a-dkg-node-ai-agent/plugins/epcis-plugin.md)
* [Contributing a plugin](build-a-dkg-node-ai-agent/contributing-a-plugin.md)
## Contribute to the DKG
diff --git a/docs/build-a-dkg-node-ai-agent/plugins/README.md b/docs/build-a-dkg-node-ai-agent/plugins/README.md
new file mode 100644
index 00000000..b2c0f26c
--- /dev/null
+++ b/docs/build-a-dkg-node-ai-agent/plugins/README.md
@@ -0,0 +1,14 @@
+# Plugins
+
+This section documents plugins that extend DKG Node functionality.
+
+Plugins can expose:
+
+- API endpoints
+- MCP tools
+- Integration-specific behavior and configuration
+
+## Available Plugins
+
+- [EPCIS Plugin](epcis-plugin.md)
+
diff --git a/docs/build-a-dkg-node-ai-agent/plugins/epcis-plugin.md b/docs/build-a-dkg-node-ai-agent/plugins/epcis-plugin.md
new file mode 100644
index 00000000..d2b56270
--- /dev/null
+++ b/docs/build-a-dkg-node-ai-agent/plugins/epcis-plugin.md
@@ -0,0 +1,155 @@
+# EPCIS Plugin
+
+The EPCIS plugin integrates EPCIS 2.0 supply-chain event data with the DKG Node.
+
+It provides both HTTP endpoints and MCP tools for:
+
+- capturing EPCIS documents
+- checking capture status
+- querying events with filters
+- retrieving published assets by UAL
+
+## Source
+
+- Plugin code: `packages/plugin-epcis/src/index.ts`
+- Query service: `packages/plugin-epcis/src/services/epcisQueryService.ts`
+- Integration guide: `packages/plugin-epcis/docs/EPCIS-Integration-Guide.md`
+
+## Quick Start
+
+1. Ensure publisher plugin and epcis plugin is enabled in server plugin registration:
+ - `apps/agent/src/server/index.ts` should include `dkgPublisherPlugin` in the `plugins` array.
+ - `apps/agent/src/server/index.ts` should include `epcisPlugin` in the `plugins` array.
+2. Run publisher plugin setup:
+ - `cd packages/plugin-dkg-publisher && npm run setup`
+ - This initializes publisher configuration (including `.env.publisher`) for the publisher flow.
+3. Configure runtime environment:
+ - `EXPO_PUBLIC_MCP_URL=http://localhost:9200` (local same-host setup)
+4. Start the DKG Node server.
+5. Submit an EPCIS document via `POST /epcis/capture`.
+6. Query captured events via `GET /epcis/events`.
+
+## Capabilities
+
+### API Endpoints
+
+- `POST /epcis/capture`
+ Accepts an EPCIS document and sends it to publisher flow.
+ Returns a numeric `captureID` on success.
+
+- `GET /epcis/capture/:captureID`
+ Gets publisher-tracked status for numeric capture IDs.
+
+- `GET /epcis/events`
+ Queries EPCIS events with filtering and pagination.
+
+- `GET /epcis/asset/*ual`
+ Retrieves an EPCIS asset by UAL.
+
+### MCP Tools
+
+- `epcis-query`
+- `epcis-track-item`
+
+## Configuration
+
+Required runtime env var:
+
+- `EXPO_PUBLIC_MCP_URL` (example local setup: `http://localhost:9200`)
+
+Runtime dependency:
+
+- Publisher API must be available through the same server URL (or routed URL) used by `EXPO_PUBLIC_MCP_URL`.
+
+If `EXPO_PUBLIC_MCP_URL` is not set, capture and status calls that depend on publisher will fail.
+
+## Example Requests
+
+### Capture EPCIS document
+
+```bash
+curl -X POST "http://localhost:9200/epcis/capture" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "epcisDocument": {
+ "@context": {
+ "@vocab": "https://gs1.github.io/EPCIS/",
+ "epcis": "https://gs1.github.io/EPCIS/",
+ "cbv": "https://ref.gs1.org/cbv/",
+ "type": "@type",
+ "id": "@id"
+ },
+ "type": "EPCISDocument",
+ "schemaVersion": "2.0",
+ "creationDate": "2024-03-01T08:00:00Z",
+ "epcisBody": {
+ "eventList": [
+ {
+ "type": "ObjectEvent",
+ "eventTime": "2024-03-01T08:00:00.000Z",
+ "eventTimeZoneOffset": "+00:00",
+ "epcList": ["urn:epc:id:sgtin:4012345.011111.1001"],
+ "action": "ADD",
+ "bizStep": "https://ref.gs1.org/cbv/BizStep-receiving",
+ "disposition": "https://ref.gs1.org/cbv/Disp-in_progress",
+ "readPoint": { "id": "urn:epc:id:sgln:4012345.00001.0" },
+ "bizLocation": { "id": "urn:epc:id:sgln:4012345.00001.0" },
+ "bizTransactionList": [
+ {
+ "type": "https://ref.gs1.org/cbv/BTT-po",
+ "bizTransaction": "urn:epc:id:gdti:4012345.00001.PO-2024-001"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "publishOptions": {
+ "privacy": "private",
+ "epochs": 12
+ }
+ }'
+```
+
+### Check capture status
+
+```bash
+curl "http://localhost:9200/epcis/capture/123"
+```
+
+### Query events with filters
+
+```bash
+curl "http://localhost:9200/epcis/events?epc=urn:epc:id:sgtin:4012345.011111.1001&fullTrace=true&limit=50&offset=0"
+```
+
+## Query Notes
+
+- `fullTrace` (HTTP query) supports: `"true"` or `"false"`
+- `limit`: integer `1..1000`
+- `offset`: integer `>= 0`
+- `bizStep` accepts shorthand (for example `assembling`) or full URI
+
+## Response and Validation Notes
+
+- `POST /epcis/capture` validates the EPCIS document structure before publishing.
+- `GET /epcis/capture/:captureID` expects numeric capture IDs from publisher responses.
+- `GET /epcis/events` rejects invalid pagination and empty-string filter parameters.
+
+## Troubleshooting
+
+- `Publisher endpoint not configured. Set EXPO_PUBLIC_MCP_URL in .env`
+ Set `EXPO_PUBLIC_MCP_URL` in runtime environment.
+
+- `Invalid captureID format`
+ Use numeric capture IDs returned by `POST /epcis/capture`.
+
+- `Parameter 'limit' must be an integer between 1 and 1000`
+ Ensure pagination values are valid integers.
+
+## Related Documentation
+
+For full EPCIS field-level details and examples, see:
+
+- `packages/plugin-epcis/docs/EPCIS-Integration-Guide.md`
+
diff --git a/package-lock.json b/package-lock.json
index 150c7fe3..374fbc59 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28364,6 +28364,7 @@
"devDependencies": {
"@dkg/eslint-config": "*",
"@dkg/typescript-config": "*",
+ "supertest": "^7.2.2",
"tsup": "^8.5.0"
}
},
@@ -28383,12 +28384,81 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "packages/plugin-epcis/node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"packages/plugin-epcis/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
+ "packages/plugin-epcis/node_modules/qs": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
+ "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "packages/plugin-epcis/node_modules/superagent": {
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
+ "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "component-emitter": "^1.3.1",
+ "cookiejar": "^2.1.4",
+ "debug": "^4.3.7",
+ "fast-safe-stringify": "^2.1.1",
+ "form-data": "^4.0.5",
+ "formidable": "^3.5.4",
+ "methods": "^1.1.2",
+ "mime": "2.6.0",
+ "qs": "^6.14.1"
+ },
+ "engines": {
+ "node": ">=14.18.0"
+ }
+ },
+ "packages/plugin-epcis/node_modules/supertest": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
+ "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cookie-signature": "^1.2.2",
+ "methods": "^1.1.2",
+ "superagent": "^10.3.0"
+ },
+ "engines": {
+ "node": ">=14.18.0"
+ }
+ },
"packages/plugin-example": {
"name": "@dkg/plugin-example",
"version": "0.0.3",
diff --git a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md
index 63aef1c5..dc52d042 100644
--- a/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md
+++ b/packages/plugin-epcis/docs/EPCIS-Integration-Guide.md
@@ -1,14 +1,26 @@
# π EPCIS-DKG Integration Guide
+This document explains all fields used in EPCIS 2.0 documents and provides comprehensive reference for integrating with the OriginTrail Decentralized Knowledge Graph (DKG).
+
## Table of Contents
1. [Overview & Architecture](#1-overview--architecture)
-2. [Quick Start](#2-quick-start)
-3. [EPCIS Event Types Explained](#3-epcis-event-types-explained)
-4. [API Reference](#4-api-reference)
-5. [Data Flow & DKG Publishing](#5-data-flow--dkg-publishing)
-6. [Query Examples](#6-query-examples)
-7. [Troubleshooting](#7-troubleshooting)
+2. [Document Structure](#2-document-structure)
+3. [JSON-LD Context](#3-json-ld-context)
+4. [Event Types](#4-event-types)
+5. [Event Fields Reference](#5-event-fields-reference)
+6. [Business Step (bizStep)](#6-business-step-bizstep)
+7. [Disposition](#7-disposition)
+8. [Business Transaction Types](#8-business-transaction-types)
+9. [GS1 URN Schemes](#9-gs1-urn-schemes)
+10. [API Reference](#10-api-reference)
+11. [MCP Tools Reference](#11-mcp-tools-reference)
+ - [Source Knowledge Assets](#source-knowledge-assets)
+ - [Event Result Structure](#event-result-structure)
+12. [Query Examples](#12-query-examples)
+13. [Data Flow & DKG Publishing](#13-data-flow--dkg-publishing)
+14. [Sample EPCIS Documents](#14-sample-epcis-documents)
+15. [Troubleshooting](#15-troubleshooting)
---
@@ -24,318 +36,1082 @@ This integration bridges **GS1 EPCIS 2.0** (Electronic Product Code Information
### Why Use DKG for EPCIS?
-| Traditional EPCIS | EPCIS + DKG |
-|-------------------|-------------|
-| Centralized database | Decentralized, permissionless network |
-| Single point of failure | Replicated across multiple nodes |
-| Trust the provider | Cryptographically verifiable |
-| Siloed data | Interlinked Knowledge Graph |
-| Company-controlled | Owned via blockchain (UAL) |
+| Traditional EPCIS | EPCIS + DKG |
+| ----------------------- | ------------------------------------- |
+| Centralized database | Decentralized, permissionless network |
+| Single point of failure | Replicated across multiple nodes |
+| Trust the provider | Cryptographically verifiable |
+| Siloed data | Interlinked Knowledge Graph |
+| Company-controlled | Owned via blockchain (UAL) |
### Architecture Overview
```
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β Your Application β
-βββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ
- β HTTP POST /epcis/capture
- βΌ
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β EPCIS Plugin β
-β βββββββββββββββββββ ββββββββββββββββββββ β
-β β Validation βββββΆβ JSON-LD Transform β β
-β β (GS1 Schema) β β (EPCIS Context) β β
-β βββββββββββββββββββ ββββββββββ¬ββββββββββ β
-ββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββ
- β
- βΌ
+β Your Application / AI Agent (MCP) β
+ββββββββββββββββ¬βββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββ
+ β HTTP API β MCP Tools
+ β POST /epcis/capture β epcis-query
+ β GET /epcis/capture/:captureID β epcis-track-item
+ β GET /epcis/events β epcis-capture
+ β GET /epcis/events/track β epcis-capture-status
+ βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β DKG Publisher Plugin β
-β βββββββββββββββ βββββββββββββββ ββββββββββββββββββββ β
-β β Asset Queue βββββΆβ BullMQ βββββΆβ DKG Network β β
-β β (MySQL) β β Workers β β (via dkg.js) β β
-β βββββββββββββββ βββββββββββββββ ββββββββββ¬ββββββββββ β
-βββββββββββββββββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββ
- β
- βΌ
+β EPCIS Plugin β
+β βββββββββββββββββββ ββββββββββββββββ ββββββββββββββββββββ β
+β β Validation β β Query Serviceβ β Publisher Serviceβ β
+β β (GS1 Schema) β β (SPARQL) β β (HTTP β DKG) β β
+β ββββββββββ¬βββββββββ ββββββββ¬ββββββββ ββββββββββ¬ββββββββββ β
+βββββββββββββΌββββββββββββββββββββββΌββββββββββββββββββββββΌββββββββββββββ
+ β β β
+ β β SPARQL SELECT β HTTP POST
+ β βΌ βΌ
+ β βββββββββββββββ ββββββββββββββββββββ
+ β β DKG Graph β β DKG Publisher β
+ β β (dkg.js) β β (/api/dkg/assets)β
+ β ββββββββ¬βββββββ ββββββββββ¬ββββββββββ
+ β β β
+ β βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β OriginTrail Decentralized Knowledge Graph β
-β β
+β OriginTrail Decentralized Knowledge Graph β
+β β
β Knowledge Asset (UAL: did:dkg:otp/0x.../123456) β
β βββ EPCIS Event Data (RDF/JSON-LD) β
-β βββ Cryptographic Proof (Blockchain anchored) β
-β βββ Ownership (NFT) β
+β βββ Cryptographic Proof (Blockchain anchored) β
+β βββ Ownership (NFT) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
```
---
-## 2. Quick Start
+## 2. Document Structure
-### Prerequisites
+An EPCIS capture request consists of two main parts:
+
+```json
+{
+ "epcisDocument": { ... }, // The EPCIS document wrapper
+ "publishOptions": { ... } // DKG publishing configuration (optional)
+}
+```
-- DKG Node running (with EPCIS and Publisher plugins enabled)
-- Access to the API endpoint (default: `http://localhost:9200`)
+### epcisDocument Fields
-### Step 1: Send Your First EPCIS Event
+| Field | Example | Description |
+| --------------- | ------------------------ | ----------------------------------------------- |
+| `@context` | `{...}` | JSON-LD context for semantic interpretation |
+| `type` | `"EPCISDocument"` | Document type identifier (must be exactly this) |
+| `schemaVersion` | `"2.0"` | EPCIS schema version |
+| `creationDate` | `"2024-03-01T08:00:00Z"` | When document was created (ISO 8601) |
+| `epcisBody` | `{ eventList: [...] }` | Container for event data |
-```bash
-curl -X POST http://localhost:9200/epcis/capture \
- -H "Content-Type: application/json" \
- -d '{
+### publishOptions Fields (DKG-specific)
+
+| Field | Example | Default | Description |
+| --------- | ----------- | ----------- | ------------------------------------------- |
+| `privacy` | `"private"` | `"private"` | Asset visibility: `"private"` or `"public"` |
+| `epochs` | `12` | `12` | How many epochs to keep asset published |
+
+> Both fields are optional. When omitted, the defaults above are used.
+
+---
+
+## 3. JSON-LD Context
+
+The `@context` defines JSON-LD namespaces for semantic interpretation. It is **extensible** - you can add custom namespaces for domain-specific vocabularies.
+
+### Standard Context
+
+```json
+"@context": {
+ "@vocab": "https://gs1.github.io/EPCIS/",
+ "epcis": "https://gs1.github.io/EPCIS/",
+ "cbv": "https://ref.gs1.org/cbv/",
+ "type": "@type",
+ "id": "@id"
+}
+```
+
+| Key | Purpose |
+| -------- | ---------------------------------------------------------- |
+| `@vocab` | Default namespace for unmapped terms |
+| `epcis` | EPCIS vocabulary namespace prefix |
+| `cbv` | Core Business Vocabulary namespace (GS1 standard values) |
+| `type` | JSON-LD alias for `@type` (required for DKG compatibility) |
+| `id` | JSON-LD alias for `@id` |
+
+### Extended Context with Custom Namespaces
+
+```json
+"@context": {
+ "@vocab": "https://gs1.github.io/EPCIS/",
+ "epcis": "https://gs1.github.io/EPCIS/",
+ "cbv": "https://ref.gs1.org/cbv/",
+ "type": "@type",
+ "id": "@id",
+
+ "mycompany": "https://mycompany.com/ontology/",
+ "schema": "https://schema.org/",
+ "scor": "http://purl.org/ontology/scor#",
+ "gr": "http://purl.org/goodrelations/v1#"
+}
+```
+
+### Common Extension Namespaces
+
+| Prefix | Namespace | Purpose |
+| --------- | ----------------------------------- | --------------------------------- |
+| `schema` | `https://schema.org/` | General-purpose vocabulary |
+| `scor` | `http://purl.org/ontology/scor#` | Supply Chain Operations Reference |
+| `gr` | `http://purl.org/goodrelations/v1#` | E-commerce and business |
+| `foaf` | `http://xmlns.com/foaf/0.1/` | People and organizations |
+| `dcterms` | `http://purl.org/dc/terms/` | Dublin Core metadata |
+
+> **Important:** Always include `"type": "@type"` in your context for DKG JSON-LD processing compatibility.
+
+---
+
+## 4. Event Types
+
+EPCIS defines five event types, each serving a specific purpose in supply chain tracking:
+
+| Event Type | Purpose | Key Fields | Example Use Case |
+| ----------------------- | ----------------------------- | --------------------------------- | ----------------------------------- |
+| **ObjectEvent** | Track individual objects | `epcList`, `action` | Receiving goods, quality inspection |
+| **AggregationEvent** | Parent-child relationships | `parentID`, `childEPCs`, `action` | Packing items onto a pallet |
+| **TransactionEvent** | Link to business transactions | `bizTransactionList` | Purchase order fulfillment |
+| **TransformationEvent** | Input/output transformations | `inputEPCList`, `outputEPCList` | Manufacturing, assembly |
+| **AssociationEvent** | Link assets together | `parentID`, `childEPCs` | Sensor attached to container |
+
+### Event Type Decision Guide
+
+```
+Is the item being created from other items?
+βββ YES β TransformationEvent (inputs β outputs)
+βββ NO
+ βββ Are items being grouped/ungrouped?
+ β βββ YES β AggregationEvent (parent-child)
+ βββ NO
+ βββ Is this linked to a business document?
+ β βββ YES β TransactionEvent
+ βββ NO β ObjectEvent (most common)
+```
+
+---
+
+## 5. Event Fields Reference
+
+### Core Event Identifiers
+
+| Field | Example | Description |
+| --------------------- | ---------------------------- | ---------------------------------- |
+| `type` | `"ObjectEvent"` | Event type identifier |
+| `eventID` | `"urn:uuid:event:001"` | Unique event identifier (optional) |
+| `eventTime` | `"2024-03-01T08:00:00.000Z"` | When event occurred (ISO 8601) |
+| `eventTimeZoneOffset` | `"+00:00"` | Timezone offset from UTC |
+
+### What (Items Being Tracked)
+
+#### For ObjectEvent
+
+| Field | Example | Description |
+| --------- | ------------------------------------------ | --------------------------- |
+| `epcList` | `["urn:epc:id:sgtin:4012345.011111.1001"]` | List of EPCs being observed |
+| `action` | `"ADD"` | Event action type |
+
+#### For AggregationEvent
+
+| Field | Example | Description |
+| ----------- | ------------------------------------------ | ----------------------------------- |
+| `parentID` | `"urn:epc:id:sscc:4012345.0000000001"` | Container/parent EPC |
+| `childEPCs` | `["urn:epc:id:sgtin:4012345.099999.9001"]` | Items inside the container |
+| `action` | `"ADD"` | ADD (packing) or DELETE (unpacking) |
+
+#### For TransformationEvent
+
+| Field | Example | Description |
+| --------------- | -------------------------- | ------------------- |
+| `inputEPCList` | `["urn:epc:id:sgtin:..."]` | Components consumed |
+| `outputEPCList` | `["urn:epc:id:sgtin:..."]` | Products created |
+
+### Action Values
+
+| Action | Description | Use Case |
+| --------- | ------------------------------------- | --------------------------------------- |
+| `ADD` | Objects entering the supply chain | Commissioning, receiving, packing |
+| `OBSERVE` | Objects observed without state change | Scanning, tracking, inspection |
+| `DELETE` | Objects leaving the supply chain | Decommissioning, unpacking, destruction |
+
+### Where (Location Fields)
+
+| Field | Example | Description |
+| ------------- | ------------------------------------------- | ---------------------------- |
+| `readPoint` | `{"id": "urn:epc:id:sgln:4012345.00001.0"}` | Specific scan/read location |
+| `bizLocation` | `{"id": "urn:epc:id:sgln:4012345.00001.0"}` | Business location (facility) |
+
+**Difference:**
+
+- `readPoint` = Where the scanner/reader is (specific station, dock door)
+- `bizLocation` = Business context location (warehouse, production line, facility)
+
+### Why (Business Context)
+
+| Field | Example | Description |
+| -------------------- | --------------------------------------------- | ------------------------- |
+| `bizStep` | `"https://ref.gs1.org/cbv/BizStep-receiving"` | Business process step |
+| `disposition` | `"https://ref.gs1.org/cbv/Disp-in_progress"` | Current state/condition |
+| `bizTransactionList` | `[{type, bizTransaction}]` | Linked business documents |
+
+---
+
+## 6. Business Step (bizStep)
+
+The `bizStep` field indicates what business process step is occurring. You can use either the full URI or shorthand (the API accepts both).
+
+### Commissioning & Decommissioning
+
+| BizStep | Description |
+| ----------------- | ---------------------------------- |
+| `commissioning` | Creating a new serialized instance |
+| `decommissioning` | Removing from active use |
+
+### Manufacturing & Production
+
+| BizStep | Description |
+| ------------- | ----------------------------------- |
+| `assembling` | Combining components into a product |
+| `disassembly` | Breaking down into components |
+| `repairing` | Fixing a defective item |
+| `repackaging` | Changing packaging |
+
+### Warehousing & Logistics
+
+| BizStep | Description |
+| ------------------ | ---------------------------- |
+| `receiving` | Goods arriving at a location |
+| `shipping` | Goods departing a location |
+| `storing` | Placing into storage |
+| `picking` | Retrieving from storage |
+| `packing` | Placing into containers |
+| `unpacking` | Removing from containers |
+| `loading` | Loading onto transport |
+| `unloading` | Unloading from transport |
+| `transporting` | In transit |
+| `staging_outbound` | Staged for shipping |
+| `arriving` | Arriving at destination |
+| `departing` | Leaving a location |
+
+### Quality & Compliance
+
+| BizStep | Description |
+| ------------ | -------------------------- |
+| `inspecting` | Quality inspection |
+| `accepting` | Accepting after inspection |
+| `rejecting` | Rejecting after inspection |
+| `holding` | Quarantine/hold status |
+| `releasing` | Releasing from hold |
+
+### Retail & Commerce
+
+| BizStep | Description |
+| ---------------- | ------------------ |
+| `retail_selling` | Point of sale |
+| `sampling` | Taking samples |
+| `void_shipping` | Voiding a shipment |
+
+### Other
+
+| BizStep | Description |
+| ------------------ | -------------------- |
+| `cycle_counting` | Inventory count |
+| `destroying` | Destruction of items |
+| `encoding` | RFID encoding |
+| `sensor_reporting` | Sensor data capture |
+
+**URI Format:** `https://ref.gs1.org/cbv/BizStep-{value}`
+
+**Shorthand:** The API accepts just the step name (e.g., `"assembling"`) and expands it automatically.
+
+---
+
+## 7. Disposition
+
+The `disposition` field indicates the current state/condition of objects.
+
+### Process States
+
+| Disposition | Description |
+| ------------- | ------------------------- |
+| `in_progress` | Currently being processed |
+| `in_transit` | Being transported |
+| `active` | In active use |
+| `inactive` | Not currently in use |
+
+### Container/Packaging States
+
+| Disposition | Description |
+| ------------------ | ------------------- |
+| `container_open` | Container is open |
+| `container_closed` | Container is sealed |
+
+### Quality States
+
+| Disposition | Description |
+| ------------------- | ----------------------- |
+| `conformant` | Meets quality standards |
+| `non_conformant` | Does not meet standards |
+| `needs_replacement` | Requires replacement |
+| `damaged` | Physical damage |
+| `expired` | Past expiration date |
+
+### Inventory States
+
+| Disposition | Description |
+| ------------------------- | ----------------------------- |
+| `available` | Available for use/sale |
+| `unavailable` | Not available |
+| `reserved` | Reserved for specific purpose |
+| `sellable_accessible` | Can be sold, accessible |
+| `sellable_not_accessible` | Can be sold, not accessible |
+| `non_sellable` | Cannot be sold |
+
+### Special States
+
+| Disposition | Description |
+| ----------- | ------------------ |
+| `recalled` | Subject to recall |
+| `returned` | Returned item |
+| `stolen` | Reported stolen |
+| `destroyed` | Has been destroyed |
+| `disposed` | Disposed of |
+| `encoded` | RFID encoded |
+| `unknown` | State unknown |
+
+**URI Format:** `https://ref.gs1.org/cbv/Disp-{value}`
+
+---
+
+## 8. Business Transaction Types
+
+The `bizTransactionList` links events to business documents.
+
+### Standard Transaction Types (CBV 2.0)
+
+| Type Code | Description | Example Use |
+| ----------- | -------------------------------- | --------------------------- |
+| `po` | Purchase Order | Customer order |
+| `prodorder` | Production Order | Manufacturing work order |
+| `desadv` | Despatch Advice | Shipping notification (ASN) |
+| `recadv` | Receiving Advice | Receipt confirmation |
+| `inv` | Invoice | Billing document |
+| `rma` | Return Merchandise Authorization | Return authorization |
+| `pedigree` | Pedigree | Chain of custody |
+| `cert` | Certificate | Quality certificate |
+
+**URI Format:** `https://ref.gs1.org/cbv/BTT-{type}`
+
+**Example:**
+
+```json
+"bizTransactionList": [
+ {
+ "type": "https://ref.gs1.org/cbv/BTT-po",
+ "bizTransaction": "urn:epc:id:gdti:4012345.00001.PO-2024-001"
+ }
+]
+```
+
+---
+
+## 9. GS1 URN Schemes
+
+GS1 URN (Uniform Resource Name) schemes provide globally unique identifiers for tracking items, locations, documents, and assets.
+
+### Overview
+
+| Scheme | Full Name | Used For | Granularity |
+| --------- | ----------------------------------- | ------------------ | --------------- |
+| **SGTIN** | Serialized Global Trade Item Number | Individual items | Unit level |
+| **LGTIN** | Lot/Batch GTIN | Batch/lot tracking | Batch level |
+| **SGLN** | Serialized Global Location Number | Locations | Location level |
+| **SSCC** | Serial Shipping Container Code | Containers/pallets | Container level |
+| **GRAI** | Global Returnable Asset ID | Reusable assets | Asset level |
+| **GIAI** | Global Individual Asset ID | Fixed assets | Asset level |
+| **GDTI** | Global Document Type ID | Documents | Document level |
+
+---
+
+### SGTIN - Serialized Global Trade Item Number
+
+**Purpose:** Uniquely identify individual product instances (serialized items).
+
+**Format:**
+
+```
+urn:epc:id:sgtin:{CompanyPrefix}.{ItemReference}.{SerialNumber}
+```
+
+**Breakdown (Bicycle Manufacturing Example):**
+
+```
+urn:epc:id:sgtin:4012345.011111.1001
+ βββββββ ββββββ ββββ
+ β β β
+ β β βββ Serial Number (unique instance: 1001)
+ β βββββββββ Item Reference (product: carbon frame)
+ ββββββββββββββββ Company Prefix (Alpine Cycles: 4012345)
+```
+
+**Examples from Bicycle Manufacturing:**
+
+| Item | EPC |
+| ---------------- | -------------------------------------- |
+| Carbon Frame | `urn:epc:id:sgtin:4012345.011111.1001` |
+| Front Wheel | `urn:epc:id:sgtin:4012345.022222.2001` |
+| Rear Wheel | `urn:epc:id:sgtin:4012345.022222.2002` |
+| Handlebar | `urn:epc:id:sgtin:4012345.033333.3001` |
+| Finished Bicycle | `urn:epc:id:sgtin:4012345.099999.9001` |
+
+---
+
+### SGLN - Serialized Global Location Number
+
+**Purpose:** Identify physical locations (facilities, zones, stations).
+
+**Format:**
+
+```
+urn:epc:id:sgln:{CompanyPrefix}.{LocationReference}.{Extension}
+```
+
+**Breakdown:**
+
+```
+urn:epc:id:sgln:4012345.00001.0
+ βββββββ βββββ ββ
+ β β β
+ β β βββ Extension (specific point, 0 = general)
+ β ββββββββ Location Reference (area/zone)
+ βββββββββββββββ Company Prefix
+```
+
+**Examples from Bicycle Manufacturing:**
+
+| Location | EPC |
+| -------------- | --------------------------------- |
+| Receiving Dock | `urn:epc:id:sgln:4012345.00001.0` |
+| Quality Lab | `urn:epc:id:sgln:4012345.00002.0` |
+| Assembly Line | `urn:epc:id:sgln:4012345.00003.0` |
+| Packing Area | `urn:epc:id:sgln:4012345.00004.0` |
+| Shipping Dock | `urn:epc:id:sgln:4012345.00005.0` |
+
+---
+
+### SSCC - Serial Shipping Container Code
+
+**Purpose:** Identify logistics units (pallets, containers, cases).
+
+**Format:**
+
+```
+urn:epc:id:sscc:{CompanyPrefix}.{SerialReference}
+```
+
+**Example:**
+
+```
+urn:epc:id:sscc:4012345.0000000001
+ βββββββ ββββββββββ
+ β β
+ β βββ Serial Reference (unique container ID)
+ ββββββββββββ Company Prefix
+```
+
+**Use Cases:**
+| Container Type | Example |
+|----------------|---------|
+| Shipping Pallet | `urn:epc:id:sscc:4012345.0000000001` |
+| Cardboard Case | `urn:epc:id:sscc:4012345.CASE000123` |
+| Shipping Container | `urn:epc:id:sscc:4012345.CONT456789` |
+
+---
+
+### GDTI - Global Document Type Identifier
+
+**Purpose:** Identify business documents.
+
+**Format:**
+
+```
+urn:epc:id:gdti:{CompanyPrefix}.{DocumentType}.{SerialNumber}
+```
+
+**Examples:**
+
+| Document Type | Example |
+| --------------- | -------------------------------------------- |
+| Purchase Order | `urn:epc:id:gdti:4012345.00001.PO-2024-001` |
+| Despatch Advice | `urn:epc:id:gdti:4012345.00001.ASN-2024-001` |
+| Invoice | `urn:epc:id:gdti:4012345.00001.INV-12345` |
+
+---
+
+## 10. API Reference
+
+### POST `/epcis/capture`
+
+Accept an EPCIS Document and queue it for publishing to DKG.
+
+**Request Body:**
+
+```json
+{
+ "epcisDocument": {
"@context": {
"@vocab": "https://gs1.github.io/EPCIS/",
"epcis": "https://gs1.github.io/EPCIS/",
"cbv": "https://ref.gs1.org/cbv/",
"type": "@type",
- "id": "@id",
- "epcisBody": "epcis:epcisBody",
- "eventList": "epcis:eventList"
+ "id": "@id"
},
"type": "EPCISDocument",
"schemaVersion": "2.0",
- "creationDate": "2024-01-01T00:00:00Z",
+ "creationDate": "2024-03-01T08:00:00Z",
"epcisBody": {
- "eventList": [{
- "type": "ObjectEvent",
- "eventTime": "2024-01-01T00:00:00.000Z",
- "eventTimeZoneOffset": "+00:00",
- "epcList": ["urn:epc:id:sgtin:0614141.107346.2017"],
- "action": "OBSERVE",
- "bizStep": "https://ref.gs1.org/cbv/BizStep-receiving",
- "disposition": "https://ref.gs1.org/cbv/Disp-in_progress",
- "readPoint": {"id": "urn:epc:id:sgln:0614141.00001.0"},
- "bizLocation": {"id": "urn:epc:id:sgln:0614141.00001.0"}
- }]
+ "eventList": [
+ /* array of events */
+ ]
}
- }'
+ },
+ "publishOptions": {
+ "privacy": "private",
+ "epochs": 12
+ }
+}
```
-### Step 2: Check Status
+**Responses:**
-The response includes a `captureID`. Use it to check publishing status:
+| Status | When | Description |
+| ----------------------------- | ------------------------- | ----------------------------------------------------------------------- |
+| **202 Accepted** | Document valid and queued | Capture forwarded to publisher; includes `captureID` for status polling |
+| **400 Bad Request** | Validation failed | GS1 schema validation error or document contains no events |
+| **500 Internal Server Error** | Publisher unreachable | Publisher service unavailable after retry attempts |
-```bash
-curl http://localhost:9200/epcis/capture/123
+**Example (HTTP 202 Accepted):**
+
+```json
+{
+ "status": "202",
+ "requestId": "epcis-1709280001123-a1b2c3",
+ "receivedAt": "2024-03-01T08:00:01.123Z",
+ "captureID": "456",
+ "eventCount": 1
+}
```
-Possible statuses:
+> Poll `GET /epcis/capture/:captureID` with the returned `captureID` to track publishing progress and retrieve the UAL once published.
-- `queued` - Waiting to be published
-- `processing` - Currently being published to DKG
-- `published` - Successfully published (includes UAL)
-- `failed` - Publishing failed (includes error message)
+**Example (HTTP 400 Bad Request - Validation):**
-### Step 3: Query Events
+```json
+{
+ "error": "Invalid EPCISDocument",
+ "details": [
+ "/epcisBody/eventList/0/eventTime: must match format \"date-time\""
+ ]
+}
+```
-Once published, query events from the DKG:
+**Example (HTTP 400 Bad Request - Empty Events):**
-```bash
-# By EPC
-curl "http://localhost:9200/epcis/events?epc=urn:epc:id:sgtin:0614141.107346.2017"
+```json
+{
+ "error": "EPCISDocument contains no events",
+ "message": "The EPCISDocument contains no events to publish. Please check the document and try again."
+}
+```
-# By time range
-curl "http://localhost:9200/epcis/events?from=2024-01-01T00:00:00Z&to=2024-12-31T23:59:59Z"
+**Example (HTTP 500 Internal Server Error):**
-# By business step
-curl "http://localhost:9200/epcis/events?bizStep=inspecting"
+```json
+{
+ "error": "Something went wrong with publishing the EPCIS document.",
+ "message": "Something went wrong with publishing the EPCIS document. Check if the publisher service is available."
+}
```
-> π‘ **Interactive Documentation**: For detailed request/response schemas and to test the API live, visit the Swagger UI at `/swagger`
-
---
-## 3. EPCIS Event Types Explained
+### GET `/epcis/capture/:captureID`
+
+Check the status of a previously submitted capture tracked by the publisher.
-### What is EPCIS?
+> **Note:** `captureID` must be a numeric string matching the pattern `^[0-9]{1,20}$`.
+> Use the numeric `captureID` returned from `POST /epcis/capture`.
-EPCIS (Electronic Product Code Information Services) is a GS1 standard for capturing and sharing supply chain events. It answers the "what, where, when, and why" of products moving through a supply chain.
+**Responses:**
-### The Five Event Types
+| Status | When | Description |
+| ----------------------------- | ------------------------ | --------------------------------------------------- |
+| **200 OK** | Capture found | Returns current status, optional UAL and timestamps |
+| **404 Not Found** | Unknown captureID | No capture with this ID exists in the publisher |
+| **500 Internal Server Error** | Upstream publisher error | Unexpected publisher/status lookup failure |
+| **504 Gateway Timeout** | Publisher timeout | Publisher service did not respond in time |
-| Event Type | Purpose | Example Use Case |
-|------------|---------|------------------|
-| **ObjectEvent** | Track individual items | Product inspection, quality check |
-| **AggregationEvent** | Items grouped/ungrouped | Packing items into a case |
-| **TransactionEvent** | Business transactions | Purchase order, invoice |
-| **TransformationEvent** | InputβOutput conversion | Manufacturing, assembly |
-| **AssociationEvent** | Link assets together | Sensor attached to container |
+**Example (HTTP 200 OK):**
-### Action Types
+```json
+{
+ "status": "published",
+ "captureID": "456",
+ "UAL": "did:dkg:otp/0x1234.../789",
+ "publishedAt": "2024-03-01T08:01:23.456Z"
+}
+```
-- **ADD** - New item introduced (e.g., manufactured, received)
-- **OBSERVE** - Item observed without state change (e.g., scanned at checkpoint)
-- **DELETE** - Item removed from tracking (e.g., sold, destroyed)
+**Example (Failed):**
-### Business Steps (bizStep)
+```json
+{
+ "status": "failed",
+ "captureID": "456",
+ "error": "Wallet balance insufficient"
+}
+```
-Common GS1 CBV (Core Business Vocabulary) business steps:
+**Example (HTTP 500 Internal Server Error):**
-| bizStep | Description |
-|---------|-------------|
-| `receiving` | Goods received at a location |
-| `shipping` | Goods shipped from a location |
-| `inspecting` | Quality inspection performed |
-| `assembling` | Components assembled into product |
-| `packing` | Items packed for shipment |
-| `commissioning` | New serial assigned (e.g., manufacturing) |
-| `decommissioning` | Serial number retired |
+```json
+{
+ "error": "Failed to get capture status"
+}
+```
-> **Shorthand supported**: You can use just `"assembling"` instead of the full URI `"https://ref.gs1.org/cbv/BizStep-assembling"`
+| Status | Description |
+| ------------ | ------------------------------------------ |
+| `pending` | Registered but not yet queued |
+| `queued` | Waiting to be published |
+| `assigned` | Assigned to a publishing wallet |
+| `publishing` | Currently being published to DKG |
+| `published` | Successfully published (includes UAL) |
+| `failed` | Publishing failed (includes error message) |
---
-## 4. API Reference
+### GET `/epcis/events`
+
+Query EPCIS events from the DKG using SPARQL.
+
+**Validation Rules:**
-### Understanding the JSON-LD Context
+- At least one filter parameter is required (excluding `fullTrace`, `limit`, `offset`)
+- When both `from` and `to` are provided, `to` must be >= `from`
+- Empty string values are rejected for all filter parameters
+- Date parameters must be valid ISO 8601 datetime strings
-EPCIS documents use JSON-LD (Linked Data) format. The `@context` object maps terms to URIs for proper semantic interpretation:
+**Query Parameters:**
+
+| Parameter | Type | Description | Example |
+| ------------- | ----------------- | ------------------------------------------------------------------- | -------------------------------------- |
+| `epc` | string | Filter by EPC identifier | `urn:epc:id:sgtin:4012345.011111.1001` |
+| `from` | string (ISO 8601) | Start of time range | `2024-03-01T00:00:00Z` |
+| `to` | string (ISO 8601) | End of time range | `2024-03-31T23:59:59Z` |
+| `bizStep` | string | Filter by business step | `assembling` or full URI |
+| `bizLocation` | string | Filter by location | `urn:epc:id:sgln:4012345.00002.0` |
+| `fullTrace` | string enum | `"true"` or `"false"` - search all EPC fields for full traceability | `"true"` |
+| `parentID` | string | Filter by parent EPC (AggregationEvent) | `urn:epc:id:sscc:...` |
+| `childEPC` | string | Filter by child EPC (AggregationEvent) | `urn:epc:id:sgtin:...` |
+| `inputEPC` | string | Filter by input EPC (TransformationEvent) | `urn:epc:id:sgtin:...` |
+| `outputEPC` | string | Filter by output EPC (TransformationEvent) | `urn:epc:id:sgtin:...` |
+| `limit` | integer | Results per page (default: 100, range: 1-1000) | `50` |
+| `offset` | integer | Results to skip (pagination, min: 0) | `0` |
+
+**Responses:**
+
+| Status | When | Description |
+| ----------------------------- | ----------------- | ---------------------------------------------- |
+| **200 OK** | Query succeeded | Returns matching events with pagination |
+| **400 Bad Request** | Validation failed | Missing filters, invalid date range, or params |
+| **500 Internal Server Error** | DKG query failed | Failed to execute SPARQL query against DKG |
+
+**Example (HTTP 200 OK):**
```json
{
- "@context": {
- "@vocab": "https://gs1.github.io/EPCIS/",
- "epcis": "https://gs1.github.io/EPCIS/",
- "cbv": "https://ref.gs1.org/cbv/",
- "type": "@type",
- "id": "@id",
- "epcisBody": "epcis:epcisBody",
- "eventList": "epcis:eventList"
+ "success": true,
+ "results": [
+ {
+ "ual": "did:dkg:otp:2043/0x.../1/private",
+ "eventType": "https://gs1.github.io/EPCIS/ObjectEvent",
+ "eventTime": "2024-03-01T08:00:00.000Z",
+ "bizStep": "https://ref.gs1.org/cbv/BizStep-receiving",
+ "bizLocation": "urn:epc:id:sgln:4012345.00001.0",
+ "disposition": "https://ref.gs1.org/cbv/Disp-in_progress",
+ "readPoint": "urn:epc:id:sgln:4012345.00001.0",
+ "action": "ADD",
+ "epcList": "urn:epc:id:sgtin:4012345.011111.1001"
+ }
+ ],
+ "count": 1,
+ "pagination": {
+ "limit": 100,
+ "offset": 0
}
}
```
-| Key | Purpose |
-|-----|---------|
-| `@vocab` | Default namespace for unmapped terms |
-| `epcis` | EPCIS vocabulary namespace |
-| `cbv` | GS1 Core Business Vocabulary |
-| `type` / `id` | Maps to JSON-LD keywords |
-| `epcisBody`, `eventList` | Explicit term mappings |
+> Each result row includes a `ual` field identifying which Knowledge Asset graph the event was found in. Array fields (`epcList`, `childEPCList`, `inputEPCs`, `outputEPCs`) are returned as comma-separated strings.
+
+**Example (HTTP 400 Bad Request):**
+
+```json
+{
+ "error": "At least one filter parameter is required."
+}
+```
+
+**Example (HTTP 500 Internal Server Error):**
-> **Note**: You can also use the shorthand `["https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld"]` but the explicit context above gives you more control and is properly tested.
+```json
+{
+ "success": false,
+ "error": "Failed to query events"
+}
+```
---
-### POST `/epcis/capture`
+### GET `/epcis/events/track`
-Accept an EPCIS Document and queue it for publishing to DKG.
+Track a single EPC through its full supply chain journey. This endpoint always performs a full-trace query across all EPC-relevant fields.
+
+**Query Parameters:**
+
+| Parameter | Type | Required | Description |
+| --------- | ------ | -------- | ----------------------------------------- |
+| `epc` | string | **Yes** | EPC identifier to track across all events |
-**Request Body**: EPCISDocument (JSON-LD)
+**Responses:**
+
+| Status | When | Description |
+| ----------------------------- | --------------------- | ---------------------------------- |
+| **200 OK** | Query succeeded | Returns matching events for EPC |
+| **400 Bad Request** | Missing/invalid `epc` | Query validation failed |
+| **500 Internal Server Error** | DKG query failed | Failed to execute full-trace query |
+
+**Example (HTTP 200 OK):**
```json
{
- "@context": {
- "@vocab": "https://gs1.github.io/EPCIS/",
- "epcis": "https://gs1.github.io/EPCIS/",
- "cbv": "https://ref.gs1.org/cbv/",
- "type": "@type",
- "id": "@id",
- "epcisBody": "epcis:epcisBody",
- "eventList": "epcis:eventList"
- },
- "type": "EPCISDocument",
- "schemaVersion": "2.0",
- "creationDate": "2024-01-01T00:00:00Z",
- "epcisBody": {
- "eventList": [/* array of events */]
+ "success": true,
+ "results": [
+ {
+ "ual": "did:dkg:otp:2043/0x.../6/private",
+ "eventType": "https://gs1.github.io/EPCIS/TransformationEvent",
+ "eventTime": "2024-03-01T14:00:00.000Z",
+ "bizStep": "https://ref.gs1.org/cbv/BizStep-assembling",
+ "bizLocation": "urn:epc:id:sgln:4012345.00003.0",
+ "inputEPCs": "urn:epc:id:sgtin:4012345.011111.1001, urn:epc:id:sgtin:4012345.022222.2001",
+ "outputEPCs": "urn:epc:id:sgtin:4012345.099999.9001"
+ }
+ ],
+ "count": 1
+}
+```
+
+**Example (HTTP 500 Internal Server Error):**
+
+```json
+{
+ "success": false,
+ "error": "Failed to query events"
+}
+```
+
+---
+
+## 11. MCP Tools Reference
+
+The EPCIS plugin exposes four MCP (Model Context Protocol) tools that AI agents can use to capture, query, and track EPCIS supply chain data.
+
+### `epcis-query` β Query EPCIS Events
+
+General-purpose query tool with the same filtering capabilities as `GET /epcis/events`. Returns matching events with pagination and source Knowledge Asset provenance.
+
+**Input Schema:**
+
+| Parameter | Type | Required | Description |
+| ------------- | ----------------- | -------- | ------------------------------------------------------ |
+| `epc` | string | No | EPC identifier to filter by |
+| `from` | string (ISO 8601) | No | Start of time range |
+| `to` | string (ISO 8601) | No | End of time range |
+| `bizStep` | string | No | Business step (shorthand or full URI) |
+| `bizLocation` | string | No | Business location URI |
+| `fullTrace` | boolean | No | If `true`, search all EPC fields for full traceability |
+| `parentID` | string | No | Parent ID for AggregationEvent queries |
+| `childEPC` | string | No | Child EPC for AggregationEvent queries |
+| `inputEPC` | string | No | Input EPC for TransformationEvent queries |
+| `outputEPC` | string | No | Output EPC for TransformationEvent queries |
+| `limit` | integer | No | Results per page (default: 100, max: 1000) |
+| `offset` | integer | No | Results to skip for pagination (default: 0) |
+
+> **Note:** At least one filter parameter is required (excluding `fullTrace`, `limit`, `offset`). Unlike the HTTP API where `fullTrace` is a string (`"true"`/`"false"`), the MCP tool accepts a native boolean.
+
+**Response (first content block):**
+
+```json
+{
+ "summary": "Found 3 EPCIS event(s)",
+ "count": 3,
+ "events": [
+ {
+ "ual": "did:dkg:otp:2043/0x.../1/private",
+ "eventType": "https://gs1.github.io/EPCIS/ObjectEvent",
+ "eventTime": "2024-03-01T08:00:00.000Z",
+ "bizStep": "https://ref.gs1.org/cbv/BizStep-receiving",
+ "bizLocation": "urn:epc:id:sgln:4012345.00001.0",
+ "disposition": "https://ref.gs1.org/cbv/Disp-in_progress",
+ "readPoint": "urn:epc:id:sgln:4012345.00001.0",
+ "action": "ADD",
+ "epcList": "urn:epc:id:sgtin:4012345.011111.1001"
+ }
+ ],
+ "pagination": {
+ "limit": 100,
+ "offset": 0
}
}
```
-**Response** (HTTP 202 Accepted):
+When results contain events from DKG Knowledge Assets, a second content block is appended with **Source Knowledge Asset** provenance (see [Source Knowledge Assets](#source-knowledge-assets)).
+
+**Error cases:**
+
+- No filter parameters β `{ "error": "At least one filter parameter is required." }`
+- Invalid date range β `{ "error": "Parameter 'to' must be greater than or equal to 'from'." }`
+- DKG query failure β `{ "error": "Query failed" }`
+
+---
+
+### `epcis-track-item` β Track Item Journey
+
+Specialized tool for tracking a single item's complete journey through the supply chain. Automatically enables full traceability to find the item across all event types (observed, transformation input/output, aggregations). Returns events in chronological order.
+
+**Input Schema:**
+
+| Parameter | Type | Required | Description |
+| --------- | ------ | -------- | --------------------------------------------------------------- |
+| `epc` | string | **Yes** | The EPC to track (e.g., `urn:epc:id:sgtin:0614141.107346.2017`) |
+
+**Response (first content block):**
+
+```json
+{
+ "summary": "Tracking: urn:epc:id:sgtin:4012345.011111.1001\nFound 4 event(s) in the supply chain.\n\nJourney Timeline:\n1. [2024-03-01T08:00:00.000Z] receiving @ urn:epc:id:sgln:4012345.00001.0\n2. [2024-03-01T10:00:00.000Z] inspecting @ urn:epc:id:sgln:4012345.00002.0\n3. [2024-03-01T14:00:00.000Z] assembling @ urn:epc:id:sgln:4012345.00003.0\n4. [2024-03-01T16:00:00.000Z] packing @ urn:epc:id:sgln:4012345.00004.0\n",
+ "epc": "urn:epc:id:sgtin:4012345.011111.1001",
+ "eventCount": 4,
+ "events": [
+ /* chronologically ordered event objects */
+ ]
+}
+```
+
+The `summary` field contains a human-readable timeline with numbered steps showing `[eventTime] bizStep @ location` for each event. When results are found, a second content block is appended with **Source Knowledge Asset** provenance (see [Source Knowledge Assets](#source-knowledge-assets)).
+
+**Error cases:**
+
+- DKG query failure β `{ "error": "Tracking failed" }`
+
+---
+
+### `epcis-capture` β Capture EPCIS Document
+
+Validates an EPCIS document and queues it for publishing via the DKG publisher service.
+
+**Input Schema:**
+
+| Parameter | Type | Required | Description |
+| ---------------- | ------ | -------- | -------------------------------------------------- |
+| `epcisDocument` | object | **Yes** | EPCIS 2.0 JSON-LD document |
+| `publishOptions` | object | No | Optional publishing settings (`privacy`, `epochs`) |
+
+**Success Response:**
```json
{
- "status": "202",
- "receivedAt": "2024-01-01T00:00:01.123Z",
"captureID": "456",
+ "requestId": "epcis-1709280001123-a1b2c3",
+ "receivedAt": "2024-03-01T08:00:01.123Z",
"eventCount": 1
}
```
+**Error cases:**
+
+- Validation error β `{ "error": "Invalid EPCISDocument", "details": ["..."] }`
+- Empty events β `{ "error": "EPCISDocument contains no events", "message": "..." }`
+- Publisher unavailable β `{ "error": "Something went wrong with publishing the EPCIS document.", "message": "..." }`
+
---
-### GET `/epcis/capture/:captureID`
+### `epcis-capture-status` β Get Capture Status
+
+Checks the publisher-tracked status for a capture request by numeric `captureID`.
-Check the status of a previously submitted capture.
+**Input Schema:**
-**Response**:
+| Parameter | Type | Required | Description |
+| ----------- | ------ | -------- | ----------------------------------------------------------------- |
+| `captureID` | string | **Yes** | Numeric capture ID (`^[0-9]{1,20}$`) returned by capture handlers |
+
+**Success Response:**
```json
{
"status": "published",
"captureID": "456",
"UAL": "did:dkg:otp/0x1234.../789",
- "publishedAt": "2024-01-01T00:01:23.456Z"
+ "publishedAt": "2024-03-01T08:01:23.456Z"
}
```
-| Field | Description |
-|-------|-------------|
-| `status` | `queued` / `processing` / `published` / `failed` |
-| `UAL` | Uniform Asset Locator (only when published) |
-| `error` | Error message (only when failed) |
+> Fields `UAL`, `publishedAt`, and `error` are only present when applicable to the current status.
+
+**Error cases:**
+
+- Capture not found β `{ "error": "Capture not found", "captureID": "456" }`
+- Publisher timeout β `{ "error": "Publisher timeout", "captureID": "456" }`
+- Upstream failure β `{ "error": "Failed to get capture status", "captureID": "456" }`
---
-### GET `/epcis/events`
+## Source Knowledge Assets
-Query EPCIS events from the DKG.
+MCP tool responses (`epcis-query` and `epcis-track-item`) include **Source Knowledge Asset provenance** when results are found. This is returned as a second MCP content block (markdown text) listing the unique Knowledge Assets that contained the matching events.
-**Query Parameters**:
+**Format:**
-| Parameter | Type | Description | Example |
-|-----------|------|-------------|---------|
-| `epc` | string | Filter by EPC identifier | `urn:epc:id:sgtin:0614141.107346.2017` |
-| `from` | string (ISO 8601) | Start of time range | `2024-01-01T00:00:00Z` |
-| `to` | string (ISO 8601) | End of time range | `2024-12-31T23:59:59Z` |
-| `bizStep` | string | Filter by business step | `assembling` or full URI |
-| `bizLocation` | string | Filter by location | `urn:epc:id:sgln:0614141.00001.0` |
-| `ual` | string | Get specific event by UAL | `did:dkg:otp/...` |
+```
+**Source Knowledge Assets:**
+- **EPCIS ObjectEvent**: EPCIS Plugin
+ [did:dkg:otp:2043/0x.../1](https://dkg.origintrail.io/explore?ual=did:dkg:otp:2043/0x.../1)
+- **EPCIS TransformationEvent**: EPCIS Plugin
+ [did:dkg:otp:2043/0x.../6](https://dkg.origintrail.io/explore?ual=did:dkg:otp:2043/0x.../6)
+```
-**Response**:
+Each entry includes:
-```json
-{
- "success": true,
- "query": "SELECT ...",
- "results": [/* array of matching events */],
- "count": 5
-}
+- **Title**: Derived from the event type (e.g., `EPCIS ObjectEvent`)
+- **Issuer**: Always `"EPCIS Plugin"`
+- **UAL**: The cleaned Knowledge Asset UAL (with `/private` or `/public` suffix removed), linked to the DKG Explorer
+
+### Event Result Structure
+
+SPARQL query results (from both the HTTP API and MCP tools) return events with the following fields:
+
+| Field | Description | Example |
+| -------------- | ------------------------------------------------- | ------------------------------------------------------ |
+| `ual` | Knowledge Asset graph containing this event | `did:dkg:otp:2043/0x.../1/private` |
+| `eventType` | Full EPCIS event type URI | `https://gs1.github.io/EPCIS/ObjectEvent` |
+| `eventTime` | When the event occurred (ISO 8601) | `2024-03-01T08:00:00.000Z` |
+| `bizStep` | Business step URI | `https://ref.gs1.org/cbv/BizStep-receiving` |
+| `bizLocation` | Business location identifier | `urn:epc:id:sgln:4012345.00001.0` |
+| `disposition` | Current state/condition URI | `https://ref.gs1.org/cbv/Disp-in_progress` |
+| `readPoint` | Scan/read location identifier | `urn:epc:id:sgln:4012345.00001.0` |
+| `action` | Event action (`ADD`, `OBSERVE`, `DELETE`) | `ADD` |
+| `parentID` | Parent EPC (AggregationEvent) | `urn:epc:id:sscc:4012345.0000000001` |
+| `epcList` | Observed EPCs (comma-separated) | `urn:epc:id:sgtin:4012345.011111.1001` |
+| `childEPCList` | Child EPCs (comma-separated, AggregationEvent) | `urn:epc:id:sgtin:4012345.099999.9001` |
+| `inputEPCs` | Input EPCs (comma-separated, TransformationEvent) | `urn:epc:id:sgtin:4012345.011111.1001, ...` |
+| `outputEPCs` | Output EPCs (comma-separated, TransformationEvent)| `urn:epc:id:sgtin:4012345.099999.9001` |
+
+> Array fields (`epcList`, `childEPCList`, `inputEPCs`, `outputEPCs`) are returned as comma-separated strings from the SPARQL `GROUP_CONCAT`. Fields that don't apply to a specific event type will be empty strings.
+
+---
+
+## 12. Query Examples
+
+### Track a Single Item's Journey
+
+Use the dedicated track endpoint for full-trace item tracking:
+
+```bash
+curl "http://localhost:9200/epcis/events/track?epc=urn:epc:id:sgtin:4012345.011111.1001"
+```
+
+### Track All Events for a Product
+
+Find all events where the carbon frame appears using the general query with full trace:
+
+```bash
+curl "http://localhost:9200/epcis/events?epc=urn:epc:id:sgtin:4012345.011111.1001&fullTrace=true"
+```
+
+### Find All Receiving Events
+
+```bash
+curl "http://localhost:9200/epcis/events?bizStep=receiving"
+```
+
+### Find Events at Quality Lab
+
+```bash
+curl "http://localhost:9200/epcis/events?bizLocation=urn:epc:id:sgln:4012345.00002.0"
+```
+
+### Find Assembly Events in a Time Range
+
+```bash
+curl "http://localhost:9200/epcis/events?bizStep=assembling&from=2024-03-01T00:00:00Z&to=2024-03-01T23:59:59Z"
+```
+
+### Find What Was Packed onto a Pallet
+
+```bash
+curl "http://localhost:9200/epcis/events?parentID=urn:epc:id:sscc:4012345.0000000001"
+```
+
+### Find Transformation Events by Output
+
+```bash
+curl "http://localhost:9200/epcis/events?outputEPC=urn:epc:id:sgtin:4012345.099999.9001"
```
---
-## 5. Data Flow & DKG Publishing
+## 13. Data Flow & DKG Publishing
### Publishing Pipeline
```
1. CAPTURE REQUEST
- βββΆ Validate against GS1 EPCIS 2.0 JSON Schema
-
-2. QUEUE (Tier 1 - MySQL)
- βββΆ Asset registered with status "queued"
- βββΆ Assigned priority and metadata
-
-3. POLLING (every 2 seconds)
- βββΆ QueuePoller checks for available wallets
- βββΆ Moves jobs to BullMQ (Tier 2 - Redis)
-
-4. PROCESSING (BullMQ Workers)
- βββΆ Worker acquires wallet lock
- βββΆ Wraps content as JSON-LD Knowledge Asset
+ βββΆ EPCIS Plugin validates against GS1 EPCIS 2.0 JSON Schema
+ βββΆ Assigns internal requestId (epcis-{timestamp}-{random})
+
+2. FORWARD TO PUBLISHER (HTTP POST)
+ βββΆ Sends JSON-LD content to DKG Publisher (/api/dkg/assets)
+ βββΆ Includes metadata (source: "EPCIS", sourceId: requestId)
+ βββΆ Includes publishOptions (privacy, epochs)
+ βββΆ Retries up to 3 times with exponential backoff on failure
+
+3. PUBLISHER QUEUING
+ βββΆ Publisher registers asset with status "pending" β "queued"
+ βββΆ Returns numeric captureID for status tracking
+
+4. PUBLISHER PROCESSING
+ βββΆ Asset assigned to publishing wallet ("assigned")
+ βββΆ Wraps content as JSON-LD Knowledge Asset ("publishing")
βββΆ Calls dkg.js asset.create()
-
+
5. DKG NETWORK
βββΆ Content replicated to DKG nodes
βββΆ Cryptographic proof anchored to blockchain
βββΆ UAL (NFT) minted for ownership
-
+
6. COMPLETION
βββΆ Asset status updated to "published"
- βββΆ UAL stored for future queries
+ βββΆ UAL stored for future queries via GET /epcis/capture/:captureID
```
### What is a UAL?
@@ -361,252 +1137,315 @@ With a UAL, you can:
---
-## 6. Query Examples
+## 14. Sample EPCIS Documents
-### Find All Events for a Product
+### ObjectEvent - Receiving Goods
-```bash
-curl "http://localhost:9200/epcis/events?epc=urn:epc:id:sgtin:0614141.107346.2017"
+Carbon fiber frame arrives from supplier:
+
+```json
+{
+ "epcisDocument": {
+ "@context": {
+ "@vocab": "https://gs1.github.io/EPCIS/",
+ "epcis": "https://gs1.github.io/EPCIS/",
+ "cbv": "https://ref.gs1.org/cbv/",
+ "type": "@type",
+ "id": "@id"
+ },
+ "type": "EPCISDocument",
+ "schemaVersion": "2.0",
+ "creationDate": "2024-03-01T08:00:00Z",
+ "epcisBody": {
+ "eventList": [
+ {
+ "type": "ObjectEvent",
+ "eventTime": "2024-03-01T08:00:00.000Z",
+ "eventTimeZoneOffset": "+00:00",
+ "epcList": ["urn:epc:id:sgtin:4012345.011111.1001"],
+ "action": "ADD",
+ "bizStep": "https://ref.gs1.org/cbv/BizStep-receiving",
+ "disposition": "https://ref.gs1.org/cbv/Disp-in_progress",
+ "readPoint": { "id": "urn:epc:id:sgln:4012345.00001.0" },
+ "bizLocation": { "id": "urn:epc:id:sgln:4012345.00001.0" },
+ "bizTransactionList": [
+ {
+ "type": "https://ref.gs1.org/cbv/BTT-po",
+ "bizTransaction": "urn:epc:id:gdti:4012345.00001.PO-2024-001"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "publishOptions": {
+ "privacy": "private",
+ "epochs": 12
+ }
+}
```
-### Find Assembly Events at a Specific Location
+### ObjectEvent - Quality Inspection
-```bash
-curl "http://localhost:9200/epcis/events?bizStep=assembling&bizLocation=urn:epc:id:sgln:0614141.00001.0"
+Frame passes quality check:
+
+```json
+{
+ "epcisDocument": {
+ "@context": {
+ "@vocab": "https://gs1.github.io/EPCIS/",
+ "epcis": "https://gs1.github.io/EPCIS/",
+ "cbv": "https://ref.gs1.org/cbv/",
+ "type": "@type",
+ "id": "@id"
+ },
+ "type": "EPCISDocument",
+ "schemaVersion": "2.0",
+ "creationDate": "2024-03-01T10:00:00Z",
+ "epcisBody": {
+ "eventList": [
+ {
+ "type": "ObjectEvent",
+ "eventTime": "2024-03-01T10:00:00.000Z",
+ "eventTimeZoneOffset": "+00:00",
+ "epcList": ["urn:epc:id:sgtin:4012345.011111.1001"],
+ "action": "OBSERVE",
+ "bizStep": "https://ref.gs1.org/cbv/BizStep-inspecting",
+ "disposition": "https://ref.gs1.org/cbv/Disp-conformant",
+ "readPoint": { "id": "urn:epc:id:sgln:4012345.00002.0" },
+ "bizLocation": { "id": "urn:epc:id:sgln:4012345.00002.0" }
+ }
+ ]
+ }
+ }
+}
```
-### Get Full Event Details by UAL
+### TransformationEvent - Assembly
-```bash
-curl "http://localhost:9200/epcis/events?ual=did:dkg:otp/0x1234.../789"
+Components assembled into finished bicycle:
+
+```json
+{
+ "epcisDocument": {
+ "@context": {
+ "@vocab": "https://gs1.github.io/EPCIS/",
+ "epcis": "https://gs1.github.io/EPCIS/",
+ "cbv": "https://ref.gs1.org/cbv/",
+ "type": "@type",
+ "id": "@id"
+ },
+ "type": "EPCISDocument",
+ "schemaVersion": "2.0",
+ "creationDate": "2024-03-01T14:00:00Z",
+ "epcisBody": {
+ "eventList": [
+ {
+ "type": "TransformationEvent",
+ "eventTime": "2024-03-01T14:00:00.000Z",
+ "eventTimeZoneOffset": "+00:00",
+ "inputEPCList": [
+ "urn:epc:id:sgtin:4012345.011111.1001",
+ "urn:epc:id:sgtin:4012345.022222.2001",
+ "urn:epc:id:sgtin:4012345.022222.2002",
+ "urn:epc:id:sgtin:4012345.033333.3001"
+ ],
+ "outputEPCList": ["urn:epc:id:sgtin:4012345.099999.9001"],
+ "bizStep": "https://ref.gs1.org/cbv/BizStep-assembling",
+ "disposition": "https://ref.gs1.org/cbv/Disp-active",
+ "readPoint": { "id": "urn:epc:id:sgln:4012345.00003.0" },
+ "bizLocation": { "id": "urn:epc:id:sgln:4012345.00003.0" }
+ }
+ ]
+ }
+ }
+}
```
-### Time Range Query
+### AggregationEvent - Packing
-```bash
-curl "http://localhost:9200/epcis/events?from=2024-01-01T00:00:00Z&to=2024-01-31T23:59:59Z"
-```
+Bicycle packed onto shipping pallet:
-### SPARQL Direct Query
+```json
+{
+ "epcisDocument": {
+ "@context": {
+ "@vocab": "https://gs1.github.io/EPCIS/",
+ "epcis": "https://gs1.github.io/EPCIS/",
+ "cbv": "https://ref.gs1.org/cbv/",
+ "type": "@type",
+ "id": "@id"
+ },
+ "type": "EPCISDocument",
+ "schemaVersion": "2.0",
+ "creationDate": "2024-03-01T16:00:00Z",
+ "epcisBody": {
+ "eventList": [
+ {
+ "type": "AggregationEvent",
+ "eventTime": "2024-03-01T16:00:00.000Z",
+ "eventTimeZoneOffset": "+00:00",
+ "parentID": "urn:epc:id:sscc:4012345.0000000001",
+ "childEPCs": ["urn:epc:id:sgtin:4012345.099999.9001"],
+ "action": "ADD",
+ "bizStep": "https://ref.gs1.org/cbv/BizStep-packing",
+ "disposition": "https://ref.gs1.org/cbv/Disp-in_transit",
+ "readPoint": { "id": "urn:epc:id:sgln:4012345.00004.0" },
+ "bizLocation": { "id": "urn:epc:id:sgln:4012345.00004.0" }
+ }
+ ]
+ }
+ }
+}
+```
-Under the hood, queries are translated to SPARQL. Example generated query:
+### ObjectEvent - Shipping
-```sparql
-PREFIX epcis:
-PREFIX schema:
+Pallet shipped to customer:
-SELECT ?ual ?eventType ?eventTime ?epc ?bizStep ?disposition ?readPoint ?bizLocation
-WHERE {
- GRAPH ?ual {
- ?event a ?eventType .
- ?event epcis:epcList "urn:epc:id:sgtin:0614141.107346.2017" .
- OPTIONAL { ?event epcis:bizStep ?bizStep . }
- OPTIONAL { ?event epcis:eventTime ?eventTime . }
+```json
+{
+ "epcisDocument": {
+ "@context": {
+ "@vocab": "https://gs1.github.io/EPCIS/",
+ "epcis": "https://gs1.github.io/EPCIS/",
+ "cbv": "https://ref.gs1.org/cbv/",
+ "type": "@type",
+ "id": "@id"
+ },
+ "type": "EPCISDocument",
+ "schemaVersion": "2.0",
+ "creationDate": "2024-03-02T08:00:00Z",
+ "epcisBody": {
+ "eventList": [
+ {
+ "type": "ObjectEvent",
+ "eventTime": "2024-03-02T08:00:00.000Z",
+ "eventTimeZoneOffset": "+00:00",
+ "epcList": ["urn:epc:id:sscc:4012345.0000000001"],
+ "action": "OBSERVE",
+ "bizStep": "https://ref.gs1.org/cbv/BizStep-shipping",
+ "disposition": "https://ref.gs1.org/cbv/Disp-in_transit",
+ "readPoint": { "id": "urn:epc:id:sgln:4012345.00005.0" },
+ "bizLocation": { "id": "urn:epc:id:sgln:4012345.00005.0" },
+ "bizTransactionList": [
+ {
+ "type": "https://ref.gs1.org/cbv/BTT-desadv",
+ "bizTransaction": "urn:epc:id:gdti:4012345.00001.ASN-2024-001"
+ }
+ ]
+ }
+ ]
+ }
}
- FILTER(STRSTARTS(STR(?eventType), "https://gs1.github.io/EPCIS/"))
}
-ORDER BY DESC(?eventTime)
-LIMIT 100
```
---
-## 7. Troubleshooting
+## Event Flow Visualization
-### Common Errors
+**Bicycle Manufacturing Supply Chain:**
-| Error | Cause | Solution |
-|-------|-------|----------|
-| `Invalid EPCISDocument` | Schema validation failed | Check your JSON matches EPCIS 2.0 spec |
-| `Invalid captureID format` | Non-numeric captureID | Use the numeric ID from capture response |
-| `Capture not found` | Unknown captureID | Verify the ID; it may have been deleted |
-| `Publishing failed` | DKG network error | Check wallet balance, node connectivity |
-| `No available wallets` | All wallets are busy | Wait or add more wallets to the pool |
+```
+Event 1: Receive Frame (receiving, in_progress) @ Receiving Dock
+ β
+Event 2: Receive Wheels (receiving, in_progress) @ Receiving Dock
+ β
+Event 3: Receive Handlebar (receiving, in_progress) @ Receiving Dock
+ β
+Event 4: Inspect Frame (inspecting, conformant) @ Quality Lab
+ β
+Event 5: Inspect Wheels (inspecting, conformant) @ Quality Lab
+ β
+Event 6: Assemble Bicycle (assembling, active) @ Assembly Line
+ [TRANSFORMATION: 4 inputs β 1 output]
+ β
+Event 7: Final QC (inspecting, conformant) @ Quality Lab
+ β
+Event 8: Pack on Pallet (packing, in_transit) @ Packing Area
+ [AGGREGATION: bicycle β pallet]
+ β
+Event 9: Ship (shipping, in_transit) @ Shipping Dock
+```
-### Checking System Health
+---
-**Publisher Dashboard**: Visit `/admin/queues` to see:
+## 15. Troubleshooting
-- Active jobs
-- Waiting queue
-- Failed jobs with error details
-- Worker status
+### Common Errors
-**API Health**: The Swagger UI at `/swagger` shows all available endpoints and their status.
+| Error | Cause | Solution |
+| -------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| `Invalid EPCISDocument` | GS1 schema validation failed | Check your JSON matches EPCIS 2.0 spec; `details` array shows specific issues |
+| `EPCISDocument contains no events` | eventList is empty | Add at least one event to `epcisBody.eventList` |
+| `Invalid captureID format` | captureID not numeric (must match `^[0-9]{1,20}$`) | Use the numeric ID from capture response |
+| `Capture not found` (404) | Unknown captureID | Verify the ID exists in the publisher |
+| `Publisher timeout` (504) | Publisher service did not respond | Publisher service may be overloaded; retry later |
+| `Something went wrong with publishing` (500) | Publisher unreachable after 3 retries | Check that `EXPO_PUBLIC_MCP_URL` is set and the publisher is running |
+| `At least one filter parameter is required` | Query with no filters | Provide at least one of: `epc`, `from`, `to`, `bizStep`, `bizLocation`, `parentID`, `childEPC`, `inputEPC`, `outputEPC` |
+| `Parameter 'to' must be >= 'from'` | Invalid date range | Ensure `to` date is not before `from` date |
+| `Parameter 'x' cannot be empty` | Empty string query parameter | Provide a value or omit the parameter entirely |
### Validation Errors
The system validates against the official GS1 EPCIS 2.0 JSON Schema. Common issues:
-1. **Missing `@context`** - Must include EPCIS context
-2. **Invalid `eventTime`** - Must be ISO 8601 format
+1. **Missing `@context`** - Must include EPCIS context with `type: @type` alias
+2. **Invalid `eventTime`** - Must be ISO 8601 format (e.g., `2024-01-01T00:00:00Z`)
3. **Wrong `type`** - Must be exactly `"EPCISDocument"` (case-sensitive)
4. **Invalid `bizStep`** - Must be valid CBV URI or shorthand
-### Getting Help
+### Environment Variables
-- **Swagger UI**: `http://your-server/swagger` - Interactive API docs
-- **OpenAPI Spec**: `http://your-server/openapi` - Raw JSON spec
-- **Logs**: Check server logs for detailed error messages
+| Variable | Required | Description |
+| --------------------- | -------- | --------------------------------------------------------------------- |
+| `EXPO_PUBLIC_MCP_URL` | Yes | Base URL of the DKG publisher service (e.g., `http://localhost:9200`) |
----
+### Checking System Health
-## Appendix: Sample EPCIS Documents
+- **Swagger UI**: Visit `/swagger` for interactive API documentation
+- **Publisher Dashboard**: Visit `/admin/queues` to monitor publishing jobs
+- **Server Logs**: Check for detailed error messages
-### Object Event (Receiving Goods)
+---
-```json
-{
- "@context": {
- "@vocab": "https://gs1.github.io/EPCIS/",
- "epcis": "https://gs1.github.io/EPCIS/",
- "cbv": "https://ref.gs1.org/cbv/",
- "type": "@type",
- "id": "@id",
- "epcisBody": "epcis:epcisBody",
- "eventList": "epcis:eventList"
- },
- "type": "EPCISDocument",
- "schemaVersion": "2.0",
- "creationDate": "2024-01-01T00:00:00Z",
- "epcisBody": {
- "eventList": [{
- "type": "ObjectEvent",
- "eventTime": "2024-01-01T00:00:00.000Z",
- "eventTimeZoneOffset": "+00:00",
- "epcList": ["urn:epc:id:sgtin:0614141.107346.2017"],
- "action": "OBSERVE",
- "bizStep": "https://ref.gs1.org/cbv/BizStep-receiving",
- "disposition": "https://ref.gs1.org/cbv/Disp-in_progress",
- "readPoint": {"id": "urn:epc:id:sgln:0614141.00001.0"},
- "bizLocation": {"id": "urn:epc:id:sgln:0614141.00001.0"},
- "bizTransactionList": [
- {
- "type": "urn:epcglobal:cbv:btt:po",
- "bizTransaction": "urn:epc:id:gdti:0614141.00001.1234"
- }
- ]
- }]
- }
-}
-```
+## Custom Extensions
-### Transformation Event (Assembly)
+You can add custom fields using your own namespace:
```json
{
"@context": {
"@vocab": "https://gs1.github.io/EPCIS/",
- "epcis": "https://gs1.github.io/EPCIS/",
- "cbv": "https://ref.gs1.org/cbv/",
"type": "@type",
"id": "@id",
- "epcisBody": "epcis:epcisBody",
- "eventList": "epcis:eventList"
+ "mycompany": "https://mycompany.com/ontology/"
},
- "type": "EPCISDocument",
- "schemaVersion": "2.0",
- "creationDate": "2024-01-01T00:00:00Z",
- "epcisBody": {
- "eventList": [{
- "type": "TransformationEvent",
- "eventTime": "2024-01-01T12:00:00.000Z",
- "eventTimeZoneOffset": "+00:00",
- "inputEPCList": [
- "urn:epc:id:sgtin:0614141.107346.001",
- "urn:epc:id:sgtin:0614141.107346.002"
- ],
- "outputEPCList": [
- "urn:epc:id:sgtin:0614141.107347.001"
- ],
- "bizStep": "https://ref.gs1.org/cbv/BizStep-assembling",
- "bizLocation": {"id": "urn:epc:id:sgln:0614141.00002.0"}
- }]
- }
+ "type": "ObjectEvent",
+ "eventTime": "2024-03-01T10:00:00.000Z",
+ "epcList": ["urn:epc:id:sgtin:4012345.011111.1001"],
+ "action": "OBSERVE",
+ "bizStep": "https://ref.gs1.org/cbv/BizStep-inspecting",
+
+ "mycompany:inspectorId": "EMP-12345",
+ "mycompany:testEquipment": "MACHINE-QC-03",
+ "mycompany:qualityScore": 98.5,
+ "mycompany:testDurationSeconds": 120
}
```
-### Aggregation Event (Packing)
+---
-```json
-{
- "@context": {
- "@vocab": "https://gs1.github.io/EPCIS/",
- "epcis": "https://gs1.github.io/EPCIS/",
- "cbv": "https://ref.gs1.org/cbv/",
- "type": "@type",
- "id": "@id",
- "epcisBody": "epcis:epcisBody",
- "eventList": "epcis:eventList"
- },
- "type": "EPCISDocument",
- "schemaVersion": "2.0",
- "creationDate": "2024-01-01T00:00:00Z",
- "epcisBody": {
- "eventList": [{
- "type": "AggregationEvent",
- "eventTime": "2024-01-01T14:00:00.000Z",
- "eventTimeZoneOffset": "+00:00",
- "parentID": "urn:epc:id:sscc:0614141.0000000001",
- "childEPCs": [
- "urn:epc:id:sgtin:0614141.107346.001",
- "urn:epc:id:sgtin:0614141.107346.002",
- "urn:epc:id:sgtin:0614141.107346.003"
- ],
- "action": "ADD",
- "bizStep": "https://ref.gs1.org/cbv/BizStep-packing",
- "bizLocation": {"id": "urn:epc:id:sgln:0614141.00001.0"}
- }]
- }
-}
-```
+## References
-### Object Event with Sensor Data
-
-```json
-{
- "@context": {
- "@vocab": "https://gs1.github.io/EPCIS/",
- "epcis": "https://gs1.github.io/EPCIS/",
- "cbv": "https://ref.gs1.org/cbv/",
- "type": "@type",
- "id": "@id",
- "epcisBody": "epcis:epcisBody",
- "eventList": "epcis:eventList"
- },
- "type": "EPCISDocument",
- "schemaVersion": "2.0",
- "creationDate": "2024-01-01T00:00:00Z",
- "epcisBody": {
- "eventList": [{
- "type": "ObjectEvent",
- "eventTime": "2024-01-01T08:00:00.000Z",
- "eventTimeZoneOffset": "+00:00",
- "epcList": ["urn:epc:id:sgtin:0614141.107346.2017"],
- "action": "OBSERVE",
- "bizStep": "https://ref.gs1.org/cbv/BizStep-inspecting",
- "disposition": "https://ref.gs1.org/cbv/Disp-conformant",
- "readPoint": {"id": "urn:epc:id:sgln:0614141.00001.0"},
- "bizLocation": {"id": "urn:epc:id:sgln:0614141.00001.0"},
- "sensorElementList": [
- {
- "sensorReport": [
- {
- "type": "https://gs1.org/voc/MeasurementType-Temperature",
- "time": "2024-01-01T08:00:00.000Z",
- "value": 23.5,
- "uom": "CEL"
- }
- ]
- }
- ]
- }]
- }
-}
-```
+- [EPCIS 2.0 Standard](https://ref.gs1.org/standards/epcis/)
+- [Core Business Vocabulary (CBV) 2.0](https://ref.gs1.org/standards/cbv/)
+- [GS1 Digital Link](https://www.gs1.org/standards/gs1-digital-link)
+- [JSON-LD 1.1 Specification](https://www.w3.org/TR/json-ld11/)
+- [OriginTrail DKG Documentation](https://docs.origintrail.io/)
---
-*Last updated: January 2026*
-*For API details, see the interactive [Swagger documentation](/swagger)*
-
+_Last updated: February 2026_
+_For API details, see the interactive [Swagger documentation](/swagger)_
diff --git a/packages/plugin-epcis/package.json b/packages/plugin-epcis/package.json
index 8b9abc8b..c51fb009 100644
--- a/packages/plugin-epcis/package.json
+++ b/packages/plugin-epcis/package.json
@@ -10,7 +10,7 @@
"build": "tsup src/*.ts --format cjs,esm --dts",
"check-types": "tsc --noEmit",
"lint": "eslint . --max-warnings 0",
- "test": "mocha --loader ../../node_modules/tsx/dist/loader.mjs 'tests/**/*.spec.ts'"
+ "test": "tsx ../../node_modules/mocha/bin/mocha.js 'tests/**/*.spec.ts'"
},
"dependencies": {
"@dkg/plugin-swagger": "^0.0.2",
@@ -21,6 +21,7 @@
"devDependencies": {
"@dkg/eslint-config": "*",
"@dkg/typescript-config": "*",
+ "supertest": "^7.2.2",
"tsup": "^8.5.0"
}
}
diff --git a/packages/plugin-epcis/src/index.ts b/packages/plugin-epcis/src/index.ts
index 8c41c202..0ebff007 100644
--- a/packages/plugin-epcis/src/index.ts
+++ b/packages/plugin-epcis/src/index.ts
@@ -1,123 +1,341 @@
import { defineDkgPlugin } from "@dkg/plugins";
import { openAPIRoute, z } from "@dkg/plugin-swagger";
-import { EpcisValidationService } from "./services/EPCISValidationService";
-import { EpcisQueryService } from "./services/EPCISQueryService";
-import type { CaptureResponse } from "./model/types";
-
-// Timeout for internal publisher requests (30s for POST, 5s for GET)
-const PUBLISHER_POST_TIMEOUT_MS = 10000;
-const PUBLISHER_GET_TIMEOUT_MS = 5000;
-
-// Helper function to send JSON-LD to publisher
-async function sendToPublisher(
- jsonLd: any,
- metadata?: { source?: string; sourceId?: string },
- publishOptions?: {
- privacy?: "private" | "public";
- epochs?: number;
+import type { EpcisQueryParams, ValidationResult } from "./model/types";
+import { EpcisQueryService } from "./services/epcisQueryService";
+import {
+ fetchPublisherCaptureStatus,
+ isTimeoutError,
+ sendToPublisher,
+} from "./services/epcisPublisherService";
+import { EpcisValidationService } from "./services/epcisValidationService";
+import {
+ hasAtLeastOneEpcisFilter,
+ hasValidEpcisDateRange,
+ optionalDateTimeQueryString,
+ optionalIntegerInputParam,
+ optionalIntegerQueryParam,
+ optionalNonEmptyQueryString,
+ requiredNonEmptyString,
+} from "./utils/epcisQueryValidation";
+import { formatSourceKAs } from "./utils/sourceKa";
+
+const QUERY_LIMIT = {
+ MIN: 1,
+ MAX: 1000,
+ DEFAULT: 100,
+};
+
+const QUERY_OFFSET = {
+ MIN: 0,
+ DEFAULT: 0,
+};
+
+const QUERY_LIMIT_ERROR = `Parameter 'limit' must be an integer between ${QUERY_LIMIT.MIN} and ${QUERY_LIMIT.MAX}`;
+const QUERY_OFFSET_ERROR = `Parameter 'offset' must be an integer bigger than ${QUERY_OFFSET.MIN}`;
+const CAPTURE_PUBLISH_ERROR = {
+ error: "Something went wrong with publishing the EPCIS document.",
+ message:
+ "Something went wrong with publishing the EPCIS document. Check if the publisher service is available.",
+};
+const CAPTURE_ID_PATTERN = /^[0-9]{1,20}$/;
+
+type CaptureResponse = {
+ status: string;
+ requestId: string;
+ receivedAt: string;
+ captureID: string;
+ eventCount: number;
+ UAL?: string;
+};
+
+type CaptureStatusResponse = {
+ status: string;
+ captureID: string;
+ UAL?: string;
+ publishedAt?: string;
+ error?: string;
+};
+
+type PublishOptions = {
+ privacy?: "private" | "public";
+ epochs?: number;
+};
+
+type PublisherCaptureStatusResponse = {
+ status: string;
+ ual?: string;
+ publishedAt?: string;
+ lastError?: string;
+};
+
+class CaptureValidationError extends Error {
+ constructor(
+ readonly payload: {
+ error: string;
+ details?: string[];
+ message?: string;
+ },
+ ) {
+ super(payload.error);
+ this.name = "CaptureValidationError";
}
-): Promise<{ id: number; status: string; attemptCount: number }> {
- const publisherUrl = process.env.PUBLISHER_URL || "http://localhost:9200";
-
- try {
- const response = await fetch(`${publisherUrl}/api/dkg/assets`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- content: jsonLd,
- metadata: metadata || { source: "EPCIS" },
- publishOptions: {
- privacy: publishOptions?.privacy ?? "private",
- epochs: publishOptions?.epochs ?? 12,
- },
- }),
- signal: AbortSignal.timeout(PUBLISHER_POST_TIMEOUT_MS),
- });
+}
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.error || "Publisher request failed");
- }
+class CapturePublishError extends Error {
+ constructor() {
+ super("Something went wrong with publishing the EPCIS document.");
+ this.name = "CapturePublishError";
+ }
+}
- return response.json();
- } catch (error: any) {
- if (error.name === "TimeoutError") {
- throw new Error("Publisher request timed out");
- }
- throw error;
+type McpTextContent = { type: "text"; text: string };
+
+function toMcpText(payload: unknown): McpTextContent {
+ return { type: "text", text: JSON.stringify(payload, null, 2) };
+}
+
+function mcpSuccess(
+ payload: unknown,
+ extraContent: McpTextContent[] = [],
+): { content: McpTextContent[] } {
+ return { content: [toMcpText(payload), ...extraContent] };
+}
+
+function mcpError(payload: unknown): {
+ content: McpTextContent[];
+ isError: true;
+} {
+ return {
+ content: [toMcpText(payload)],
+ isError: true,
+ };
+}
+
+function generateRequestId(): string {
+ return `epcis-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+function buildTrackItemSummary(epc: string, events: any[]): string {
+ const eventCount = events.length;
+ let summary = `Tracking: ${epc}\n`;
+ summary += `Found ${eventCount} event(s) in the supply chain.\n\n`;
+
+ if (eventCount === 0) {
+ return summary;
}
+
+ summary += "Journey Timeline:\n";
+ events.forEach((event: any, idx: number) => {
+ const time = event.eventTime || "Unknown time";
+ const step =
+ event.bizStep?.split("-").pop() ||
+ event.eventType?.split("/").pop() ||
+ "Unknown";
+ const location = event.bizLocation || event.readPoint || "Unknown location";
+ summary += `${idx + 1}. [${time}] ${step} @ ${location}\n`;
+ });
+
+ return summary;
}
-export default defineDkgPlugin((ctx, mcp, api) => {
+function getCaptureValidationError(
+ validation: ValidationResult,
+): { error: string; details?: string[]; message?: string } | null {
+ if (!validation.valid) {
+ return {
+ error: "Invalid EPCISDocument",
+ details: validation.errors,
+ };
+ }
+
+ if ((validation.eventCount ?? 0) < 1) {
+ return {
+ error: "EPCISDocument contains no events",
+ message:
+ "The EPCISDocument contains no events to publish. Please check the document and try again.",
+ };
+ }
+ return null;
+}
+
+export default defineDkgPlugin((ctx, mcp, api) => {
const validationService = new EpcisValidationService();
const queryService = new EpcisQueryService();
- console.log("π EPCIS Plugin loaded");
+ async function executeEpcisEventsQuery(queryParams: EpcisQueryParams) {
+ const sparqlQuery = queryService.buildQuery(queryParams);
+ console.debug("[EPCIS] Executing SPARQL query:", sparqlQuery);
+
+ const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT");
+ const resultData = results?.data ?? [];
+
+ return {
+ results,
+ resultData,
+ resultCount: resultData.length,
+ pagination: {
+ limit: Math.min(
+ queryParams.limit ?? QUERY_LIMIT.DEFAULT,
+ QUERY_LIMIT.MAX,
+ ),
+ offset: queryParams.offset ?? QUERY_OFFSET.DEFAULT,
+ },
+ };
+ }
+
+ async function executeCapture(
+ epcisDocument: object,
+ publishOptions?: PublishOptions,
+ requestId: string = generateRequestId(),
+ ): Promise {
+ const validationResult = validationService.validate(epcisDocument);
+ const validationError = getCaptureValidationError(validationResult);
+ if (validationError) {
+ throw new CaptureValidationError(validationError);
+ }
+
+ let publishResult: any;
+ try {
+ publishResult = await sendToPublisher(
+ epcisDocument,
+ { source: "EPCIS", sourceId: requestId },
+ publishOptions,
+ );
+ } catch {
+ throw new CapturePublishError();
+ }
+
+ return {
+ status: "202",
+ requestId,
+ receivedAt: new Date().toISOString(),
+ captureID: String(publishResult.id),
+ eventCount: validationResult.eventCount ?? 0,
+ };
+ }
+
+ async function parseCaptureStatus(
+ captureID: string,
+ ): Promise<
+ { notFound: true } | ({ notFound: false } & CaptureStatusResponse)
+ > {
+ const response = await fetchPublisherCaptureStatus(captureID);
+ if (!response.ok) {
+ if (response.status === 404) {
+ return { notFound: true };
+ }
+ throw new Error(`Publisher returned ${response.status}`);
+ }
+
+ const asset = (await response.json()) as PublisherCaptureStatusResponse;
+ return {
+ notFound: false,
+ status: asset.status,
+ captureID,
+ ...(asset.ual && { UAL: asset.ual }),
+ ...(asset.publishedAt && { publishedAt: asset.publishedAt }),
+ ...(asset.lastError && { error: asset.lastError }),
+ };
+ }
+
+ console.info("[EPCIS] Plugin loaded");
// MCP Tool: Query EPCIS events from DKG
mcp.registerTool(
"epcis-query",
{
title: "Query EPCIS Events",
- description:
+ description:
"Query EPCIS supply chain events from the OriginTrail DKG. " +
"Can filter by EPC (product identifier), from date to date, business step, or location. " +
"Use fullTrace=true to search across all event types (transformations, aggregations) for complete supply chain traceability.",
inputSchema: {
- epc: z.string().optional().describe("EPC identifier (e.g., urn:epc:id:sgtin:0614141.107346.2017)"),
- from: z.string().optional().describe("Query events from this date onwards, requires it to follow ISO 8601 format (e.g., 2024-01-01T00:00:00Z)"),
- to: z.string().optional().describe("Query events up to this date, requires it to follow ISO 8601 format (e.g., 2024-01-01T00:00:00Z)"),
- bizStep: z.string().optional().describe("Business step (e.g., 'receiving', 'shipping', 'assembling')"),
- bizLocation: z.string().optional().describe("Business location URI"),
- fullTrace: z.boolean().optional().describe("If true, search all EPC fields for full traceability"),
+ epc: optionalNonEmptyQueryString("epc").describe(
+ "EPC identifier (e.g., urn:epc:id:sgtin:0614141.107346.2017)",
+ ),
+ from: optionalDateTimeQueryString("from").describe(
+ "Query events from this date onwards, requires it to follow ISO 8601 format (e.g., 2024-01-01T00:00:00Z)",
+ ),
+ to: optionalDateTimeQueryString("to").describe(
+ "Query events up to this date, requires it to follow ISO 8601 format (e.g., 2024-01-01T00:00:00Z)",
+ ),
+ bizStep: optionalNonEmptyQueryString("bizStep").describe(
+ "Business step (e.g., 'receiving', 'shipping', 'assembling')",
+ ),
+ bizLocation: optionalNonEmptyQueryString("bizLocation").describe(
+ "Business location URI",
+ ),
+ fullTrace: z
+ .boolean()
+ .optional()
+ .describe("If true, search all EPC fields for full traceability"),
+ parentID: optionalNonEmptyQueryString("parentID").describe(
+ "Parent ID for AggregationEvent queries",
+ ),
+ childEPC: optionalNonEmptyQueryString("childEPC").describe(
+ "Child EPC for AggregationEvent queries",
+ ),
+ inputEPC: optionalNonEmptyQueryString("inputEPC").describe(
+ "Input EPC for TransformationEvent queries",
+ ),
+ outputEPC: optionalNonEmptyQueryString("outputEPC").describe(
+ "Output EPC for TransformationEvent queries",
+ ),
+ limit: optionalIntegerInputParam({
+ min: QUERY_LIMIT.MIN,
+ max: QUERY_LIMIT.MAX,
+ errorMessage: QUERY_LIMIT_ERROR,
+ }).describe(
+ `Number of results per page (default: ${QUERY_LIMIT.DEFAULT}, max: ${QUERY_LIMIT.MAX})`,
+ ),
+ offset: optionalIntegerInputParam({
+ min: QUERY_OFFSET.MIN,
+ errorMessage: QUERY_OFFSET_ERROR,
+ }).describe(
+ `Number of results to skip for pagination (default: ${QUERY_OFFSET.DEFAULT})`,
+ ),
},
},
async (input) => {
try {
- const sparqlQuery = queryService.buildQuery({
- epc: input.epc,
- from: input.from,
- to: input.to,
- bizStep: input.bizStep,
- bizLocation: input.bizLocation,
- fullTrace: input.fullTrace,
- });
+ if (!hasAtLeastOneEpcisFilter(input)) {
+ return mcpError({
+ error: "At least one filter parameter is required.",
+ });
+ }
- const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT");
+ if (!hasValidEpcisDateRange(input)) {
+ return mcpError({
+ error: "Parameter 'to' must be greater than or equal to 'from'.",
+ });
+ }
+
+ const { resultData, resultCount, pagination } =
+ await executeEpcisEventsQuery(input);
- const summary = results?.length
- ? `Found ${results.length} EPCIS event(s)`
+ const summary = resultCount
+ ? `Found ${resultCount} EPCIS event(s)`
: "No events found matching the criteria";
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify({
- summary,
- count: results?.data.length || 0,
- events: results || [],
- //query: sparqlQuery,
- }, null, 2)
- }
- ],
- };
+ const sourceKAs = formatSourceKAs(resultData);
+ return mcpSuccess(
+ {
+ summary,
+ count: resultCount,
+ events: resultData || [],
+ pagination: {
+ limit: pagination.limit,
+ offset: pagination.offset,
+ },
+ },
+ sourceKAs ? [sourceKAs] : [],
+ );
} catch (error: any) {
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify({
- error: "Query failed",
- message: error.message,
- }, null, 2)
- }
- ],
- isError: true,
- };
+ console.error("[EPCIS] DKG query failed:", error);
+ return mcpError({ error: "Query failed" });
}
- }
+ },
);
// MCP Tool: Track item journey (full traceability)
@@ -125,65 +343,139 @@ export default defineDkgPlugin((ctx, mcp, api) => {
"epcis-track-item",
{
title: "Track Item Journey",
- description:
+ description:
"Track a single item's complete journey through the supply chain. " +
"Finds all events where this EPC appears - as observed item, transformation input/output, or in aggregations. " +
"Returns events in chronological order showing the item's full lifecycle.",
inputSchema: {
- epc: z.string().describe("The EPC to track (e.g., urn:epc:id:sgtin:0614141.107346.2017)"),
+ epc: requiredNonEmptyString("epc").describe(
+ "The EPC to track (e.g., urn:epc:id:sgtin:0614141.107346.2017)",
+ ),
},
},
async (input) => {
try {
- const sparqlQuery = queryService.buildQuery({
+ const { resultData, resultCount } = await executeEpcisEventsQuery({
epc: input.epc,
- fullTrace: true, // Always use full traceability for item tracking
+ fullTrace: true, // Always use full traceability for item tracking
+ });
+
+ const summary = buildTrackItemSummary(input.epc, resultData);
+
+ const sourceKAs = formatSourceKAs(resultData);
+ return mcpSuccess(
+ {
+ summary,
+ epc: input.epc,
+ eventCount: resultCount,
+ events: resultData || [],
+ },
+ sourceKAs ? [sourceKAs] : [],
+ );
+ } catch (error: any) {
+ console.error(`[EPCIS] Item tracking failed, epc: ${input.epc}`, error);
+ return mcpError({ error: "Tracking failed" });
+ }
+ },
+ );
+
+ // MCP Tool: Capture EPCISDocument and queue for publishing
+ mcp.registerTool(
+ "epcis-capture",
+ {
+ title: "Capture EPCIS Document",
+ description:
+ "Validate an EPCISDocument and queue it for publishing to the DKG.",
+ inputSchema: {
+ epcisDocument: z.object({}).passthrough(),
+ publishOptions: z
+ .object({
+ privacy: z.enum(["private", "public"]).optional(),
+ epochs: z.number().min(1).optional(),
+ })
+ .optional(),
+ },
+ },
+ async (input) => {
+ try {
+ const response = await executeCapture(
+ input.epcisDocument,
+ input.publishOptions,
+ );
+ return mcpSuccess({
+ captureID: response.captureID,
+ requestId: response.requestId,
+ receivedAt: response.receivedAt,
+ eventCount: response.eventCount,
});
+ } catch (error: unknown) {
+ if (error instanceof CaptureValidationError) {
+ return mcpError(error.payload);
+ }
- const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT");
+ if (error instanceof CapturePublishError) {
+ return mcpError(CAPTURE_PUBLISH_ERROR);
+ }
- const eventCount = results?.length || 0;
- let summary = `Tracking: ${input.epc}\n`;
- summary += `Found ${eventCount} event(s) in the supply chain.\n\n`;
+ console.error("[EPCIS] MCP capture failed:", error);
+ return mcpError({ error: "Capture failed" });
+ }
+ },
+ );
- if (eventCount > 0) {
- summary += "Journey Timeline:\n";
- results.forEach((event: any, idx: number) => {
- const time = event.eventTime || "Unknown time";
- const step = event.bizStep?.split("-").pop() || event.eventType?.split("/").pop() || "Unknown";
- const location = event.bizLocation || event.readPoint || "Unknown location";
- summary += `${idx + 1}. [${time}] ${step} @ ${location}\n`;
+ // MCP Tool: Check publisher-tracked status by capture ID
+ mcp.registerTool(
+ "epcis-capture-status",
+ {
+ title: "Get Capture Status",
+ description: "Check publisher-tracked status for a capture request.",
+ inputSchema: {
+ captureID: z
+ .string()
+ .regex(CAPTURE_ID_PATTERN, { message: "Invalid captureID format" })
+ .describe(
+ "Numeric publisher capture ID returned from epcis-capture or POST /epcis/capture",
+ ),
+ },
+ },
+ async (input) => {
+ try {
+ const captureStatus = await parseCaptureStatus(input.captureID);
+ if (captureStatus.notFound) {
+ return mcpError({
+ error: "Capture not found",
+ captureID: input.captureID,
});
}
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify({
- summary,
- epc: input.epc,
- eventCount,
- events: results || [],
- }, null, 2)
- }
- ],
- };
- } catch (error: any) {
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify({
- error: "Tracking failed",
- message: error.message,
- }, null, 2)
- }
- ],
- isError: true,
+ const payload: CaptureStatusResponse = {
+ status: captureStatus.status,
+ captureID: captureStatus.captureID,
+ ...(captureStatus.UAL && { UAL: captureStatus.UAL }),
+ ...(captureStatus.publishedAt && {
+ publishedAt: captureStatus.publishedAt,
+ }),
+ ...(captureStatus.error && { error: captureStatus.error }),
};
+ return mcpSuccess(payload);
+ } catch (error: unknown) {
+ if (isTimeoutError(error)) {
+ return mcpError({
+ error: "Publisher timeout",
+ captureID: input.captureID,
+ });
+ }
+
+ console.error(
+ `[EPCIS] MCP capture status failed, captureID: ${input.captureID}`,
+ error,
+ );
+ return mcpError({
+ error: "Failed to get capture status",
+ captureID: input.captureID,
+ });
}
- }
+ },
);
// POST /epcis/capture - Accept EPCISDocument and queue for publishing
@@ -193,26 +485,32 @@ export default defineDkgPlugin((ctx, mcp, api) => {
{
tag: "EPCIS",
summary: "Capture EPCIS Document",
- description: "Accept an EPCISDocument and queue it for publishing to DKG",
+ description:
+ "Accept an EPCISDocument and queue it for publishing to DKG",
body: z.object({
epcisDocument: z.object({}).passthrough().openapi({
description: "The EPCISDocument (JSON-LD)",
}),
- publishOptions: z.object({
- privacy: z.enum(["private", "public"]).optional().openapi({
- description: "Asset visibility (default: private)",
- }),
- epochs: z.number().min(1).optional().openapi({
- description: "Number of epochs to publish for (default: 12)",
+ publishOptions: z
+ .object({
+ privacy: z.enum(["private", "public"]).optional().openapi({
+ description: "Asset visibility (default: private)",
+ }),
+ epochs: z.number().min(1).optional().openapi({
+ description: "Number of epochs to publish for (default: 12)",
+ }),
+ })
+ .optional()
+ .openapi({
+ description:
+ "Publishing options (all optional with sensible defaults)",
}),
- }).optional().openapi({
- description: "Publishing options (all optional with sensible defaults)",
- }),
}),
response: {
- description: "Capture accepted",
+ description: "Capture accepted (202)",
schema: z.object({
status: z.string(),
+ requestId: z.string(),
receivedAt: z.string(),
captureID: z.string(),
eventCount: z.number(),
@@ -220,47 +518,48 @@ export default defineDkgPlugin((ctx, mcp, api) => {
},
},
async (req, res) => {
+ const requestId = generateRequestId();
+ console.info(
+ `[EPCIS] Capture request received, requestId: ${requestId}`,
+ );
+
try {
const { epcisDocument, publishOptions } = req.body;
+ const response = await executeCapture(
+ epcisDocument,
+ publishOptions,
+ requestId,
+ );
+ console.info(
+ `[EPCIS] Document queued via publisher, requestId: ${response.requestId}, eventCount: ${response.eventCount}, captureID: ${response.captureID}`,
+ );
- // Validate the EPCIS document
- const validation = validationService.validate(epcisDocument);
+ return res.status(202).json(response);
+ } catch (error: unknown) {
+ if (error instanceof CaptureValidationError) {
+ console.warn(
+ `[EPCIS] Capture validation failed, requestId: ${requestId}`,
+ );
+ return res.status(400).json(error.payload as any);
+ }
- if (!validation.valid) {
- return res.status(400).json({
- error: "Invalid EPCISDocument",
- details: validation.errors,
- } as any);
+ if (error instanceof CapturePublishError) {
+ console.error(`[EPCIS] Publishing failed, requestId: ${requestId}`);
+ return res.status(500).json(CAPTURE_PUBLISH_ERROR as any);
}
- // Send to publisher with user-provided options (or defaults)
- const result = await sendToPublisher(
- epcisDocument,
- {
- source: "EPCIS",
- sourceId: `epcis-${Date.now()}`,
- },
- publishOptions
+ console.error(
+ `[EPCIS] Unexpected error while processing capture request, requestId: ${requestId}:`,
+ error,
);
-
- // Return capture response
- const response: CaptureResponse = {
- status: "202",
- receivedAt: new Date().toISOString(),
- captureID: String(result.id),
- eventCount: validation.eventCount || 0,
- };
-
- res.status(202).json(response);
- } catch (error: any) {
- console.error("[EPCIS Capture] Error:", error);
- res.status(500).json({
- error: "Failed to process capture",
- //message: error.message,
+ return res.status(500).json({
+ error: "Something went wrong with processing the EPCIS document.",
+ message:
+ "An unexpected error occurred while processing the EPCIS document.",
} as any);
}
- }
- )
+ },
+ ),
);
// GET /epcis/capture/:captureID - Check capture status
@@ -270,12 +569,16 @@ export default defineDkgPlugin((ctx, mcp, api) => {
{
tag: "EPCIS",
summary: "Get Capture Status",
- description: "Check the status of an EPCIS capture by captureID",
+ description: "Check publisher-tracked status by numeric captureID.",
params: z.object({
- captureID: z.string().openapi({
- description: "The capture ID returned from POST /epcis/capture",
- example: "123",
- }),
+ captureID: z
+ .string()
+ .regex(CAPTURE_ID_PATTERN, { message: "Invalid captureID format" })
+ .openapi({
+ description:
+ "Numeric publisher capture ID returned from POST /epcis/capture",
+ example: "123",
+ }),
}),
response: {
description: "Capture status",
@@ -289,63 +592,52 @@ export default defineDkgPlugin((ctx, mcp, api) => {
},
},
async (req, res) => {
+ const { captureID } = req.params;
+ console.info(
+ `[EPCIS] Capture status request received, captureID: ${captureID}`,
+ );
+
try {
- const { captureID } = req.params;
- const publisherUrl = process.env.PUBLISHER_URL || "http://localhost:9200";
+ const captureStatus = await parseCaptureStatus(captureID);
+ if (captureStatus.notFound) {
+ return res
+ .status(404)
+ .json({ error: "Capture not found", captureID } as any);
+ }
- const captureIdPattern = /^[0-9]{1,20}$/;
- if (!captureIdPattern.test(captureID)) {
- return res.status(400).json({
- error: "Invalid captureID format",
+ const payload: CaptureStatusResponse = {
+ status: captureStatus.status,
+ captureID: captureStatus.captureID,
+ ...(captureStatus.UAL && { UAL: captureStatus.UAL }),
+ ...(captureStatus.publishedAt && {
+ publishedAt: captureStatus.publishedAt,
+ }),
+ ...(captureStatus.error && { error: captureStatus.error }),
+ };
+ return res.json(payload);
+ } catch (error: unknown) {
+ if (isTimeoutError(error)) {
+ return res.status(504).json({
+ error: "Publisher timeout",
captureID,
} as any);
}
- // Query publisher for asset status
- let response: Response;
- try {
- response = await fetch(
- `${publisherUrl}/api/dkg/assets/status/${encodeURIComponent(captureID)}`,
- { signal: AbortSignal.timeout(PUBLISHER_GET_TIMEOUT_MS) }
- );
- } catch (error: any) {
- if (error.name === "TimeoutError") {
- return res.status(504).json({
- error: "Publisher timeout",
- captureID,
- } as any);
- }
- throw error;
- }
-
- if (!response.ok) {
- if (response.status === 404) {
- return res.status(404).json({ error: "Capture not found", captureID } as any);
- }
- throw new Error("Failed to fetch capture status");
- }
-
- const asset = await response.json();
- // Map publisher status to EPCIS response
- const result: any = {
- status: asset.status,
+ const errorName =
+ error instanceof Error ? error.name : "UnknownError";
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ console.error("[EPCIS] Capture status request failed", {
captureID,
- };
-
- if (asset.ual) result.UAL = asset.ual;
- if (asset.publishedAt) result.publishedAt = asset.publishedAt;
- if (asset.lastError) result.error = asset.lastError;
-
- res.json(result);
- } catch (error: any) {
- console.error("[EPCIS Status] Error:", error);
- res.status(500).json({
+ errorName,
+ errorMessage,
+ });
+ return res.status(500).json({
error: "Failed to get capture status",
- //message: error.message,
} as any);
}
- }
- )
+ },
+ ),
);
// GET /epcis/events - Query EPCIS events from DKG
@@ -356,81 +648,160 @@ export default defineDkgPlugin((ctx, mcp, api) => {
tag: "EPCIS",
summary: "Query EPCIS Events",
description: "Query EPCIS events from DKG using various filters",
- query: z.object({
- epc: z.string().optional().openapi({
- description: "Filter by EPC (product identifier)",
- example: "urn:epc:id:sgtin:0614141.107346.2017",
- }),
- from: z.string().datetime({ message: "Must be ISO 8601 format (e.g., 2024-01-01T00:00:00Z)" }).optional().openapi({
- description: "Start of time range (ISO 8601)",
- example: "2024-01-01T00:00:00Z",
- }),
- to: z.string().datetime({ message: "Must be ISO 8601 format (e.g., 2024-12-31T23:59:59Z)" }).optional().openapi({
- description: "End of time range (ISO 8601)",
- example: "2024-12-31T23:59:59Z",
- }),
- bizStep: z.string().optional().openapi({
- description: "Filter by business step URI",
- example: "https://ref.gs1.org/cbv/BizStep-assembling",
+ query: z
+ .object({
+ epc: optionalNonEmptyQueryString("epc").openapi({
+ description: "Filter by EPC (product identifier)",
+ example: "urn:epc:id:sgtin:0614141.107346.2017",
+ }),
+ from: optionalDateTimeQueryString("from").openapi({
+ description: "Start of time range (ISO 8601)",
+ example: "2024-01-01T00:00:00Z",
+ }),
+ to: optionalDateTimeQueryString("to").openapi({
+ description: "End of time range (ISO 8601)",
+ example: "2024-12-31T23:59:59Z",
+ }),
+ bizStep: optionalNonEmptyQueryString("bizStep").openapi({
+ description: "Filter by business step URI",
+ example: "https://ref.gs1.org/cbv/BizStep-assembling",
+ }),
+ bizLocation: optionalNonEmptyQueryString("bizLocation").openapi({
+ description: "Filter by business location",
+ example: "urn:epc:id:sgln:0614141.00001.0",
+ }),
+ fullTrace: z
+ .enum(["true", "false"])
+ .transform((v) => v === "true")
+ .optional()
+ .openapi({
+ description:
+ "If 'true', search all EPC fields for full supply chain traceability",
+ example: "true",
+ }),
+ parentID: optionalNonEmptyQueryString("parentID").openapi({
+ description: "Filter by parent ID (AggregationEvent)",
+ example: "urn:epc:id:sscc:0614141.0000000001",
+ }),
+ childEPC: optionalNonEmptyQueryString("childEPC").openapi({
+ description: "Filter by child EPC (AggregationEvent)",
+ example: "urn:epc:id:sgtin:0614141.107346.2017",
+ }),
+ inputEPC: optionalNonEmptyQueryString("inputEPC").openapi({
+ description: "Filter by input EPC (TransformationEvent)",
+ example: "urn:epc:id:sgtin:0614141.107346.2017",
+ }),
+ outputEPC: optionalNonEmptyQueryString("outputEPC").openapi({
+ description: "Filter by output EPC (TransformationEvent)",
+ example: "urn:epc:id:sgtin:0614141.099999.9001",
+ }),
+ limit: optionalIntegerQueryParam({
+ min: 1,
+ max: 1000,
+ errorMessage: QUERY_LIMIT_ERROR,
+ }).openapi({
+ description: `Number of results per page (default: ${QUERY_LIMIT.DEFAULT}, max: ${QUERY_LIMIT.MAX})`,
+ example: "50",
+ }),
+ offset: optionalIntegerQueryParam({
+ min: 0,
+ errorMessage: QUERY_OFFSET_ERROR,
+ }).openapi({
+ description: `Number of results to skip for pagination (default: ${QUERY_OFFSET.DEFAULT})`,
+ example: "0",
+ }),
+ })
+ .refine(hasAtLeastOneEpcisFilter, {
+ message: "At least one filter parameter is required.",
+ })
+ .refine(hasValidEpcisDateRange, {
+ path: ["to"],
+ message: "Parameter 'to' must be greater than or equal to 'from'.",
}),
- bizLocation: z.string().optional().openapi({
- description: "Filter by business location",
- example: "urn:epc:id:sgln:0614141.00001.0",
+ response: {
+ description: "Query results",
+ schema: z.object({
+ success: z.boolean(),
+ results: z.array(z.any()),
+ count: z.number(),
+ pagination: z.object({
+ limit: z.number(),
+ offset: z.number(),
+ }),
}),
- /*ual: z.string().optional().openapi({
- description: "Get event by specific UAL",
- }),*/
- fullTrace: z.string().optional().openapi({
- description: "If 'true', search all EPC fields (epcList, inputEPCList, outputEPCList, childEPCs, parentID) for full supply chain traceability",
- example: "true",
+ },
+ },
+ async (req, res) => {
+ console.info("[EPCIS] Events query received");
+ try {
+ const { resultData, resultCount, pagination } =
+ await executeEpcisEventsQuery(req.query);
+
+ res.json({
+ success: true,
+ results: resultData,
+ count: resultCount,
+ pagination: {
+ limit: pagination.limit,
+ offset: pagination.offset,
+ },
+ });
+ } catch (error: any) {
+ console.error("[EPCIS] Events query failed:", error);
+ res.status(500).json({
+ success: false,
+ error: "Failed to query events",
+ } as any);
+ }
+ },
+ ),
+ );
+
+ // GET /epcis/events/track - Track single EPC across full trace
+ api.get(
+ "/epcis/events/track",
+ openAPIRoute(
+ {
+ tag: "EPCIS",
+ summary: "Track Item Journey",
+ description:
+ "Track a single EPC across all event types using full traceability.",
+ query: z.object({
+ epc: requiredNonEmptyString("epc").openapi({
+ description: "EPC identifier to track",
+ example: "urn:epc:id:sgtin:0614141.107346.2017",
}),
}),
response: {
- description: "Query results",
+ description: "Tracking query results",
schema: z.object({
success: z.boolean(),
- query: z.string().optional(),
results: z.array(z.any()),
count: z.number(),
}),
},
},
async (req, res) => {
+ console.info("[EPCIS] Track item query received");
try {
- const { epc, from, to, bizStep, bizLocation, /*ual,*/ fullTrace } = req.query;
-
- // Build the SPARQL query based on parameters
- const sparqlQuery = queryService.buildQuery({
- epc: epc as string,
- from: from as string,
- to: to as string,
- bizStep: bizStep as string,
- bizLocation: bizLocation as string,
- //ual: ual as string,
- fullTrace: fullTrace === 'true',
+ const { resultData, resultCount } = await executeEpcisEventsQuery({
+ epc: req.query.epc,
+ fullTrace: true,
});
- console.log("[EPCIS Events] Executing SPARQL query:", sparqlQuery);
-
- // Execute query against DKG
- const results = await ctx.dkg.graph.query(sparqlQuery, "SELECT");
-
res.json({
success: true,
- //query: sparqlQuery,
- results: results || [],
- count: results?.length || 0,
+ results: resultData,
+ count: resultCount,
});
} catch (error: any) {
- console.error("[EPCIS Events] Query error:", error);
+ console.error("[EPCIS] Track query failed:", error);
res.status(500).json({
success: false,
error: "Failed to query events",
- message: error.message,
} as any);
}
- }
- )
+ },
+ ),
);
-
-});
\ No newline at end of file
+});
diff --git a/packages/plugin-epcis/src/model/types.ts b/packages/plugin-epcis/src/model/types.ts
index ccf4ab87..fec13b3f 100644
--- a/packages/plugin-epcis/src/model/types.ts
+++ b/packages/plugin-epcis/src/model/types.ts
@@ -1,50 +1,56 @@
// EPCIS Document types based on GS1 EPCIS 2.0
export interface EPCISDocument {
- "@context": string | string[] | Record;
- type: "EPCISDocument";
- schemaVersion: string;
- creationDate: string;
- epcisBody?: {
- eventList: EPCISEvent[];
- };
- eventList?: EPCISEvent[];
- [key: string]: any;
- }
-
- export interface EPCISEvent {
- type: string;
- eventTime: string;
- eventTimeZoneOffset?: string;
- epcList?: string[];
- action?: string;
- bizStep?: string;
- disposition?: string;
- readPoint?: { id: string };
- bizLocation?: { id: string };
- bizTransactionList?: Array<{ type: string; bizTransaction: string }>;
- sensorElementList?: any[];
- [key: string]: any;
- }
-
- // API Response types
- export interface CaptureResponse {
- status: string;
- receivedAt: string;
- captureID: string;
- eventCount: number;
- }
-
- export interface CaptureStatusResponse {
- status: "pending" | "queued" | "assigned" | "publishing" | "published" | "failed";
- UAL?: string;
- eventCount?: number;
- error?: string;
- publishedAt?: string | null;
- }
-
- // Validation result type
- export interface ValidationResult {
- valid: boolean;
- errors?: string[];
- eventCount?: number;
- }
\ No newline at end of file
+ "@context": string | string[] | Record;
+ type: "EPCISDocument";
+ schemaVersion: string;
+ creationDate: string;
+ epcisBody?: {
+ eventList: EPCISEvent[];
+ };
+ eventList?: EPCISEvent[];
+ [key: string]: any;
+}
+
+export interface EPCISEvent {
+ type: string;
+ eventTime: string;
+ eventTimeZoneOffset?: string;
+ epcList?: string[];
+ action?: string;
+ bizStep?: string;
+ disposition?: string;
+ readPoint?: { id: string };
+ bizLocation?: { id: string };
+ bizTransactionList?: Array<{ type: string; bizTransaction: string }>;
+ sensorElementList?: any[];
+ [key: string]: any;
+}
+
+// Validation result type
+export interface ValidationResult {
+ valid: boolean;
+ errors?: string[];
+ eventCount?: number;
+}
+
+export interface EpcisQueryParams {
+ epc?: string;
+ from?: string;
+ to?: string;
+ bizStep?: string;
+ bizLocation?: string;
+ /** If true, searches all EPC fields (epcList, inputEPCList, outputEPCList, childEPCs, parentID) */
+ fullTrace?: boolean;
+ /** Filter by parent ID (AggregationEvent) */
+ parentID?: string;
+ /** Filter by child EPCs (AggregationEvent) */
+ childEPC?: string;
+ /** Filter by input EPCs (TransformationEvent) */
+ inputEPC?: string;
+ /** Filter by output EPCs (TransformationEvent) */
+ outputEPC?: string;
+ /** Number of results per page (default: 100, max: 1000) */
+ limit?: number;
+ /** Number of results to skip (for pagination) */
+ offset?: number;
+}
diff --git a/packages/plugin-epcis/src/services/EPCISQueryService.ts b/packages/plugin-epcis/src/services/EPCISQueryService.ts
deleted file mode 100644
index a867014d..00000000
--- a/packages/plugin-epcis/src/services/EPCISQueryService.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-/**
- * EPCIS Query Service
- * Supports composite filtering - combine multiple filters in one query
- */
-
-// Namespace prefixes for EPCIS queries
-const PREFIXES = `
-PREFIX epcis:
-PREFIX schema:
-PREFIX xsd:
-`;
-
-export interface EpcisQueryParams {
- epc?: string;
- from?: string;
- to?: string;
- bizStep?: string;
- bizLocation?: string;
- // ual?: string; // TODO: Re-enable when UAL query is implemented
- /** If true, searches all EPC fields (epcList, inputEPCList, outputEPCList, childEPCs, parentID) */
- fullTrace?: boolean;
-}
-
-/**
- * Escape special characters in SPARQL string literals
- */
-function escapeSparql(value: string): string {
- return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
-}
-
-/**
- * Normalize bizStep to full GS1 CBV URI
- * Accepts: "assembling" or "https://ref.gs1.org/cbv/BizStep-assembling"
- */
-function normalizeBizStep(value: string): string {
-
- if (typeof value !== "string" || value.length === 0) {
- throw new Error("Invalid bizStep value");
- }
-
- if (!value.includes('://')) {
- return `https://ref.gs1.org/cbv/BizStep-${value}`;
- }
- return value;
-}
-
-export class EpcisQueryService {
- /**
- * Build a composite SPARQL query supporting multiple filters
- * All provided filters are combined with AND logic
- */
- buildQuery(params: EpcisQueryParams): string {
- // Special case: UAL lookup returns all triples for that graph
- /*if (params.ual) {
- return this.getEventByUal(params.ual);
- }*/
-
- const wherePatterns: string[] = [];
- const filterClauses: string[] = [];
- const optionalClauses: string[] = [];
-
- // Base pattern - always present
- wherePatterns.push('?event a ?eventType .');
-
- // Filter by event type (must be EPCIS event)
- filterClauses.push('FILTER(STRSTARTS(STR(?eventType), "https://gs1.github.io/EPCIS/"))');
-
- // EPC filter - with optional full traceability across all EPC fields
- if (params.epc) {
- const epcValue = escapeSparql(params.epc);
- if (params.fullTrace) {
- // Search across ALL EPC fields for full supply chain traceability
- wherePatterns.push(`{
- { ?event epcis:epcList "${epcValue}" }
- UNION { ?event epcis:inputEPCList "${epcValue}" }
- UNION { ?event epcis:outputEPCList "${epcValue}" }
- UNION { ?event epcis:childEPCs "${epcValue}" }
- UNION { ?event epcis:parentID "${epcValue}" }
- }`);
- } else {
- // Default: only search epcList
- wherePatterns.push(`?event epcis:epcList "${epcValue}" .`);
- }
- } else {
- optionalClauses.push('OPTIONAL { ?event epcis:epcList ?epc . }');
- }
-
- // BizStep filter (accepts shorthand like "assembling" or full URI)
- if (params.bizStep) {
- const bizStepUri = normalizeBizStep(params.bizStep);
- wherePatterns.push('?event epcis:bizStep ?bizStep .');
- filterClauses.push(`FILTER(STR(?bizStep) = "${escapeSparql(bizStepUri)}")`);
- } else {
- optionalClauses.push('OPTIONAL { ?event epcis:bizStep ?bizStep . }');
- }
-
- // BizLocation filter
- if (params.bizLocation) {
- wherePatterns.push(`?event epcis:bizLocation "${escapeSparql(params.bizLocation)}" .`);
- } else {
- optionalClauses.push('OPTIONAL { ?event epcis:bizLocation ?bizLocation . }');
- }
-
- // Time range filter - use xsd:dateTime for proper date comparison
- if (params.from || params.to) {
- wherePatterns.push('?event epcis:eventTime ?eventTime .');
- if (params.from && params.to) {
- filterClauses.push(
- `FILTER(xsd:dateTime(?eventTime) >= xsd:dateTime("${escapeSparql(params.from)}") && xsd:dateTime(?eventTime) <= xsd:dateTime("${escapeSparql(params.to)}"))`
- );
- } else if (params.from) {
- filterClauses.push(`FILTER(xsd:dateTime(?eventTime) >= xsd:dateTime("${escapeSparql(params.from)}"))`);
- } else if (params.to) {
- filterClauses.push(`FILTER(xsd:dateTime(?eventTime) <= xsd:dateTime("${escapeSparql(params.to)}"))`);
- }
- } else {
- optionalClauses.push('OPTIONAL { ?event epcis:eventTime ?eventTime . }');
- }
-
- // Always optional fields
- optionalClauses.push('OPTIONAL { ?event epcis:disposition ?disposition . }');
- optionalClauses.push('OPTIONAL { ?event epcis:readPoint ?readPoint . }');
-
- // Assemble the query
- return `${PREFIXES}
-SELECT ?ual ?eventType ?eventTime ?epc ?bizStep ?disposition ?readPoint ?bizLocation
-WHERE {
- GRAPH ?ual {
- ${wherePatterns.join('\n ')}
- ${optionalClauses.join('\n ')}
- }
- ${filterClauses.join('\n ')}
-}
-ORDER BY DESC(?eventTime)
-LIMIT 100`;
- }
-
- /**
- * Query event by UAL (get full event details)
- */
- /*private getEventByUal(ual: string): string {
- // Basic UAL format validation
- if (!ual.startsWith('did:')) {
- throw new Error('Invalid UAL format');
- }
- return `${PREFIXES}
-SELECT ?predicate ?object
-WHERE {
- GRAPH <${escapeSparql(ual)}> {
- ?subject ?predicate ?object .
- }
-}`;
- }*/
-}
diff --git a/packages/plugin-epcis/src/services/epcisPublisherService.ts b/packages/plugin-epcis/src/services/epcisPublisherService.ts
new file mode 100644
index 00000000..437704f3
--- /dev/null
+++ b/packages/plugin-epcis/src/services/epcisPublisherService.ts
@@ -0,0 +1,105 @@
+const PUBLISHER_POST_TIMEOUT_MS = 10_000;
+const PUBLISHER_GET_TIMEOUT_MS = 5_000;
+const SEND_TO_PUBLISHER_MAX_RETRIES = 3;
+const SEND_TO_PUBLISHER_RETRY_DELAY_MS = 1_000;
+
+type AssetInput = {
+ content: object | string;
+ metadata?: {
+ source?: string;
+ sourceId?: string;
+ [key: string]: any;
+ };
+ publishOptions?: {
+ privacy?: "private" | "public";
+ epochs?: number;
+ };
+};
+
+type PublisherMetadata = {
+ source?: string;
+ sourceId?: string;
+};
+
+type PublishOptions = {
+ privacy?: "private" | "public";
+ epochs?: number;
+};
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+export async function sendToPublisher(
+ jsonLd: object | string,
+ metadata?: PublisherMetadata,
+ publishOptions?: PublishOptions,
+): Promise<{ id: number; status: string; attemptCount: number; ual?: string }> {
+ for (let attempt = 1; attempt <= SEND_TO_PUBLISHER_MAX_RETRIES; attempt++) {
+ try {
+ const publisherUrl = getPublisherEndpoint();
+ const url = `${publisherUrl}/api/dkg/assets`;
+ const payload: AssetInput = {
+ content: jsonLd,
+ metadata: metadata || { source: "EPCIS" },
+ publishOptions: {
+ privacy: publishOptions?.privacy ?? "private",
+ epochs: publishOptions?.epochs ?? 12,
+ },
+ };
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ signal: AbortSignal.timeout(PUBLISHER_POST_TIMEOUT_MS),
+ });
+
+ if (
+ !response.ok ||
+ !response.headers.get("content-type")?.includes("application/json")
+ ) {
+ throw new Error("Publisher not available");
+ }
+
+ return await response.json();
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.warn(
+ `[EPCIS] Publisher attempt ${attempt}/${SEND_TO_PUBLISHER_MAX_RETRIES} failed:`,
+ message,
+ );
+
+ if (attempt < SEND_TO_PUBLISHER_MAX_RETRIES) {
+ await delay(
+ SEND_TO_PUBLISHER_RETRY_DELAY_MS * Math.pow(2, attempt - 1),
+ );
+ continue;
+ }
+ }
+ }
+
+ throw new Error("Publisher not available");
+}
+
+export function isTimeoutError(error: unknown): boolean {
+ return error instanceof Error && error.name === "TimeoutError";
+}
+
+export async function fetchPublisherCaptureStatus(
+ captureID: string,
+): Promise {
+ const publisherUrl = getPublisherEndpoint();
+ const url = `${publisherUrl}/api/dkg/assets/status/${encodeURIComponent(captureID)}`;
+ return fetch(url, { signal: AbortSignal.timeout(PUBLISHER_GET_TIMEOUT_MS) });
+}
+
+function getPublisherEndpoint(): string {
+ const publisherUrl = process.env.EXPO_PUBLIC_MCP_URL;
+ if (!publisherUrl) {
+ throw new Error(
+ "Publisher endpoint not configured. Set EXPO_PUBLIC_MCP_URL in .env",
+ );
+ }
+ return publisherUrl;
+}
diff --git a/packages/plugin-epcis/src/services/epcisQueryService.ts b/packages/plugin-epcis/src/services/epcisQueryService.ts
new file mode 100644
index 00000000..5f2d473c
--- /dev/null
+++ b/packages/plugin-epcis/src/services/epcisQueryService.ts
@@ -0,0 +1,181 @@
+import type { EpcisQueryParams } from "../model/types";
+/**
+ * EPCIS Query Service
+ * Supports composite filtering - combine multiple filters in one query
+ */
+
+const PREFIXES = `
+PREFIX epcis:
+PREFIX schema:
+PREFIX xsd:
+`;
+
+/**
+ * Escape special characters in SPARQL string literals
+ */
+function escapeSparql(value: string): string {
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+}
+
+/**
+ * Normalize bizStep to full GS1 CBV URI
+ * Accepts: "assembling" or "https://ref.gs1.org/cbv/BizStep-assembling"
+ */
+function normalizeBizStep(value: string): string {
+ if (typeof value !== "string" || value.length === 0) {
+ throw new Error("Invalid bizStep value");
+ }
+
+ if (!value.includes("://")) {
+ return `https://ref.gs1.org/cbv/BizStep-${value}`;
+ }
+ return value;
+}
+
+export class EpcisQueryService {
+ /**
+ * Build a composite SPARQL query supporting multiple filters
+ * All provided filters are combined with AND logic
+ */
+ buildQuery(params: EpcisQueryParams): string {
+ const wherePatterns: string[] = [];
+ const filterClauses: string[] = [];
+ const optionalClauses: string[] = [];
+
+ // Base pattern - always present
+ wherePatterns.push("?event a ?eventType .");
+
+ // Filter by event type (must be EPCIS event)
+ filterClauses.push(
+ 'FILTER(STRSTARTS(STR(?eventType), "https://gs1.github.io/EPCIS/"))',
+ );
+
+ // EPC filter - with optional full traceability across all EPC fields
+ if (params.epc) {
+ const epcValue = escapeSparql(params.epc);
+ if (params.fullTrace) {
+ // Search across ALL EPC fields for full supply chain traceability
+ wherePatterns.push(`{
+ { ?event epcis:epcList "${epcValue}" }
+ UNION { ?event epcis:inputEPCList "${epcValue}" }
+ UNION { ?event epcis:outputEPCList "${epcValue}" }
+ UNION { ?event epcis:childEPCs "${epcValue}" }
+ UNION { ?event epcis:parentID "${epcValue}" }
+ }`);
+ } else {
+ // Default: only search epcList
+ wherePatterns.push(`?event epcis:epcList "${epcValue}" .`);
+ }
+ }
+ // Always bind ?epc for GROUP_CONCAT projection
+ optionalClauses.push("OPTIONAL { ?event epcis:epcList ?epc . }");
+
+ // Parent ID filter (AggregationEvent)
+ if (params.parentID) {
+ wherePatterns.push(
+ `?event epcis:parentID "${escapeSparql(params.parentID)}" .`,
+ );
+ }
+
+ // Child EPCs filter (AggregationEvent)
+ if (params.childEPC) {
+ wherePatterns.push(
+ `?event epcis:childEPCs "${escapeSparql(params.childEPC)}" .`,
+ );
+ }
+
+ // Input EPCs filter (TransformationEvent)
+ if (params.inputEPC) {
+ wherePatterns.push(
+ `?event epcis:inputEPCList "${escapeSparql(params.inputEPC)}" .`,
+ );
+ }
+
+ // Output EPCs filter (TransformationEvent)
+ if (params.outputEPC) {
+ wherePatterns.push(
+ `?event epcis:outputEPCList "${escapeSparql(params.outputEPC)}" .`,
+ );
+ }
+
+ // BizStep filter (accepts shorthand like "assembling" or full URI)
+ if (params.bizStep) {
+ const bizStepUri = normalizeBizStep(params.bizStep);
+ wherePatterns.push("?event epcis:bizStep ?bizStep .");
+ filterClauses.push(
+ `FILTER(STR(?bizStep) = "${escapeSparql(bizStepUri)}")`,
+ );
+ } else {
+ optionalClauses.push("OPTIONAL { ?event epcis:bizStep ?bizStep . }");
+ }
+
+ // BizLocation filter
+ if (params.bizLocation) {
+ wherePatterns.push(
+ `?event epcis:bizLocation "${escapeSparql(params.bizLocation)}" .`,
+ );
+ } else {
+ optionalClauses.push(
+ "OPTIONAL { ?event epcis:bizLocation ?bizLocation . }",
+ );
+ }
+
+ // Time range filter - use xsd:dateTime for proper date comparison
+ if (params.from || params.to) {
+ wherePatterns.push("?event epcis:eventTime ?eventTime .");
+ if (params.from && params.to) {
+ filterClauses.push(
+ `FILTER(xsd:dateTime(?eventTime) >= xsd:dateTime("${escapeSparql(params.from)}") && xsd:dateTime(?eventTime) <= xsd:dateTime("${escapeSparql(params.to)}"))`,
+ );
+ } else if (params.from) {
+ filterClauses.push(
+ `FILTER(xsd:dateTime(?eventTime) >= xsd:dateTime("${escapeSparql(params.from)}"))`,
+ );
+ } else if (params.to) {
+ filterClauses.push(
+ `FILTER(xsd:dateTime(?eventTime) <= xsd:dateTime("${escapeSparql(params.to)}"))`,
+ );
+ }
+ } else {
+ optionalClauses.push("OPTIONAL { ?event epcis:eventTime ?eventTime . }");
+ }
+
+ // Always optional fields
+ optionalClauses.push(
+ "OPTIONAL { ?event epcis:disposition ?disposition . }",
+ );
+ optionalClauses.push("OPTIONAL { ?event epcis:readPoint ?readPoint . }");
+ optionalClauses.push("OPTIONAL { ?event epcis:action ?action . }");
+ optionalClauses.push("OPTIONAL { ?event epcis:parentID ?parentID . }");
+ optionalClauses.push("OPTIONAL { ?event epcis:childEPCs ?childEPCs . }");
+ optionalClauses.push(
+ "OPTIONAL { ?event epcis:inputEPCList ?inputEPCList . }",
+ );
+ optionalClauses.push(
+ "OPTIONAL { ?event epcis:outputEPCList ?outputEPCList . }",
+ );
+
+ // Pagination with defaults and max limits
+ const limit = Math.min(params.limit ?? 100, 1000); // Default 100, max 1000
+ const offset = params.offset ?? 0;
+
+ // Assemble the query with GROUP_CONCAT for array fields
+ return `${PREFIXES}
+SELECT ?ual ?eventType ?eventTime ?bizStep ?bizLocation ?disposition ?readPoint ?action ?parentID
+ (GROUP_CONCAT(DISTINCT ?epc; SEPARATOR=", ") AS ?epcList)
+ (GROUP_CONCAT(DISTINCT ?childEPCs; SEPARATOR=", ") AS ?childEPCList)
+ (GROUP_CONCAT(DISTINCT ?inputEPCList; SEPARATOR=", ") AS ?inputEPCs)
+ (GROUP_CONCAT(DISTINCT ?outputEPCList; SEPARATOR=", ") AS ?outputEPCs)
+WHERE {
+ GRAPH ?ual {
+ ${wherePatterns.join("\n ")}
+ ${optionalClauses.join("\n ")}
+ }
+ ${filterClauses.join("\n ")}
+}
+GROUP BY ?ual ?eventType ?eventTime ?bizStep ?bizLocation ?disposition ?readPoint ?action ?parentID
+ORDER BY DESC(?eventTime)
+LIMIT ${limit}
+OFFSET ${offset}`;
+ }
+}
diff --git a/packages/plugin-epcis/src/services/EPCISValidationService.ts b/packages/plugin-epcis/src/services/epcisValidationService.ts
similarity index 100%
rename from packages/plugin-epcis/src/services/EPCISValidationService.ts
rename to packages/plugin-epcis/src/services/epcisValidationService.ts
diff --git a/packages/plugin-epcis/src/utils/epcisQueryValidation.ts b/packages/plugin-epcis/src/utils/epcisQueryValidation.ts
new file mode 100644
index 00000000..6769b945
--- /dev/null
+++ b/packages/plugin-epcis/src/utils/epcisQueryValidation.ts
@@ -0,0 +1,82 @@
+import { z } from "@dkg/plugin-swagger";
+
+type OptionalIntegerParamOptions = {
+ min: number;
+ max?: number;
+ errorMessage: string;
+};
+
+export function optionalNonEmptyQueryString(parameter: string) {
+ return z
+ .string()
+ .trim()
+ .min(1, { message: `Parameter '${parameter}' cannot be empty` })
+ .optional();
+}
+
+export function requiredNonEmptyString(parameter: string) {
+ return z
+ .string()
+ .trim()
+ .min(1, { message: `Parameter '${parameter}' cannot be empty` });
+}
+
+export function optionalDateTimeQueryString(parameter: string) {
+ return z
+ .string()
+ .trim()
+ .min(1, { message: `Parameter '${parameter}' cannot be empty` })
+ .datetime({
+ message: "Must be ISO 8601 format (e.g., 2024-01-01T00:00:00Z)",
+ })
+ .optional();
+}
+
+export function optionalIntegerQueryParam({
+ min,
+ max,
+ errorMessage,
+}: OptionalIntegerParamOptions) {
+ return z
+ .string()
+ .trim()
+ .regex(/^-?\d+$/, { message: errorMessage })
+ .transform((value) => Number.parseInt(value, 10))
+ .refine((value) => value >= min && (max === undefined || value <= max), {
+ message: errorMessage,
+ })
+ .optional();
+}
+
+export function optionalIntegerInputParam({
+ min,
+ max,
+ errorMessage,
+}: OptionalIntegerParamOptions) {
+ return z
+ .number()
+ .int({ message: errorMessage })
+ .refine((value) => value >= min && (max === undefined || value <= max), {
+ message: errorMessage,
+ })
+ .optional();
+}
+
+export function hasAtLeastOneEpcisFilter(
+ query: Record,
+): boolean {
+ return Object.entries(query)
+ .filter(([key]) => !["fullTrace", "limit", "offset"].includes(key))
+ .some(([, value]) => value !== undefined);
+}
+
+export function hasValidEpcisDateRange(query: {
+ from?: string;
+ to?: string;
+}): boolean {
+ if (!query.from || !query.to) {
+ return true;
+ }
+
+ return Date.parse(query.from) <= Date.parse(query.to);
+}
diff --git a/packages/plugin-epcis/src/utils/sourceKa.ts b/packages/plugin-epcis/src/utils/sourceKa.ts
new file mode 100644
index 00000000..3833abae
--- /dev/null
+++ b/packages/plugin-epcis/src/utils/sourceKa.ts
@@ -0,0 +1,41 @@
+// DKG Explorer URL for source KAs
+const DKG_EXPLORER_BASE_URL = "https://dkg.origintrail.io/explore?ual=";
+
+export type SourceKA = {
+ title: string;
+ issuer: string;
+ ual: string;
+};
+
+/**
+ * Format source Knowledge Assets for MCP tool responses.
+ * Extracts unique UALs from query results and formats them as markdown
+ * that can be parsed by the chat UI to display KA chips.
+ */
+export function formatSourceKAs(results: any[]): { type: "text"; text: string } | null {
+ const seenUals = new Set();
+ const kas: SourceKA[] = [];
+
+ for (const row of results) {
+ if (row.ual && !seenUals.has(row.ual)) {
+ seenUals.add(row.ual);
+ const eventType = row.eventType?.split('/').pop() || 'Event';
+ // Clean UAL by removing /private or /public suffix
+ const cleanUal = row.ual.replace(/\/(private|public)$/, '');
+ kas.push({
+ title: `EPCIS ${eventType}`,
+ issuer: "EPCIS Plugin",
+ ual: cleanUal,
+ });
+ }
+ }
+
+ if (kas.length === 0) return null;
+
+ return {
+ type: "text",
+ text: "**Source Knowledge Assets:**\n" +
+ kas.map(k => `- **${k.title}**: ${k.issuer}\n [${k.ual}](${DKG_EXPLORER_BASE_URL}${k.ual})`).join("\n"),
+ };
+}
+
diff --git a/packages/plugin-epcis/tests/fixtures/bicycleStoryFixtures.ts b/packages/plugin-epcis/tests/fixtures/bicycleStoryFixtures.ts
new file mode 100644
index 00000000..e8f8e56f
--- /dev/null
+++ b/packages/plugin-epcis/tests/fixtures/bicycleStoryFixtures.ts
@@ -0,0 +1,201 @@
+type QueryRow = {
+ ual: string;
+ eventType: string;
+ eventTime: string;
+ bizStep: string;
+ bizLocation: string;
+ disposition: string;
+ readPoint: string;
+ action: string;
+ parentID: string;
+ epcList: string;
+ childEPCList: string;
+ inputEPCs: string;
+ outputEPCs: string;
+};
+
+const UAL_BASE = "did:dkg:otp:2043";
+const EPCIS_OBJECT_EVENT = "https://gs1.github.io/EPCIS/ObjectEvent";
+const EPCIS_TRANSFORMATION_EVENT = "https://gs1.github.io/EPCIS/TransformationEvent";
+const EPCIS_AGGREGATION_EVENT = "https://gs1.github.io/EPCIS/AggregationEvent";
+
+const FRAME_EPC = "urn:epc:id:sgtin:4012345.011111.1001";
+const FRONT_WHEEL_EPC = "urn:epc:id:sgtin:4012345.022222.2001";
+const REAR_WHEEL_EPC = "urn:epc:id:sgtin:4012345.022222.2002";
+const HANDLEBAR_EPC = "urn:epc:id:sgtin:4012345.033333.3001";
+const BICYCLE_EPC = "urn:epc:id:sgtin:4012345.099999.9001";
+const PALLET_EPC = "urn:epc:id:sscc:4012345.0000000001";
+
+const RECEIVING_DOCK = "urn:epc:id:sgln:4012345.00001.0";
+const QUALITY_LAB = "urn:epc:id:sgln:4012345.00002.0";
+const ASSEMBLY_LINE = "urn:epc:id:sgln:4012345.00003.0";
+const PACKING_AREA = "urn:epc:id:sgln:4012345.00004.0";
+
+export function jsonResponse(body: object, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+export function publisherQueuedResponse(id: number): Response {
+ return jsonResponse({ id, status: "queued", attemptCount: 1 });
+}
+
+export function publisherStatusResponse(
+ status: string,
+ ual?: string,
+ publishedAt?: string,
+): Response {
+ return jsonResponse({
+ status,
+ ...(ual && { ual }),
+ ...(publishedAt && { publishedAt }),
+ });
+}
+
+export function makeDkgQueryResult(rows: QueryRow[]): { data: QueryRow[] } {
+ return { data: rows };
+}
+
+export const RECEIVING_EVENTS: QueryRow[] = [
+ {
+ ual: `${UAL_BASE}/1/private`,
+ eventType: EPCIS_OBJECT_EVENT,
+ eventTime: "2024-03-01T08:00:00.000Z",
+ bizStep: "https://ref.gs1.org/cbv/BizStep-receiving",
+ bizLocation: RECEIVING_DOCK,
+ disposition: "https://ref.gs1.org/cbv/Disp-in_progress",
+ readPoint: RECEIVING_DOCK,
+ action: "ADD",
+ parentID: "",
+ epcList: FRAME_EPC,
+ childEPCList: "",
+ inputEPCs: "",
+ outputEPCs: "",
+ },
+ {
+ ual: `${UAL_BASE}/2/private`,
+ eventType: EPCIS_OBJECT_EVENT,
+ eventTime: "2024-03-01T08:30:00.000Z",
+ bizStep: "https://ref.gs1.org/cbv/BizStep-receiving",
+ bizLocation: RECEIVING_DOCK,
+ disposition: "https://ref.gs1.org/cbv/Disp-in_progress",
+ readPoint: RECEIVING_DOCK,
+ action: "ADD",
+ parentID: "",
+ epcList: `${FRONT_WHEEL_EPC}, ${REAR_WHEEL_EPC}`,
+ childEPCList: "",
+ inputEPCs: "",
+ outputEPCs: "",
+ },
+ {
+ ual: `${UAL_BASE}/3/private`,
+ eventType: EPCIS_OBJECT_EVENT,
+ eventTime: "2024-03-01T09:00:00.000Z",
+ bizStep: "https://ref.gs1.org/cbv/BizStep-receiving",
+ bizLocation: RECEIVING_DOCK,
+ disposition: "https://ref.gs1.org/cbv/Disp-in_progress",
+ readPoint: RECEIVING_DOCK,
+ action: "ADD",
+ parentID: "",
+ epcList: HANDLEBAR_EPC,
+ childEPCList: "",
+ inputEPCs: "",
+ outputEPCs: "",
+ },
+];
+
+export const QUALITY_LAB_EVENTS: QueryRow[] = [
+ {
+ ual: `${UAL_BASE}/4/private`,
+ eventType: EPCIS_OBJECT_EVENT,
+ eventTime: "2024-03-01T10:00:00.000Z",
+ bizStep: "https://ref.gs1.org/cbv/BizStep-inspecting",
+ bizLocation: QUALITY_LAB,
+ disposition: "https://ref.gs1.org/cbv/Disp-conformant",
+ readPoint: QUALITY_LAB,
+ action: "OBSERVE",
+ parentID: "",
+ epcList: FRAME_EPC,
+ childEPCList: "",
+ inputEPCs: "",
+ outputEPCs: "",
+ },
+ {
+ ual: `${UAL_BASE}/5/private`,
+ eventType: EPCIS_OBJECT_EVENT,
+ eventTime: "2024-03-01T10:30:00.000Z",
+ bizStep: "https://ref.gs1.org/cbv/BizStep-inspecting",
+ bizLocation: QUALITY_LAB,
+ disposition: "https://ref.gs1.org/cbv/Disp-conformant",
+ readPoint: QUALITY_LAB,
+ action: "OBSERVE",
+ parentID: "",
+ epcList: `${FRONT_WHEEL_EPC}, ${REAR_WHEEL_EPC}`,
+ childEPCList: "",
+ inputEPCs: "",
+ outputEPCs: "",
+ },
+ {
+ ual: `${UAL_BASE}/7/private`,
+ eventType: EPCIS_OBJECT_EVENT,
+ eventTime: "2024-03-01T15:00:00.000Z",
+ bizStep: "https://ref.gs1.org/cbv/BizStep-inspecting",
+ bizLocation: QUALITY_LAB,
+ disposition: "https://ref.gs1.org/cbv/Disp-conformant",
+ readPoint: QUALITY_LAB,
+ action: "OBSERVE",
+ parentID: "",
+ epcList: BICYCLE_EPC,
+ childEPCList: "",
+ inputEPCs: "",
+ outputEPCs: "",
+ },
+];
+
+const ASSEMBLY_EVENT: QueryRow = {
+ ual: `${UAL_BASE}/6/private`,
+ eventType: EPCIS_TRANSFORMATION_EVENT,
+ eventTime: "2024-03-01T14:00:00.000Z",
+ bizStep: "https://ref.gs1.org/cbv/BizStep-assembling",
+ bizLocation: ASSEMBLY_LINE,
+ disposition: "https://ref.gs1.org/cbv/Disp-active",
+ readPoint: ASSEMBLY_LINE,
+ action: "",
+ parentID: "",
+ epcList: "",
+ childEPCList: "",
+ inputEPCs: `${FRAME_EPC}, ${FRONT_WHEEL_EPC}, ${REAR_WHEEL_EPC}, ${HANDLEBAR_EPC}`,
+ outputEPCs: BICYCLE_EPC,
+};
+
+const PACKING_EVENT: QueryRow = {
+ ual: `${UAL_BASE}/8/private`,
+ eventType: EPCIS_AGGREGATION_EVENT,
+ eventTime: "2024-03-01T16:00:00.000Z",
+ bizStep: "https://ref.gs1.org/cbv/BizStep-packing",
+ bizLocation: PACKING_AREA,
+ disposition: "https://ref.gs1.org/cbv/Disp-in_transit",
+ readPoint: PACKING_AREA,
+ action: "ADD",
+ parentID: PALLET_EPC,
+ epcList: "",
+ childEPCList: BICYCLE_EPC,
+ inputEPCs: "",
+ outputEPCs: "",
+};
+
+export const FRAME_TRACE_EVENTS: QueryRow[] = [
+ RECEIVING_EVENTS[0],
+ QUALITY_LAB_EVENTS[0],
+ ASSEMBLY_EVENT,
+];
+
+export const BICYCLE_TRACE_EVENTS: QueryRow[] = [
+ ASSEMBLY_EVENT,
+ QUALITY_LAB_EVENTS[2],
+ PACKING_EVENT,
+];
+
+export const ASSEMBLY_EVENTS: QueryRow[] = [ASSEMBLY_EVENT];
diff --git a/packages/plugin-epcis/tests/plugin-epcis.spec.ts b/packages/plugin-epcis/tests/plugin-epcis.spec.ts
deleted file mode 100644
index f1e5e674..00000000
--- a/packages/plugin-epcis/tests/plugin-epcis.spec.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { describe, it, beforeEach, afterEach } from "mocha";
-import { expect } from "chai";
-import sinon from "sinon";
-import pluginEpcisPlugin from "../dist/index.js";
-import {
- createExpressApp,
- createInMemoryBlobStorage,
- createMcpServerClientPair,
- createMockDkgClient,
-} from "@dkg/plugins/testing";
-import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
-import express from "express";
-// import request from "supertest";
-
-// Mock DKG context
-const mockDkgContext = {
- dkg: createMockDkgClient(),
- blob: createInMemoryBlobStorage(),
-};
-
-describe("@dkg/plugin-epcis checks", function () {
- let mockMcpServer: McpServer;
- let mockMcpClient: Client;
- let apiRouter: express.Router;
- let app: express.Application;
-
- this.timeout(5000);
-
- beforeEach(async () => {
- const { server, client, connect } = await createMcpServerClientPair();
- mockMcpServer = server;
- mockMcpClient = client;
- apiRouter = express.Router();
- app = createExpressApp();
-
- // Initialize plugin
- pluginEpcisPlugin(mockDkgContext, mockMcpServer, apiRouter);
- await connect();
- app.use("/", apiRouter);
- });
-
- afterEach(() => {
- sinon.restore();
- });
-
- describe("Plugin Configuration", () => {
- it("should create plugin without errors", () => {
- expect(pluginEpcisPlugin).to.be.a("function");
- });
- });
-
- describe("Core Functionality", () => {
- it("should register tools or endpoints", async () => {
- // TODO: Replace this placeholder with your actual tests!
- // Example for MCP tools:
- // const tools = await mockMcpClient.listTools().then((r) => r.tools);
- // expect(tools.some((t) => t.name === "your-tool-name")).to.equal(true);
-
- // Example for API endpoints:
- // request(app).get("/your-endpoint").expect(200);
-
- throw new Error(
- "TODO: Replace placeholder test with your actual plugin functionality tests",
- );
- });
- });
-
- describe("Error Handling", () => {
- it("should handle invalid parameters", async () => {
- // TODO: Replace this placeholder with your actual error handling tests!
- // Example:
- // await request(app).get("/invalid-endpoint").expect(400);
-
- throw new Error(
- "TODO: Replace placeholder test with your actual error handling tests",
- );
- });
- });
-});
diff --git a/packages/plugin-epcis/tests/pluginEpcis.spec.ts b/packages/plugin-epcis/tests/pluginEpcis.spec.ts
new file mode 100644
index 00000000..f79fe8e1
--- /dev/null
+++ b/packages/plugin-epcis/tests/pluginEpcis.spec.ts
@@ -0,0 +1,572 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable turbo/no-undeclared-env-vars */
+
+import { describe, it, beforeEach, afterEach } from "mocha";
+import { expect } from "chai";
+import sinon from "sinon";
+import request from "supertest";
+import pluginEpcisPlugin from "../dist/index.js";
+import bicycleStory from "../test-data/bicycle-manufacturing-story.json";
+import {
+ ASSEMBLY_EVENTS,
+ BICYCLE_TRACE_EVENTS,
+ FRAME_TRACE_EVENTS,
+ QUALITY_LAB_EVENTS,
+ RECEIVING_EVENTS,
+ jsonResponse,
+ makeDkgQueryResult,
+ publisherQueuedResponse,
+ publisherStatusResponse,
+} from "./fixtures/bicycleStoryFixtures";
+import {
+ createExpressApp,
+ createInMemoryBlobStorage,
+ createMcpServerClientPair,
+ createMockDkgClient,
+} from "@dkg/plugins/testing";
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import express from "express";
+
+const story = bicycleStory as any;
+const event1 = story.events[0].request;
+const event2 = story.events[1].request;
+const event6 = story.events[5].request;
+const event8 = story.events[7].request;
+const frameEpc = story.characters.components.frame as string;
+const bicycleEpc = story.characters.finishedProduct.bicycle as string;
+const qualityLab = story.locations.qualityLab as string;
+
+function parseToolResult(result: any): Record {
+ return JSON.parse((result.content as any[])[0].text);
+}
+
+function expectResponseErrorMessage(
+ responseBody: Record,
+ messageFragment: string,
+): void {
+ expect(responseBody.error).to.be.a("string");
+ expect(responseBody.error).to.include(messageFragment);
+}
+
+describe("@dkg/plugin-epcis checks", function () {
+ let mockMcpServer: McpServer;
+ let mockMcpClient: Client;
+ let apiRouter: express.Router;
+ let app: express.Application;
+ let dkgContext: any;
+ let dkgQueryStub: sinon.SinonStub;
+ let fetchStub: sinon.SinonStub;
+ let originalMcpUrl: string | undefined;
+
+ this.timeout(5000);
+
+ beforeEach(async () => {
+ originalMcpUrl = process.env.EXPO_PUBLIC_MCP_URL;
+ process.env.EXPO_PUBLIC_MCP_URL = "http://test-publisher:9999";
+ fetchStub = sinon.stub(global, "fetch");
+
+ dkgContext = {
+ dkg: createMockDkgClient(),
+ blob: createInMemoryBlobStorage(),
+ };
+ dkgQueryStub = sinon.stub(dkgContext.dkg.graph, "query");
+
+ const { server, client, connect } = await createMcpServerClientPair();
+ mockMcpServer = server;
+ mockMcpClient = client;
+ apiRouter = express.Router();
+ app = createExpressApp();
+
+ pluginEpcisPlugin(dkgContext, mockMcpServer, apiRouter);
+ await connect();
+ app.use("/", apiRouter);
+ });
+
+ afterEach(() => {
+ sinon.restore();
+ if (originalMcpUrl !== undefined) {
+ process.env.EXPO_PUBLIC_MCP_URL = originalMcpUrl;
+ } else {
+ delete process.env.EXPO_PUBLIC_MCP_URL;
+ }
+ });
+
+ describe("Plugin Registration", () => {
+ it("registers epcis MCP tools with expected schema fields", async () => {
+ const tools = await mockMcpClient.listTools().then((t) => t.tools);
+ const queryTool = tools.find((tool) => tool.name === "epcis-query");
+ const trackTool = tools.find((tool) => tool.name === "epcis-track-item");
+ const captureTool = tools.find((tool) => tool.name === "epcis-capture");
+ const captureStatusTool = tools.find(
+ (tool) => tool.name === "epcis-capture-status",
+ );
+
+ expect(queryTool).to.not.equal(undefined);
+ expect(trackTool).to.not.equal(undefined);
+ expect(captureTool).to.not.equal(undefined);
+ expect(captureStatusTool).to.not.equal(undefined);
+ expect((queryTool as any).inputSchema.properties).to.include.keys(
+ "epc",
+ "bizStep",
+ "bizLocation",
+ "limit",
+ "offset",
+ );
+ expect((trackTool as any).inputSchema.properties).to.include.keys("epc");
+ expect((captureTool as any).inputSchema.properties).to.include.keys(
+ "epcisDocument",
+ "publishOptions",
+ );
+ expect((captureStatusTool as any).inputSchema.properties).to.include.keys(
+ "captureID",
+ );
+ });
+ });
+
+ describe("Capture - POST /epcis/capture", () => {
+ it("captures a valid ObjectEvent and returns 202", async () => {
+ fetchStub.resolves(publisherQueuedResponse(101));
+ const response = await request(app)
+ .post("/epcis/capture")
+ .send(event1)
+ .expect(202);
+
+ expect(response.body.captureID).to.equal("101");
+ expect(response.body.requestId).to.match(/^epcis-/);
+ expect(response.body.receivedAt).to.be.a("string");
+ expect(response.body.eventCount).to.equal(1);
+ });
+
+ it("captures a multi-EPC ObjectEvent and keeps eventCount as 1", async () => {
+ fetchStub.resolves(publisherQueuedResponse(102));
+ const response = await request(app)
+ .post("/epcis/capture")
+ .send(event2)
+ .expect(202);
+
+ expect(response.body.captureID).to.equal("102");
+ expect(response.body.eventCount).to.equal(1);
+ });
+
+ it("captures a valid TransformationEvent", async () => {
+ fetchStub.resolves(publisherQueuedResponse(106));
+ await request(app).post("/epcis/capture").send(event6).expect(202);
+ });
+
+ it("captures a valid AggregationEvent", async () => {
+ fetchStub.resolves(publisherQueuedResponse(108));
+ await request(app).post("/epcis/capture").send(event8).expect(202);
+ });
+ });
+
+ describe("Capture Status - GET /epcis/capture/:captureID", () => {
+ it("returns completed status with UAL", async () => {
+ fetchStub.resolves(
+ publisherStatusResponse(
+ "completed",
+ "did:dkg:otp:2043/0xabc/123",
+ "2024-03-01T16:30:00.000Z",
+ ),
+ );
+ const response = await request(app).get("/epcis/capture/123").expect(200);
+
+ expect(response.body.status).to.equal("completed");
+ expect(response.body.captureID).to.equal("123");
+ expect(response.body.UAL).to.equal("did:dkg:otp:2043/0xabc/123");
+ });
+
+ it("returns 404 when publisher returns 404", async () => {
+ fetchStub.resolves(jsonResponse({ error: "not found" }, 404));
+ const response = await request(app).get("/epcis/capture/404").expect(404);
+
+ expect(response.body.error).to.equal("Capture not found");
+ expect(response.body.captureID).to.equal("404");
+ });
+
+ it("returns 504 on publisher timeout", async () => {
+ fetchStub.rejects(new DOMException("Timed out", "TimeoutError"));
+ const response = await request(app).get("/epcis/capture/888").expect(504);
+
+ expect(response.body.error).to.equal("Publisher timeout");
+ expect(response.body.captureID).to.equal("888");
+ });
+ });
+
+ describe("Events Query - GET /epcis/events", () => {
+ it("queries by bizStep=receiving and returns 3 events", async () => {
+ dkgQueryStub.resolves(makeDkgQueryResult(RECEIVING_EVENTS));
+ const response = await request(app)
+ .get("/epcis/events")
+ .query({ bizStep: "receiving" })
+ .expect(200);
+
+ expect(response.body.count).to.equal(3);
+ expect(response.body.results).to.have.length(3);
+ expect(dkgQueryStub.firstCall.args[0]).to.include("BizStep-receiving");
+ });
+
+ it("queries by quality lab bizLocation and returns 3 events", async () => {
+ dkgQueryStub.resolves(makeDkgQueryResult(QUALITY_LAB_EVENTS));
+ const response = await request(app)
+ .get("/epcis/events")
+ .query({ bizLocation: qualityLab })
+ .expect(200);
+
+ expect(response.body.count).to.equal(3);
+ expect(response.body.results).to.have.length(3);
+ expect(dkgQueryStub.calledOnce).to.equal(true);
+ const sparql = dkgQueryStub.firstCall.args[0] as string;
+ expect(sparql).to.include(`epcis:bizLocation "${qualityLab}"`);
+ });
+
+ it("queries full trace for frame EPC and returns receiving/inspecting/assembling", async () => {
+ dkgQueryStub.resolves(makeDkgQueryResult(FRAME_TRACE_EVENTS));
+ const response = await request(app)
+ .get("/epcis/events")
+ .query({ epc: frameEpc, fullTrace: "true" })
+ .expect(200);
+
+ const steps = response.body.results.map((row: any) => row.bizStep);
+ expect(response.body.count).to.equal(3);
+ expect(
+ steps.some((step: string) => step.endsWith("BizStep-receiving")),
+ ).to.equal(true);
+ expect(
+ steps.some((step: string) => step.endsWith("BizStep-inspecting")),
+ ).to.equal(true);
+ expect(
+ steps.some((step: string) => step.endsWith("BizStep-assembling")),
+ ).to.equal(true);
+ });
+
+ it("queries full trace for bicycle EPC and returns 3 events", async () => {
+ dkgQueryStub.resolves(makeDkgQueryResult(BICYCLE_TRACE_EVENTS));
+ const response = await request(app)
+ .get("/epcis/events")
+ .query({ epc: bicycleEpc, fullTrace: "true" })
+ .expect(200);
+
+ expect(response.body.count).to.equal(3);
+ expect(response.body.results).to.have.length(3);
+ expect(dkgQueryStub.calledOnce).to.equal(true);
+ const sparql = dkgQueryStub.firstCall.args[0] as string;
+ expect(sparql).to.include("UNION");
+ expect(sparql).to.include(`?event epcis:outputEPCList "${bicycleEpc}"`);
+ expect(sparql).to.include(`?event epcis:childEPCs "${bicycleEpc}"`);
+ });
+
+ it("includes date range filters in generated SPARQL", async () => {
+ dkgQueryStub.resolves(makeDkgQueryResult(RECEIVING_EVENTS));
+ await request(app)
+ .get("/epcis/events")
+ .query({
+ from: "2024-03-01T00:00:00Z",
+ to: "2024-03-01T23:59:59Z",
+ })
+ .expect(200);
+
+ const sparql = dkgQueryStub.firstCall.args[0] as string;
+ expect(sparql).to.include('xsd:dateTime("2024-03-01T00:00:00Z")');
+ expect(sparql).to.include('xsd:dateTime("2024-03-01T23:59:59Z")');
+ });
+
+ it("returns pagination based on limit and offset query params", async () => {
+ dkgQueryStub.resolves(makeDkgQueryResult(RECEIVING_EVENTS));
+ const response = await request(app)
+ .get("/epcis/events")
+ .query({ bizStep: "receiving", limit: "5", offset: "10" })
+ .expect(200);
+
+ expect(response.body.pagination).to.deep.equal({ limit: 5, offset: 10 });
+ });
+ });
+
+ describe("Events Track - GET /epcis/events/track", () => {
+ it("tracks item events with full trace and returns results", async () => {
+ dkgQueryStub.resolves(makeDkgQueryResult(BICYCLE_TRACE_EVENTS));
+ const response = await request(app)
+ .get("/epcis/events/track")
+ .query({ epc: bicycleEpc })
+ .expect(200);
+
+ expect(response.body.success).to.equal(true);
+ expect(response.body.count).to.equal(3);
+ expect(response.body.results).to.have.length(3);
+ expect(dkgQueryStub.calledOnce).to.equal(true);
+ const sparql = dkgQueryStub.firstCall.args[0] as string;
+ expect(sparql).to.include("UNION");
+ expect(sparql).to.include(`?event epcis:outputEPCList "${bicycleEpc}"`);
+ expect(sparql).to.include(`?event epcis:childEPCs "${bicycleEpc}"`);
+ });
+
+ it("returns 400 for missing epc", async () => {
+ const response = await request(app)
+ .get("/epcis/events/track")
+ .expect(400);
+ expect(response.body.error).to.be.a("string");
+ });
+ });
+
+ describe("MCP Tools", () => {
+ it("epcis-query returns assembly event results", async () => {
+ dkgQueryStub.resolves(makeDkgQueryResult(ASSEMBLY_EVENTS));
+ const result = await mockMcpClient.callTool({
+ name: "epcis-query",
+ arguments: { bizStep: "assembling" },
+ });
+
+ const payload = parseToolResult(result);
+ expect(result.isError).to.not.equal(true);
+ expect(payload.count).to.equal(1);
+ expect(payload.summary).to.include("Found 1 EPCIS event");
+ expect(payload.events[0].bizStep).to.include("BizStep-assembling");
+ });
+
+ it("epcis-track-item returns journey timeline with numbered steps", async () => {
+ dkgQueryStub.resolves(makeDkgQueryResult(BICYCLE_TRACE_EVENTS));
+ const result = await mockMcpClient.callTool({
+ name: "epcis-track-item",
+ arguments: { epc: bicycleEpc },
+ });
+
+ const payload = parseToolResult(result);
+ expect(payload.eventCount).to.equal(3);
+ expect(payload.summary).to.include("Journey Timeline");
+ expect(payload.summary).to.include("1.");
+ expect(payload.summary).to.include("assembling");
+ expect(payload.summary).to.include("inspecting");
+ expect(payload.summary).to.include("packing");
+ expect(dkgQueryStub.calledOnce).to.equal(true);
+ const sparql = dkgQueryStub.firstCall.args[0] as string;
+ expect(sparql).to.include("UNION");
+ expect(sparql).to.include(`?event epcis:inputEPCList "${bicycleEpc}"`);
+ });
+
+ it("epcis-capture captures valid document and returns capture details", async () => {
+ fetchStub.resolves(publisherQueuedResponse(501));
+ const result = await mockMcpClient.callTool({
+ name: "epcis-capture",
+ arguments: event1,
+ });
+
+ const payload = parseToolResult(result);
+ expect(result.isError).to.not.equal(true);
+ expect(payload.captureID).to.equal("501");
+ expect(payload.requestId).to.match(/^epcis-/);
+ expect(payload.receivedAt).to.be.a("string");
+ expect(payload.eventCount).to.equal(1);
+ });
+
+ it("epcis-capture returns validation error for invalid document", async () => {
+ const result = await mockMcpClient.callTool({
+ name: "epcis-capture",
+ arguments: { epcisDocument: { type: "NotAnEPCIS" } },
+ });
+
+ const payload = parseToolResult(result);
+ expect(result.isError).to.equal(true);
+ expect(payload.error).to.equal("Invalid EPCISDocument");
+ });
+
+ it("epcis-capture returns publisher error when publisher is unavailable", async function () {
+ this.timeout(15000);
+ fetchStub.rejects(new Error("publisher down"));
+ const result = await mockMcpClient.callTool({
+ name: "epcis-capture",
+ arguments: event1,
+ });
+
+ const payload = parseToolResult(result);
+ expect(result.isError).to.equal(true);
+ expect(payload.error).to.include("Something went wrong");
+ expect(fetchStub.callCount).to.equal(3);
+ });
+
+ it("epcis-capture-status returns completed status with UAL", async () => {
+ fetchStub.resolves(
+ publisherStatusResponse(
+ "completed",
+ "did:dkg:otp:2043/0xabc/123",
+ "2024-03-01T16:30:00.000Z",
+ ),
+ );
+ const result = await mockMcpClient.callTool({
+ name: "epcis-capture-status",
+ arguments: { captureID: "123" },
+ });
+
+ const payload = parseToolResult(result);
+ expect(result.isError).to.not.equal(true);
+ expect(payload.status).to.equal("completed");
+ expect(payload.captureID).to.equal("123");
+ expect(payload.UAL).to.equal("did:dkg:otp:2043/0xabc/123");
+ });
+
+ it("epcis-capture-status returns error when capture is not found", async () => {
+ fetchStub.resolves(jsonResponse({ error: "not found" }, 404));
+ const result = await mockMcpClient.callTool({
+ name: "epcis-capture-status",
+ arguments: { captureID: "404" },
+ });
+
+ const payload = parseToolResult(result);
+ expect(result.isError).to.equal(true);
+ expect(payload.error).to.equal("Capture not found");
+ expect(payload.captureID).to.equal("404");
+ });
+
+ it("epcis-capture-status returns timeout error on publisher timeout", async () => {
+ fetchStub.rejects(new DOMException("Timed out", "TimeoutError"));
+ const result = await mockMcpClient.callTool({
+ name: "epcis-capture-status",
+ arguments: { captureID: "888" },
+ });
+
+ const payload = parseToolResult(result);
+ expect(result.isError).to.equal(true);
+ expect(payload.error).to.equal("Publisher timeout");
+ expect(payload.captureID).to.equal("888");
+ });
+ });
+
+ describe("MCP/API Parity", () => {
+ it("returns the same query event data for epcis-query and GET /epcis/events", async () => {
+ dkgQueryStub.resolves(makeDkgQueryResult(ASSEMBLY_EVENTS));
+
+ const mcpResult = await mockMcpClient.callTool({
+ name: "epcis-query",
+ arguments: { bizStep: "assembling" },
+ });
+ const mcpPayload = parseToolResult(mcpResult);
+
+ const apiResponse = await request(app)
+ .get("/epcis/events")
+ .query({ bizStep: "assembling" })
+ .expect(200);
+
+ expect(mcpPayload.events).to.deep.equal(apiResponse.body.results);
+ expect(mcpPayload.count).to.equal(apiResponse.body.count);
+ });
+
+ it("returns the same captureID and eventCount for epcis-capture and POST /epcis/capture", async () => {
+ fetchStub.onFirstCall().resolves(publisherQueuedResponse(777));
+ fetchStub.onSecondCall().resolves(publisherQueuedResponse(777));
+
+ const mcpResult = await mockMcpClient.callTool({
+ name: "epcis-capture",
+ arguments: event1,
+ });
+ const mcpPayload = parseToolResult(mcpResult);
+
+ const apiResponse = await request(app)
+ .post("/epcis/capture")
+ .send(event1)
+ .expect(202);
+
+ expect(mcpPayload.captureID).to.equal(apiResponse.body.captureID);
+ expect(mcpPayload.eventCount).to.equal(apiResponse.body.eventCount);
+ });
+ });
+
+ describe("Error Handling", () => {
+ it("returns 400 when EPCISDocument fails schema validation", async () => {
+ const response = await request(app)
+ .post("/epcis/capture")
+ .send({ epcisDocument: { type: "NotAnEPCIS" } })
+ .expect(400);
+
+ expect(response.body.error).to.equal("Invalid EPCISDocument");
+ });
+
+ it("returns 400 when EPCISDocument has no events", async () => {
+ const emptyEventDoc = structuredClone(event1.epcisDocument);
+ emptyEventDoc.epcisBody.eventList = [];
+
+ const response = await request(app)
+ .post("/epcis/capture")
+ .send({
+ epcisDocument: emptyEventDoc,
+ publishOptions: event1.publishOptions,
+ })
+ .expect(400);
+
+ expect(response.body.error).to.equal("EPCISDocument contains no events");
+ });
+
+ it("returns 500 when publisher is unavailable for capture", async function () {
+ this.timeout(15000);
+ fetchStub.rejects(new Error("publisher down"));
+
+ const response = await request(app)
+ .post("/epcis/capture")
+ .send(event1)
+ .expect(500);
+
+ expect(response.body.error).to.include("Something went wrong");
+ expect(fetchStub.callCount).to.equal(3);
+ });
+
+ it("returns 400 when events query has no filters", async () => {
+ const response = await request(app).get("/epcis/events").expect(400);
+ expectResponseErrorMessage(
+ response.body,
+ "At least one filter parameter is required.",
+ );
+ });
+
+ it("returns 400 when 'to' is before 'from'", async () => {
+ const response = await request(app)
+ .get("/epcis/events")
+ .query({
+ from: "2024-03-02T00:00:00Z",
+ to: "2024-03-01T00:00:00Z",
+ })
+ .expect(400);
+ expectResponseErrorMessage(
+ response.body,
+ "Parameter 'to' must be greater than or equal to 'from'.",
+ );
+ });
+
+ it("returns 400 for non-numeric captureID", async () => {
+ const response = await request(app).get("/epcis/capture/abc").expect(400);
+ expectResponseErrorMessage(response.body, "Invalid captureID format");
+ });
+
+ it("returns MCP error when epcis-query has no filters", async () => {
+ const result = await mockMcpClient.callTool({
+ name: "epcis-query",
+ arguments: {},
+ });
+ const payload = parseToolResult(result);
+
+ expect(result.isError).to.equal(true);
+ expect(payload.error).to.include(
+ "At least one filter parameter is required.",
+ );
+ });
+
+ it("returns MCP error when DKG query fails", async () => {
+ dkgQueryStub.rejects(new Error("query exploded"));
+ const result = await mockMcpClient.callTool({
+ name: "epcis-query",
+ arguments: { bizStep: "receiving" },
+ });
+ const payload = parseToolResult(result);
+
+ expect(result.isError).to.equal(true);
+ expect(payload.error).to.equal("Query failed");
+ });
+
+ it("returns 500 when /epcis/events DKG query fails", async () => {
+ dkgQueryStub.rejects(new Error("query exploded"));
+ const response = await request(app)
+ .get("/epcis/events")
+ .query({ bizStep: "receiving" })
+ .expect(500);
+
+ expect(response.body.error).to.equal("Failed to query events");
+ });
+ });
+});