@emilia-protocol/mcp-server 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +967 -34
- package/auto-receipt.js +402 -0
- package/index.js +1411 -245
- package/package.json +40 -10
package/auto-receipt.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMILIA Protocol — Auto-Receipt Middleware
|
|
3
|
+
* @license Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* Wraps MCP tool call handlers and automatically generates behavioral receipts
|
|
6
|
+
* from every tool invocation outcome. This closes the data loop: every tool call
|
|
7
|
+
* becomes a potential trust signal without requiring manual receipt submission.
|
|
8
|
+
*
|
|
9
|
+
* Design principles:
|
|
10
|
+
* - Opt-in by default. Auto-receipt is disabled unless explicitly enabled.
|
|
11
|
+
* - Non-blocking. Receipt submission is fire-and-forget; it never delays the tool response.
|
|
12
|
+
* - Privacy-preserving. Sensitive fields are redacted before any data leaves the process.
|
|
13
|
+
* - Provenance-honest. Auto-generated receipts are always marked unilateral — they cannot
|
|
14
|
+
* be bilateral without counterparty confirmation.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* import { AutoReceiptMiddleware } from './auto-receipt.js';
|
|
18
|
+
*
|
|
19
|
+
* const middleware = new AutoReceiptMiddleware({
|
|
20
|
+
* epApiUrl: 'https://api.emiliaprotocol.com',
|
|
21
|
+
* epApiKey: process.env.EP_API_KEY,
|
|
22
|
+
* optIn: true,
|
|
23
|
+
* entityId: 'my-mcp-server',
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* const safeHandler = middleware.wrap('ep_trust_profile', originalHandler);
|
|
27
|
+
* const result = await safeHandler(args);
|
|
28
|
+
*
|
|
29
|
+
* @license Apache-2.0
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import crypto from 'crypto';
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Constants
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Default fields whose values should never appear in a stored receipt.
|
|
40
|
+
* Matched case-insensitively against all keys in input/output objects.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_SENSITIVE_FIELDS = [
|
|
43
|
+
'password',
|
|
44
|
+
'token',
|
|
45
|
+
'key',
|
|
46
|
+
'secret',
|
|
47
|
+
'api_key',
|
|
48
|
+
'apikey',
|
|
49
|
+
'auth',
|
|
50
|
+
'credential',
|
|
51
|
+
'credentials',
|
|
52
|
+
'authorization',
|
|
53
|
+
'private_key',
|
|
54
|
+
'access_token',
|
|
55
|
+
'refresh_token',
|
|
56
|
+
'client_secret',
|
|
57
|
+
'bearer',
|
|
58
|
+
'ssn',
|
|
59
|
+
'credit_card',
|
|
60
|
+
'card_number',
|
|
61
|
+
'cvv',
|
|
62
|
+
'pin',
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** Sentinel value replacing redacted field contents. */
|
|
66
|
+
const REDACTED_SENTINEL = '[REDACTED]';
|
|
67
|
+
|
|
68
|
+
/** Maximum auto-submit batch size (matches /api/receipts/auto-submit limit). */
|
|
69
|
+
const BATCH_MAX = 100;
|
|
70
|
+
|
|
71
|
+
/** Auto-submit endpoint path. */
|
|
72
|
+
const AUTO_SUBMIT_PATH = '/api/receipts/auto-submit';
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// AutoReceiptMiddleware
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export class AutoReceiptMiddleware {
|
|
79
|
+
/**
|
|
80
|
+
* Create an AutoReceiptMiddleware instance.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} config
|
|
83
|
+
* @param {string} [config.epApiUrl='https://api.emiliaprotocol.com']
|
|
84
|
+
* Base URL of the EP API. Must not include a trailing slash.
|
|
85
|
+
* @param {string} [config.epApiKey='']
|
|
86
|
+
* Bearer token for auto-submission. If omitted, receipts are generated
|
|
87
|
+
* locally but submission silently skips.
|
|
88
|
+
* @param {boolean} [config.optIn=false]
|
|
89
|
+
* Master switch. Auto-receipt does nothing unless this is true.
|
|
90
|
+
* Agents must explicitly call ep_configure_auto_receipt to enable.
|
|
91
|
+
* @param {string[]} [config.sensitiveFields=[]]
|
|
92
|
+
* Additional field names to redact, merged with the built-in defaults.
|
|
93
|
+
* @param {string} [config.entityId='']
|
|
94
|
+
* The entity ID attributed as the submitter of every auto-generated receipt.
|
|
95
|
+
* Typically the MCP server operator's entity slug.
|
|
96
|
+
*/
|
|
97
|
+
constructor(config = {}) {
|
|
98
|
+
this.epApiUrl = (config.epApiUrl || 'https://api.emiliaprotocol.com').replace(/\/$/, '');
|
|
99
|
+
this.epApiKey = config.epApiKey || '';
|
|
100
|
+
this.optIn = config.optIn === true;
|
|
101
|
+
this.entityId = config.entityId || '';
|
|
102
|
+
this.sensitiveFields = [
|
|
103
|
+
...DEFAULT_SENSITIVE_FIELDS,
|
|
104
|
+
...(config.sensitiveFields || []).map(f => f.toLowerCase()),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/** Pending receipts buffer — drained asynchronously. @type {object[]} */
|
|
108
|
+
this._pending = [];
|
|
109
|
+
this._flushScheduled = false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// -------------------------------------------------------------------------
|
|
113
|
+
// Public API
|
|
114
|
+
// -------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Wrap a tool handler function with auto-receipt instrumentation.
|
|
118
|
+
*
|
|
119
|
+
* The returned wrapper is a drop-in replacement: it accepts the same
|
|
120
|
+
* arguments, returns the same result, and throws the same errors.
|
|
121
|
+
* Auto-receipt runs asynchronously in the background.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} toolName The MCP tool name (used as context.task_type).
|
|
124
|
+
* @param {Function} handler Original async tool handler: (args) => Promise<any>.
|
|
125
|
+
* @returns {Function} Wrapped handler with identical signature.
|
|
126
|
+
*/
|
|
127
|
+
wrap(toolName, handler) {
|
|
128
|
+
if (typeof handler !== 'function') {
|
|
129
|
+
throw new TypeError(`AutoReceiptMiddleware.wrap: handler for "${toolName}" must be a function`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return async (args) => {
|
|
133
|
+
const start = Date.now();
|
|
134
|
+
let result;
|
|
135
|
+
let error = null;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
result = await handler(args);
|
|
139
|
+
return result;
|
|
140
|
+
} catch (err) {
|
|
141
|
+
error = err;
|
|
142
|
+
throw err;
|
|
143
|
+
} finally {
|
|
144
|
+
// Always runs — even if the handler throws.
|
|
145
|
+
const latencyMs = Date.now() - start;
|
|
146
|
+
|
|
147
|
+
if (this.optIn) {
|
|
148
|
+
// Generate the receipt draft outside the hot path.
|
|
149
|
+
// Errors here must never surface to the tool caller.
|
|
150
|
+
try {
|
|
151
|
+
const receipt = this.generateReceiptDraft(toolName, args, result, latencyMs, error);
|
|
152
|
+
this._enqueue(receipt);
|
|
153
|
+
} catch (draftErr) {
|
|
154
|
+
// Silently swallow — receipt generation must never affect tool behavior.
|
|
155
|
+
if (process.env.EP_AUTO_RECEIPT_DEBUG) {
|
|
156
|
+
console.warn('[AutoReceipt] Draft generation failed:', draftErr.message);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Generate a receipt draft from a tool call's metadata.
|
|
166
|
+
*
|
|
167
|
+
* The draft conforms to the EP receipt schema and is marked
|
|
168
|
+
* auto_generated: true with provenance 'unilateral'.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} toolName MCP tool name.
|
|
171
|
+
* @param {object} input Tool input arguments (will be redacted).
|
|
172
|
+
* @param {any} output Tool output (will be redacted if object).
|
|
173
|
+
* @param {number} latencyMs Wall-clock latency in milliseconds.
|
|
174
|
+
* @param {Error|null} error The error thrown by the handler, if any.
|
|
175
|
+
* @returns {object} EP receipt draft.
|
|
176
|
+
*/
|
|
177
|
+
generateReceiptDraft(toolName, input, output, latencyMs, error) {
|
|
178
|
+
const completed = error == null;
|
|
179
|
+
const errorOccurred = !completed;
|
|
180
|
+
|
|
181
|
+
// Sanitize inputs before storing.
|
|
182
|
+
const safeInput = input && typeof input === 'object'
|
|
183
|
+
? this.redactSensitive(input)
|
|
184
|
+
: {};
|
|
185
|
+
|
|
186
|
+
// Only include output metadata (type + size), never raw output content,
|
|
187
|
+
// to prevent inadvertent PII storage in receipt payloads.
|
|
188
|
+
const outputMeta = this._outputMeta(output);
|
|
189
|
+
|
|
190
|
+
const draft = {
|
|
191
|
+
// Who is submitting this receipt (the MCP server operator).
|
|
192
|
+
entity_id: this.entityId || 'unknown',
|
|
193
|
+
|
|
194
|
+
// Counterparty is unknown in unilateral auto-receipts.
|
|
195
|
+
counterparty_id: 'auto',
|
|
196
|
+
|
|
197
|
+
// Synthetic transaction reference: tool + timestamp + random suffix
|
|
198
|
+
// for idempotency on the EP side.
|
|
199
|
+
transaction_ref: `auto_${toolName}_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
|
|
200
|
+
|
|
201
|
+
// Behavioral context.
|
|
202
|
+
context: {
|
|
203
|
+
task_type: toolName,
|
|
204
|
+
input_keys: Object.keys(safeInput),
|
|
205
|
+
modality: 'mcp_tool',
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
// Observable outcome signals.
|
|
209
|
+
outcome: {
|
|
210
|
+
completed,
|
|
211
|
+
latency_ms: latencyMs,
|
|
212
|
+
error_occurred: errorOccurred,
|
|
213
|
+
error_type: errorOccurred ? (error.name || 'Error') : null,
|
|
214
|
+
output_type: outputMeta.type,
|
|
215
|
+
output_size_chars: outputMeta.sizeChars,
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
// Provenance: unilateral — submitted by one party without counterparty confirmation.
|
|
219
|
+
provenance: 'unilateral',
|
|
220
|
+
|
|
221
|
+
// Metadata markers.
|
|
222
|
+
auto_generated: true,
|
|
223
|
+
generated_at: new Date().toISOString(),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return draft;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Deep-clone an object and replace sensitive field values with REDACTED_SENTINEL.
|
|
231
|
+
*
|
|
232
|
+
* Matching is case-insensitive and checks whether any sensitive term appears
|
|
233
|
+
* as a substring of the field name, to catch variants like `api_key_v2` or
|
|
234
|
+
* `authorizationHeader`.
|
|
235
|
+
*
|
|
236
|
+
* @param {object} obj Object to sanitize. Must be a plain object or array.
|
|
237
|
+
* @returns {object} Deep-cloned object with sensitive values replaced.
|
|
238
|
+
*/
|
|
239
|
+
redactSensitive(obj) {
|
|
240
|
+
return this._deepRedact(obj);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Update the opt-in state and entity ID at runtime.
|
|
245
|
+
* Called by the ep_configure_auto_receipt tool handler.
|
|
246
|
+
*
|
|
247
|
+
* @param {boolean} enabled Whether to enable auto-receipt.
|
|
248
|
+
* @param {string} entityId Entity ID to attribute receipts to.
|
|
249
|
+
*/
|
|
250
|
+
configure(enabled, entityId) {
|
|
251
|
+
this.optIn = Boolean(enabled);
|
|
252
|
+
if (entityId) this.entityId = entityId;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// -------------------------------------------------------------------------
|
|
256
|
+
// Internal helpers
|
|
257
|
+
// -------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Enqueue a receipt draft for async submission.
|
|
261
|
+
* Schedules a microtask flush if one is not already pending.
|
|
262
|
+
*
|
|
263
|
+
* @param {object} receipt
|
|
264
|
+
*/
|
|
265
|
+
_enqueue(receipt) {
|
|
266
|
+
this._pending.push(receipt);
|
|
267
|
+
|
|
268
|
+
if (!this._flushScheduled) {
|
|
269
|
+
this._flushScheduled = true;
|
|
270
|
+
// Use setImmediate (Node) or Promise.resolve (browsers) to stay
|
|
271
|
+
// outside the current call stack without blocking I/O.
|
|
272
|
+
setImmediate(() => this._flush());
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Drain the pending queue and submit to the EP auto-submit endpoint.
|
|
278
|
+
* Batches receipts up to BATCH_MAX per HTTP request.
|
|
279
|
+
* Never throws — errors are swallowed to protect the calling MCP tool.
|
|
280
|
+
*/
|
|
281
|
+
async _flush() {
|
|
282
|
+
this._flushScheduled = false;
|
|
283
|
+
|
|
284
|
+
if (!this._pending.length) return;
|
|
285
|
+
if (!this.epApiKey) {
|
|
286
|
+
// No key configured — drop receipts silently.
|
|
287
|
+
this._pending = [];
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Drain a snapshot; new receipts may arrive while we await.
|
|
292
|
+
const batch = this._pending.splice(0, BATCH_MAX);
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
await this._submitBatch(batch);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
if (process.env.EP_AUTO_RECEIPT_DEBUG) {
|
|
298
|
+
console.warn('[AutoReceipt] Batch submission failed:', err.message);
|
|
299
|
+
}
|
|
300
|
+
// Do not re-enqueue — dropped receipts are preferable to infinite retries
|
|
301
|
+
// that could destabilise the MCP server under network partitions.
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// If more accumulated during the flush, schedule another pass.
|
|
305
|
+
if (this._pending.length > 0 && !this._flushScheduled) {
|
|
306
|
+
this._flushScheduled = true;
|
|
307
|
+
setImmediate(() => this._flush());
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* HTTP POST a batch of receipts to the EP auto-submit endpoint.
|
|
313
|
+
*
|
|
314
|
+
* @param {object[]} receipts
|
|
315
|
+
* @returns {Promise<object>} API response body.
|
|
316
|
+
*/
|
|
317
|
+
async _submitBatch(receipts) {
|
|
318
|
+
const url = `${this.epApiUrl}${AUTO_SUBMIT_PATH}`;
|
|
319
|
+
const res = await fetch(url, {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: {
|
|
322
|
+
'Content-Type': 'application/json',
|
|
323
|
+
// Key is included for rate-limiting attribution, not authentication.
|
|
324
|
+
'X-EP-Auto-Key': this.epApiKey,
|
|
325
|
+
},
|
|
326
|
+
body: JSON.stringify({ receipts }),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (!res.ok) {
|
|
330
|
+
const body = await res.text();
|
|
331
|
+
throw new Error(`Auto-submit returned ${res.status}: ${body.slice(0, 200)}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return res.json();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Recursively redact sensitive keys from a value.
|
|
339
|
+
*
|
|
340
|
+
* @param {any} value Current node being traversed.
|
|
341
|
+
* @param {number} [depth=0] Recursion depth guard.
|
|
342
|
+
* @returns {any} Sanitized clone.
|
|
343
|
+
*/
|
|
344
|
+
_deepRedact(value, depth = 0) {
|
|
345
|
+
// Guard against circular structures and extremely deep objects.
|
|
346
|
+
if (depth > 10) return '[DEPTH_LIMIT]';
|
|
347
|
+
|
|
348
|
+
if (Array.isArray(value)) {
|
|
349
|
+
return value.map(item => this._deepRedact(item, depth + 1));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (value !== null && typeof value === 'object') {
|
|
353
|
+
const out = {};
|
|
354
|
+
for (const [k, v] of Object.entries(value)) {
|
|
355
|
+
if (this._isSensitiveKey(k)) {
|
|
356
|
+
out[k] = REDACTED_SENTINEL;
|
|
357
|
+
} else {
|
|
358
|
+
out[k] = this._deepRedact(v, depth + 1);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Primitives pass through unchanged.
|
|
365
|
+
return value;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Return true if a field name matches any sensitive term.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} key
|
|
372
|
+
* @returns {boolean}
|
|
373
|
+
*/
|
|
374
|
+
_isSensitiveKey(key) {
|
|
375
|
+
const lower = key.toLowerCase();
|
|
376
|
+
return this.sensitiveFields.some(term => lower.includes(term));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Produce safe metadata about tool output without storing its contents.
|
|
381
|
+
*
|
|
382
|
+
* @param {any} output
|
|
383
|
+
* @returns {{ type: string, sizeChars: number|null }}
|
|
384
|
+
*/
|
|
385
|
+
_outputMeta(output) {
|
|
386
|
+
if (output === undefined || output === null) {
|
|
387
|
+
return { type: 'null', sizeChars: 0 };
|
|
388
|
+
}
|
|
389
|
+
if (typeof output === 'string') {
|
|
390
|
+
return { type: 'string', sizeChars: output.length };
|
|
391
|
+
}
|
|
392
|
+
if (typeof output === 'object') {
|
|
393
|
+
try {
|
|
394
|
+
const serialized = JSON.stringify(output);
|
|
395
|
+
return { type: 'object', sizeChars: serialized.length };
|
|
396
|
+
} catch {
|
|
397
|
+
return { type: 'object', sizeChars: null };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { type: typeof output, sizeChars: null };
|
|
401
|
+
}
|
|
402
|
+
}
|