From 0d65b5a27380e135b130c6453ed049337ab239e8 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Wed, 18 Mar 2026 10:19:37 +0100 Subject: [PATCH] chore: update MCP package version to 0.0.7, add formatting script, and enhance README with architecture and principles --- packages/mcp/README.md | 439 ++++++++++++++++++------ packages/mcp/package.json | 5 +- packages/mcp/src/api-client.ts | 111 ++++++ packages/mcp/src/tools/course-drafts.ts | 263 ++++++++++++-- 4 files changed, 686 insertions(+), 132 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 7319d54d..422016f9 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1,77 +1,106 @@ -# ClassroomIO MCP Server +# @classroomio/mcp -Thin stdio MCP server for ClassroomIO course-draft authoring. +Thin stdio MCP server for ClassroomIO course authoring. + +The package does not parse PDFs or generate course structures. The agent does that work. `@classroomio/mcp` only exposes tools, validates tool input, and forwards requests to the ClassroomIO API. ## Architecture ```text -Teacher +Teacher / Admin | v -Claude Desktop / Claude Code / other MCP client +Claude Code / Codex / Cursor / other MCP client | - | 1. Reads prompt or PDF content - | 2. Produces normalized course JSON + | 1. Reads prompt, PDF, or existing course goal + | 2. Produces or edits normalized course JSON v -ClassroomIO MCP Server (stdio) +@classroomio/mcp (stdio) | - | 3. Forwards validated tool calls - | - Authorization: Bearer + | 3. Sends authenticated tool calls + | Authorization: Bearer v ClassroomIO API | - | 4. Validates payload - | 5. Resolves org from the key - | 6. Stores draft or publishes course + | 4. Validates payloads + | 5. Resolves the organization from the key + | 6. Creates drafts or applies changes to a course v ClassroomIO DB ``` -## How It Comes Together +## Principles -1. The agent owns reasoning and extraction. -2. The MCP server does not parse PDFs or generate course structure. -3. The MCP server only exposes tools and forwards structured payloads into ClassroomIO. -4. ClassroomIO API remains the trust boundary for auth, validation, and persistence. -5. Draft creation and publish are explicit separate steps. +1. The agent owns reasoning. +2. The MCP package owns transport and input validation. +3. ClassroomIO API remains the trust boundary for auth, validation, and persistence. +4. Draft creation and publish are separate operations. +5. Updating an existing course is done through a seeded draft, not blind writes to the live course. ## Tool Surface +Current tools: + +- `list_org_courses` +- `get_course_structure` +- `update_course_landing_page` - `create_course_draft` +- `create_course_draft_from_course` - `get_course_draft` +- `list_course_exercises` +- `get_course_exercise` +- `create_course_exercise` +- `create_course_exercise_from_template` +- `update_course_exercise` - `update_course_draft` +- `tag_course_draft` +- `tag_courses` - `publish_course_draft` - -## Required Environment Variables - -- `CLASSROOMIO_API_URL` -- `CLASSROOMIO_API_KEY` +- `publish_course_draft_to_existing_course` ## Auth Model -Current implementation uses an org-scoped automation key created in ClassroomIO under `Automation -> MCP`. +The package expects an org-scoped ClassroomIO automation key generated from `Automation -> MCP` in the ClassroomIO dashboard. -The MCP server sends that key as a bearer token on every request. +The MCP process sends the key as a bearer token on every request. ClassroomIO API: 1. hashes and verifies the key 2. resolves the owning organization 3. checks the key scopes -4. persists the draft or publish request +4. executes the requested action -The MCP server does not decide permissions. ClassroomIO API remains the source of truth. +The MCP package never decides permissions. -## Run +## Required Environment Variables + +- `CLASSROOMIO_API_URL` +- `CLASSROOMIO_API_KEY` +- `CLASSROOMIO_USER_AGENT` optional + +## Run Locally + +From the repo root: ```bash -pnpm --filter @classroomio/mcp build +pnpm build --filter=@classroomio/mcp CLASSROOMIO_API_URL=http://localhost:3081 \ CLASSROOMIO_API_KEY= \ node packages/mcp/dist/index.js ``` -## Claude Code Example +Published package: + +```bash +CLASSROOMIO_API_URL=https://api.classroomio.com \ +CLASSROOMIO_API_KEY= \ +npx -y @classroomio/mcp +``` + +## Client Setup + +### Claude Code ```bash claude mcp add-json classroomio '{ @@ -84,7 +113,7 @@ claude mcp add-json classroomio '{ }' ``` -## Codex Example +### Codex ```bash codex mcp add classroomio \ @@ -93,7 +122,7 @@ codex mcp add classroomio \ -- npx -y @classroomio/mcp ``` -## Cursor Example +### Cursor ```json { @@ -110,89 +139,16 @@ codex mcp add classroomio \ } ``` -## User Flow Examples +## Draft Model -This package uses the same org-scoped automation key system that will later power the public API. +A draft is a saved, structured course proposal. It is not the live course. -Different integrations get different keys and scope presets. The infrastructure is shared, the secrets should not be. - -### Flow 1: Prompt to draft to publish - -User says: - -```text -Create a complete course on exponential functions for high school students in ClassroomIO. -``` - -Expected agent behavior: - -1. Claude plans the course structure. -2. Claude calls `create_course_draft` with: - - course title and description - - sections - - lessons - - lesson language content -3. Claude reviews the returned draft ID and summary. -4. User requests changes. -5. Claude calls `update_course_draft`. -6. When the user approves, Claude calls `publish_course_draft`. - -Result: - -- a real ClassroomIO course -- sections created -- lessons created -- lesson language content inserted - -### Flow 2: PDF to draft - -User says: - -```text -I have my course in a PDF. Extract it and turn it into a structured ClassroomIO course draft. -``` - -Expected agent behavior: - -1. Claude reads the PDF itself. -2. Claude extracts and restructures the material into normalized course JSON. -3. Claude calls `create_course_draft`. -4. Claude returns a summary of: - - detected sections - - detected lessons - - warnings or ambiguous splits -5. User asks for revisions. -6. Claude calls `update_course_draft`. -7. User approves publish. -8. Claude calls `publish_course_draft`. - -Important: - -- the PDF never needs to be uploaded to the MCP server -- the MCP server only receives structured JSON - -### Flow 3: Human-in-the-loop revision - -User says: - -```text -Move logarithmic review before growth models and shorten lesson 2. -``` - -Expected agent behavior: - -1. Claude calls `get_course_draft`. -2. Claude computes the revised structure. -3. Claude calls `update_course_draft` with the modified payload. -4. Claude summarizes what changed before publish. - -## Normalized Payload Shape - -The core payload sent through the MCP server is: +Draft payload shape: ```text draft course + tags[] sections[] lessons[] lessonLanguages[] @@ -201,4 +157,267 @@ draft exercises[] optional ``` -This keeps prompt-based creation and PDF-based extraction on the same ingestion path. +Drafts support: + +- review before publish +- iterative edits from the agent +- safer updates to existing courses +- auditability of AI-generated structure + +Lesson authoring rule: + +- `lessonLanguages[].content` should contain only the lesson body HTML +- do not include the lesson title or a top-level heading that repeats `lessons[].title` +- ClassroomIO already renders the lesson title in the course UI + +## After Publish + +Once a draft has been published, the live course becomes the safer source of truth. + +Recommended agent rule: + +- if the draft is still `DRAFT`, continue editing that draft +- if the draft is `PUBLISHED`, do not keep reusing it for major follow-up edits +- instead, read the live course and create a fresh draft from that course + +Recommended post-publish sequence: + +1. `get_course_structure` +2. `create_course_draft_from_course` +3. `update_course_draft` +4. `publish_course_draft_to_existing_course` + +Why: + +- teachers may edit the live course in the UI after publish +- another agent session may have changed the course +- reusing an old published draft can overwrite newer live changes + +For exercises on an already-published course, prefer the direct exercise tools instead of creating a whole new draft when the user only wants to add or revise exercises. + +## User Flows + +### Flow 1: Create a new course from a prompt + +User says: + +```text +Create a complete course on exponential functions for high school students. +``` + +Expected tool sequence: + +1. Agent plans the course. +2. Agent calls `create_course_draft`. +3. User asks for revisions. +4. Agent calls `update_course_draft`. +5. User approves. +6. Agent calls `publish_course_draft`. + +Result: + +- a new course is created +- sections and lessons are created +- localized lesson content is written +- publish returns the live `courseId` and public `courseUrl` +- publish can optionally set a random Unsplash-derived course cover + +### Flow 2: Update a landing page with AI-generated copy and media + +User says: + +```text +Rewrite this course landing page to feel more premium, generate a new cover image, and add three believable reviews. +``` + +Expected tool sequence: + +1. Agent calls `list_org_courses` if it needs to resolve the course ID by name first. +2. Agent calls `update_course_landing_page`. + +What the landing-page tool can update: + +- title +- short description +- overview +- requirements +- goals +- pricing fields +- instructor section +- reviews +- landing page image + +The tool can either: + +- use an explicit `imageUrl` +- or generate a random Unsplash-based image with `generateImage` and `imageQuery` + +### Flow 3: Create a draft from a PDF + +User says: + +```text +I have my course in a PDF. Extract it and turn it into a ClassroomIO course draft. +``` + +Expected tool sequence: + +1. Agent reads the PDF itself. +2. Agent restructures the content into normalized JSON. +3. Agent calls `create_course_draft`. +4. User reviews the draft. +5. Agent calls `update_course_draft` if needed. +6. User approves. +7. Agent calls `publish_course_draft`. + +Important: + +- the PDF does not need to be uploaded to MCP +- the MCP package only receives structured JSON + +### Flow 4: Tag a draft before publish + +User says: + +```text +Add tags for algebra, beginner, and exponential growth to this draft. +``` + +Expected tool sequence: + +1. Agent calls `tag_course_draft`. +2. User reviews the updated draft tags. +3. Agent later calls `publish_course_draft`. + +Result: + +- the draft stores tag names +- publish ensures the tags exist in ClassroomIO +- publish assigns those tags to the live course + +### Flow 5: Update an existing course safely + +User says: + +```text +Take my existing Algebra course, reorganize it, and rewrite the lesson content for a beginner audience. +``` + +Expected tool sequence: + +1. Agent calls `list_org_courses` if it needs to resolve the course ID from the course name first. +2. Agent calls `get_course_structure` with the existing `courseId`. +3. Agent optionally calls `create_course_draft_from_course` to persist a seeded draft. +4. Agent updates the draft with the new structure and content using `update_course_draft`. +5. User reviews the changes. +6. Agent calls `publish_course_draft_to_existing_course`. + +What this publish does today: + +- updates the existing course title, description, type, and metadata +- can set a generated course cover from Unsplash +- returns the public `courseUrl` +- updates existing sections when the draft keeps their IDs +- updates existing lessons when the draft keeps their IDs +- creates new sections and lessons for draft items without matching live IDs +- upserts lesson language content +- merges draft tags into the existing course + +### Flow 6: Add exercises to a live course + +User says: + +```text +Add a 5-question multiple-choice exercise to lesson 3. +``` + +Expected tool sequence: + +1. Agent calls `list_course_exercises` if it needs current context. +2. Agent calls `create_course_exercise` for a brand new exercise. +3. Agent calls `update_course_exercise` for later revisions. + +Use this direct path when the course is already live and the change is exercise-specific. + +What it does not do today: + +- automatically delete sections or lessons that are absent from the draft +- automatically delete lesson locales that are absent from the draft + +That behavior is intentional. The current update path is non-destructive by default. + +## How Existing-Course Updates Work + +When a draft is seeded from a live course: + +- section `externalId` values are the real section IDs +- lesson `externalId` values are the real lesson IDs + +As long as the agent preserves those IDs while editing the draft, publish-to-existing-course can map draft items back to the live records and update them safely. + +If the agent adds a brand new section or lesson, it should assign a new synthetic `externalId`. ClassroomIO will create that record on publish. + +## Input Contract Notes + +For large updates, the agent should: + +1. call `get_course_structure` +2. keep existing `externalId` values for records it wants to update +3. only generate new `externalId` values for new records +4. avoid deleting records from the draft unless the user explicitly wants omitted content to remain untouched + +Publish input also supports: + +- `bannerImageUrl`: use this exact course cover +- `bannerImageQuery`: search Unsplash with this query +- `generateBannerImage`: fetch a random Unsplash image using the course title or query + +Tagging tools use: + +- `tagNames`: human-readable tag names, not tag IDs +- `mode: "merge" | "replace"` +- `groupName` optional, defaults to `Automation` + +The API creates missing tags automatically when needed. + +Exercise tools use the live course directly: + +- `list_course_exercises` +- `get_course_exercise` +- `create_course_exercise` +- `create_course_exercise_from_template` +- `update_course_exercise` + +Supported `questionTypeId` values for exercise payloads: + +- `1` `RADIO` - Single answer +- `2` `CHECKBOX` - Multiple answers +- `3` `TEXTAREA` - Paragraph +- `4` `TRUE_FALSE` - True/False +- `5` `SHORT_ANSWER` - Short answer +- `6` `NUMERIC` - Numeric answer +- `7` `FILL_BLANK` - Fill in the blank +- `8` `FILE_UPLOAD` - File upload +- `9` `MATCHING` - Matching +- `10` `ORDERING` - Ordering +- `11` `HOTSPOT` - Hotspot +- `12` `LINK` - Links + +## Operational Notes + +- `create_course_draft_from_course` may add warnings if a live course uses legacy lesson notes instead of localized lesson content +- if a course has ungrouped lessons, the seeded draft may add a synthetic `Ungrouped` section so the structure stays valid +- published drafts are treated as finalized snapshots + +## Recommended Agent Behavior + +For small edits, an agent can still read the course structure and modify only the affected items in the draft. + +For broad revisions, prefer this sequence: + +1. `get_course_structure` +2. `create_course_draft_from_course` +3. `update_course_draft` +4. `publish_course_draft_to_existing_course` + +That keeps major AI-assisted edits reviewable and explicit. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 8a954efa..e0a2d7ef 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@classroomio/mcp", - "version": "0.0.2", + "version": "0.0.7", "private": false, "type": "module", "main": "./dist/index.js", @@ -16,7 +16,8 @@ "scripts": { "build": "tsc --noEmit && rm -rf dist tsconfig.tsbuildinfo && node ./scripts/build.mjs", "dev": "tsx src/index.ts", - "start": "node dist/index.js" + "start": "node dist/index.js", + "format": "prettier --write \"src/**/*.ts\"" }, "repository": { "type": "git", diff --git a/packages/mcp/src/api-client.ts b/packages/mcp/src/api-client.ts index 95bed668..217f72f4 100644 --- a/packages/mcp/src/api-client.ts +++ b/packages/mcp/src/api-client.ts @@ -1,8 +1,25 @@ import type { + TCourseImportCourseParam, TCourseImportDraftCreate, + TCourseImportDraftCreateFromCourse, TCourseImportDraftPublish, + TCourseImportDraftPublishToCourse, TCourseImportDraftUpdate } from '@cio/utils/validation/course-import'; +import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course'; +import type { + TExerciseCreate, + TExerciseFromTemplate, + TExerciseGetParam, + TExerciseListQuery, + TExerciseUpdate +} from '@cio/utils/validation/exercise'; +import type { TGetOrganizationCoursesQuery } from '@cio/utils/validation/organization'; +import type { + TAutomationCourseTagAssignment, + TAutomationDraftTagAssignment, + TAutomationDraftTagParam +} from '@cio/utils/validation/tag'; import type { McpServerConfig } from './config.js'; @@ -41,12 +58,83 @@ export class ClassroomIoApiClient { }); } + async createCourseDraftFromCourse(payload: TCourseImportDraftCreateFromCourse) { + return this.request('/organization/course-import/drafts/from-course', { + method: 'POST', + body: payload + }); + } + + async getCourseStructure(courseId: TCourseImportCourseParam['courseId']) { + return this.request(`/organization/course-import/courses/${courseId}/structure`, { + method: 'GET' + }); + } + + async updateCourseLandingPage(courseId: TCourseImportCourseParam['courseId'], payload: TCourseLandingPageUpdate) { + return this.request(`/course/${courseId}/landing-page`, { + method: 'PUT', + body: payload + }); + } + async getCourseDraft(draftId: string) { return this.request(`/organization/course-import/drafts/${draftId}`, { method: 'GET' }); } + async listOrganizationCourses(query: TGetOrganizationCoursesQuery = {}) { + const searchParams = new URLSearchParams(); + if (query.tags) searchParams.set('tags', query.tags); + + const querySuffix = searchParams.toString() ? `?${searchParams.toString()}` : ''; + return this.request(`/organization/courses${querySuffix}`, { + method: 'GET' + }); + } + + async listCourseExercises(courseId: string, query: TExerciseListQuery = {}) { + const searchParams = new URLSearchParams(); + if (query.lessonId) searchParams.set('lessonId', query.lessonId); + if (query.sectionId) searchParams.set('sectionId', query.sectionId); + + const querySuffix = searchParams.toString() ? `?${searchParams.toString()}` : ''; + return this.request(`/course/${courseId}/exercise${querySuffix}`, { + method: 'GET' + }); + } + + async getCourseExercise(courseId: string, exerciseId: TExerciseGetParam['exerciseId']) { + return this.request(`/course/${courseId}/exercise/${exerciseId}`, { + method: 'GET' + }); + } + + async createCourseExercise(courseId: string, payload: Omit) { + return this.request(`/course/${courseId}/exercise`, { + method: 'POST', + body: { + ...payload, + courseId + } + }); + } + + async createCourseExerciseFromTemplate(courseId: string, payload: TExerciseFromTemplate) { + return this.request(`/course/${courseId}/exercise/from-template`, { + method: 'POST', + body: payload + }); + } + + async updateCourseExercise(courseId: string, exerciseId: TExerciseGetParam['exerciseId'], payload: TExerciseUpdate) { + return this.request(`/course/${courseId}/exercise/${exerciseId}`, { + method: 'PUT', + body: payload + }); + } + async updateCourseDraft(draftId: string, payload: TCourseImportDraftUpdate) { return this.request(`/organization/course-import/drafts/${draftId}`, { method: 'PUT', @@ -54,6 +142,13 @@ export class ClassroomIoApiClient { }); } + async tagCourseDraft(draftId: TAutomationDraftTagParam['draftId'], payload: TAutomationDraftTagAssignment) { + return this.request(`/organization/course-import/drafts/${draftId}/tags`, { + method: 'PUT', + body: payload + }); + } + async publishCourseDraft(draftId: string, payload: TCourseImportDraftPublish) { return this.request(`/organization/course-import/drafts/${draftId}/publish`, { method: 'POST', @@ -61,6 +156,22 @@ export class ClassroomIoApiClient { }); } + async publishCourseDraftToExistingCourse(draftId: string, payload: TCourseImportDraftPublishToCourse) { + return this.request(`/organization/course-import/drafts/${draftId}/publish-existing-course`, { + method: 'POST', + body: payload + }); + } + + async tagCourses(payload: TAutomationCourseTagAssignment) { + return this.request< + Array<{ courseId: string; tags: Array<{ id: string; name: string; slug: string; color: string }> }> + >('/organization/tags/courses/assign', { + method: 'PUT', + body: payload + }); + } + private async request( path: string, options: { diff --git a/packages/mcp/src/tools/course-drafts.ts b/packages/mcp/src/tools/course-drafts.ts index d4b2faae..4a7b277d 100644 --- a/packages/mcp/src/tools/course-drafts.ts +++ b/packages/mcp/src/tools/course-drafts.ts @@ -1,9 +1,26 @@ import { + ZAutomationCourseTagAssignment, + ZAutomationDraftTagAssignment, + ZAutomationDraftTagParam +} from '@cio/utils/validation/tag'; +import { ZGetOrganizationCoursesQuery } from '@cio/utils/validation/organization'; +import { + ZExerciseCreate, + ZExerciseFromTemplate, + ZExerciseGetParam, + ZExerciseListQuery, + ZExerciseUpdate +} from '@cio/utils/validation/exercise'; +import { + ZCourseImportCourseParam, ZCourseImportDraftCreate, + ZCourseImportDraftCreateFromCourse, ZCourseImportDraftGetParam, ZCourseImportDraftPublish, + ZCourseImportDraftPublishToCourse, ZCourseImportDraftUpdate } from '@cio/utils/validation/course-import'; +import { ZCourseLandingPageUpdate } from '@cio/utils/validation/course'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js'; @@ -13,39 +30,245 @@ export const ZUpdateCourseDraftToolInput = ZCourseImportDraftUpdate.extend({ draftId: ZCourseImportDraftGetParam.shape.draftId }); +export const ZTagCourseDraftToolInput = ZAutomationDraftTagAssignment.extend({ + draftId: ZAutomationDraftTagParam.shape.draftId +}); + +export const ZListCourseExercisesToolInput = ZExerciseListQuery.extend({ + courseId: ZCourseImportCourseParam.shape.courseId +}); + +export const ZGetCourseExerciseToolInput = ZExerciseGetParam.extend({ + courseId: ZCourseImportCourseParam.shape.courseId +}); + +export const ZCreateCourseExerciseToolInput = ZExerciseCreate.omit({ + courseId: true +}).extend({ + courseId: ZCourseImportCourseParam.shape.courseId +}); + +export const ZCreateCourseExerciseFromTemplateToolInput = ZExerciseFromTemplate.extend({ + courseId: ZCourseImportCourseParam.shape.courseId +}); + +export const ZUpdateCourseExerciseToolInput = ZExerciseUpdate.extend({ + courseId: ZCourseImportCourseParam.shape.courseId, + exerciseId: ZExerciseGetParam.shape.exerciseId +}); + +export const ZUpdateCourseLandingPageToolInput = ZCourseLandingPageUpdate.extend({ + courseId: ZCourseImportCourseParam.shape.courseId +}); + export const ZPublishCourseDraftToolInput = ZCourseImportDraftPublish.extend({ draftId: ZCourseImportDraftGetParam.shape.draftId }); +export const ZPublishCourseDraftToExistingCourseToolInput = ZCourseImportDraftPublishToCourse.extend({ + draftId: ZCourseImportDraftGetParam.shape.draftId +}); + const createCourseDraftShape = ZCourseImportDraftCreate.shape as unknown as ZodRawShapeCompat; +const createCourseDraftFromCourseShape = ZCourseImportDraftCreateFromCourse.shape as unknown as ZodRawShapeCompat; +const listOrganizationCoursesShape = ZGetOrganizationCoursesQuery.shape as unknown as ZodRawShapeCompat; +const getCourseStructureShape = ZCourseImportCourseParam.shape as unknown as ZodRawShapeCompat; const getCourseDraftShape = ZCourseImportDraftGetParam.shape as unknown as ZodRawShapeCompat; const updateCourseDraftShape = ZUpdateCourseDraftToolInput.shape as unknown as ZodRawShapeCompat; +const tagCourseDraftShape = ZTagCourseDraftToolInput.shape as unknown as ZodRawShapeCompat; +const listCourseExercisesShape = ZListCourseExercisesToolInput.shape as unknown as ZodRawShapeCompat; +const getCourseExerciseShape = ZGetCourseExerciseToolInput.shape as unknown as ZodRawShapeCompat; +const createCourseExerciseShape = ZCreateCourseExerciseToolInput.shape as unknown as ZodRawShapeCompat; +const createCourseExerciseFromTemplateShape = + ZCreateCourseExerciseFromTemplateToolInput.shape as unknown as ZodRawShapeCompat; +const updateCourseExerciseShape = ZUpdateCourseExerciseToolInput.shape as unknown as ZodRawShapeCompat; +const updateCourseLandingPageShape = ZUpdateCourseLandingPageToolInput.shape as unknown as ZodRawShapeCompat; const publishCourseDraftShape = ZPublishCourseDraftToolInput.shape as unknown as ZodRawShapeCompat; +const publishCourseDraftToExistingCourseShape = + ZPublishCourseDraftToExistingCourseToolInput.shape as unknown as ZodRawShapeCompat; +const tagCoursesShape = ZAutomationCourseTagAssignment.shape as unknown as ZodRawShapeCompat; + +const SUPPORTED_QUESTION_TYPES_GUIDE = + 'Supported questionTypeId values: RADIO=1 (Single answer), CHECKBOX=2 (Multiple answers), TEXTAREA=3 (Paragraph), TRUE_FALSE=4 (True/False), SHORT_ANSWER=5 (Short answer), NUMERIC=6 (Numeric answer), FILL_BLANK=7 (Fill in the blank), FILE_UPLOAD=8 (File upload), MATCHING=9 (Matching), ORDERING=10 (Ordering), HOTSPOT=11 (Hotspot), LINK=12 (Links). Use the numeric ids in exercise payloads.'; +const LESSON_HTML_GUIDE = + 'For draft lesson HTML, put only the lesson body in lessonLanguages[].content. Do not include the lesson title or a top-level heading that repeats lessons[].title because the ClassroomIO UI already renders the lesson title.'; export function registerCourseDraftTools(server: McpServer, apiClient: ClassroomIoApiClient) { - server.tool('create_course_draft', createCourseDraftShape, async (args) => { - const payload = ZCourseImportDraftCreate.parse(args); - const result = await apiClient.createCourseDraft(payload); - return jsonContent(result); - }); + server.tool( + 'list_org_courses', + 'List all courses in the authenticated organization. Use this when the user refers to a course by name and you need to discover the available course IDs first.', + listOrganizationCoursesShape, + async (args) => { + const query = ZGetOrganizationCoursesQuery.parse(args); + const result = await apiClient.listOrganizationCourses(query); + return jsonContent(result); + } + ); - server.tool('get_course_draft', getCourseDraftShape, async (args) => { - const { draftId } = ZCourseImportDraftGetParam.parse(args); - const result = await apiClient.getCourseDraft(draftId); - return jsonContent(result); - }); + server.tool( + 'get_course_structure', + 'Read the current live course structure. Use this before creating a fresh draft for post-publish edits.', + getCourseStructureShape, + async (args) => { + const { courseId } = ZCourseImportCourseParam.parse(args); + const result = await apiClient.getCourseStructure(courseId); + return jsonContent(result); + } + ); - server.tool('update_course_draft', updateCourseDraftShape, async (args) => { - const { draftId, ...payload } = ZUpdateCourseDraftToolInput.parse(args); - const result = await apiClient.updateCourseDraft(draftId, payload); - return jsonContent(result); - }); + server.tool( + 'update_course_landing_page', + 'Update landing-page-facing course fields on a live course, including headline copy, overview, requirements, goals, pricing, reviews, instructor information, and the course image. Use imageUrl to set an explicit cover image, or set generateImage/imageQuery to fetch a random Unsplash-based image.', + updateCourseLandingPageShape, + async (args) => { + const { courseId, ...payload } = ZUpdateCourseLandingPageToolInput.parse(args); + const result = await apiClient.updateCourseLandingPage(courseId, payload); + return jsonContent(result); + } + ); - server.tool('publish_course_draft', publishCourseDraftShape, async (args) => { - const { draftId, ...payload } = ZPublishCourseDraftToolInput.parse(args); - const result = await apiClient.publishCourseDraft(draftId, payload); - return jsonContent(result); - }); + server.tool( + 'create_course_draft', + `Create a new unpublished course draft from structured course JSON. Use this before the first publish. ${LESSON_HTML_GUIDE}`, + createCourseDraftShape, + async (args) => { + const payload = ZCourseImportDraftCreate.parse(args); + const result = await apiClient.createCourseDraft(payload); + return jsonContent(result); + } + ); + + server.tool( + 'create_course_draft_from_course', + 'Create a fresh draft from an existing live course. Use this after a course has already been published and the user wants more changes.', + createCourseDraftFromCourseShape, + async (args) => { + const payload = ZCourseImportDraftCreateFromCourse.parse(args); + const result = await apiClient.createCourseDraftFromCourse(payload); + return jsonContent(result); + } + ); + + server.tool( + 'get_course_draft', + 'Fetch an existing draft. Draft responses include status and publishedCourseId so you can decide whether to keep editing the draft or seed a fresh one from the live course.', + getCourseDraftShape, + async (args) => { + const { draftId } = ZCourseImportDraftGetParam.parse(args); + const result = await apiClient.getCourseDraft(draftId); + return jsonContent(result); + } + ); + + server.tool( + 'list_course_exercises', + 'List exercises for a live course, optionally filtered by lessonId or sectionId.', + listCourseExercisesShape, + async (args) => { + const { courseId, ...query } = ZListCourseExercisesToolInput.parse(args); + const result = await apiClient.listCourseExercises(courseId, query); + return jsonContent(result); + } + ); + + server.tool( + 'get_course_exercise', + 'Fetch a single exercise from a live course, including its questions and options.', + getCourseExerciseShape, + async (args) => { + const { courseId, exerciseId } = ZGetCourseExerciseToolInput.parse(args); + const result = await apiClient.getCourseExercise(courseId, exerciseId); + return jsonContent(result); + } + ); + + server.tool( + 'create_course_exercise', + `Create a new exercise directly on a live course. Use this for adding exercises after a course has already been published. ${SUPPORTED_QUESTION_TYPES_GUIDE}`, + createCourseExerciseShape, + async (args) => { + const { courseId, ...payload } = ZCreateCourseExerciseToolInput.parse(args); + const result = await apiClient.createCourseExercise(courseId, payload); + return jsonContent(result); + } + ); + + server.tool( + 'create_course_exercise_from_template', + 'Create a new exercise on a live course from an existing template.', + createCourseExerciseFromTemplateShape, + async (args) => { + const { courseId, ...payload } = ZCreateCourseExerciseFromTemplateToolInput.parse(args); + const result = await apiClient.createCourseExerciseFromTemplate(courseId, payload); + return jsonContent(result); + } + ); + + server.tool( + 'update_course_exercise', + `Update an existing exercise on a live course, including questions and options. ${SUPPORTED_QUESTION_TYPES_GUIDE}`, + updateCourseExerciseShape, + async (args) => { + const { courseId, exerciseId, ...payload } = ZUpdateCourseExerciseToolInput.parse(args); + const result = await apiClient.updateCourseExercise(courseId, exerciseId, payload); + return jsonContent(result); + } + ); + + server.tool( + 'update_course_draft', + `Update an unpublished draft. If the draft is already published, do not keep editing it blindly; create a fresh draft from the live course first. ${LESSON_HTML_GUIDE}`, + updateCourseDraftShape, + async (args) => { + const { draftId, ...payload } = ZUpdateCourseDraftToolInput.parse(args); + const result = await apiClient.updateCourseDraft(draftId, payload); + return jsonContent(result); + } + ); + + server.tool( + 'tag_course_draft', + 'Attach human-readable tags to a draft before publish. The tags are stored on the draft and applied during publish.', + tagCourseDraftShape, + async (args) => { + const { draftId, ...payload } = ZTagCourseDraftToolInput.parse(args); + const result = await apiClient.tagCourseDraft(draftId, payload); + return jsonContent(result); + } + ); + + server.tool( + 'publish_course_draft', + 'Publish an unpublished draft as a new live course. The result includes courseId and courseUrl. After publish, future major edits should usually start from a fresh draft created from the live course.', + publishCourseDraftShape, + async (args) => { + const { draftId, ...payload } = ZPublishCourseDraftToolInput.parse(args); + const result = await apiClient.publishCourseDraft(draftId, payload); + return jsonContent(result); + } + ); + + server.tool( + 'publish_course_draft_to_existing_course', + 'Apply a fresh draft back onto an existing live course. Use this after reading the live course and creating a new draft from it.', + publishCourseDraftToExistingCourseShape, + async (args) => { + const { draftId, ...payload } = ZPublishCourseDraftToExistingCourseToolInput.parse(args); + const result = await apiClient.publishCourseDraftToExistingCourse(draftId, payload); + return jsonContent(result); + } + ); + + server.tool( + 'tag_courses', + 'Assign tags directly to one or more live courses by name. Default mode is merge, so existing tags are preserved unless replace is requested.', + tagCoursesShape, + async (args) => { + const payload = ZAutomationCourseTagAssignment.parse(args); + const result = await apiClient.tagCourses(payload); + return jsonContent(result); + } + ); } function jsonContent(data: unknown) {