Publish @classroomio/mcp package
This commit is contained in:
@@ -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 }}
|
||||
@@ -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.
|
||||
@@ -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 <help@classroomio.com>",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user