feat(server): add document write tools for mcp (#14245)

## Summary

This PR adds write capabilities to AFFiNE's MCP (Model Context Protocol)
integration, enabling external tools (Claude, GPT, etc.) to create and
modify documents programmatically.

**New MCP Tools:**
- `create_document` - Create new documents from markdown content
- `update_document` - Update document content using structural diffing
for minimal changes (preserves document history and enables real-time
collaboration)

**Implementation:**
- `markdown_to_ydoc.rs` - Converts markdown to AFFiNE-compatible y-octo
binary format
- `markdown_utils.rs` - Shared markdown parsing utilities (used by both
ydoc-to-md and md-to-ydoc)
- `update_ydoc.rs` - Structural diffing implementation for updating
existing documents
- `DocWriter` service - TypeScript service for document operations
- Exposes `markdownToDocBinary` and `updateDocBinary` via napi bindings

**Supported Markdown Elements:**
- Headings (H1-H6)
- Paragraphs
- Bullet lists and numbered lists
- Code blocks (with language detection)
- Blockquotes
- Horizontal dividers
- Todo items (checkboxes)

**y-octo Changes:**
This PR reverts the y-octo sync (ca2462f, a5b60cf) which introduced a
concurrency bug causing hangs when creating documents with many nested
block structures. It also ports the improved `get_node_index` binary
search fix from upstream that prevents divide-by-zero panics when
decoding documents.

## Test Results 

### Unit Tests (47/47 passing)

| Test Suite | Tests | Status |
|------------|-------|--------|
| markdown_to_ydoc | 16/16 |  Pass |
| markdown_utils | 11/11 |  Pass |
| update_ydoc | 13/13 |  Pass |
| delta_markdown | 2/2 |  Pass |
| affine (doc parser) | 5/5 |  Pass |

### End-to-End MCP Testing 

Tested against local AFFiNE server with real MCP client requests:

| Tool | Result | Notes |
|------|--------|-------|
| `tools/list` |  Pass | Returns all 5 tools with correct schemas |
| `create_document` |  Pass | Successfully created test documents |
| `update_document` |  Pass | Successfully updated documents with
structural diffing |
| `read_document` |  Pass | Existing tool, works correctly |
| `keyword_search` |  Pass | Existing tool, works correctly |

**E2E Test Details:**
- Started local AFFiNE server with PostgreSQL, Redis, and Manticore
- Created test user and workspace via seed/GraphQL
- Verified MCP endpoint at `/api/workspaces/:workspaceId/mcp`
- Tested JSON-RPC calls with proper SSE streaming
- Confirmed documents are stored and indexed correctly (verified via
server logs)

## Test Plan
- [x] All Rust unit tests pass (47 tests)
- [x] Native bindings build successfully (release mode)
- [x] Document creation via MCP works end-to-end
- [x] Document update via MCP works end-to-end
- [x] CodeRabbit feedback addressed
- [ ] Integration testing with Claude/GPT MCP clients

Closes #14161

---

**Requested by:** @realies  
**Key guidance from:** @darkskygit (use y-octo instead of yjs for memory
efficiency)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Create documents from Markdown: generate new documents directly from
Markdown content with automatic title extraction
* Update documents with Markdown: modify existing documents using
Markdown as the source with automatic diff calculation for efficient
updates
* Copilot integration: new tools for document creation and updates
through Copilot's interface

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
realies
2026-01-16 14:57:24 +02:00
committed by GitHub
parent 2c5559ed0b
commit 0da91e406e
14 changed files with 2585 additions and 4 deletions
@@ -584,6 +584,113 @@ pub fn get_doc_ids_from_binary(doc_bin: Vec<u8>, include_trash: bool) -> Result<
Ok(doc_ids)
}
/// Adds a document ID to the root doc's meta.pages array.
/// Returns a binary update that can be applied to the root doc.
///
/// # Arguments
/// * `root_doc_bin` - The current root doc binary
/// * `doc_id` - The document ID to add
/// * `title` - Optional title for the document
///
/// # Returns
/// A Vec<u8> containing the y-octo update binary to add the doc
pub fn add_doc_to_root_doc(root_doc_bin: Vec<u8>, doc_id: &str, title: Option<&str>) -> Result<Vec<u8>, ParseError> {
// Handle empty or minimal root doc - create a new one
let doc = if root_doc_bin.is_empty() || root_doc_bin == [0, 0] {
DocOptions::new().build()
} else {
let mut doc = DocOptions::new().build();
doc
.apply_update_from_binary_v1(&root_doc_bin)
.map_err(|_| ParseError::InvalidBinary)?;
doc
};
// Capture state before modifications to encode only the delta
let state_before = doc.get_state_vector();
// Get or create the meta map
let mut meta = doc.get_or_create_map("meta")?;
// Get existing pages array or create new one
let pages_exists = meta.get("pages").and_then(|v| v.to_array()).is_some();
if pages_exists {
// Get the existing array and add to it
let mut pages = meta.get("pages").and_then(|v| v.to_array()).unwrap();
// Check if doc already exists
let doc_exists = pages.iter().any(|page_val| {
page_val
.to_map()
.and_then(|page| get_string(&page, "id"))
.map(|id| id == doc_id)
.unwrap_or(false)
});
if !doc_exists {
// Create a new page entry
let page_map = doc.create_map().map_err(|e| ParseError::ParserError(e.to_string()))?;
// Insert into pages array first, then populate
let idx = pages.len();
pages
.insert(idx, page_map)
.map_err(|e| ParseError::ParserError(e.to_string()))?;
// Now get the inserted map and populate it
if let Some(mut inserted_page) = pages.get(idx).and_then(|v| v.to_map()) {
inserted_page
.insert("id".to_string(), Any::String(doc_id.to_string()))
.map_err(|e| ParseError::ParserError(e.to_string()))?;
if let Some(t) = title {
inserted_page
.insert("title".to_string(), Any::String(t.to_string()))
.map_err(|e| ParseError::ParserError(e.to_string()))?;
}
// Set createDate to current timestamp
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
inserted_page
.insert("createDate".to_string(), Any::BigInt64(timestamp))
.map_err(|e| ParseError::ParserError(e.to_string()))?;
}
}
} else {
// Create new pages array with this doc
let page_entry = vec![Any::Object(
[
("id".to_string(), Any::String(doc_id.to_string())),
("title".to_string(), Any::String(title.unwrap_or("").to_string())),
(
"createDate".to_string(),
Any::BigInt64(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0),
),
),
]
.into_iter()
.collect(),
)];
meta
.insert("pages".to_string(), Any::Array(page_entry))
.map_err(|e| ParseError::ParserError(e.to_string()))?;
}
// Encode only the changes (delta) since state_before
doc
.encode_state_as_update_v1(&state_before)
.map_err(|e| ParseError::ParserError(e.to_string()))
}
fn paragraph_prefix(type_: &str) -> &'static str {
match type_ {
"h1" => "# ",