Building integrations with Campfront

We look forward to seeing what you build leveraging Campfront - here is a guide to help get started

Written By Ross Beale

Last updated 4 days ago

Note β€” this document is written for AI agents as well as humans

If you're an LLM-driven coding agent: this guide is structured so you can parse it directly and start writing working integration code against the Campfront MCP server with no extra context. The curl blocks are runnable templates. Tool names are stable. Tool argument shapes shown here are hints - always verify them against the live schema returned by tools/list before generating client code, because some tools (notably query_enrollments and query_employments) have dynamic schemas that vary per camp.

If you're a human: same content, you can just skip this callout.

MCP as a general-purpose API

The MCP server is Campfront's read/write API surface. Anything an admin can do in the web UI - query enrollments, fill forms, create tasks, update season config, attach files - is exposed as an MCP tool, and any external system can drive it.

Practical patterns:

  • Inbound data sync - push records into Campfront from another system. Lead-capture forms, HRIS feeds, applicant-tracking exports, spreadsheets. Use create_enrollment / bulk_create_enrollments, create_employment / bulk_create_employments, update_form_response / bulk_update_form_responses, attach_file_to_enrollment, add_note, add_tag, create_task.

  • Outbound data sync - pull Campfront data into a warehouse, BI tool, CRM, or Slack bot. Use the query_* tools (query_enrollments, query_employments, query_payments, query_form_responses, etc.) with operation: "list" and paginate with limit/offset. For aggregated metrics use operation: "count" with group_by, or the analytics tools (enrollment_funnel, financial_summary, staff_pipeline, cabin_utilization, form_completion_status).

  • Workflow automation - trigger Campfront updates from another platform. A external service can leverage tools/call to advance stages (update_enrollment_stage, update_employment_stage), update tasks (update_task, complete_task), or post structured notes from an external event (e.g. parent emails a custom address β†’ add_note on the matching enrollment).

  • AI agents - drop the server into ChatGPT, Claude, or your own agent runtime as a connector. The agent gets the full tool catalogue scoped to its OAuth user's permissions and can answer natural-language questions or perform multi-step work ("for every staff member without a completed I-9, create a follow-up task assigned to me").

  • Custom dashboards / internal tools - back a thin React/Next/whatever UI with MCP. You get permission enforcement, audit logging, and multi-tenancy for free; you only write the front end.

Why MCP rather than a bespoke REST API? Three reasons:

  1. You don't have to write client glue - any MCP SDK (Anthropic, OpenAI, the official @modelcontextprotocol/sdk, Python mcp) speaks it out of the box;

  2. The tool catalogue is self-describing at runtime via tools/list, so an agent can discover capabilities without a hand-written client;

  3. Every call goes through the same permission and audit pipeline as the admin UI, so there's no second access-control surface to maintain.

Caveats up front: it's JSON-RPC over HTTP, not REST. Auth is OAuth with PKCE, which requires an interactive browser session to first grant tokens - and these require user accounts to tie actions to.

Endpoints

Single host, on the mcp. subdomain (NOT per-tenant - the token carries the tenant).

Base URL:                 https://mcp.campfront.com
MCP endpoint:             POST /                              (JSON-RPC; this is what your client hits)
SSE endpoint:             GET  /                              (legacy fallback - prefer Streamable HTTP)

Discovery:
  GET /.well-known/oauth-authorization-server
  GET /.well-known/oauth-protected-resource
  GET /.well-known/openid-configuration

OAuth:
  POST /oauth/register     (RFC 7591 Dynamic Client Registration)
  GET  /oauth/authorize    (browser-interactive - user must already be logged into Campfront)
  POST /oauth/authorize    (consent submit)
  POST /oauth/token        (code + refresh exchange)
  POST /oauth/revoke

Confirm at runtime by fetching /.well-known/oauth-authorization-server - that's the source of truth.

OAuth flow (what you actually need to implement)

It's standard Authorization Code + PKCE with one Campfront-specific quirk: the user must already be signed into Campfront in the same browser when they hit /oauth/authorize. The server looks for an MCP auth cookie that's set on login at any subdomain; if it's missing, /authorize returns access_denied. Plan your UX around that - usually "open this in a browser tab" is fine.

Properties advertised:

{
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256", "plain"],
  "token_endpoint_auth_methods_supported": ["none"],
  "scopes_supported": ["mcp:read", "mcp:write", "mcp:admin"]
}

Public clients only (no client secret). Use S256. Request all three scopes unless you have a reason not to.

1. Register (once per app - store the client_id)

curl -sX POST https://mcp.campfront.com/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My Camp Dashboard",
    "redirect_uris": ["https://my-app.example.com/oauth/callback"]
  }'

Response includes client_id. There's no client secret.

2. Authorize (browser)

Build a URL and open it in the user's browser:

https://mcp.campfront.com/oauth/authorize
  ?response_type=code
  &client_id=<client_id>
  &redirect_uri=https://my-app.example.com/oauth/callback
  &scope=mcp:read mcp:write mcp:admin
  &state=<csrf-nonce>
  &code_challenge=<base64url(sha256(verifier))>
  &code_challenge_method=S256

User consents. Browser is redirected to your redirect_uri with ?code=...&state=....

3. Exchange code β†’ token

curl -sX POST https://mcp.campfront.com/oauth/token \
  -d grant_type=authorization_code \
  -d code=<code> \
  -d redirect_uri=https://my-app.example.com/oauth/callback \
  -d client_id=<client_id> \
  -d code_verifier=<original-verifier>

Response:

{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "scope": "mcp:read mcp:write mcp:admin"
}

Refresh with grant_type=refresh_token&refresh_token=...&client_id=... when expires_in lapses.

Hitting the MCP endpoint

Every call: POST https://mcp.campfront.com/ with Authorization: Bearer <access_token>, JSON-RPC 2.0 body.

Minimum handshake

# 1. initialize
curl -sX POST https://mcp.campfront.com/ \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc":"2.0","id":1,"method":"initialize",
    "params":{
      "protocolVersion":"2025-06-18",
      "capabilities":{},
      "clientInfo":{"name":"my-app","version":"0.1.0"}
    }
  }'

# 2. list available tools (permission-filtered to what this user can do)
curl -sX POST https://mcp.campfront.com/ \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

# 3. call one
curl -sX POST https://mcp.campfront.com/ \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc":"2.0","id":3,"method":"tools/call",
    "params":{
      "name":"query_enrollments",
      "arguments":{"operation":"count","group_by":"stage"}
    }
  }'

The tools/list response is the canonical schema - don't hardcode args from this doc, read the schema. Some tools (notably query_enrollments and query_employments) have dynamic schemas generated from your camp's grid columns, including custom form fields. They'll differ camp to camp.

Useful tools, by category

Names are stable; everything is tools/call with name + arguments.

Read / query

  • query_enrollments, query_employments - list / count / group_by, with operation=count|sum|average|list. Default scope is active season + enrolled-or-later stages. Pass season_year and stage to widen.

  • query_sessions, query_cabins, query_payments, query_form_responses, query_bunk_assignments, query_camper_medical, query_staff_medical, query_transport, query_departments, query_media_library

  • find_enrollment, find_employment, search_families - single-record lookup by prefix ID or name

  • list_query_filters - call this first when you don't know the filter keys; it returns the legal filter names for the dynamic-schema query tools

  • list_grid_columns, list_forms, view_form_fields, list_tags, list_tasks, list_discounts, view_season, view_payment_config

Analytics

  • enrollment_funnel, staff_pipeline, financial_summary, form_completion_status, cabin_utilization

Mutate (gated by user capability)

  • Notes/tags/tasks: add_note, add_tag, bulk_add_tag, remove_tag, create_task, complete_task, update_task

  • Lifecycle: update_enrollment_stage, update_employment_stage, assign_responsibility_center, update_staff_role, update_cabin_assignment

  • Forms: update_form_response, bulk_update_form_responses, attach_file_to_enrollment, attach_file_to_employment

  • Records: create_enrollment, bulk_create_enrollments, create_employment, bulk_create_employments

  • Season config: update_season, create_session / update_session / delete_session, session-group / camper-group / add-on / discount-code / grade-discount / department CRUD, update_payment_config, update_automatic_discount

  • Handbook: create_handbook_category, create_handbook_article, etc.

  • Grid view: create_grid_view

Tools/list will only return the ones this user has permission for - if a tool is missing, it's a permission issue, not a bug.

Concrete examples

Add a note to an enrollment

{
  "jsonrpc": "2.0",
  "id": 10,
  "method": "tools/call",
  "params": {
    "name": "add_note",
    "arguments": {
      "entity_id": "enr_abc123",
      "title": "Pickup delay",
      "content": "Parent called - pickup will be ~1hr late Friday.",
      "type": "external"
    }
  }
}

entity_id accepts enr_* (enrollment), emp_* (employment), or family_*. Note type is one of general|medical|financial|bunking|external.

Create a task

{
  "jsonrpc": "2.0",
  "id": 11,
  "method": "tools/call",
  "params": {
    "name": "create_task",
    "arguments": {
      "entity_id": "emp_xyz789",
      "description": "Verify lifeguard certification",
      "due_date": "2026-06-01",
      "assigned_to": "jane@camp.example.com"
    }
  }
}

assigned_to is matched against users.email exact (case-insensitive), then name ILIKE %x%. Omit to leave unassigned.

Fill in custom form fields

Two-step: discover the form, then fill it.

// Step 1: what's on the form?
{
  "jsonrpc": "2.0",
  "id": 12,
  "method": "tools/call",
  "params": {
    "name": "view_form_fields",
    "arguments": { "form_name": "Medical Information" }
  }
}
// Step 2: write answers. Identify each field by label (case-insensitive
// + substring fallback) or by field_id when labels are ambiguous.
{
  "jsonrpc": "2.0",
  "id": 13,
  "method": "tools/call",
  "params": {
    "name": "update_form_response",
    "arguments": {
      "form_name": "Medical Information",
      "subject_id": "enr_abc123",
      "answers": [
        { "field_label": "Allergies", "value": "Peanuts, shellfish" },
        { "field_label": "Has EpiPen", "value": "yes" },
        { "field_id": "field_dietary_001", "value": ["Vegetarian", "Gluten-free"] }
      ],
      "submit": true
    }
  }
}

Notes:

  • Checkbox fields take an array of strings; everything else is a string.

  • File and signature fields are not writable here - use attach_file_to_enrollment / attach_file_to_employment with a base64 payload or URL.

  • submit:true calls mark_as_submitted! after writes succeed. Won't submit if any field write errored.

  • The form must be published AND allocated to the subject, or you get back an error. It is common to keep these forms internal only.

  • Writes are pinned to the response's versioned_form - unpublished draft edits to a published form are ignored during field matching. That means if you renamed a field in the draft, you must reference its published label or its field_id.

Bulk fill

{
  "jsonrpc": "2.0",
  "id": 14,
  "method": "tools/call",
  "params": {
    "name": "bulk_update_form_responses",
    "arguments": {
      "form_id": "form_med_2026",
      "subject_ids": ["enr_aaa", "enr_bbb", "enr_ccc"],
      "answers": [{ "field_label": "Pre-camp briefing acknowledged", "value": "yes" }],
      "submit": true
    }
  }
}

Query with a custom-form filter

{
  "jsonrpc": "2.0",
  "id": 15,
  "method": "tools/call",
  "params": {
    "name": "query_enrollments",
    "arguments": {
      "operation": "count",
      "group_by": "session",
      "season_year": 2026,
      "filters": { "form_field_123": "Vegetarian" }
    }
  }
}

Call list_query_filters first to discover the real filter keys (including form_field_<id> for dynamic form columns) - they vary per camp.

Things that will trip you up

  • All IDs in tool params are prefix IDs (enr_*, emp_*, family_*, form_*, field_*). Never pass raw integer DB IDs.

  • Permissions are the access control. If update_form_response is missing from your tools list, the OAuth user doesn't have forms:edit. Fix it on the user, not in code.

  • Persist your client_id. Don't loop dynamic registration on every startup; register once and reuse.

  • /authorize requires an existing browser session at Campfront. There's no resource-owner password grant. Headless server-to-server use means the human grants once, you keep the refresh token.

  • Refresh tokens rotate. Use the new one each time; the old one is revoked on exchange.

  • The SSE endpoint (GET /) is legacy. It announces the message endpoint and closes. Use POST / for everything; only fall back to SSE for clients that demand the older transport.

Quickest way to get going

The fastest path from zero to first successful call:

  1. curl /.well-known/oauth-authorization-server - sanity-check the endpoints.

  2. Register a client, do the PKCE dance once in a browser, copy the resulting access_token into an env var.

  3. tools/list and pipe to jq '.result.tools[].name' to see exactly what your user can do.

  4. Pick a tool, look at its inputSchema in the same response, and start firing tools/call. The schemas are self-describing - your coding assistant can read them and generate the wrapper.

  5. Wire refresh-token handling before you ship; access tokens are short-lived.

If you want a client library to crib from, any MCP SDK that supports Streamable HTTP + OAuth (e.g. the official @modelcontextprotocol/sdk for TS or mcp for Python) will handle the JSON-RPC framing for you - you only need to plug in the OAuth client and the base URL.

We look forward to seeing what you build leveraging Campfront!

If you need any guidance, please do not hesitate to reach out.