---
title: Building an Agent Against the ChartHop MCP Server
slug: building-an-agent-against-the-charthop-mcp-server
docTags: 
createdAt: 2026-09-03T13:07:59.877Z
---

# Building an Agent Against the ChartHop MCP Server

This guide is for developers connecting an AI agent to ChartHop over the [Model Context Protocol (MCP)](https://modelcontextprotocol.io). It covers how to connect and authenticate, what tools are available, how to call them, and the security model you're operating inside.

:::BlockQuote
**Prerequisites:** MCP server access requires the **AI Pro** module. Ensure AI Pro is enabled in **Admin > AI** before proceeding.
:::

If you're using a supported MCP client (Claude, ChatGPT connectors, Cursor, etc.), most of the OAuth handshake is automatic — you paste a URL and approve a consent screen. If you're building a client from scratch, the [Connecting](./#connecting) section documents the full flow.

***

## TL;DR

- **Transport:** JSON-RPC 2.0 over HTTP POST (Streamable HTTP). Request/response only (no SSE stream).
- **Auth:** OAuth 2.0 with PKCE and dynamic client identification (CIMD). You end up with a `Bearer` token scoped to one agent and one org.
- **Connection URL:** `https://<host>/mcp/{agent}` (org chosen at consent time) or `https://<host>/mcp/{agent}/{orgId}` (org fixed in the URL).
- **Tools:** Four: `Search`, `List`, `Detail` (all read-only), and `AskAgent` (delegate a task to a ChartHop agent).
- **Writes:** Off by default. The three data-access tools are read-only by design; actual mutation happens only through `AskAgent`, and only when the agent install has writes enabled.

***

## Connecting

### Connection URL shapes

There are two URL patterns. `{agent}` is the slug of the ChartHop AI agent you're connecting to (an org can publish more than one agent).

| Pattern                     | When to use                                                                                                                                              |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /mcp/{agent}`         | **Org-less.** The org is selected during the OAuth consent step. Use this for marketplace or directory listings where the URL can't hardcode a customer. |
| `POST /mcp/{agent}/{orgId}` | **Org-scoped.** The org is fixed in the URL. `{orgId}` may be an org id or slug.                                                                         |

`GET` on either path returns `405 Method Not Allowed` — the server does not offer an SSE stream. All traffic is request/response `POST`.

### The OAuth flow

ChartHop implements OAuth 2.0 with **PKCE (S256, mandatory)** and **CIMD** (client identification by metadata document) instead of pre-registered client secrets. Your client is identified by a URL to a hosted client-metadata document rather than a `client_id`/`client_secret` you register ahead of time.

A compliant MCP client performs these steps automatically. In order:

**1. Discover the authorization server.** Starting from just the MCP URL, fetch the protected-resource metadata (RFC 9728):

```javascript
GET /.well-known/oauth-protected-resource/mcp/{agent}[/{orgId}]
→ { resource, authorization_servers, bearer_methods_supported }
```

Then the authorization-server metadata (RFC 8414) at the advertised location:

```javascript
GET /.well-known/oauth-authorization-server/mcp/{agent}[/{orgId}]
```

**2. Fetch grant info** for the consent screen:

```javascript
GET /oauth/mcp/grant-info?clientId=<CIMD_URL>&resource=<resource>&redirectUri=<redirect>
```

- `clientId` — the URL of your hosted client-metadata document (e.g. `https://claude.ai/.well-known/oauth-client-metadata.json`).
- `resource` — the MCP server URL being authorized (RFC 8707).
- The response describes your client, the signed-in user, and — for an org-less URL — the list of candidate orgs the user can pick from.

**3. User approves.** The ChartHop consent screen posts the approval:

```javascript
POST /oauth/mcp/approve
{
  "clientId":            "<CIMD_URL>",
  "redirectUri":         "<redirect>",
  "codeChallenge":       "<S256 hash>",
  "codeChallengeMethod": "S256",
  "resource":            "<resource URL>",
  "state":               "<opaque>",
  "orgId":               "<selected org>"   // only for the org-less URL
}
→ { "redirectUrl": "<redirect>?code=...&state=...&iss=..." }
```

The authorization code is bound to the user, agent, org, PKCE challenge, and redirect URI, and carries the `MCP` and `SENSITIVE` scopes.

**4. Exchange the code for an access token** (PKCE verifier required):

```javascript
POST /oauth/token
  grant_type=authorization_code
  code=<code>
  client_id=<CIMD_URL>
  redirect_uri=<redirect>
  code_verifier=<PKCE verifier>
  resource=<resource URL>          // RFC 8707, optional
→ { "access_token": "...", "token_type": "bearer", "expires_in": ... }
```

The CIMD document at `clientId` is validated server-side: HTTPS only, no redirects, size-capped, fetched through an egress proxy (SSRF protection), and the `redirect_uri` must be listed in it. Loopback redirect URIs match with port flexibility per RFC 8252 §7.3 so native clients can use an ephemeral local port.

### Using the token

Send it as a bearer token on every MCP request:

```javascript
Authorization: Bearer <access_token>
```

The token is **audience-bound to the agent** and **org-bound** (the org you chose at consent, or the org in the URL). A token minted for one agent/org cannot be replayed against another. The `SENSITIVE` scope controls whether sensitive fields (e.g. compensation) come back unmasked — subject to the agent's own permissions.

***

## The protocol

### Versions

The server speaks multiple MCP protocol versions and negotiates per client:

| Version      | Notes                                                                           |
| ------------ | ------------------------------------------------------------------------------- |
| `2026-07-28` | Newest. Stateless: declared per-request (see below), no `initialize` handshake. |
| `2025-06-18` | Latest legacy version. No JSON-RPC batching.                                    |
| `2025-03-26` | Legacy. Supports batching.                                                      |
| `2024-11-05` | Legacy. Supports batching.                                                      |

**Legacy clients** send an `initialize` request; the server replies with the negotiated `protocolVersion`, `capabilities`, and `serverInfo`, and you send an `MCP-Protocol-Version` header on subsequent requests. If a legacy `initialize` asks for `2026-07-28`, it's clamped down to the latest legacy version — the stateless path is opted into differently.

**Stateless (**`2026-07-28`**) clients** skip `initialize` entirely and declare the version on each request under `params._meta["io.modelcontextprotocol/protocolVersion"]`. Discover support with the `server/discover` method, which returns `supportedVersions` (newest first).

### Content type

The message endpoint accepts **any** `Content-Type` — some clients (e.g. ChatGPT's connector) `POST` without `application/json`. The body is parsed as JSON-RPC regardless, and parse failures come back as JSON-RPC errors rather than HTTP 415. Responses are `application/json`.

Notifications (JSON-RPC messages with no `id`) get `202 Accepted` with no body.

### Methods

| Method            | Purpose                                                                                                 |
| ----------------- | ------------------------------------------------------------------------------------------------------- |
| `initialize`      | Legacy handshake. Returns `protocolVersion`, `capabilities`, `serverInfo`, optional `instructions`.     |
| `server/discover` | `2026-07-28` discovery. Returns `supportedVersions`, `capabilities`, identity, optional `instructions`. |
| `ping`            | Health check. Returns `{}`.                                                                             |
| `tools/list`      | List the available tools (see below).                                                                   |
| `tools/call`      | Invoke a tool.                                                                                          |

`tools/list` and `server/discover` carry cache hints (`ttlMs`, `cacheScope: "private"`) — the tool set is auth- and agent-specific, so cache it briefly and per-credential. Tools are returned in a stable (name-sorted) order.

**Server instructions.** If the org's admin has configured an "MCP Instructions" hint, its text is returned in the `instructions` field of `initialize` / `server/discover`. Treat it as system guidance for how to use this particular org's tools; it's omitted when unset.

***

## The tools

Four tools are exposed. `Search`, `List`, and `Detail` are read-only data-access tools. `AskAgent` delegates a whole task to a ChartHop agent.

Each tool in `tools/list` carries MCP annotations you can use to drive UI or warnings: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`.

### `Search`

Find entities by name.

| Param           | Type      | Req | Description                                                                                                                                                                                          |
| --------------- | --------- | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `query`         | string    | Yes | Name to match against.                                                                                                                                                                               |
| `entityTypes`   | string\[] |     | Which types to search; omit to search all. Enum: `persons`, `jobs`, `fields`, `contents`, `functions`, `appUsers`, `scenarios`, `compBands`, `compReviews`, `reports`, `users`, `orgs`, `customers`. |
| `includeFormer` | boolean   |     | Include former employees as if current. Default false.                                                                                                                                               |

Use `Search` to turn a name into an id, then feed that id to `Detail` or a `List` filter.

### `List`

List entities of a type as tabular data, with filtering, pagination, sorting, and grouping/aggregation.

| Param                   | Type      | Req | Description                                                                                                                                       |
| ----------------------- | --------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `entityType`            | enum      | Yes | The entity type to list (see [Entity types](./#entity-types)).                                                                                    |
| `filter`                | string    |     | CQL filter expression, e.g. `dept:engineering` or `status:PENDING`.                                                                               |
| `columns`               | string\[] |     | Columns to return. Omit for the type's default columns. Request specifics like `baseComp`, `startDate`, `email`, `manager`.                       |
| `sort`                  | string    |     | Column to sort by; prefix `-` for descending (e.g. `-baseComp`).                                                                                  |
| `limit`                 | integer   |     | Max results. Default 100, max 200.                                                                                                                |
| `next`                  | string    |     | Pagination cursor from a prior `List` result.                                                                                                     |
| `groupBy`               | string    |     | Column to group by (e.g. headcount by `department`). When set, `next`/`limit`/`sort` are ignored — aggregation runs over the whole filtered set.  |
| `aggFn`                 | string    |     | Comma-separated aggregations for `groupBy`. `count` is always included; add `sum`, `mean`, `min`, `max`.                                          |
| `startDate` / `endDate` | string    |     | Inclusive range for date-aware types. `yyyy-MM-dd` or ISO-8601 datetime; supports relative dates (`today`, `-30d`, `startOfMonth`, `endOfMonth`). |
| `scenarioId`            | string    |     | List within a planning scenario instead of the live org.                                                                                          |

**Pagination:** call again with `next` set to the cursor from the previous result until it's absent.

### `Detail`

Fetch full structured detail for up to **20** entities by id.

| Param                   | Type      | Req | Description                                                                                          |
| ----------------------- | --------- | --- | ---------------------------------------------------------------------------------------------------- |
| `entityType`            | enum      | Yes | The entity type (see [Entity types](./#entity-types)).                                               |
| `ids`                   | string\[] | Yes | Entity ids (24-char hex ObjectIds). Max 20.                                                          |
| `startDate` / `endDate` | string    |     | Range for date-aware entities (reports/dashboards), `yyyy-MM-dd`.                                    |
| `interval`              | enum      |     | For date-aware entities: `DAY`, `WEEK`, `MONTH`, `QUARTER`, `FISCAL_QUARTER`, `YEAR`, `FISCAL_YEAR`. |
| `scenarioId`            | string    |     | View the entity within a scenario.                                                                   |

Over MCP, `Detail` always returns structured metadata.

:::BlockQuote
**ID chaining caveat.** The ids you pass to `Detail` must come from a `Search` or `List` result in the *same* scope (org, scenario, date range). Ids are not globally stable across scenarios — pull the id from a `List`/`Search` that used the same `scenarioId`/date window you're about to query.
:::

### `AskAgent`

Delegate a question or task to a ChartHop AI agent. The agent runs autonomously with its own tools and permissions, then returns a result. Use this when you'd rather hand off "answer this HR/org-data question" than orchestrate `List`/`Detail` yourself. It's also the **only** path to mutations over MCP.

| Param        | Type    | Req | Description                                                                                                                                                                                  |
| ------------ | ------- | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prompt`     | string  | Yes | The question or task for the agent.                                                                                                                                                          |
| `agent`      | enum    |     | Which agent to ask. Enumerated dynamically from what your access allows. Defaults to the org's default agent.                                                                                |
| `readOnly`   | boolean |     | If true (default), the agent is restricted to read-only tools. Set false to allow data-modifying tools — subject to the agent's own permissions and the install's write setting (see below). |
| `complexity` | enum    |     | Reasoning effort: `high`, `medium`, `low`. Higher uses a more capable model. Defaults to the agent's configured tier.                                                                        |

***

## Calling a tool

`tools/call` takes the tool `name` and an `arguments` object:

```json
{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/call",
  "params": {
    "name": "List",
    "arguments": {
      "entityType": "PERSON",
      "filter": "dept:engineering",
      "columns": ["name", "title", "manager", "startDate"],
      "sort": "-startDate",
      "limit": 50
    }
  }
}
```

The result is standard MCP tool content — a `content` array of text blocks, with `isError` set when the call failed:

```json
{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "content": [{ "type": "text", "text": "<display-formatted table>" }],
    "isError": false
  }
}
```

Tool output is already display-formatted server-side (tabular data rendered to text). A failed call returns `isError: true` with an explanatory message rather than a JSON-RPC transport error.

***

## Entity types

`List` and `Detail` accept these `entityType` values (over MCP they're exposed as an enum, and the set is filtered to what your access and the org's configuration allow — e.g. `TIMEOFF` is hidden when the time-off tab is disabled):

`PERSON`, `JOB`, `GROUP`, `FIELD`, `REPORT`, `REPORT_CHART`, `EVENT`, `FILE`, `FORM`, `TASK`, `TEMPLATE`, `CONTENT`, `SCENARIO`, `TABLE`, `PROCESS`, `ACTION`, `CALENDAR`, `CALENDAR_ENTRY`, `COMP_BAND`, `COMP_REVIEW`, `ASSESSMENT`, `GOAL`, `GOAL_TARGET`, `GOAL_PROGRESS`, `GOAL_TYPE`, `STOCK_GRANT`, `TIMEOFF`, `TRANSCRIPT`.

In addition, **custom tables** can be queried by name (use the table's name as the `entityType`, and `TABLE` to discover table metadata).

***

## Security model

Understanding this will save you debugging time when a call returns less than you expect.

- **Read-only by default.** `Search`/`List`/`Detail` cannot mutate anything.
- **Writes are gated twice.** A mutation can only happen through `AskAgent` with `readOnly: false`, and only if the agent install has `enableMcpWrite` turned on. If it's off, write attempts are rejected at the MCP edge with a clear message. This is defense-in-depth layered on top of the agent's role/policy — it never grants access beyond what the agent's role already allows.
- **MCP must be enabled** on the install (`enableMcpServer`) for the server to respond at all.
- **Everything runs as the agent, on behalf of the authenticated user.** Data visibility (including whether sensitive fields are masked) is the intersection of the agent's role/policies and the user's access — not the raw database. Two different tokens against the same org can legitimately see different results.
- **Scenario/date scope is explicit.** If you don't pass `scenarioId` or a date range, you get the live org as of today. Chain ids within a consistent scope.

***

## A minimal agent loop

1. **Connect and authenticate.** Obtain a bearer token for `{agent}` and org.
2. `initialize` (or `server/discover` for stateless). Read `instructions`.
3. `tools/list`. Register `Search`, `List`, `Detail`, `AskAgent` with your model.
4. Let the model call tools:
   - Resolve names to ids with `Search`.
   - Pull tabular/aggregate data with `List` (paginate via `next`).
   - Expand specific records with `Detail` (up to 20 ids, matching scope).
   - Hand off open-ended tasks — or any write — to `AskAgent`.
5. Feed each tool result's `content` text back to the model and continue.
