From fcc45dd9bcb70f013cf3ba44d8e54bf6b1d21128 Mon Sep 17 00:00:00 2001 From: "Thomas G." <1809566+tomgrv@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:43:33 +0000 Subject: [PATCH 1/3] feat: add initial configuration files for development environment and versioning --- .devcontainer/devcontainer.json | 20 ++++++++ .gitattributes | 8 +++ .github/instructions/general.instructions.md | 21 ++++++++ .github/skills/feature-common-utils/SKILL.md | 38 +++++++++++++++ .github/skills/feature-gitversion/SKILL.md | 36 ++++++++++++++ .gitignore | 1 + .gitversion | 3 ++ package.json | 51 ++++++++++++++++++++ 8 files changed, 178 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100755 .gitattributes create mode 100755 .github/instructions/general.instructions.md create mode 100755 .github/skills/feature-common-utils/SKILL.md create mode 100755 .github/skills/feature-gitversion/SKILL.md create mode 100755 .gitversion create mode 100644 package.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..5d6d7bd --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,20 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:noble", + "name": "", + "features": { + "ghcr.io/tomgrv/devcontainer-features/githooks:5": {}, + "ghcr.io/tomgrv/devcontainer-features/gitutils:5": {}, + "ghcr.io/tomgrv/devcontainer-features/gitversion:7": {} + }, + "remoteEnv": {}, + "postCreateCommand": [ + "mkdir -p /home/vscode/.ssh && chmod 700 /home/vscode/.ssh && ssh-keyscan github.com >>/home/vscode/.ssh/known_hosts", + "sudo chown -Rf vscode:vscode ${containerWorkspaceFolder:-.}/* ${containerWorkspaceFolder:-.}/.*", + "sudo find ${containerWorkspaceFolder:-.} -mindepth 1 -type d -exec chmod 755 {} +" + ], + "postStartCommand": [ + "git config --global --add safe.directory ${containerWorkspaceFolder:-.}", + "test -z \"$CODESPACES\" && git config --global gpg.program gpg2" + ], + "customizations": {} +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100755 index 0000000..3d34a90 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf +*.css linguist-vendored +*.scss linguist-vendored +*.js linguist-vendored +*.json merge=json +CHANGELOG.md export-ignore diff --git a/.github/instructions/general.instructions.md b/.github/instructions/general.instructions.md new file mode 100755 index 0000000..2ad8873 --- /dev/null +++ b/.github/instructions/general.instructions.md @@ -0,0 +1,21 @@ +--- +applyTo: '**' +--- + + + +## Minimal Changes Discipline + +Make the smallest possible change to address the specific request. Do not modify files unrelated to the task. + +- **Package files** (`package.json`, `package-lock.json`): only modify if explicitly required for the task. +- **Build artifacts and symlinks**: do not commit unless they are the direct target of the request. +- **Repository setup** (symlinks, environment bootstrapping): use temporarily for development/testing only; do not commit. +- **Infrastructure changes**: avoid unless specifically requested. + +When working on any task: + +1. Identify the **exact files** that need to change. +2. Change **only** those files. +3. Use temporary local setup for testing; revert any unrelated side effects before committing. +4. Focus on the **specific feature or fix** requested, not general improvements. diff --git a/.github/skills/feature-common-utils/SKILL.md b/.github/skills/feature-common-utils/SKILL.md new file mode 100755 index 0000000..d30bf7b --- /dev/null +++ b/.github/skills/feature-common-utils/SKILL.md @@ -0,0 +1,38 @@ +--- +name: feature-common-utils +description: Shared shell utilities for JSON validation, normalization, and reusable zz_* helper scripts. +--- + + + +# common-utils + +## Description + +Use this feature as a shared utility layer for shell-based automation across features (`jq`, `dos2unix`, and `zz_*` helper scripts). + +## Commands + +- `validate-json [options] ` - Validate JSON against a schema. +- `normalize-json [options] ` - Normalize JSON order/format using schema rules. +- `zz_dist [options]` - Copy `zz_*` helpers to a target directory. +- `zz_args` - Parse command-line arguments in shell scripts. +- `zz_log` - Emit structured/colorized shell logs. +- `zz_json` - Read or manipulate JSON from shell scripts. + +## Use For + +- JSON validation/normalization workflows (`validate-json`, `normalize-json`). +- Shell scripting with standardized logging, argument parsing, and prompts (`zz_log`, `zz_args`, `zz_ask`). +- Distributing shared helper scripts into project folders (`zz_dist`). + +## Do Not Use For + +- Feature-specific business logic. +- Git workflow automation (use `gitutils` or `githooks`). + +## Agent Guidance + +- Reuse existing `zz_*` scripts before adding new helpers. +- Prefer `normalize-json`/`validate-json` in lint pipelines for schema-safe edits. +- Keep automation generic and composable for cross-feature reuse. diff --git a/.github/skills/feature-gitversion/SKILL.md b/.github/skills/feature-gitversion/SKILL.md new file mode 100755 index 0000000..3f29429 --- /dev/null +++ b/.github/skills/feature-gitversion/SKILL.md @@ -0,0 +1,36 @@ +--- +name: feature-gitversion +description: Semantic versioning utilities that derive versions from git history and update tags/changelog. +--- + + + +# gitversion + +## Description + +Use this feature when an agent must derive application version numbers from Git history in a consistent way. + +## Commands + +- `gv` - Compute version metadata from Git history. +- `bump-version` - Update version fields/files from computed version. +- `bump-tag` - Create/update version tag from release version. +- `bump-changelog` - Generate or update changelog for release context. + +## Use For + +- Calculating semantic versions from branch/commit state. +- Providing version metadata for build/release automation. +- Aligning local version behavior with CI versioning. + +## Do Not Use For + +- Manual tag/edit workflows without GitVersion involvement. +- Hook or lint orchestration (use `githooks`). + +## Agent Guidance + +- Use the installed GitVersion tooling as the source of truth for computed versions. +- Keep versioning logic centralized; avoid duplicating derivation rules in scripts. +- Pair with release helpers only when version flow is explicitly required. diff --git a/.gitignore b/.gitignore index 8626afa..ffb0db2 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ Thumbs.db .idea/ *.swp *.swo +./.gitattributes diff --git a/.gitversion b/.gitversion new file mode 100755 index 0000000..bd951cf --- /dev/null +++ b/.gitversion @@ -0,0 +1,3 @@ +major-version-bump-message: "^(build|ci|docs|feat|fix|perf|refactor|revert|style|test)(\\([\\w\\s-]*\\))?(!:|:.*\\n\\n((.+\\n)+\\n)?BREAKING CHANGE:\\s.+)" +minor-version-bump-message: "^(feat)(\\([\\w\\s-]*\\))?:" +patch-version-bump-message: "^(build|ci|docs|fix|perf|refactor|revert|style|test)(\\([\\w\\s-]*\\))?:" diff --git a/package.json b/package.json new file mode 100644 index 0000000..3a9d4a1 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "bump-changelog": { + "types": [ + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "feat", + "section": "Features" + } + ] + }, + "bump-version": { + "files": [ + { + "filename": "VERSION", + "type": "plain-text" + }, + { + "filename": "composer.json", + "type": "json" + }, + { + "filename": "devcontainer-feature.json", + "type": "json@ws" + }, + { + "filename": "package.json", + "type": "json" + }, + { + "filename": "package.json", + "type": "json@ws" + } + ] + }, + "commit-and-tag-version": { + "scripts": { + "prebump": "gitversion -config .gitversion -showvariable SemVer" + } + }, + "devDependencies": { + "npm-check-updates": "*" + }, + "private": true, + "scripts": { + "update": "npm-check-updates -i -u", + "update-all": "npm run update -w --root" + } +} From 53c3a341a254e27e41977614100becf761f925a4 Mon Sep 17 00:00:00 2001 From: "Thomas G." <1809566+tomgrv@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:31:04 +0100 Subject: [PATCH 2/3] feat(oc_mentor): initial OpenClassrooms RPA + MCP server scaffold Adds a standalone oc_mentor/ package with 8 MCP tools backed by a Playwright connector to the OpenClassrooms mentor portal: - oc_login / oc_status (auth + health) - oc_list_sessions / oc_get_session / oc_update_session_notes (sessions) - oc_list_projects / oc_get_project / oc_submit_evaluation (evaluations) --- oc_mentor/.env.example | 9 ++ oc_mentor/.gitignore | 5 + oc_mentor/README.md | 83 +++++++++++ oc_mentor/package.json | 26 ++++ oc_mentor/src/connector/auth.ts | 94 ++++++++++++ oc_mentor/src/connector/browser.ts | 55 +++++++ oc_mentor/src/connector/projects.ts | 223 ++++++++++++++++++++++++++++ oc_mentor/src/connector/sessions.ts | 188 +++++++++++++++++++++++ oc_mentor/src/index.ts | 8 + oc_mentor/src/server.ts | 210 ++++++++++++++++++++++++++ oc_mentor/src/types.ts | 51 +++++++ oc_mentor/tsconfig.json | 15 ++ 12 files changed, 967 insertions(+) create mode 100644 oc_mentor/.env.example create mode 100644 oc_mentor/.gitignore create mode 100644 oc_mentor/README.md create mode 100644 oc_mentor/package.json create mode 100644 oc_mentor/src/connector/auth.ts create mode 100644 oc_mentor/src/connector/browser.ts create mode 100644 oc_mentor/src/connector/projects.ts create mode 100644 oc_mentor/src/connector/sessions.ts create mode 100644 oc_mentor/src/index.ts create mode 100644 oc_mentor/src/server.ts create mode 100644 oc_mentor/src/types.ts create mode 100644 oc_mentor/tsconfig.json diff --git a/oc_mentor/.env.example b/oc_mentor/.env.example new file mode 100644 index 0000000..e16298f --- /dev/null +++ b/oc_mentor/.env.example @@ -0,0 +1,9 @@ +# OpenClassrooms credentials +OC_EMAIL=your@email.com +OC_PASSWORD=yourpassword + +# Optional: override where browser auth state is persisted (default: ~/.oc_mentor/auth-state.json) +# AUTH_STATE_PATH=/custom/path/auth-state.json + +# Optional: set to "false" to run browser in headed mode for debugging +# PLAYWRIGHT_HEADLESS=false diff --git a/oc_mentor/.gitignore b/oc_mentor/.gitignore new file mode 100644 index 0000000..ebf502f --- /dev/null +++ b/oc_mentor/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.auth-state.json +~/.oc_mentor/ diff --git a/oc_mentor/README.md b/oc_mentor/README.md new file mode 100644 index 0000000..e234f1e --- /dev/null +++ b/oc_mentor/README.md @@ -0,0 +1,83 @@ +# oc_mentor + +OpenClassrooms mentor RPA connector + MCP server for Claude Code. + +Exposes 8 tools that let Claude read and write to your OC mentor dashboard via Playwright browser automation: + +| Tool | Description | +|---|---| +| `oc_login` | Authenticate (called automatically on first use) | +| `oc_status` | Auth state, student count, next session | +| `oc_list_sessions` | List upcoming/past sessions | +| `oc_get_session` | Session detail + notes | +| `oc_update_session_notes` | Write session notes | +| `oc_list_projects` | List student projects (pending/evaluated) | +| `oc_get_project` | Project brief, deliverables, competencies | +| `oc_submit_evaluation` | Submit pass/fail evaluation with feedback | + +## Setup + +### 1. Install dependencies + +```bash +npm install +npx playwright install chromium +``` + +### 2. Configure credentials + +```bash +cp .env.example .env +# Edit .env and set OC_EMAIL and OC_PASSWORD +``` + +### 3. Build + +```bash +npm run build +``` + +### 4. Test the server + +```bash +echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | node dist/index.js +``` + +You should see a JSON response listing all 8 tools. + +## Add to Claude Code + +Add to `~/.claude/settings.json` (or a project-level `.mcp.json`): + +```json +{ + "mcpServers": { + "oc_mentor": { + "command": "node", + "args": ["/absolute/path/to/oc_mentor/dist/index.js"], + "env": { + "OC_EMAIL": "your@email.com", + "OC_PASSWORD": "yourpassword" + } + } + } +} +``` + +Restart Claude Code after editing settings. The `oc_*` tools will appear in the tool list. + +## Debug mode + +To run the browser in headed (visible) mode for troubleshooting: + +```bash +PLAYWRIGHT_HEADLESS=false node dist/index.js +``` + +## Session persistence + +After the first login, auth state is saved to `~/.oc_mentor/auth-state.json` and reused for up to 8 hours before re-authenticating. + +## Notes on selectors + +The Playwright selectors in `src/connector/` target OC's current DOM. If OpenClassrooms updates their frontend, selectors may need adjusting. Run in headed mode and use browser DevTools to find updated selectors. diff --git a/oc_mentor/package.json b/oc_mentor/package.json new file mode 100644 index 0000000..ed5529e --- /dev/null +++ b/oc_mentor/package.json @@ -0,0 +1,26 @@ +{ + "name": "oc_mentor", + "version": "1.0.0", + "description": "OpenClassrooms mentor RPA connector + MCP server for Claude Code", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "dotenv": "^16.4.5", + "playwright": "^1.49.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/oc_mentor/src/connector/auth.ts b/oc_mentor/src/connector/auth.ts new file mode 100644 index 0000000..1804d89 --- /dev/null +++ b/oc_mentor/src/connector/auth.ts @@ -0,0 +1,94 @@ +import { getContext, saveSession, hasValidSession } from "./browser.js"; +import type { OCAuthStatus } from "../types.js"; + +const OC_LOGIN_URL = "https://openclassrooms.com/en/login"; +const OC_DASHBOARD_URL = "https://openclassrooms.com/en/mentor/dashboard"; + +export async function ensureLoggedIn(): Promise { + if (hasValidSession()) return; + await login(); +} + +export async function login(): Promise { + const email = process.env.OC_EMAIL; + const password = process.env.OC_PASSWORD; + + if (!email || !password) { + throw new Error("OC_EMAIL and OC_PASSWORD environment variables are required"); + } + + const ctx = await getContext(); + const page = await ctx.newPage(); + + try { + await page.goto(OC_LOGIN_URL, { waitUntil: "networkidle" }); + + // Fill credentials + await page.fill('input[name="email"], input[type="email"]', email); + await page.fill('input[name="password"], input[type="password"]', password); + await page.click('button[type="submit"], input[type="submit"]'); + + // Wait for redirect after login + await page.waitForURL((url) => !url.href.includes("/login"), { + timeout: 15000, + }); + + // Navigate to mentor dashboard to verify access + await page.goto(OC_DASHBOARD_URL, { waitUntil: "networkidle" }); + + const isMentor = page.url().includes("/mentor"); + if (!isMentor) { + throw new Error("Login succeeded but no mentor dashboard found. Check account permissions."); + } + + await saveSession(); + } finally { + await page.close(); + } +} + +export async function getStatus(): Promise { + if (!hasValidSession()) { + return { loggedIn: false }; + } + + const ctx = await getContext(); + const page = await ctx.newPage(); + + try { + await page.goto(OC_DASHBOARD_URL, { waitUntil: "networkidle" }); + + if (!page.url().includes("/mentor")) { + return { loggedIn: false }; + } + + // Extract student count from dashboard + const studentCountText = await page + .locator('[data-testid="student-count"], .student-count, .students-count') + .first() + .textContent() + .catch(() => null); + + const studentCount = studentCountText + ? parseInt(studentCountText.replace(/\D/g, ""), 10) + : undefined; + + // Extract next session from dashboard + const nextSessionText = await page + .locator('[data-testid="next-session"], .next-session-date, .upcoming-session') + .first() + .textContent() + .catch(() => null); + + const email = process.env.OC_EMAIL; + + return { + loggedIn: true, + email, + studentCount: isNaN(studentCount ?? NaN) ? undefined : studentCount, + nextSession: nextSessionText?.trim() ?? undefined, + }; + } finally { + await page.close(); + } +} diff --git a/oc_mentor/src/connector/browser.ts b/oc_mentor/src/connector/browser.ts new file mode 100644 index 0000000..b7871d2 --- /dev/null +++ b/oc_mentor/src/connector/browser.ts @@ -0,0 +1,55 @@ +import { chromium, BrowserContext } from "playwright"; +import { existsSync, mkdirSync, statSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; + +const AUTH_STATE_PATH = + process.env.AUTH_STATE_PATH ?? join(homedir(), ".oc_mentor", "auth-state.json"); + +const SESSION_MAX_AGE_MS = 8 * 60 * 60 * 1000; // 8 hours + +let _context: BrowserContext | null = null; + +export async function getContext(): Promise { + if (_context) return _context; + + const headless = process.env.PLAYWRIGHT_HEADLESS !== "false"; + const stateDir = join(homedir(), ".oc_mentor"); + + if (!existsSync(stateDir)) { + mkdirSync(stateDir, { recursive: true }); + } + + const hasState = + existsSync(AUTH_STATE_PATH) && + Date.now() - statSync(AUTH_STATE_PATH).mtimeMs < SESSION_MAX_AGE_MS; + + _context = await chromium.launchPersistentContext("", { + headless, + storageState: hasState ? AUTH_STATE_PATH : undefined, + viewport: { width: 1280, height: 800 }, + userAgent: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + }); + + return _context; +} + +export async function saveSession(): Promise { + if (!_context) return; + await _context.storageState({ path: AUTH_STATE_PATH }); +} + +export async function closeBrowser(): Promise { + if (_context) { + await _context.close(); + _context = null; + } +} + +export function hasValidSession(): boolean { + return ( + existsSync(AUTH_STATE_PATH) && + Date.now() - statSync(AUTH_STATE_PATH).mtimeMs < SESSION_MAX_AGE_MS + ); +} diff --git a/oc_mentor/src/connector/projects.ts b/oc_mentor/src/connector/projects.ts new file mode 100644 index 0000000..35a1b16 --- /dev/null +++ b/oc_mentor/src/connector/projects.ts @@ -0,0 +1,223 @@ +import { getContext } from "./browser.js"; +import { ensureLoggedIn } from "./auth.js"; +import type { OCProject, OCCompetency, OCEvaluation } from "../types.js"; + +const OC_STUDENTS_URL = "https://openclassrooms.com/en/mentor/students"; + +export async function listProjects( + filter: "pending" | "evaluated" | "all" = "all" +): Promise { + await ensureLoggedIn(); + + const ctx = await getContext(); + const page = await ctx.newPage(); + + try { + await page.goto(OC_STUDENTS_URL, { waitUntil: "networkidle" }); + + // Navigate to projects/deliverables section if a tab exists + const projectsTab = page.locator( + '[data-testid="projects-tab"], a[href*="projects"], button[class*="project"]' + ).first(); + + if (await projectsTab.isVisible().catch(() => false)) { + await projectsTab.click(); + await page.waitForLoadState("networkidle"); + } + + const cards = await page + .locator('[data-testid="project-card"], .project-card, [class*="deliverable"]') + .all(); + + const projects: OCProject[] = []; + + for (const card of cards) { + const id = + (await card.getAttribute("data-project-id")) ?? + (await card.getAttribute("data-id")) ?? + String(projects.length + 1); + + const title = await card + .locator('[class*="title"], [data-testid="project-title"], h2, h3') + .first() + .textContent() + .catch(() => ""); + + const studentName = await card + .locator('[class*="student-name"], [data-testid="student-name"]') + .first() + .textContent() + .catch(() => ""); + + const statusText = await card + .locator('[class*="status"], [data-testid="project-status"]') + .first() + .textContent() + .catch(() => ""); + + const status = resolveProjectStatus(statusText); + + if (filter === "pending" && status !== "pending_evaluation") continue; + if (filter === "evaluated" && status !== "evaluated") continue; + + projects.push({ + id, + title: title?.trim() ?? "", + studentName: studentName?.trim() ?? "", + studentId: "", + status, + deliverableLinks: [], + competencies: [], + }); + } + + return projects; + } finally { + await page.close(); + } +} + +export async function getProject(projectId: string): Promise { + await ensureLoggedIn(); + + const ctx = await getContext(); + const page = await ctx.newPage(); + + try { + // OC project detail pages follow /mentor/students/{studentId}/projects/{projectId} + // We navigate via the project list and find the right one + await page.goto(`${OC_STUDENTS_URL}`, { waitUntil: "networkidle" }); + + // Try direct URL patterns OC might use + const directUrl = `https://openclassrooms.com/en/mentor/projects/${projectId}`; + await page.goto(directUrl, { waitUntil: "networkidle" }); + + const title = await page + .locator('[data-testid="project-title"], .project-title, h1') + .first() + .textContent() + .catch(() => ""); + + const studentName = await page + .locator('[data-testid="student-name"], [class*="student-name"]') + .first() + .textContent() + .catch(() => ""); + + const brief = await page + .locator('[data-testid="project-brief"], [class*="brief"], [class*="description"]') + .first() + .textContent() + .catch(() => ""); + + // Collect deliverable links + const linkEls = await page + .locator('[data-testid="deliverable-link"], a[href*="deliverable"], [class*="deliverable"] a') + .all(); + const deliverableLinks: string[] = []; + for (const el of linkEls) { + const href = await el.getAttribute("href").catch(() => null); + if (href) deliverableLinks.push(href); + } + + // Collect competencies + const compEls = await page + .locator('[data-testid="competency"], [class*="competency"], [class*="skill"]') + .all(); + const competencies: OCCompetency[] = []; + for (const el of compEls) { + const compId = + (await el.getAttribute("data-competency-id")) ?? + (await el.getAttribute("data-id")) ?? + String(competencies.length + 1); + const compTitle = await el + .locator('[class*="title"], [class*="name"], span') + .first() + .textContent() + .catch(() => ""); + competencies.push({ id: compId, title: compTitle?.trim() ?? "" }); + } + + return { + id: projectId, + title: title?.trim() ?? "", + studentName: studentName?.trim() ?? "", + studentId: "", + status: "pending_evaluation", + brief: brief?.trim() ?? undefined, + deliverableLinks, + competencies, + }; + } finally { + await page.close(); + } +} + +export async function submitEvaluation(evaluation: OCEvaluation): Promise { + await ensureLoggedIn(); + + const ctx = await getContext(); + const page = await ctx.newPage(); + + try { + const evalUrl = `https://openclassrooms.com/en/mentor/projects/${evaluation.projectId}/evaluate`; + await page.goto(evalUrl, { waitUntil: "networkidle" }); + + // Select pass/fail decision + const decisionSelector = + evaluation.decision === "pass" + ? '[data-testid="decision-pass"], input[value="pass"], label[for*="pass"]' + : '[data-testid="decision-fail"], input[value="fail"], label[for*="fail"]'; + + await page.locator(decisionSelector).first().click(); + + // Fill competency levels + for (const comp of evaluation.competencies) { + const levelInput = page + .locator( + `[data-competency-id="${comp.id}"] input[type="range"],` + + `[data-competency-id="${comp.id}"] select,` + + `[data-id="${comp.id}"] input[type="number"]` + ) + .first(); + + if (await levelInput.isVisible().catch(() => false)) { + await levelInput.fill(String(comp.level)); + } + } + + // Fill feedback textarea + const feedbackArea = page + .locator('textarea[name*="feedback"], textarea[data-testid="feedback"], textarea[class*="feedback"]') + .first(); + await feedbackArea.fill(evaluation.feedback); + + // Submit + const submitBtn = page + .locator('button[data-testid="submit-evaluation"], button[type="submit"]') + .first(); + await submitBtn.click(); + + // Wait for confirmation + await page + .locator('[class*="success"], [role="alert"], [data-testid="evaluation-submitted"]') + .first() + .waitFor({ timeout: 15000 }) + .catch(() => { + // Some pages redirect on success without a banner + }); + } finally { + await page.close(); + } +} + +function resolveProjectStatus(statusText: string | null): OCProject["status"] { + const text = (statusText ?? "").toLowerCase(); + if (text.includes("evaluat") && (text.includes("done") || text.includes("complet"))) { + return "evaluated"; + } + if (text.includes("pending") || text.includes("waiting") || text.includes("à évaluer")) { + return "pending_evaluation"; + } + return "in_progress"; +} diff --git a/oc_mentor/src/connector/sessions.ts b/oc_mentor/src/connector/sessions.ts new file mode 100644 index 0000000..ad69d60 --- /dev/null +++ b/oc_mentor/src/connector/sessions.ts @@ -0,0 +1,188 @@ +import { getContext } from "./browser.js"; +import { ensureLoggedIn } from "./auth.js"; +import type { OCSession } from "../types.js"; + +const OC_SESSIONS_URL = "https://openclassrooms.com/en/mentor/sessions"; + +export async function listSessions( + filter: "upcoming" | "past" | "all" = "all", + limit = 20 +): Promise { + await ensureLoggedIn(); + + const ctx = await getContext(); + const page = await ctx.newPage(); + + try { + const url = + filter === "upcoming" + ? `${OC_SESSIONS_URL}?tab=upcoming` + : filter === "past" + ? `${OC_SESSIONS_URL}?tab=past` + : OC_SESSIONS_URL; + + await page.goto(url, { waitUntil: "networkidle" }); + + // Each session card — selectors may need updating based on OC markup + const sessionCards = await page + .locator('[data-testid="session-card"], .session-card, .session-item, li[class*="session"]') + .all(); + + const sessions: OCSession[] = []; + + for (const card of sessionCards.slice(0, limit)) { + const id = + (await card.getAttribute("data-session-id")) ?? + (await card.getAttribute("data-id")) ?? + String(sessions.length + 1); + + const dateText = await card + .locator('[class*="date"], time, [data-testid="session-date"]') + .first() + .textContent() + .catch(() => ""); + + const studentName = await card + .locator('[class*="student-name"], [class*="student"] [class*="name"], [data-testid="student-name"]') + .first() + .textContent() + .catch(() => ""); + + const statusText = await card + .locator('[class*="status"], [data-testid="session-status"]') + .first() + .textContent() + .catch(() => ""); + + const hasNotesEl = await card + .locator('[class*="notes"], [data-testid="session-notes"]') + .first() + .isVisible() + .catch(() => false); + + const status = resolveStatus(statusText, dateText); + + sessions.push({ + id, + date: dateText?.trim() ?? "", + studentName: studentName?.trim() ?? "", + studentId: "", + status, + hasNotes: hasNotesEl, + }); + } + + return sessions; + } finally { + await page.close(); + } +} + +export async function getSession(sessionId: string): Promise { + await ensureLoggedIn(); + + const ctx = await getContext(); + const page = await ctx.newPage(); + + try { + await page.goto(`${OC_SESSIONS_URL}/${sessionId}`, { waitUntil: "networkidle" }); + + const date = await page + .locator('[data-testid="session-date"], .session-date, time') + .first() + .textContent() + .catch(() => ""); + + const studentName = await page + .locator('[data-testid="student-name"], [class*="student-name"]') + .first() + .textContent() + .catch(() => ""); + + const notesText = await page + .locator('textarea[class*="notes"], [data-testid="session-notes"], [class*="session-notes"]') + .first() + .inputValue() + .catch(async () => { + // Fallback: read as text content if not a textarea + return page + .locator('[data-testid="session-notes"], [class*="session-notes"]') + .first() + .textContent() + .catch(() => ""); + }); + + const statusText = await page + .locator('[data-testid="session-status"], [class*="status"]') + .first() + .textContent() + .catch(() => ""); + + return { + id: sessionId, + date: date?.trim() ?? "", + studentName: studentName?.trim() ?? "", + studentId: "", + status: resolveStatus(statusText), + hasNotes: (notesText?.trim().length ?? 0) > 0, + notes: notesText?.trim() ?? undefined, + }; + } finally { + await page.close(); + } +} + +export async function updateSessionNotes( + sessionId: string, + notes: string +): Promise { + await ensureLoggedIn(); + + const ctx = await getContext(); + const page = await ctx.newPage(); + + try { + await page.goto(`${OC_SESSIONS_URL}/${sessionId}`, { waitUntil: "networkidle" }); + + const notesField = page.locator( + 'textarea[class*="notes"], [data-testid="session-notes-input"], textarea[name*="notes"]' + ).first(); + + await notesField.waitFor({ timeout: 10000 }); + await notesField.fill(notes); + + // Submit / save + const saveBtn = page.locator( + 'button[data-testid="save-notes"], button[class*="save"], button[type="submit"]' + ).first(); + await saveBtn.click(); + + // Wait for confirmation + await page + .locator('[class*="success"], [data-testid="save-success"], [role="alert"]') + .first() + .waitFor({ timeout: 8000 }) + .catch(() => { + // Ignore if no explicit confirmation UI + }); + } finally { + await page.close(); + } +} + +function resolveStatus( + statusText: string | null, + dateText?: string | null +): OCSession["status"] { + const text = (statusText ?? "").toLowerCase(); + if (text.includes("cancel")) return "cancelled"; + if (text.includes("complet") || text.includes("done") || text.includes("past")) { + return "completed"; + } + // Fall back to date comparison + if (dateText) { + const parsed = new Date(dateText); + if (!isNaN(parsed.getTime()) && parsed < new Date()) return "completed"; + } + return "upcoming"; +} diff --git a/oc_mentor/src/index.ts b/oc_mentor/src/index.ts new file mode 100644 index 0000000..2e93aea --- /dev/null +++ b/oc_mentor/src/index.ts @@ -0,0 +1,8 @@ +import "dotenv/config"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createServer } from "./server.js"; + +const server = createServer(); +const transport = new StdioServerTransport(); + +await server.connect(transport); diff --git a/oc_mentor/src/server.ts b/oc_mentor/src/server.ts new file mode 100644 index 0000000..2765b73 --- /dev/null +++ b/oc_mentor/src/server.ts @@ -0,0 +1,210 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { login, getStatus } from "./connector/auth.js"; +import { + listSessions, + getSession, + updateSessionNotes, +} from "./connector/sessions.js"; +import { + listProjects, + getProject, + submitEvaluation, +} from "./connector/projects.js"; + +export function createServer(): McpServer { + const server = new McpServer({ + name: "oc_mentor", + version: "1.0.0", + }); + + // ── Utility ─────────────────────────────────────────────────────────────── + + server.registerTool( + "oc_login", + { + title: "Log in to OpenClassrooms", + description: + "Authenticate with the OpenClassrooms mentor portal using OC_EMAIL and OC_PASSWORD env vars. " + + "Called automatically on first use; call explicitly to refresh a stale session.", + inputSchema: z.object({}), + }, + async () => { + await login(); + return { content: [{ type: "text", text: "Logged in successfully." }] }; + } + ); + + server.registerTool( + "oc_status", + { + title: "OC Mentor Status", + description: + "Check current authentication state, number of assigned students, and next upcoming session.", + inputSchema: z.object({}), + }, + async () => { + const status = await getStatus(); + return { + content: [{ type: "text", text: JSON.stringify(status, null, 2) }], + }; + } + ); + + // ── Sessions ────────────────────────────────────────────────────────────── + + server.registerTool( + "oc_list_sessions", + { + title: "List Mentoring Sessions", + description: + "List your mentoring sessions. Returns date, student name, status, and whether notes exist.", + inputSchema: z.object({ + filter: z + .enum(["upcoming", "past", "all"]) + .optional() + .describe("Filter sessions by time: 'upcoming', 'past', or 'all' (default)"), + limit: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe("Maximum number of sessions to return (default 20)"), + }), + }, + async ({ filter, limit }) => { + const sessions = await listSessions(filter ?? "all", limit ?? 20); + return { + content: [{ type: "text", text: JSON.stringify(sessions, null, 2) }], + }; + } + ); + + server.registerTool( + "oc_get_session", + { + title: "Get Session Detail", + description: + "Get full details of a specific mentoring session, including the notes text.", + inputSchema: z.object({ + session_id: z.string().describe("The session ID from oc_list_sessions"), + }), + }, + async ({ session_id }) => { + const session = await getSession(session_id); + return { + content: [{ type: "text", text: JSON.stringify(session, null, 2) }], + }; + } + ); + + server.registerTool( + "oc_update_session_notes", + { + title: "Update Session Notes", + description: + "Write or replace session notes. Notes must be submitted within 48 hours of the session.", + inputSchema: z.object({ + session_id: z.string().describe("The session ID from oc_list_sessions"), + notes: z.string().describe("The full notes content to save"), + }), + }, + async ({ session_id, notes }) => { + await updateSessionNotes(session_id, notes); + return { + content: [{ type: "text", text: `Notes saved for session ${session_id}.` }], + }; + } + ); + + // ── Projects / Evaluations ──────────────────────────────────────────────── + + server.registerTool( + "oc_list_projects", + { + title: "List Student Projects", + description: + "List student projects/deliverables assigned to you for evaluation.", + inputSchema: z.object({ + filter: z + .enum(["pending", "evaluated", "all"]) + .optional() + .describe( + "'pending' = awaiting evaluation, 'evaluated' = done, 'all' = both (default)" + ), + }), + }, + async ({ filter }) => { + const projects = await listProjects(filter ?? "all"); + return { + content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], + }; + } + ); + + server.registerTool( + "oc_get_project", + { + title: "Get Project Detail", + description: + "Get full project details: brief, deliverable links, and competencies to assess.", + inputSchema: z.object({ + project_id: z.string().describe("The project ID from oc_list_projects"), + }), + }, + async ({ project_id }) => { + const project = await getProject(project_id); + return { + content: [{ type: "text", text: JSON.stringify(project, null, 2) }], + }; + } + ); + + server.registerTool( + "oc_submit_evaluation", + { + title: "Submit Project Evaluation", + description: + "Record a project evaluation with decision (pass/fail), competency levels, and written feedback.", + inputSchema: z.object({ + project_id: z + .string() + .describe("The project ID from oc_list_projects"), + decision: z + .enum(["pass", "fail"]) + .describe("Overall evaluation decision"), + competencies: z + .array( + z.object({ + id: z.string().describe("Competency ID from oc_get_project"), + level: z + .number() + .int() + .min(1) + .max(4) + .describe("Competency level: 1=not validated, 2=partially, 3=validated, 4=exceeded"), + }) + ) + .describe("Competency assessments"), + feedback: z + .string() + .min(20) + .describe("Written feedback for the student (min 20 characters)"), + }), + }, + async ({ project_id, decision, competencies, feedback }) => { + await submitEvaluation({ projectId: project_id, decision, competencies, feedback }); + return { + content: [ + { + type: "text", + text: `Evaluation submitted for project ${project_id}: ${decision}.`, + }, + ], + }; + } + ); + + return server; +} diff --git a/oc_mentor/src/types.ts b/oc_mentor/src/types.ts new file mode 100644 index 0000000..346976c --- /dev/null +++ b/oc_mentor/src/types.ts @@ -0,0 +1,51 @@ +export interface OCStudent { + id: string; + name: string; + path: string; + currentProject?: string; + nextSessionDate?: string; +} + +export interface OCSession { + id: string; + date: string; + studentName: string; + studentId: string; + status: "upcoming" | "completed" | "cancelled"; + hasNotes: boolean; + notes?: string; + durationMinutes?: number; +} + +export interface OCProject { + id: string; + title: string; + studentName: string; + studentId: string; + status: "pending_evaluation" | "evaluated" | "in_progress"; + deliverableLinks: string[]; + competencies: OCCompetency[]; + brief?: string; + submittedAt?: string; +} + +export interface OCCompetency { + id: string; + title: string; + level?: number; +} + +export interface OCEvaluation { + projectId: string; + decision: "pass" | "fail"; + competencies: { id: string; level: number }[]; + feedback: string; +} + +export interface OCAuthStatus { + loggedIn: boolean; + email?: string; + studentCount?: number; + nextSession?: string; + sessionAge?: string; +} diff --git a/oc_mentor/tsconfig.json b/oc_mentor/tsconfig.json new file mode 100644 index 0000000..e75171b --- /dev/null +++ b/oc_mentor/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From e5d349a6b67a7aad9120fdf601ed6cd233064ea3 Mon Sep 17 00:00:00 2001 From: "Thomas G." <1809566+tomgrv@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:49:23 +0100 Subject: [PATCH 3/3] feat(oc_mentor): add Streamable HTTP transport for Claude.ai integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/http.ts — a Node built-in HTTP server using StreamableHTTPServerTransport with per-session state management and optional Bearer token auth (MCP_AUTH_TOKEN). Updates src/index.ts to switch between stdio (Claude Code) and HTTP (Claude.ai) based on MCP_TRANSPORT=http env var or --http argv flag. Adds start:http / dev:http npm scripts and updates README with Claude.ai Integrations setup instructions. --- oc_mentor/README.md | 82 ++++++++++++++++++++++-------- oc_mentor/package.json | 6 ++- oc_mentor/src/http.ts | 113 +++++++++++++++++++++++++++++++++++++++++ oc_mentor/src/index.ts | 16 ++++-- 4 files changed, 190 insertions(+), 27 deletions(-) create mode 100644 oc_mentor/src/http.ts diff --git a/oc_mentor/README.md b/oc_mentor/README.md index e234f1e..14d8b90 100644 --- a/oc_mentor/README.md +++ b/oc_mentor/README.md @@ -1,8 +1,9 @@ # oc_mentor -OpenClassrooms mentor RPA connector + MCP server for Claude Code. +OpenClassrooms mentor RPA connector + MCP server. +Works as both a **Claude Code tool** (stdio) and a **Claude.ai integration** (HTTP). -Exposes 8 tools that let Claude read and write to your OC mentor dashboard via Playwright browser automation: +Exposes 8 tools backed by Playwright browser automation against the OC mentor portal: | Tool | Description | |---|---| @@ -15,9 +16,11 @@ Exposes 8 tools that let Claude read and write to your OC mentor dashboard via P | `oc_get_project` | Project brief, deliverables, competencies | | `oc_submit_evaluation` | Submit pass/fail evaluation with feedback | +--- + ## Setup -### 1. Install dependencies +### 1. Install ```bash npm install @@ -28,7 +31,7 @@ npx playwright install chromium ```bash cp .env.example .env -# Edit .env and set OC_EMAIL and OC_PASSWORD +# Edit .env — set OC_EMAIL and OC_PASSWORD ``` ### 3. Build @@ -37,17 +40,11 @@ cp .env.example .env npm run build ``` -### 4. Test the server +--- -```bash -echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | node dist/index.js -``` +## Use with Claude Code (stdio) -You should see a JSON response listing all 8 tools. - -## Add to Claude Code - -Add to `~/.claude/settings.json` (or a project-level `.mcp.json`): +Add to `~/.claude/settings.json`: ```json { @@ -64,20 +61,65 @@ Add to `~/.claude/settings.json` (or a project-level `.mcp.json`): } ``` -Restart Claude Code after editing settings. The `oc_*` tools will appear in the tool list. +Restart Claude Code — the `oc_*` tools will appear in the tool list. + +--- -## Debug mode +## Use as a Claude.ai integration (HTTP) -To run the browser in headed (visible) mode for troubleshooting: +Claude.ai supports remote MCP servers via its **Integrations** settings. + +### Start the HTTP server ```bash -PLAYWRIGHT_HEADLESS=false node dist/index.js +# Set a secret token to protect the endpoint +MCP_AUTH_TOKEN=mysecrettoken npm run start:http +# → [oc_mentor] HTTP MCP server → http://localhost:3000/mcp ``` +For production, run behind a reverse proxy (nginx, Caddy) with TLS. +Set `PORT=` to change the port. + +### Add to Claude.ai + +1. Open **claude.ai → Settings → Integrations** +2. Click **Add custom integration** +3. Enter the server URL: `https://your-server.com/mcp` (or `http://localhost:3000/mcp` for local) +4. Set authentication: **Bearer token** → paste the value of `MCP_AUTH_TOKEN` +5. Save — the `oc_*` tools will appear in Claude.ai conversations + +### Test the endpoint + +```bash +curl -X POST http://localhost:3000/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer mysecrettoken" \ + -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}' +``` + +--- + +## Verify stdio mode + +```bash +echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | node dist/index.js +# → JSON with all 8 tools +``` + +--- + +## Debug (headed browser) + +```bash +PLAYWRIGHT_HEADLESS=false npm run start:http +``` + +--- + ## Session persistence -After the first login, auth state is saved to `~/.oc_mentor/auth-state.json` and reused for up to 8 hours before re-authenticating. +Auth state is saved to `~/.oc_mentor/auth-state.json` after first login and reused for up to 8 hours. -## Notes on selectors +## Selector maintenance -The Playwright selectors in `src/connector/` target OC's current DOM. If OpenClassrooms updates their frontend, selectors may need adjusting. Run in headed mode and use browser DevTools to find updated selectors. +OC's frontend may update its DOM — if tools stop returning data, run in headed mode and use DevTools to update selectors in `src/connector/`. diff --git a/oc_mentor/package.json b/oc_mentor/package.json index ed5529e..5352fa5 100644 --- a/oc_mentor/package.json +++ b/oc_mentor/package.json @@ -1,13 +1,15 @@ { "name": "oc_mentor", "version": "1.0.0", - "description": "OpenClassrooms mentor RPA connector + MCP server for Claude Code", + "description": "OpenClassrooms mentor RPA connector + MCP server for Claude Code and Claude.ai", "type": "module", "main": "dist/index.js", "scripts": { "build": "tsc", "start": "node dist/index.js", - "dev": "tsx src/index.ts" + "start:http": "MCP_TRANSPORT=http node dist/index.js", + "dev": "tsx src/index.ts", + "dev:http": "MCP_TRANSPORT=http tsx src/index.ts" }, "engines": { "node": ">=18" diff --git a/oc_mentor/src/http.ts b/oc_mentor/src/http.ts new file mode 100644 index 0000000..b170381 --- /dev/null +++ b/oc_mentor/src/http.ts @@ -0,0 +1,113 @@ +import "dotenv/config"; +import { createServer as createHttpServer } from "node:http"; +import { randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { createServer } from "./server.js"; + +const PORT = parseInt(process.env.PORT ?? "3000", 10); +const AUTH_TOKEN = process.env.MCP_AUTH_TOKEN; + +const transports = new Map(); + +function checkAuth(req: IncomingMessage, res: ServerResponse): boolean { + if (!AUTH_TOKEN) return true; + const auth = (req.headers["authorization"] ?? "") as string; + if (auth === `Bearer ${AUTH_TOKEN}`) return true; + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Unauthorized" })); + return false; +} + +function setCors(res: ServerResponse): void { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id"); +} + +async function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let data = ""; + req.on("data", (chunk: Buffer) => { data += chunk.toString(); }); + req.on("end", () => { + try { resolve(data ? JSON.parse(data) : undefined); } + catch { reject(new Error("Invalid JSON body")); } + }); + req.on("error", reject); + }); +} + +const httpServer = createHttpServer(async (req, res) => { + setCors(res); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (req.url !== "/mcp") { + res.writeHead(404); + res.end(); + return; + } + + if (!checkAuth(req, res)) return; + + try { + if (req.method === "POST") { + const body = await readBody(req); + const sessionId = req.headers["mcp-session-id"] as string | undefined; + + if (sessionId && transports.has(sessionId)) { + await transports.get(sessionId)!.handleRequest(req, res, body); + } else if (!sessionId && isInitializeRequest(body)) { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + transports.set(id, transport); + }, + }); + transport.onclose = () => { + if (transport.sessionId) transports.delete(transport.sessionId); + }; + const server = createServer(); + await server.connect(transport); + await transport.handleRequest(req, res, body); + } else { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Bad request: send an initialize request without a session ID" })); + } + } else if (req.method === "GET" || req.method === "DELETE") { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports.has(sessionId)) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing or unknown Mcp-Session-Id" })); + return; + } + await transports.get(sessionId)!.handleRequest(req, res); + } else { + res.writeHead(405); + res.end(); + } + } catch (err) { + console.error("[oc_mentor] request error:", err); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Internal server error" })); + } + } +}); + +process.on("SIGTERM", async () => { + for (const t of transports.values()) await t.close().catch(() => {}); + httpServer.close(() => process.exit(0)); +}); + +httpServer.listen(PORT, () => { + console.error(`[oc_mentor] HTTP MCP server → http://localhost:${PORT}/mcp`); + if (!AUTH_TOKEN) { + console.error("[oc_mentor] Warning: MCP_AUTH_TOKEN not set — server is unauthenticated"); + } +}); diff --git a/oc_mentor/src/index.ts b/oc_mentor/src/index.ts index 2e93aea..91ca015 100644 --- a/oc_mentor/src/index.ts +++ b/oc_mentor/src/index.ts @@ -1,8 +1,14 @@ import "dotenv/config"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { createServer } from "./server.js"; -const server = createServer(); -const transport = new StdioServerTransport(); +const mode = process.env.MCP_TRANSPORT ?? process.argv[2]; -await server.connect(transport); +if (mode === "http") { + await import("./http.js"); +} else { + const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js"); + const { createServer } = await import("./server.js"); + + const server = createServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); +}