@adeu/mcp-server 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -19,21 +19,17 @@ import {
19
19
  create_word_patch_diff,
20
20
  finalize_document,
21
21
  } from "@adeu/core";
22
+ import { describe_illegal_control_chars } from "@adeu/core";
22
23
 
23
24
  import {
24
25
  build_paginated_response,
26
+ build_full_document_response,
25
27
  build_outline_response,
26
28
  build_appendix_response,
27
29
  build_search_response,
28
30
  } from "./response-builders.js";
29
31
 
30
- import { login_to_adeu_cloud, logout_of_adeu_cloud } from "./tools/auth.js";
31
- import {
32
- search_and_fetch_emails,
33
- create_email_draft,
34
- list_available_mailboxes,
35
- } from "./tools/email.js";
36
- import { MARKDOWN_UI_URI, EMAIL_UI_URI } from "./shared.js";
32
+ import { MARKDOWN_UI_URI } from "./shared.js";
37
33
  // Parity with Python models.py `_infer_type_in_place` + `_coerce_match_mode_in_place`.
38
34
  // The MCP boundary schema is permissive; these repairs let recoverable payloads
39
35
  // (a missing `type` that's unambiguous from the key signature, or a non-canonical
@@ -133,7 +129,7 @@ const READ_DOCX_TAIL =
133
129
  "Modes:\n- 'full' (default): paginated body content. Use page=N to navigate.\n- 'outline': heading map only — start here for large docs to plan targeted reads. Defaults to L1-L2 headings; pass outline_max_level=3-6 to see deeper structure.\n- 'appendix': defined terms, anchors, and cross-reference targets. Consult before editing legal/technical docs to avoid breaking references.";
134
130
 
135
131
  const PROCESS_BATCH_COMMON_DESC =
136
- "Applies a batch of edits and review actions to a DOCX.\n\nAll changes evaluate against the ORIGINAL document state do not chain dependent edits within one batch (e.g. rename X to Y, then modify Y). Apply the rename first, then send a second batch.\n\n";
132
+ "Applies a batch of edits and review actions to a DOCX.\n\nBatches apply SEQUENTIALLY: each change is validated and applied against the document state produced by the changes before it, so you may chain dependent edits within one batch (e.g. rename X to Y, then modify Y — the second edit must target Y, the text as it reads after the rename). Validation failures reject the whole batch transactionally: nothing is applied until every change resolves.\n\n";
137
133
  const PROCESS_BATCH_OPERATIONS_DESC =
138
134
  "Each item in `changes` must specify a `type`:\n1. 'modify': Search-and-replace. By default `target_text` must match uniquely (`match_mode`:'strict') — add surrounding context to disambiguate, or set `match_mode`:'first'/'all' to edit the first or every occurrence. Set `regex`:true to treat `target_text` as a regular expression (capture groups available in `new_text` as $1, $2…). `new_text` supports Markdown: '# Heading 1' through '###### Heading 6', '**bold**', '_italic_', and '\\n\\n' to split into multiple paragraphs. Empty `new_text` deletes. Do NOT write CriticMarkup tags ({++, {--, {>>) manually — use the `comment` parameter for comments.\n • EMPTY/FORM TABLE CELLS: a blank cell has no text to match. `read_docx` renders each cell with a trailing `{#cell:<id>}` anchor — to fill a blank cell, set `target_text` to that exact anchor (e.g. '{#cell:0000005E}') and put the value in `new_text`. Do NOT try to match the pipe layout ('Date | | |'); the pipes are display separators, not editable text.\n2. 'accept' / 'reject': Finalize or revert a tracked change by `target_id` (e.g. 'Chg:12').\n3. 'reply': Reply to a comment by `target_id` (e.g. 'Com:5') with `text`.\n4. 'insert_row' / 'delete_row': Table edits. Disk mode only — not supported on Live Word canvas.\n\nID VOLATILITY: 'Chg:N' and 'Com:N' shift between document states. Always call `read_docx` immediately before any accept/reject/reply — do not reuse IDs from earlier in the conversation. The `{#cell:<id>}` anchors are stable (Word-assigned) and safe to reuse across reads.\n\n`author_name` is used for attribution on all tracked changes and comments, in both disk and Live Word modes.";
139
135
 
@@ -144,14 +140,6 @@ const gitSha = process.env.GIT_SHA || "unknown";
144
140
  const packageVersion = process.env.PACKAGE_VERSION || "unknown";
145
141
  const buildTag = ` [Adeu v${packageVersion}+${gitSha}]`;
146
142
 
147
- // --- Scope Configuration ---
148
- const args = process.argv.slice(2);
149
- const scopeIdx = args.indexOf("--scope");
150
- const requestedScope = (
151
- scopeIdx !== -1 ? args[scopeIdx + 1] : "all"
152
- ).toLowerCase();
153
- const isDocxOnly = requestedScope === "docx";
154
-
155
143
  // --- Server Setup ---
156
144
  const server = new McpServer({
157
145
  name: "adeu-redlining-service",
@@ -234,34 +222,6 @@ registerAppResource(
234
222
  },
235
223
  );
236
224
 
237
- registerAppResource(
238
- server,
239
- EMAIL_UI_URI,
240
- EMAIL_UI_URI,
241
- { mimeType: RESOURCE_MIME_TYPE, description: "Adeu Email Viewer UI" },
242
- async () => {
243
- let html = getAssetContent(
244
- "templates",
245
- "email_ui.html",
246
- "<html><body>UI Template Not Found</body></html>",
247
- );
248
- const svg = getAssetContent("assets", "adeu.svg", "");
249
-
250
- html = html.replace("[[ adeu_svg_code ]]", svg);
251
-
252
- return {
253
- contents: [
254
- {
255
- uri: EMAIL_UI_URI,
256
- mimeType: RESOURCE_MIME_TYPE,
257
- text: html,
258
- _meta: { ui: { csp: UI_CSP } },
259
- },
260
- ],
261
- };
262
- },
263
- );
264
-
265
225
  // ==========================================
266
226
  // 2. UI-ENABLED TOOLS
267
227
  // ==========================================
@@ -294,7 +254,7 @@ registerAppTool(
294
254
  .union([z.number(), z.string()])
295
255
  .optional()
296
256
  .describe(
297
- "Without `search_query`: 1-indexed document page to display (defaults to 1). With `search_query`: restricts matches to that document page (defaults to searching all pages; pass `page='all'` to be explicit).",
257
+ "Without `search_query`: 1-indexed document page to display (defaults to 1). With `search_query`: restricts matches to that document page (defaults to searching all pages; pass `page='all'` to be explicit). Note: pages are synthetic, length-based content chunks sized for LLM consumption — they do NOT correspond to printed Word pages or explicit page breaks.",
298
258
  ),
299
259
  outline_max_level: z.coerce
300
260
  .number()
@@ -374,13 +334,40 @@ registerAppTool(
374
334
  );
375
335
  return res as any;
376
336
  }
337
+ // In full mode, page='all' returns the entire document without page
338
+ // chrome — the round-trip artifact for text-based apply/diff
339
+ // (QA 2026-07-17 F1 parity with the Python CLI's --page all).
340
+ if (
341
+ mode === "full" &&
342
+ page !== undefined &&
343
+ page !== null &&
344
+ String(page).trim().toLowerCase() === "all"
345
+ ) {
346
+ const res = build_full_document_response(text, file_path);
347
+ return res as any;
348
+ }
377
349
  // In non-search mode, `page` defaults to 1 (show document page 1).
378
- const resolvedPage =
379
- page === undefined || page === null
380
- ? 1
381
- : typeof page === "number"
382
- ? page
383
- : parseInt(String(page), 10) || 1;
350
+ // Non-numeric values must error, not silently fall back to page 1
351
+ // (QA L1 parity with the Python CLI).
352
+ let resolvedPage = 1;
353
+ if (page !== undefined && page !== null) {
354
+ const parsed =
355
+ typeof page === "number" ? page : parseInt(String(page).trim(), 10);
356
+ if (!Number.isFinite(parsed)) {
357
+ return {
358
+ isError: true,
359
+ content: [
360
+ {
361
+ type: "text",
362
+ text:
363
+ `Invalid page value: '${page}'. Provide a positive integer ` +
364
+ `(pages are 1-indexed; 'all' is valid for mode='full' and together with search_query).`,
365
+ },
366
+ ],
367
+ };
368
+ }
369
+ resolvedPage = parsed;
370
+ }
384
371
  if (mode === "appendix") {
385
372
  const res = build_appendix_response(text, resolvedPage, file_path);
386
373
  return res as any;
@@ -488,7 +475,7 @@ server.registerTool(
488
475
  changes: z
489
476
  .array(z.union([z.string(), CHANGE_ITEM_SCHEMA]))
490
477
  .describe(
491
- "Ordered list of changes to apply. Each item is an object carrying a `type` discriminator plus that type's fields (see the per-field docs and the tool description). All items evaluate against the ORIGINAL document state.",
478
+ "Ordered list of changes to apply. Each item is an object carrying a `type` discriminator plus that type's fields (see the per-field docs and the tool description). Items apply SEQUENTIALLY: each one evaluates against the document state produced by the items before it, so later items may target text an earlier item introduced.",
492
479
  ),
493
480
  output_path: z.string().optional().describe("Optional output path."),
494
481
  dry_run: z
@@ -516,6 +503,18 @@ server.registerTool(
516
503
  { type: "text", text: "Error: author_name cannot be empty." },
517
504
  ],
518
505
  };
506
+ const author_ctrl = describe_illegal_control_chars(author_name);
507
+ if (author_ctrl)
508
+ return {
509
+ content: [
510
+ {
511
+ type: "text",
512
+ text:
513
+ `Error: author_name contains control character(s) (${author_ctrl}) ` +
514
+ `that cannot be stored in a DOCX. Remove them and retry.`,
515
+ },
516
+ ],
517
+ };
519
518
  if (!changes || changes.length === 0)
520
519
  return {
521
520
  content: [{ type: "text", text: "Error: No changes provided." }],
@@ -627,10 +626,29 @@ server.registerTool(
627
626
 
628
627
  if (!dry_run) {
629
628
  const outBuf = await doc.save();
630
- fs.writeFileSync(outPath, outBuf);
629
+ try {
630
+ fs.writeFileSync(outPath, outBuf);
631
+ } catch (e: any) {
632
+ // Filesystem failures (name too long, missing directory, perms)
633
+ // must surface as a clear, actionable error (QA H3 parity).
634
+ return {
635
+ isError: true,
636
+ content: [
637
+ {
638
+ type: "text",
639
+ text: `Could not write output file '${outPath}': ${e.message}`,
640
+ },
641
+ ],
642
+ };
643
+ }
631
644
  }
632
645
 
633
- const res = formatBatchResult(stats, outPath, !!dry_run);
646
+ let res = formatBatchResult(stats, outPath, !!dry_run);
647
+ if (sanitizedChanges.length === 0) {
648
+ res =
649
+ `⚠️ 0 changes provided — nothing to do. The output is an unmodified copy of the original.\n\n` +
650
+ res;
651
+ }
634
652
  return { content: [{ type: "text", text: res }] };
635
653
  } catch (e: any) {
636
654
  return {
@@ -834,188 +852,6 @@ server.registerTool(
834
852
  },
835
853
  );
836
854
 
837
- if (!isDocxOnly) {
838
- registerAppTool(
839
- server,
840
- "search_and_fetch_emails",
841
- {
842
- title: "Search & Fetch Emails",
843
- description:
844
- "Searches the user's live email inbox via the Adeu cloud backend.\n\n" +
845
- "TWO MODES:\n" +
846
- "1. Search mode (no `email_id`): returns up to `limit` lightweight previews. Use filters (`sender`, `subject`, `is_unread`, `days_ago`, `folder`, `has_attachments`, `attachment_name`) to narrow down.\n" +
847
- "2. Fetch mode (with `email_id`): returns the full email body, thread history, and downloads attachments under `max_attachment_size_mb` to the local disk.\n\n" +
848
- "AUTO-ESCALATION: If a search returns exactly one preview, the backend automatically fetches the full email in the same call. Plan around the response shape — check the `type` field (`previews` vs `full_email`) before assuming.\n\n" +
849
- "EMAIL ID FORMATS (`email_id` parameter accepts any of):\n" +
850
- "- `msg_<6 chars>` — short ID returned by previews on THIS machine. NOT portable across machines or sessions; the local cache holds the most recent 1000. If you reference one that's been evicted, the tool returns a StaleShortIdError telling you to re-search. The mailbox used in the original search is remembered with the short ID and re-applied automatically when you fetch or reply without specifying `mailbox_address`.\n" +
851
- "- `adeu_<numeric>` — server-side reference for emails Adeu has previously processed. Portable across machines and sessions for the same authenticated user.\n" +
852
- "- Raw provider ID (Gmail/Outlook native ID) — works if you have it, but you usually won't.\n\n" +
853
- "FOLDER DEFAULT: omitting `folder` searches the Inbox only (matching what the user sees in their mail client). Use `folder='sent'` for sent items, `folder='all'` to include Deleted Items, Drafts, and other folders.\n\n" +
854
- "ATTACHMENTS: attachments larger than `max_attachment_size_mb` (default 10) are listed in the response but NOT downloaded — raise the cap if you need them. Always set `working_directory` when calling from a project so attachments land alongside the user's other files; the directory is created automatically if it does not exist. This directory path refers to the user's native operating system, not the LLM's sandbox environment.",
855
- inputSchema: z.object({
856
- reasoning: z
857
- .string()
858
- .describe(
859
- "Why do I need to search or fetch these emails? State this reason before any other parameter.",
860
- ),
861
- sender: z.string().optional(),
862
- subject: z.string().optional(),
863
- has_attachments: z.boolean().optional(),
864
- attachment_name: z.string().optional(),
865
- is_unread: z.boolean().optional(),
866
- days_ago: z.coerce.number().optional(),
867
- folder: z.enum(["inbox", "sent", "all"]).optional(),
868
- limit: z.coerce.number().default(10),
869
- offset: z.coerce.number().default(0),
870
- email_id: z.string().optional(),
871
- working_directory: z
872
- .string()
873
- .optional()
874
- .describe(
875
- "Optional. The current working directory of the project or task. " +
876
- "If provided, attachments will be saved here under an 'adeu_attachments' subfolder; " +
877
- "the directory is created automatically if it does not exist. " +
878
- "If omitted, attachments are saved to the system temp directory.",
879
- ),
880
- mailbox_address: z
881
- .string()
882
- .optional()
883
- .describe("Optional target mailbox email address to search within."),
884
- task_id: z
885
- .string()
886
- .optional()
887
- .describe("If resuming a pending check, provide the task ID here."),
888
- max_attachment_size_mb: z.coerce
889
- .number()
890
- .optional()
891
- .describe(
892
- "Maximum attachment size in MB to download (default 10). Attachments larger than this are listed in the response but not downloaded. Raise this to fetch large files.",
893
- ),
894
- }),
895
- _meta: { ui: { resourceUri: EMAIL_UI_URI } },
896
- },
897
- async (args) => {
898
- try {
899
- return (await search_and_fetch_emails(args)) as any;
900
- } catch (e: any) {
901
- return {
902
- isError: true,
903
- content: [{ type: "text", text: e.message }],
904
- };
905
- }
906
- },
907
- );
908
-
909
- server.registerTool(
910
- "login_to_adeu_cloud",
911
- {
912
- description:
913
- "Logs the user into Adeu Cloud. Opens a browser window for SSO authentication.\n\n" +
914
- "IMPORTANT — login is user-level, not account-level:\n" +
915
- "- An Adeu user can have multiple linked provider accounts (Microsoft, Google) and multiple mailboxes (personal + shared/delegated). One linked account is marked primary.\n" +
916
- "- Signing in through ANY of the user's linked accounts authenticates the same Adeu user. Once logged in, the session can read from and draft in ALL of that user's linked accounts and ALL of their mailboxes — not just the one used to sign in.\n" +
917
- "- The choice of which provider account to sign in through is purely an SSO mechanism; it does not select a 'current account' for the session.\n\n" +
918
- "When the user asks which accounts or mailboxes are available, call `list_available_mailboxes` rather than naming a single account from the login response.",
919
- inputSchema: {
920
- reasoning: z
921
- .string()
922
- .describe(
923
- "Why do I need to log in to Adeu Cloud? State this reason before any other parameter.",
924
- ),
925
- },
926
- },
927
- async () => {
928
- try {
929
- return (await login_to_adeu_cloud()) as any;
930
- } catch (e: any) {
931
- return { isError: true, content: [{ type: "text", text: e.message }] };
932
- }
933
- },
934
- );
935
-
936
- server.registerTool(
937
- "logout_of_adeu_cloud",
938
- {
939
- description: "Logs out of the Adeu Cloud backend.",
940
- inputSchema: {
941
- reasoning: z
942
- .string()
943
- .describe(
944
- "Why do I need to log out of Adeu Cloud? State this reason before any other parameter.",
945
- ),
946
- },
947
- },
948
- async () => {
949
- try {
950
- return (await logout_of_adeu_cloud()) as any;
951
- } catch (e: any) {
952
- return { isError: true, content: [{ type: "text", text: e.message }] };
953
- }
954
- },
955
- );
956
- server.registerTool(
957
- "create_email_draft",
958
- {
959
- description:
960
- "Creates an email draft in the user's native draft box (Outlook Drafts or Gmail Drafts).\n\n" +
961
- "TWO MODES:\n" +
962
- "1. Reply mode: pass `reply_to_email_id` to create a threaded reply. The draft inherits subject, recipients, and threading headers from the original — do NOT pass `subject` or `to_recipients`.\n" +
963
- "2. New email mode: omit `reply_to_email_id` and pass BOTH `subject` and `to_recipients`.\n\n" +
964
- "`reply_to_email_id` accepts the same ID formats as search_and_fetch_emails (`msg_*` short IDs, `adeu_*` references, or raw provider IDs). Short IDs are validated against the local cache before the call; stale ones fail fast with a clear error telling you to re-search.\n\n" +
965
- "`body_markdown` is converted server-side to styled HTML with inlined CSS for email-client compatibility. Write the body in plain Markdown — do not pre-render HTML.\n\n" +
966
- "`attachment_paths` takes absolute file paths on the user's local disk and uploads them with the draft. Useful right after search_and_fetch_emails downloaded attachments — those local paths can be passed directly here.",
967
- inputSchema: {
968
- reasoning: z
969
- .string()
970
- .describe(
971
- "Why do I need to create this email draft? State this reason before any other parameter.",
972
- ),
973
- body_markdown: z.string(),
974
- reply_to_email_id: z.string().optional(),
975
- subject: z.string().optional(),
976
- to_recipients: z.array(z.string()).optional(),
977
- attachment_paths: z.array(z.string()).optional(),
978
- mailbox_address: z
979
- .string()
980
- .optional()
981
- .describe(
982
- "Optional target mailbox email address to create the draft in.",
983
- ),
984
- },
985
- },
986
- async (args) => {
987
- try {
988
- return (await create_email_draft(args)) as any;
989
- } catch (e: any) {
990
- return { isError: true, content: [{ type: "text", text: e.message }] };
991
- }
992
- },
993
- );
994
- server.registerTool(
995
- "list_available_mailboxes",
996
- {
997
- description:
998
- "Lists all personal and shared/delegated mailboxes the authenticated Adeu user has access to, across ALL of their linked provider accounts. Returns each mailbox's `email_address`, `display_name`, auto-processing settings, and write-back preference.\n\n" +
999
- "This is the right tool to answer 'which accounts/mailboxes am I logged into?' — Adeu login is user-level, so a single MCP session can see every mailbox listed here regardless of which provider account was used for SSO.\n\n" +
1000
- "Call this FIRST when the user names a specific mailbox or shared inbox, to resolve the canonical `email_address`. Then pass that address as `mailbox_address` to `search_and_fetch_emails` or `create_email_draft` to scope the operation. Omitting `mailbox_address` on those tools targets the user's primary personal mailbox.",
1001
- inputSchema: {
1002
- reasoning: z
1003
- .string()
1004
- .describe(
1005
- "Why do I need to list available mailboxes? State this reason before any other parameter.",
1006
- ),
1007
- },
1008
- },
1009
- async () => {
1010
- try {
1011
- return (await list_available_mailboxes()) as any;
1012
- } catch (e: any) {
1013
- return { isError: true, content: [{ type: "text", text: e.message }] };
1014
- }
1015
- },
1016
- );
1017
- }
1018
-
1019
855
  // --- Formatter for process_document_batch ---
1020
856
  export function formatBatchResult(
1021
857
  stats: any,
@@ -5,6 +5,8 @@ import {
5
5
  split_structural_appendix,
6
6
  extract_outline,
7
7
  OutlineNode,
8
+ RegexTimeoutError,
9
+ userFindAllMatches,
8
10
  } from "@adeu/core";
9
11
 
10
12
  export interface ToolResult {
@@ -64,6 +66,26 @@ export function render_outline_tree(
64
66
  return lines.join("\n");
65
67
  }
66
68
 
69
+ export function build_full_document_response(
70
+ text: string,
71
+ file_path: string,
72
+ ): ToolResult {
73
+ // The ENTIRE document body with no page banner, continuation footer, or
74
+ // appendix pointer — the round-trip artifact for text-based apply/diff
75
+ // (QA 2026-07-17 F1; mirrors Python's build_full_document_response).
76
+ const [body] = split_structural_appendix(text);
77
+ const ui_markdown = body;
78
+ const llm_content = `> **File Path:** \`${resolve(file_path)}\`\n\n${ui_markdown}`;
79
+ return {
80
+ content: [{ type: "text", text: llm_content }],
81
+ structuredContent: {
82
+ markdown: ui_markdown,
83
+ file_path: resolve(file_path),
84
+ title: basename(file_path),
85
+ },
86
+ };
87
+ }
88
+
67
89
  export function build_paginated_response(
68
90
  text: string,
69
91
  page: number,
@@ -112,6 +134,11 @@ export function build_outline_response(
112
134
  outline_verbose: boolean = false,
113
135
  paragraph_offsets: Map<any, [number, number]> | null = null,
114
136
  ): ToolResult {
137
+ // Levels outside 1-6 are meaningless (0/negative would render a
138
+ // nonsensical "L1-L0" range label, QA L2). Clamp to the nearest sensible
139
+ // depth, mirroring the Python builder.
140
+ outline_max_level = Math.max(1, Math.min(outline_max_level, 6));
141
+
115
142
  const [body] = split_structural_appendix(projected_text);
116
143
  const pagination_result = paginate(body, "");
117
144
 
@@ -229,9 +256,11 @@ export function build_search_response(
229
256
  // the same broken regex.
230
257
  let regexDowngradedNote = "";
231
258
  let regex: RegExp;
259
+ let isUserRegex = false;
232
260
  if (search_regex) {
233
261
  try {
234
262
  regex = new RegExp(search_query, flags);
263
+ isUserRegex = true;
235
264
  } catch (e: any) {
236
265
  regexDowngradedNote =
237
266
  `> **Note:** \`${search_query}\` is not a valid regular expression ` +
@@ -243,7 +272,26 @@ export function build_search_response(
243
272
  regex = new RegExp(escapeRegExp(search_query), flags);
244
273
  }
245
274
 
246
- const allMatches = Array.from(body.matchAll(regex));
275
+ // Patterns that blow the matching time budget (catastrophic backtracking,
276
+ // QA 2026-07-17 F5) get the same literal downgrade as invalid patterns —
277
+ // for a read-only search, degraded results beat a hung event loop.
278
+ let allMatches: Array<{ 0: string; index?: number }>;
279
+ if (isUserRegex) {
280
+ try {
281
+ allMatches = userFindAllMatches(search_query, body, flags).map((m) => ({
282
+ 0: body.slice(m.start, m.end),
283
+ index: m.start,
284
+ }));
285
+ } catch (e: any) {
286
+ if (!(e instanceof RegexTimeoutError)) throw e;
287
+ regexDowngradedNote =
288
+ `> **Note:** \`${search_query}\` was searched as literal text instead of as ` +
289
+ `a regular expression: ${e.message}`;
290
+ allMatches = Array.from(body.matchAll(new RegExp(escapeRegExp(search_query), flags)));
291
+ }
292
+ } else {
293
+ allMatches = Array.from(body.matchAll(regex));
294
+ }
247
295
 
248
296
  // Compute document pagination once — needed for both annotation and filtering.
249
297
  const pag_res = paginate(body, "");
package/src/shared.ts CHANGED
@@ -1,7 +1,2 @@
1
1
  // FILE: node/packages/mcp-server/src/shared.ts
2
- export const FRONTEND_URL =
3
- process.env.ADEU_FRONTEND_URL || "https://app.adeu.ai";
4
- export const BACKEND_URL =
5
- process.env.ADEU_BACKEND_URL || "https://app.adeu.ai";
6
2
  export const MARKDOWN_UI_URI = "ui://adeu/markdown-ui";
7
- export const EMAIL_UI_URI = "ui://adeu/email-ui";