@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.

Files changed (58) hide show
  1. package/.context/public/guides/ema-user-guide.md +7 -6
  2. package/.context/public/guides/mcp-tools-guide.md +46 -23
  3. package/dist/config/index.js +11 -0
  4. package/dist/config/workflow-patterns.js +361 -0
  5. package/dist/mcp/autobuilder.js +2 -2
  6. package/dist/mcp/domain/generation-schema.js +15 -9
  7. package/dist/mcp/domain/structural-rules.js +3 -3
  8. package/dist/mcp/domain/validation-rules.js +20 -27
  9. package/dist/mcp/domain/workflow-generator.js +3 -3
  10. package/dist/mcp/domain/workflow-graph.js +1 -1
  11. package/dist/mcp/guidance.js +60 -1
  12. package/dist/mcp/handlers/conversation/adapter.js +13 -0
  13. package/dist/mcp/handlers/conversation/create.js +19 -0
  14. package/dist/mcp/handlers/conversation/delete.js +18 -0
  15. package/dist/mcp/handlers/conversation/formatters.js +62 -0
  16. package/dist/mcp/handlers/conversation/history.js +15 -0
  17. package/dist/mcp/handlers/conversation/index.js +43 -0
  18. package/dist/mcp/handlers/conversation/list.js +40 -0
  19. package/dist/mcp/handlers/conversation/messages.js +13 -0
  20. package/dist/mcp/handlers/conversation/rename.js +16 -0
  21. package/dist/mcp/handlers/conversation/send.js +90 -0
  22. package/dist/mcp/handlers/data/index.js +169 -3
  23. package/dist/mcp/handlers/feedback/client-id.js +49 -0
  24. package/dist/mcp/handlers/feedback/coalesce.js +167 -0
  25. package/dist/mcp/handlers/feedback/index.js +42 -1
  26. package/dist/mcp/handlers/feedback/outbox.js +301 -0
  27. package/dist/mcp/handlers/feedback/probes.js +127 -0
  28. package/dist/mcp/handlers/feedback/remote-store.js +59 -0
  29. package/dist/mcp/handlers/feedback/store.js +13 -1
  30. package/dist/mcp/handlers/persona/delete.js +7 -28
  31. package/dist/mcp/handlers/persona/update.js +7 -26
  32. package/dist/mcp/handlers/persona/version.js +30 -15
  33. package/dist/mcp/handlers/template/adapter.js +23 -0
  34. package/dist/mcp/handlers/template/crud.js +174 -0
  35. package/dist/mcp/handlers/template/index.js +6 -7
  36. package/dist/mcp/handlers/workflow/adapter.js +30 -46
  37. package/dist/mcp/handlers/workflow/index.js +2 -2
  38. package/dist/mcp/handlers/workflow/validation.js +2 -2
  39. package/dist/mcp/knowledge-guidance-topics.js +90 -53
  40. package/dist/mcp/knowledge.js +7 -357
  41. package/dist/mcp/prompts.js +5 -5
  42. package/dist/mcp/resources-dynamic.js +46 -38
  43. package/dist/mcp/resources-validation.js +5 -5
  44. package/dist/mcp/server.js +38 -5
  45. package/dist/mcp/tools.js +340 -8
  46. package/dist/sdk/client-adapter.js +90 -2
  47. package/dist/sdk/client.js +7 -0
  48. package/dist/sdk/ema-client.js +242 -27
  49. package/dist/sdk/generated/agent-catalog.js +96 -39
  50. package/dist/sdk/generated/deprecated-actions.js +1 -1
  51. package/dist/sdk/grpc-client.js +67 -5
  52. package/dist/sync/central-factory.js +86 -0
  53. package/dist/sync/central-version-storage.js +387 -0
  54. package/dist/sync/dis-port.js +75 -0
  55. package/dist/sync/version-policy.js +29 -31
  56. package/dist/sync/version-storage-interface.js +11 -0
  57. package/dist/sync/version-storage.js +22 -22
  58. 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 { createVersionStorage } from "../../../sync/version-storage.js";
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
- // Strict guidance: snapshot before any destructive change (local, forced)
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 storage = createVersionStorage(process.cwd());
74
- const engine = createVersionPolicyEngine(storage);
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 (!snap.created || !snap.version) {
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 (e) {
100
- if (!force) {
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 { createVersionStorage } from "../../../sync/version-storage.js";
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
- // Strict guidance: snapshot before any update/change (local, forced)
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 storage = createVersionStorage(process.cwd());
242
- const engine = createVersionPolicyEngine(storage);
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 (!snap.created || !snap.version) {
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 (e) {
264
- if (!force) {
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