Building Domain-Specific MCP Servers on MCPR
MCPR is designed to be a substrate. It handles transport,
private-session execution, optional session attachment, and tool schemas
so that domain packages can focus on what they know: their own tools.
This vignette shows how ToolRegistry supports that through
multi-directory discovery and filtering.
The Problem
By default, mcprServer$new() discovers all tools shipped
with MCPR:
library(MCPR)
registry <- ToolRegistry$new(tools_dir = system.file(package = "MCPR"))
registry$search_tools()
registry$get_tool_summary()
#> name description parameters source_dir
#> 1 execute_r_code Execute R code in the active R session and ... 2 /path/MCPR
#> 2 inspect_object Inspect an R object in the active session ... 3 /path/MCPR
#> 3 manage_r_sessions Manage R sessions: list, join, or get info... 2 /path/MCPR
#> 4 show_plot Generate and display R plots as base64 PNG... 4 /path/MCPR
#> 5 view View the current state of the R session, t... 2 /path/MCPRThat’s the right default for a general-purpose R assistant. But a
domain package like mypackage wants a narrower surface: its
own tools, maybe optional attachment controls, and definitely
not execute_r_code — which would let an LLM run
arbitrary code in a production pipeline.
Two mechanisms solve this: multi-directory discovery and filtering.
Case 1: MCPR Tools Only (Default)
Nothing changes. This is how MCPR works today:
server <- mcprServer$new()Under the hood this creates a ToolRegistry pointed at
MCPR’s installed inst/ directory, calls
search_tools(), and loads every tool-*.R file
it finds.
Case 2: MCPR Tools + Your Package’s Tools
Pass a character vector to tools_dir:
mcpr_dir <- system.file(package = "MCPR")
my_dir <- system.file("tools", package = "mypackage")
registry <- ToolRegistry$new(
tools_dir = c(mcpr_dir, my_dir)
)
registry$search_tools()
registry$get_tool_summary()
#> name description parameters source_dir
#> 1 execute_r_code Execute R code in the active R session and ... 2 /path/MCPR
#> 2 inspect_object ... 3 /path/MCPR
#> ...
#> 7 add Add two numbers and return the result. 2 /path/mypackage
#> 8 subtract Subtract b from a and return the result. 2 /path/mypackage
server <- mcprServer$new(registry = registry)Each directory is scanned independently. If a directory doesn’t exist it’s skipped with a warning, not an error — so your package works gracefully whether a dependency is installed or not.
Duplicate tool names produce a warning. If both
directories define a tool called view,
search_tools() warns with provenance details showing which
directories contributed the conflict. You should resolve it by filtering
one out — silent shadowing causes debugging nightmares in composed
registries.
Case 3: Only Your Package’s Tools
Point the registry at your package alone:
registry <- ToolRegistry$new(
tools_dir = system.file("tools", package = "mypackage")
)
registry$search_tools()
server <- mcprServer$new(registry = registry)No MCPR built-in tools are loaded. Your server exposes exactly what your package defines and runs local-only in the private MCP server process. There is no hidden session discovery or attachment behavior.
Case 4: Cherry-Pick from MCPR + All of Your Package
This is the most common real-world pattern. You want MCPR’s optional
session-management control plane, but not execute_r_code or
other open-ended tools.
Two approaches, same result:
Approach A — exclude what you don’t want:
registry <- ToolRegistry$new(
tools_dir = c(system.file(package = "MCPR"), system.file("tools", package = "mypackage"))
)
registry$search_tools()
registry$filter(exclude = c("execute_r_code", "show_plot", "inspect_object"))
registry$get_tool_summary()
#> name ... source_dir
#> 1 manage_r_sessions ... /path/MCPR
#> 2 view ... /path/MCPR
#> 3 add ... /path/mypackage
#> 4 subtract ... /path/mypackage
server <- mcprServer$new(registry = registry)Approach B — include what you want:
registry <- ToolRegistry$new(
tools_dir = c(system.file(package = "MCPR"), system.file("tools", package = "mypackage"))
)
registry$search_tools()
registry$filter(include = c("manage_r_sessions", "add", "subtract"))Use exclude when you want “everything except these”. Use
include when you want “only these, nothing else”.
Filters persist across search_tools() calls — if you
re-scan (e.g., search_tools(force_refresh = TRUE)), the
same filter is reapplied automatically. Call filter() with
no arguments to clear all filters.
Case 5: Multiple Tool Subdirectories from One Package
Some packages organize tools by domain:
mypackage/
inst/
tools-basic/
tool-add.R
tool-subtract.R
tools-advanced/
tool-multiply.R
tool-divide.R
Point the registry at all of them:
pkg_base <- system.file(package = "mypackage")
registry <- ToolRegistry$new(
tools_dir = c(
file.path(pkg_base, "tools-basic"),
file.path(pkg_base, "tools-advanced")
)
)
registry$search_tools()
registry$get_tool_summary()
#> name ... source_dir
#> 1 add Add two numbers. /path/tools-basic
#> 2 subtract Subtract b from a. /path/tools-basic
#> 3 multiply Multiply two numbers. /path/tools-advanced
#> 4 divide Divide a by b. /path/tools-advancedOr load a subset of modules:
# Basic calculator only — no advanced operations
registry <- ToolRegistry$new(
tools_dir = file.path(pkg_base, "tools-basic")
)Chaining: configure(), filter(), search_tools()
configure(), filter(), and
set_verbose() return self (invisibly) for
chaining. search_tools() returns the tool list directly.
The intended usage pattern is:
# Declarative: set up, then scan
registry <- ToolRegistry$new(tools_dir = system.file(package = "MCPR"))
registry$search_tools()
registry$filter(exclude = "execute_r_code")
# Or reconfigure an existing registry
registry$configure(
tools_dir = c(system.file(package = "MCPR"), system.file("tools", package = "mypackage"))
)
registry$search_tools()
registry$filter(include = c("manage_r_sessions", "add"))configure() clears the cache and resets the directory
list. filter() applies immediately to already-discovered
tools and persists for future scans.
search_tools() respects both.
Provenance: Where Did This Tool Come From?
Every discovered tool is stamped with source_dir and
source_file in its annotations. This shows up in
get_tool_summary() and is accessible programmatically:
tool <- registry$get_tool("add")
tool$annotations$source_dir
#> [1] "/Library/R/4.3/library/mypackage/tools-basic"
tool$annotations$source_file
#> [1] "tool-add.R"Enable verbose mode to see provenance during discovery:
registry$set_verbose(TRUE)
registry$search_tools(force_refresh = TRUE)
#> ℹ Searching 4 files across 2 directories...
#> ℹ Loaded 1 tool from tool-add.R
#> ℹ Loaded 1 tool from tool-subtract.R
#> ...
#> ℹ Found 4 tools, 2 active after filtering.Writing Tools for Your Package
MCPR discovers tools through roxygen2 tags. A tool file in your package looks exactly like MCPR’s own:
# View Tool
# Main dispatcher for viewing R session state, terminal output, and workspace information.
# Provides focused inspection of specific aspects of the current R environment.
#' View R session information and workspace state
#'
#' @description View specific aspects of your R session including session info, terminal output, errors, packages, workspace files, search path, warnings, last computed value, and help documentation. This tool provides focused inspection of different components of your R environment. Use this for system and session state. For deep analysis of specific R objects (data frames, functions, models, lists), use inspect_object instead.
#' @param what character What to view. Options: "session" (R objects and session info), "terminal" (recent commands and output), "last_error" (most recent error details), "installed_packages" (installed R packages), "workspace" (current directory structure), "search_path" (package search path), "warnings" (recent warnings), "last_value" (inspect last computed R result), "help" (parsed help page, requires topic parameter), "vignette" (package vignette source, requires topic parameter)
#' @param max_lines integer Maximum number of lines to display in output (default: 100). Controls output length for terminal history, error traces, package lists, file listings, etc.
#' @param topic character Topic to look up. Required when what="help" or what="vignette". For what="help", supports "function_name" or "package::function_name" format. For what="vignette", supports three depths: "pkg" (index of all vignettes in the package), "pkg::name" (full raw source of one vignette), or "pkg::name#Section" (a single section of one vignette).
#' @keywords mcpr_tool
#' @return Formatted information about the requested aspect of the R session
view <- function(what = "session", max_lines = 100, topic = NULL) {
# Input validation and argument matching
if (!is.character(what) || length(what) != 1) {The key elements:
-
@keywords mcpr_tool— this is howToolRegistryidentifies tool functions -
@paramtags with type annotations — converted to JSON Schema for MCP clients -
@description— becomes the tool description that LLMs see
Parameter Types
The first token after the parameter name is its type. The vocabulary is closed — an unrecognised token aborts the registry build rather than guessing:
| Token | JSON Schema | Aliases |
|---|---|---|
string |
string |
character |
number |
number |
numeric |
integer |
integer |
int |
boolean |
boolean |
logical, bool
|
enum(a\|b\|c) |
string with enum
|
— |
array |
array of strings |
— |
object |
open-ended object | — |
object{...} |
object with declared fields | — |
json_object |
arbitrary named list |
named_list, list
|
json_array |
arbitrary list | — |
Two of these take a refinement suffix, written immediately after the token with no space. Parens list permitted values; braces list named fields:
#' @param rank_by enum(q_asc|nes_desc|name) Sort key for the result table.
#' @param query object{terms: array, mode?: enum(auto|exact|regex), max_hits?: integer} Structured search request. Terms are OR-matched.A field marked with a trailing ? is optional; every
other field is required. Field types may themselves be refined, so
objects nest. A declared field list is closed: the emitted schema
carries additionalProperties: false, and a call passing an
undeclared field is rejected.
Prefer enum(...) over a plain string
whenever the valid set is known and fixed. An agent cannot infer
permitted values from prose, and a string parameter
documented as “one of …” is a routine source of invalid calls.
Three things to watch:
-
Refinements must be balanced. roxygen2 discards any
@paramwith mismatched braces or quotes before MCPR sees it. MCPR catches the resulting gap and aborts, but the reported error is an undocumented parameter rather than a syntax error — check your braces first. -
No space before the refinement.
object {a: string}is an error, not an open-endedobjectwith{a: string}as its description. The space is almost always a typo, so MCPR refuses it rather than emitting a schema you did not write. - Field descriptions go in the parameter’s own description. The refinement syntax carries names and types only. Describe the fields in the prose that follows it.
One @param may document several parameters that share a
declaration, using roxygen2’s comma form —
#' @param x,y integer Inclusive bounds. — and each named
parameter gets its own copy of the schema.
Required vs Optional Parameters
MCPR now follows two different patterns depending on how the tool is defined:
-
Roxygen auto-discovery (
ToolRegistry) infers the JSON Schemarequiredarray from the function signature. Parameters without a default value are emitted as required; parameters with a default value are emitted as optional. This matches the signature-driven pattern used by the official MCP Python SDK. -
Explicit
tool()definitions keep the declaredmcpr_typerequired = TRUE/FALSEsetting as the source of truth. MCPR does not override manually declared schemas based on function defaults. This matches the schema-first pattern used by the official MCP TypeScript SDK.
For package authors using inst/tools/ wrappers, the
practical rule is simple:
# Required in schema: no default in the wrapper
#' @param a numeric First operand (required).
#' @param b numeric Second operand (required).
add <- function(a, b) {
mypackage::add(a = a, b = b)
}
# Optional in schema: defaulted in the wrapper
#' @param b numeric Multiplier (default: 1, i.e. identity).
multiply <- function(a, b = 1) {
mypackage::multiply(a = a, b = b)
}Two important caveats:
-
Nullability is not the same as optionality. A
parameter like
contrast = NULLis optional because the caller may omit it. WhetherNULLis also a meaningful value is a separate concern handled by the tool implementation, not by the JSON Schemarequiredarray. -
Conditional requirements must still be documented in
prose. JSON Schema can express “always required” well, but many
tools need rules like “required only when
what = \"help\"” or “required for most plot types”. Keep those constraints in the@paramdescription and enforce them at runtime.
Place your tool files in inst/tools/ (or any
subdirectory convention you prefer) and point ToolRegistry
at it. That’s it — MCPR handles roxygen parsing, schema generation,
JSON-RPC dispatch, private execution, and optional attachment when
manage_r_sessions is registered.
Note on paths:
system.file(package = "pkg") returns the installed
package root, where inst/ contents are lifted to the top
level. During development with devtools::load_all(), tool
files remain under inst/ — use the source path directly in
that case.
Putting It All Together: A Package Entry Point
A downstream package typically exposes a convenience function:
# In mypackage/R/server.R
#' Start the mypackage MCP Server
#' @param include_session_tools Include MCPR session management tools (default: FALSE)
#' @export
calc_server <- function(include_session_tools = FALSE) {
dirs <- system.file("tools", package = "mypackage")
if (include_session_tools) {
dirs <- c(system.file(package = "MCPR"), dirs)
}
registry <- MCPR::ToolRegistry$new(tools_dir = dirs)
registry$search_tools()
if (include_session_tools) {
# Keep session management, drop open-ended tools
registry$filter(exclude = c("execute_r_code", "show_plot"))
}
# Ordinary package tools run in the private MCP server process.
# If manage_r_sessions is included, attachment is controlled explicitly.
MCPR::mcpr_server(registry = registry)
}Users of the package get a focused, domain-specific MCP server — in
this case exposing only the calculator tools (add,
subtract, multiply, divide) plus
optional attachment controls — without needing to understand MCPR
internals.
Summary
| I want to… | Code |
|---|---|
| Use MCPR defaults | mcprServer$new() |
| Add my tools alongside MCPR’s | ToolRegistry$new(tools_dir = c(mcpr, mine)) |
| Use only my tools | ToolRegistry$new(tools_dir = mine) |
| Cherry-pick from MCPR | registry$filter(include = c("manage_r_sessions")) |
| Remove dangerous tools | registry$filter(exclude = c("execute_r_code")) |
| Multiple tool subdirs | tools_dir = c(dir1, dir2, dir3) |
| See where tools came from |
registry$get_tool_summary() or
tool$annotations$source_dir
|
