Building an MCP Server for Adobe Customer Journey Analytics in R
How I wired the cjar R package to Claude Code using the Model Context Protocol — and what it feels like to query CJA in plain English.
The problem: analytics data is locked behind the UI
Adobe Customer Journey Analytics is powerful, but getting data out of it programmatically means writing API calls, managing auth tokens, and knowing which endpoint to hit. The cjar R package solves most of that, but you still have to write R code — not ideal when you just want to ask a quick question.
The Model Context Protocol (MCP) lets you expose tools to an LLM so it can call them on your behalf. That’s the bridge I wanted: type a question in Claude Code, have it call the right cjar function, and get the answer back as JSON I can work with.
The result is cja_mcp_server.R — a single R script that spins up an MCP server over stdio, registers every exported cjar function as a tool, and lets Claude Code drive CJA.
How MCP works (briefly)
MCP is a JSON-RPC protocol. A host (Claude Code, in this case) launches a server process as a subprocess and speaks to it over stdin/stdout. The server advertises a list of tools — each with a name, description, and JSON Schema for its inputs. The host sends tools/call requests; the server executes them and returns a text or JSON content block.
The whole thing runs over stdio, which means zero networking setup: the server is just a process the host manages.
The mcpr package
mcpr is an R implementation of the server side of MCP. It gives you:
new_tool()— declare a tool with a name, description, and input schemaschema()/property_string()/property_boolean()/ etc. — build JSON Schema definitions in Rresponse_text()— return a text content blockadd_capability()— register tools on a serverserve_io()— the request loop over stdin/stdout
With mcpr, wiring a cjar function to MCP looks like this:
tool_get_me <- new_tool(
name = "cja_get_me",
description = "Get the authenticated user's CJA profile. Good connectivity check.",
input_schema = schema(properties = list(
expansion = property_string("Expansion", "Extra fields. Only 'admin' supported."),
debug = property_boolean("Debug", "Log API request/response to stderr.")
)),
handler = make_handler(cja_get_me)
)The make_handler helper is a one-liner that I factored out since almost every tool does the same thing: pluck arguments from the MCP input list, drop NULLs (so cjar’s defaults apply), and call the function:
make_handler <- function(fn, as_date = character(0)) {
force(fn); force(as_date)
function(input) {
args <- prepare_args(input, as_date = as_date)
result <- do.call(fn, args)
json_response(result)
}
}json_response serializes the return value to pretty-printed JSON and wraps it in mcpr::response_text(). Data frames come out row-wise; NA becomes null.
Auth and startup
CJA uses OAuth Server-to-Server credentials (Adobe deprecated JWT in June 2025). The server reads a credentials JSON file path from the CJA_AUTH_FILE environment variable at startup:
tryCatch(
{
auth_file <- Sys.getenv("CJA_AUTH_FILE")
if (nzchar(auth_file)) {
cja_auth()
log_stderr("Authenticated with CJA at startup.")
} else {
log_stderr("CJA_AUTH_FILE not set; tools will fail until auth is configured.")
}
},
error = function(e) log_stderr("Startup auth failed: ", conditionMessage(e))
)Errors are swallowed so the server starts regardless — Claude Code doesn’t surface stderr until a tool call, so a hard exit here would produce a confusing silent failure.
All logging goes through message() (stderr). This is important: stdout belongs to the JSON-RPC protocol. A stray print() or cat() in a handler will corrupt the framing and break the handshake.
The Windows stdin problem
mcpr::serve_io calls readLines("stdin", n = 1) inside its loop. On Windows, Rscript reopens stdin on each iteration, which means it only ever sees the first line — the initialize handshake goes through but tools/list never does.
The fix is simple: open stdin once and reuse the connection:
serve_io_persistent <- function(mcp) {
con <- file("stdin", open = "r", blocking = TRUE)
on.exit(close(con), add = TRUE)
repeat {
line <- readLines(con, n = 1, warn = FALSE)
if (length(line) == 0) break
response <- mcpr:::parse_request(line, mcp)
if (!length(response)) next
mcpr:::send(response, stdout())
}
}This restores the expected multi-request behavior. I verified it by feeding an initialize frame followed by a tools/list frame and confirming both responses came back.
The tools exposed
The server registers 24 tools across four categories:
Auth / session cja_auth, cja_auth_with, cja_auth_path, cja_auth_name — manage credentials and session options.
Metadata cja_get_me, cja_get_dataviews, cja_get_dimensions, cja_get_metrics, cja_get_calculatedmetrics, cja_get_filters, cja_get_filter, cja_get_dateranges, cja_get_projects, cja_get_project_config, cja_get_audit_logs, cja_get_audit_logs_search.
Reporting cja_freeform_table — the main workhorse. Equivalent to a Workspace Freeform Table: pass dimension IDs, metric IDs, a date range, and optional filters. Dimension order matters for performance: the first dimension is queried top-level, then each value is broken down by the next (one API call per value), so put low-cardinality dimensions first.
Filter authoring filter_verbs, filter_rule, filter_con, filter_seq, filter_then, filter_build, filter_val — a compositional API for building CJA segments. Since cjar’s filter functions pass nested R lists, these tools accept and return JSON strings that Claude assembles step by step.
Installing and registering
install.packages(c("pak", "jsonlite"))
pak::pkg_install("benrwoodard/cjar")
pak::pkg_install("devOpifex/mcpr")Then register the server once:
claude mcp add cja -- Rscript /absolute/path/to/cja_mcp_server.RSet two env vars before starting Claude Code so they propagate to the subprocess:
export CJA_AUTH_FILE=/path/to/cja_credentials.json
export CJA_DATAVIEW_ID=dv_XXXXXXXXXXXXXXXXXXXXXXXXWhat it looks like in practice
Once registered, the tools show up as mcp__cja__* inside Claude Code. A connectivity check:
“Call cja_get_me to verify the connection.”
returns the service account profile and IMS org ID. From there:
“Show me page views and sessions by marketing channel for the last 30 days.”
Claude figures out the right dimension ID, metric IDs, and date range, then calls cja_freeform_table. The result comes back as JSON you can pipe into R, paste into a notebook, or just read inline.
The filter tools let you go further:
“Build a filter for mobile visitors who completed a purchase in the same visit.”
Claude chains filter_rule → filter_con → filter_seq → filter_build, assembling the segment definition incrementally.
What’s next
The server exposes all current cjar exports, but a few things would make it sharper:
- Streaming — large freeform queries with many breakdowns can take minutes. MCP’s streaming content blocks would let Claude show partial results as they arrive.
- Write-back —
cjarcan POST filters and annotations; those tools could be added so Claude can create assets directly. - Better error messages — right now API errors surface as raw JSON. Parsing them into natural-language explanations would help.
The code is on GitHub: benrwoodard/cjar-mcpr. One file, ~800 lines, no framework beyond mcpr itself.