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.) withoperation: "list"and paginate withlimit/offset. For aggregated metrics useoperation: "count"withgroup_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/callto 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_noteon 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:
You don't have to write client glue - any MCP SDK (Anthropic, OpenAI, the official
@modelcontextprotocol/sdk, Pythonmcp) speaks it out of the box;The tool catalogue is self-describing at runtime via
tools/list, so an agent can discover capabilities without a hand-written client;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. Passseason_yearandstageto 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_libraryfind_enrollment,find_employment,search_families- single-record lookup by prefix ID or namelist_query_filters- call this first when you don't know the filter keys; it returns the legal filter names for the dynamic-schema query toolslist_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_taskLifecycle:
update_enrollment_stage,update_employment_stage,assign_responsibility_center,update_staff_role,update_cabin_assignmentForms:
update_form_response,bulk_update_form_responses,attach_file_to_enrollment,attach_file_to_employmentRecords:
create_enrollment,bulk_create_enrollments,create_employment,bulk_create_employmentsSeason 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_discountHandbook:
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_employmentwith a base64 payload or URL.submit:truecallsmark_as_submitted!after writes succeed. Won't submit if any field write errored.The form must be
publishedANDallocatedto 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 itsfield_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_responseis missing from your tools list, the OAuth user doesn't haveforms: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./authorizerequires 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:
curl /.well-known/oauth-authorization-server- sanity-check the endpoints.Register a client, do the PKCE dance once in a browser, copy the resulting
access_tokeninto an env var.tools/listand pipe tojq '.result.tools[].name'to see exactly what your user can do.Pick a tool, look at its
inputSchemain the same response, and start firingtools/call. The schemas are self-describing - your coding assistant can read them and generate the wrapper.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.