chore: update MCP package version to 0.0.7, add formatting script, and enhance README with architecture and principles
This commit is contained in:
+329
-110
@@ -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 <cio_mcp_...>
|
||||
| 3. Sends authenticated tool calls
|
||||
| Authorization: Bearer <cio_mcp_...>
|
||||
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=<cio_mcp_key> \
|
||||
node packages/mcp/dist/index.js
|
||||
```
|
||||
|
||||
## Claude Code Example
|
||||
Published package:
|
||||
|
||||
```bash
|
||||
CLASSROOMIO_API_URL=https://api.classroomio.com \
|
||||
CLASSROOMIO_API_KEY=<cio_mcp_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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<TExerciseCreate, 'courseId'>) {
|
||||
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<TResponse>(
|
||||
path: string,
|
||||
options: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user