idiolect-mcp 0.4.0 → 0.5.1

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/server.mjs CHANGED
@@ -24,13 +24,54 @@
24
24
  // }
25
25
  // }
26
26
 
27
- import { readFileSync } from "node:fs";
27
+ import { readFileSync, realpathSync } from "node:fs";
28
+ import { fileURLToPath } from "node:url";
28
29
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
29
30
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
30
31
  import {
31
32
  CallToolRequestSchema,
32
33
  ListToolsRequestSchema,
33
34
  } from "@modelcontextprotocol/sdk/types.js";
35
+ import {
36
+ publicMcpState,
37
+ } from "./public-contract.js";
38
+
39
+ const progressiveCatalog = JSON.parse(
40
+ readFileSync(new URL("./progressive-catalog.json", import.meta.url), "utf8"),
41
+ );
42
+ const PROGRESSIVE_ACCOUNT_TOOL_NAMES = new Set(
43
+ progressiveCatalog.tools.map((tool) => tool.name),
44
+ );
45
+ /** Cached tool names stay callable through the remote compatibility adapter. */
46
+ export const accountRuntimeCompatibilityToolNames = Object.freeze([
47
+ "feedback",
48
+ "refine",
49
+ "profile",
50
+ "manage_profile",
51
+ "learn_writing_style",
52
+ "get_my_voice",
53
+ "create_my_voice",
54
+ "draft_in_voice",
55
+ "rewrite_in_voice",
56
+ "score_voice",
57
+ "set_up_my_voice",
58
+ "record_voice_feedback",
59
+ "write_as_me",
60
+ "rewrite_as_me",
61
+ "list_writing_profiles",
62
+ "list_writing_profile_samples",
63
+ "add_samples_to_writing_profile",
64
+ "replace_samples_in_writing_profile",
65
+ "remove_sample_from_writing_profile",
66
+ "apply_writing_template",
67
+ "clear_default_writing_template",
68
+ "add_writing_preference",
69
+ "remove_writing_preference",
70
+ "remove_learned_writing_preference",
71
+ "list_style_contributions",
72
+ "remove_style_contribution",
73
+ ]);
74
+ const REMOTE_COMPATIBILITY_TOOL_NAMES = new Set(accountRuntimeCompatibilityToolNames);
34
75
 
35
76
  const BASE = process.env.IDIOLECT_BASE || "https://idiolect.app";
36
77
  const ACCOUNT_BASE = BASE.replace(/\/+$/, "");
@@ -75,27 +116,247 @@ async function scoreVoice(candidates, region) {
75
116
  return res.json();
76
117
  }
77
118
 
78
- async function readErrorBody(res) {
119
+ export function formatLegacyScoreVoiceResult(result) {
120
+ const scores = Array.isArray(result?.scores) ? result.scores : [];
121
+ const lines = scores
122
+ .map((score, index) => {
123
+ const text = typeof score?.text === "string" ? score.text : "";
124
+ const excerpt = `${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`;
125
+ const measured = score?.scored !== false && Number.isFinite(score?.voiceMatch);
126
+ const reportable = measured && score?.absoluteScoreReportable !== false;
127
+ const withheld = score?.scored === true && score?.absoluteScoreReportable === false;
128
+ const match = reportable
129
+ ? `Voice Match ${score.voiceMatch}/100`
130
+ : withheld
131
+ ? `Voice Match not calibrated${score?.reason ? ` — ${score.reason}` : ""}`
132
+ : `Voice Match unavailable${score?.reason ? ` — ${score.reason}` : ""}`;
133
+ return `#${index + 1} · ${match} — ${excerpt}`;
134
+ })
135
+ .join("\n");
136
+ const provenance = result?.metric?.validated === true
137
+ ? "Relative Voice Match on the validated instrument"
138
+ : result?.metric?.validated === false
139
+ ? "Relative Voice Match from a quick estimate"
140
+ : "Relative Voice Match; metric provenance unavailable";
141
+
142
+ return `${lines}\n\n(${provenance} — higher = reads more like you; not a forensic identity claim.)`;
143
+ }
144
+
145
+ const CREDIT_STATES = new Set([
146
+ "free_beta",
147
+ "balance_available",
148
+ "low_balance",
149
+ "zero_balance",
150
+ "legacy_unlimited",
151
+ "accounting_unavailable",
152
+ ]);
153
+ const CREDIT_MODES = new Set(["free", "credits"]);
154
+
155
+ function plainObject(value) {
156
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
157
+ }
158
+
159
+ function safeManageUrl(value) {
160
+ if (value === null) return null;
161
+ if (typeof value !== "string") return undefined;
79
162
  try {
80
- const contentType = res.headers.get("content-type") || "";
81
- const body = contentType.includes("application/json")
82
- ? JSON.stringify(await res.json())
83
- : await res.text();
84
- return body.trim().slice(0, 500);
163
+ const parsed = new URL(value);
164
+ const accountOrigin = new URL(ACCOUNT_BASE).origin;
165
+ const firstParty = parsed.hostname === "idiolect.app" || parsed.hostname.endsWith(".idiolect.app");
166
+ if (
167
+ !["https:", "http:"].includes(parsed.protocol)
168
+ || (parsed.origin !== accountOrigin && !firstParty)
169
+ || parsed.pathname !== "/settings"
170
+ || parsed.username
171
+ || parsed.password
172
+ || parsed.search !== "?section=credits"
173
+ || parsed.hash
174
+ ) return undefined;
175
+ return `${parsed.origin}/settings?section=credits`;
85
176
  } catch {
86
- return "";
177
+ return undefined;
178
+ }
179
+ }
180
+
181
+ function creditMessage(credits) {
182
+ const { state, balance, charged, manageUrl, mode } = credits;
183
+ if (state === "free_beta") {
184
+ return charged === 0
185
+ ? "Free during beta. No credits are being used."
186
+ : "Completed free during beta. No credit was used.";
187
+ }
188
+ if (state === "legacy_unlimited") return "Unlimited generation access is active. Credits are not used.";
189
+ if (state === "accounting_unavailable") {
190
+ return mode === "free"
191
+ ? "Free during beta. The credit balance is temporarily unavailable; generation remains available."
192
+ : "The credit balance is temporarily unavailable. Generation is paused; no credit was used.";
193
+ }
194
+ if (state === "zero_balance") {
195
+ return charged === 1
196
+ ? "This request completed with the last credit."
197
+ : `The credit balance is 0. A draft or rewrite needs 1 credit. Credit management: ${manageUrl}.`;
87
198
  }
199
+ if (charged === 1) return `1 credit used. ${balance} ${balance === 1 ? "remains" : "remain"}.`;
200
+ if (state === "low_balance") return `${balance} credit${balance === 1 ? "" : "s"} remain. Credit management: ${manageUrl}.`;
201
+ return `${balance} credits available. Each draft or rewrite uses 1 credit.`;
88
202
  }
89
203
 
90
- async function accountErrorMessage(res, toolName) {
91
- if (res.status === 401) {
92
- return "Idiolect AI API key is invalid or expired (401). Check IDIOLECT_API_KEY.";
204
+ function validCreditInvariant(credits) {
205
+ const {
206
+ state,
207
+ mode,
208
+ balance,
209
+ balancePreview,
210
+ unitCost,
211
+ charged,
212
+ unlimited,
213
+ accountingAvailable,
214
+ generationAvailable,
215
+ purchaseRequired,
216
+ manageUrl,
217
+ } = credits;
218
+ if (state === "free_beta") {
219
+ return mode === "free" && balance !== null && balancePreview && unitCost === 0 && charged === 0
220
+ && !unlimited && accountingAvailable && generationAvailable && !purchaseRequired && manageUrl === null;
221
+ }
222
+ if (state === "legacy_unlimited") {
223
+ return mode === "credits" && balance === null && !balancePreview && unitCost === 0 && charged === 0
224
+ && unlimited && accountingAvailable && generationAvailable && !purchaseRequired && manageUrl === null;
225
+ }
226
+ if (state === "accounting_unavailable") {
227
+ return balance === null && !balancePreview && charged === 0 && !unlimited && !accountingAvailable
228
+ && !purchaseRequired && manageUrl === null && unitCost === (mode === "free" ? 0 : 1)
229
+ && generationAvailable === (mode === "free");
230
+ }
231
+ if (mode !== "credits" || balance === null || balancePreview || unitCost !== 1 || unlimited || !accountingAvailable) {
232
+ return false;
93
233
  }
94
- if (res.status === 402) {
95
- return `${toolName}: Idiolect AI Pro is required for this account API call (402).`;
234
+ if (state === "balance_available") {
235
+ return balance > 5 && generationAvailable && !purchaseRequired && manageUrl === null;
236
+ }
237
+ if (state === "low_balance") {
238
+ return balance >= 1 && balance <= 5 && generationAvailable && !purchaseRequired && manageUrl !== null;
239
+ }
240
+ return state === "zero_balance" && balance === 0 && !generationAvailable && purchaseRequired && manageUrl !== null;
241
+ }
242
+
243
+ /**
244
+ * Rebuild the public credit envelope from known primitives only. Never copy an
245
+ * upstream message or unknown field into an MCP response.
246
+ */
247
+ export function safeCreditPresentation(value) {
248
+ if (!plainObject(value)) return null;
249
+ const balance = value.balance;
250
+ const manageUrl = safeManageUrl(value.manageUrl);
251
+ if (
252
+ !CREDIT_STATES.has(value.state)
253
+ || !CREDIT_MODES.has(value.mode)
254
+ || !(balance === null || (Number.isSafeInteger(balance) && balance >= 0))
255
+ || typeof value.balancePreview !== "boolean"
256
+ || ![0, 1].includes(value.unitCost)
257
+ || ![0, 1].includes(value.charged)
258
+ || typeof value.unlimited !== "boolean"
259
+ || typeof value.accountingAvailable !== "boolean"
260
+ || typeof value.generationAvailable !== "boolean"
261
+ || typeof value.purchaseRequired !== "boolean"
262
+ || manageUrl === undefined
263
+ || typeof value.replayed !== "boolean"
264
+ ) return null;
265
+
266
+ const credits = {
267
+ state: value.state,
268
+ mode: value.mode,
269
+ balance,
270
+ balancePreview: value.balancePreview,
271
+ unitCost: value.unitCost,
272
+ charged: value.charged,
273
+ unlimited: value.unlimited,
274
+ accountingAvailable: value.accountingAvailable,
275
+ generationAvailable: value.generationAvailable,
276
+ purchaseRequired: value.purchaseRequired,
277
+ manageUrl,
278
+ replayed: value.replayed,
279
+ };
280
+ if (!validCreditInvariant(credits)) return null;
281
+ return { ...credits, message: creditMessage(credits) };
282
+ }
283
+
284
+ function safeCreditToolErrorPayload(value) {
285
+ if (!plainObject(value) || !plainObject(value.error) || !plainObject(value.retry)) return null;
286
+ const credits = safeCreditPresentation(value.credits);
287
+ if (!credits) return null;
288
+ const purchaseRequired = value.status === "purchase_required"
289
+ && value.error.code === "insufficient_credits"
290
+ && credits.state === "zero_balance"
291
+ && credits.mode === "credits"
292
+ && credits.balance === 0
293
+ && credits.charged === 0
294
+ && value.retry.after === "balance_updated";
295
+ const accountingUnavailable = value.status === "accounting_unavailable"
296
+ && value.error.code === "credit_accounting_unavailable"
297
+ && credits.state === "accounting_unavailable"
298
+ && credits.mode === "credits"
299
+ && credits.charged === 0
300
+ && value.retry.after === "accounting_available";
301
+ if (
302
+ (!purchaseRequired && !accountingUnavailable)
303
+ || value.error.retryable !== true
304
+ || value.retry.safe !== true
305
+ || value.retry.idempotent !== true
306
+ || value.retry.scope !== "pre_generation"
307
+ ) return null;
308
+
309
+ const message = purchaseRequired
310
+ ? `This request needs 1 credit; the balance is 0. Nothing was charged. Credit management: ${credits.manageUrl}. The same request remains safe to retry after the balance changes.`
311
+ : "The credit balance is temporarily unavailable. No generation started and no credit was used. The same request remains safe to retry after accounting recovers.";
312
+ return {
313
+ status: value.status,
314
+ message,
315
+ error: { code: value.error.code, retryable: true },
316
+ credits: { ...credits, message },
317
+ retry: {
318
+ safe: true,
319
+ idempotent: true,
320
+ scope: "pre_generation",
321
+ after: value.retry.after,
322
+ },
323
+ };
324
+ }
325
+
326
+ async function accountRequestError(res, toolName) {
327
+ if ((res.headers.get("content-type") || "").includes("application/json")) {
328
+ try {
329
+ const payload = await res.json();
330
+ const creditContent = safeCreditToolErrorPayload(payload);
331
+ if (creditContent) {
332
+ return new AccountRequestError(creditContent.message, res.status, creditContent);
333
+ }
334
+ const creationContent = toolName === "create_my_voice" ? safeCreationFailurePayload(payload) : null;
335
+ if (creationContent) {
336
+ return new AccountRequestError(creationContent.message, res.status, creationContent);
337
+ }
338
+ } catch {
339
+ // The bounded status fallback below deliberately ignores malformed bodies.
340
+ }
341
+ }
342
+
343
+ const message = res.status === 401
344
+ ? "Idiolect AI API key is invalid or expired (401). Check IDIOLECT_API_KEY."
345
+ : res.status === 402
346
+ ? `${toolName}: generation access is unavailable for this Idiolect AI account (402).`
347
+ : res.status === 429
348
+ ? `${toolName}: Idiolect AI is temporarily busy (429). Try again shortly.`
349
+ : `${toolName}: Idiolect AI account API failed (${res.status}).`;
350
+ return new AccountRequestError(message, res.status);
351
+ }
352
+
353
+ class AccountRequestError extends Error {
354
+ constructor(message, status, structuredContent = null) {
355
+ super(message);
356
+ this.name = "AccountRequestError";
357
+ this.status = status;
358
+ this.structuredContent = structuredContent;
96
359
  }
97
- const body = await readErrorBody(res);
98
- return `${toolName}: Idiolect AI account API failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}`;
99
360
  }
100
361
 
101
362
  async function accountRequest(path, { method = "GET", body, toolName }) {
@@ -107,10 +368,26 @@ async function accountRequest(path, { method = "GET", body, toolName }) {
107
368
  },
108
369
  body: body ? JSON.stringify(body) : undefined,
109
370
  });
110
- if (!res.ok) throw new Error(await accountErrorMessage(res, toolName));
371
+ if (!res.ok) throw await accountRequestError(res, toolName);
111
372
  return res.json();
112
373
  }
113
374
 
375
+ async function accountMcpRequest(name, args) {
376
+ const res = await fetch(`${ACCOUNT_BASE}/api/mcp`, {
377
+ method: "POST",
378
+ headers: {
379
+ Authorization: `Bearer ${API_KEY}`,
380
+ accept: "application/json, text/event-stream",
381
+ "content-type": "application/json",
382
+ },
383
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
384
+ });
385
+ if (!res.ok) throw await accountRequestError(res, name);
386
+ const body = await res.json();
387
+ if (body.error || !body.result) throw new Error(`${name}: Idiolect AI returned an invalid MCP response.`);
388
+ return body.result;
389
+ }
390
+
114
391
  function formatValue(value) {
115
392
  if (value === undefined || value === null) return "";
116
393
  if (typeof value === "string") return value;
@@ -118,17 +395,18 @@ function formatValue(value) {
118
395
  return JSON.stringify(value, null, 2);
119
396
  }
120
397
 
121
- function formatAlternatives(alternatives) {
122
- if (!Array.isArray(alternatives) || alternatives.length === 0) return "";
123
- return `\n\nAlternatives:\n${alternatives
124
- .map((alternative, i) => `${i + 1}. ${formatValue(alternative)}`)
125
- .join("\n")}`;
126
- }
127
-
128
- function scoreSummary(r) {
398
+ export function scoreSummary(r) {
129
399
  const lines = [];
130
400
  if (r.voiceMatch !== undefined) {
131
- lines.push(r.voiceMatch === null ? "Voice Match: unavailable" : `Voice Match: ${formatValue(r.voiceMatch)}`);
401
+ const withheld = r.measured === true
402
+ && r.absoluteScoreReportable === false;
403
+ lines.push(
404
+ r.voiceMatch !== null && !withheld
405
+ ? `Voice Match: ${formatValue(r.voiceMatch)}`
406
+ : withheld
407
+ ? `Voice Match: not calibrated — ${r.reason || "insufficient evidence"}.`
408
+ : "Voice Match: unavailable",
409
+ );
132
410
  }
133
411
  if (r.delta !== undefined) lines.push(r.delta === null ? "Delta: unavailable" : `Delta: ${formatValue(r.delta)}`);
134
412
  if (r.candidateDelta !== undefined && r.candidateDelta !== r.delta) {
@@ -136,6 +414,10 @@ function scoreSummary(r) {
136
414
  }
137
415
  if (r.validated !== undefined) lines.push(`Validated: ${formatValue(r.validated)}`);
138
416
  if (r.measured !== undefined) lines.push(`Measured: ${formatValue(r.measured)}`);
417
+ if (r.scoreConfidence) lines.push(`Score confidence: ${formatValue(r.scoreConfidence)}`);
418
+ if (r.absoluteScoreReportable !== undefined) {
419
+ lines.push(`Absolute score reportable: ${formatValue(r.absoluteScoreReportable)}`);
420
+ }
139
421
  if (r.reason) lines.push(`Reason: ${formatValue(r.reason)}`);
140
422
  if (r.instrument) lines.push(`Instrument: ${formatValue(r.instrument)}`);
141
423
  if (r.register) lines.push(`Calibration register: ${formatValue(r.register)}`);
@@ -145,14 +427,122 @@ function scoreSummary(r) {
145
427
 
146
428
  // Mirrors REWRITE_REGISTERS in lib/connectors.ts. This file ships as a standalone npm
147
429
  // package with no build step, so it cannot import from the app — keep the two in sync.
148
- const REWRITE_REGISTERS = ["email", "slack", "essay"];
430
+ const REWRITE_REGISTERS = ["email", "slack", "social", "essay"];
149
431
  const REGISTER_SET = new Set(REWRITE_REGISTERS);
432
+ const CHANGE_SCOPES = ["light_edit", "rewrite", "transform"];
433
+ const CHANGE_SCOPE_SET = new Set([...CHANGE_SCOPES, "polish"]);
434
+ const EFFECT_KINDS = ["register", "tone", "humour", "candour", "energy", "voice", "other"];
435
+ const EFFECT_KIND_SET = new Set(EFFECT_KINDS);
436
+ const EFFECT_STRENGTHS = ["light", "full"];
437
+ const EFFECT_STRENGTH_SET = new Set(EFFECT_STRENGTHS);
438
+ const EFFECT_PROPERTY_SET = new Set(["kind", "ask", "strength"]);
439
+ // Who wrote the source or brief; mirrors lib/generation/change-request.ts's SOURCE_AUTHORS.
440
+ const SOURCE_AUTHORS = ["user", "host", "other", "unknown"];
441
+ const SOURCE_AUTHOR_SET = new Set(SOURCE_AUTHORS);
442
+
443
+ function backtickedValues(values) {
444
+ return values.map((value) => `\`${value}\``).join(", ");
445
+ }
446
+
447
+ const CHANGE_SCOPE_SCHEMA = {
448
+ type: "string",
449
+ enum: CHANGE_SCOPES,
450
+ description: "`changeScope` carries how much the person wants changed, from their own words.",
451
+ };
452
+ const EFFECTS_SCHEMA = {
453
+ type: "array",
454
+ maxItems: 20,
455
+ items: {
456
+ type: "object",
457
+ properties: {
458
+ kind: {
459
+ type: "string",
460
+ enum: EFFECT_KINDS,
461
+ description: "`kind` names the tone, register or effect the requester identified.",
462
+ },
463
+ ask: {
464
+ type: "string",
465
+ minLength: 1,
466
+ maxLength: 2000,
467
+ description: "`ask` preserves the requester's own words for this effect.",
468
+ },
469
+ strength: {
470
+ type: "string",
471
+ enum: EFFECT_STRENGTHS,
472
+ description: "`strength` records whether the requester asked for the effect to be `light` or `full`.",
473
+ },
474
+ },
475
+ required: ["kind", "ask"],
476
+ additionalProperties: false,
477
+ },
478
+ description: "`effects` carries each named tone, register or effect.",
479
+ };
480
+ const SOURCE_AUTHOR_SCHEMA = {
481
+ type: "string",
482
+ enum: SOURCE_AUTHORS,
483
+ description:
484
+ "Who wrote this text. `user` only when the person wrote it themselves; `host` when you (the assistant) drafted it; `other` for a third party's text. Leave it out if unsure.",
485
+ };
486
+ const BRIEF_AUTHOR_SCHEMA = {
487
+ type: "string",
488
+ enum: SOURCE_AUTHORS,
489
+ description:
490
+ "Who wrote the brief. `user` only when the person wrote it themselves; `host` when you (the assistant) drafted it; `other` for a third party's text. Leave it out if unsure.",
491
+ };
492
+ const CALIBRATION_SCHEMA = {
493
+ type: "object",
494
+ properties: {
495
+ direction: {
496
+ type: "string",
497
+ enum: ["direct", "balanced", "gentle"],
498
+ description: "The user's answer to the single task-local calibration question.",
499
+ },
500
+ },
501
+ required: ["direction"],
502
+ additionalProperties: false,
503
+ };
504
+
505
+ /** Validates `sourceAuthor`/`briefAuthor` and defaults an absent one to `"unknown"` on the
506
+ * returned copy of `args`. Returns a tool error, never throws. */
507
+ export function authorProvenanceError(args, toolName, key) {
508
+ if (args[key] === undefined) return null;
509
+ if (typeof args[key] !== "string" || !SOURCE_AUTHOR_SET.has(args[key])) {
510
+ return toolError(`${toolName}: \`${key}\` must be one of ${backtickedValues(SOURCE_AUTHORS)}.`);
511
+ }
512
+ return null;
513
+ }
514
+
515
+ export function changeRequestError(args, toolName) {
516
+ if (args.changeScope !== undefined
517
+ && (typeof args.changeScope !== "string" || !CHANGE_SCOPE_SET.has(args.changeScope))) {
518
+ return toolError(`${toolName}: \`changeScope\` must be one of ${backtickedValues(CHANGE_SCOPES)}.`);
519
+ }
520
+ if (args.effects === undefined) return null;
521
+ if (!Array.isArray(args.effects)) return toolError(`${toolName}: \`effects\` must be a list.`);
522
+ if (args.effects.length > 20) return toolError(`${toolName}: \`effects\` accepts at most 20 entries.`);
523
+ for (const effect of args.effects) {
524
+ if (!plainObject(effect) || Object.keys(effect).some((key) => !EFFECT_PROPERTY_SET.has(key))) {
525
+ return toolError(`${toolName}: each \`effects\` entry must contain only \`kind\`, \`ask\`, and \`strength\`.`);
526
+ }
527
+ if (typeof effect.kind !== "string" || !EFFECT_KIND_SET.has(effect.kind)) {
528
+ return toolError(`${toolName}: \`effects[].kind\` must be one of ${backtickedValues(EFFECT_KINDS)}.`);
529
+ }
530
+ if (typeof effect.ask !== "string" || effect.ask.length < 1 || effect.ask.length > 2000) {
531
+ return toolError(`${toolName}: \`effects[].ask\` must be 1 to 2,000 characters.`);
532
+ }
533
+ if (effect.strength !== undefined
534
+ && (typeof effect.strength !== "string" || !EFFECT_STRENGTH_SET.has(effect.strength))) {
535
+ return toolError(`${toolName}: \`effects[].strength\` must be one of ${backtickedValues(EFFECT_STRENGTHS)}.`);
536
+ }
537
+ }
538
+ return null;
539
+ }
150
540
 
151
541
  const legacyTools = [
152
542
  {
153
543
  name: "get_my_voice",
154
544
  description:
155
- "Load the user's Idiolect AI Voice Card. Call this BEFORE writing anything on the user's behalf (emails, posts, messages, docs) and then write strictly in the returned voice. Returns a style guide describing how the user actually writes.",
545
+ "Load the user's Idiolect AI Voice Card when they ask for personal outward-facing prose, such as an email, message, post, or reply, to match their established voice. Do not use it for ordinary factual writing or repository artifacts. Returns a style guide describing how the user actually writes.",
156
546
  inputSchema: {
157
547
  type: "object",
158
548
  properties: {
@@ -215,14 +605,95 @@ const PROVENANCE_OUTPUT_PROPERTIES = {
215
605
  validated: { type: "boolean", description: "True only when this result was measured by the validated LUAR instrument." },
216
606
  instrument: { type: ["string", "null"] },
217
607
  reason: { type: ["string", "null"] },
218
- register: { type: "string" },
219
- approximate: { type: "boolean" },
220
608
  voice: SELECTED_VOICE_SCHEMA,
221
609
  evidence: EVIDENCE_OUTPUT_SCHEMA,
222
610
  };
611
+ const CREDIT_OUTPUT_SCHEMA = {
612
+ type: "object",
613
+ additionalProperties: false,
614
+ properties: {
615
+ state: {
616
+ type: "string",
617
+ enum: ["free_beta", "balance_available", "low_balance", "zero_balance", "legacy_unlimited", "accounting_unavailable"],
618
+ },
619
+ mode: { type: "string", enum: ["free", "credits"] },
620
+ balance: { type: ["integer", "null"], minimum: 0 },
621
+ balancePreview: { type: "boolean" },
622
+ unitCost: { type: "number", enum: [0, 1] },
623
+ charged: { type: "number", enum: [0, 1] },
624
+ unlimited: { type: "boolean" },
625
+ accountingAvailable: { type: "boolean" },
626
+ generationAvailable: { type: "boolean" },
627
+ purchaseRequired: { type: "boolean" },
628
+ manageUrl: { type: ["string", "null"] },
629
+ replayed: { type: "boolean" },
630
+ message: { type: "string" },
631
+ },
632
+ required: [
633
+ "state",
634
+ "mode",
635
+ "balance",
636
+ "balancePreview",
637
+ "unitCost",
638
+ "charged",
639
+ "unlimited",
640
+ "accountingAvailable",
641
+ "generationAvailable",
642
+ "purchaseRequired",
643
+ "manageUrl",
644
+ "replayed",
645
+ "message",
646
+ ],
647
+ };
648
+ const CREDIT_ERROR_SCHEMA = {
649
+ type: "object",
650
+ additionalProperties: false,
651
+ properties: {
652
+ code: { type: "string", enum: ["insufficient_credits", "credit_accounting_unavailable"] },
653
+ retryable: { type: "boolean", enum: [true] },
654
+ },
655
+ required: ["code", "retryable"],
656
+ };
657
+ const CREDIT_RETRY_SCHEMA = {
658
+ type: "object",
659
+ additionalProperties: false,
660
+ properties: {
661
+ safe: { type: "boolean", enum: [true] },
662
+ idempotent: { type: "boolean", enum: [true] },
663
+ scope: { type: "string", enum: ["pre_generation"] },
664
+ after: { type: "string", enum: ["balance_updated", "accounting_available"] },
665
+ },
666
+ required: ["safe", "idempotent", "scope", "after"],
667
+ };
668
+ // Carried by a credit-gate refusal only; a completed draft or rewrite no longer reports
669
+ // credits (13 constants in free mode) or a status beside `state` (IDI-381).
670
+ const CREDIT_GENERATION_OUTPUT_PROPERTIES = {
671
+ status: { type: "string", enum: ["purchase_required", "accounting_unavailable"] },
672
+ message: { type: "string" },
673
+ credits: CREDIT_OUTPUT_SCHEMA,
674
+ error: CREDIT_ERROR_SCHEMA,
675
+ retry: CREDIT_RETRY_SCHEMA,
676
+ };
677
+ const NEXT_ACTION_SCHEMA = {
678
+ type: "object",
679
+ additionalProperties: false,
680
+ properties: {
681
+ action: { type: "string", enum: ["collect_evidence", "reload_voice", "continue_writing", "ask_feedback", "retry", "done"] },
682
+ tool: { type: ["string", "null"], enum: ["create_my_voice", "get_my_voice", "record_voice_feedback", null] },
683
+ },
684
+ required: ["action", "tool"],
685
+ };
686
+ const PUBLIC_STATE_PROPERTIES = {
687
+ state: { type: "string", enum: ["ready", "missing", "thin", "stale", "complete", "failed"] },
688
+ message: { type: "string" },
689
+ next: NEXT_ACTION_SCHEMA,
690
+ };
223
691
  const VOICE_OUTPUT_SCHEMA = {
224
692
  type: "object",
225
693
  properties: {
694
+ ...PUBLIC_STATE_PROPERTIES,
695
+ status: { type: "string", enum: ["ready", "setup_required"] },
696
+ message: { type: "string" },
226
697
  id: { type: "string" },
227
698
  name: { type: ["string", "null"] },
228
699
  isDefault: { type: "boolean" },
@@ -235,58 +706,330 @@ const VOICE_OUTPUT_SCHEMA = {
235
706
  taboos: { type: "array", items: { type: "string" } },
236
707
  positions: { type: "array", items: { type: "string" }, description: "Real takes the user has expressed; never invent new ones." },
237
708
  evidence: EVIDENCE_OUTPUT_SCHEMA,
238
- register: { type: "string" },
239
- approximate: { type: "boolean" },
240
709
  },
241
710
  };
242
711
  const SCORE_OUTPUT_SCHEMA = {
243
712
  type: "object",
244
713
  properties: {
714
+ ...PUBLIC_STATE_PROPERTIES,
245
715
  voiceMatch: { type: ["number", "null"], description: "0–100; higher = closer to the user's own writing. Relative, not a forensic identity claim." },
246
716
  measured: { type: "boolean", description: "False when the candidate or corpus is in the unscored band, or the scorer is unavailable." },
247
717
  ...PROVENANCE_OUTPUT_PROPERTIES,
718
+ register: { type: "string", description: "Calibration register the anchors come from; the absolute is approximate outside it." },
719
+ approximate: { type: "boolean" },
248
720
  },
249
721
  };
722
+ // A draft or rewrite is the writing, the profile that wrote it and its evidence state.
723
+ // The score cluster (voiceMatch, measured, deltas, candidate fields, crossFit, the
724
+ // calibration constants) was constant on every call and is gone from the account API
725
+ // and from here (IDI-381). Voice Match is measured on score_voice.
726
+ // IDI-522 step 2: present only for a soft language-policy pass through the profile-less
727
+ // shared engine — mirrors app/api/mcp/schemas.ts's `caveatsOutputShape` so this stdio
728
+ // schema stays in step with every other writing-tool surface.
729
+ const CAVEATS_OUTPUT_PROPERTY = { caveats: { type: "array", items: { type: "string", enum: ["language"] } } };
250
730
  const REWRITE_OUTPUT_SCHEMA = {
251
731
  type: "object",
252
732
  properties: {
253
- rewritten: { type: "string", description: "The recommended text. When the cross-fit-selected candidate regresses on reporting evidence, this is the original." },
254
- voiceMatch: { type: ["number", "null"] },
255
- delta: { type: ["number", "null"], description: "Before→after Voice Match change." },
256
- measured: { type: "boolean" },
257
- crossFit: { type: "boolean" },
258
- candidateText: { type: "string", description: "Best generated alternative, retained even when the original is recommended." },
259
- candidateVoiceMatch: { type: ["number", "null"] },
260
- candidateDelta: { type: ["number", "null"] },
261
- improved: { type: ["boolean", "null"] },
262
- regressed: { type: ["boolean", "null"] },
263
- ...PROVENANCE_OUTPUT_PROPERTIES,
733
+ ...PUBLIC_STATE_PROPERTIES,
734
+ rewritten: { type: "string", description: "The rewritten text." },
735
+ reason: { type: ["string", "null"], description: "Present only when scoring is enabled and the engine did not measure; says why." },
736
+ voice: SELECTED_VOICE_SCHEMA,
737
+ evidence: EVIDENCE_OUTPUT_SCHEMA,
738
+ ...CREDIT_GENERATION_OUTPUT_PROPERTIES,
739
+ ...CAVEATS_OUTPUT_PROPERTY,
264
740
  },
265
741
  };
266
742
  const DRAFT_OUTPUT_SCHEMA = {
267
743
  type: "object",
268
744
  properties: {
745
+ ...PUBLIC_STATE_PROPERTIES,
269
746
  draft: { type: "string", description: "New prose drafted in the user's voice." },
270
- voiceMatch: { type: ["number", "null"] },
271
- measured: { type: "boolean" },
272
- crossFit: { type: "boolean" },
273
- ...PROVENANCE_OUTPUT_PROPERTIES,
747
+ reason: { type: ["string", "null"], description: "Present only when scoring is enabled and the engine did not measure; says why." },
748
+ voice: SELECTED_VOICE_SCHEMA,
749
+ evidence: EVIDENCE_OUTPUT_SCHEMA,
750
+ ...CREDIT_GENERATION_OUTPUT_PROPERTIES,
751
+ ...CAVEATS_OUTPUT_PROPERTY,
752
+ },
753
+ };
754
+ const CREATE_VOICE_OUTPUT_SCHEMA = {
755
+ type: "object",
756
+ properties: {
757
+ ...PUBLIC_STATE_PROPERTIES,
758
+ status: { type: "string", enum: ["ready", "more_evidence_required", "build_in_progress", "failed"] },
759
+ message: { type: "string" },
760
+ reason: { type: ["string", "null"] },
761
+ voiceId: { type: ["string", "null"] },
762
+ neededChars: { type: "number", description: "Present only when more writing is needed." },
763
+ neededBlocks: { type: "number", description: "Present only when more writing is needed." },
764
+ replay: { type: "boolean" },
765
+ },
766
+ };
767
+ const FEEDBACK_OUTPUT_SCHEMA = {
768
+ type: "object",
769
+ properties: {
770
+ ...PUBLIC_STATE_PROPERTIES,
771
+ needsFinalText: { type: "boolean" },
772
+ },
773
+ };
774
+ const PROFILE_REF_OPTIONAL_SCHEMA = {
775
+ type: "string", minLength: 1,
776
+ description: "The Writing Profile's id, or its exact name. Omit to use the current profile.",
777
+ };
778
+ const PROFILE_REF_REQUIRED_SCHEMA = {
779
+ type: "string", minLength: 1,
780
+ description: "The Writing Profile's id, or its exact name.",
781
+ };
782
+ const PROFILE_ID_SCHEMA = { type: "string", format: "uuid" };
783
+ const PROFILE_SAMPLE_SCHEMA = {
784
+ type: "array",
785
+ minItems: 1,
786
+ maxItems: 500,
787
+ items: { type: "string", maxLength: 20000 },
788
+ };
789
+ const OPTIONAL_PROFILE_SAMPLE_SCHEMA = {
790
+ type: "array",
791
+ maxItems: 500,
792
+ items: { type: "string", maxLength: 20000 },
793
+ };
794
+ const PROFILE_SAMPLE_STATE_SCHEMA = {
795
+ type: "object",
796
+ properties: {
797
+ profileId: PROFILE_ID_SCHEMA,
798
+ generation: { type: "integer", minimum: 0 },
799
+ samples: {
800
+ type: "array",
801
+ items: {
802
+ type: "object",
803
+ properties: {
804
+ id: PROFILE_ID_SCHEMA,
805
+ text: { type: "string" },
806
+ addedAt: { type: "string" },
807
+ },
808
+ required: ["id", "text", "addedAt"],
809
+ additionalProperties: false,
810
+ },
811
+ },
812
+ needsWriting: { type: "boolean" },
813
+ heldNotUsable: { type: "integer", minimum: 0 },
274
814
  },
815
+ required: ["profileId", "generation", "samples", "needsWriting", "heldNotUsable"],
816
+ additionalProperties: false,
817
+ };
818
+ /** What the caller must change to succeed; present only on evidence failures. */
819
+ const PROFILE_SHORTFALL_SCHEMA = {
820
+ type: "object",
821
+ properties: {
822
+ reason: { type: "string", enum: ["insufficient_input", "unsupported_language"] },
823
+ message: { type: "string" },
824
+ neededChars: { type: "integer", minimum: 0 },
825
+ neededBlocks: { type: "integer", minimum: 0 },
826
+ droppedBlocks: { type: "integer", minimum: 0 },
827
+ minimumChars: { type: "integer", minimum: 0 },
828
+ minimumBlocks: { type: "integer", minimum: 0 },
829
+ },
830
+ required: ["message"],
831
+ additionalProperties: false,
275
832
  };
276
833
 
277
- const accountTools = [
834
+ function profileActionOutput(value, codes) {
835
+ return {
836
+ type: "object",
837
+ properties: {
838
+ ok: { type: "boolean" },
839
+ value,
840
+ error: {
841
+ type: "object",
842
+ properties: {
843
+ code: { type: "string", enum: [...codes, "unexpected_error"] },
844
+ detail: PROFILE_SHORTFALL_SCHEMA,
845
+ },
846
+ required: ["code"],
847
+ additionalProperties: false,
848
+ },
849
+ },
850
+ required: ["ok"],
851
+ additionalProperties: false,
852
+ };
853
+ }
854
+
855
+ const accountToolDefinitions = [
278
856
  {
279
857
  name: "get_my_voice",
280
858
  description:
281
- "Use this when you are about to write anything on the user's behalf — emails, posts, messages, commit messages, PR descriptions, docs. It loads the user's Idiolect AI voice so you can write strictly as them.",
859
+ "Use this when the user asks for personal outward-facing prose, such as an email, message, post, or reply, to match their established voice. Do not use it for ordinary factual writing or repository artifacts. Reports whether setup is complete, and what is still needed when it is not.",
282
860
  inputSchema: { type: "object", properties: {} },
283
861
  outputSchema: VOICE_OUTPUT_SCHEMA,
284
862
  annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: "Load Idiolect AI Voice" },
285
863
  },
864
+ {
865
+ name: "create_my_voice",
866
+ description:
867
+ "Compatibility setup action for creating a first Writing Profile, from writing samples the user authored or from short answers about how they write. Use create_writing_profile for explicit multi-profile creation.",
868
+ inputSchema: {
869
+ type: "object",
870
+ properties: {
871
+ profileId: {
872
+ ...PROFILE_ID_SCHEMA,
873
+ description:
874
+ "Exact owned Writing Profile to receive this evidence. Omit only when creating the account's first profile or adding to the current profile.",
875
+ },
876
+ samples: {
877
+ type: "array",
878
+ maxItems: 500,
879
+ items: { type: "string", maxLength: 20000 },
880
+ description: "Distinct blocks of writing supplied or explicitly approved by the user.",
881
+ },
882
+ answers: {
883
+ type: "array",
884
+ maxItems: 20,
885
+ items: {
886
+ type: "object",
887
+ properties: {
888
+ question: { type: "string", maxLength: 500 },
889
+ answer: { type: "string", maxLength: 5000 },
890
+ },
891
+ required: ["question", "answer"],
892
+ additionalProperties: false,
893
+ },
894
+ description: "Optional guided questions and the user's own answers.",
895
+ },
896
+ name: { type: "string", maxLength: 60, description: "Optional private name for the Writing Profile." },
897
+ },
898
+ additionalProperties: false,
899
+ },
900
+ outputSchema: CREATE_VOICE_OUTPUT_SCHEMA,
901
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: "Create My Writing Profile" },
902
+ },
903
+ {
904
+ name: "list_writing_profiles",
905
+ description: "List the authenticated user's Writing Profiles and identify the current profile.",
906
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
907
+ outputSchema: profileActionOutput({ type: "array" }, []),
908
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false, destructiveHint: false, title: "List Writing Profiles" },
909
+ },
910
+ {
911
+ name: "list_writing_profile_samples",
912
+ description: "List the writing samples and stable sample IDs held by the exact Writing Profile identified by profileId. This does not change the profile.",
913
+ inputSchema: {
914
+ type: "object",
915
+ properties: { profileId: PROFILE_REF_REQUIRED_SCHEMA },
916
+ required: ["profileId"],
917
+ additionalProperties: false,
918
+ },
919
+ outputSchema: profileActionOutput(PROFILE_SAMPLE_STATE_SCHEMA, ["profile_not_found"]),
920
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false, destructiveHint: false, title: "List Writing Profile Samples" },
921
+ },
922
+ {
923
+ name: "create_writing_profile",
924
+ description: "Create a new Writing Profile from approved writing. This never changes an existing profile.",
925
+ inputSchema: {
926
+ type: "object",
927
+ properties: {
928
+ name: { type: "string", maxLength: 60 },
929
+ samples: OPTIONAL_PROFILE_SAMPLE_SCHEMA,
930
+ answers: {
931
+ type: "array",
932
+ maxItems: 20,
933
+ items: {
934
+ type: "object",
935
+ properties: {
936
+ question: { type: "string", maxLength: 500 },
937
+ answer: { type: "string", maxLength: 5000 },
938
+ },
939
+ required: ["question", "answer"],
940
+ additionalProperties: false,
941
+ },
942
+ },
943
+ },
944
+ additionalProperties: false,
945
+ },
946
+ outputSchema: profileActionOutput({ type: "object" }, [
947
+ "insufficient_evidence", "rate_limited", "request_conflict", "build_in_progress",
948
+ ]),
949
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false, destructiveHint: false, title: "Create Writing Profile" },
950
+ },
951
+ {
952
+ name: "add_samples_to_writing_profile",
953
+ description: "Add approved writing samples to the exact Writing Profile identified by profileId.",
954
+ inputSchema: {
955
+ type: "object",
956
+ properties: { profileId: PROFILE_REF_OPTIONAL_SCHEMA, samples: PROFILE_SAMPLE_SCHEMA },
957
+ required: ["samples"],
958
+ additionalProperties: false,
959
+ },
960
+ outputSchema: profileActionOutput({ type: "object" }, [
961
+ "insufficient_evidence", "request_conflict", "build_in_progress", "profile_not_found",
962
+ ]),
963
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false, destructiveHint: false, title: "Add Samples to Writing Profile" },
964
+ },
965
+ {
966
+ name: "replace_samples_in_writing_profile",
967
+ description: "Replace the writing sample set for the exact Writing Profile identified by profileId. Displaced writing is revoked, not deleted. Refuses when the profile holds writing this tool cannot list.",
968
+ inputSchema: {
969
+ type: "object",
970
+ properties: { profileId: PROFILE_REF_REQUIRED_SCHEMA, samples: OPTIONAL_PROFILE_SAMPLE_SCHEMA },
971
+ required: ["profileId", "samples"],
972
+ additionalProperties: false,
973
+ },
974
+ outputSchema: profileActionOutput(PROFILE_SAMPLE_STATE_SCHEMA, ["profile_not_found"]),
975
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: true, title: "Replace Samples in Writing Profile" },
976
+ },
977
+ {
978
+ name: "remove_sample_from_writing_profile",
979
+ description: "Remove the exact writing sample identified by sampleId from the Writing Profile identified by profileId. The sample stops being used; it is revoked, not erased.",
980
+ inputSchema: {
981
+ type: "object",
982
+ properties: { profileId: PROFILE_REF_REQUIRED_SCHEMA, sampleId: PROFILE_ID_SCHEMA },
983
+ required: ["profileId", "sampleId"],
984
+ additionalProperties: false,
985
+ },
986
+ outputSchema: profileActionOutput(PROFILE_SAMPLE_STATE_SCHEMA, ["profile_not_found", "sample_not_found"]),
987
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: true, title: "Remove Sample from Writing Profile" },
988
+ },
989
+ {
990
+ name: "set_current_writing_profile",
991
+ description: "Make the exact Writing Profile identified by profileId current for future writing.",
992
+ inputSchema: {
993
+ type: "object",
994
+ properties: { profileId: PROFILE_REF_REQUIRED_SCHEMA },
995
+ required: ["profileId"],
996
+ additionalProperties: false,
997
+ },
998
+ outputSchema: profileActionOutput({ type: "object" }, ["profile_not_found"]),
999
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false, destructiveHint: false, title: "Set Current Writing Profile" },
1000
+ },
1001
+ {
1002
+ name: "delete_writing_profile",
1003
+ description: "Delete the exact Writing Profile identified by profileId. A current profile may require a replacementProfileId.",
1004
+ inputSchema: {
1005
+ type: "object",
1006
+ properties: { profileId: PROFILE_REF_REQUIRED_SCHEMA, replacementProfileId: PROFILE_REF_REQUIRED_SCHEMA },
1007
+ required: ["profileId"],
1008
+ additionalProperties: false,
1009
+ },
1010
+ outputSchema: profileActionOutput({ type: "object" }, [
1011
+ "profile_not_found", "replacement_required", "invalid_replacement",
1012
+ ]),
1013
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: true, title: "Delete Writing Profile" },
1014
+ },
1015
+ {
1016
+ name: "get_credit_status",
1017
+ description:
1018
+ "Check the account's current generation-credit state. During the free beta, this confirms that no credits are being used.",
1019
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1020
+ outputSchema: CREDIT_OUTPUT_SCHEMA,
1021
+ annotations: {
1022
+ readOnlyHint: true,
1023
+ idempotentHint: true,
1024
+ openWorldHint: false,
1025
+ destructiveHint: false,
1026
+ title: "Check Credit Status",
1027
+ },
1028
+ },
286
1029
  {
287
1030
  name: "score_voice",
288
1031
  description:
289
- "Use this when you want to check how close a piece of text is to the user's own writing. Returns a Voice Match (0–100, relative — higher means closer to the user's voice; not a forensic identity claim).",
1032
+ "Use this only when the user explicitly asks to measure or check how close a piece of text is to their own writing. Returns a relative Voice Match with visible provenance, or an honest reason measurement is unavailable; never a forensic identity claim.",
290
1033
  inputSchema: {
291
1034
  type: "object",
292
1035
  properties: { text: { type: "string", description: "Text to score against the user's voice." } },
@@ -298,7 +1041,7 @@ const accountTools = [
298
1041
  {
299
1042
  name: "rewrite_in_voice",
300
1043
  description:
301
- "Rewrite supplied prose in the user's voice. Returns an in-voice alternative with a Voice Match and before→after delta; use it only when it preserves the user's meaning and fits their request.",
1044
+ "Rewrite supplied prose in the user's voice. Returns an in-voice alternative with measurement details and a before→after delta when measured; absolute Voice Match stays withheld unless independent held-out evidence and the target register support it. Use it only when it preserves the user's meaning and fits their request.",
302
1045
  inputSchema: {
303
1046
  type: "object",
304
1047
  properties: {
@@ -311,6 +1054,18 @@ const accountTools = [
311
1054
  description:
312
1055
  "Optional target register. Adapts the output format while keeping the user's voice; omitted, the input's own format is kept.",
313
1056
  },
1057
+ // The ask, kept out of `text`. /api/v1/rewrite has accepted this all along;
1058
+ // only the tool schema was missing it.
1059
+ instructions: {
1060
+ type: "string",
1061
+ description:
1062
+ "The user's actual change request — length, tone, audience, what to cut or keep, make it sound less AI-written, roughen it up, or make it sound like I am talking. Kept separate from `text`: put the request here, never inside the prose to rewrite. `changeScope` carries magnitude; `instructions` carries the requested change.",
1063
+ },
1064
+ changeScope: CHANGE_SCOPE_SCHEMA,
1065
+ effects: EFFECTS_SCHEMA,
1066
+ sourceAuthor: SOURCE_AUTHOR_SCHEMA,
1067
+ calibration: CALIBRATION_SCHEMA,
1068
+ profileId: { type: "string", format: "uuid", description: "Use this owned Writing Profile for this piece only. Select an exact profileId; this does not change the account default." },
314
1069
  },
315
1070
  required: ["text"],
316
1071
  },
@@ -320,31 +1075,496 @@ const accountTools = [
320
1075
  {
321
1076
  name: "draft_in_voice",
322
1077
  description:
323
- "Draft new prose in the user's voice from a supplied brief. Returns a draft with a Voice Match score for the user or agent to review.",
1078
+ "Draft new prose in the user's voice from a supplied `brief`, with the user's direction in the optional `instructions`. Returns a draft with measurement details for review; absolute Voice Match stays withheld unless independent held-out evidence and the target register support it.",
324
1079
  inputSchema: {
325
1080
  type: "object",
326
1081
  properties: {
327
- brief: { type: "string", description: "What to write: audience, goal, constraints, and source facts." },
1082
+ brief: {
1083
+ type: "string",
1084
+ description:
1085
+ "`brief` is the material: what the piece is about and what it must contain — its audience, its goal, and the source facts and specifics it has to carry. Never paste directions into `brief`; how to write it goes in `instructions`.",
1086
+ },
328
1087
  format: {
329
1088
  type: "string",
330
1089
  enum: ["linkedin", "x_post", "x_thread", "newsletter", "generic"],
331
- description: "Optional output format (default: generic).",
1090
+ description: "Optional output format (default: `generic`).",
1091
+ },
1092
+ // The direction, kept out of `brief`. /api/v1/draft accepts it; this schema is the
1093
+ // stdio package's own copy because it cannot import lib/connectors.ts. The bounds
1094
+ // mirror GENERATION_INPUT_LIMITS.draftInstructions, including the nonempty minimum
1095
+ // the remote catalog advertises — an optional field that accepts "" is a third
1096
+ // state neither side means.
1097
+ instructions: {
1098
+ type: "string",
1099
+ minLength: 1,
1100
+ maxLength: 8000,
1101
+ description:
1102
+ "`instructions` is the user's actual direction: how to write the piece — tone, length, format, structure, and what to leave out. Optional; omit it when the user gave none. Never paste directions into `brief`, which carries the material, and never put material here. A named output shape goes in `format`.",
332
1103
  },
1104
+ effects: EFFECTS_SCHEMA,
1105
+ briefAuthor: BRIEF_AUTHOR_SCHEMA,
1106
+ calibration: CALIBRATION_SCHEMA,
1107
+ profileId: { type: "string", format: "uuid", description: "Use this owned Writing Profile for this piece only. Select an exact profileId; this does not change the account default." },
333
1108
  },
334
1109
  required: ["brief"],
335
1110
  },
336
1111
  outputSchema: DRAFT_OUTPUT_SCHEMA,
337
1112
  annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: "Draft In Voice" },
338
1113
  },
1114
+ {
1115
+ name: "learn_writing_style",
1116
+ description:
1117
+ "Teach Idiolect AI one lasting thing about how the user writes. Send authored writing only with the user's consent, preferences in their words, or observations grounded in their own writing. This stores learning and never writes or revises prose.",
1118
+ inputSchema: {
1119
+ type: "object",
1120
+ additionalProperties: false,
1121
+ properties: {
1122
+ kind: {
1123
+ type: "string",
1124
+ enum: ["authored_example", "preference", "observation"],
1125
+ description: "Whether this is the user's authored writing, a preference they stated, or an observation grounded in their writing.",
1126
+ },
1127
+ text: { type: "string", minLength: 1, maxLength: 12000 },
1128
+ consent: {
1129
+ type: "boolean",
1130
+ const: true,
1131
+ description: "Required only for authored_example: the user wrote it and agreed it may be stored.",
1132
+ },
1133
+ basis: { type: "string", minLength: 1, maxLength: 2000 },
1134
+ scope: { type: "string", enum: ["general", "occasion", "piece"] },
1135
+ occasion: { type: "string", minLength: 1, maxLength: 500 },
1136
+ profileId: { type: "string", format: "uuid" },
1137
+ writingJobVersionId: { type: "string", format: "uuid" },
1138
+ },
1139
+ required: ["kind", "text"],
1140
+ },
1141
+ outputSchema: {
1142
+ type: "object",
1143
+ additionalProperties: false,
1144
+ properties: {
1145
+ status: { type: "string", enum: ["saved", "pending", "ready", "failed"] },
1146
+ message: { type: "string" },
1147
+ next: {
1148
+ type: "object",
1149
+ additionalProperties: false,
1150
+ properties: {
1151
+ action: { type: "string", enum: ["done", "retry", "continue_writing"] },
1152
+ tool: { type: ["string", "null"], enum: ["refine", null] },
1153
+ },
1154
+ required: ["action", "tool"],
1155
+ },
1156
+ contribution: {
1157
+ type: "object",
1158
+ additionalProperties: false,
1159
+ properties: {
1160
+ id: { type: "string", format: "uuid" },
1161
+ kind: { type: "string", enum: ["authored_example", "preference", "observation"] },
1162
+ status: { type: "string", enum: ["pending", "waiting", "applied", "not_used", "piece_only"] },
1163
+ },
1164
+ required: ["id", "kind", "status"],
1165
+ },
1166
+ profileId: { type: "string", format: "uuid" },
1167
+ writingJobVersionId: { type: "string", format: "uuid" },
1168
+ neededWriting: {
1169
+ type: "object",
1170
+ additionalProperties: false,
1171
+ properties: {
1172
+ pieces: { type: "integer", minimum: 0 },
1173
+ characters: { type: "integer", minimum: 0 },
1174
+ },
1175
+ required: ["pieces", "characters"],
1176
+ },
1177
+ },
1178
+ required: ["status", "message", "next"],
1179
+ },
1180
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: "Learn Writing Style" },
1181
+ },
1182
+ {
1183
+ name: "record_voice_feedback",
1184
+ description:
1185
+ "Record bounded feedback for an Idiolect AI output. Send a verdict every time the user responds to one; a verdict on its own is worth having. Put what you observed in critique. Send correctionText only when you have the user's final text verbatim.",
1186
+ inputSchema: {
1187
+ type: "object",
1188
+ additionalProperties: false,
1189
+ properties: {
1190
+ outputId: { type: "string", format: "uuid" },
1191
+ // Hand-written mirror of the HTTP route's vocabulary — this file cannot import
1192
+ // `lib/feedback/outcomes.ts`. `superseded` means the person changed the brief or
1193
+ // direction rather than judging the text (IDI-446); keep it in step by hand.
1194
+ status: { type: "string", enum: ["accepted", "corrected_and_accepted", "rejected", "superseded"] },
1195
+ correctionText: {
1196
+ type: "string",
1197
+ minLength: 1,
1198
+ maxLength: 20000,
1199
+ description: "The user's final text, verbatim and complete. Never an excerpt, a diff, or your own description of what changed — if you do not have their exact words, omit this field and describe the change in critique instead.",
1200
+ },
1201
+ critique: {
1202
+ type: "string",
1203
+ minLength: 1,
1204
+ maxLength: 2000,
1205
+ description: "What you observed about how the output landed, in the user's words or your own: what was wrong, what they changed, or why they kept it. Free text, and the right home for any description of an edit you cannot quote verbatim.",
1206
+ },
1207
+ },
1208
+ required: ["outputId", "status"],
1209
+ },
1210
+ outputSchema: FEEDBACK_OUTPUT_SCHEMA,
1211
+ annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: "Record Voice Feedback" },
1212
+ },
339
1213
  ];
340
1214
 
1215
+ export const accountTools = progressiveCatalog.tools;
1216
+ export const accountInstructions = progressiveCatalog.instructions;
1217
+ /** Hidden schemas retained for cached stdio callers and compatibility tests. */
1218
+ export const accountCompatibilityTools = accountToolDefinitions;
1219
+
1220
+ function toolError(message) {
1221
+ return { content: [{ type: "text", text: message }], isError: true };
1222
+ }
1223
+
1224
+ function accountToolError(error) {
1225
+ if (error instanceof AccountRequestError && error.structuredContent) {
1226
+ return {
1227
+ content: [{ type: "text", text: error.structuredContent.message }],
1228
+ structuredContent: error.structuredContent,
1229
+ isError: true,
1230
+ };
1231
+ }
1232
+ if (error instanceof AccountRequestError) return toolError(error.message);
1233
+ return toolError("Idiolect AI account API failed before a safe response was available.");
1234
+ }
1235
+
1236
+ function createVoiceBody(args) {
1237
+ const samples = args.samples ?? [];
1238
+ const answers = args.answers ?? [];
1239
+ if (!Array.isArray(samples) || samples.length > 500 || samples.some((sample) => typeof sample !== "string" || sample.length > 20000)) {
1240
+ return { error: "create_my_voice: `samples` must contain at most 500 text blocks of at most 20,000 characters each." };
1241
+ }
1242
+ if (
1243
+ !Array.isArray(answers)
1244
+ || answers.length > 20
1245
+ || answers.some((answer) => (
1246
+ !answer
1247
+ || typeof answer !== "object"
1248
+ || Array.isArray(answer)
1249
+ || typeof answer.question !== "string"
1250
+ || answer.question.length > 500
1251
+ || typeof answer.answer !== "string"
1252
+ || answer.answer.length > 5000
1253
+ ))
1254
+ ) {
1255
+ return { error: "create_my_voice: `answers` must contain at most 20 question/answer pairs within the documented limits." };
1256
+ }
1257
+ if (args.name !== undefined && (typeof args.name !== "string" || args.name.length > 60)) {
1258
+ return { error: "create_my_voice: `name` must be text of at most 60 characters." };
1259
+ }
1260
+ if (args.profileId !== undefined && typeof args.profileId !== "string") {
1261
+ return { error: "create_my_voice: `profileId` must be a Writing Profile UUID." };
1262
+ }
1263
+ return {
1264
+ body: {
1265
+ ...(args.profileId !== undefined ? { profileId: args.profileId } : {}),
1266
+ samples,
1267
+ answers: answers.map(({ question, answer }) => ({ question, answer })),
1268
+ ...(args.name !== undefined ? { name: args.name } : {}),
1269
+ },
1270
+ };
1271
+ }
1272
+
1273
+ function safeCreationResult(r) {
1274
+ const status = r.status ?? "more_evidence_required";
1275
+ const state = status === "ready" ? "complete" : status === "build_in_progress" ? "stale" : status === "failed" ? "failed" : "thin";
1276
+ const next = status === "ready"
1277
+ ? { action: "reload_voice", tool: "get_my_voice" }
1278
+ : status === "build_in_progress"
1279
+ ? { action: "retry", tool: "create_my_voice" }
1280
+ : status === "failed" ? { action: "done", tool: null } : { action: "collect_evidence", tool: "create_my_voice" };
1281
+ const neededChars = Number.isFinite(r.neededChars) ? r.neededChars : 0;
1282
+ const neededBlocks = Number.isFinite(r.neededBlocks) ? r.neededBlocks : 0;
1283
+ const message = state === "thin"
1284
+ ? `More of the user's own writing is needed: add ${neededBlocks} distinct block${neededBlocks === 1 ? "" : "s"} and ${neededChars} characters.`
1285
+ : r.message ?? (state === "complete" ? "Writing Profile ready." : "Writing Profile creation is still in progress.");
1286
+ return publicMcpState(state, message, next, {
1287
+ status,
1288
+ reason: r.reason ?? null,
1289
+ voiceId: r.voiceId ?? null,
1290
+ // The shortfall appears only when there is one; the fixed minimums are gone (IDI-381).
1291
+ ...(neededChars > 0 || neededBlocks > 0 ? { neededChars, neededBlocks } : {}),
1292
+ replay: r.replay === true,
1293
+ });
1294
+ }
1295
+
1296
+ /** Preserve a server-proven refinement conflict without copying unknown fields. */
1297
+ function safeCreationFailurePayload(value) {
1298
+ if (
1299
+ !plainObject(value)
1300
+ || value.status !== "failed"
1301
+ || typeof value.message !== "string"
1302
+ || !(value.reason === null || typeof value.reason === "string")
1303
+ || !(value.voiceId === null || typeof value.voiceId === "string")
1304
+ || typeof value.replay !== "boolean"
1305
+ ) return null;
1306
+ return safeCreationResult(value);
1307
+ }
1308
+
1309
+ const WRITING_PROFILE_ERROR_CODES = {
1310
+ list_writing_profiles: new Set(["unexpected_error"]),
1311
+ list_writing_profile_samples: new Set(["profile_not_found", "unexpected_error"]),
1312
+ create_writing_profile: new Set([
1313
+ "insufficient_evidence", "rate_limited", "request_conflict", "build_in_progress", "unexpected_error",
1314
+ ]),
1315
+ add_samples_to_writing_profile: new Set([
1316
+ "insufficient_evidence", "request_conflict", "build_in_progress", "profile_not_found", "unexpected_error",
1317
+ ]),
1318
+ replace_samples_in_writing_profile: new Set([
1319
+ "insufficient_evidence", "profile_not_found", "samples_not_fully_visible", "unexpected_error",
1320
+ ]),
1321
+ remove_sample_from_writing_profile: new Set([
1322
+ "profile_not_found", "sample_not_found", "unexpected_error",
1323
+ ]),
1324
+ set_current_writing_profile: new Set(["profile_not_found", "unexpected_error"]),
1325
+ delete_writing_profile: new Set([
1326
+ "profile_not_found", "replacement_required", "invalid_replacement", "unexpected_error",
1327
+ ]),
1328
+ };
1329
+
1330
+ function writingProfileRequest(name, args) {
1331
+ if (name === "list_writing_profiles") return { path: "/api/writing-profiles", method: "GET" };
1332
+ if (name === "create_writing_profile") {
1333
+ return {
1334
+ path: "/api/writing-profiles",
1335
+ method: "POST",
1336
+ body: {
1337
+ samples: args.samples ?? [],
1338
+ answers: args.answers ?? [],
1339
+ ...(args.name === undefined ? {} : { name: args.name }),
1340
+ },
1341
+ };
1342
+ }
1343
+ const id = encodeURIComponent(args.profileId ?? '');
1344
+ if (name === "list_writing_profile_samples") {
1345
+ return { path: `/api/writing-profiles/${id}/samples`, method: "GET" };
1346
+ }
1347
+ if (name === "add_samples_to_writing_profile") {
1348
+ // Absent means the current profile; the account API resolves it, this only forwards.
1349
+ const path = args.profileId === undefined
1350
+ ? "/api/writing-profiles/current/samples"
1351
+ : `/api/writing-profiles/${id}/samples`;
1352
+ return { path, method: "POST", body: { samples: args.samples } };
1353
+ }
1354
+ if (name === "replace_samples_in_writing_profile") {
1355
+ return { path: `/api/writing-profiles/${id}/samples`, method: "PUT", body: { samples: args.samples } };
1356
+ }
1357
+ if (name === "remove_sample_from_writing_profile") {
1358
+ const sampleId = encodeURIComponent(args.sampleId);
1359
+ return { path: `/api/writing-profiles/${id}/samples/${sampleId}`, method: "DELETE" };
1360
+ }
1361
+ if (name === "set_current_writing_profile") {
1362
+ return { path: `/api/writing-profiles/${id}/current`, method: "PUT" };
1363
+ }
1364
+ const replacement = args.replacementProfileId === undefined
1365
+ ? ""
1366
+ : `?replacementProfileId=${encodeURIComponent(args.replacementProfileId)}`;
1367
+ return { path: `/api/writing-profiles/${id}${replacement}`, method: "DELETE" };
1368
+ }
1369
+
1370
+ /** The shortfall is re-validated field by field: an unchecked passthrough would let the
1371
+ * account API widen this tool's declared output schema without the stdio server knowing. */
1372
+ function safeProfileShortfall(value) {
1373
+ if (!plainObject(value) || typeof value.message !== "string") return null;
1374
+ const detail = { message: value.message.slice(0, 2000) };
1375
+ if (value.reason === "insufficient_input" || value.reason === "unsupported_language") {
1376
+ detail.reason = value.reason;
1377
+ }
1378
+ // Counts travel only when the evidence gate produced them; a profile that could not be
1379
+ // found has no shortfall to count.
1380
+ for (const key of ["neededChars", "neededBlocks", "droppedBlocks", "minimumChars", "minimumBlocks"]) {
1381
+ if (Number.isInteger(value[key]) && value[key] >= 0) detail[key] = value[key];
1382
+ }
1383
+ return detail;
1384
+ }
1385
+
1386
+ function safeWritingProfileResult(name, value) {
1387
+ if (!plainObject(value) || typeof value.ok !== "boolean") return null;
1388
+ if (value.ok === true && Object.hasOwn(value, "value")) return { ok: true, value: value.value };
1389
+ const code = plainObject(value.error) ? value.error.code : null;
1390
+ if (typeof code !== "string" || !WRITING_PROFILE_ERROR_CODES[name]?.has(code)) return null;
1391
+ const detail = safeProfileShortfall(plainObject(value.error) ? value.error.detail : null);
1392
+ return { ok: false, error: detail ? { code, detail } : { code } };
1393
+ }
1394
+
1395
+ function writingProfileSuccessMessage(name, value, title) {
1396
+ if (name === "list_writing_profiles" && Array.isArray(value)) {
1397
+ if (value.length === 0) return "No Writing Profiles found.";
1398
+ return ["Writing Profiles:", ...value.map((profile) => {
1399
+ const label = typeof profile?.name === "string" && profile.name.length > 0
1400
+ ? profile.name
1401
+ : "Unnamed profile";
1402
+ const current = profile?.isCurrent === true ? " (current)" : "";
1403
+ return `- ${label}: ${String(profile?.id ?? "unknown")}${current}`;
1404
+ })].join("\n");
1405
+ }
1406
+ if (
1407
+ ["list_writing_profile_samples", "replace_samples_in_writing_profile", "remove_sample_from_writing_profile"].includes(name)
1408
+ && plainObject(value)
1409
+ && Array.isArray(value.samples)
1410
+ ) {
1411
+ const samples = value.samples.map((sample) => {
1412
+ const id = plainObject(sample) && typeof sample.id === "string" ? sample.id : "unknown";
1413
+ const writing = plainObject(sample) && typeof sample.text === "string" ? sample.text : "";
1414
+ return `- sampleId ${id}: ${JSON.stringify(writing)}`;
1415
+ });
1416
+ return [
1417
+ `${title} complete for profile ${String(value.profileId ?? "unknown")} (generation ${String(value.generation ?? "unknown")}).`,
1418
+ `Usable samples: ${value.samples.length}.`,
1419
+ ...(samples.length > 0 ? samples : ["- No usable writing samples remain."]),
1420
+ ...(Number.isInteger(value.heldNotUsable) && value.heldNotUsable > 0
1421
+ ? [`Held but not usable for rebuild: ${value.heldNotUsable}.`]
1422
+ : []),
1423
+ ].join("\n");
1424
+ }
1425
+ return `${title} complete.`;
1426
+ }
1427
+
1428
+ async function callWritingProfileApi(name, args) {
1429
+ const request = writingProfileRequest(name, args);
1430
+ const res = await fetch(`${ACCOUNT_BASE}${request.path}`, {
1431
+ method: request.method,
1432
+ headers: {
1433
+ Authorization: `Bearer ${API_KEY}`,
1434
+ ...(request.body ? { "content-type": "application/json" } : {}),
1435
+ },
1436
+ body: request.body ? JSON.stringify(request.body) : undefined,
1437
+ });
1438
+ let parsed = null;
1439
+ try {
1440
+ parsed = safeWritingProfileResult(name, await res.json());
1441
+ } catch {
1442
+ // The bounded failure below handles non-JSON and malformed responses.
1443
+ }
1444
+ if (!parsed) throw new AccountRequestError(`${name}: Idiolect AI account API failed (${res.status}).`, res.status);
1445
+ const title = accountToolDefinitions.find((tool) => tool.name === name)?.annotations.title ?? name;
1446
+ const building = name === "create_writing_profile"
1447
+ && parsed.ok === false
1448
+ && parsed.error.code === "build_in_progress";
1449
+ const message = parsed.ok
1450
+ ? writingProfileSuccessMessage(name, parsed.value, title)
1451
+ : building
1452
+ ? "Writing Profile accepted and building. Retry this same call shortly to get the completed profile."
1453
+ : parsed.error.detail?.message ?? `${title} failed: ${parsed.error.code}.`;
1454
+ return {
1455
+ content: [{ type: "text", text: message }],
1456
+ structuredContent: parsed,
1457
+ ...(!parsed.ok && !building ? { isError: true } : {}),
1458
+ };
1459
+ }
1460
+
1461
+ /** Import-safe account handler used by the stdio server and its parity tests. */
1462
+ export async function handleAccountTool(name, args = {}) {
1463
+ if (Object.hasOwn(WRITING_PROFILE_ERROR_CODES, name)) {
1464
+ try {
1465
+ return await callWritingProfileApi(name, args);
1466
+ } catch (error) {
1467
+ return accountToolError(error);
1468
+ }
1469
+ }
1470
+ if (name === "get_credit_status") {
1471
+ try {
1472
+ const credits = safeCreditPresentation(await accountRequest("/api/v1/credits", {
1473
+ toolName: "get_credit_status",
1474
+ }));
1475
+ if (!credits) return toolError("get_credit_status: Idiolect AI returned an invalid credit status.");
1476
+ return {
1477
+ content: [{ type: "text", text: credits.message }],
1478
+ structuredContent: credits,
1479
+ };
1480
+ } catch (error) {
1481
+ return accountToolError(error);
1482
+ }
1483
+ }
1484
+
1485
+ if (name === "get_my_voice") {
1486
+ try {
1487
+ const r = await accountRequest("/api/v1/voice", { toolName: "get_my_voice" });
1488
+ const card = r.card || formatValue(r.voice);
1489
+ return {
1490
+ content: [
1491
+ {
1492
+ type: "text",
1493
+ text: `${card}\n\n---\n(Idiolect AI account voice loaded. Write everything as this person from now on.)`,
1494
+ },
1495
+ ],
1496
+ structuredContent: publicMcpState("ready", "Writing Profile ready.", { action: "continue_writing", tool: null }, {
1497
+ status: "ready",
1498
+ ...(r.voice || {}),
1499
+ }),
1500
+ };
1501
+ } catch (error) {
1502
+ if (error instanceof AccountRequestError && error.status === 404) {
1503
+ const message = "No Writing Profile yet. Offer the user both ways to create one: sharing writing they authored, or answering a few short questions about how they write. Use only natural writing the user actually composed; approval alone does not establish authorship. Nothing is created until the user agrees.";
1504
+ return {
1505
+ content: [{ type: "text", text: message }],
1506
+ structuredContent: publicMcpState("missing", message, { action: "collect_evidence", tool: "create_my_voice" }, {
1507
+ status: "setup_required",
1508
+ }),
1509
+ };
1510
+ }
1511
+ return accountToolError(error);
1512
+ }
1513
+ }
1514
+
1515
+ if (name === "create_my_voice") {
1516
+ const prepared = createVoiceBody(args);
1517
+ if (prepared.error) return {
1518
+ content: [{ type: "text", text: prepared.error }],
1519
+ structuredContent: publicMcpState("failed", prepared.error, { action: "retry", tool: "create_my_voice" }),
1520
+ isError: true,
1521
+ };
1522
+ try {
1523
+ const r = await accountRequest("/api/v1/voice/create", {
1524
+ method: "POST",
1525
+ body: prepared.body,
1526
+ toolName: "create_my_voice",
1527
+ });
1528
+ const structuredContent = safeCreationResult(r);
1529
+ return {
1530
+ content: [{ type: "text", text: structuredContent.message }],
1531
+ structuredContent,
1532
+ };
1533
+ } catch (error) {
1534
+ return accountToolError(error);
1535
+ }
1536
+ }
1537
+
1538
+ if (name === "record_voice_feedback" || name === "learn_writing_style") {
1539
+ try {
1540
+ return await accountMcpRequest(name, args);
1541
+ } catch {
1542
+ const learning = name === "learn_writing_style";
1543
+ const message = learning
1544
+ ? "Writing style could not be saved. Retry once or finish."
1545
+ : "Feedback could not be recorded. Retry once or finish.";
1546
+ return {
1547
+ content: [{ type: "text", text: message }],
1548
+ ...(learning
1549
+ ? { structuredContent: { status: "failed", message, next: { action: "retry", tool: null } } }
1550
+ : { structuredContent: publicMcpState("failed", message, { action: "retry", tool: "record_voice_feedback" }) }),
1551
+ isError: true,
1552
+ };
1553
+ }
1554
+ }
1555
+
1556
+ return undefined;
1557
+ }
1558
+
1559
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
341
1560
  const server = new Server(
342
- { name: "idiolect", version: "0.3.0" },
1561
+ { name: "idiolect", version: PACKAGE_VERSION },
343
1562
  {
344
1563
  capabilities: { tools: {} },
345
- // Hint folded into the client's context (MCP spec): use Idiolect AI proactively.
346
- instructions:
347
- "Idiolect AI makes you write in THIS user's own voice. Whenever you are about to write any prose on the user's behalf — emails, replies, messages, posts, commit messages, PR descriptions, docs — FIRST call get_my_voice (once per session) and write in that voice. After drafting, you may call rewrite_in_voice to generate an in-voice alternative with a Voice Match; use it only when it preserves the user's meaning and fits their request. Do not use for code. Never invent the user's opinions — only match their style.",
1564
+ // Hint folded into the client's context (MCP spec).
1565
+ instructions: ACCOUNT_MODE
1566
+ ? accountInstructions
1567
+ : "Use `rewrite_in_voice` or `draft_in_voice` only when the user asks for writing in the configured voice. Use `score_voice` only when the user explicitly asks for measurement. Never infer another person's writing or invent the user's opinions.",
348
1568
  },
349
1569
  );
350
1570
 
@@ -352,27 +1572,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
352
1572
  tools: ACCOUNT_MODE ? accountTools : legacyTools,
353
1573
  }));
354
1574
 
355
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
1575
+ export async function handleToolRequest(req) {
356
1576
  if (ACCOUNT_MODE) {
357
1577
  const args = req.params.arguments || {};
358
-
359
- if (req.params.name === "get_my_voice") {
1578
+ if (
1579
+ PROGRESSIVE_ACCOUNT_TOOL_NAMES.has(req.params.name) ||
1580
+ REMOTE_COMPATIBILITY_TOOL_NAMES.has(req.params.name)
1581
+ ) {
360
1582
  try {
361
- const r = await accountRequest("/api/v1/voice", { toolName: "get_my_voice" });
362
- const card = r.card || formatValue(r.voice);
363
- return {
364
- content: [
365
- {
366
- type: "text",
367
- text: `${card}\n\n---\n(Idiolect AI account voice loaded. Write everything as this person from now on.)`,
368
- },
369
- ],
370
- structuredContent: r.voice || {},
371
- };
372
- } catch (e) {
373
- return { content: [{ type: "text", text: String(e) }], isError: true };
1583
+ return await accountMcpRequest(req.params.name, args);
1584
+ } catch (error) {
1585
+ return accountToolError(error);
374
1586
  }
375
1587
  }
1588
+ const nativeAccountResult = await handleAccountTool(req.params.name, args);
1589
+ if (nativeAccountResult) return nativeAccountResult;
376
1590
 
377
1591
  if (req.params.name === "score_voice") {
378
1592
  const text = typeof args.text === "string" ? args.text.trim() : "";
@@ -387,26 +1601,38 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
387
1601
  });
388
1602
  return {
389
1603
  content: [{ type: "text", text: scoreSummary(r) }],
390
- structuredContent: {
391
- voiceMatch: r.voiceMatch ?? null,
1604
+ structuredContent: publicMcpState("complete", "Voice Match check complete.", { action: "continue_writing", tool: null }, {
1605
+ voiceMatch: r.absoluteScoreReportable === false ? null : r.voiceMatch ?? null,
392
1606
  measured: !!r.measured,
393
1607
  validated: !!r.validated,
394
1608
  instrument: r.instrument ?? null,
395
1609
  reason: r.reason ?? null,
396
- register: r.register ?? "",
397
- approximate: !!r.approximate,
1610
+ ...(typeof r.register === "string" ? { register: r.register } : {}),
1611
+ ...(typeof r.approximate === "boolean" ? { approximate: r.approximate } : {}),
398
1612
  ...(r.voice ? { voice: r.voice } : {}),
399
1613
  ...(r.evidence ? { evidence: r.evidence } : {}),
400
- },
1614
+ }),
401
1615
  };
402
1616
  } catch (e) {
403
- return { content: [{ type: "text", text: String(e) }], isError: true };
1617
+ return accountToolError(e);
404
1618
  }
405
1619
  }
406
1620
 
407
1621
  if (req.params.name === "rewrite_in_voice") {
408
1622
  const text = typeof args.text === "string" ? args.text.trim() : "";
1623
+ const profileId = typeof args.profileId === "string" ? args.profileId : undefined;
409
1624
  const targetRegister = typeof args.targetRegister === "string" ? args.targetRegister : undefined;
1625
+ const instructions = typeof args.instructions === "string" && args.instructions.trim()
1626
+ ? args.instructions.trim()
1627
+ : undefined;
1628
+ const invalidChangeRequest = changeRequestError(args, "rewrite_in_voice");
1629
+ if (invalidChangeRequest) return invalidChangeRequest;
1630
+ const invalidSourceAuthor = authorProvenanceError(args, "rewrite_in_voice", "sourceAuthor");
1631
+ if (invalidSourceAuthor) return invalidSourceAuthor;
1632
+ const changeScope = args.changeScope === "polish" ? "light_edit" : args.changeScope;
1633
+ const effects = args.effects;
1634
+ const sourceAuthor = args.sourceAuthor;
1635
+ const calibration = args.calibration;
410
1636
  if (!text) {
411
1637
  return { content: [{ type: "text", text: "rewrite_in_voice: provide `text` to rewrite." }], isError: true };
412
1638
  }
@@ -415,7 +1641,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
415
1641
  content: [
416
1642
  {
417
1643
  type: "text",
418
- text: `rewrite_in_voice: \`targetRegister\` must be one of ${REWRITE_REGISTERS.join(", ")}.`,
1644
+ text: `rewrite_in_voice: \`targetRegister\` must be one of ${backtickedValues(REWRITE_REGISTERS)}.`,
419
1645
  },
420
1646
  ],
421
1647
  isError: true,
@@ -424,45 +1650,51 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
424
1650
  try {
425
1651
  const r = await accountRequest("/api/v1/rewrite", {
426
1652
  method: "POST",
427
- body: { text, ...(targetRegister ? { targetRegister } : {}) },
1653
+ body: {
1654
+ text,
1655
+ ...(profileId !== undefined ? { voice: profileId } : {}),
1656
+ ...(targetRegister ? { targetRegister } : {}),
1657
+ ...(instructions ? { instructions } : {}),
1658
+ ...(changeScope !== undefined ? { changeScope } : {}),
1659
+ ...(effects !== undefined ? { effects } : {}),
1660
+ ...(sourceAuthor !== undefined ? { sourceAuthor } : {}),
1661
+ ...(calibration !== undefined ? { calibration } : {}),
1662
+ },
428
1663
  toolName: "rewrite_in_voice",
429
1664
  });
1665
+ // The account API returns the rewrite, the profile and its evidence state. The
1666
+ // score block this used to print and the constants it used to coerce into the
1667
+ // structured result are gone (IDI-381).
430
1668
  return {
431
- content: [
432
- {
433
- type: "text",
434
- text: `Rewritten:\n${r.rewritten || ""}\n\n${scoreSummary(r)}${formatAlternatives(r.alternatives)}`,
435
- },
436
- ],
437
- structuredContent: {
1669
+ content: [{ type: "text", text: `Rewritten:\n${r.rewritten || ""}` }],
1670
+ structuredContent: publicMcpState("complete", "Rewrite complete.", { action: "continue_writing", tool: null }, {
438
1671
  rewritten: r.rewritten ?? "",
439
- voiceMatch: r.voiceMatch ?? null,
440
- delta: r.delta ?? null,
441
- measured: !!r.measured,
442
- crossFit: !!r.crossFit,
443
- candidateText: r.candidateText ?? r.rewritten ?? "",
444
- candidateVoiceMatch: r.candidateVoiceMatch ?? null,
445
- candidateDelta: r.candidateDelta ?? null,
446
- improved: r.improved ?? null,
447
- regressed: r.regressed ?? null,
448
- validated: !!r.validated,
449
- instrument: r.instrument ?? null,
450
- reason: r.reason ?? null,
451
- register: r.register ?? "",
452
- approximate: !!r.approximate,
1672
+ ...(r.reason !== undefined ? { reason: r.reason ?? null } : {}),
453
1673
  ...(r.voice ? { voice: r.voice } : {}),
454
1674
  ...(r.evidence ? { evidence: r.evidence } : {}),
455
- },
1675
+ }),
456
1676
  };
457
1677
  } catch (e) {
458
- return { content: [{ type: "text", text: String(e) }], isError: true };
1678
+ return accountToolError(e);
459
1679
  }
460
1680
  }
461
1681
 
462
1682
  if (req.params.name === "draft_in_voice") {
463
1683
  const brief = typeof args.brief === "string" ? args.brief.trim() : "";
1684
+ const profileId = typeof args.profileId === "string" ? args.profileId : undefined;
464
1685
  const format = typeof args.format === "string" ? args.format : undefined;
1686
+ const instructions = typeof args.instructions === "string" && args.instructions.trim()
1687
+ ? args.instructions.trim()
1688
+ : undefined;
465
1689
  const formats = new Set(["linkedin", "x_post", "x_thread", "newsletter", "generic"]);
1690
+ const invalidChangeRequest = changeRequestError(args, "draft_in_voice");
1691
+ if (invalidChangeRequest) return invalidChangeRequest;
1692
+ const invalidBriefAuthor = authorProvenanceError(args, "draft_in_voice", "briefAuthor");
1693
+ if (invalidBriefAuthor) return invalidBriefAuthor;
1694
+ const changeScope = args.changeScope === "polish" ? "light_edit" : args.changeScope;
1695
+ const effects = args.effects;
1696
+ const briefAuthor = args.briefAuthor;
1697
+ const calibration = args.calibration;
466
1698
  if (!brief) {
467
1699
  return { content: [{ type: "text", text: "draft_in_voice: provide `brief` to draft from." }], isError: true };
468
1700
  }
@@ -471,7 +1703,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
471
1703
  content: [
472
1704
  {
473
1705
  type: "text",
474
- text: "draft_in_voice: `format` must be one of linkedin, x_post, x_thread, newsletter, generic.",
1706
+ text: `draft_in_voice: \`format\` must be one of ${backtickedValues([...formats])}.`,
475
1707
  },
476
1708
  ],
477
1709
  isError: true,
@@ -480,32 +1712,29 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
480
1712
  try {
481
1713
  const r = await accountRequest("/api/v1/draft", {
482
1714
  method: "POST",
483
- body: { brief, ...(format ? { format } : {}) },
1715
+ body: {
1716
+ brief,
1717
+ ...(profileId !== undefined ? { voice: profileId } : {}),
1718
+ ...(format ? { format } : {}),
1719
+ ...(instructions ? { instructions } : {}),
1720
+ ...(changeScope !== undefined ? { changeScope } : {}),
1721
+ ...(effects !== undefined ? { effects } : {}),
1722
+ ...(briefAuthor !== undefined ? { briefAuthor } : {}),
1723
+ ...(calibration !== undefined ? { calibration } : {}),
1724
+ },
484
1725
  toolName: "draft_in_voice",
485
1726
  });
486
1727
  return {
487
- content: [
488
- {
489
- type: "text",
490
- text: `Draft:\n${r.draft || ""}\n\n${scoreSummary(r)}${formatAlternatives(r.alternatives)}`,
491
- },
492
- ],
493
- structuredContent: {
1728
+ content: [{ type: "text", text: `Draft:\n${r.draft || ""}` }],
1729
+ structuredContent: publicMcpState("complete", "Draft complete.", { action: "continue_writing", tool: null }, {
494
1730
  draft: r.draft ?? "",
495
- voiceMatch: r.voiceMatch ?? null,
496
- measured: !!r.measured,
497
- crossFit: !!r.crossFit,
498
- validated: !!r.validated,
499
- instrument: r.instrument ?? null,
500
- reason: r.reason ?? null,
501
- register: r.register ?? "",
502
- approximate: !!r.approximate,
1731
+ ...(r.reason !== undefined ? { reason: r.reason ?? null } : {}),
503
1732
  ...(r.voice ? { voice: r.voice } : {}),
504
1733
  ...(r.evidence ? { evidence: r.evidence } : {}),
505
- },
1734
+ }),
506
1735
  };
507
1736
  } catch (e) {
508
- return { content: [{ type: "text", text: String(e) }], isError: true };
1737
+ return accountToolError(e);
509
1738
  }
510
1739
  }
511
1740
 
@@ -533,14 +1762,11 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
533
1762
  }
534
1763
  try {
535
1764
  const r = await scoreVoice(texts, region);
536
- const lines = r.scores
537
- .map((s, i) => `#${i + 1} · Voice Match ${s.voiceMatch}/100 — ${s.text.slice(0, 60)}…`)
538
- .join("\n");
539
1765
  return {
540
1766
  content: [
541
1767
  {
542
1768
  type: "text",
543
- text: `${lines}\n\n(Relative voice-fidelity on the validated LUAR metric — higher = reads more like you. Revise low-scoring drafts before sending.)`,
1769
+ text: formatLegacyScoreVoiceResult(r),
544
1770
  },
545
1771
  ],
546
1772
  };
@@ -577,7 +1803,20 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
577
1803
  } catch (e) {
578
1804
  return { content: [{ type: "text", text: String(e) }], isError: true };
579
1805
  }
580
- });
1806
+ }
1807
+
1808
+ server.setRequestHandler(CallToolRequestSchema, handleToolRequest);
581
1809
 
582
- const transport = new StdioServerTransport();
583
- await server.connect(transport);
1810
+ function isMainModule() {
1811
+ if (!process.argv[1]) return false;
1812
+ try {
1813
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
1814
+ } catch {
1815
+ return false;
1816
+ }
1817
+ }
1818
+
1819
+ if (isMainModule()) {
1820
+ const transport = new StdioServerTransport();
1821
+ await server.connect(transport);
1822
+ }