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 schema
  • schema() / property_string() / property_boolean() / etc. — build JSON Schema definitions in R
  • response_text() — return a text content block
  • add_capability() — register tools on a server
  • serve_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.R

Set 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_XXXXXXXXXXXXXXXXXXXXXXXX

What 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_rulefilter_confilter_seqfilter_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-backcjar can 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.