Let AI safely control your TipTap editor via the Model Context Protocol (MCP) and OpenAI Function Calling.
tiptap-apcore wraps every TipTap editor command as a schema-driven APCore module — complete with JSON Schema validation, safety annotations, and fine-grained access control. Any MCP-compatible AI agent can then discover and invoke these modules to read, format, insert, or restructure rich-text content.
- 79 built-in commands across 7 categories (query, format, content, destructive, selection, history, unknown)
- Automatic extension discovery — scans TipTap extensions at runtime, no manual wiring
- MCP Server in one line —
serve(executor)exposes all commands via stdio / HTTP / SSE - OpenAI Function Calling —
toOpenaiTools(executor)exports tool definitions for GPT - Role-based ACL —
readonly,editor,adminroles with tag-level and module-level overrides - Safety annotations — every command tagged
readonly,destructive,idempotent,requiresApproval,openWorld,streaming - Strict JSON Schemas —
inputSchema+outputSchemawithadditionalProperties: falsefor all known commands - Dynamic re-discovery — call
apcore.refresh()orregistry.discover()to pick up extensions added at runtime - Dynamic ACL — call
apcore.setAcl()to switch roles without recreating the instance - Framework agnostic — works with React, Vue, Angular, or Vanilla JS
npm install tiptap-apcore apcore-js apcore-mcp @tiptap/coreapcore-js, apcore-mcp, and @tiptap/core are peer dependencies.
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { TiptapAPCore } from "tiptap-apcore";
// 1. Create a TipTap editor
const editor = new Editor({
extensions: [StarterKit],
content: "<p>Hello world</p>",
});
// 2. Create the APCore instance
const apcore = new TiptapAPCore(editor, {
acl: { role: "editor" }, // no destructive ops
});
// 3. Call commands directly
await apcore.call("tiptap.format.toggleBold", {});
const { html } = await apcore.call("tiptap.query.getHTML", {});
// 4. Switch roles at runtime (e.g. when user toggles admin mode)
apcore.setAcl({ role: "admin" });
// 5. Launch an MCP Server (Node.js only — import from tiptap-apcore/server)
import { serve } from "tiptap-apcore/server";
await serve(apcore.executor);
// 6. Or export OpenAI tool definitions
import { toOpenaiTools } from "tiptap-apcore/server";
const tools = toOpenaiTools(apcore.executor);import { withApcore } from "tiptap-apcore";
const { registry, executor } = withApcore(editor, {
acl: { role: "editor" },
});
await executor.call("tiptap.format.toggleBold", {});withApcore returns a { registry, executor } pair. Use it when you don't need dynamic ACL updates or the convenience methods on TiptapAPCore.
All commands follow the module ID pattern {prefix}.{category}.{commandName}.
| Command | Input | Output |
|---|---|---|
getHTML |
— | { html: string } |
getJSON |
— | { json: object } |
getText |
{ blockSeparator?: string } |
{ text: string } |
isActive |
{ name: string, attrs?: object } |
{ active: boolean } |
getAttributes |
{ typeOrName: string } |
{ attributes: object } |
isEmpty |
— | { value: boolean } |
isEditable |
— | { value: boolean } |
isFocused |
— | { value: boolean } |
getCharacterCount |
— | { count: number } |
getWordCount |
— | { count: number } |
toggleBold, toggleItalic, toggleStrike, toggleCode, toggleUnderline, toggleSubscript, toggleSuperscript, toggleHighlight, toggleHeading, toggleBulletList, toggleOrderedList, toggleTaskList, toggleCodeBlock, toggleBlockquote, setTextAlign, setMark, unsetMark, unsetAllMarks, clearNodes, updateAttributes, setLink, unsetLink, setHardBreak, setHorizontalRule, setBold, setItalic, setStrike, setCode, unsetBold, unsetItalic, unsetStrike, unsetCode, setBlockquote, unsetBlockquote, setHeading, setParagraph
insertContent, insertContentAt, setNode, splitBlock, liftListItem, sinkListItem, wrapIn, joinBackward, joinForward, lift, splitListItem, wrapInList, toggleList, exitCode, deleteNode
clearContent, setContent, deleteSelection, deleteRange, deleteCurrentNode, cut
setTextSelection, setNodeSelection, selectAll, selectParentNode, selectTextblockStart, selectTextblockEnd, selectText, focus, blur, scrollIntoView
undo, redo
Commands discovered from extensions but not in the built-in catalog. Excluded by default (includeUnsafe: false). Set includeUnsafe: true to include them with permissive schemas.
// Read-only: only query commands
new TiptapAPCore(editor, { acl: { role: "readonly" } });
// Editor: query + format + content + history + selection
new TiptapAPCore(editor, { acl: { role: "editor" } });
// Admin: everything including destructive
new TiptapAPCore(editor, { acl: { role: "admin" } });
// Custom: readonly base + allow format tag
new TiptapAPCore(editor, { acl: { role: "readonly", allowTags: ["format"] } });
// Custom: admin but deny destructive tag
new TiptapAPCore(editor, { acl: { role: "admin", denyTags: ["destructive"] } });
// Module-level: deny specific commands
new TiptapAPCore(editor, {
acl: { role: "admin", denyModules: ["tiptap.destructive.clearContent"] },
});
// Dynamic: switch roles at runtime
apcore.setAcl({ role: "admin" });Precedence: denyModules > allowModules > denyTags > allowTags > role
Note:
allowModulesis additive — it grants access to listed modules but does not deny unlisted ones. Combine with a role to restrict the baseline.
Server functions must be imported from the tiptap-apcore/server subpath (Node.js only).
import { TiptapAPCore } from "tiptap-apcore";
import { serve } from "tiptap-apcore/server";
const apcore = new TiptapAPCore(editor);
// stdio (default)
await serve(apcore.executor);
// HTTP streaming
await serve(apcore.executor, {
transport: "streamable-http",
host: "127.0.0.1",
port: 8000,
});
// Server-Sent Events
await serve(apcore.executor, { transport: "sse", port: 3000 });asyncServe returns a Node.js HTTP request handler that you can mount in any existing server — no separate process needed.
import express from "express";
import { TiptapAPCore } from "tiptap-apcore";
import { asyncServe } from "tiptap-apcore/server";
const app = express();
const apcore = new TiptapAPCore(editor, { acl: { role: "admin" } });
// Create the MCP handler with the built-in Tool Explorer UI
const { handler, close } = await asyncServe(apcore.executor, {
endpoint: "/mcp", // MCP protocol endpoint
explorer: true, // Enable /explorer UI for interactive testing
explorerPrefix: "/explorer",
allowExecute: true, // Allow tool execution from the explorer
name: "my-editor-mcp",
});
// Mount alongside your existing routes
app.all("/mcp", (req, res) => handler(req, res));
app.all("/explorer", (req, res) => handler(req, res));
app.all("/explorer/*", (req, res) => handler(req, res));
app.listen(8000);
// On shutdown:
await close();MCP clients (Claude Desktop, Cursor, etc.) can then connect to your server:
{
"mcpServers": {
"my-editor": {
"url": "http://localhost:8000/mcp"
}
}
}import { TiptapAPCore } from "tiptap-apcore";
import { toOpenaiTools } from "tiptap-apcore/server";
const apcore = new TiptapAPCore(editor);
const tools = toOpenaiTools(apcore.executor);
// Use with OpenAI API
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [...],
tools,
});APCore's JSON schemas work directly with AI SDK's jsonSchema() — no Zod conversion needed. Combined with generateText({ maxSteps }), the tool-use loop is fully automatic.
import { generateText, tool, jsonSchema } from "ai";
import { openai } from "@ai-sdk/openai";
import { TiptapAPCore } from "tiptap-apcore";
const apcore = new TiptapAPCore(editor, { acl: { role: "editor" } });
// Convert APCore modules to AI SDK tools
const tools: Record<string, CoreTool> = {};
for (const id of apcore.list()) {
const def = apcore.getDefinition(id)!;
tools[id.replaceAll(".", "--")] = tool({
description: def.description,
parameters: jsonSchema(def.inputSchema),
execute: (args) => apcore.call(id, args),
});
}
const { text, steps } = await generateText({
model: openai("gpt-4o"),
system: "You are an editor assistant...",
messages,
tools,
maxSteps: 10,
});The primary entry point. Encapsulates registry, executor, ACL, and extension discovery.
const apcore = new TiptapAPCore(editor, options?);| Option | Type | Default | Description |
|---|---|---|---|
prefix |
string |
"tiptap" |
Module ID prefix (lowercase alphanumeric) |
acl |
AclConfig |
undefined |
Access control configuration (permissive if omitted) |
includeUnsafe |
boolean |
false |
Include commands not in the built-in catalog |
logger |
Logger |
undefined |
Logger for diagnostic output |
sanitizeHtml |
(html: string) => string |
undefined |
HTML sanitizer for insertContent/setContent |
| Method / Property | Description |
|---|---|
registry |
The APCore Registry (read-only) |
executor |
The APCore Executor (read-only) |
call(moduleId, inputs) |
Execute a command (async) |
list(options?) |
List module IDs, optionally filtered by tags and/or prefix |
getDefinition(moduleId) |
Get full ModuleDescriptor or null |
setAcl(acl) |
Update ACL configuration at runtime (validates role) |
refresh() |
Re-scan extensions and update registry; returns module count |
Factory function that creates a TiptapAPCore instance and returns { registry, executor }. Accepts the same options as TiptapAPCore.
| Method | Description |
|---|---|
list(options?) |
List module IDs, optionally filtered by tags (OR) and/or prefix |
getDefinition(moduleId) |
Get full ModuleDescriptor or null |
has(moduleId) |
Check if a module exists |
iter() |
Iterate [moduleId, descriptor] pairs |
count |
Number of registered modules |
moduleIds |
Array of all module IDs |
on(event, callback) |
Listen for "register" / "unregister" events |
discover() |
Re-scan extensions and update registry |
| Method | Description |
|---|---|
call(moduleId, inputs) |
Execute a module (async) |
callAsync(moduleId, inputs) |
Alias for call() |
registry |
Access the underlying registry |
All errors are instances of TiptapModuleError (extends Error).
import { TiptapModuleError, ErrorCodes } from "tiptap-apcore";
try {
await apcore.call("tiptap.format.toggleBold", {});
} catch (err) {
if (err instanceof TiptapModuleError) {
console.log(err.code, err.message, err.details);
}
}| Code | Description |
|---|---|
MODULE_NOT_FOUND |
Module ID not registered |
COMMAND_NOT_FOUND |
Command not available on editor |
ACL_DENIED |
Access denied by ACL policy |
EDITOR_NOT_READY |
Editor is destroyed |
COMMAND_FAILED |
TipTap command returned false |
SCHEMA_VALIDATION_ERROR |
Invalid options (bad prefix, bad role) |
INTERNAL_ERROR |
Unexpected error |
| Function | Description |
|---|---|
serve(executor, options?) |
Launch an MCP server (stdio / streamable-http / sse) |
asyncServe(executor, options?) |
Build an embeddable HTTP handler — returns { handler, close } |
toOpenaiTools(executor, options?) |
Export OpenAI Function Calling tool definitions |
resolveRegistry(executor) |
Access the registry from an executor |
resolveExecutor(registry) |
Create an executor from a registry |
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
"apcore-mcp" |
MCP server name |
endpoint |
string |
"/mcp" |
MCP protocol endpoint path |
explorer |
boolean |
false |
Enable the browser-based Tool Explorer UI |
explorerPrefix |
string |
"/explorer" |
URL prefix for the explorer |
allowExecute |
boolean |
false |
Allow tool execution from the explorer UI |
validateInputs |
boolean |
false |
Validate inputs against JSON schemas |
tags |
string[] |
null |
Filter modules by tags |
prefix |
string |
null |
Filter modules by prefix |
asyncServe returns { handler, close } where handler is (IncomingMessage, ServerResponse) => Promise<void>.
┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐
│ TipTap Editor │────▶│ tiptap-apcore │────▶│ apcore-mcp │
│ (@tiptap/core) │ │ (this package) │ │ (protocol) │
└──────────────────┘ └──────────────────┘ └──────────────┘
Registry + Executor MCP / OpenAI
tiptap-apcore provides:
- Extension discovery (
ExtensionScanner) - Module building (
ModuleBuilder+AnnotationCatalog+SchemaCatalog) - Command execution (
TiptapExecutor) - Access control (
AclGuard)
apcore-mcp provides:
serve(executor)— Launch an MCP server (stdio / HTTP / SSE)toOpenaiTools(executor)— Export OpenAI Function Calling tool definitions- Types and constants for the APCore protocol
| Category | Count | Commands |
|---|---|---|
| Query | 10 | getHTML, getJSON, getText, isActive, getAttributes, isEmpty, isEditable, isFocused, getCharacterCount, getWordCount |
| Format | 36 | Toggle: toggleBold, toggleItalic, toggleStrike, toggleCode, toggleUnderline, toggleSubscript, toggleSuperscript, toggleHighlight, toggleHeading, toggleBulletList, toggleOrderedList, toggleTaskList, toggleCodeBlock, toggleBlockquote. Set/Unset: setBold, setItalic, setStrike, setCode, unsetBold, unsetItalic, unsetStrike, unsetCode, setBlockquote, unsetBlockquote, setHeading, setParagraph. Other: setTextAlign, setMark, unsetMark, unsetAllMarks, clearNodes, updateAttributes, setLink, unsetLink, setHardBreak, setHorizontalRule |
| Content | 15 | insertContent, insertContentAt, setNode, splitBlock, liftListItem, sinkListItem, wrapIn, joinBackward, joinForward, lift, splitListItem, wrapInList, toggleList, exitCode, deleteNode |
| Destructive | 6 | clearContent, setContent, deleteSelection, deleteRange, deleteCurrentNode, cut |
| Selection | 10 | setTextSelection, setNodeSelection, selectAll, selectParentNode, selectTextblockStart, selectTextblockEnd, selectText, focus, blur, scrollIntoView |
| History | 2 | undo, redo |
The selectText command enables semantic text selection — the AI can select text by content rather than by position, which is more natural for LLM-driven editing.
| Feature | Reason |
|---|---|
Clipboard operations (copy, paste) |
Requires browser Clipboard API — not available in headless / server-side |
| Drag and drop | Requires browser DOM events |
| IME / composition events | Requires browser input events |
| Real-time collaboration (Yjs/Hocuspocus) | Collaboration is handled at the transport layer, not the command layer |
| Streaming content generation | Content generation is delegated to the LLM; the executor applies discrete commands |
| Comment threads | Not part of core TipTap — requires @tiptap-pro extensions |
TipTap's official AI solution is the AI Toolkit (@tiptap-pro/ai-toolkit), a paid extension for client-side AI-powered editing. The two projects serve different use cases and are complementary.
| TipTap AI Toolkit | tiptap-apcore | |
|---|---|---|
| Type | Client-side TipTap extension | Server-side / headless adapter |
| License | Proprietary (TipTap Pro subscription) | Apache-2.0 (open source) |
| Runtime | Browser only | Browser + Node.js + headless |
| Protocol | Provider-specific adapters | MCP standard + OpenAI Function Calling |
| Tools exposed | 5 coarse tools | 79+ fine-grained commands |
| Access control | None built-in | 3 roles + tag/module allow/deny lists |
| Safety annotations | None | readonly, destructive, idempotent, requiresApproval per command |
| Streaming output | streamText(), streamHtml() |
Not yet supported |
| Headless mode | Not supported | Full support |
Use TipTap AI Toolkit when you need real-time streaming of AI-generated content with a built-in accept/reject review UI.
Use tiptap-apcore when you want any MCP-compatible agent to control the editor with fine-grained access control, strict schema validation, and headless/server-side support.
Use both when you want streaming AI content generation AND structured command control in the same application.
The demo/ directory contains a full-stack example with two modes:
cd demo/server && npm install && npm run dev # Terminal 1
cd demo/frontend && npm install && npm run dev # Terminal 2
# Open http://localhost:5173Set LLM_MODEL (e.g. openai:gpt-4o, anthropic:claude-sonnet-4-5) in demo/.env. See demo/README.md for details.
A React + Vite frontend with a TipTap editor and an Express backend that uses the Vercel AI SDK to let any LLM edit the document via APCore tools. Includes role-based ACL switching, demo scenarios, and a tool call log.
A persistent headless TipTap editor exposed as an MCP streamable-http endpoint via asyncServe(). The tab shows:
- Status — live server status, tool count, and endpoint URL
- Tool Explorer — embedded
/explorerUI for browsing and executing all 79+ tools interactively - Connect — copy-paste config snippets for Claude Desktop, Cursor, and generic MCP clients
The MCP endpoint is available at http://localhost:8000/mcp — connect any MCP client to control the editor remotely.
- Getting Started Guide — React integration, ACL roles, MCP and AI SDK setup.
- Technical Design — Architecture, security model, and design decisions.
npm install # Install dependencies
npm test # Run tests
npm run typecheck # Type check
npm run build # BuildApache-2.0