Building an Agent Against the ChartHop MCP Server
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). It covers how to connect and authenticate, what tools are available, how to call them, and the security model you're operating inside.
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๏ปฟ 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):
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:
GET /.well-known/oauth-authorization-server/mcp/{agent}[/{orgId}]2. Fetch grant info for the consent screen:
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:
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):
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:
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๏ปฟ). |
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๏ปฟ). |
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.
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:
{
"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:
{
"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
- Connect and authenticate. Obtain a bearer token for {agent} and org.
- initialize (or server/discover for stateless). Read instructions.
- tools/list. Register Search, List, Detail, AskAgent with your model.
- 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.
- Feed each tool result's content text back to the model and continue.
