Skip to contents

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/MCPR

That’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-advanced

Or 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:

  1. @keywords mcpr_tool — this is how ToolRegistry identifies tool functions
  2. @param tags with type annotations — converted to JSON Schema for MCP clients
  3. @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:

  1. Refinements must be balanced. roxygen2 discards any @param with 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.
  2. No space before the refinement. object {a: string} is an error, not an open-ended object with {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.
  3. 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 Schema required array 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 declared mcpr_type required = TRUE/FALSE setting 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:

  1. Nullability is not the same as optionality. A parameter like contrast = NULL is optional because the caller may omit it. Whether NULL is also a meaningful value is a separate concern handled by the tool implementation, not by the JSON Schema required array.
  2. 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 @param description 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