rmd-editor
RMD Editor MCP Server
A Model Context Protocol (MCP) server for editing R Markdown (.rmd) documents with chunk- and prose-level primitives and R kernel execution. Provides 24 native tools for working with R Markdown the way data scientists actually use it: code chunks as first-class citizens, YAML frontmatter as structured data, and a real R kernel for running chunks and capturing results.
Why a chunk-native server?
R Markdown files are .rmd text documents with YAML frontmatter, interleaved markdown prose, and executable ```{r} code chunks — not a flat source file and not a JSON notebook. A chunk-specific server can model this correctly:
Chunks are the unit of analysis. Add, delete, reorder, split, merge — all operations that a line-based editor would corrupt.
Chunk options are structured. Labels (
setup,analysis),echo=FALSE,fig.width=7,message=FALSE— these live in the chunk header or in#|lines at the top of the chunk, and need first-class read/write tools that read them as knitr does.YAML frontmatter is structured too.
title,author,output,datebelong in the YAML header, not scattered in prose. Dedicated tools keep edits clean.Kernel execution is part of the workflow. Running a chunk and inspecting output is how analysts iterate. Outputs are returned inline to the caller and are never written to the
.rmdfile — R Markdown regenerates outputs on render (rmarkdown::render()), so persisting them would drift from the canonical format.
This server complements notebook-editor (for .ipynb Jupyter notebooks): different file format, same mental model. If you already use notebook-editor, the API here is deliberately parallel.
Related MCP server: notebook-tools-mcp
Tools Exposed
All tools require file_path to be an absolute path. Every tool except create_rmd additionally requires the .rmd file to already exist. A cell index is zero-based, as list_cells shows it, and -1 means the last cell; indices shift after adds, deletes, splits and merges, so run list_cells again rather than guessing. The chunk tools (get_chunk_options, set_chunk_option, remove_chunk_option, execute_cell) reject a markdown cell's index.
Document creation
Tool | Parameters | Description |
|
| Create a new empty |
Cell structure & navigation
Tool | Parameters | Description |
|
| Outline of all cells with index, type (code/markdown), language/label/option-count for chunks, line count, and a one-line preview. Chunks are the ones knitr finds (see Format notes). The label and the option count are knitr's, from the chunk header and its `# |
|
| Return the source text of a specific cell. A chunk's text leaves off the line break before its closing fence, and an indented or blockquoted chunk's leaves off the indentation or |
|
| Insert a new cell at the given index. Pass |
|
| Remove a cell by index. |
|
| Reorder: move a cell from one position to another. |
|
| Split one cell into two at a line boundary: the first |
|
| Merge consecutive cells of the same type into one; |
Cell content editing
Tool | Parameters | Description |
|
| Replace a cell's entire body. A chunk keeps its header line (language, and the label and options written there); its `# |
|
| Add lines to the top of a cell, one line break above the cell's text (unless the new text ends with one); in a chunk that starts with `# |
|
| Add lines to the bottom of a cell, one line break below the cell's text (in a chunk, unless the new text starts with one). The cell's own blank lines stay. In a chunk, an option line after other lines is refused (option lines can go only into an empty chunk or one holding option lines alone), and so is text that would change which lines knitr reads as option lines; see Chunk text. |
Chunk options (labels and key=value pairs on code chunks)
Tool | Parameters | Description |
|
| Return the chunk's language, label, and every option knitr reads, from the header and the `# |
|
| Set or overwrite one option. Use |
|
| Remove one option, wherever it is written: every copy in the header and its `# |
YAML frontmatter
Tool | Parameters | Description |
|
| Return the YAML frontmatter as a formatted YAML string. Tags such as R Markdown's |
|
| Set or overwrite one top-level YAML key. Value is parsed as YAML where possible (so |
|
| Remove one top-level YAML key along with its indented lines. The rest of the header is left as it is. A key the header doesn't have is an error. |
Discovery (read-only)
Tool | Parameters | Description |
|
| Case-sensitive literal substring search (not a regex) across all cell sources. Returns |
Kernel execution
Tool | Parameters | Description |
|
| Execute one code chunk via a Jupyter kernel. Captured outputs are returned inline — never written to the |
|
| Execute all code chunks in order. Returns a cell-by-cell transcript. With |
|
| Report whether this document's kernel is |
|
| Restart the kernel. All in-memory state is lost. |
|
| Send a signal to stop a long-running chunk. Returns an error saying the kernel is not running if its process has died. |
|
| Shut down the kernel and release resources. |
Kernel lifecycle notes
The kernel is started lazily on the first
execute_cell/execute_all_cellscall.One kernel per
.rmdfile path, cached in memory for the server's lifetime. The kernel tools look it up by the exact path stringexecute_cellwas given;restart_kernelandinterrupt_kernelreturn an error when no kernel runs for it.Runs in the document's folder. As in Jupyter, the kernel's working directory is the folder that contains the
.rmdfile, so relative paths in chunks (for exampleread.csv("data/input.csv")) resolve against that folder, not the server's install directory.restart_kernelkeeps the same folder.Kernel chosen by the first chunk run. The language of the first executed chunk picks the kernelspec:
rlaunches IRkernel (ir),pythonlaunchespython3, and other languages pass through lowercased as the kernelspec name.julialaunches the installed IJulia kernelspec:juliaorjulia-1if one exists, otherwise the highest-versionedjulia-X.Y(IJulia names its kernelspecs after the Julia version, for examplejulia-1.11). With no Julia kernel installed, the error namesjulia-1. Later chunks run in that same kernel whatever their language, so in a mixed-language document callshutdown_kernelbefore running chunks in another language (this discards the kernel's state). The matching Jupyter kernel must be installed on the host.Install IRkernel (R): in R, run
install.packages("IRkernel"); IRkernel::installspec().Outputs are never persisted. R Markdown regenerates outputs on render (
rmarkdown::render()), so the server returns outputs inline to the caller only.Kernels survive across tool calls: state accumulates across
execute_cellinvocations. Userestart_kernelto reset orshutdown_kernelto free resources.A timeout interrupts the chunk. When a chunk is still running after
timeoutseconds, the server interrupts it (asinterrupt_kerneldoes) and waits up to 5 seconds for the kernel to stop. The error then says the chunk was interrupted; the kernel keeps its state and is ready for the next call. If the chunk ignores the interrupt, the error says the kernel is still busy: callrestart_kernel.timeoutmust be positive. If the kernel hadn't started the chunk yet when the timeout passed, the chunk is interrupted as soon as it starts.Calls on one document take turns. Concurrent
execute_cell/execute_all_cellscalls on the same file never run two chunks at once, and each gets its own outputs; another call's chunk can run between two chunks of anexecute_all_cellsrun.interrupt_kernelacts at once.restart_kernelandshutdown_kerneldon't wait for a running chunk: they kill the kernel, and that execute call fails with "Kernel died during execution". Calls waiting behind it run afterwards on the restarted kernel, or fail with "Kernel was shut down" aftershutdown_kernel.
Format notes
.rmdhas no cell-boundary markers between adjacent prose blocks (unlike.ipynb). Two adjacentmarkdowncells in the model auto-merge on save + re-parse. This is expected; prefer inserting prose between code chunks or appending to an existing markdown cell.Every save reads back as intended. Before writing, each tool reads its new text back and compares the YAML header and the cells (type, text, and a chunk's language, label, options and fence) with what the edit meant, adjacent markdown cells counting as one. If they differ, the tool returns an error saying which cell would read back as what, and the file is left unchanged. See Prose for the edits this stops.
Polyglot chunks (
{python},{bash},{sql}) parse and round-trip correctly. For execution, the matching Jupyter kernel must be installed. No tool creates a non-R chunk or changes a chunk's language yet:add_cellwrites```{r},replace_cell_sourcekeeps the language, andset_chunk_option(key="language")only adds alanguage=option to the header.Chunks are found where knitr finds them (its R Markdown
chunk.beginandchunk.endpatterns and the fence matching of itsgroup_indices, checked in knitr 1.52.5's source). A chunk opens at a header line: three or more backticks after nothing but spaces, tabs or>, maybe spaces, then{engine}or{engine options}, the engine being letters, digits and underscores (```{r},```{r},> ```{r},````{r},``` {r}). It closes only at a line with the header's fence, its indentation or>marks and its backticks, then nothing but spaces: in a```{r}chunk```closes it, while````and```are code. The one exception is knitr's too: a chunk on the first line of a file without a YAML header closes at the first line of backticks alone, whatever its indentation or length. A header with the chunk's own fence right before its{(```{python}inside a```{r}chunk) starts a new chunk there, the one before it having no closing fence; a header with another fence (```{r},````{r},``` {r}in a```{r}chunk) is code. A chunk that nothing closes runs to the end of the file. The text of an indented or blockquoted chunk is its code as knitr runs it, without the indentation or>marks (so its#|lines are read too), and edits write them back, its header line and closing fence keeping them. Regular markdown code fences (```pythonwithout braces,```{.r}, and```{ r}or```{r-x}, which knitr doesn't read as headers) stay prose.This changed how some files split into cells. Before, only
```{lang}at the start of a line opened a chunk, only```closed it, other header lines in a chunk were code, and a header nothing closed was prose. Files that use other fences, hold a chunk header inside a chunk, leave a chunk open, or have header lines knitr doesn't read as headers (```{ r},```{r-x}, which opened chunks before and are prose now) read as knitr reads them, solist_cellscan show other cells for them than before; files where every chunk opens with```{engine ...}at the start of a line and closes with```read as they did. knitr refuses a few layouts outright (a chunk holding a closing fence of another fence that none of its own follows, or a chunk on the first line holding a header line); their lines read as code here, as in any other chunk.A chunk without a closing fence (ended by the next chunk's header, or by the end of the file) is written back as it was, and gets its closing fence when anything but a chunk header with its fence, as written in the file, comes after it: a new cell, prose moved there, or the text after the chunk that ended it, once that chunk is deleted.
A document whose last line has no trailing newline keeps it that way while its last cell's text is left alone: re-saving that text, or changing a chunk's label or options, keeps the end of the file as it was. A newline is added when a cell is added after the last one or moved past it, so chunk fences never end up on the same line as other text. Rewriting the last cell's text also ends the file with a newline: new text from
replace_cell_source,prepend_to_cellorappend_to_cell, or asplit_cellormerge_cellsthat takes in the last cell. For example,replace_cell_source(1, "z")on```{r}\nx\n```\nlastwrites```{r}\nx\n```\nz\n.Only a line break ends a line, as in knitr:
\n,\r\n, or a lone\r. A form feed or a Unicode line separator (U+2028) stays part of its line, so a chunk fence that follows one on the same line doesn't start a chunk. Every tool counts lines this way: the line count inlist_cells, theline_offsetofsplit_cell, the line numbersfind_in_rmdgives, and the line and columnget_frontmatter's YAML errors give.Line endings are kept. A CRLF document, or one that mixes line endings, parses like an LF one: the YAML header, chunk fences and chunk headers are found the same way, and
get_cell,get_frontmatterandfind_in_rmdreturn text with\nline breaks. On save, cells you don't edit and header lines outside the edited key keep their bytes, and so do the body and closing fence of a chunk whose label or options you change (only its header line, or the#|lines of the option, is rewritten). Any other edited cell is rewritten whole, and everything an edit writes (a cell, a chunk header line, a frontmatter key's lines, a new header) uses the document's line ending, the one most of its lines end with. The one exception: a lone\rthat an edit leaves just before a\nis rewritten in the document's line ending, so the two don't read back as one\r\nline break. Write the text you pass with\n;\r\nworks too, and both are converted. An untouched CRLF file saves back byte for byte.create_rmdalways writes LF.A byte order mark is kept. A file that starts with a UTF-8 byte order mark (as some Windows editors save it) reads as if it had none: the YAML header or a chunk fence on its first line is found, and no cell's text starts with the mark. Every save writes the mark back at the start of the file, so an untouched file still saves back byte for byte.
create_rmdnever writes one.Lone surrogates are refused. JSON lets a client send half of a surrogate pair (
\ud800), which isn't a character and has no UTF-8 form. Every tool that takes text (cell sources, chunk option keys and values, labels, frontmatter keys and values, search text,create_rmd'stitle,authorandoutput_format) and every tool that opens the file refuses one in its arguments or path before doing anything:Refused: the text holds a lone surrogate, U+D800, which is not a character and can't be written as UTF-8. <path> was not modified., with a surrogate in the path shown escaped (\ud800). One that the file's YAML gives (#| label: "a\ud800b", written with a real backslash) is shown in the reply or error as its escape, a backslash thenud800; before,list_cellsandget_chunk_optionsreturned a reply that couldn't be sent, and over stdio the server process died.
Chunk text
A chunk's text leaves off the line break before its closing fence.
get_cellreturnsx <- 1for a one-line chunk, andreplace_cell_source,add_cell,prepend_to_cellandappend_to_celltake text the same way. The closing fence always starts a new line, so text that ends with\nleaves a blank line before it:replace_cell_source(i, "x <- 1\n")writesx <- 1, then a blank line, then the fence. Pass"x <- 1"for no blank line.Blank lines only. A chunk that holds nothing but blank lines comes back as one
\nper blank line, so an empty chunk ("", the fence right after the header) and a chunk with one blank line ("\n") stay apart.Every line is kept. Saving a cell's own text back (the text
get_cellreturned) or adding empty text leaves the file byte for byte as it was, whatever its line endings.split_cellkeeps every line of the chunk it splits, blank lines included, andmerge_cellsevery line of the chunks it merges but the later chunks' labels, the options they set again, and the blank line after their option lines (see Chunk options). An option or label edit doesn't touch the body, apart from the#|lines of an option or label written there.list_cellscounts the lines of a chunk's text, so forx <- 1\nit counts one line: the blank line before the fence isn't counted, andsplit_cellcan't split it off.Adding lines keeps blank lines.
prepend_to_cellandappend_to_cellput the new text on lines of its own, one line break from the cell's text. The new text may supply that line break itself, by ending with one (prepend_to_cell) or starting with one (append_to_cell); none of the cell's own line breaks is taken for it, so its blank lines all stay.prepend_to_cell(i, "Top")on prose\nMore\nwritesTop\n\nMore\n, andappend_to_cell(i, "y")on a chunk whose text isx\n(x, then a blank line before the fence) writesx\n\ny. In prose, the line break that ends the cell's last line is the one before appended text. Text appended to a chunk keeps the rule above:"y\n"leaves a blank line before the fence.Option lines stay at the top. knitr reads a chunk's option lines (
#|forr,--|forsql,/*| ... */forc, each engine's comment; see Chunk options) only at the very top of its body, so a line above them would make them code, silently.prepend_to_cellputs the text below them, and below the blank line after them that knitr takes with them:prepend_to_cell(i, "library(x)")on#| echo: false\nxwrites#| echo: false\nlibrary(x)\nx. There, text that starts with an option line is refused (Line 1 of the new text, '#| warning: false', is a chunk option line, but the chunk starts with option lines already ...): set options withset_chunk_option, or rewrite the option lines withreplace_cell_source. In a chunk without option lines the text goes on top, so its own option lines become the chunk's.add_cell,replace_cell_source,prepend_to_cellandappend_to_cellrefuse an option line after other lines of the text (library(x)\n#| echo: false\nx), which knitr would read as code, giving its line number in the text (Line 2 of the new text, '#| echo: false', is a chunk option line after other lines, ...: move it up with the option lines at the top, or set the option with set_chunk_option); forreplace_cell_sourceandprepend_to_cell, a line the chunk already had there is fine.append_to_celladds option lines only to an empty chunk or one holding option lines alone, and checks only the lines of the new text, so text that starts with its own line break can go below a last line that starts as an option line does.prepend_to_cellandappend_to_cellalso refuse text that would change which lines knitr reads as option lines: prependingint y; */, which ends with*/, under the/*| echo: false */line of a c chunk, or appending a line ending in*/under an open/*|line.split_cellrefuses a split inside the option lines, which would give some of them to the new chunk, and one that would start the new chunk with a line that starts as an option line does, which is code where it is now. In a c, css or sas chunk whose every line ends with the comment's closing characters (*/, or;for sas), knitr reads only the first line as an option line, sosplit_cellalso refuses a split that would leave the first chunk its option lines alone, and the errors for text say so (... knitr reads only the first line of a chunk whose every line ends with '*/' as an option line ...).No fence lines inside. knitr ends a chunk at a closing fence, a line of three or more backticks with nothing after them but spaces or tabs, and nothing before them but spaces, tabs or
>(```,````,```,> ```), and at a chunk header (such backticks, then{engine}or{engine options}:```{r},```{python}), where it starts another chunk. Which of these lines ends a chunk depends on the chunk's fence and on where it is (see Format notes), soadd_cell,replace_cell_source,prepend_to_cellandappend_to_cellrefuse new chunk text that holds any of them, as inLine 2 of the new text, '```{r}', is a chunk header for knitr (...), which would end the chunk there and start another; the error gives its line number in the text you passed.merge_cellsputs the chunks' lines under the first chunk's fence, so it refuses a merge that would make one of them end the merged chunk (a```or```{r}line from a````{r}chunk, or any closing fence under a chunk on the first line of the file), naming its cell and line. Other lines that start with backticks are fine (```{ r},```r), and only the text you pass is checked, so a chunk that already holds such a line can still be appended to.
Prose
Markdown text can't add or remove chunks, so the cells you see stay the cells you get:
No chunk openings.
add_cell(markdown), andreplace_cell_source,prepend_to_cellandappend_to_cellon a markdown cell, refuse new text that holds a line that would open a chunk: a chunk header for knitr, which this server reads the same way (three or more backticks after nothing but spaces, tabs or>, then{engine}or{engine options}, as in```{r},```{r setup, echo=FALSE},```{r},> ```{r},````{r}or``` {r}). The error gives the line number in the text you passed. Add a chunk withadd_cell(cell_type="code")instead, and to show chunk syntax as text, put`r ''`before the backticks (`r ''````{r}), as the R Markdown Cookbook does. Plain code blocks (```r,````,```{.r}), lines knitr doesn't read as headers (```{ r},```{r-x}),~~~fences and inline`r x`are fine. Only the text you pass is checked.No new YAML header. In a document without a header, text that would put a
---line on the first line of the file, with another---line further down, would read back as the YAML header. Every tool refuses that, whether the text is new (add_cellat index 0, or a new first line for cell 0) or the cell becomes the first one (delete_cell,move_cell); the error gives the lines that would become the header. Start the first cell with other text, and useset_frontmatter_keyto add a header.A chunk left open stays a chunk. A chunk header that nothing closes opens a chunk that runs to the end of the file, as for knitr (before, such a line was read as prose). A cell added after it gets the chunk's closing fence first, so it doesn't become chunk code:
add_cell(-1, "More", "markdown")onIntro\n```{r}\nx\nwritesIntro\n```{r}\nx\n```\nMore\n.Chunks that change where they end. An edit that would make knitr end a chunk elsewhere is refused by the read-back check: for example deleting the prose before a chunk that holds an
```line, which would put the chunk on the first line of the file, where knitr closes it at that line. The other way round, a chunk on the first line that a closing fence of another fence closed (> ```after```{r}) is written anew with its own closing fence once a cell or a YAML header goes before it:add_cell(0, "Intro", "markdown")on```{r}\nx\n> ```\nMore\nwritesIntro\n```{r}\nx\n```\nMore\n.
Chunk headers
set_chunk_option and remove_chunk_option rewrite only the chunk's header line (```{r label, key=value}); the body and closing fence keep their bytes (an option or label written in a #| line is edited there instead; see Chunk options). The new header must read back with exactly the label and options asked for, and knitr, which reads keys and values as R code, must be able to read it. If not, the tool returns an error saying why (for text that would read back as other options, the header it would write and how that reads back), and the file is left unchanged:
Keys R can read. knitr's
csv_optionsreads the header as the arguments ofalist(...), where a key must be a syntactic R name: letters, digits, dots and underscores, starting with a letter or a dot not followed by a digit, and not a reserved word (if,TRUE,...). A dashed name of one of knitr's options, as Quarto writes them, goes in the header as the dotted name knitr reads it as (itsdot_names):set_chunk_option(key="fig-width", value="5")writes```{r fig.width=5}, and a header copy writtenfig-width=3becomesfig.width=5. In a chunk that starts with YAML#|lines, a new dashed name goes there as written instead (#| fig-width: 5, after the other keys), and so does any other key that isn't a syntactic name (#| code-fold: true,#| my key: 1, quoted when R's yaml package would read it as something else); without#|lines such a key is refused (Option key 'code-fold' isn't a syntactic R name ...). When the#|lines can't take a new line (one c comment over several lines, or sas lines that would all end with;), a dashed name of knitr's goes in the header as the dotted name after all. A header key written in backticks or quotes, which R reads too (`my key`=1,"fig-width"=3), is found by the name it stands for, and a dashed one of knitr's is written dotted when set. A header copy R can't read at all (code-fold=TRUE) counts as none: setting the key writes it as a new one and drops that copy, or is refused.One line. A key, value or label holding a line break (
\n,\r\nor\r) is refused. For a line break inside an R string, write the escape\n, as infig.cap="Line one\nLine two".Quote text. A comma outside a quoted string would split the header into other options, so
fig.cap=a, bis refused: pass the value"a, b". In a key or value, a#outside a quoted string starts an R comment that runs to the end of the header, and an=outside quotes and brackets is a second=that knitr can't read (fig.cap=a=1), so both are refused;x == 1,x <= 1andlist(a = 1)are fine. A key can't hold=at all, and a label holding one goes first in the header as an R string (```{r "x == 1"}), asquote_labelquotes no bare name with an=in it (see Chunk labels). Reading a header, a part iskey=valueat its first=outside quotes, backticks and brackets that isn't part of==,!=,<=or>=, as R reads it, so"a=b"andlist(a=1)are names without a value, and"fig.cap"="x"is the key"fig.cap".Close what you open. A key, value or bare-name label must close every bracket and quote it opens, and a key or value every backtick, or it would swallow the options after it:
"a,c(1, 2,`xandx}are refused. Inside a quoted string a backslash escapes the next character, as in R, so"say \"hi\", then go"is one value, and}or backticks inside a quoted string are fine.Give a value. An empty value is refused for any key but
label, where it clears the label: knitr reads a bare name in a chunk header as the chunk's label. Surrounding whitespace is dropped from keys, values and labels.Headers written by hand. If a header in the file already ends in an unmatched quote or bracket, a new option can't go after it: set that option to a balanced value first. A bare name stays where it is written when the header is rewritten:
```{r, foo}keeps its comma, and knitr readsfooas the label either way.
Chunk labels
knitr finds a chunk's label in three places, and the tools follow it:
Where it can be written. As a name without a value in the header (
```{r setup}, and also after other options or a comma:```{r echo=FALSE, setup}and```{r, setup}), as alabel=option (```{r label="setup"}), or as a#| label: setupline among the option lines a chunk's body starts with (knitr 1.35 and later, and Quarto; knitr takes#| id:when there is nolabel:). When more than one is written, knitr takes the#| label:line over the header (warning about it), andlabel=over the bare name: itscsv_optionsmakes the bare name the label only when nolabel=is given.Names without a value. knitr's
csv_optionsquotes the names written before the firstkey=valueas one string, so```{r ab, cd, echo=TRUE}and```{r ab, cd}have the one labelab, cd, commas and all;get_chunk_optionssays so. A first name of one character is quoted alone (by itsquote_label), so in```{r a, b}the names are two.quote_labelquotes no text with an=in it, even inside quotes or brackets, so a name holding one ends the names before it and is R code of its own:```{r "a=b"}and```{r echo=FALSE, "a=b"}are labelleda=b,```{r list(a = 1)}is labelledlist(a=1), and```{r foo, "a=b"}has two names, which knitr fails on. It quotes the text before the header's first=up to the last comma there, so a name with a comma before an=inside it (```{r cd(x, y=1)}) is cut in two, which R can't parse:get_chunk_optionssays knitr fails on the header and lists the name among the options. A name after other options is R code: knitr takes a string's text, and writes other code without its spaces (```{r echo=FALSE, f(x, y)}is labelledf(x,y)). A header with any other name without a value (```{r echo=TRUE, a, b},```{r a, echo=TRUE, b},```{r a, b}) is one knitr stops at ("All options must be of the form 'tag=value' except for the chunk label"):get_chunk_optionsshowslabel: (none: knitr fails on this chunk header, ...)naming them, lists them among the options, andset_chunk_option(key="label")andremove_chunk_option(key="label")are refused until you give them values (set_chunk_option(key="b", value="TRUE")) or remove them (remove_chunk_option(key="b")).Reading.
list_cellsandget_chunk_optionsshow the label knitr uses.get_chunk_optionssays where it is written when it isn't a bare name, and which other spellings knitr reads it over, as inlabel: setup (from label="setup"); it doesn't listlabel=among the options, andlist_cellsdoesn't count it. A line break in a label is shown as\n. Alabel:key the#|lines repeat is shown with the copies, as R's yaml package rejects a repeated key and knitr fails on the chunk; so is a repeatedlabel=option, which knitr takes the first copy of in some steps and the last in others. With a#| label:line too, knitr puts the line's label in the first copy, so it still takes the lastlabel=copy in some steps, which is noted.Setting.
set_chunk_option(key="label")rewrites the label where knitr reads it: the#| label:(orid:) key, as one line holding its value as YAML, quoted when needed (also for text R's yaml package reads as something else, such asy,n,1,000,.or0:30; the body's other lines, the closing fence and their line endings stay as they are), thelabel=option (as an R string in the quotes it had, with\and quotes escaped), or else the bare name or names, in their place: first in the header, or after other options as an R name or, for other text, a quoted R string (```{r echo=FALSE, "my label"}). A chunk without a label gets one first in the header, as a quoted R string if it holds an=(```{r "x == 1"}), which R would otherwise read as code there. The spellings knitr reads it over are removed, so one is left:```{r setup, label="other"}becomes```{r label="L"}, and repeated#| label:keys orlabel=options leave one.Removing.
remove_chunk_option(key="label"), orset_chunk_option(key="label", value=""), removes every spelling, so no other one becomes the label:```{r ab, cd, echo=TRUE}becomes```{r echo=TRUE}.Option lines count only at the very top of the body, in YAML form (the first one is
#| key: value); see Chunk options for how they are found. They are read as one YAML document, as knitr reads them, so a value can go on over several lines, and alabel:with no value gives no label. A#| label:line after other code, or R-style#| label="x"lines, are left alone.
Chunk options as knitr reads them
knitr reads a chunk's options from its header and from the option lines at the top of its body, and merges them. get_chunk_options, list_cells, set_chunk_option and remove_chunk_option follow it:
Option lines. knitr's
divide_chunkfinds them by the engine's comment characters followed by|(xfun'scomment_charstable):#|forr,python,bashand any engine not in the table,--|forsql,mysql,psql,luaandhaskell,//|forRcpp,cc,js,go,java,rustand the other//languages,%|formatlabandtikz, and a comment closed on each line forcandcss(/*| echo: false */) andsas(*| echo: false;). Engine names match exactly, as in knitr ({SQL}uses#|), and when the first line doesn't start with the engine's characters,#|lines are read instead. Where each line closes its comment, the lines run while they end with the closing characters, and knitr takes as many characters off each line as those have, spaces after them or not, so/*| label: q */is labelledq */; when every line of the chunk ends with them, the option lines are the first alone. Such lines are edited line by line. Lines that form one comment over several lines (/*| a: 1, then/*| b: 2 */) are read, but edits to them are refused.Merging. Each option knitr reads is listed once, as the spelling it uses: a
#|line over the header (knitr warns about such a pair), a dashed name such as#| fig-width: 5over the dottedfig.width=7(knitr'sdot_names, for the options knitr knows, so#| code-fold: truestays as it is), and the last copy of a key the header repeats (```{r echo=TRUE, echo=FALSE}isecho=FALSE), which also wins over a#|line for that key, as knitr puts the#|value in the first copy. The spellings it reads that one over are noted, as in#| echo: false (knitr reads it over echo=TRUE). A key the#|lines repeat is noted too: R's yaml package rejects it, so knitr fails on the chunk.list_cellscounts each option once.Setting.
set_chunk_optionwrites the value where knitr reads it: in the#|line that holds the key (its other lines and the body stay as they are; a value over several lines becomes one line), else in the header copy knitr uses, the last if the key is repeated there. The other spellings are removed, so one is left:```{r echo=TRUE, fig.width=5, echo=FALSE}withecho=TRUEbecomes```{r fig.width=5, echo=TRUE}, and a header copy that a#|line overrides goes. A key written nowhere yet goes at the end of the header, a dashed name of one of knitr's options as its dotted name; in a chunk with YAML#|lines, a dashed name or another key that isn't a syntactic R name goes in a new#|line after the other keys (see Chunk headers). In a#|line the R value is written as YAML that knitr reads back as the same value:TRUE,FALSEandNULLastrue,falseandnull, a decimal number as written, a quoted R string as a YAML string (quoted when needed, as for labels), and any other R code tagged!expr(c(6, 4)becomes!expr c(6, 4)), which knitr evaluates.Removing.
remove_chunk_optionremoves every spelling: each header copy, the#|line, and the dashed or dotted name knitr reads as the same option.Keeping the lines YAML. knitr reads the option lines as YAML only when the first one holds a key. So when an edit removes the first key, the comment and blank
#|lines below it (#| # note) move to just after the next key; with no key left, they go too. An edit that would make the other lines read differently, such as removing an anchor (&w) that another key's alias (*w) needs, or make code below them option lines (a line ending in*/under the last/*| ... */line of a c chunk), is refused.Repeated header keys are kept. Rewriting a header for another option, or rewriting a chunk's text, keeps every copy of a repeated key as written; only an edit of that key leaves one.
Merging chunks.
merge_cellskeeps every chunk's options where knitr reads them. The first chunk's label stays and the others' labels (a bare name,label=,#| label:or#| id:) go; the others' header options join the first chunk's header. All option lines (#|for r,--|for sql,//|for js,/*| ... */for c) go in one block at the top, written with the first block's comment characters, and the blank line after a later chunk's option lines, which knitr takes with them, goes. Rewritten lines keep their values: where the usualtext */form would change one (the lines of a block scalar such asfig-cap: |), they are written as knitr reads them,/*| fig-cap: |*/and/*| a*/with no space before*/, or as#|lines that keep their trailing spaces.One value per option. An option several merged chunks set to one value is written once, as the first of them writes it. Values are compared as knitr reads them: header
echo=FALSEmatches#| echo: false,fig-widthmatchesfig.width, and a header string and a#|string with the same text, plain and quoted strings,FALSEandfalse(oryesandtrue), a value with a comment after it, flow and block lists, and!exprcode with other spaces around it (not inside:c(6,4)againstc(6, 4)is refused) are one value. Numbers, dates, and unquoted text that R's yaml reads as something else (y,n, number-like text) must be written alike:7against7.0, oryagainst"y", is refused. An option set to different values (a header option against a#|line too) is refused, naming the option and both cells: give them one value withset_chunk_option, or remove one, first.Merges that are refused. The file is left unchanged when a chunk's option lines aren't YAML
key: valuelines, when a chunk's header is one knitr fails on, when a later chunk's option lines can't lose its label or an option set again (an alias to an anchor that goes, or one/*| ... */comment over several lines; the error startsCan't merge cells i to j, names the cell and the keys, and suggests rewriting those lines withreplace_cell_sourcefirst), and when knitr would read other option lines at the top of the merged chunk, also when no later chunk has options: an open/*|comment in the first c chunk closed by a later chunk's code, c chunks holding only option lines, or a c, css or sas chunk whose every line ends with the closing characters merged with code that doesn't.
Frontmatter edits
set_frontmatter_key and remove_frontmatter_key edit the YAML header line by line. They change only the target key's lines (the key: line at the start of the line plus the indented lines below it) and leave every other byte of the header as it is: comments, blank lines, quoting, tags such as !r and !expr, and key order.
Values. The value is parsed as YAML. If it means the same when written as given, it's written that way: on the key's line for a one-line value, or indented below the key for a multi-line one. So
"!r Sys.Date()"stays an R expression,'"2026-04-20"'stays a quoted string, and comments in a multi-line value are kept. Any other value is written as YAML for the parsed value. Quote a value to keep it a string ('"42"', not42, which is an int).No lone surrogates. A value whose YAML reading holds a lone UTF-16 surrogate is refused, the file unchanged: an escape of a lone surrogate (
"\ud800"), or of either half of a surrogate pair (so the pair of escapes for U+1F600 is refused too), anywhere in the value, keys and tagged values included (The value for frontmatter key 'k' holds U+D800 as YAML reads it, a lone UTF-16 surrogate, which YAML can only write as an escape that the libyaml in R's yaml package rejects, ...). Write a character beyond U+FFFF as itself, or as an escape of eight hex digits ("\U0001F600").New keys go just before the closing
---. A document without a header gets a new one at the top. A new key longer than 1024 characters as written, its quotes and escapes included, is refused with an error that says so: YAML reads a key on akey: valueline only up to 1024 characters (the limit the YAML spec sets for an implicit key, which PyYAML and the libyaml in R's yaml package keep). The spaces and other whitespace around a key are dropped, but not a line break (\n,\r, or NEL, U+2028 and U+2029, which YAML reads as line breaks too): a new key holding one is refused (Frontmatter key 'short\n' contains a line break, ...), so"title\n"doesn't settitle, andremove_frontmatter_key("title\n")finds no such key. A key holding a lone surrogate gets the refusal every tool gives (see Format notes).Repeated keys. If a key appears more than once,
set_frontmatter_keyreplaces the first copy and removes the others, leaving one copy with the new value.remove_frontmatter_keyremoves every copy.Headers that aren't valid YAML (for example, a tab used for indentation) can still be edited: lines that start with
key:at column 0 count as keys.get_frontmatterreturns such a header as raw text, after a note saying what is wrong and where, as a line and column of the file (counted past a byte order mark, and with only a line break ending a line). That holds for syntax errors, for a control character such as a form feed, for an impossible date such as2026-13-45(the one PyYAML rejected, if there are several), for an explicit tag on a value it can't hold (!!bool maybegivescan't read 'maybe' as !!bool, likewise!!int ""or!!timestamp hello), for an int too long to print (more than 4300 digits, as hex digits can make it), for an escape past the last Unicode character ("\U00110000"), and for values nested more than 100 levels deep, deeper than this server reads. That depth counts the values aliases stand for, and each merge key (<<), since PyYAML builds and writes them recursively. Every other tool still works on such a file.Values that can't be built. A value passed to
set_frontmatter_key(or a key, orcreate_rmd'soutput_format) that would be such a value, such as!!bool maybe, is not YAML this server can read, so it is written as a quoted string, like any text that isn't valid YAML. A value sits one level down, under its key, so one nested 100 levels deep is written as a string too.What can't be edited key by key: a header that is a YAML list or a single value rather than
key: valuelines, a flow-style header such as{title: x, author: y}, and a header whose keys are indented rather than starting at column 0. For these, both tools return an error and the file is left unchanged.Safety check. If the header was valid YAML before an edit, it must still be valid afterwards, with the same other keys. Otherwise the tool returns an error and doesn't write the file. This can happen when the edit removes an anchor that another key refers to.
Which tool should I use?
Discovering what's in a document (do this first)
Don't know what's in the document? →
list_cellsNeed to see a chunk's language/label/options? →
get_chunk_optionsNeed to see the YAML header? →
get_frontmatterSearching for a name or string? →
find_in_rmdNeed to read a specific cell? →
get_cell
Creating documents
Need a new empty
.rmd? →create_rmd(do NOT hand-write.rmdwith a genericWritetool —create_rmdguarantees a valid YAML frontmatter)
Editing
Intent | Tool |
Add a new cell at a specific position |
|
Remove a cell |
|
Reorder cells |
|
Break a large cell into two |
|
Combine consecutive same-type cells |
|
Rewrite a cell's whole body |
|
Add lines to the top of a cell |
|
Add lines to the bottom of a cell |
|
Set a chunk label or option |
|
Remove a chunk label or option |
|
Set a frontmatter key |
|
Remove a frontmatter key |
|
Running chunks
Intent | Tool |
Run one specific chunk |
|
Re-run the whole document |
|
Stop a stuck chunk |
|
Reset kernel state |
|
Check if the kernel is alive |
|
Release kernel resources |
|
Avoid
replace_cell_sourceto add a few lines: useprepend_to_cellorappend_to_cell.add_cellto replace a cell: usereplace_cell_source.A key of your own naming for the label: set it with
set_chunk_option(key="label").
Prerequisites
This MCP server uses uv to manage its Python environment and dependencies automatically. Install uv if you don't have it already:
macOS / Linux:
curl -LsSf https://astral.sh/uv/install.sh | shWindows (PowerShell):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"Homebrew (macOS):
brew install uvpip (any platform):
pip install uvVerify the installation:
uv --versionFor more options (Docker, Cargo, WinGet, etc.), see the official uv installation docs.
uv 0.9.17 or newer is required. This server's pyproject.toml sets a 7-day dependency cooldown ([tool.uv] exclude-newer = "7 days"). Older uv versions can't parse that relative duration. They print a warning and ignore the setting. A plain uv sync or uv run, including the uv --directory … run launch commands below, then discards the committed uv.lock, re-resolves to the newest compatible versions (even ones published in the last 7 days), and rewrites the file. Upgrade with uv self update, brew upgrade uv, or pip install -U uv, depending on how you installed it. Upgrading doesn't undo an earlier rewrite: restore the original uv.lock with git checkout HEAD -- uv.lock in this folder, or copy it again from the archive you downloaded.
For kernel execution, install the matching Jupyter kernel:
R: in R, run
install.packages("IRkernel"); IRkernel::installspec(). Docs: https://irkernel.github.io/Python:
pip install ipykernel, thenpython -m ipykernel install --userwith the same Python. The second step registers the kernel in your user Jupyter folder, which the server always searches. Without it, the kernelspec stays under that Python's own prefix (for example a Homebrew, pyenv or conda Python), where the server may not find it.Julia: in Julia,
using Pkg; Pkg.add("IJulia"). IJulia names its kernelspec after the Julia version (for examplejulia-1.11); the server finds it under that name.
The server itself depends on mcp, pyyaml, and jupyter-client. uv handles all of these automatically on first run.
Installation
Note: Replace
/absolute/path/to/rmd-editorbelow with the absolute path to thermd-editorfolder on your machine (the folder that contains itspyproject.toml).
Method 1: CLI Configuration (Claude Code, Codex, Gemini)
If your agent supports adding servers via CLI, run the following:
--scope user installs the server globally so it's available in every project on your machine. Drop it if you only want the server active in the current project.
# Claude Code / Codex
[claude|codex] mcp add rmd-editor --scope user -- uv --directory /absolute/path/to/rmd-editor run rmd-editor-mcp
# Gemini CLI
gemini mcp add --transport stdio --scope user rmd-editor -- uv --directory /absolute/path/to/rmd-editor run rmd-editor-mcpMethod 2: JSON Configuration
For tools that use a mcp_config.json or settings.json file, add the following block.
Important: Use the absolute path to
uvfor"command", not just"uv". GUI-based MCP clients (Claude Desktop, Cursor) don't always inherit your shellPATH, so a bare"uv"will fail with a "command not found" error. Get your absolute path with:which uv # e.g. /Users/you/.local/bin/uv or /opt/homebrew/bin/uv
{
"mcpServers": {
"rmd-editor": {
"command": "/absolute/path/to/uv",
"args": [
"--directory",
"/absolute/path/to/rmd-editor",
"run",
"rmd-editor-mcp"
]
}
}
}Agent | Configuration File Path |
Claude Desktop |
|
Cursor |
|
Windsurf | Agent Panel → "..." → MCP Servers → View raw config |
Antigravity |
|
Gemini CLI |
|
Antigravity IDE
The Antigravity IDE uses its own MCP config file and a uvx-based command — not the uv --directory … run form shown above. Add the server to ~/.gemini/config/mcp_config.json:
Important: Use the absolute path to
uvxfor"command", not a bare"uvx"— Antigravity doesn't inherit your shellPATH. Find it withwhich uvx(e.g./opt/homebrew/bin/uvx).
Note:
uvxignores both this server'suv.lockand the[tool.uv]settings in itspyproject.toml. It re-resolves dependencies on each launch instead of using the locked versions, and the 7-day cooldown is passed explicitly as--exclude-newer "7 days"inargs, so you get the newest compatible releases that are at least 7 days old. This requires uv 0.9.17 or newer: older versions reject the value (invalid value '7 days' for '--exclude-newer <EXCLUDE_NEWER>') and the server fails to start.
{
"mcpServers": {
"rmd-editor": {
"command": "/absolute/path/to/uvx",
"args": [
"--exclude-newer",
"7 days",
"--from",
"/absolute/path/to/rmd-editor",
"rmd-editor-mcp"
]
}
}
}Using Standard Python (Fallback)
If you prefer not to use uv, run the following from the rmd-editor folder (the one that contains its pyproject.toml), then point to the .venv executable it creates. python3 must be Python 3.10 or newer.
python3 -m venv .venv && source .venv/bin/activate && pip install .This path doesn't use uv, so the [tool.uv] 7-day dependency cooldown doesn't apply. With pip 26.1 or newer, add --uploaded-prior-to P7D to the pip install command to apply the same cooldown.
{
"mcpServers": {
"rmd-editor": {
"command": "/absolute/path/to/rmd-editor/.venv/bin/python",
"args": ["-m", "rmd_editor.server"]
}
}
}Agent Configuration (Important)
Coding agents are heavily biased toward their default tools. You must explicitly instruct them to use rmd-editor when working with R Markdown documents. The agent prompt lives in RMD-EDITOR.md — a standalone file you wire into your agent's system instructions. The server also sends a condensed version of this guidance as MCP server instructions, which some clients pass to the model automatically. Wiring in the full prompt file is still recommended.
Claude Code / Claude Desktop (via @-include)
Claude Code supports @filename includes in CLAUDE.md. Copy the prompt file into your global config directory and add one include line:
cp /absolute/path/to/rmd-editor/RMD-EDITOR.md ~/.claude/
echo '@RMD-EDITOR.md' >> ~/.claude/CLAUDE.mdOr for a single project, place it next to the project's CLAUDE.md and add @RMD-EDITOR.md there.
Other agents (Cursor, Codex CLI, Windsurf, Antigravity, Aider, Gemini CLI, etc.)
Copy the contents of RMD-EDITOR.md into your agent's instruction file (AGENTS.md, .cursor/rules/*.mdc, .windsurfrules, .github/copilot-instructions.md, system prompt, etc.). Most non-Claude agents don't support @-include — paste the prompt body directly.
Agent | Instruction file |
Any agent that reads |
|
Cursor |
|
GitHub Copilot |
|
Windsurf |
|
Antigravity |
|
Aider / Gemini CLI / generic | Rules file or system prompt |
Logging & Debugging
The server logs all tool invocations and errors to stderr (safe for stdio transport — does not interfere with JSON-RPC). Logs include timestamps and severity levels.
To inspect logs when running under Claude Desktop, check:
macOS:
~/Library/Logs/Claude/mcp*.logWindows:
%APPDATA%\Claude\logs\mcp*.log
For interactive testing, use the MCP Inspector, which provides a UI for exercising each tool with custom parameters and inspecting responses.
Common issues
No such kernel named <name>— the Jupyter kernel for the chunk's language isn't installed (irfor R chunks,python3for Python chunks,julia-1for Julia chunks when no IJulia kernelspec is found). To see the kernels the server can find, runuv --directory /absolute/path/to/rmd-editor run jupyter kernelspec list. Install the missing one per the Prerequisites section, then run the chunk again;get_kernel_statestaysnot startedafter this error, so the server doesn't need a restart."Kernel failed to start" — the kernelspec exists but the kernel couldn't be launched (for example, its command points to an R installation that has moved), or it started but died or didn't become ready within 30 seconds. The message includes the underlying error. Check that the kernel's own runtime works (for example, that R starts and loads IRkernel), then retry.
restart_kernelreports the same kinds of problems as "Kernel failed to restart". After any of these errors nothing is left running (get_kernel_statereportsnot started), and the server log gets one warning line, not a traceback."Execution timed out after Ns; the cell was interrupted" — the chunk ran longer than
timeoutand was stopped; the kernel keeps its state. For long-running code, pass a largertimeout(in seconds)."... the kernel was interrupted but is still busy" — the chunk ignored the interrupt (for example, native code that doesn't check for interrupts). Call
restart_kernel; in-memory state is lost."Kernel is not running (its process has exited)" from
interrupt_kernel— the kernel crashed or was killed (get_kernel_statereportsdead). The nextexecute_cellstarts a fresh kernel."File is not a .rmd document" — every tool checks that the path ends in
.rmd. The check ignores case, so.Rmd(the usual RStudio extension) and.RMDwork as they are. Any other extension, such as.qmd,.mdor.txt, is rejected; rename the file to.Rmd. For a Jupyter notebook, usenotebook-editor, or convert between.Rmdand.ipynbwithjupytext.Outputs aren't appearing in the file — by design. Outputs are returned inline to the caller. To persist, render with
rmarkdown::render("file.rmd").Adjacent markdown cells merged after a save — by design.
.rmdhas no cell-boundary markers for prose; the model collapses consecutive markdown cells on re-parse.
Tool count
24 tools total:
1 document creation (
create_rmd)7 cell structure & navigation (
list_cells,get_cell,add_cell,delete_cell,move_cell,split_cell,merge_cells)3 cell content editing (
replace_cell_source,prepend_to_cell,append_to_cell)3 chunk options (
get_chunk_options,set_chunk_option,remove_chunk_option)3 YAML frontmatter (
get_frontmatter,set_frontmatter_key,remove_frontmatter_key)1 discovery (
find_in_rmd)2 execution (
execute_cell,execute_all_cells)4 kernel lifecycle (
get_kernel_state,restart_kernel,interrupt_kernel,shutdown_kernel)
This server cannot be deployed
Maintenance
Related MCP Connectors
MCP-native collaborative markdown editor with real-time AI document editing
MCP Server for Slima - AI Writing IDE for Novel Authors with AI Beta Reader.
Unified 15-tool SEO, SERP, clean Markdown & GEO (LLM citation Share-of-Voice) MCP server.
Document conversion MCP server: PDF to Markdown, image OCR, spreadsheet parsing.
Related MCP Servers
- AlicenseAqualityBmaintenanceAn MCP server for programmatically editing Jupyter notebooks, offering 29 tools for reading, modifying, and batch-processing notebooks without requiring a Jupyter server.2925 PyPI5MIT
- AlicenseNot gradedqualityDmaintenanceMCP server for Jupyter notebook navigation and editing. Reads .ipynb files directly as JSON, providing efficient tools for reading, searching, and modifying cells without needing a Jupyter server.MIT
- FlicenseAqualityCmaintenanceMCP server for structural editing of Jupyter notebook cells (list, read, insert, edit, patch, delete, move) without kernel execution.10-
- AlicenseAqualityBmaintenanceMCP server for Rootr markdown knowledge collaboration. Provides 27 tools for reading, writing, searching, and managing documents, issues, logs, and webhooks via natural language.811,177 npmMIT