# cBioPortal — MCP: Handing a cBioPortal study to an agent: governance over MCP

"Check whether the study files on this branch still match what we govern, and tell me what changed."

# Handing a cBioPortal study to an agent: governance over MCP

"Check whether the study files on this branch still match what we govern, and tell me what changed."

That sentence is a complete task specification for an AI agent connected to CoreModels over the
Model Context Protocol. The agent needs no client library, no schema parser, and no copy of your
governance rules - it needs four tools, three artifact names, and a project id. This article shows
the exact calls, the exact argument names, and the response shapes an agent actually sees, plus the
two places where the MCP surface deliberately differs from the HTTP one.

## The two endpoints

CoreModels serves MCP over stateless streamable HTTP at two paths:

* `https://coremodels.example.com/mcp` - the public endpoint. Only Viewer-role tools are listed or
  executable here. `audit_vendor_project`, `generate_vendor_artifacts`, and
  `get_vendor_integration_status` all run at Viewer role, so the entire read side of cBioPortal
  governance is available on the public endpoint.
* `https://coremodels.example.com/mcp-admin` - the admin endpoint, which additionally serves the
  write tools, including `import_vendor_project` (Admin role).

Authorization is OAuth 2.0 with RFC 9728 protected-resource metadata, Dynamic Client Registration,
and PKCE (S256). A spec-compliant client discovers all of it on its own: an unauthenticated request
gets a `401` with a `WWW-Authenticate` header pointing at the resource metadata, and the client
takes it from there. Adding the server from a terminal client is one line.

```bash
claude mcp add --transport http coremodels https://coremodels.example.com/mcp
```

The authorization boundary that actually matters is not which endpoint minted a token - it is the
per-project role check. Write tools require Editor or Admin membership on the project regardless of
endpoint, and every tool declares `readOnlyHint` plus, where relevant, destructive, idempotent, and
open-world annotations in `tools/list`, so a client can reason about a call before making it.

## Step 1 - orient with `get_vendor_integration_status`

Before touching artifacts, an agent should confirm what the project already knows. Called without a
`vendor`, this tool lists every registered connector with its capabilities and expected artifacts;
called with one, it returns the project's last-import state.

```json
{
  "name": "get_vendor_integration_status",
  "arguments": {
    "graphProjectId": "9d41c2b7e85f4a63b0d7c1e58f2a6b04",
    "vendor": "cbioportal"
  }
}
```

```json
{
  "vendor": "cbioportal",
  "imported": true,
  "state": {
    "vendor": "cbioportal",
    "projectName": "example_brca_2026",
    "importedAt": "2026-08-03T09:14:22.7431180+00:00",
    "sourceFingerprint": "c4a71f2e9b380d56",
    "counts": "fieldsAdded=0, lineageAdded=0, lineageSkipped=0, nodesEnriched=10",
    "facts": "{\"instruments\":\"2\",\"attributes\":\"8\",\"cancerType\":\"brca\",\"studyId\":\"example_brca_2026\",\"studyName\":\"Example BRCA Cohort 2026\"}"
  },
  "governedDatasets": 2
}
```

One property of MCP payloads is worth building into your agent's prompt: they are serialized
compactly in camelCase with default values omitted. Null strings, `false` booleans, and - this is
the one that bites - zero-valued integers do not appear at all. An agent must read a missing
`errorCount` as zero, not as "unknown". Empty collections still serialize as `[]`.

Every argument schema is closed (`additionalProperties: false`) and `graphProjectId` is validated
against the pattern `^[a-f0-9]{32}$`, so a mistyped id fails fast instead of running against the
wrong project.

## Step 2 - audit with `audit_vendor_project`

This is the Schema Audit: fresh artifacts against the governed graph, read-only, reporting coverage,
drift, and conformance. Required arguments are `graphProjectId` and `vendor`. Artifacts arrive
inline through `artifacts` (name to raw content), through `artifactUrls` (name to URL), or both
merged. `spaces` is optional.

```json
{
  "name": "audit_vendor_project",
  "arguments": {
    "graphProjectId": "9d41c2b7e85f4a63b0d7c1e58f2a6b04",
    "vendor": "cbioportal",
    "artifacts": {
      "clinical_patient": "#Patient Identifier\tAge\tSex\tOverall Survival Status\n#Unique patient identifier\tAge at diagnosis in years\tSex at birth\n#STRING\tSTRING\tSTRING\tSTRING\n#1\t1\t1\t2\nPATIENT_ID\tAGE\tSEX\tOS_STATUS\n",
      "clinical_sample": "#Patient Identifier\tSample Identifier\tCancer Type\tTumor Purity\n#Patient this sample belongs to\tUnique sample identifier\tOncoTree cancer type\tEstimated tumor purity percentage\n#STRING\tSTRING\tSTRING\tNUMBER\n#1\t1\t1\t3\nPATIENT_ID\tSAMPLE_ID\tcancerType\tTUMOR_PURITY\n"
    }
  }
}
```

Note what changed in that patient file relative to what the project governs: the datatype row now
reads `STRING` where `AGE` used to be `NUMBER`. Here is what comes back:

```json
{
  "vendor": "cbioportal",
  "projectName": "cbioportal-study",
  "errorCount": 1,
  "warningCount": 1,
  "infoCount": 1,
  "metrics": {
    "Datasets (estate)": "2",
    "Datasets governed": "2 / 2",
    "Fields governed": "8 / 8",
    "Governed nodes with canonical mappings": "0 / 10 (0%)",
    "Last import": "2026-08-03T09:14:22.7431180+00:00"
  },
  "findings": [
    {
      "section": "Drift",
      "severity": "Error",
      "code": "field-type-drift",
      "subject": "patient.AGE",
      "message": "Field type changed since the last import.",
      "detail": "governed: NUMBER, estate: STRING"
    },
    {
      "section": "Conformance",
      "severity": "Info",
      "code": "attribute-no-description",
      "subject": "patient",
      "message": "1 clinical attribute(s) carry no description row - curators downstream will guess.",
      "detail": "OS_STATUS"
    },
    {
      "section": "Conformance",
      "severity": "Warning",
      "code": "attribute-id-not-upper",
      "subject": "sample.cancerType",
      "message": "Attribute IDs must be UPPER_CASE for cBioPortal validation to pass."
    }
  ],
  "markdown": "# cbioportal Schema Audit - cbioportal-study\n\n🔴 **1 errors · 1 warnings · 1 info**\n…",
  "fetchProblems": []
}
```

Two things to teach the agent. `projectName` is `cbioportal-study` because this call sent no `meta`
artifact - the study identifier comes from `cancer_study_identifier` in `meta_study.txt`, and
without it the connector uses a neutral default rather than guessing. And `markdown` is what belongs
in a review comment: it already carries the verdict line, the metrics table, and per-section
collapsible blocks, so the agent should not re-render findings itself.

Unlike the HTTP audit route, this tool has no `recordHistory` argument. Agent-driven audits are
strictly read-only; appending to the rolling trail stays with the HTTP surfaces that CI and humans
call.

The result shape is also trimmed for a conversational client. It carries the counts, the metrics, the
full findings list, the markdown, and `fetchProblems` - but not the `codes` rollup, the
`driftedObjects` list, the artifact `fingerprint`, or the `lossiness` channel that the HTTP report
includes. An agent that needs a code histogram counts the findings itself; an agent that needs the
fingerprint calls the HTTP route.

## Step 3 - the `artifactUrls` flow

Clinical staging headers are small, but agents frequently cannot inline file contents at all -
either the client truncates large tool arguments or the files are not on the agent's disk.
`artifactUrls` solves that: the server fetches each artifact itself.

```json
{
  "name": "audit_vendor_project",
  "arguments": {
    "graphProjectId": "9d41c2b7e85f4a63b0d7c1e58f2a6b04",
    "vendor": "cbioportal",
    "artifactUrls": {
      "clinical_patient": "https://files.example.org/studies/example_brca_2026/data_clinical_patient.txt",
      "clinical_sample": "https://files.example.org/studies/example_brca_2026/data_clinical_sample.txt",
      "meta": "https://files.example.org/studies/example_brca_2026/meta_study.txt"
    }
  }
}
```

Those fetches are server-side GETs to caller-supplied URLs, so they are guarded rather than trusted.
HTTPS only. Redirects disabled, because a redirect to an internal host would defeat any pre-flight
check. Hosts that resolve to loopback, link-local (including cloud metadata addresses), or private
ranges are refused. The response is size-capped and the fetch times out after a minute.

Failures are reported, never silently swallowed. A refused or unreachable URL adds a line to
`fetchProblems` while the audit proceeds on whatever was retrieved:

```json
{
  "fetchProblems": [
    "Refused to fetch 'meta' from https://10.0.0.7/meta_study.txt: host resolves to a non-public address (10.0.0.7)"
  ]
}
```

If nothing at all is usable - no inline artifacts, no fetchable URLs - the tool returns an error
naming the arguments it needs. That generic message is phrased around dbt's artifact names, so
remember that for this connector the names are `clinical_patient`, `clinical_sample`, and `meta`,
with at least one clinical file required. You can also mix the two maps: inline the small `meta`
file and fetch the clinical files by URL. Both merge into one artifact set.

## Step 4 - writing, on the admin endpoint

Bringing a new study under governance is an Admin act, and `import_vendor_project` is served only on
`/mcp-admin`. Its arguments are identical to the audit tool's - same `artifacts` and `artifactUrls`
handling, same URL guards.

```json
{
  "name": "import_vendor_project",
  "arguments": {
    "graphProjectId": "9d41c2b7e85f4a63b0d7c1e58f2a6b04",
    "vendor": "cbioportal",
    "artifactUrls": {
      "clinical_patient": "https://files.example.org/studies/example_brca_2026/data_clinical_patient.txt",
      "clinical_sample": "https://files.example.org/studies/example_brca_2026/data_clinical_sample.txt",
      "meta": "https://files.example.org/studies/example_brca_2026/meta_study.txt"
    }
  }
}
```

```json
{
  "success": true,
  "vendor": "cbioportal",
  "projectName": "example_brca_2026",
  "datasetsAdded": 2,
  "nodesEnriched": 10,
  "lossiness": [
    {
      "kind": "TypeApproximation",
      "path": "patient.AGE",
      "explanation": "Native type 'NUMBER' was approximated as Double; the exact native type is preserved in the vendor metadata mixin."
    },
    {
      "kind": "TypeApproximation",
      "path": "sample.TUMOR_PURITY",
      "explanation": "Native type 'NUMBER' was approximated as Double; the exact native type is preserved in the vendor metadata mixin."
    }
  ],
  "errors": [],
  "fetchProblems": []
}
```

`datasetsSkippedExisting`, `fieldsAdded`, and both lineage counts are absent from that payload
because they are zero - the compact-serialization rule again. The import is additive and never
mutates governed nodes, so an agent that re-imports a study it already imported is safe: it will see
`datasetsSkippedExisting` appear and `datasetsAdded` disappear.

One field the HTTP import returns is not in the tool's result at all: `snapshotStored`. The snapshot
is still persisted exactly as it would be over HTTP - it is simply not reported back here. An agent
that needs to confirm a re-audit baseline exists should check the HTTP import response, or try the
re-audit route and read its refusal message.

## Step 5 - generating scaffolds back out

`generate_vendor_artifacts` runs at Viewer role and is therefore on the public endpoint. For
cBioPortal it emits one `data_clinical_{instrument}.txt` header scaffold per governed Type.

```json
{
  "name": "generate_vendor_artifacts",
  "arguments": {
    "graphProjectId": "9d41c2b7e85f4a63b0d7c1e58f2a6b04",
    "vendor": "cbioportal",
    "typeNames": ["patient"]
  }
}
```

```json
{
  "artifacts": [
    {
      "name": "data_clinical_patient.txt",
      "kind": "tsv",
      "content": "#PATIENT_ID\tAGE\tSEX\tOS_STATUS\n#Unique patient identifier\tAge at diagnosis in years\tSex at birth\tOS_STATUS\n#STRING\tNUMBER\tSTRING\tSTRING\n#1\t1\t1\t1\nPATIENT_ID\tAGE\tSEX\tOS_STATUS\n"
    }
  ],
  "lossiness": []
}
```

`typeNames` matters more than it looks: generation walks every governed Type in the project, not
only the ones cBioPortal imported. Instruct agents to scope it in any project that governs more than
one estate. `targetVersion` exists on the schema for vendors with dialect versions; cBioPortal
ignores it.

## What is not on MCP

Three verbs from this series' HTTP surface have no MCP tool: `reaudit`, `history`, and `badge`
(the sync-plan routes are HTTP-only too). That is a deliberate boundary - the drift trail and its
badge are automation bookkeeping, driven by CI pipelines and scheduled jobs rather than by
conversational agents. An agent that needs the trail
calls the HTTP route or asks a human to.

Everything else composes normally. After an audit, an agent can call `get_project_summary` for the
Types and Elements in the project, `search_nodes` to inspect one attribute, or the schema export
tools to hand the governed study to another system. The audit tells it what drifted; the read tools
tell it what the drifted thing means.

For the artifact contract behind all of this, see the cBioPortal quickstart in the CoreModels
integration docs.
