@ema.co/mcp-toolkit 2026.2.27 → 2026.2.28
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.
Potentially problematic release.
This version of @ema.co/mcp-toolkit might be problematic. Click here for more details.
- package/.context/public/guides/ema-user-guide.md +7 -6
- package/.context/public/guides/mcp-tools-guide.md +46 -23
- package/dist/config/index.js +11 -0
- package/dist/config/workflow-patterns.js +361 -0
- package/dist/mcp/autobuilder.js +2 -2
- package/dist/mcp/domain/generation-schema.js +15 -9
- package/dist/mcp/domain/structural-rules.js +3 -3
- package/dist/mcp/domain/validation-rules.js +20 -27
- package/dist/mcp/domain/workflow-generator.js +3 -3
- package/dist/mcp/domain/workflow-graph.js +1 -1
- package/dist/mcp/guidance.js +60 -1
- package/dist/mcp/handlers/conversation/adapter.js +13 -0
- package/dist/mcp/handlers/conversation/create.js +19 -0
- package/dist/mcp/handlers/conversation/delete.js +18 -0
- package/dist/mcp/handlers/conversation/formatters.js +62 -0
- package/dist/mcp/handlers/conversation/history.js +15 -0
- package/dist/mcp/handlers/conversation/index.js +43 -0
- package/dist/mcp/handlers/conversation/list.js +40 -0
- package/dist/mcp/handlers/conversation/messages.js +13 -0
- package/dist/mcp/handlers/conversation/rename.js +16 -0
- package/dist/mcp/handlers/conversation/send.js +90 -0
- package/dist/mcp/handlers/data/index.js +169 -3
- package/dist/mcp/handlers/feedback/client-id.js +49 -0
- package/dist/mcp/handlers/feedback/coalesce.js +167 -0
- package/dist/mcp/handlers/feedback/index.js +42 -1
- package/dist/mcp/handlers/feedback/outbox.js +301 -0
- package/dist/mcp/handlers/feedback/probes.js +127 -0
- package/dist/mcp/handlers/feedback/remote-store.js +59 -0
- package/dist/mcp/handlers/feedback/store.js +13 -1
- package/dist/mcp/handlers/persona/delete.js +7 -28
- package/dist/mcp/handlers/persona/update.js +7 -26
- package/dist/mcp/handlers/persona/version.js +30 -15
- package/dist/mcp/handlers/template/adapter.js +23 -0
- package/dist/mcp/handlers/template/crud.js +174 -0
- package/dist/mcp/handlers/template/index.js +6 -7
- package/dist/mcp/handlers/workflow/adapter.js +30 -46
- package/dist/mcp/handlers/workflow/index.js +2 -2
- package/dist/mcp/handlers/workflow/validation.js +2 -2
- package/dist/mcp/knowledge-guidance-topics.js +90 -53
- package/dist/mcp/knowledge.js +7 -357
- package/dist/mcp/prompts.js +5 -5
- package/dist/mcp/resources-dynamic.js +46 -38
- package/dist/mcp/resources-validation.js +5 -5
- package/dist/mcp/server.js +38 -5
- package/dist/mcp/tools.js +340 -8
- package/dist/sdk/client-adapter.js +90 -2
- package/dist/sdk/client.js +7 -0
- package/dist/sdk/ema-client.js +242 -27
- package/dist/sdk/generated/agent-catalog.js +96 -39
- package/dist/sdk/generated/deprecated-actions.js +1 -1
- package/dist/sdk/grpc-client.js +67 -5
- package/dist/sync/central-factory.js +86 -0
- package/dist/sync/central-version-storage.js +387 -0
- package/dist/sync/dis-port.js +75 -0
- package/dist/sync/version-policy.js +29 -31
- package/dist/sync/version-storage-interface.js +11 -0
- package/dist/sync/version-storage.js +22 -22
- package/package.json +2 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox — Local accumulation and batched flush of feedback/telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Raw entries are appended to ~/.ema-mcp/outbox/pending.jsonl. When flush
|
|
5
|
+
* conditions are met, the pending file is atomically renamed, coalesced into
|
|
6
|
+
* a FeedbackDigest, uploaded, and logged to sent-log.jsonl for transparency.
|
|
7
|
+
*
|
|
8
|
+
* Flush triggers:
|
|
9
|
+
* 1. MCP server boot (catches data from previous sessions, including npx)
|
|
10
|
+
* 2. Entry count threshold (default 50)
|
|
11
|
+
* 3. Periodic timer (30 min)
|
|
12
|
+
* 4. Age-based: oldest entry > 2 hours
|
|
13
|
+
*
|
|
14
|
+
* Race safety: rename-and-process ensures concurrent writers don't corrupt
|
|
15
|
+
* in-flight data. New entries go to a fresh pending.jsonl while the renamed
|
|
16
|
+
* batch is processed.
|
|
17
|
+
*/
|
|
18
|
+
import { promises as fs } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { getEmaMcpDir } from "./client-id.js";
|
|
22
|
+
import { buildDigest } from "./coalesce.js";
|
|
23
|
+
import { uploadDigest } from "./remote-store.js";
|
|
24
|
+
import { getOrCreateClientId } from "./client-id.js";
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// Constants
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
const OUTBOX_DIR_NAME = "outbox";
|
|
29
|
+
const PENDING_FILE = "pending.jsonl";
|
|
30
|
+
const SENT_LOG_FILE = "sent-log.jsonl";
|
|
31
|
+
const DEFAULT_FLUSH_THRESHOLD = 50;
|
|
32
|
+
const FLUSH_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
|
33
|
+
const MAX_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
34
|
+
const MAX_PENDING_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB safety cap
|
|
35
|
+
let flushTimer = null;
|
|
36
|
+
let flushInProgress = false;
|
|
37
|
+
let entryCountSinceLastFlush = 0;
|
|
38
|
+
let oldestEntryTs = null;
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// Paths
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
function outboxDir() {
|
|
43
|
+
return join(getEmaMcpDir(), OUTBOX_DIR_NAME);
|
|
44
|
+
}
|
|
45
|
+
function pendingPath() {
|
|
46
|
+
return join(outboxDir(), PENDING_FILE);
|
|
47
|
+
}
|
|
48
|
+
function sentLogPath() {
|
|
49
|
+
return join(outboxDir(), SENT_LOG_FILE);
|
|
50
|
+
}
|
|
51
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
// Append
|
|
53
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
/**
|
|
55
|
+
* Append a raw entry to the local outbox. Fire-and-forget — never throws.
|
|
56
|
+
* Returns true if the caller should trigger a flush check.
|
|
57
|
+
*/
|
|
58
|
+
export async function appendToOutbox(entry) {
|
|
59
|
+
try {
|
|
60
|
+
const dir = outboxDir();
|
|
61
|
+
await fs.mkdir(dir, { recursive: true });
|
|
62
|
+
const path = pendingPath();
|
|
63
|
+
// Safety cap — don't let the file grow unbounded
|
|
64
|
+
try {
|
|
65
|
+
const stat = await fs.stat(path);
|
|
66
|
+
if (stat.size > MAX_PENDING_SIZE_BYTES) {
|
|
67
|
+
console.error("[FEEDBACK] Outbox pending file exceeds 5 MB — skipping append");
|
|
68
|
+
return true; // trigger flush to clear it
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// File doesn't exist yet — fine
|
|
73
|
+
}
|
|
74
|
+
const line = JSON.stringify(entry) + "\n";
|
|
75
|
+
await fs.appendFile(path, line, "utf-8");
|
|
76
|
+
entryCountSinceLastFlush++;
|
|
77
|
+
if (oldestEntryTs === null) {
|
|
78
|
+
oldestEntryTs = Date.now();
|
|
79
|
+
}
|
|
80
|
+
const threshold = getFlushThreshold();
|
|
81
|
+
return entryCountSinceLastFlush >= threshold;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
// Flush
|
|
89
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
/**
|
|
91
|
+
* Flush the outbox: rename pending → processing, coalesce, upload, log.
|
|
92
|
+
* Safe for concurrent calls — only one flush runs at a time.
|
|
93
|
+
*/
|
|
94
|
+
export async function flushOutbox(toolkitVersion) {
|
|
95
|
+
if (flushInProgress)
|
|
96
|
+
return;
|
|
97
|
+
flushInProgress = true;
|
|
98
|
+
try {
|
|
99
|
+
// Recover orphaned processing files from previous crashes
|
|
100
|
+
await recoverOrphanedBatches(toolkitVersion);
|
|
101
|
+
const pending = pendingPath();
|
|
102
|
+
// Check if there's anything to flush
|
|
103
|
+
try {
|
|
104
|
+
const stat = await fs.stat(pending);
|
|
105
|
+
if (stat.size === 0)
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return; // No pending file
|
|
110
|
+
}
|
|
111
|
+
// Atomic rename — new entries go to a fresh pending.jsonl
|
|
112
|
+
const processingPath = join(outboxDir(), `processing_${Date.now()}_${randomUUID().slice(0, 8)}.jsonl`);
|
|
113
|
+
try {
|
|
114
|
+
await fs.rename(pending, processingPath);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return; // Rename failed — another flush may have beaten us
|
|
118
|
+
}
|
|
119
|
+
// Reset counters after rename
|
|
120
|
+
entryCountSinceLastFlush = 0;
|
|
121
|
+
oldestEntryTs = null;
|
|
122
|
+
// Parse entries from renamed file
|
|
123
|
+
const entries = await readOutboxEntries(processingPath);
|
|
124
|
+
if (entries.length === 0) {
|
|
125
|
+
await fs.unlink(processingPath).catch(() => { });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Build coalesced digest
|
|
129
|
+
const clientId = await getOrCreateClientId();
|
|
130
|
+
const digest = buildDigest(entries, clientId, toolkitVersion);
|
|
131
|
+
// Attempt upload
|
|
132
|
+
const uploaded = await uploadDigest(digest);
|
|
133
|
+
// Log to sent-log for transparency (regardless of upload success)
|
|
134
|
+
await logSentDigest(digest, uploaded);
|
|
135
|
+
// Clean up processing file
|
|
136
|
+
await fs.unlink(processingPath).catch(() => { });
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
console.error(`[FEEDBACK] Flush error: ${err instanceof Error ? err.message : String(err)}`);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
flushInProgress = false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Check if a flush should be triggered based on entry count or age.
|
|
147
|
+
*/
|
|
148
|
+
export function shouldFlush() {
|
|
149
|
+
const threshold = getFlushThreshold();
|
|
150
|
+
if (entryCountSinceLastFlush >= threshold)
|
|
151
|
+
return true;
|
|
152
|
+
if (oldestEntryTs !== null) {
|
|
153
|
+
const age = Date.now() - oldestEntryTs;
|
|
154
|
+
if (age >= MAX_AGE_MS)
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Start the periodic flush timer. Idempotent — safe to call multiple times.
|
|
161
|
+
*/
|
|
162
|
+
export function startFlushTimer(toolkitVersion) {
|
|
163
|
+
if (flushTimer)
|
|
164
|
+
return;
|
|
165
|
+
flushTimer = setInterval(() => {
|
|
166
|
+
if (shouldFlush()) {
|
|
167
|
+
flushOutbox(toolkitVersion).catch(() => { });
|
|
168
|
+
}
|
|
169
|
+
}, FLUSH_INTERVAL_MS);
|
|
170
|
+
// Don't keep the process alive just for feedback flushing
|
|
171
|
+
if (flushTimer && typeof flushTimer === "object" && "unref" in flushTimer) {
|
|
172
|
+
flushTimer.unref();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Stop the periodic flush timer and perform a final flush.
|
|
177
|
+
*/
|
|
178
|
+
export async function stopFlushTimer(toolkitVersion) {
|
|
179
|
+
if (flushTimer) {
|
|
180
|
+
clearInterval(flushTimer);
|
|
181
|
+
flushTimer = null;
|
|
182
|
+
}
|
|
183
|
+
await flushOutbox(toolkitVersion);
|
|
184
|
+
}
|
|
185
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
186
|
+
// Stats (for diagnostics)
|
|
187
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
188
|
+
export async function getOutboxStats() {
|
|
189
|
+
let pendingEntries = 0;
|
|
190
|
+
let pendingSize = 0;
|
|
191
|
+
let sentDigests = 0;
|
|
192
|
+
try {
|
|
193
|
+
const stat = await fs.stat(pendingPath());
|
|
194
|
+
pendingSize = stat.size;
|
|
195
|
+
const content = await fs.readFile(pendingPath(), "utf-8");
|
|
196
|
+
pendingEntries = content.split("\n").filter((l) => l.trim()).length;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// No pending file
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const content = await fs.readFile(sentLogPath(), "utf-8");
|
|
203
|
+
sentDigests = content.split("\n").filter((l) => l.trim()).length;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// No sent log
|
|
207
|
+
}
|
|
208
|
+
return { pending_entries: pendingEntries, pending_size_bytes: pendingSize, sent_digests: sentDigests };
|
|
209
|
+
}
|
|
210
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
211
|
+
// Internals
|
|
212
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
213
|
+
async function readOutboxEntries(filePath) {
|
|
214
|
+
try {
|
|
215
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
216
|
+
return content
|
|
217
|
+
.split("\n")
|
|
218
|
+
.filter((line) => line.trim().length > 0)
|
|
219
|
+
.map((line) => {
|
|
220
|
+
try {
|
|
221
|
+
return JSON.parse(line);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
.filter((entry) => entry !== null);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function recoverOrphanedBatches(toolkitVersion) {
|
|
234
|
+
try {
|
|
235
|
+
const dir = outboxDir();
|
|
236
|
+
const files = await fs.readdir(dir).catch(() => []);
|
|
237
|
+
for (const f of files) {
|
|
238
|
+
if (!f.startsWith("processing_") || !f.endsWith(".jsonl"))
|
|
239
|
+
continue;
|
|
240
|
+
const orphanPath = join(dir, f);
|
|
241
|
+
const entries = await readOutboxEntries(orphanPath);
|
|
242
|
+
if (entries.length > 0) {
|
|
243
|
+
const clientId = await getOrCreateClientId();
|
|
244
|
+
const digest = buildDigest(entries, clientId, toolkitVersion);
|
|
245
|
+
const uploaded = await uploadDigest(digest);
|
|
246
|
+
await logSentDigest(digest, uploaded);
|
|
247
|
+
}
|
|
248
|
+
await fs.unlink(orphanPath).catch(() => { });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Best-effort recovery
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const MAX_SENT_LOG_ENTRIES = 200;
|
|
256
|
+
async function logSentDigest(digest, uploaded) {
|
|
257
|
+
try {
|
|
258
|
+
const logEntry = {
|
|
259
|
+
ts: digest.flushed_at,
|
|
260
|
+
client_id: digest.client_id,
|
|
261
|
+
entry_count: digest.entry_count,
|
|
262
|
+
feedback_count: digest.feedback.length,
|
|
263
|
+
telemetry_count: digest.telemetry.length,
|
|
264
|
+
uploaded,
|
|
265
|
+
period: digest.period,
|
|
266
|
+
};
|
|
267
|
+
const dir = outboxDir();
|
|
268
|
+
await fs.mkdir(dir, { recursive: true });
|
|
269
|
+
const logPath = sentLogPath();
|
|
270
|
+
await fs.appendFile(logPath, JSON.stringify(logEntry) + "\n", "utf-8");
|
|
271
|
+
// Probabilistic rotation to prevent unbounded growth
|
|
272
|
+
if (Date.now() % 10 < 2) {
|
|
273
|
+
await rotateSentLog(logPath);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// Best-effort logging
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function rotateSentLog(filePath) {
|
|
281
|
+
try {
|
|
282
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
283
|
+
const lines = content.split("\n").filter((l) => l.trim().length > 0);
|
|
284
|
+
if (lines.length <= MAX_SENT_LOG_ENTRIES)
|
|
285
|
+
return;
|
|
286
|
+
const kept = lines.slice(lines.length - MAX_SENT_LOG_ENTRIES);
|
|
287
|
+
await fs.writeFile(filePath, kept.join("\n") + "\n", "utf-8");
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Best-effort rotation
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function getFlushThreshold() {
|
|
294
|
+
const envVal = process.env.EMA_FEEDBACK_BATCH_THRESHOLD;
|
|
295
|
+
if (envVal) {
|
|
296
|
+
const parsed = parseInt(envVal, 10);
|
|
297
|
+
if (!isNaN(parsed) && parsed > 0)
|
|
298
|
+
return parsed;
|
|
299
|
+
}
|
|
300
|
+
return DEFAULT_FLUSH_THRESHOLD;
|
|
301
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe System — Targeted questions injected into MCP tool responses.
|
|
3
|
+
*
|
|
4
|
+
* Maintainers define probes (questions about specific tool behavior, pain points,
|
|
5
|
+
* or feature usage). The system injects one probe at a time into tool responses
|
|
6
|
+
* via a `_probe` field. Agents can respond using toolkit_feedback(category="probe_response").
|
|
7
|
+
*
|
|
8
|
+
* Probes are:
|
|
9
|
+
* - Rate-limited (max one per N tool calls)
|
|
10
|
+
* - Context-aware (only shown for matching tools/operations)
|
|
11
|
+
* - Tracked per-client (each probe shown at most once per client session)
|
|
12
|
+
*/
|
|
13
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
// Active Probes Registry
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
/**
|
|
17
|
+
* Active probes defined by maintainers. Add/remove probes here to control
|
|
18
|
+
* what questions agents are asked. Keep this list short (3-5 probes max).
|
|
19
|
+
*/
|
|
20
|
+
export const ACTIVE_PROBES = [
|
|
21
|
+
{
|
|
22
|
+
id: "workflow-deploy-confidence-2026q1",
|
|
23
|
+
question: "When deploying workflows, how confident were you that the workflow_def you built was correct before deploying? What would increase your confidence?",
|
|
24
|
+
tools: ["workflow"],
|
|
25
|
+
operations: ["deploy"],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "guidance-usefulness-2026q1",
|
|
29
|
+
question: "Did the server instructions and _tip/_next_step guidance help you use the tools effectively? What was missing or unhelpful?",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "error-recovery-2026q1",
|
|
33
|
+
question: "When you encountered an error from an MCP tool, were you able to recover and try again? What information was missing from the error?",
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Session State
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
function isFeedbackDisabled() {
|
|
40
|
+
return process.env.EMA_FEEDBACK_DISABLED === "1" ||
|
|
41
|
+
process.env.EMA_FEEDBACK_DISABLED === "true";
|
|
42
|
+
}
|
|
43
|
+
const PROBE_INTERVAL = 10; // show probe every N tool calls
|
|
44
|
+
const respondedProbes = new Set();
|
|
45
|
+
const shownProbes = new Set();
|
|
46
|
+
let toolCallCount = 0;
|
|
47
|
+
let probesShownCount = 0;
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
// Public API
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
/**
|
|
52
|
+
* Pick a probe to inject into a tool response, or return undefined.
|
|
53
|
+
* Rate-limited and context-aware.
|
|
54
|
+
*/
|
|
55
|
+
export function pickProbeForTool(toolName, operation) {
|
|
56
|
+
toolCallCount++;
|
|
57
|
+
if (isFeedbackDisabled())
|
|
58
|
+
return undefined;
|
|
59
|
+
if (toolCallCount % PROBE_INTERVAL !== 0)
|
|
60
|
+
return undefined;
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
const candidates = ACTIVE_PROBES.filter((p) => {
|
|
63
|
+
if (respondedProbes.has(p.id))
|
|
64
|
+
return false;
|
|
65
|
+
if (shownProbes.has(p.id))
|
|
66
|
+
return false;
|
|
67
|
+
if (p.tools && p.tools.length > 0 && !p.tools.includes(toolName))
|
|
68
|
+
return false;
|
|
69
|
+
if (p.operations && p.operations.length > 0 && !p.operations.includes(operation))
|
|
70
|
+
return false;
|
|
71
|
+
if (p.active_from && now < p.active_from)
|
|
72
|
+
return false;
|
|
73
|
+
if (p.active_until && now > p.active_until)
|
|
74
|
+
return false;
|
|
75
|
+
return true;
|
|
76
|
+
});
|
|
77
|
+
if (candidates.length === 0)
|
|
78
|
+
return undefined;
|
|
79
|
+
const probe = candidates[0];
|
|
80
|
+
shownProbes.add(probe.id);
|
|
81
|
+
probesShownCount++;
|
|
82
|
+
return probe;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Record that a client responded to a probe.
|
|
86
|
+
* Called when toolkit_feedback receives a probe_response submission.
|
|
87
|
+
*/
|
|
88
|
+
export function markProbeResponded(probeId) {
|
|
89
|
+
respondedProbes.add(probeId);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build the `_probe` field for a tool response.
|
|
93
|
+
*/
|
|
94
|
+
export function formatProbeField(probe) {
|
|
95
|
+
return {
|
|
96
|
+
id: probe.id,
|
|
97
|
+
type: "probe",
|
|
98
|
+
question: probe.question,
|
|
99
|
+
how_to_respond: `toolkit_feedback(method="submit", category="probe_response", message="<your answer>", context="${probe.id}")`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Inject a probe into a response object if conditions are met.
|
|
104
|
+
* Mutates the response in-place for simplicity.
|
|
105
|
+
*/
|
|
106
|
+
export function maybeInjectProbe(response, toolName, operation) {
|
|
107
|
+
// Don't inject probes into feedback tool responses (avoid recursion)
|
|
108
|
+
if (toolName === "toolkit_feedback")
|
|
109
|
+
return;
|
|
110
|
+
const probe = pickProbeForTool(toolName, operation);
|
|
111
|
+
if (probe) {
|
|
112
|
+
response._probe = formatProbeField(probe);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Get the number of probes shown in this session (for digest metadata). */
|
|
116
|
+
export function getProbesShownCount() {
|
|
117
|
+
return probesShownCount;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Reset session state (for testing).
|
|
121
|
+
*/
|
|
122
|
+
export function resetProbeState() {
|
|
123
|
+
respondedProbes.clear();
|
|
124
|
+
shownProbes.clear();
|
|
125
|
+
toolCallCount = 0;
|
|
126
|
+
probesShownCount = 0;
|
|
127
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote Store — Fire-and-forget digest upload to cloud storage.
|
|
3
|
+
*
|
|
4
|
+
* Uploads a FeedbackDigest to a pre-configured GCS bucket via anonymous PUT.
|
|
5
|
+
* The upload is best-effort with a hard timeout — failure never blocks the
|
|
6
|
+
* main toolkit execution.
|
|
7
|
+
*
|
|
8
|
+
* Endpoint and behavior are controlled by environment variables:
|
|
9
|
+
* - EMA_FEEDBACK_ENDPOINT: Full base URL for PUT (e.g., "https://storage.googleapis.com/my-bucket")
|
|
10
|
+
* - EMA_FEEDBACK_DISABLED: Set to "1" or "true" to disable remote uploads entirely
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
const UPLOAD_TIMEOUT_MS = 5_000;
|
|
14
|
+
// TODO: Migrate to em1-feedback bucket (ema-dev-401514 project)
|
|
15
|
+
// Once GCS permissions are configured for anonymous PUT on em1-feedback,
|
|
16
|
+
// update this and copy existing digests from ema-mcp-feedback.
|
|
17
|
+
// const DEFAULT_ENDPOINT = "https://storage.googleapis.com/em1-feedback";
|
|
18
|
+
const DEFAULT_ENDPOINT = "https://storage.googleapis.com/ema-mcp-feedback";
|
|
19
|
+
/**
|
|
20
|
+
* Upload a digest to the remote feedback store. Returns true on success.
|
|
21
|
+
* Never throws — all errors are swallowed (fire-and-forget).
|
|
22
|
+
*/
|
|
23
|
+
export async function uploadDigest(digest) {
|
|
24
|
+
const endpoint = getEndpoint();
|
|
25
|
+
if (!endpoint)
|
|
26
|
+
return false;
|
|
27
|
+
try {
|
|
28
|
+
const date = digest.flushed_at.slice(0, 10);
|
|
29
|
+
const key = `digests/${date}/digest_${digest.client_id}_${Date.now()}_${randomUUID()}.json`;
|
|
30
|
+
const url = `${endpoint}/${key}`;
|
|
31
|
+
const resp = await fetch(url, {
|
|
32
|
+
method: "PUT",
|
|
33
|
+
body: JSON.stringify(digest),
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
36
|
+
});
|
|
37
|
+
if (resp.ok) {
|
|
38
|
+
console.error(`[FEEDBACK] Digest uploaded: ${key} (${digest.entry_count} entries)`);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
console.error(`[FEEDBACK] Upload failed: HTTP ${resp.status} — ${await resp.text().catch(() => "(no body)")}`);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error(`[FEEDBACK] Upload error: ${err instanceof Error ? err.message : String(err)}`);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function getEndpoint() {
|
|
50
|
+
const disabled = process.env.EMA_FEEDBACK_DISABLED === "1" ||
|
|
51
|
+
process.env.EMA_FEEDBACK_DISABLED === "true";
|
|
52
|
+
if (disabled)
|
|
53
|
+
return undefined;
|
|
54
|
+
return process.env.EMA_FEEDBACK_ENDPOINT || DEFAULT_ENDPOINT;
|
|
55
|
+
}
|
|
56
|
+
/** Check whether remote upload is configured and not disabled. */
|
|
57
|
+
export function isRemoteEnabled() {
|
|
58
|
+
return !!getEndpoint();
|
|
59
|
+
}
|
|
@@ -12,6 +12,8 @@ import { promises as fs } from "node:fs";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
14
|
import { getToolkitRoot } from "../../../sdk/paths.js";
|
|
15
|
+
import { appendToOutbox } from "./outbox.js";
|
|
16
|
+
import { isRemoteEnabled } from "./remote-store.js";
|
|
15
17
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
18
|
// Constants
|
|
17
19
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -95,6 +97,12 @@ export async function submitFeedback(entry, rootOverride) {
|
|
|
95
97
|
await appendJsonl(filePath, full);
|
|
96
98
|
// Log to stderr for visibility (stdout is the MCP stdio transport - never write there)
|
|
97
99
|
console.error(`[FEEDBACK] ${full.category}: ${full.message}`);
|
|
100
|
+
// Mirror to outbox for centralized collection (fire-and-forget).
|
|
101
|
+
// Skipped when user has opted out, or for probe_response (written separately in index.ts).
|
|
102
|
+
if (isRemoteEnabled() && full.category !== "probe_response") {
|
|
103
|
+
const outboxEntry = { kind: "feedback", data: full };
|
|
104
|
+
appendToOutbox(outboxEntry).catch(() => { });
|
|
105
|
+
}
|
|
98
106
|
return full;
|
|
99
107
|
}
|
|
100
108
|
/**
|
|
@@ -109,8 +117,12 @@ export async function recordTelemetry(entry, rootOverride) {
|
|
|
109
117
|
...entry,
|
|
110
118
|
};
|
|
111
119
|
await appendJsonl(filePath, full);
|
|
120
|
+
// Mirror to outbox for centralized collection (fire-and-forget)
|
|
121
|
+
if (isRemoteEnabled()) {
|
|
122
|
+
const outboxEntry = { kind: "telemetry", data: full };
|
|
123
|
+
appendToOutbox(outboxEntry).catch(() => { });
|
|
124
|
+
}
|
|
112
125
|
// Rotate periodically (check every 100 writes based on simple modulo of time)
|
|
113
|
-
// We use a lightweight check: rotate if file > MAX * 1.5 entries
|
|
114
126
|
const now = Date.now();
|
|
115
127
|
if (now % 100 < 5) {
|
|
116
128
|
await rotateJsonl(filePath, MAX_TELEMETRY_ENTRIES);
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* Deletes a persona with safety confirmation.
|
|
5
5
|
*/
|
|
6
6
|
import { resolvePersona, normalizeTriggerType } from "../utils.js";
|
|
7
|
-
import {
|
|
8
|
-
import { createVersionPolicyEngine } from "../../../sync/version-policy.js";
|
|
7
|
+
import { createCentralStorageEngine } from "../../../sync/central-factory.js";
|
|
9
8
|
/**
|
|
10
9
|
* Handle persona(mode="delete") - delete persona
|
|
11
10
|
*
|
|
@@ -65,47 +64,27 @@ export async function handleDelete(args, client) {
|
|
|
65
64
|
hint: "Use persona(method='delete', id='...', confirm=true) to proceed with deletion.",
|
|
66
65
|
};
|
|
67
66
|
}
|
|
68
|
-
//
|
|
67
|
+
// Best-effort: snapshot before any destructive change (via central DIS storage)
|
|
69
68
|
const force = args.force;
|
|
70
69
|
const targetEnv = args.env ?? "unknown";
|
|
71
70
|
if (fullPersona) {
|
|
72
71
|
try {
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const snap = engine.forceCreateVersion(fullPersona, {
|
|
72
|
+
const { engine } = createCentralStorageEngine(client, fullPersona.id);
|
|
73
|
+
const snap = await engine.forceCreateVersion(fullPersona, {
|
|
76
74
|
environment: targetEnv,
|
|
77
75
|
tenant_id: targetEnv,
|
|
78
76
|
message: "Before delete",
|
|
79
77
|
created_by: "mcp-toolkit",
|
|
80
78
|
});
|
|
81
|
-
if (
|
|
82
|
-
if (!force) {
|
|
83
|
-
return {
|
|
84
|
-
error: "Failed to create pre-delete snapshot (required before delete)",
|
|
85
|
-
persona_id: persona.id,
|
|
86
|
-
details: snap.reason,
|
|
87
|
-
hint: "Fix snapshotting (workspace storage) and retry deletion. Use force=true to bypass.",
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
// force=true: proceed without snapshot (emergency only)
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
79
|
+
if (snap.created && snap.version) {
|
|
93
80
|
deletionSummary.version_snapshot = {
|
|
94
81
|
id: snap.version.id,
|
|
95
82
|
version_name: snap.version.version_name,
|
|
96
83
|
};
|
|
97
84
|
}
|
|
98
85
|
}
|
|
99
|
-
catch
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
error: "Failed to create pre-delete snapshot (required before delete)",
|
|
103
|
-
persona_id: persona.id,
|
|
104
|
-
details: e instanceof Error ? e.message : String(e),
|
|
105
|
-
hint: "Fix snapshotting (workspace storage) and retry deletion. Use force=true to bypass.",
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
// force=true: proceed without snapshot (emergency only)
|
|
86
|
+
catch {
|
|
87
|
+
// Best-effort: snapshot failure does not block the delete
|
|
109
88
|
}
|
|
110
89
|
}
|
|
111
90
|
// Perform deletion
|
|
@@ -16,8 +16,7 @@ import { PROJECT_TYPES } from "../../knowledge.js";
|
|
|
16
16
|
import { resolvePersona, validateWidgetsForApi } from "../utils.js";
|
|
17
17
|
import { validateSearchDataSourceConsistency, validationToHandlerResult } from "../workflow/validation.js";
|
|
18
18
|
import { checkRemovedParams } from "../deprecation.js";
|
|
19
|
-
import {
|
|
20
|
-
import { createVersionPolicyEngine } from "../../../sync/version-policy.js";
|
|
19
|
+
import { createCentralStorageEngine } from "../../../sync/central-factory.js";
|
|
21
20
|
import { fingerprintPersona } from "../../../sync.js";
|
|
22
21
|
/**
|
|
23
22
|
* Apply WorkflowSpec changes to an existing workflow_def.
|
|
@@ -234,41 +233,23 @@ export async function handleUpdate(args, client) {
|
|
|
234
233
|
hint: "Re-run persona(method='get') / workflow(mode='get') to fetch the latest state, re-apply your changes, then update again. Use force=true only if you intend to overwrite out-of-band changes.",
|
|
235
234
|
};
|
|
236
235
|
}
|
|
237
|
-
//
|
|
236
|
+
// Best-effort: snapshot before any update/change (via central DIS storage)
|
|
238
237
|
const targetEnv = args.env ?? "unknown";
|
|
239
238
|
let versionSnapshot;
|
|
240
239
|
try {
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
const snap = engine.forceCreateVersion(fullPersona, {
|
|
240
|
+
const { engine } = createCentralStorageEngine(client, fullPersona.id);
|
|
241
|
+
const snap = await engine.forceCreateVersion(fullPersona, {
|
|
244
242
|
environment: targetEnv,
|
|
245
243
|
tenant_id: targetEnv,
|
|
246
244
|
message: "Pre-update snapshot",
|
|
247
245
|
created_by: "mcp-toolkit",
|
|
248
246
|
});
|
|
249
|
-
if (
|
|
250
|
-
if (!force) {
|
|
251
|
-
return {
|
|
252
|
-
error: "Failed to create pre-update snapshot (required before update)",
|
|
253
|
-
persona_id: persona.id,
|
|
254
|
-
details: snap.reason,
|
|
255
|
-
hint: "Fix snapshotting (workspace storage) or retry with force=true for emergency override.",
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
else {
|
|
247
|
+
if (snap.created && snap.version) {
|
|
260
248
|
versionSnapshot = { id: snap.version.id, version_name: snap.version.version_name };
|
|
261
249
|
}
|
|
262
250
|
}
|
|
263
|
-
catch
|
|
264
|
-
|
|
265
|
-
return {
|
|
266
|
-
error: "Failed to create pre-update snapshot (required before update)",
|
|
267
|
-
persona_id: persona.id,
|
|
268
|
-
details: e instanceof Error ? e.message : String(e),
|
|
269
|
-
hint: "Retry after fixing local workspace write access, or use force=true for emergency override.",
|
|
270
|
-
};
|
|
271
|
-
}
|
|
251
|
+
catch {
|
|
252
|
+
// Best-effort: snapshot failure does not block the update
|
|
272
253
|
}
|
|
273
254
|
const existingProtoConfig = (fullPersona?.proto_config ?? persona.proto_config ?? {});
|
|
274
255
|
const existingWorkflow = fullPersona
|