From fe44edb261054a1a1e4a9a02141ba0d45e6d28d9 Mon Sep 17 00:00:00 2001 From: Karl Kemister-Sheppard Date: Mon, 3 Aug 2026 09:26:09 +1000 Subject: [PATCH 1/4] TINYDOC-3545 - Add MCP live demo to the on-premises MCP documentation Adds a live demo to the MCP and web integrations page showing the editor experience that MCP configuration enables. The AI service backing the demo has an MCP server connected to a sample database of acquisition deals and company records. The demo uses a dedicated API key, which is required for MCP demos and is scoped to them. The key is referenced through new antora.yml attributes and applied with the liveDemo script_url_override attribute so no other demo loads it. An example.js is included so the JS tab shows a generic token endpoint and the CodePen tab is suppressed. The demo stylesheet is published as an attachment and loaded with content_css, as the live-demo extension does not serve per-demo assets. --- antora.yml | 3 + .../tinymceai-mcp-demo/content.css | 366 ++++++++++++ .../live-demos/tinymceai-mcp/example.js | 65 +++ .../live-demos/tinymceai-mcp/index.html | 62 +++ .../live-demos/tinymceai-mcp/index.js | 527 ++++++++++++++++++ .../live-demos/tinymceai-mcp/style.css | 47 ++ .../ROOT/pages/tinymceai-on-premises-mcp.adoc | 9 + 7 files changed, 1079 insertions(+) create mode 100644 modules/ROOT/attachments/tinymceai-mcp-demo/content.css create mode 100644 modules/ROOT/examples/live-demos/tinymceai-mcp/example.js create mode 100644 modules/ROOT/examples/live-demos/tinymceai-mcp/index.html create mode 100644 modules/ROOT/examples/live-demos/tinymceai-mcp/index.js create mode 100644 modules/ROOT/examples/live-demos/tinymceai-mcp/style.css diff --git a/antora.yml b/antora.yml index 3a5f74aec9..31c676dd42 100644 --- a/antora.yml +++ b/antora.yml @@ -25,6 +25,9 @@ asciidoc: exportpdf_service_url: exportpdf_service_url placeholder # TinyMCE AI demo API key tinymceai_demo_api_key: qagffr3pkuv17a8on1afax661irst1hbr4e6tbv888sz91jc + # TinyMCE AI MCP demo API key - only for MCP live demos, which require additional service access + tinymceai_mcp_demo_api_key: 3ibmjj7hijc8a4pgc2er8hgq781a2q2r81aj5dbj0wzq9a6c + tinymceai_mcp_live_demo_url: https://cdn.tiny.cloud/1/3ibmjj7hijc8a4pgc2er8hgq781a2q2r81aj5dbj0wzq9a6c/tinymce/8/tinymce.min.js # product variables productname: TinyMCE productmajorversion: 8 diff --git a/modules/ROOT/attachments/tinymceai-mcp-demo/content.css b/modules/ROOT/attachments/tinymceai-mcp-demo/content.css new file mode 100644 index 0000000000..63d0a8cd77 --- /dev/null +++ b/modules/ROOT/attachments/tinymceai-mcp-demo/content.css @@ -0,0 +1,366 @@ +/* Main demo content styles */ + +/* Fixing always showing comments */ +.tox-comment[contenteditable=false]:not([data-mce-selected]), span.tox-comment img:not([data-mce-selected]), span.tox-comment span.mce-preview-object:not([data-mce-selected]), span.tox-comment > audio:not([data-mce-selected]), span.tox-comment > video:not([data-mce-selected]) { + outline:3px solid #ffe796 +} +span.tox-comment:not([data-mce-selected]) { + background-color: #ffe796; + outline:0 +} +body:not(.tox-comments-visible) span.tox-comment[data-mce-selected] { + background-color: #ffe796; + outline:0 +} + +/* Fix issue with overlapping comments */ +.tox-comments-visible span.tox-comment[data-mce-selected] { + z-index: 1; + position: relative; +} + +/* Hide comments in AI previews */ +.tox-tinymceai__preview-body span.tox-comment { + background-color: transparent; + outline: 0; +} + +/* Fixing placeholder */ +.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before { + left: calc(var(--root-padding) * 2) !important; +} + +/* Improvement to mergetag making them selectable */ +.mce-mergetag { + user-select: all !important; + -webkit-user-select: all !important; +} + +/* Make mergetag background appear selected on firefox */ +.mce-mergetag::selection { + background-color: #b3d8ff; +} + +/* --- CONTENT STYLES -- */ + +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + --root-padding: 2rem; + --bg-color: #f7faff; + --dash-color: #d4e1f9; + --contrast-text-color: #1a1a1a; + background: var(--bg-color); + padding: var(--root-padding); + + &::before, + &::after { + content: ''; + display: block; + margin: 0 calc(50% - 50vw); + border-bottom: 1px dashed var(--dash-color); + pointer-events: none; + } + @media (width < 750px) { + --root-padding: 1rem; + } +} + +body { + font: 16px / 28px 'Inter', Arial, sans-serif; + max-width: 900px; + margin: 0 auto; + padding: var(--root-padding); + color: #2c2c2c; + background: #fff; + background-clip: padding-box; + border-right: 1px dashed var(--dash-color); + border-left: 1px dashed var(--dash-color); + overflow-x: hidden; +} + +.hero { + container-type: inline-size; + container-name: hero; + position: relative; + display: grid; + grid-template-columns: 6fr 4fr; + gap: 2rem; + margin: 0 calc(var(--root-padding) * -1) 1rem; + padding: calc(var(--root-padding) / 2) var(--root-padding); + background: #fff; + border-bottom: var(--root-padding) solid var(--bg-color); + + &:first-child, + [data-mce-bogus] + &:nth-child(2) { + margin-top: calc(var(--root-padding) * -1 - 1px); + } + + &::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + width: 100vw; + margin-left: -50vw; + border-top: 1px dashed var(--dash-color); + pointer-events: none; + } + &::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + width: 100vw; + height: var(--root-padding); + margin-left: -50vw; + border: 1px dashed var(--dash-color); + border-style: dashed none; + pointer-events: none; + } +} + +@media (width >= 750px) { + .hero__aside { + text-align: right; + margin: 0; + padding-top: 1rem; + } +} + +@media (width < 750px) { + .hero__main { + grid-column: 1 / -1; + } + .hero__aside { + grid-column: 1 / -1; + } +} + +h1 { + font-family: 'Inter', Arial, sans-serif; + font-weight: 200; + font-size: 2.6rem; + line-height: 1.2; + margin-bottom: 0.25em; + color: var(--contrast-text-color); + + @media (width < 750px) { + font-size: 2rem; + } +} + +h1 strong { + font-size: inherit; + font-weight: 700; + text-transform: none; + letter-spacing: normal; +} + +h2 { + font-family: 'Inter', Arial, sans-serif; + font-weight: 700; + font-size: 1.35rem; + margin-top: 2.25rem; + margin-bottom: 0.2rem; + color: var(--contrast-text-color); +} + +h3 { + font-family: 'Inter', Arial, sans-serif; + font-weight: 700; + font-size: 1.1rem; + margin-top: 1.5rem; + margin-bottom: 0.2rem; + color: var(--contrast-text-color); +} + +h4 { + margin: 0.75em 0; +} + +h2:first-child, +h3:first-child, +h4:first-child { + margin-top: 0; +} + +p + p { + margin: 0.75em 0; +} + +hr { + border: none; + height: 4px; + background: linear-gradient(135deg, #ED716A 20%, #EEB83F 80%); + margin: 2rem 0; +} + +.smallcaps { + font-size: 0.8em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +em { + font-style: italic; + color: inherit; +} + +.highlight { + color: #335dff; +} + +h1 .highlight { + color: var(--contrast-text-color); + background: linear-gradient(#EEB83F 20%, #ED716A 80%); + padding: 0 0.2em; + margin: 0 -0.2em; +} + +.subtitle { + font-size: 1.15rem; + color: #888cab; + font-weight: 200; + margin: 0; +} + +a { + color: #335dff; + text-decoration: underline; + text-decoration-color: #335dff80; +} + +a:hover { + color: #233477; + text-decoration-color: #233477; +} + +ul, ol { + padding-left: 1.25rem; + margin: 1rem 0; +} + +li { + margin-bottom: 0.5em; +} + +table { + width: 100%; + border-collapse: collapse; + margin: 1.25rem 0; + font-size: 0.95rem; + border: 1px dashed var(--dash-color); + + thead { + border-bottom: 3px solid #335dff; + } + + th, + thead td { + font-size: 0.8em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + text-align: left; + color: var(--contrast-text-color); + border: 1px dashed var(--dash-color); + + &.center { + text-align: center; + } + } + + thead th, + thead td { + background: var(--bg-color); + padding: 0.6rem 0.75rem; + } + + tbody th, td { + padding: 0.5rem 0.75rem; + border: 1px dashed var(--dash-color); + border-bottom: 1px solid var(--dash-color); + } + + .number { + text-align: right; + } + + tbody tr { + background: #fff; + } + + tbody tr:nth-child(even) { + background: var(--bg-color); + } + + tfoot td { + border: 1px dashed var(--dash-color); + border-bottom: none; + border-top: 2px solid var(--dash-color); + padding-top: 0.65rem; + } +} + +blockquote { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + margin: 1.25rem 0; + + &::before, + &::after { + content: ''; + width: 60px; + height: 4px; + background: linear-gradient(135deg, #ED716A 20%, #EEB83F 80%); + } +} + +blockquote p { + width: 80%; + margin: 0; + font-size: 1.3rem; +} + +img, +figure.image { + display: block; + max-width: 100%; + height: auto; + margin-bottom: 0.75em; +} +img[align="left"], +figure.image.align-left { + float: left; + margin-right: 0.75em; +} +img[align="right"], +figure.image.align-right { + float: right; + margin-left: 0.75em; +} + +figure figcaption { + font: 12px / 20px 'Inter', Arial, sans-serif; + text-align: center; + color: #888cab; +} + +:not(.tox-tinymceai__annotation) > .placeholder { + background: rgba(from var(--dash-color) r g b / .3); + outline: 1px solid var(--dash-color); + padding: 0.1rem 0.3rem; + margin: 0 -0.3rem; + border-radius: 0.25rem; + display: block; + color: #888cab; + color: lch(from var(--dash-color) calc(l / 2) c h) +} \ No newline at end of file diff --git a/modules/ROOT/examples/live-demos/tinymceai-mcp/example.js b/modules/ROOT/examples/live-demos/tinymceai-mcp/example.js new file mode 100644 index 0000000000..1c051730e1 --- /dev/null +++ b/modules/ROOT/examples/live-demos/tinymceai-mcp/example.js @@ -0,0 +1,65 @@ +// The MCP server is configured on the AI service, not in the editor. The editor only +// needs the tinymceai plugin and a token provider. +// The demo-only data plumbing (merge tag list, mentions directory, revision history, +// and template storage) is omitted below to keep the AI configuration in focus. +tinymce.init({ + selector: 'textarea#tinymceai-mcp', + height: 'clamp(650px, 80vh, 1200px)', + plugins: [ + 'autolink', 'link', 'emoticons', 'lists', 'table', 'advlist', 'searchreplace', 'wordcount', 'autocorrect', 'tinymcespellchecker', 'charmap', 'fullscreen', 'advcode', 'accordion', 'anchor', 'footnotes', 'powerpaste', 'importword', 'exportpdf', 'exportword', 'markdown', 'a11ychecker', 'typography', 'casechange', 'checklist', 'advtable', 'formatpainter', 'permanentpen', 'tableofcontents', 'help', 'math', 'linkchecker', 'media', 'mediaembed', + 'quickbars', 'tinymceai', 'tinycomments', 'uploadcare' + ], + toolbar: 'undo redo | tinymceai-chat tinymceai-review | ai-quickactions-translate | styles fontsizeinput | bold italic underline forecolor backcolor casechange | link uploadcare table addcomment | align bullist numlist checklist removeformat | code fullscreen help', + quickbars_selection_toolbar: 'tinymceai-quickactions ai-quickactions-improve-writing addcomment', + body_class: 'tox-comments-visible', // Ensure comments show up on init + sidebar_show: 'tinymceai-chat', + toolbar_mode: 'sliding', + visual: false, + table_default_attributes: {}, // Prevents inserting border=1 attribute on a new table + content_css: 'content.css', + user_id: 'james-wilson', + advcode_inline: true, + highlight_on_focus: false, + tinycomments_mode: 'embedded', + tinymceai_default_model: 'claude-4-6-sonnet', + tinymceai_allow_model_selection: false, + tinymceai_chat_welcome_message: 'This demo is connected to an Airtable database containing acquisition deals and related companies information.', + tinymceai_chat_welcome_actions: [ + { text: 'Try using the following actions with this database:' }, + { text: '**Get help editing your document**' }, + { + title: 'Fill with "Nuvo Technologies" account details', + command: 'TinyMCEAIChatPrompt', + value: { + prompt: 'Pull the "Nuvo Technologies" record from "TinyMCE demo data" Airtable database and fill in all placeholders in this document', + displayedPrompt: 'Pull the "Nuvo Technologies" record from Airtable and fill in all placeholders in this document' + }, + icon: 'document-properties' + }, + { text: '**Explore data via chat**' }, + { + title: 'Compile an open deals summary report', + command: 'TinyMCEAIChatPrompt', + value: { + prompt: 'Find all deals in "proposal sent" status and provide a summary in chat with their deal value, notes, confidence level and potential red flags' + }, + icon: 'comment' + } + ], + tinymceai_languages: [ + { title: 'English', language: 'english' }, + { title: 'Chinese (Simplified)', language: 'chinese' }, + { title: 'Spanish', language: 'spanish' }, + { title: 'German', language: 'german' }, + { title: 'Japanese', language: 'japanese' }, + { title: 'Portuguese', language: 'portuguese' }, + { title: 'Korean', language: 'korean' }, + { title: 'Italian', language: 'italian' } + ], + // Required for authentication. Replace with the token endpoint for the AI service. + tinymceai_token_provider: () => { + return fetch('/api/tinymceai-token', { credentials: 'include' }) + .then(resp => resp.text()) + .then(token => ({ token })); + } +}); diff --git a/modules/ROOT/examples/live-demos/tinymceai-mcp/index.html b/modules/ROOT/examples/live-demos/tinymceai-mcp/index.html new file mode 100644 index 0000000000..9da97d2145 --- /dev/null +++ b/modules/ROOT/examples/live-demos/tinymceai-mcp/index.html @@ -0,0 +1,62 @@ + diff --git a/modules/ROOT/examples/live-demos/tinymceai-mcp/index.js b/modules/ROOT/examples/live-demos/tinymceai-mcp/index.js new file mode 100644 index 0000000000..955eca2241 --- /dev/null +++ b/modules/ROOT/examples/live-demos/tinymceai-mcp/index.js @@ -0,0 +1,527 @@ +const apiKey = '{{tinymceai_mcp_demo_api_key}}'; + +// Once per page, set up an auth session for TestDrive so we can fetch a JWT later. +// More than once per page probably won't break anything, but there's a chance things might get out of sync. +// So to ensure demos run well, we recommend only fetching this once per page. +// This function is more complex than we recommend in the docs because we've included error handling. The error will appear as a banner if hit. +// Docs example for reference: const isLoggedIn = fetch(`https://demo.api.tiny.cloud/1/${apiKey}/auth/random`, { method: "POST", credentials: "include" }); +async function getAuthSession() { + const res = await fetch(`https://demo.api.tiny.cloud/1/${apiKey}/auth/random`, { method: 'POST', credentials: 'include' }); + if (!res.ok) throw new Error(`TestDrive auth session request failed: ${res.status}`); + const { token } = await res.json(); + return token; +} + +// Fetch a TinyMCE AI JWT from TestDrive. Returns { token } as the plugin's token +// provider requires. +// This is more complex than we recommend in the docs because we've included error handling. The error will appear as a banner if hit. +const tinymceaiTokenProvider = async () => { + // Check we've got an auth session before fetching a JWT. + await getAuthSession(); + const res = await fetch(`https://demo.api.tiny.cloud/1/${apiKey}/jwt/tinymceai`, { credentials: 'include' }); + if (!res.ok) throw new Error(`TestDrive JWT request failed: ${res.status}`); + return res.text().then(token => ({ token })); +}; + +// Tracks the visual viewport height (which excludes the on-screen keyboard on mobile) +// and exposes it as a CSS custom property --vvh. This allows the editor height to +// shrink correctly when the keyboard appears, unlike 100vh which ignores it. +const vv = window.visualViewport; +function updateVvh() { + document.documentElement.style.setProperty('--vvh', vv.height + 'px'); +} +vv.addEventListener('resize', updateVvh); +updateVvh(); + +// The Editor implementation + +// The demo document is provided by the textarea in index.html. +const initialContent = document.querySelector('textarea#tinymceai-mcp').value; + +// Merge tags +const mergetags_list = [ + { + title: 'Recipient', + menu: [ + { value: 'Recipient.FirstName', title: 'First name' }, + { value: 'Recipient.LastName', title: 'Last name' }, + { value: 'Recipient.FullName', title: 'Full name' }, + { value: 'Recipient.Salutation', title: 'Salutation (Mr/Ms/Dr …)' }, + { value: 'Recipient.Email', title: 'Email address' }, + { value: 'Recipient.Phone', title: 'Phone number' }, + { value: 'Recipient.JobTitle', title: 'Job title' }, + { value: 'Recipient.Company', title: 'Company name' }, + ] + }, + { + title: 'Sender', + menu: [ + { value: 'Sender.FirstName', title: 'First name' }, + { value: 'Sender.LastName', title: 'Last name' }, + { value: 'Sender.FullName', title: 'Full name' }, + { value: 'Sender.Email', title: 'Email address' }, + { value: 'Sender.Phone', title: 'Phone number' }, + { value: 'Sender.JobTitle', title: 'Job title' }, + { value: 'Sender.Department', title: 'Department' }, + { value: 'Sender.Signature', title: 'Email signature block' }, + ] + }, + { + title: 'Organization', + menu: [ + { value: 'Org.Name', title: 'Organization name' }, + { value: 'Org.LogoUrl', title: 'Logo URL' }, + { value: 'Org.Website', title: 'Website URL' }, + { value: 'Org.Address', title: 'Street address' }, + { value: 'Org.City', title: 'City' }, + { value: 'Org.Country', title: 'Country' }, + { value: 'Org.SupportEmail', title: 'Support email' }, + { value: 'Org.Phone', title: 'Main phone number' }, + ] + }, + { + title: 'Document', + menu: [ + { value: 'Document.Title', title: 'Document title' }, + { value: 'Document.ID', title: 'Document ID / reference' }, + { value: 'Document.Version', title: 'Version number' }, + { value: 'Document.CreatedDate', title: 'Created date' }, + { value: 'Document.DueDate', title: 'Due / expiry date' }, + { value: 'Document.Status', title: 'Status' }, + { value: 'Document.OwnerName', title: 'Document owner name' }, + ] + }, + { + title: 'Date & Time', + menu: [ + { value: 'Date.Today', title: 'Today\'s date' }, + { value: 'Date.CurrentYear', title: 'Current year' }, + { value: 'Date.CurrentMonth', title: 'Current month' }, + { value: 'Date.CurrentDay', title: 'Day of the week' }, + { value: 'DateTime.Now', title: 'Current date & time' }, + ] + }, + { + title: 'Contract & Legal', + menu: [ + { value: 'Contract.Number', title: 'Contract number' }, + { value: 'Contract.StartDate', title: 'Contract start date' }, + { value: 'Contract.EndDate', title: 'Contract end date' }, + { value: 'Contract.Value', title: 'Contract value' }, + { value: 'Contract.Currency', title: 'Currency' }, + { value: 'Contract.PaymentTerms', title: 'Payment terms' }, + { value: 'Contract.GoverningLaw', title: 'Governing law / jurisdiction' }, + ] + }, + { + title: 'Product & Order', + menu: [ + { value: 'Order.ID', title: 'Order ID' }, + { value: 'Order.Date', title: 'Order date' }, + { value: 'Order.Total', title: 'Order total' }, + { value: 'Order.ItemCount', title: 'Number of items' }, + { value: 'Product.Name', title: 'Product name' }, + { value: 'Product.SKU', title: 'Product SKU' }, + { value: 'Product.Price', title: 'Unit price' }, + { value: 'Order.TrackingURL', title: 'Shipment tracking URL' }, + ] + } +]; + +// Advanced Templates store (sessionStorage-backed) +const SESSION_KEY = 'tinymce_templates'; +const SEED_TEMPLATES = [ + { + id: '1', + title: 'Resolving tickets', + content: '

Hi {{prefix}}Customer.FirstName{{suffix}},

Thank you for reaching out. I\'m happy to let you know that your ticket has been resolved. Please don\'t hesitate to get in touch if you need further assistance.

Best regards,
{{prefix}}Agent.FirstName{{suffix}}

', + }, + { + id: '2', + title: 'Quick replies', + items: [ + { + id: '3', + title: 'Message received', + content: '

Hi {{prefix}}Customer.FirstName{{suffix}},

Thanks for your message — we\'ve received it and will get back to you as soon as possible.

Best regards,
{{prefix}}Agent.FirstName{{suffix}}

', + }, + { + id: '4', + title: 'Progress update', + content: '

Hi {{prefix}}Customer.FirstName{{suffix}},

Just a quick note to let you know we\'re still working on your request. We\'ll follow up with an update shortly.

Best regards,
{{prefix}}Agent.FirstName{{suffix}}

', + } + ] + }, + { + id: 'idheroes', + title: "Hero blocks", + items: [ + { + id: 'heroblue', + title: "Hero with link", + content: ` +
+
+

Hero
Title

Block

+

Write your subtitle here

+
+ +
+ ` + } + ] + } +]; + +const loadTemplates = () => { + try { return JSON.parse(sessionStorage.getItem(SESSION_KEY)) || SEED_TEMPLATES; } + catch { return SEED_TEMPLATES; } +}; +const saveTemplates = (templates) => sessionStorage.setItem(SESSION_KEY, JSON.stringify(templates)); +const nextId = () => Math.random().toString(36).slice(2, 10); + +const flatten = (items) => items.flatMap((t) => t.items ? flatten(t.items) : [t]); + +const findCategory = (items, id) => { + for (const item of items) { + if (item.id === id && item.items) return item; + if (item.items) { const found = findCategory(item.items, id); if (found) return found; } + } + return null; +}; + +const removeById = (items, id) => items.reduce((acc, item) => { + if (item.id === id) return acc; + return [...acc, item.items ? { ...item, items: removeById(item.items, id) } : item]; +}, []); + +// Returns the full template tree for populating the template picker +const advtemplate_list = () => Promise.resolve(loadTemplates()); + +// Returns a single template by id (called when user inserts a template) +const advtemplate_get_template = (id) => { + const tpl = flatten(loadTemplates()).find((t) => t.id === id); + return tpl ? Promise.resolve(tpl) : Promise.reject(new Error(`Template ${id} not found`)); +}; + +// Creates a new empty category and appends it to the top-level list +const advtemplate_create_category = (title) => { + const templates = loadTemplates(); + const category = { id: nextId(), title, items: [] }; + templates.push(category); + saveTemplates(templates); + return Promise.resolve(category); +}; + +// Creates a new template, optionally inside a category +const advtemplate_create_template = (title, content, categoryId) => { + const templates = loadTemplates(); + const tpl = { id: nextId(), title, content }; + if (categoryId) { + const cat = findCategory(templates, categoryId); + if (cat) cat.items.push(tpl); + } else { + templates.push(tpl); + } + saveTemplates(templates); + return Promise.resolve(tpl); +}; + +// Renames an existing category +const advtemplate_rename_category = (id, title) => { + const templates = loadTemplates(); + const cat = findCategory(templates, id); + if (cat) cat.title = title; + saveTemplates(templates); + return Promise.resolve({}); +}; + +// Renames an existing template +const advtemplate_rename_template = (id, title) => { + const templates = loadTemplates(); + const tpl = flatten(templates).find((t) => t.id === id); + if (tpl) tpl.title = title; + saveTemplates(templates); + return Promise.resolve({}); +}; + +// Deletes a single template by id +const advtemplate_delete_template = (id) => { + saveTemplates(removeById(loadTemplates(), id)); + return Promise.resolve({}); +}; + +// Deletes a category and all templates inside it +const advtemplate_delete_category = (id) => { + saveTemplates(removeById(loadTemplates(), id)); + return Promise.resolve({}); +}; + +// Moves a single template to a different category +const advtemplate_move_template = (id, categoryId) => { + const templates = loadTemplates(); + const tpl = flatten(templates).find((t) => t.id === id); + if (!tpl) return Promise.reject(new Error(`Template ${id} not found`)); + const pruned = removeById(templates, id); + const cat = findCategory(pruned, categoryId); + if (cat) cat.items.push(tpl); else pruned.push(tpl); + saveTemplates(pruned); + return Promise.resolve({}); +}; + +// Moves all templates from one category into another +const advtemplate_move_category_items = (id, categoryId) => { + const templates = loadTemplates(); + const src = findCategory(templates, id); + if (!src) return Promise.reject(new Error(`Category ${id} not found`)); + const items = [...src.items]; + src.items = []; + const dest = findCategory(templates, categoryId); + if (dest) dest.items.push(...items); else templates.push(...items); + saveTemplates(templates); + return Promise.resolve({}); +}; + +const revisions = [ +{ + revisionId: "1", + createdAt: new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate() - 1, 14, 32, 0).getTime(), + author: { + id: 'jade-scott', + name: 'Jade Scott', + avatar: 'https://sneak-preview.tiny.cloud/demouserdirectory/images/employee_jade-scott_128_515dc6a1.jpg', + }, + content: initialContent +}, +{ + revisionId: "0", + createdAt: new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate() - 1, 14, 32, 0).getTime(), + author: { + id: 'jade-scott', + name: 'Jade Scott', + avatar: 'https://sneak-preview.tiny.cloud/demouserdirectory/images/employee_jade-scott_128_515dc6a1.jpg', + }, + content: '' +} +]; + +const mentions_fetch = async (query, success) => { + const searchPhrase = query.term.toLowerCase(); + await fetch(`https://demouserdirectory.tiny.cloud/v1/users?q=${encodeURIComponent(searchPhrase)}`) + .then((response) => response.json()) + .then((users) => success(users.data.map((userInfo) => ({ + id: userInfo.id, + name: userInfo.name, + image: userInfo.avatar, + description: userInfo.custom.role + })))) + .catch((error) => console.log(error)); +}; + +const mentions_menu_complete = (editor, userInfo) => { + const span = editor.getDoc().createElement('span'); + span.className = 'mymention'; + span.setAttribute('data-mention-id', userInfo.id); + span.appendChild(editor.getDoc().createTextNode('@' + userInfo.name)); + return span; +}; + +const createCard = (userInfo) => { + const div = document.createElement('div'); + div.innerHTML = ( + '
' + + '' + + '

' + userInfo.name + '

' + + '

' + userInfo.description + '

' + + '
' + ); + return div; +}; + +const mentions_select = async (mention, success) => { + const id = mention.getAttribute('data-mention-id'); + await fetch(`https://demouserdirectory.tiny.cloud/v1/users/${id}`) + .then((response) => response.json()) + .then((userInfo) => { + const card = createCard({ + id: userInfo.id, + name: userInfo.name, + image: userInfo.avatar, + description: userInfo.custom.role + }); + success(card); + }) + .catch((error) => console.error(error)); +}; + +const mentions_menu_hover = async (userInfo, success) => { + const card = createCard(userInfo); + success(card); +}; + +tinymce.IconManager.add('fancy-ai-icons', { + icons: { + 'ai-assistant': '', + 'ai-review': '' + } +}); + +tinymce.init({ + selector: 'textarea#tinymceai-mcp', + plugins: [ + "autolink", "link", "emoticons", "lists", "table", "advlist", "searchreplace", "wordcount", "autocorrect", "tinymcespellchecker", "charmap", "fullscreen", "advcode", "accordion", "anchor", "footnotes", "powerpaste", "importword", "exportpdf", "exportword", "markdown", "a11ychecker", "typography", "casechange", "checklist", "advtable", "formatpainter", "permanentpen", "tableofcontents", "help", "math", "linkchecker", "media", "mediaembed", // general plugins + "quickbars", "tinymceai", "mergetags", "tinycomments", "mentions", "revisionhistory", "suggestededits", "advtemplate", "uploadcare" + ], + toolbar: "undo redo | tinymceai-chat tinymceai-review | ai-quickactions-translate | styles fontsizeinput | bold italic underline forecolor backcolor casechange | link uploadcare table addcomment | align bullist numlist checklist removeformat | code fullscreen help", + mobile: { + toolbar: 'styles bold italic underline forecolor backcolor | link uploadcare tableofcontents accordion hr toc footnotes | align bullist numlist checklist removeformat | showcomments code help', + sidebar_show: '', + height: 'calc(var(--vvh, 100svh) * 0.9)', + statusbar: false, + quickbars_selection_toolbar: false, + quickbars_insert_toolbar: false, + // Resizes the editor container to 90% of the visual viewport height whenever + // the viewport changes (e.g. keyboard appears). Also scrolls the editor into + // view when the keyboard opens to ensure it remains visible. + setup: (editor) => { + editor.on('init', () => { + const container = editor.getContainer(); + if (!container) return; + let prevHeight = vv.height; + function syncHeight() { + const keyboardOpened = prevHeight - vv.height > 100; + prevHeight = vv.height; + container.style.height = `calc(${vv.height}px * 0.9)`; + if (keyboardOpened) { + container.scrollIntoView({ block: 'start', behavior: 'smooth' }); + } + } + vv.addEventListener('resize', syncHeight); + }); + } + }, + height: 'clamp(650px, 80vh, 1200px)', + icons: 'fancy-ai-icons', + quickbars_selection_toolbar: 'tinymceai-quickactions ai-quickactions-improve-writing addcomment', + body_class: 'tox-comments-visible', // Ensure comments show up on init + sidebar_show: 'tinymceai-chat', + toolbar_mode: 'sliding', + visual: false, + table_default_attributes: {}, // Prevents inserting border=1 attribute on a new table + formats: { + smallcaps: { inline: 'span', selector: 'p,h1,h2,h3,h4,h5,h6,span', classes: 'smallcaps' }, + subtitle: { inline: 'span', selector: 'p,h1,h2,h3,h4,h5,h6,span', classes: 'subtitle' }, + highlight: { inline: 'span', selector: 'p,h1,h2,h3,h4,h5,h6,span', classes: 'highlight' }, + }, + style_formats_merge: true, + style_formats: [ + { + title: 'Styles', items: [ + { title: 'Small caps', format: 'smallcaps' }, + { title: 'Subtitle', format: 'subtitle' }, + { title: 'Highlight', format: 'highlight' }, + ] + }, + ], + content_css: '{{attachmentsdir}}/tinymceai-mcp-demo/content.css', + user_id: "james-wilson", + spellchecker_dialog: false, + uploadcare_public_key: '630992ad50fe2291c406', + uploadcare_store_type: 'temporary', + uploadcare_cdn_base_url: 'https://tiny.ucarecdn.com', + images_file_types: 'jpeg,jpg,jpe,jfi,jif,jfif,png,gif,bmp,webp,svg', + advcode_inline: true, + highlight_on_focus: false, + mergetags_list: mergetags_list, + tinycomments_mode: 'embedded', + fetch_users: (userIds) => Promise.all(userIds.map((userId) => fetch(`https://demouserdirectory.tiny.cloud/v1/users/${userId}`).then((response) => response.json()).catch(() => ({ id: userId })))), + mentions_item_type: "profile", + mentions_fetch, + mentions_menu_hover, + mentions_menu_complete, + mentions_select, + mentions_selector: '.mymention', + revisionhistory_fetch: () => new Promise((resolve) => { + resolve(revisions); + }), + revisionhistory_display_author: true, + advtemplate_list, + advtemplate_get_template, + advtemplate_create_category, + advtemplate_create_template, + advtemplate_rename_category, + advtemplate_rename_template, + advtemplate_delete_template, + advtemplate_delete_category, + advtemplate_move_template, + advtemplate_move_category_items, + tinymceai_token_provider: tinymceaiTokenProvider, + tinymceai_languages: [ + { title: 'English', language: 'english' }, + { title: 'Chinese (Simplified)', language: 'chinese' }, + { title: 'Spanish', language: 'spanish' }, + { title: 'German', language: 'german' }, + { title: 'Japanese', language: 'japanese' }, + { title: 'Portuguese', language: 'portuguese' }, + { title: 'Swedish', language: 'Burträskmål' }, + { title: 'Korean', language: 'korean' }, + { title: 'Hindi (Devanagari)', language: 'hindi devanagari'}, + { title: 'Italian', language: 'italian' }, + { title: 'Klingon', language: 'klingon' }, + { title: 'Dothraki', language: 'dothraki' }, + ], + tinymceai_chat_welcome_message: 'This demo is connected to an Airtable database containing acquisition deals and related companies information.', + tinymceai_chat_welcome_actions: [ + { text: 'Try using the following actions with this database:' }, + { text: '**Get help editing your document**' }, + { + title: 'Fill with "Nuvo Technologies" account details', + command: 'TinyMCEAIChatPrompt', + value: { + prompt: 'Pull the "Nuvo Technologies" record from "TinyMCE demo data" Airtable database and fill in all placeholders in this document', + displayedPrompt: 'Pull the "Nuvo Technologies" record from Airtable and fill in all placeholders in this document' + }, + icon: 'document-properties' + }, + { + title: 'Analyze and summarize our last closed-lost deal', + command: 'TinyMCEAIChatPrompt', + value: { + prompt: 'Find the closed lost deal record from "TinyMCE demo data" Airtable database and fill in all placeholders in this document. In "M&A deal summary" placeholder list 3 potential reasons for why the deal was lost - use ol element with proper markup', + displayedPrompt: 'Analyze and summarize our last closed-lost deal in Airtable data' + }, + icon: 'document-properties' + }, + { text: '**Explore data via chat**' }, + { + title: 'Compile an open deals summary report', + command: 'TinyMCEAIChatPrompt', + value: { + prompt: 'Find all deals in "proposal sent" status and provide a summary in chat with their deal value, notes, confidence level and potential red flags' + }, + icon: 'comment' + }, + { + title: 'Analyze stalled deals, and identify recurring issues', + command: 'TinyMCEAIChatPrompt', + value: { + prompt: 'Look across all deals and their linked companies. Identify any pattern in which deals stall or lose confidence — for example by industry, deal stage, deal size, or ownership structure. State the pattern you find and cite the specific deals that support it. Respond in chat.' + }, + icon: 'comment' + }, + ], + tinymceai_default_model: 'claude-4-6-sonnet', + tinymceai_allow_model_selection: false, + setup: (editor) => { + // Opens the comments sidebar when clicking a comment marker in the content, + // but only if it isn't already visible (body lacks tox-comments-visible class). + editor.on('click', (e) => { + const commentEl = e.target.closest('.tox-comment'); + if (commentEl && !editor.getBody().classList.contains('tox-comments-visible')) { + tinymce.activeEditor.execCommand('ToggleSidebar', false, 'showcomments'); + } + }); + }, +}); diff --git a/modules/ROOT/examples/live-demos/tinymceai-mcp/style.css b/modules/ROOT/examples/live-demos/tinymceai-mcp/style.css new file mode 100644 index 0000000000..a81cbd8886 --- /dev/null +++ b/modules/ROOT/examples/live-demos/tinymceai-mcp/style.css @@ -0,0 +1,47 @@ +/* Styles the mentions profile card, which renders in the editor user interface. */ + +span.mymention { + color: gray !important; +} + +div.card, +.tox div.card { + width: 240px; + background: white; + border: 1px solid #ccc; + border-radius: 3px; + box-shadow: 0 4px 8px 0 rgba(34, 47, 62, .1); + padding: 8px; + font-size: 14px; + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; +} + +div.card::after, +.tox div.card::after { + content: ""; + clear: both; + display: table; +} + +div.card h1, +.tox div.card h1 { + font-size: 14px; + font-weight: bold; + margin: 0 0 8px; + padding: 0; + line-height: normal; + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; +} + +div.card p, +.tox div.card p { + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; +} + +div.card img.avatar, +.tox div.card img.avatar { + width: 48px; + height: 48px; + margin-right: 8px; + float: left; +} diff --git a/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc b/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc index 128998aa43..c2b5e1ca84 100644 --- a/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc +++ b/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc @@ -12,6 +12,15 @@ These features are configured entirely through environment variables on the AI s [.text-center] image::tinymceai-on-premises/mcp-web-integrations-architecture.svg[MCP and web integrations architecture: TinyMCE editor connects to AI Service through SSE conversations. AI Service connects to LLM provider for inference and to MCP servers and web search and scrape endpoints for tool calling and live web content.,width=100%] +[[demo]] +== Demo + +The following demo shows the editor experience that <> enables. The AI service backing the demo has an MCP server connected to a sample database of acquisition deals and company records. Choosing one of the suggested actions in the Chat sidebar prompts the model to call the MCP server's tools, then either fill in the document placeholders or answer in Chat. + +The editor configuration shown in the *JS* tab applies to on-premises deployments without change. MCP servers are declared on the AI service through <>; the editor requires only the `+tinymceai+` plugin and a token provider. + +liveDemo::tinymceai-mcp[script_url_override="{tinymceai_mcp_live_demo_url}"] + == Prerequisites This page assumes the following are already configured: From 2786ee7fd7e88787b81b0b6f327493dd6d12fbd4 Mon Sep 17 00:00:00 2001 From: Karl Kemister-Sheppard Date: Mon, 3 Aug 2026 10:17:53 +1000 Subject: [PATCH 2/4] TINYDOC-3545 - Hide the table of contents on the MCP page to widen the demo The live demo sits inside the article column, which is reduced by the table of contents column. On a 1440px display this left the editor content area at 341px against the 900px the demo stylesheet targets, because the AI Chat sidebar occupies a fixed 440px. Setting the -toc page role returns 252px to the article column, taking the content area to 593px. This matches the other TinyMCE AI pages that host a live demo, all of which already set this role. --- modules/ROOT/pages/tinymceai-on-premises-mcp.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc b/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc index c2b5e1ca84..e67e008671 100644 --- a/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc +++ b/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc @@ -4,6 +4,7 @@ :description_short: MCP, web search, and web scraping setup. :keywords: AI, on-premises, MCP, Model Context Protocol, web search, web scraping, tool calling, Streamable HTTP :pluginname: TinyMCE AI +:page-role: -toc The AI service extends model capabilities through two integration points: the https://modelcontextprotocol.io/[Model Context Protocol] (MCP) for tool calling, and pluggable web endpoints for page fetching and search. Both features operate within AI conversations only. Web search and scraping allow the AI to reference live internet content during conversations, and for most deployments, enabling at least web search improves response quality. From 007297097f5c9018082e7dcdddf44425239fb97c Mon Sep 17 00:00:00 2001 From: Karl Kemister-Sheppard Date: Mon, 3 Aug 2026 10:45:55 +1000 Subject: [PATCH 3/4] TINYDOC-3545 - Restore the table of contents on the MCP page Reverts the -toc page role, returning the page to the standard layout with the table of contents column. --- modules/ROOT/pages/tinymceai-on-premises-mcp.adoc | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc b/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc index e67e008671..c2b5e1ca84 100644 --- a/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc +++ b/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc @@ -4,7 +4,6 @@ :description_short: MCP, web search, and web scraping setup. :keywords: AI, on-premises, MCP, Model Context Protocol, web search, web scraping, tool calling, Streamable HTTP :pluginname: TinyMCE AI -:page-role: -toc The AI service extends model capabilities through two integration points: the https://modelcontextprotocol.io/[Model Context Protocol] (MCP) for tool calling, and pluggable web endpoints for page fetching and search. Both features operate within AI conversations only. Web search and scraping allow the AI to reference live internet content during conversations, and for most deployments, enabling at least web search improves response quality. From 2fcd453e6fc0956ea2bc8dc8898ea93f1018ff27 Mon Sep 17 00:00:00 2001 From: Karl Kemister-Sheppard Date: Fri, 7 Aug 2026 13:41:55 +1000 Subject: [PATCH 4/4] TINYDOC-3545: replace init content. --- .../live-demos/tinymceai-mcp/index.html | 104 ++++++++---------- 1 file changed, 44 insertions(+), 60 deletions(-) diff --git a/modules/ROOT/examples/live-demos/tinymceai-mcp/index.html b/modules/ROOT/examples/live-demos/tinymceai-mcp/index.html index 9da97d2145..1404834227 100644 --- a/modules/ROOT/examples/live-demos/tinymceai-mcp/index.html +++ b/modules/ROOT/examples/live-demos/tinymceai-mcp/index.html @@ -1,62 +1,46 @@