Publish @classroomio/mcp package

This commit is contained in:
Rotimi Best
2026-03-13 18:01:46 +01:00
parent 6e6f825e89
commit 77a1188573
9 changed files with 531 additions and 0 deletions
+31
View File
@@ -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 }}
+204
View File
@@ -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 <cio_mcp_...>
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=<cio_mcp_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": "<cio_mcp_key>"
}
}'
```
## Codex Example
```bash
codex mcp add classroomio \
--env CLASSROOMIO_API_URL=https://api.classroomio.com \
--env CLASSROOMIO_API_KEY=<cio_mcp_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": "<cio_mcp_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.
+47
View File
@@ -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 <[email protected]>",
"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"
}
}
+15
View File
@@ -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'
}
});
+99
View File
@@ -0,0 +1,99 @@
import type {
TCourseImportDraftCreate,
TCourseImportDraftPublish,
TCourseImportDraftUpdate
} from '@cio/utils/validation/course-import';
import type { McpServerConfig } from './config.js';
type ApiSuccess<T> = {
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<TResponse>(
path: string,
options: {
method: 'GET' | 'POST' | 'PUT';
body?: unknown;
}
): Promise<TResponse> {
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<TResponse> | 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;
}
}
+13
View File
@@ -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<typeof ZConfig>;
export function getConfig(env: NodeJS.ProcessEnv = process.env): McpServerConfig {
return ZConfig.parse(env);
}
+41
View File
@@ -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);
});
+60
View File
@@ -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)
}
]
};
}
+21
View File
@@ -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
}
}