msteams-mcp 0.14.3 → 0.15.3

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.
@@ -10,7 +10,7 @@ import { ErrorCode, createError } from '../types/errors.js';
10
10
  import { ok, err } from '../types/result.js';
11
11
  import { getUserDisplayName } from '../auth/token-extractor.js';
12
12
  import { requireMessageAuth, getRegion, getTeamsBaseUrl } from '../utils/auth-guards.js';
13
- import { stripHtml, extractLinks, buildMessageLink, buildOneOnOneConversationId, extractObjectId, extractActivityTimestamp, parseVirtualConversationMessage } from '../utils/parsers.js';
13
+ import { stripHtml, extractLinks, buildMessageLink, buildOneOnOneConversationId, extractObjectId, extractActivityTimestamp, parseVirtualConversationMessage, markdownToTeamsHtml } from '../utils/parsers.js';
14
14
  import { DEFAULT_ACTIVITY_LIMIT, SAVED_MESSAGES_ID, FOLLOWED_THREADS_ID, VIRTUAL_CONVERSATION_PREFIX, SELF_CHAT_ID, MRI_ORGID_PREFIX } from '../constants.js';
15
15
  // Reusable date formatter for human-readable timestamps with day of week
16
16
  // Hoisted to module scope to avoid creating a new formatter per message
@@ -68,23 +68,12 @@ export async function sendMessage(conversationId, content, options = {}) {
68
68
  const displayName = getUserDisplayName() || 'User';
69
69
  // Generate unique message ID
70
70
  const clientMessageId = Date.now().toString();
71
- // Process content: handle mentions and links
72
- let processedContent;
73
- let mentionsToSend = [];
74
- // If content is already HTML, pass through as-is
75
- if (content.startsWith('<')) {
76
- processedContent = content;
77
- }
78
- else {
79
- // Process mentions and links together
80
- const parsed = parseContentWithMentionsAndLinks(content);
81
- processedContent = parsed.html;
82
- mentionsToSend = parsed.mentions;
83
- }
84
- // Wrap content in paragraph if not already HTML
85
- const htmlContent = processedContent.startsWith('<')
86
- ? processedContent
87
- : `<p>${processedContent}</p>`;
71
+ // Process content: handle mentions, links, and markdown formatting
72
+ // Always convert through markdown→HTML pipeline (never pass raw HTML through,
73
+ // as Teams requires proper block-level wrapping like <p> tags)
74
+ const parsed = parseContentWithMentionsAndLinks(content);
75
+ const htmlContent = parsed.html;
76
+ const mentionsToSend = parsed.mentions;
88
77
  // Build the message body
89
78
  const body = {
90
79
  content: htmlContent,
@@ -388,8 +377,9 @@ export async function editMessage(conversationId, messageId, newContent) {
388
377
  const auth = authResult.value;
389
378
  const { region, baseUrl } = getApiConfig();
390
379
  const displayName = getUserDisplayName() || 'User';
391
- // Wrap content in paragraph if not already HTML
392
- const htmlContent = newContent.startsWith('<') ? newContent : `<p>${escapeHtml(newContent)}</p>`;
380
+ // Always convert through markdown→HTML pipeline (never pass raw HTML through,
381
+ // as Teams requires proper block-level wrapping like <p> tags)
382
+ const htmlContent = markdownToTeamsHtml(newContent);
393
383
  // Build the edit request body
394
384
  // The API requires the message structure with updated content
395
385
  const body = {
@@ -624,30 +614,43 @@ function parseContentWithMentionsAndLinks(content) {
624
614
  ...findAll(mentionPattern, 'mention'),
625
615
  ...findAll(linkPattern, 'link'),
626
616
  ].sort((a, b) => a.index - b.index);
627
- // Build result
617
+ // No mentions or links - use full markdown conversion
618
+ if (matches.length === 0) {
619
+ return { html: markdownToTeamsHtml(content), mentions: [] };
620
+ }
621
+ // Strategy: replace mentions/links with unique placeholders, run the whole
622
+ // content through markdownToTeamsHtml (so links stay inline within their
623
+ // paragraph), then substitute placeholders back with actual HTML.
628
624
  const mentions = [];
629
- let result = '';
630
- let lastIndex = 0;
625
+ const placeholders = new Map();
631
626
  let mentionId = 0;
632
- for (const m of matches) {
633
- // Add escaped text before this match
634
- const textBefore = content.substring(lastIndex, m.index);
635
- result += escapeHtml(textBefore);
627
+ // Build content with placeholders (process in reverse to preserve indices)
628
+ let placeholderContent = content;
629
+ for (let i = matches.length - 1; i >= 0; i--) {
630
+ const m = matches[i];
631
+ const placeholder = `\uE000MCP_PH_${i}\uE001`;
632
+ let html;
636
633
  if (m.type === 'mention') {
637
634
  mentions.push({ mri: m.target, displayName: m.text });
638
- result += buildMentionHtml(m.text, mentionId);
635
+ html = buildMentionHtml(m.text, mentionId);
639
636
  mentionId++;
640
637
  }
641
638
  else {
642
- // Link
643
639
  const safeText = escapeHtml(m.text);
644
640
  const safeUrl = m.target.replace(/"/g, '&quot;');
645
- result += `<a href="${safeUrl}">${safeText}</a>`;
641
+ html = `<a href="${safeUrl}">${safeText}</a>`;
646
642
  }
647
- lastIndex = m.index + m.length;
643
+ placeholders.set(placeholder, html);
644
+ placeholderContent = placeholderContent.substring(0, m.index) + placeholder + placeholderContent.substring(m.index + m.length);
645
+ }
646
+ // Reverse mentions array since we processed in reverse order
647
+ mentions.reverse();
648
+ // Convert the whole content (with placeholders) through markdown pipeline
649
+ let result = markdownToTeamsHtml(placeholderContent);
650
+ // Substitute placeholders back with actual HTML
651
+ for (const [placeholder, html] of placeholders) {
652
+ result = result.replace(placeholder, html);
648
653
  }
649
- // Add remaining text
650
- result += escapeHtml(content.substring(lastIndex));
651
654
  return { html: result, mentions };
652
655
  }
653
656
  /**
@@ -4,7 +4,6 @@
4
4
  */
5
5
  import { saveSessionState } from './context.js';
6
6
  import { OVERLAY_STEP_PAUSE_MS, OVERLAY_COMPLETE_PAUSE_MS, } from '../constants.js';
7
- import { extractSubstrateToken, clearTokenCache } from '../auth/token-extractor.js';
8
7
  /**
9
8
  * Default Teams URL for initial login.
10
9
  *
@@ -285,18 +284,61 @@ async function hasAuthenticatedContent(page) {
285
284
  }
286
285
  return false;
287
286
  }
287
+ /**
288
+ * Waits for the Teams SPA to be fully loaded and interactive.
289
+ *
290
+ * Teams is a heavy SPA — after navigation, the shell loads quickly but MSAL
291
+ * (which manages tokens) needs time to bootstrap. We wait for key UI elements
292
+ * that only render after the app has fully initialised, then wait for network
293
+ * activity to settle (indicating MSAL token requests have completed).
294
+ */
295
+ async function waitForTeamsReady(page, log, timeoutMs = 60000) {
296
+ log('Waiting for Teams to fully load...');
297
+ try {
298
+ // Wait for any SPA UI element that indicates the app has bootstrapped
299
+ await page.waitForSelector(AUTH_SUCCESS_SELECTORS.join(', '), { timeout: timeoutMs });
300
+ log('Teams UI elements detected.');
301
+ // Wait for network to settle — MSAL token refresh requests should complete
302
+ try {
303
+ await page.waitForLoadState('networkidle', { timeout: 15000 });
304
+ log('Network settled.');
305
+ }
306
+ catch {
307
+ // Network may not become fully idle (websockets, polling), that's OK
308
+ log('Network did not fully settle, continuing...');
309
+ }
310
+ return true;
311
+ }
312
+ catch {
313
+ log('Teams did not fully load within timeout.');
314
+ return false;
315
+ }
316
+ }
288
317
  /**
289
318
  * Triggers MSAL to acquire the Substrate token.
290
319
  *
291
320
  * MSAL only acquires tokens for specific scopes when the app makes API calls
292
321
  * requiring those scopes. The Substrate API is only used for search, so we
293
- * perform a minimal search ("is:Messages") to trigger token acquisition.
322
+ * perform a search to trigger token acquisition.
323
+ *
324
+ * Returns true if a Substrate API call was detected, false otherwise.
294
325
  */
295
326
  async function triggerTokenAcquisition(page, log) {
296
327
  log('Triggering token acquisition...');
297
328
  try {
298
- // Wait for the app to be ready
299
- await page.waitForTimeout(5000);
329
+ // Wait for Teams SPA to be fully loaded (MSAL must bootstrap first)
330
+ const ready = await waitForTeamsReady(page, log);
331
+ if (!ready) {
332
+ log('Teams did not load — cannot trigger token acquisition.');
333
+ return false;
334
+ }
335
+ // Set up Substrate API listener BEFORE triggering search
336
+ let substrateDetected = false;
337
+ const substratePromise = page.waitForResponse(resp => resp.url().includes('substrate.office.com') && resp.status() === 200, { timeout: 30000 }).then(() => {
338
+ substrateDetected = true;
339
+ }).catch(() => {
340
+ // Timeout — no Substrate call detected
341
+ });
300
342
  // Try multiple methods to trigger search
301
343
  let searchTriggered = false;
302
344
  // Method 1: Navigate to search results URL (triggers Substrate API call directly)
@@ -306,7 +348,6 @@ async function triggerTokenAcquisition(page, log) {
306
348
  waitUntil: 'domcontentloaded',
307
349
  timeout: 30000,
308
350
  });
309
- await page.waitForTimeout(5000);
310
351
  searchTriggered = true;
311
352
  log('Search results page loaded.');
312
353
  }
@@ -355,28 +396,31 @@ async function triggerTokenAcquisition(page, log) {
355
396
  await page.keyboard.press('Enter');
356
397
  searchTriggered = true;
357
398
  }
358
- // Wait for the Substrate search API call to complete
359
- log('Waiting for search API...');
360
- try {
361
- // Wait for the actual API request to substrate.office.com
362
- await Promise.race([
363
- page.waitForResponse(resp => resp.url().includes('substrate.office.com') && resp.status() === 200, { timeout: 15000 }),
364
- page.waitForTimeout(15000),
365
- ]);
366
- log('Substrate API call detected.');
399
+ // Wait for the Substrate API response
400
+ log('Waiting for Substrate API...');
401
+ await substratePromise;
402
+ if (substrateDetected) {
403
+ log('Substrate API call detected — tokens acquired.');
367
404
  }
368
- catch {
369
- log('No Substrate API call detected, continuing...');
405
+ else {
406
+ log('No Substrate API call detected within timeout.');
370
407
  }
408
+ // Give MSAL a moment to persist tokens to localStorage
371
409
  await page.waitForTimeout(2000);
372
410
  // Close search and reset
373
- await page.keyboard.press('Escape');
374
- await page.waitForTimeout(1000);
411
+ try {
412
+ await page.keyboard.press('Escape');
413
+ await page.waitForTimeout(500);
414
+ }
415
+ catch {
416
+ // Page may have navigated, ignore
417
+ }
375
418
  log('Token acquisition complete.');
419
+ return substrateDetected;
376
420
  }
377
421
  catch (error) {
378
422
  log(`Token acquisition warning: ${error instanceof Error ? error.message : String(error)}`);
379
- await page.waitForTimeout(3000);
423
+ return false;
380
424
  }
381
425
  }
382
426
  /**
@@ -517,23 +561,20 @@ export async function waitForManualLogin(page, context, timeoutMs = 5 * 60 * 100
517
561
  await showLoginProgress(page, 'acquiring');
518
562
  }
519
563
  // Trigger a search to cause MSAL to acquire the Substrate token
520
- await triggerTokenAcquisition(page, log);
564
+ const acquired = await triggerTokenAcquisition(page, log);
565
+ if (!acquired) {
566
+ log('Warning: Substrate API call was not detected after login.');
567
+ if (showOverlay) {
568
+ await showLoginProgress(page, 'error', { pause: true });
569
+ }
570
+ throw new Error('Login completed but token acquisition failed. Please try again.');
571
+ }
521
572
  if (showOverlay) {
522
573
  await showLoginProgress(page, 'saving');
523
574
  }
524
575
  // Save the session state with fresh tokens
525
576
  await saveSessionState(context);
526
- clearTokenCache();
527
577
  log('Session state saved.');
528
- // Verify tokens were actually acquired
529
- const token = extractSubstrateToken();
530
- if (!token || token.expiry.getTime() <= Date.now()) {
531
- log('Warning: No valid Substrate token found after login. You may need to try again.');
532
- if (showOverlay) {
533
- await showLoginProgress(page, 'error', { pause: true });
534
- }
535
- throw new Error('Login completed but token acquisition failed. Please try again.');
536
- }
537
578
  if (showOverlay) {
538
579
  await showLoginProgress(page, 'complete', { pause: true });
539
580
  }
@@ -566,39 +607,23 @@ export async function ensureAuthenticated(page, context, onProgress, showOverlay
566
607
  const status = await navigateToTeams(page);
567
608
  if (status.isAuthenticated) {
568
609
  log('Already authenticated.');
569
- // Snapshot token expiry before refresh attempt
570
- const beforeToken = extractSubstrateToken();
571
- const beforeExpiry = beforeToken?.expiry?.getTime() ?? 0;
572
610
  if (showOverlay) {
573
611
  await showLoginProgress(page, 'refreshing');
574
612
  }
575
613
  // Trigger a search to cause MSAL to acquire/refresh the Substrate token
576
- await triggerTokenAcquisition(page, log);
614
+ const acquired = await triggerTokenAcquisition(page, log);
615
+ if (!acquired) {
616
+ log('Token refresh failed: Substrate API call was not detected.');
617
+ if (showOverlay) {
618
+ await showLoginProgress(page, 'error', { pause: true });
619
+ }
620
+ throw new Error('Token refresh failed. Please use teams_login with forceNew to re-authenticate.');
621
+ }
577
622
  if (showOverlay) {
578
623
  await showLoginProgress(page, 'saving');
579
624
  }
580
625
  // Save the session state with fresh tokens
581
626
  await saveSessionState(context);
582
- // Clear token cache so we re-extract from the freshly saved session
583
- clearTokenCache();
584
- // Verify tokens were actually refreshed
585
- const afterToken = extractSubstrateToken();
586
- const afterExpiry = afterToken?.expiry?.getTime() ?? 0;
587
- const tokenIsValid = afterToken !== null && afterExpiry > Date.now();
588
- const tokenWasRefreshed = afterExpiry > beforeExpiry;
589
- if (!tokenIsValid) {
590
- log('Warning: No valid token found after refresh attempt.');
591
- if (headless) {
592
- throw new Error('Token refresh failed: no valid token after SSO. Browser-based re-authentication required.');
593
- }
594
- }
595
- else if (!tokenWasRefreshed && beforeToken && beforeExpiry <= Date.now() + 10 * 60 * 1000) {
596
- // Token was close to expiry but wasn't refreshed - MSAL likely didn't initialise in time
597
- log('Warning: Token was not refreshed despite being close to expiry.');
598
- if (headless) {
599
- throw new Error('Token refresh failed: token not refreshed despite being close to expiry. Browser-based re-authentication required.');
600
- }
601
- }
602
627
  if (showOverlay) {
603
628
  await showLoginProgress(page, 'complete', { pause: true });
604
629
  }
@@ -72,13 +72,13 @@ export const GetFollowedThreadsInputSchema = z.object({
72
72
  // ─────────────────────────────────────────────────────────────────────────────
73
73
  const sendMessageToolDefinition = {
74
74
  name: 'teams_send_message',
75
- description: 'Send a message to a Teams conversation. Supports @mentions using @[Name](mri) syntax inline. Example: "Hey @[John Smith](8:orgid:abc...), check this". Get MRI from teams_search_people. Defaults to self-notes (48:notes). For channel thread replies, provide replyToMessageId.',
75
+ description: 'Send a message to a Teams conversation. Use markdown for formatting (not HTML): **bold**, *italic*, ~~strikethrough~~, `code`, ```code blocks```, lists, and newlines. Supports @mentions using @[Name](mri) syntax inline. Example: "Hey @[John Smith](8:orgid:abc...), check this". Get MRI from teams_search_people. Defaults to self-notes (48:notes). For channel thread replies, provide replyToMessageId.',
76
76
  inputSchema: {
77
77
  type: 'object',
78
78
  properties: {
79
79
  content: {
80
80
  type: 'string',
81
- description: 'The message content. For @mentions, use @[DisplayName](mri) syntax. Example: "Hey @[John Smith](8:orgid:abc...), can you review this?"',
81
+ description: 'The message content in markdown (not HTML). Supports: **bold**, *italic*, ~~strikethrough~~, `inline code`, ```code blocks```, bullet lists (- item), numbered lists (1. item), and newlines. Do NOT send raw HTML tags. For @mentions, use @[DisplayName](mri) syntax. Example: "Hey @[John Smith](8:orgid:abc...), can you review this?"',
82
82
  },
83
83
  conversationId: {
84
84
  type: 'string',
@@ -218,6 +218,27 @@ export declare function buildOneOnOneConversationId(userId1: string, userId2: st
218
218
  * @returns ISO timestamp string, or null if no valid timestamp found
219
219
  */
220
220
  export declare function extractActivityTimestamp(msg: Record<string, unknown>): string | null;
221
+ /**
222
+ * Converts markdown-formatted text to Teams-compatible HTML.
223
+ *
224
+ * Supports:
225
+ * - **bold** / __bold__ → <b>
226
+ * - *italic* / _italic_ → <i>
227
+ * - ~~strikethrough~~ → <s>
228
+ * - `inline code` → <code>
229
+ * - ```code blocks``` → <pre><code>
230
+ * - Newlines → paragraph breaks
231
+ * - Ordered lists (1. item) → <ol><li>
232
+ * - Unordered lists (- item, * item) → <ul><li>
233
+ *
234
+ * Plain text without any formatting is returned as-is (HTML-escaped).
235
+ */
236
+ export declare function markdownToTeamsHtml(text: string): string;
237
+ /**
238
+ * Checks whether text contains any markdown formatting that would
239
+ * benefit from conversion to HTML.
240
+ */
241
+ export declare function hasMarkdownFormatting(text: string): boolean;
221
242
  /** Common fields from a virtual conversation message (48:saved, 48:threads, etc). */
222
243
  export interface VirtualConversationItem {
223
244
  id: string;
@@ -669,6 +669,155 @@ export function extractActivityTimestamp(msg) {
669
669
  }
670
670
  return null;
671
671
  }
672
+ // ─────────────────────────────────────────────────────────────────────────────
673
+ // Markdown to Teams HTML Conversion
674
+ // ─────────────────────────────────────────────────────────────────────────────
675
+ /**
676
+ * Escapes HTML special characters in text.
677
+ */
678
+ function escapeHtmlChars(text) {
679
+ return text
680
+ .replace(/&/g, '&amp;')
681
+ .replace(/</g, '&lt;')
682
+ .replace(/>/g, '&gt;')
683
+ .replace(/"/g, '&quot;');
684
+ }
685
+ /**
686
+ * Converts inline markdown formatting to Teams HTML within a single line.
687
+ * Handles: bold, italic, strikethrough, inline code.
688
+ * Text outside of formatting markers is HTML-escaped.
689
+ */
690
+ function convertInlineFormatting(line) {
691
+ // Process inline code first (to prevent other formatting inside code spans)
692
+ // Split on `code` patterns, escape and format alternately
693
+ const codeParts = line.split(/`([^`]+)`/);
694
+ let result = '';
695
+ for (let i = 0; i < codeParts.length; i++) {
696
+ if (i % 2 === 1) {
697
+ // Inside backticks - render as code, only escape HTML
698
+ result += `<code>${escapeHtmlChars(codeParts[i])}</code>`;
699
+ }
700
+ else {
701
+ // Outside backticks - process other inline formatting
702
+ let segment = escapeHtmlChars(codeParts[i]);
703
+ // Bold: **text** or __text__
704
+ segment = segment.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
705
+ segment = segment.replace(/__(.+?)__/g, '<b>$1</b>');
706
+ // Italic: *text* or _text_ (but not inside words for underscore)
707
+ segment = segment.replace(/\*(.+?)\*/g, '<i>$1</i>');
708
+ segment = segment.replace(/(?<!\w)_(.+?)_(?!\w)/g, '<i>$1</i>');
709
+ // Strikethrough: ~~text~~
710
+ segment = segment.replace(/~~(.+?)~~/g, '<s>$1</s>');
711
+ result += segment;
712
+ }
713
+ }
714
+ return result;
715
+ }
716
+ /**
717
+ * Converts markdown-formatted text to Teams-compatible HTML.
718
+ *
719
+ * Supports:
720
+ * - **bold** / __bold__ → <b>
721
+ * - *italic* / _italic_ → <i>
722
+ * - ~~strikethrough~~ → <s>
723
+ * - `inline code` → <code>
724
+ * - ```code blocks``` → <pre><code>
725
+ * - Newlines → paragraph breaks
726
+ * - Ordered lists (1. item) → <ol><li>
727
+ * - Unordered lists (- item, * item) → <ul><li>
728
+ *
729
+ * Plain text without any formatting is returned as-is (HTML-escaped).
730
+ */
731
+ export function markdownToTeamsHtml(text) {
732
+ // Handle fenced code blocks first (```...```)
733
+ // Split text into code blocks and non-code-block segments
734
+ const segments = [];
735
+ const codeBlockRegex = /```(\w*)\n?([\s\S]*?)```/g;
736
+ let lastIndex = 0;
737
+ let match;
738
+ while ((match = codeBlockRegex.exec(text)) !== null) {
739
+ // Text before this code block
740
+ if (match.index > lastIndex) {
741
+ segments.push({ type: 'text', content: text.substring(lastIndex, match.index) });
742
+ }
743
+ segments.push({ type: 'codeblock', content: match[2], lang: match[1] || undefined });
744
+ lastIndex = match.index + match[0].length;
745
+ }
746
+ // Remaining text after last code block
747
+ if (lastIndex < text.length) {
748
+ segments.push({ type: 'text', content: text.substring(lastIndex) });
749
+ }
750
+ const htmlParts = [];
751
+ for (const segment of segments) {
752
+ if (segment.type === 'codeblock') {
753
+ // Code blocks: escape HTML but preserve whitespace
754
+ const escaped = escapeHtmlChars(segment.content.replace(/\n$/, ''));
755
+ htmlParts.push(`<pre><code>${escaped}</code></pre>`);
756
+ continue;
757
+ }
758
+ // Process text segments: split into paragraphs on double newlines
759
+ const paragraphs = segment.content.split(/\n{2,}/);
760
+ for (const para of paragraphs) {
761
+ const trimmed = para.trim();
762
+ if (!trimmed)
763
+ continue;
764
+ const lines = trimmed.split('\n');
765
+ // Check if this paragraph is a list
766
+ const isUnorderedList = lines.every(l => /^\s*[-*]\s+/.test(l));
767
+ const isOrderedList = lines.every(l => /^\s*\d+[.)]\s+/.test(l));
768
+ if (isUnorderedList) {
769
+ const items = lines.map(l => {
770
+ const content = l.replace(/^\s*[-*]\s+/, '');
771
+ return `<li>${convertInlineFormatting(content)}</li>`;
772
+ });
773
+ htmlParts.push(`<ul>${items.join('')}</ul>`);
774
+ }
775
+ else if (isOrderedList) {
776
+ const items = lines.map(l => {
777
+ const content = l.replace(/^\s*\d+[.)]\s+/, '');
778
+ return `<li>${convertInlineFormatting(content)}</li>`;
779
+ });
780
+ htmlParts.push(`<ol>${items.join('')}</ol>`);
781
+ }
782
+ else {
783
+ // Regular paragraph - join lines with <br>
784
+ const htmlLines = lines.map(l => convertInlineFormatting(l));
785
+ htmlParts.push(`<p>${htmlLines.join('<br>')}</p>`);
786
+ }
787
+ }
788
+ }
789
+ return htmlParts.join('') || '<p></p>';
790
+ }
791
+ /**
792
+ * Checks whether text contains any markdown formatting that would
793
+ * benefit from conversion to HTML.
794
+ */
795
+ export function hasMarkdownFormatting(text) {
796
+ // Code blocks
797
+ if (/```[\s\S]*```/.test(text))
798
+ return true;
799
+ // Inline code
800
+ if (/`[^`]+`/.test(text))
801
+ return true;
802
+ // Bold
803
+ if (/\*\*.+?\*\*/.test(text) || /__.+?__/.test(text))
804
+ return true;
805
+ // Italic (single * or _)
806
+ if (/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/.test(text))
807
+ return true;
808
+ // Strikethrough
809
+ if (/~~.+?~~/.test(text))
810
+ return true;
811
+ // Lists
812
+ if (/^\s*[-*]\s+/m.test(text))
813
+ return true;
814
+ if (/^\s*\d+[.)]\s+/m.test(text))
815
+ return true;
816
+ // Multiple newlines (paragraph breaks)
817
+ if (/\n/.test(text))
818
+ return true;
819
+ return false;
820
+ }
672
821
  /**
673
822
  * Parses a raw message from a virtual conversation (48:saved, 48:threads, etc).
674
823
  *
@@ -5,7 +5,7 @@
5
5
  * produce expected outputs regardless of internal logic.
6
6
  */
7
7
  import { describe, it, expect } from 'vitest';
8
- import { stripHtml, extractLinks, buildMessageLink, getConversationType, extractMessageTimestamp, parsePersonSuggestion, parseV2Result, parseJwtProfile, calculateTokenStatus, parseSearchResults, parsePeopleResults, extractObjectId, buildOneOnOneConversationId, decodeBase64Guid, extractActivityTimestamp, } from './parsers.js';
8
+ import { stripHtml, extractLinks, buildMessageLink, getConversationType, extractMessageTimestamp, parsePersonSuggestion, parseV2Result, parseJwtProfile, calculateTokenStatus, parseSearchResults, parsePeopleResults, extractObjectId, buildOneOnOneConversationId, decodeBase64Guid, extractActivityTimestamp, markdownToTeamsHtml, } from './parsers.js';
9
9
  import { searchResultItem, searchResultWithHtml, searchResultMinimal, searchResultTooShort, searchResultThreadReply, searchEntitySetsResponse, personSuggestion, personMinimal, personWithBase64Id, peopleGroupsResponse, jwtPayloadFull, jwtPayloadMinimal, jwtPayloadCommaName, jwtPayloadSpaceName, sourceWithMessageId, sourceWithConvIdMessageId, } from '../__fixtures__/api-responses.js';
10
10
  describe('stripHtml', () => {
11
11
  it('removes HTML tags', () => {
@@ -509,3 +509,75 @@ describe('extractActivityTimestamp', () => {
509
509
  expect(extractActivityTimestamp(msg)).toBeNull();
510
510
  });
511
511
  });
512
+ describe('markdownToTeamsHtml', () => {
513
+ it('wraps plain text in paragraph tags', () => {
514
+ expect(markdownToTeamsHtml('Hello world')).toBe('<p>Hello world</p>');
515
+ });
516
+ it('escapes HTML special characters', () => {
517
+ expect(markdownToTeamsHtml('1 < 2 & 3 > 0')).toBe('<p>1 &lt; 2 &amp; 3 &gt; 0</p>');
518
+ });
519
+ it('converts bold markdown', () => {
520
+ expect(markdownToTeamsHtml('This is **bold** text')).toBe('<p>This is <b>bold</b> text</p>');
521
+ expect(markdownToTeamsHtml('This is __bold__ text')).toBe('<p>This is <b>bold</b> text</p>');
522
+ });
523
+ it('converts italic markdown', () => {
524
+ expect(markdownToTeamsHtml('This is *italic* text')).toBe('<p>This is <i>italic</i> text</p>');
525
+ });
526
+ it('converts strikethrough markdown', () => {
527
+ expect(markdownToTeamsHtml('This is ~~deleted~~ text')).toBe('<p>This is <s>deleted</s> text</p>');
528
+ });
529
+ it('converts inline code', () => {
530
+ expect(markdownToTeamsHtml('Use `console.log()` here')).toBe('<p>Use <code>console.log()</code> here</p>');
531
+ });
532
+ it('does not process markdown inside inline code', () => {
533
+ expect(markdownToTeamsHtml('Use `**not bold**` here')).toBe('<p>Use <code>**not bold**</code> here</p>');
534
+ });
535
+ it('escapes HTML inside inline code', () => {
536
+ expect(markdownToTeamsHtml('Use `<div>` tag')).toBe('<p>Use <code>&lt;div&gt;</code> tag</p>');
537
+ });
538
+ it('converts fenced code blocks', () => {
539
+ expect(markdownToTeamsHtml('```\nconst x = 1;\n```')).toBe('<pre><code>const x = 1;</code></pre>');
540
+ });
541
+ it('converts fenced code blocks with language', () => {
542
+ expect(markdownToTeamsHtml('```js\nconst x = 1;\n```')).toBe('<pre><code>const x = 1;</code></pre>');
543
+ });
544
+ it('escapes HTML inside code blocks', () => {
545
+ expect(markdownToTeamsHtml('```\n<div>test</div>\n```')).toBe('<pre><code>&lt;div&gt;test&lt;/div&gt;</code></pre>');
546
+ });
547
+ it('handles text before and after code blocks', () => {
548
+ const input = 'Before\n\n```\ncode\n```\n\nAfter';
549
+ expect(markdownToTeamsHtml(input)).toBe('<p>Before</p><pre><code>code</code></pre><p>After</p>');
550
+ });
551
+ it('converts single newlines to br tags', () => {
552
+ expect(markdownToTeamsHtml('Line 1\nLine 2')).toBe('<p>Line 1<br>Line 2</p>');
553
+ });
554
+ it('converts double newlines to separate paragraphs', () => {
555
+ expect(markdownToTeamsHtml('Para 1\n\nPara 2')).toBe('<p>Para 1</p><p>Para 2</p>');
556
+ });
557
+ it('converts unordered lists', () => {
558
+ expect(markdownToTeamsHtml('- Item 1\n- Item 2\n- Item 3')).toBe('<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>');
559
+ });
560
+ it('converts unordered lists with * marker', () => {
561
+ expect(markdownToTeamsHtml('* Item 1\n* Item 2')).toBe('<ul><li>Item 1</li><li>Item 2</li></ul>');
562
+ });
563
+ it('converts ordered lists', () => {
564
+ expect(markdownToTeamsHtml('1. First\n2. Second\n3. Third')).toBe('<ol><li>First</li><li>Second</li><li>Third</li></ol>');
565
+ });
566
+ it('handles inline formatting inside list items', () => {
567
+ expect(markdownToTeamsHtml('- **Bold** item\n- *Italic* item')).toBe('<ul><li><b>Bold</b> item</li><li><i>Italic</i> item</li></ul>');
568
+ });
569
+ it('handles combined formatting', () => {
570
+ const input = '**Bold** and *italic* and `code`';
571
+ expect(markdownToTeamsHtml(input)).toBe('<p><b>Bold</b> and <i>italic</i> and <code>code</code></p>');
572
+ });
573
+ it('handles complex multi-paragraph message', () => {
574
+ const input = 'Hello **team**!\n\nHere are the updates:\n\n- Item 1\n- Item 2\n\nThanks!';
575
+ expect(markdownToTeamsHtml(input)).toBe('<p>Hello <b>team</b>!</p><p>Here are the updates:</p><ul><li>Item 1</li><li>Item 2</li></ul><p>Thanks!</p>');
576
+ });
577
+ it('returns empty paragraph for empty string', () => {
578
+ expect(markdownToTeamsHtml('')).toBe('<p></p>');
579
+ });
580
+ it('handles whitespace-only input', () => {
581
+ expect(markdownToTeamsHtml(' ')).toBe('<p></p>');
582
+ });
583
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "msteams-mcp",
3
- "version": "0.14.3",
3
+ "version": "0.15.3",
4
4
  "description": "MCP server for Microsoft Teams - search messages, send replies, manage favourites",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",