# Scolavo MCP Integration Specification

**Official integration reference for licensed partners.**
Audience: your engineering team building against the Scolavo Model Context Protocol (MCP) server.

| | |
|---|---|
| **MCP endpoint** | `https://mcp.scolavo.com/mcp` |
| **Transport** | JSON-RPC 2.0 over HTTPS, Model Context Protocol, **stateless JSON mode, POST only** |
| **Token endpoint (M2M)** | `https://scolavo-auth.auth.us-east-1.amazoncognito.com/oauth2/token` |
| **OAuth flows** | `client_credentials` (machine-to-machine); standard OAuth discovery (interactive hosts) |
| **Access-token lifetime** | 15 minutes |
| **Tools** | 26 — 25 read-only, plus the opt-in `record_progress` writer (§7 Model A) |
| **Corpus** | 3,680 class-lessons · 125 subjects · 5 tiers |
| **Support / licensing** | `licensing@scolavo.com` |

---

## 1. Overview

The Scolavo MCP server exposes Scolavo's curriculum as a **read-only, organization-scoped, multi-tenant content-licensing surface** over the Model Context Protocol. A licensed partner's AI host (Claude, ChatGPT, Cursor) or backend service connects, authenticates as the **organization**, and calls tools to discover subjects, read lesson content, mint media URLs, export bundles, and pull test-prep items — all confined to the content your license grants.

### Corpus scale

- **3,680 class-lessons** across **125 subjects** and **5 tiers**: Primary, Middle, High School, College, and Topics.
- Every response is served from a single immutable corpus snapshot identified by a `contentVersion` string.

### Design principles

- **Read-only by default.** 25 of the 26 tools are `readOnlyHint: true`, `idempotentHint: true`, `openWorldHint: false`. The one exception is `record_progress` (`readOnlyHint: false`), the opt-in learner-progress writer (§7 Model A) — it writes only that learner's own progress row. The server never mutates curriculum and stores no caller-supplied content.
- **Organization-scoped, multi-tenant-safe.** The token identifies a licensee org. Every response is filtered to that org's content grants; unlicensed subjects never leak — not through catalog, search, learning paths, or deep-research.
- **Stateless (entitlements).** No org/entitlement state is cached across calls. Every call re-resolves the org fresh, so a suspend or revoke takes effect on the **very next call**. Whether the server stores **learner** progress depends on the personalization model you choose (§7) — the default read-only surface stores none.
- **Denials are data, not failures.** Business denials (quota, entitlement, unknown id) are returned as `isError: true` tool results carrying a `code`, `message`, and often a `hint` — **never** as HTTP errors. A `429`/`403` on `POST /mcp` reads as a broken server to a host and can get a connector auto-disabled, so the server refuses to do that.
- **The model shouldn't have to guess.** Enums (tiers, subjectSlugs, test ids, section ids) and ID-format hints are built from the live corpus and ride in the `tools/list` schema. IDs are always handed back ready to use (`lessonId`, `mediaId`) so a model never constructs them by hand.
- **Licensed provenance on every payload.** Nearly every response carries a `_license` envelope and `contentVersion`; export bundles additionally carry in-body canaries and per-class `sha256` for tamper evidence.

---

## 2. Connecting

There are four connection paths. All of them terminate at the same endpoint (`https://mcp.scolavo.com/mcp`) and all resolve to an OAuth access token bearing at least one `scolavo-mcp/*` scope.

### Required request shape (all paths)

```
POST https://mcp.scolavo.com/mcp
Authorization: Bearer <access_token>
Content-Type: application/json
Accept: application/json
```

- **POST only.** Stateless JSON mode — no SSE stream, no session cookies.
- The body is standard JSON-RPC 2.0 MCP (`initialize`, `tools/list`, `tools/call`, …).

### Path A — Claude (interactive)

Add Scolavo as a **custom connector** using the endpoint `https://mcp.scolavo.com/mcp`. Claude performs standard OAuth discovery against the resource, the user signs in, and Claude obtains a scoped token. **No API key is placed in the client.**

### Path B — ChatGPT (interactive, deep research)

Add Scolavo as a connector/custom action pointed at the same endpoint. ChatGPT's deep-research pipeline uses the two verbatim-contract tools `search` and `fetch` (see §5). OAuth discovery + user sign-in as above.

### Path C — Cursor (interactive)

Register the MCP server URL in Cursor's MCP configuration. Cursor runs the same OAuth discovery/sign-in and obtains a scoped token.

### OAuth discovery (Paths A–C)

Interactive hosts discover the authorization server automatically from the protected resource:

```
GET https://mcp.scolavo.com/.well-known/oauth-protected-resource
GET https://mcp.scolavo.com/.well-known/oauth-authorization-server
```

These documents advertise the authorization server, the token endpoint, and the supported scopes (prefixed `scolavo-mcp/`). Hosts sign the user in and manage token refresh themselves — **you do not embed a secret in an interactive client.**

### Path D — Machine-to-machine (`client_credentials`)

For backend services (your LMS, sync jobs, batch exporters), request a token directly with your issued client credentials:

```bash
# 1) Get an access token and capture it (client_credentials).
#    Note --data-urlencode for the space-separated scope list.
ACCESS_TOKEN=$(curl -s -X POST \
  https://scolavo-auth.auth.us-east-1.amazoncognito.com/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  --data-urlencode "scope=scolavo-mcp/content.read scolavo-mcp/content.export" \
  | jq -r .access_token)
```

The raw token response looks like (the `jq` above extracts `access_token` into `$ACCESS_TOKEN`):

```json
{
  "access_token": "eyJraWQiO...",
  "token_type": "Bearer",
  "expires_in": 900
}
```

Then call the MCP endpoint with that token:

```bash
# 2) Call a tool with the captured token.
curl -s -X POST https://mcp.scolavo.com/mcp \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": { "name": "get_license_status", "arguments": {} }
  }'
```

### Token facts (all paths)

- **Lifetime: 15 minutes** (`expires_in: 900`). Request a fresh token before expiry; do not assume long-lived tokens.
- The token must be a Cognito **access** token (`token_use === "access"`), unexpired (60s clock-skew leeway), issued by the Scolavo issuer, and carry **at least one** `scolavo-mcp/*` scope.
- The token's `client_id` must be the shared public client (interactive) or a non-revoked M2M client in Scolavo's client registry. Revoking a client in that registry is the **M2M kill switch** and takes effect on the next call.
- Request only the scopes you need in the `scope` parameter (§3).

---

## 3. Authorization model & scopes

### The authenticated party is the ORGANIZATION

The token authenticates the **licensee organization**, not an individual learner. There is no per-student identity anywhere in the protocol (see §7 for what that means for progress data).

### The content scopes

Scopes are declared at the discovery endpoints and, on the wire, are prefixed **`scolavo-mcp/`** (e.g. `scolavo-mcp/content.read`). They are enforced **per-tool at the HTTP layer** — before the request reaches the MCP transport — via a static tool→scope map.

| Scope | Grants | Tool families |
|---|---|---|
| `content.read` | Read catalog, discovery, lessons, transcripts, quizzes, learning paths, concepts, test-prep catalog, license status, and the deep-research `search`/`fetch` | 20 of 26 tools — the broad default scope |
| `content.export` | Bulk-download stamped bundles | `export_class`, `export_subject` |
| `media.download` | Mint a single signed media URL | `get_download_url` |
| `testprep.read` | Fetch practice **items** (the catalog itself is `content.read`) | `get_testprep_items` |
| `content.download` | *Reserved.* Declared in the discovery documents but **not currently assigned to any tool.** Do not depend on it. | — |

> OIDC scopes `openid`, `email`, `profile` are also accepted for **identity** during interactive sign-in. They convey no content authorization.

> Two further scopes — **`progress.read`** and **`progress.write`** — exist for partners who enable **Scolavo-hosted progress** (§7, Model A). They gate the learner-scoped progress tools (§5 Group G), are **not** part of the default content surface, and are provisioned per license.

### Exact scope → tool map

| Scope | Tools |
|---|---|
| `content.read` | `list_tiers`, `list_subjects`, `get_catalog`, `get_subject_outline`, `search_content`, `search_subjects`, `get_learning_path`, `get_concept`, `get_next_lesson`, `get_prev_lesson`, `get_lesson`, `get_lesson_context`, `get_lesson_summary`, `get_lesson_transcript`, `get_quiz`, `list_lesson_media`, `list_testprep_catalog`, `get_license_status`, `search`, `fetch` |
| `content.export` | `export_class`, `export_subject` |
| `media.download` | `get_download_url` |
| `testprep.read` | `get_testprep_items` |
| `progress.read` | `get_progress` *(opt-in, §7 Model A)* |
| `progress.write` | `record_progress` *(opt-in, §7 Model A)* |

### Org grants

Beyond scopes (what **actions** are allowed), each org has **content grants** (what **content** is licensed). A grant is either an entire **tier** (`"college"`) or a single **subject**, written tier-qualified as `"{tier}/{subjectSlug}"` (e.g. `"topics/ai-and-llms"`). The tier prefix keeps a grant unambiguous and self-describing in the grants list even though subjectSlugs are already globally unique. All catalog/search/path/lesson responses are filtered to these grants. Grants are visible any time via `get_license_status`.

### How denials surface

Two distinct failure surfaces — know which is which:

1. **Missing OAuth scope** → HTTP **`403 insufficient_scope`** with a `WWW-Authenticate` header naming the exact scope required. This is the *only* case the server answers with an HTTP error, and it happens at the transport edge before the tool runs. Fix: request a token carrying the named scope.

2. **Everything else** (org not licensed, suspended, expired, tier/subject not granted, quota hit, unknown id) → a normal `200` JSON-RPC response whose tool result is **`isError: true`** with a structured `{ code, message, hint? }` body (§8). These are **business errors, never HTTP errors.** An AI host should read the `code` and react; it must not treat them as a broken server.

---

## 4. Data model

### Tiers

Five tiers, with a fixed canonical ordering used by `list_*`/`get_catalog` responses:

`topics` → `college` → `high` → `middle` → `primary`

| tier value | label |
|---|---|
| `primary` | Primary School |
| `middle` | Middle School |
| `high` | High School |
| `college` | College |
| `topics` | Topics |

### subjectSlug

A globally unique, lowercase, hyphenated identifier for a subject (e.g. `hs-science-chemistry`, `art`, `ai-and-llms`). **subjectSlugs are unique across all tiers.** Always use the exact slug from `list_subjects` / `get_catalog` / `get_subject_outline` — not the display name. (The server tolerates a bare name like `"chemistry"` only when it resolves **uniquely**; anything ambiguous returns `UNKNOWN_SUBJECT` with candidates.)

### lessonId

Format: **`{tier}/{subjectSlug}/{classNum}`** — e.g. `high/hs-science-chemistry/1`.

Regex enforced server-side:

```
^(primary|middle|high|college|topics)/([a-z0-9-]+)/([1-9]\d{0,2})$
```

`classNum` is 1-based. **Do not construct lessonIds by hand** — take them from `get_subject_outline` (each class carries its ready `lessonId`) or from any `search`/`search_content` hit. A malformed id returns `BAD_LESSON_ID`.

### card / quiz / media ids

- **Card id** — each lesson card has a stable `id` (e.g. `pitfalls`, `recap`). Key intra-lesson state to these.
- **Quiz** — an ordered array; each item has `q`, `options[]`, and an `answer` index. `inlineChecks` are mid-lesson checks carrying hints + rationale.
- **mediaId** — format **`{lessonId}#{assetId}`**, returned by `list_lesson_media`. Pass it verbatim to `get_download_url`; do not build it by hand.

These ids are **stable identifiers** — the anchors partners hang their own learner state on (§7).

### contentVersion

A string naming the corpus snapshot. It rides on nearly every response and is embedded inside pagination cursors. Use it for **change detection**: if `contentVersion` differs from the value you last stored, treat cached ids/content as potentially stale and re-validate (§9).

### The `_license` envelope

Attached to nearly every content response. It stamps org / ECLA / license-tag provenance onto the payload. Export bundles additionally embed **canaries** and a per-class **`sha256`** in the manifest for tamper evidence. Do not strip `_license` from stored content — it is the license provenance record.

> **Contract exception:** the ChatGPT deep-research `search` and `fetch` tools return **verbatim, fixed shapes with no extra keys** — they carry **no** `_license` and **no** top-level `contentVersion` (`fetch` surfaces `contentVersion` only inside its flat `metadata` string map). This is deliberate; strict connector clients reject unexpected keys.

### Pagination

Paged tools (`list_subjects`, `search_content`) return **25 items/page**; `get_testprep_items` pages at a caller-chosen `count` (1–100, default 25). All of them return the same explicit metadata so a model never under-paginates:

| Field | Meaning |
|---|---|
| `total` | total items across all pages |
| `pageNum` | current page (1-based) |
| `totalPages` | total page count |
| `hasMore` | `true` if another page exists |
| `nextCursor` | opaque cursor for the next page (present only when `hasMore`) |

Pass `nextCursor` back as the `cursor` argument to advance. The cursor is `base64url({offset, contentVersion})` — it **embeds the corpus version**, so if the corpus is rebuilt mid-pagination the cursor becomes invalid and the call returns a transport-level **`InvalidParams: "cursor expired"`** (an MCP protocol error, *not* a `BizError`). React by restarting pagination from page 1.

---

## 5. Tool reference

All 26 tools. Inputs marked **required** have no default. Unless noted, every response also carries `contentVersion` and `_license`.

### Group A — Catalog & Discovery (`content.read`)

#### `list_tiers`
- **Purpose:** Curriculum tiers with a subject count each (grant-scoped). Lightest overview call.
- **Inputs:** none.
- **Returns:** `tiers[{tier, label, count}]`, `total`.
- **When to use:** First glance at what the org licenses before drilling in.

#### `list_subjects`
- **Purpose:** Paginated (25/page) list of subjects across all tiers, grant-scoped, optionally filtered by tier, sorted by tier.
- **Inputs:** `tier?` (`primary|middle|high|college|topics`), `cursor?` (opaque, from a prior `nextCursor`).
- **Returns:** `total, pageNum, totalPages, hasMore, subjects[{subjectSlug, tier, name, level, classes, coverage, difficulty?, tags?, prerequisites?, relatedSubjects?}], nextCursor?`.
- **When to use:** Enumerate subjects one tier or one page at a time.

#### `get_catalog`
- **Purpose:** The **entire** licensed catalog in one call, grouped by tier, metadata only (no lesson bodies).
- **Inputs:** none.
- **Returns:** `total, byTier: { tier: [{subjectSlug, name, tier, level, classes, difficulty?, tags?}] }`.
- **When to use:** You want the whole picture at once instead of paging `list_subjects`.

#### `get_subject_outline`
- **Purpose:** Ordered class list for one subject; each class carries a ready-to-use `lessonId` and availability flags.
- **Inputs:** `subjectSlug` **(required, enum)**.
- **Returns:** `subjectSlug, tier, difficulty?, tags?, prerequisites?, relatedSubjects?, classes[{lessonId, classNum, title, hasVideo, hasNarration, hasTranscript, …}]`.
- **When to use:** Step 2 of the core workflow — get lessonIds to pass to `get_lesson`/`get_quiz`/`list_lesson_media`.

#### `search_content`
- **Purpose:** Full-text search over lesson **titles, subtitles, card labels, key-point bullets, and quiz questions** (not full body text). Grant-scoped, paginated.
- **Inputs:** `query` **(required, min 1 char)**, `tier?`, `subject?` (subjectSlug enum), `cursor?`.
- **Returns:** `total, pageNum, totalPages, hasMore, hits[{lessonId, title, snippet, matchedField, url}], nextCursor?`.
- **When to use:** Find individual lessons matching a term.

#### `search_subjects`
- **Purpose:** Find which **subjects** cover a concept (aggregated from the lesson index), ranked by number of matching classes.
- **Inputs:** `query` **(required, min 1 char)**.
- **Returns:** `query, results[{subjectSlug, tier, name, relevance: high|medium|low, matchedIn: ["class N", …]}]`.
- **When to use:** "Which subjects teach X?" — then use `search_content` for the individual lessons.

#### `get_learning_path`
- **Purpose:** Ordered study sequence to reach a **goal** subject — prerequisites walked transitively (cycle-safe, post-order) then the goal, each node with difficulty, class count, estimated hours, and a `licensed` flag.
- **Inputs:** `subjectSlug` **(required, enum — the GOAL subject)**.
- **Returns:** `goal, path[{subjectSlug, tier, name, difficulty?, classes, estimatedHours, licensed, isGoal? | reason?}], totalClasses, totalHours, relatedSubjects`.
- **When to use:** Sequence a curriculum toward a target; check `licensed` per node to see what's inside the org's grants.

#### `get_concept`
- **Purpose:** Trace a concept across the curriculum — every lesson where it appears (grant-scoped), which subjects teach it, prerequisite subjects, and subjects it leads to (reverse-prerequisite edges).
- **Inputs:** `concept` **(required, min 1 char)**.
- **Returns:** `concept, appearances[{lessonId, subjectSlug, tier, classNum, title, url}], subjects[{subjectSlug, name}], prerequisites[…], leadsTo[…]`.
- **When to use:** "Where is X taught / what do I need first / where does it lead."

### Group B — Lesson Content (`content.read`)

#### `get_lesson`
- **Purpose:** One class-lesson as a **lean text projection** — cards (body, bullets, availability flags, concept-level `illustrationAlt`) plus server-synthesized `estimatedMinutes` and `keyTerms`. Extra sections are opt-in.
- **Inputs:** `lessonId` **(required)**, `include?` (array of `quiz | interactives | inlineChecks | narrationText | videoTranscript | media | workedExample`).
- **Returns:** `lessonId, tier, subject?, classNum, title?, url?, estimatedMinutes, keyTerms[], analogies?[], cards[{id, label, body, bullets, hasVideo, hasNarration, hasTranscript, hasIllustration, illustrationAlt?, narrationText?, media?}]` (+ `quiz`/`inlineChecks`/`interactives`/`workedExample` when included).
- **Media:** `include:["media"]` returns each asset as **`{mediaId, type, url: null}`** — the same shape `list_lesson_media` returns. Asset URLs are **never** served on a `content.read` path; mint a short-lived one per asset with `get_download_url` (scope `media.download`, metered). The one place raw URLs appear is inside an export bundle (`content.export`, §5 Group C).
- **When to use:** Step 3 — read one lesson's teaching text. Counts toward `lessonreads` velocity + the enumeration alarm.

#### `get_lesson_context`
- **Purpose:** **AI-tutor bundle** — one call returns everything to teach one class: full card bodies + `illustrationAlt`, `keyTerms`, `estimatedMinutes`, `quiz`, `inlineChecks` (hints + rationale), `interactives`, worked example, `commonMistakes` text (from the "pitfalls" card), and adjacent prev/next lesson stubs.
- **Inputs:** `lessonId` **(required)**.
- **Returns:** `lessonId, title?, estimatedMinutes, keyTerms[], analogies?[], cards[], quiz[], inlineChecks[], commonMistakes?, adjacent: {prev, next}`.
- **When to use:** Instead of chaining `get_lesson` + `get_quiz` + navigation. Same metering as `get_lesson`, and the same media rule (cards carry `{mediaId, type, url: null}` — mint via `get_download_url`).

#### `get_lesson_summary`
- **Purpose:** Lightweight review summary without the full lesson.
- **Inputs:** `lessonId` **(required)**.
- **Returns:** `lessonId, title?, keyPoints[], keyTerms[], quiz[], estimatedMinutes`.
- **When to use:** Spaced review / quick recap — a smaller payload than `get_lesson`, but it reads the whole lesson server-side, so it carries the same controls (`lessonreads` velocity + the enumeration alarm).

#### `get_lesson_transcript`
- **Purpose:** The **full spoken transcript** of one class — the cold-open narration, each card's narration (the distinct `narrationText` script where it exists, otherwise the card body, which is exactly the text that was narrated to audio), and each animated clip's spoken line (`videoTranscript`).
- **Inputs:** `lessonId` **(required)**.
- **Returns:** `lessonId, title?, transcript` (ready-to-read markdown), `cards[{id, label, narration?, videoTranscript?}]` (only cards that carry words), `hasNarration, hasVideoTranscript`.
- **When to use:** Captions, study notes, or an audio script. Not every clip has spoken words (some are wordless B-roll). Reads a whole lesson, so it carries the same controls as `get_lesson` (`lessonreads` velocity + the enumeration alarm).

#### `get_quiz`
- **Purpose:** End-of-class quiz (`q`/`options`/`answer` index) plus mid-lesson `inlineChecks` (hints + rationale).
- **Inputs:** `lessonId` **(required)**.
- **Returns:** `quiz[], inlineChecks[]`.
- **When to use:** Just the assessment items for a lesson.

#### `get_next_lesson`
- **Purpose:** Metadata for the **next** class in the same subject (`lesson: null` at the end).
- **Inputs:** `lessonId` **(required)**.
- **Returns:** `lesson: {lessonId, classNum, title, hasVideo, hasNarration, hasTranscript} | null`.
- **When to use:** Advance a learner without guessing the next class number.

#### `get_prev_lesson`
- **Purpose:** Metadata for the **previous** class in the same subject (`lesson: null` at the start).
- **Inputs:** `lessonId` **(required)**.
- **Returns:** same shape as `get_next_lesson`.
- **When to use:** Step a learner backward.

### Group C — Media & Export (`media.download` / `content.export`)

#### `list_lesson_media`
- **Purpose:** List a lesson's media assets as typed IDs — **no URLs** (mint per asset via `get_download_url`). Scope: `content.read`.
- **Inputs:** `lessonId` **(required)**, `type?` (`video|audio|image`).
- **Returns:** `items[{mediaId, type, url: null}], note`.
- **When to use:** Discover a lesson's media before minting a URL.

#### `get_download_url`
- **Purpose:** Mint a short-lived signed URL for **one** media asset previously listed. Scope: `media.download`.
- **Inputs:** `mediaId` **(required, `{lessonId}#{assetId}`)**, `expiresIn?` (60–900s; up to 3600 for orgs with the `longUrls` override; default 900).
- **Returns:** `url, expiresAt`.
- **When to use:** Fetch a single playable/downloadable asset. Metered: monthly `downloads` + hourly `mints`. **Ledgered** (durable export ledger).

#### `export_class`
- **Purpose:** Download one whole class as a bundle — stamped lesson JSON + transcript (when narrated) + media manifest + `LICENSE.txt`. Scope: `content.export`.
- **Inputs:** `lessonId` **(required)**, `format?` (`zip_url` default | `inline` = gzip+base64 for pipelines).
- **Returns:** `format, url?, expiresAt?, encoding?, data?, classCount`.
- **When to use:** Ingest a single class into your own store. Metered: 1 `download` + 1 `export`. **Ledgered.**

#### `export_subject`
- **Purpose:** Download an **entire subject/Topic** (all classes) as a stamped zip bundle. Scope: `content.export`.
- **Inputs:** `subjectSlug` **(required, enum)**, `include?` (array of the same six section flags), `format?` (`zip_url` default | `inline`).
- **Returns:** `format, url?, expiresAt?, encoding?, data?, classCount, subjectSlug`.
- **When to use:** Bulk-ingest a subject. Metered: **one `download` per class** + 1 `export` — **call `get_license_status` first** to confirm quota. `inline` is for pipeline clients only (it will swamp a chat context). **Ledgered.**

### Group D — Test Prep

#### `list_testprep_catalog` (`content.read`)
- **Purpose:** The four Scolavo practice tests (Digital SAT, ACT, PSAT/NMSQT, CAASPP) with sections and item counts. All items are **original Scolavo-authored** — not actual exam questions.
- **Inputs:** none.
- **Returns:** `tests[{test, name, sections:[{id, name, items}], items}], note`.
- **When to use:** Discover tests/sections before pulling items.

#### `get_testprep_items` (`testprep.read`)
- **Purpose:** A **page** of practice items for one test (optionally one section).
- **Inputs:** `test` **(required, enum from the catalog — e.g. `digital-sat`, `act`, `psat-nmsqt`, `caaspp`)**, `section?` (a section id **belonging to that test** — e.g. `rw`/`math` for SAT/PSAT, `english`/`reading`/`science` for ACT, `ela`/`math`/`science` for CAASPP), `count?` (**page size**, 1–100, default 25), `cursor?` (opaque, from a prior `nextCursor`).
- **Returns:** `test, section?, items[], total, pageNum, totalPages, hasMore, nextCursor?`.
- **When to use:** Pull practice items. Note the **distinct scope** (`testprep.read`) vs. the catalog's `content.read`.
- **Paging:** pass `nextCursor` back as `cursor` to walk the bank. The cursor carries the **offset**, so changing `count` mid-walk keeps the item stream gap-free and non-overlapping (`pageNum`/`totalPages` are always relative to the `count` of the *current* call).
- **Metering:** every item served increments the monthly **`testprepitems`** meter, and each call counts toward the hourly `lessonreads` cap (§9). Page what you need — the whole bank is reachable, not free. If a contract sets `testprepItemsPerMonth` and `count` exceeds what is left this month, the page is **shortened** to the remaining allowance (`hasMore`/`nextCursor` as usual) rather than refused; `QUOTA_EXCEEDED` means the allowance is genuinely spent.
- **Errors:** an unknown `test` → `UNKNOWN_TEST`; a section id that belongs to a *different* test → `UNKNOWN_SECTION` (it names that test's valid `sections[]`) rather than a silently empty page.

### Group E — Account (`content.read`)

#### `get_license_status`
- **Purpose:** The org's tier, status, content grants, remaining quota, ECLA version, and corpus version. **Unmetered and not entitlement-gated** — a suspended or pre-ECLA org can still call it to see *why* it's blocked.
- **Inputs:** none.
- **Returns:** `orgId, orgName?, tier?, status?, contentGrants[], quota:{calls:{used,limit}, downloads:{used,limit}, exports:{used,limit}, testprepitems:{used,limit}}, resetsAt, eclaVersion?, requiredEclaVersion, renewsAt?, contentVersion, licenseUrl`. One entry per monthly meter (§9); `limit: null` means the meter is telemetry-only on your contract.
- **When to use:** Any time — health check, quota check before a large export, or to diagnose a denial.

### Group F — ChatGPT Deep-Research contract (`content.read`)

These two tools have **fixed response shapes mandated by the ChatGPT connector spec. No extra keys** — no `_license`, no top-level `contentVersion`.

#### `search`
- **Purpose:** Search licensed curriculum for the deep-research pipeline.
- **Inputs:** `query` **(required, min 1 char)**.
- **Returns:** `results[{id, title, url}]` — `id` is a `lessonId`; deduped, grant-scoped, capped at 25.
- **When to use:** ChatGPT deep research only. Pass an `id` to `fetch`.

#### `fetch`
- **Purpose:** Full lesson text by id, rendered as markdown-ish plain text, with a scolavo.com citation URL.
- **Inputs:** `id` **(required, a `lessonId` from `search`)**.
- **Returns:** `id, title?, text (capped ~40KB), url?, metadata` — where `metadata` is a **flat string map** (`{tier, subject, subjectSlug, classNum, contentVersion, license}`, all string values; strict clients reject nested/non-string values).
- **When to use:** ChatGPT deep research only. Serves full lesson text, so it counts toward `lessonreads` velocity + the enumeration alarm.

### Group G — Learner progress (`progress.read` / `progress.write`)

**Opt-in, provisioned per license** (§7 Model A) and **not** part of the default content surface. Both need a **learner-scoped** token from an interactive sign-in — a `client_credentials` (M2M) token carries no learner and gets `LEARNER_REQUIRED`. Neither requires a per-subject content grant: this is the learner's own record, not curriculum.

#### `get_progress` (`progress.read`)
- **Purpose:** The signed-in learner's XP, streak, and per-lesson completion/score — read from the **same record scolavo.com shows**.
- **Inputs:** `learnerId?` (learner profile under the account, default `"l1"`), `subjectSlug?` (enum — return only this subject's lessons).
- **Returns:** `learnerId, xp, streak, completedLessons, masteredLessons, lastActive, lessons[{lessonId, subjectSlug, classNum, pct, level, at}]` — newest first. A lesson record is written **once, at completion**, so every lesson that appears is at least `Completed`; `level` is `Mastered` when `pct ≥ 80` and `Completed` otherwise (including `pct: 0` and `pct: null`).
- **When to use:** Resume a learner where they left off, or report on what they've finished.

#### `record_progress` (`progress.write`) — **the one write tool**
- **Purpose:** Record that the signed-in learner completed a class, optionally with a quiz score. Writes the same record scolavo.com reads, so it lands on the learner's dashboard/heatmap.
- **Inputs:** `lessonId` **(required)**, `score?` (0–100 — omit to mark simply completed), `learnerId?` (default `"l1"`).
- **Returns:** `lessonId, learnerId, recorded: {pct, level, at}`.
- **Semantics:** recording a lesson means the class was **finished**; the score only decides whether it *also* counts as mastered. So `score ≥ 80` → `Mastered`, and **every other case** — a lower score, a score of exactly `0`, or an omitted score — → `Completed`. This is byte-for-byte the rule scolavo.com applies to the same stored row, so the site and the MCP never disagree about a lesson's level. It writes **only the caller's own** progress, and does **not** set XP / streak / skill mastery — the site owns those. The write runs under optimistic concurrency against the live site; if it loses every retry, nothing is saved and you get `WRITE_CONFLICT` (retryable, §8).
- **When to use:** Your host taught the class through the MCP and you want it reflected on scolavo.com.

### Recommended call workflows

**1. Discovery → outline → lesson (the core path)**
```
list_tiers → list_subjects(tier) → get_subject_outline(subjectSlug)
          → get_lesson(lessonId)  |  get_quiz(lessonId)  |  list_lesson_media(lessonId)
```
(Substitute `get_catalog` for `list_tiers + list_subjects` when you want everything at once.)

**2. AI-tutor bundle (teach one class in one call)**
```
get_subject_outline(subjectSlug) → get_lesson_context(lessonId)
```
One call returns cards, keyTerms, quiz, inlineChecks, interactives, worked example, commonMistakes, and prev/next.

**3. Learning path (sequence toward a goal)**
```
get_learning_path(goalSubjectSlug) → for each licensed node: get_subject_outline → get_lesson_context
```

**4. Concept lookup (where is X?)**
```
search_subjects(concept)  →  get_concept(concept)  →  search_content(query) → get_lesson(lessonId)
```

**5. Media / export ingestion**
```
list_lesson_media(lessonId) → get_download_url(mediaId)          # single asset
get_license_status() → export_subject(subjectSlug)               # bulk, quota-checked
```

---

## 6. Metadata & AI-tutor features

Two kinds of enrichment ride on responses: **authored subject metadata** (curated, stored per subject) and **request-time synthesized lesson fields** (computed on the fly, free, no corpus change needed).

### Authored subject metadata

Merged into catalog/outline/path responses where present:

| Field | Shape | Appears in |
|---|---|---|
| `difficulty` | `{ level: 1–5, label }` | `list_subjects`, `get_catalog`, `get_subject_outline`, `get_learning_path` nodes |
| `tags` | `string[]` | `list_subjects`, `get_catalog`, `get_subject_outline` |
| `prerequisites` | `[{ subjectSlug, tier, reason }]` | `list_subjects`, `get_subject_outline`; drives `get_learning_path` and `get_concept` |
| `relatedSubjects` | `[…]` | `list_subjects`, `get_subject_outline`, `get_learning_path` |
| `analogies` (per-lesson) | `[{ concept, analogy }]`, keyed to `classNum` | `get_lesson` / `get_lesson_context` (as `analogies[]`, only for the matching class) |

### Request-time synthesized lesson fields

Computed from the lesson at request time:

| Field | What it is | Where |
|---|---|---|
| `estimatedMinutes` | Study-time estimate (reading @ ~200 wpm + ~1 min/video card + 0.5 min/quiz-or-check), min 1 | `get_lesson`, `get_lesson_context`, `get_lesson_summary` |
| `keyTerms` | Up to 20 `{ term, definition, firstAppearsInCard }` — a **bold** term + the sentence that defines it | `get_lesson`, `get_lesson_context`, `get_lesson_summary` |
| `illustrationAlt` | Concept-level alt text per card (only where a card `hasIllustration`) | `get_lesson`, `get_lesson_context` cards |
| `commonMistakes` | The "pitfalls" card body surfaced as a misconceptions string | `get_lesson_context` only |

### AI-tutor tool cheat-sheet

| Need | Tool |
|---|---|
| Everything to teach one class in one call | `get_lesson_context` |
| Lean lesson body, opt-in extras | `get_lesson` |
| Review/recap without the full lesson | `get_lesson_summary` |
| Ordered path to a goal subject | `get_learning_path` |
| Where a concept lives + what leads to it | `get_concept` |
| Just the assessment | `get_quiz` |

---

## 7. Learner progress & personalization

Scolavo supports **two models** for where learner progress lives. Which one applies is set **per partner at licensing time** — talk to us about the right fit.

The whole choice turns on one fact about the token: the MCP bearer token is a **standard signed JWT (a Cognito access token)** in the `Authorization` header. On the **interactive** paths (A/B/C) the learner signs in through Scolavo's OAuth (email or Google), so the token carries **both the organization and the learner identity** (the learner's `sub`). A **machine-to-machine** (`client_credentials`) token carries **only the org** — no learner (`sub` is null).

### Model A — Scolavo-hosted progress *(recommended when learners use Scolavo identity)*

Scolavo records progress against the learner in the **same backend that powers scolavo.com dashboards** — no separate store, and nothing for the partner to build.

- **How it works:** learner-scoped interactive token → the server reads the learner `sub` from the JWT → progress reads/writes are keyed to that learner in Scolavo's existing progress store. Because it is the **same identity namespace** as scolavo.com, progress made through the MCP and progress made on the site are the **same record**.
- **What the partner does:** nothing beyond the standard OAuth sign-in the interactive hosts already perform. Pass the learner's token on each MCP call.
- **Progress tools:** `get_progress` (**`progress.read`**) and `record_progress` (**`progress.write`**) — documented in §5 Group G. They are **provisioned per license** and are **not** part of the default content surface; enable them for your org via `licensing@scolavo.com`.
- **M2M note:** progress tools require a **learner-scoped** token. A `client_credentials` token (no `sub`) cannot call them.
- **Data note:** in this model Scolavo is the **system of record for learner progress** and acts as a **data processor** for that progress on your behalf, under your DPA. No student PII beyond progress is collected through the connector.

### Model B — Partner-owned progress *(for partners with their own identity / LMS, or data-sovereignty needs)*

Keep progress entirely on your side and use the MCP purely as a content pipe:

- Store `(learnerId, lessonId, state)` in **your** system, keyed to Scolavo's **stable** `lessonId` and card/quiz ids.
- Use `get_lesson_summary` / `get_lesson_context` for review; `get_next_lesson` / `get_learning_path` to sequence; compute due-for-review and mastery yourself.
- Persist `contentVersion` alongside each record and re-validate ids on a version change (§9).
- The server stays **fully read-only** and stores **no learner state** — no student data reaches Scolavo through the connector.

### Choosing between them

| | Model A — Scolavo-hosted | Model B — Partner-owned |
|---|---|---|
| Learner identity | Scolavo (OAuth sign-in) | Yours (SSO / LMS) |
| Progress store | Scolavo's existing backend | Yours |
| Partner build effort | Minimal | You own the store |
| Student data at Scolavo | Progress only (as processor) | None |
| Works with M2M tokens | No — needs a learner token | Yes |
| Same record as scolavo.com | Yes | No |

Most partners whose learners already use Scolavo identity choose **Model A** for the turnkey path; partners bringing their own identity or requiring data sovereignty choose **Model B**.

---

## 8. Errors

Business denials are returned as **`isError: true` tool results** with a JSON body `{ code, message?, hint?, …extra }`. They arrive on a normal HTTP `200` — **never** as an HTTP error. (The sole HTTP-level error is `403 insufficient_scope` for a missing OAuth scope, §3.)

| Code | Meaning / trigger | Extra fields | How an AI host should react |
|---|---|---|---|
| `NO_ORGANIZATION` | The identity isn't bound to any licensed org | `message` | Stop; the credential isn't licensed. Surface "contact `licensing@scolavo.com`". Do not retry. |
| `ORG_SUSPENDED` | Org is not `active`, or was just auto-suspended by the enumeration alarm | `status`, `reason?` (`"enumeration"`) | Stop all calls. If `reason:"enumeration"`, you tripped anti-scraping — a human must reinstate. Do not retry. |
| `LICENSE_EXPIRED` | `renewsAt` is in the past | `renewsAt` | Stop; license lapsed. Prompt renewal via `licensing@scolavo.com`. Do not retry. |
| `LICENSE_REQUIRED` | Accepted `eclaVersion` is below the required version | `requiredEclaVersion`, `eclaVersion` | Stop; the org must accept the current ECLA. Surface `requiredEclaVersion` and the `licenseUrl` from `get_license_status`. |
| `TIER_NOT_LICENSED` | The requested tier isn't in the org's grants at all | `requiredTier` | Don't retry that tier. Steer to a licensed tier (list grants via `get_license_status`). |
| `CONTENT_NOT_GRANTED` | Tier is licensed but this specific subject isn't | `grant` | Don't retry that subject. Pick a granted subject. |
| `UNKNOWN_SUBJECT` | `subjectSlug` doesn't resolve (unknown or ambiguous) | `subjectSlug`, `message`, `hint`, **`candidates?` (≤8, only on the tools that take a `subjectSlug` argument)**; the `lessonId`-driven tools return `tier` + `subjectSlug` instead | Re-resolve via `list_subjects`; if `candidates` present, pick an exact slug. On an id-driven tool, the pair `{tier, subjectSlug}` you passed does not exist — subjectSlugs are tier-specific; take the id from `get_subject_outline`. |
| `BAD_LESSON_ID` | `lessonId` doesn't match `{tier}/{subjectSlug}/{classNum}` | `lessonId`, `message`, `hint` | Stop hand-building ids. Get a real `lessonId` from `get_subject_outline` or `search`. |
| `LESSON_NOT_FOUND` | `classNum` out of range, or the object is missing for a catalog-valid id | `lessonId`, `classNum?`, `classCount?` | Re-check the outline; use a `classNum` within `classCount`. |
| `UNKNOWN_MEDIA` | `mediaId` not among the lesson's media | `mediaId`, `message`, `hint` | Call `list_lesson_media` and pass a `mediaId` exactly as returned. |
| `UNKNOWN_TEST` | `test` id not in the test-prep registry | `test`, `message`, `hint` | Call `list_testprep_catalog` for valid test ids. |
| `UNKNOWN_SECTION` | `section` is a valid section id, but not one of **this** test's sections (the schema enum is the union across all tests) | `test`, `section`, `sections[]` (this test's valid ids), `message`, `hint` | Re-issue with a section from the returned `sections[]`, or drop `section` for the whole bank. Do not retry the same pair. |
| `QUOTA_EXCEEDED` | A monthly meter (`calls`, `downloads`, `testprepitems`) would exceed the limit | `retryAfterSeconds`, `quotaResetsAt` (start of next UTC month) | Stop metered calls until reset; don't hammer. Consider a higher-tier license. |
| `EXPORT_LIMIT` | Monthly `exports` meter exceeded | `exportsUsed`, `exportsLimit` | Stop exporting until next month; check `get_license_status`. |
| `RATE_LIMITED` | Hourly velocity cap exceeded (`calls`, `lessonreads`, or `mints`) | `retryAfterSeconds` (seconds to the top of the next UTC hour) | Back off and retry after `retryAfterSeconds`. Transient. |
| `LEARNER_REQUIRED` | `get_progress` / `record_progress` (§5 Group G) called with a machine-to-machine token — no learner identity in it | `message`, `hint` | Have the learner sign in through the interactive OAuth flow so the token carries their `sub`. A `client_credentials` token can never call these. |
| `WRITE_CONFLICT` | `record_progress` only (Model A, §7): the learner's record was being written concurrently (e.g. the learner is active on scolavo.com) and the write lost every bounded retry. **Nothing was saved.** | `lessonId`, `learnerId`, `retryAfterSeconds`, `message`, `hint` | Retry the same call after a short pause. Transient — it is a hot record, not a server fault. |
| `INTERNAL` | Any unexpected server-side exception | `message: "internal error"` | Retry once with backoff; if it persists, contact support. |

**Transport-level (not a `BizError`):**

| Condition | Surfaces as | React |
|---|---|---|
| Stale pagination cursor (corpus rebuilt mid-page) | MCP `InvalidParams: "cursor expired"` | Restart pagination from page 1 with a fresh call (no cursor). |
| Missing OAuth scope | HTTP `403 insufficient_scope` + `WWW-Authenticate` | Request a token carrying the named scope, then retry. |

---

## 9. Limits, metering & versioning

### Per-org monthly quota meters

Reset at the start of each **UTC month**. Current usage vs. limit is visible any time via `get_license_status`.

| Meter | Plan limit field | Incremented by |
|---|---|---|
| `calls` | `callsPerMonth` | **Every** gated tool call (metered first) |
| `downloads` | `downloadsPerMonth` | `get_download_url` (+1); `export_class` (+1); `export_subject` (**+1 per class in the subject**) |
| `exports` | `exportsPerMonth` | `export_class`, `export_subject` (+1 each) — over-limit throws `EXPORT_LIMIT` |
| `testprepitems` | `testprepItemsPerMonth` | `get_testprep_items` (**+1 per item actually served**) — telemetry unless the contract sets a limit |

### Per-org hourly velocity caps

Reset at the **top of the next UTC hour**; over-limit throws `RATE_LIMITED` with `retryAfterSeconds`.

| Velocity | Plan limit field | Applies to |
|---|---|---|
| `calls` | `callsPerHour` | every gated call (telemetry-only unless the plan sets a limit) |
| `lessonreads` | `lessonReadsPerHour` | every whole-lesson read — `get_lesson`, `get_lesson_context`, `get_lesson_summary`, `get_lesson_transcript`, `get_quiz`, `list_lesson_media`, `fetch` — plus `get_testprep_items` (bank paging) |
| `mints` | `urlMintsPerHour` | `get_download_url` |

### Enumeration (anti-scraping) alarm

- Fed by every single-lesson read — `get_lesson`, `get_lesson_context`, `get_lesson_summary`, `get_lesson_transcript`, `get_quiz`, `list_lesson_media`, `fetch`. The `export_*` tools are the **sanctioned bulk path** and do **not** feed it.
- Tracks distinct lessons read per org per UTC day, deduplicated.
- **Threshold: reading > 40% of the org's own granted lesson count in one UTC day.** (Grant-relative — never the global catalog.)
- On trip: the org's plan flips to `suspended`, an internal notification fires, and the tripping call is denied with `ORG_SUSPENDED` (`reason: "enumeration"`). **Reinstatement is a human action** — there is no auto-recovery. If you need a full corpus locally, use `export_subject` (bulk path), not a loop of `get_lesson`.

### Durable export ledger

Successful `export_subject`, `export_class`, and `get_download_url` calls also write a **no-TTL ledger row** (durable record of what content left the building), separate from the 90-day audit trail. Query text and IPs are HMAC-salted in all logs — never stored raw.

### `contentVersion` change handling

- `contentVersion` rides on nearly every response and is embedded in pagination cursors.
- **Detect changes:** compare the returned `contentVersion` to the last value you stored. A change means the corpus snapshot moved.
- **On change:** treat cached lesson bodies as potentially stale and re-fetch; re-validate stored `lessonId`s/`mediaId`s; **restart any in-flight pagination** (old cursors return `"cursor expired"`).
- lessonIds and subjectSlugs are intended to be **stable** across versions, but always re-validate after a version bump rather than assuming.

### Tool-list caching / discovering new tools

- MCP clients cache the `tools/list` result **per connection**. If Scolavo adds a tool, an already-connected client will **not** see it until it reconnects.
- To pick up new tools, **reconnect** (re-run `initialize` + `tools/list`) — for M2M, that means starting a fresh MCP session; for interactive hosts, refresh/reconnect the connector.
- The enum values inside the schema (subjectSlugs, test ids, section ids) are also snapshotted into that cached `tools/list`; reconnect after a corpus change to refresh them.
- The `subjectSlug` and `tier` enums are **grant-scoped**: they list only what your license covers at the moment you connect (an org with no grants, or one whose license is suspended/expired, gets those two enums omitted entirely — the field stays a plain string). Reconnect after a grant change to see new subjects. This affects the schema only: `tools/call` still validates against the full catalog, so an ungranted slug returns the `CONTENT_NOT_GRANTED` business error rather than a schema rejection.

---

## 10. Support & licensing

- **Licensing, evaluation licenses, grant changes, ECLA questions, reinstatement after an enumeration suspension:** `licensing@scolavo.com`.
- **Evaluation licenses** are available for partners building an integration; they carry the same tool surface with scoped grants and quotas — call `get_license_status` to see exactly what your evaluation license grants.
- **All content is licensed** under the Scolavo Enterprise Content License Agreement (ECLA). Each response carries a `_license` envelope; export bundles carry canaries and per-class checksums. Do not strip license metadata from stored or redistributed content — redistribution outside the ECLA terms is not permitted.
- **Test-prep note:** all practice items are **original Scolavo-authored** material, not actual exam questions from any testing body.

---

*This specification describes the Scolavo MCP server's 26-tool surface as deployed (25 read-only tools plus the opt-in `record_progress` writer). Tool schemas (including live enums for tiers, subjectSlugs, test ids, and section ids) are authoritative in the server's `tools/list` response; this document is the human-readable companion.*
