diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml new file mode 100644 index 00000000..80d41bab --- /dev/null +++ b/.github/workflows/publish-mcp.yml @@ -0,0 +1,31 @@ +name: Publish @classroomio/mcp to NPM + +on: + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20.19.3 + registry-url: 'https://registry.npmjs.org' + + - uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Build publishable MCP package + run: pnpm --filter @classroomio/mcp build + + - name: Publish package + run: pnpm --filter @classroomio/mcp publish --no-git-checks --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 00000000..7319d54d --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,204 @@ +# ClassroomIO MCP Server + +Thin stdio MCP server for ClassroomIO course-draft authoring. + +## Architecture + +```text +Teacher + | + v +Claude Desktop / Claude Code / other MCP client + | + | 1. Reads prompt or PDF content + | 2. Produces normalized course JSON + v +ClassroomIO MCP Server (stdio) + | + | 3. Forwards validated tool calls + | - Authorization: Bearer + v +ClassroomIO API + | + | 4. Validates payload + | 5. Resolves org from the key + | 6. Stores draft or publishes course + v +ClassroomIO DB +``` + +## How It Comes Together + +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. + +## Tool Surface + +- `create_course_draft` +- `get_course_draft` +- `update_course_draft` +- `publish_course_draft` + +## Required Environment Variables + +- `CLASSROOMIO_API_URL` +- `CLASSROOMIO_API_KEY` + +## Auth Model + +Current implementation uses an org-scoped automation key created in ClassroomIO under `Automation -> MCP`. + +The MCP server sends that 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 + +The MCP server does not decide permissions. ClassroomIO API remains the source of truth. + +## Run + +```bash +pnpm --filter @classroomio/mcp build +CLASSROOMIO_API_URL=http://localhost:3081 \ +CLASSROOMIO_API_KEY= \ +node packages/mcp/dist/index.js +``` + +## Claude Code Example + +```bash +claude mcp add-json classroomio '{ + "command": "npx", + "args": ["-y", "@classroomio/mcp"], + "env": { + "CLASSROOMIO_API_URL": "https://api.classroomio.com", + "CLASSROOMIO_API_KEY": "" + } +}' +``` + +## Codex Example + +```bash +codex mcp add classroomio \ + --env CLASSROOMIO_API_URL=https://api.classroomio.com \ + --env CLASSROOMIO_API_KEY= \ + -- npx -y @classroomio/mcp +``` + +## Cursor Example + +```json +{ + "mcpServers": { + "classroomio": { + "command": "npx", + "args": ["-y", "@classroomio/mcp"], + "env": { + "CLASSROOMIO_API_URL": "https://api.classroomio.com", + "CLASSROOMIO_API_KEY": "" + } + } + } +} +``` + +## User Flow Examples + +This package uses the same org-scoped automation key system that will later power the public API. + +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: + +```text +draft + course + sections[] + lessons[] + lessonLanguages[] + warnings[] + sourceReferences[] optional + exercises[] optional +``` + +This keeps prompt-based creation and PDF-based extraction on the same ingestion path. diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 00000000..0a2adbdc --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,47 @@ +{ + "name": "@classroomio/mcp", + "version": "0.0.1", + "private": false, + "type": "module", + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist" + ], + "bin": { + "classroomio-mcp": "./dist/index.js" + }, + "scripts": { + "build": "pnpm -C ../utils build && tsc --noEmit && rm -rf dist tsconfig.tsbuildinfo && node ./scripts/build.mjs", + "dev": "tsx src/index.ts", + "start": "node dist/index.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/classroomio/classroomio.git" + }, + "bugs": { + "url": "https://github.com/classroomio/classroomio/issues" + }, + "homepage": "https://github.com/classroomio/classroomio/blob/main/packages/mcp#readme", + "author": "ClassroomIO ", + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", + "zod": "^4.1.8" + }, + "devDependencies": { + "@cio/tsconfig": "workspace:*", + "@cio/utils": "workspace:*", + "@types/node": "^24.10.1", + "esbuild": "^0.25.11", + "tsx": "^4.20.3", + "typescript": "^5.9.3" + } +} diff --git a/packages/mcp/scripts/build.mjs b/packages/mcp/scripts/build.mjs new file mode 100644 index 00000000..f11dcee6 --- /dev/null +++ b/packages/mcp/scripts/build.mjs @@ -0,0 +1,15 @@ +import { build } from 'esbuild'; + +await build({ + entryPoints: ['src/index.ts'], + outfile: 'dist/index.js', + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + sourcemap: false, + external: ['@modelcontextprotocol/sdk', 'zod'], + banner: { + js: '#!/usr/bin/env node' + } +}); diff --git a/packages/mcp/src/api-client.ts b/packages/mcp/src/api-client.ts new file mode 100644 index 00000000..95bed668 --- /dev/null +++ b/packages/mcp/src/api-client.ts @@ -0,0 +1,99 @@ +import type { + TCourseImportDraftCreate, + TCourseImportDraftPublish, + TCourseImportDraftUpdate +} from '@cio/utils/validation/course-import'; + +import type { McpServerConfig } from './config.js'; + +type ApiSuccess = { + success: true; + data: T; +}; + +type ApiFailure = { + success?: false; + error?: string; + message?: string; + code?: string; + field?: string; +}; + +export class ClassroomIoApiError extends Error { + constructor( + message: string, + public status: number, + public code?: string, + public field?: string + ) { + super(message); + this.name = 'ClassroomIoApiError'; + } +} + +export class ClassroomIoApiClient { + constructor(private readonly config: McpServerConfig) {} + + async createCourseDraft(payload: TCourseImportDraftCreate) { + return this.request('/organization/course-import/drafts', { + method: 'POST', + body: payload + }); + } + + async getCourseDraft(draftId: string) { + return this.request(`/organization/course-import/drafts/${draftId}`, { + method: 'GET' + }); + } + + async updateCourseDraft(draftId: string, payload: TCourseImportDraftUpdate) { + return this.request(`/organization/course-import/drafts/${draftId}`, { + method: 'PUT', + body: payload + }); + } + + async publishCourseDraft(draftId: string, payload: TCourseImportDraftPublish) { + return this.request(`/organization/course-import/drafts/${draftId}/publish`, { + method: 'POST', + body: payload + }); + } + + private async request( + path: string, + options: { + method: 'GET' | 'POST' | 'PUT'; + body?: unknown; + } + ): Promise { + const response = await fetch(new URL(path, this.config.CLASSROOMIO_API_URL), { + method: options.method, + headers: { + Authorization: `Bearer ${this.config.CLASSROOMIO_API_KEY}`, + 'content-type': 'application/json', + 'user-agent': this.config.CLASSROOMIO_USER_AGENT + }, + body: options.body ? JSON.stringify(options.body) : undefined + }); + + const json = (await response.json().catch(() => null)) as ApiSuccess | ApiFailure | null; + + if (!response.ok) { + const errorPayload = json as ApiFailure | null; + throw new ClassroomIoApiError( + errorPayload?.error ?? errorPayload?.message ?? `ClassroomIO request failed with status ${response.status}`, + response.status, + errorPayload?.code, + errorPayload?.field + ); + } + + if (!json || typeof json !== 'object' || !('success' in json) || !json.success) { + throw new ClassroomIoApiError('ClassroomIO returned an invalid response payload', response.status); + } + + return json.data; + } +} diff --git a/packages/mcp/src/config.ts b/packages/mcp/src/config.ts new file mode 100644 index 00000000..0c69fd0f --- /dev/null +++ b/packages/mcp/src/config.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +const ZConfig = z.object({ + CLASSROOMIO_API_URL: z.string().url(), + CLASSROOMIO_API_KEY: z.string().min(1), + CLASSROOMIO_USER_AGENT: z.string().min(1).default('classroomio-mcp/0.0.1') +}); + +export type McpServerConfig = z.infer; + +export function getConfig(env: NodeJS.ProcessEnv = process.env): McpServerConfig { + return ZConfig.parse(env); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 00000000..a4ae94b5 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,41 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +import { ClassroomIoApiClient, ClassroomIoApiError } from './api-client.js'; +import { getConfig } from './config.js'; +import { registerCourseDraftTools } from './tools/course-drafts.js'; + +async function main() { + const config = getConfig(); + const apiClient = new ClassroomIoApiClient(config); + const server = new McpServer({ + name: 'classroomio-course-authoring', + version: '0.0.1' + }); + + registerCourseDraftTools(server, apiClient); + + const transport = new StdioServerTransport(); + await server.connect(transport); + + process.stdin.on('close', () => { + void server.close(); + }); +} + +main().catch((error: unknown) => { + if (error instanceof ClassroomIoApiError) { + console.error( + JSON.stringify({ + error: error.message, + status: error.status, + code: error.code, + field: error.field + }) + ); + process.exit(1); + } + + console.error(error); + process.exit(1); +}); diff --git a/packages/mcp/src/tools/course-drafts.ts b/packages/mcp/src/tools/course-drafts.ts new file mode 100644 index 00000000..d4b2faae --- /dev/null +++ b/packages/mcp/src/tools/course-drafts.ts @@ -0,0 +1,60 @@ +import { + ZCourseImportDraftCreate, + ZCourseImportDraftGetParam, + ZCourseImportDraftPublish, + ZCourseImportDraftUpdate +} from '@cio/utils/validation/course-import'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js'; + +import type { ClassroomIoApiClient } from '../api-client.js'; + +export const ZUpdateCourseDraftToolInput = ZCourseImportDraftUpdate.extend({ + draftId: ZCourseImportDraftGetParam.shape.draftId +}); + +export const ZPublishCourseDraftToolInput = ZCourseImportDraftPublish.extend({ + draftId: ZCourseImportDraftGetParam.shape.draftId +}); + +const createCourseDraftShape = ZCourseImportDraftCreate.shape as unknown as ZodRawShapeCompat; +const getCourseDraftShape = ZCourseImportDraftGetParam.shape as unknown as ZodRawShapeCompat; +const updateCourseDraftShape = ZUpdateCourseDraftToolInput.shape as unknown as ZodRawShapeCompat; +const publishCourseDraftShape = ZPublishCourseDraftToolInput.shape as unknown as ZodRawShapeCompat; + +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('get_course_draft', getCourseDraftShape, async (args) => { + const { draftId } = ZCourseImportDraftGetParam.parse(args); + const result = await apiClient.getCourseDraft(draftId); + 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('publish_course_draft', publishCourseDraftShape, async (args) => { + const { draftId, ...payload } = ZPublishCourseDraftToolInput.parse(args); + const result = await apiClient.publishCourseDraft(draftId, payload); + return jsonContent(result); + }); +} + +function jsonContent(data: unknown) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(data) + } + ] + }; +} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 00000000..3cba4eee --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@cio/tsconfig/base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "module": "ESNext", + "target": "ES2022", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tsconfig.tsbuildinfo"], + "references": [{ "path": "../utils" }], + "tsc-alias": { + "resolveFullPaths": true, + "verbose": false + } +}