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 <[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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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