campus-cli
Advanced tools
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| export declare function registerBannerTools(server: McpServer): void; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.registerBannerTools = registerBannerTools; | ||
| const zod_1 = require("zod"); | ||
| const client_js_1 = require("./api/client.js"); | ||
| const registration_js_1 = require("./api/registration.js"); | ||
| const login_js_1 = require("./auth/login.js"); | ||
| const schedule_js_1 = require("./schedule.js"); | ||
| function registerBannerTools(server) { | ||
| server.registerTool('campus_get_weekly_schedule', { | ||
| description: 'Get the student\'s UPC weekly class schedule from their Banner registrations. ' + | ||
| 'Returns classes grouped Monday through Sunday, including time, room, building, section and courses without scheduled meetings. ' + | ||
| 'Uses the active term by default; pass term to consult a registered past term.', | ||
| inputSchema: { | ||
| term: zod_1.z.string().regex(/^\d{6}$/, 'term must be a six-digit Banner term code, e.g. 202610').optional() | ||
| .describe('Optional Banner term code. Omit to use the active enrollment term.'), | ||
| }, | ||
| annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, | ||
| }, async ({ term }) => { | ||
| const client = (0, client_js_1.createBannerClient)(await (0, login_js_1.loadOrRefreshBannerSession)()); | ||
| const terms = await (0, registration_js_1.listStudentTerms)(client); | ||
| const selected = term | ||
| ? terms.find((candidate) => candidate.code === term) | ||
| : terms.find((candidate) => !candidate.viewOnly) ?? terms[0]; | ||
| if (term && !selected) | ||
| throw new Error(`No tienes matrícula registrada para el período ${term}`); | ||
| if (!selected) | ||
| throw new Error('No se encontraron períodos con matrícula en Banner'); | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify((0, schedule_js_1.weeklySchedule)(selected, await (0, registration_js_1.getRegistrations)(client, selected.code))) }], | ||
| }; | ||
| }); | ||
| } |
| import type { Registration, Term } from './types.js'; | ||
| /** Turns Banner's registration rows into a predictable, Monday-first weekly | ||
| * agenda. Keeping the raw course list alongside the days makes asynchronous | ||
| * courses visible instead of silently disappearing from the student's plan. */ | ||
| export declare function weeklySchedule(term: Term, registrations: Registration[]): { | ||
| term: { | ||
| code: string; | ||
| description: string; | ||
| }; | ||
| courses: { | ||
| courseCode: string; | ||
| courseTitle: string; | ||
| section: string; | ||
| credits: number | null; | ||
| scheduleType: string | null; | ||
| hasScheduledMeetings: boolean; | ||
| }[]; | ||
| week: { | ||
| day: 0 | 1 | 2 | 3 | 4 | 5 | 6; | ||
| label: "Lunes" | "Martes" | "Miércoles" | "Jueves" | "Viernes" | "Sábado" | "Domingo"; | ||
| classes: { | ||
| startsAt: string | null; | ||
| endsAt: string | null; | ||
| courseCode: string; | ||
| courseTitle: string; | ||
| section: string; | ||
| building: string | null; | ||
| room: string | null; | ||
| location: string | null; | ||
| }[]; | ||
| }[]; | ||
| }; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.weeklySchedule = weeklySchedule; | ||
| const WEEK_DAYS = [ | ||
| { day: 1, label: 'Lunes' }, | ||
| { day: 2, label: 'Martes' }, | ||
| { day: 3, label: 'Miércoles' }, | ||
| { day: 4, label: 'Jueves' }, | ||
| { day: 5, label: 'Viernes' }, | ||
| { day: 6, label: 'Sábado' }, | ||
| { day: 0, label: 'Domingo' }, | ||
| ]; | ||
| function formatTime(value) { | ||
| if (!value || !/^\d{3,4}$/.test(value)) | ||
| return null; | ||
| const normalized = value.padStart(4, '0'); | ||
| return `${normalized.slice(0, 2)}:${normalized.slice(2)}`; | ||
| } | ||
| function formatLocation(meeting) { | ||
| return [meeting.building, meeting.room].filter(Boolean).join(' · ') || null; | ||
| } | ||
| /** Banner's registration history includes rows that are no longer part of the | ||
| * student's current enrolment. Keep unfamiliar statuses visible rather than | ||
| * accidentally hiding a valid class, but never schedule explicitly inactive | ||
| * rows. */ | ||
| function isActiveRegistration(registration) { | ||
| return !/\b(drop(?:ped)?|withdraw(?:n)?|cancel(?:l?ed)?|retirad[oa]?|cancelad[oa]?)\b/i.test(registration.status ?? ''); | ||
| } | ||
| /** Turns Banner's registration rows into a predictable, Monday-first weekly | ||
| * agenda. Keeping the raw course list alongside the days makes asynchronous | ||
| * courses visible instead of silently disappearing from the student's plan. */ | ||
| function weeklySchedule(term, registrations) { | ||
| const activeRegistrations = registrations.filter(isActiveRegistration); | ||
| const classes = activeRegistrations.flatMap((registration) => registration.meetings.map((meeting) => ({ | ||
| day: meeting.day, | ||
| startsAt: formatTime(meeting.begin), | ||
| endsAt: formatTime(meeting.end), | ||
| courseCode: registration.courseCode, | ||
| courseTitle: registration.courseTitle, | ||
| section: registration.crn, | ||
| building: meeting.building, | ||
| room: meeting.room, | ||
| location: formatLocation(meeting), | ||
| }))); | ||
| const week = WEEK_DAYS.map(({ day, label }) => ({ | ||
| day, | ||
| label, | ||
| classes: classes | ||
| .filter((item) => item.day === day) | ||
| .sort((a, b) => (a.startsAt ?? '99:99').localeCompare(b.startsAt ?? '99:99') || a.courseTitle.localeCompare(b.courseTitle, 'es')) | ||
| .map(({ day: _day, ...item }) => item), | ||
| })); | ||
| return { | ||
| term: { code: term.code, description: term.description }, | ||
| courses: activeRegistrations.map((registration) => ({ | ||
| courseCode: registration.courseCode, | ||
| courseTitle: registration.courseTitle, | ||
| section: registration.crn, | ||
| credits: registration.credits, | ||
| scheduleType: registration.scheduleType, | ||
| hasScheduledMeetings: registration.meetings.length > 0, | ||
| })), | ||
| week, | ||
| }; | ||
| } |
| import type { UclassRecording, UclassSession, UclassTranscript } from './types.js'; | ||
| /** Class sometimes returns epoch milliseconds instead of recording-relative | ||
| * seconds. This makes source citations portable to every MCP client. */ | ||
| export declare function normalizeTranscriptTiming(transcript: UclassTranscript): UclassTranscript; | ||
| export declare function listRecordings(session: UclassSession, classId: string): Promise<UclassRecording[]>; | ||
| export declare function readTranscript(session: UclassSession, recording: Pick<UclassRecording, 'classId' | 'recordingId' | 'url'>): Promise<UclassTranscript>; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.normalizeTranscriptTiming = normalizeTranscriptTiming; | ||
| exports.listRecordings = listRecordings; | ||
| exports.readTranscript = readTranscript; | ||
| const PLAYER_ORIGIN = 'https://upc.class.com'; | ||
| const API_ORIGIN = 'https://upc.rest.pod-2.sa-east-1.prod.class.com'; | ||
| function stringOrNull(value) { | ||
| return typeof value === 'string' && value.trim() ? value.trim() : null; | ||
| } | ||
| function finite(value) { | ||
| return typeof value === 'number' && Number.isFinite(value) ? value : null; | ||
| } | ||
| function headers(session, referer) { | ||
| return { | ||
| accept: 'application/json', | ||
| user_uuid: session.userUuid, | ||
| 'x-class-user-uuid': session.userUuid, | ||
| cookie: session.cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; '), | ||
| origin: PLAYER_ORIGIN, | ||
| referer, | ||
| }; | ||
| } | ||
| function assertUuid(value, label) { | ||
| if (!/^[0-9a-f-]{36}$/i.test(value)) | ||
| throw new Error(`${label} no tiene un formato válido`); | ||
| } | ||
| /** Class sometimes returns epoch milliseconds instead of recording-relative | ||
| * seconds. This makes source citations portable to every MCP client. */ | ||
| function normalizeTranscriptTiming(transcript) { | ||
| const values = transcript.transcript.flatMap((line) => [line.startSeconds, line.endSeconds]) | ||
| .filter((value) => value !== null && Number.isFinite(value)); | ||
| const absolute = values.filter((value) => value >= 1_000_000_000); | ||
| if (!absolute.length) | ||
| return transcript; | ||
| const origin = Math.min(...absolute); | ||
| const milliseconds = origin >= 100_000_000_000; | ||
| const relative = (value) => value === null || value < 1_000_000_000 | ||
| ? value | ||
| : Math.max(0, (value - origin) / (milliseconds ? 1_000 : 1)); | ||
| return { ...transcript, transcript: transcript.transcript.map((line) => ({ ...line, startSeconds: relative(line.startSeconds), endSeconds: relative(line.endSeconds) })) }; | ||
| } | ||
| async function listRecordings(session, classId) { | ||
| assertUuid(classId, 'La sala de Class'); | ||
| if (!session.userUuid || session.expiresAt <= Date.now()) | ||
| throw new Error('La sesión de Class venció; ejecuta campus login y vuelve a intentar'); | ||
| const referer = `${PLAYER_ORIGIN}/react/lti/${classId}`; | ||
| const schoolResponse = await fetch(`${API_ORIGIN}/api/zoom/get_school_info`, { headers: headers(session, referer) }); | ||
| if (schoolResponse.status === 401 || schoolResponse.status === 403) | ||
| throw new Error('Class rechazó la sesión; ejecuta campus login y vuelve a intentar'); | ||
| if (!schoolResponse.ok) | ||
| throw new Error(`Class no pudo identificar la institución (${schoolResponse.status})`); | ||
| const school = await schoolResponse.json(); | ||
| const schoolId = stringOrNull(school.school_uuid) ?? stringOrNull(school.uuid); | ||
| if (!schoolId) | ||
| throw new Error('Class no devolvió la institución de esta sala'); | ||
| const response = await fetch(`${API_ORIGIN}/recording/v1/schools/${encodeURIComponent(schoolId)}/class/${encodeURIComponent(classId)}/recordings`, { headers: headers(session, referer) }); | ||
| if (response.status === 401 || response.status === 403) | ||
| throw new Error('Class rechazó la sesión; ejecuta campus login y vuelve a intentar'); | ||
| if (!response.ok) | ||
| throw new Error(`Class no pudo listar las grabaciones (${response.status})`); | ||
| const payload = await response.json(); | ||
| return (Array.isArray(payload.recordings) ? payload.recordings : []).map((row) => { | ||
| const item = row && typeof row === 'object' ? row : {}; | ||
| const recordingId = stringOrNull(item.recordingId) ?? stringOrNull(item.recording_id) ?? stringOrNull(item.uuid); | ||
| if (!recordingId || !/^[0-9a-f-]{36}$/i.test(recordingId)) | ||
| return null; | ||
| return { | ||
| classId, | ||
| recordingId, | ||
| url: `${PLAYER_ORIGIN}/player/recording/${classId}/${recordingId}`, | ||
| title: stringOrNull(item.name), | ||
| durationSeconds: finite(item.duration), | ||
| publishedAt: stringOrNull(item.when) ?? stringOrNull(item.created_at), | ||
| }; | ||
| }).filter((recording) => Boolean(recording)) | ||
| .sort((a, b) => Date.parse(b.publishedAt ?? '') - Date.parse(a.publishedAt ?? '')); | ||
| } | ||
| async function readTranscript(session, recording) { | ||
| assertUuid(recording.classId, 'La sala de Class'); | ||
| assertUuid(recording.recordingId, 'La grabación de Class'); | ||
| if (!session.userUuid || session.expiresAt <= Date.now()) | ||
| throw new Error('La sesión de Class venció; ejecuta campus login y vuelve a intentar'); | ||
| const response = await fetch(`${API_ORIGIN}/meeting/${encodeURIComponent(recording.classId)}/v1_join_async_meeting`, { | ||
| method: 'POST', | ||
| headers: { 'content-type': 'application/json', ...headers(session, recording.url) }, | ||
| body: JSON.stringify({ recordingUuid: recording.recordingId }), | ||
| }); | ||
| if (response.status === 401 || response.status === 403) | ||
| throw new Error('Class rechazó la sesión; ejecuta campus login y vuelve a intentar'); | ||
| if (!response.ok) | ||
| throw new Error(`Class no pudo abrir la grabación (${response.status})`); | ||
| const payload = await response.json(); | ||
| const data = payload.recordingData ?? {}; | ||
| const rows = Array.isArray(data.transcripts) ? data.transcripts : []; | ||
| return normalizeTranscriptTiming({ | ||
| recording, | ||
| title: stringOrNull(data.name), | ||
| durationSeconds: finite(data.duration), | ||
| transcript: rows.map((row, index) => { | ||
| const item = row && typeof row === 'object' ? row : {}; | ||
| const line = item.data && typeof item.data === 'object' ? item.data : {}; | ||
| return { | ||
| id: String(item.class_session_note_id ?? line.message_id ?? index), | ||
| startSeconds: finite(line.start_time), | ||
| endSeconds: finite(line.end_time), | ||
| speaker: stringOrNull(line.user_name), | ||
| text: stringOrNull(item.override_text) ?? stringOrNull(item.content) ?? '', | ||
| }; | ||
| }).filter((line) => line.text), | ||
| }); | ||
| } |
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| /** | ||
| * These tools deliberately return source material, not an AI conclusion. | ||
| * The MCP client can reason with the selected model while Campus guarantees | ||
| * the recording belongs to the requested Blackboard course and timestamps are | ||
| * normalized. Ask `uclass_search_transcript` first; use the full transcript | ||
| * only when its context is insufficient or contradictory. | ||
| */ | ||
| export declare function registerUclassTools(server: McpServer): void; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.registerUclassTools = registerUclassTools; | ||
| const zod_1 = require("zod"); | ||
| const service_js_1 = require("./service.js"); | ||
| const courseId = zod_1.z.string().regex(/^_\d+_\d+$/, 'courseId must look like a Blackboard ID, e.g. _554422_1'); | ||
| const recordingId = zod_1.z.string().uuid(); | ||
| /** | ||
| * These tools deliberately return source material, not an AI conclusion. | ||
| * The MCP client can reason with the selected model while Campus guarantees | ||
| * the recording belongs to the requested Blackboard course and timestamps are | ||
| * normalized. Ask `uclass_search_transcript` first; use the full transcript | ||
| * only when its context is insufficient or contradictory. | ||
| */ | ||
| function registerUclassTools(server) { | ||
| server.registerTool('uclass_list_recordings', { | ||
| description: 'List published UPC Class recordings for one Blackboard course. Uses the student\'s existing Campus SSO once, then reads Class over HTTP.', | ||
| inputSchema: { courseId: courseId.describe('Blackboard course ID') }, | ||
| annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, | ||
| }, async ({ courseId }) => ({ content: [{ type: 'text', text: JSON.stringify(await (0, service_js_1.recordingsForCourse)(courseId)) }] })); | ||
| server.registerTool('uclass_search_transcript', { | ||
| description: 'Search a published Class transcript and return evidence windows with neighboring interventions and [m:ss] timestamps. Use it to answer what was explained, agreed, assigned, or said in class. Do not treat a candidate, proposal, or partial result as a final decision without reading its surrounding evidence.', | ||
| inputSchema: { | ||
| courseId: courseId.describe('Blackboard course ID'), | ||
| query: zod_1.z.string().min(2).max(500).describe('Natural-language topic, name, task, date, or question'), | ||
| recordingId: recordingId.optional().describe('Optional Class recording ID; defaults to the latest published recording'), | ||
| }, | ||
| annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, | ||
| }, async ({ courseId, query, recordingId }) => ({ content: [{ type: 'text', text: JSON.stringify(await (0, service_js_1.searchTranscript)(courseId, query, recordingId)) }] })); | ||
| server.registerTool('uclass_read_transcript', { | ||
| description: 'Read the complete normalized native transcript of a published Class recording. Use only when the evidence windows do not settle the question; cite [m:ss] timestamps in the answer.', | ||
| inputSchema: { | ||
| courseId: courseId.describe('Blackboard course ID'), | ||
| recordingId: recordingId.optional().describe('Optional Class recording ID; defaults to the latest published recording'), | ||
| }, | ||
| annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, | ||
| }, async ({ courseId, recordingId }) => ({ content: [{ type: 'text', text: JSON.stringify(await (0, service_js_1.transcriptForCourse)(courseId, recordingId)) }] })); | ||
| } |
| import type { UclassRecording, UclassTranscript } from './types.js'; | ||
| export declare function recordingsForCourse(courseId: string): Promise<UclassRecording[]>; | ||
| export declare function transcriptForCourse(courseId: string, recordingId?: string): Promise<UclassTranscript>; | ||
| /** Supplies a small evidence window to the connected model. Returning the | ||
| * neighboring interventions is crucial: it prevents a candidate, example or | ||
| * preliminary vote from being interpreted as the final answer. */ | ||
| export declare function searchTranscript(courseId: string, query: string, recordingId?: string): Promise<{ | ||
| recording: Pick<UclassRecording, "url" | "classId" | "recordingId">; | ||
| title: string | null; | ||
| durationSeconds: number | null; | ||
| query: string; | ||
| excerpts: string[]; | ||
| hitCount: number; | ||
| }>; |
| "use strict"; | ||
| var __importDefault = (this && this.__importDefault) || function (mod) { | ||
| return (mod && mod.__esModule) ? mod : { "default": mod }; | ||
| }; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.recordingsForCourse = recordingsForCourse; | ||
| exports.transcriptForCourse = transcriptForCourse; | ||
| exports.searchTranscript = searchTranscript; | ||
| const node_os_1 = __importDefault(require("node:os")); | ||
| const node_path_1 = __importDefault(require("node:path")); | ||
| const browser_install_js_1 = require("../../browser-install.js"); | ||
| const session_js_1 = require("../blackboard/auth/session.js"); | ||
| const api_js_1 = require("./api.js"); | ||
| const PROFILE_DIR = node_path_1.default.join(node_os_1.default.homedir(), '.blackboard-cli', 'browser-profile'); | ||
| const sessions = new Map(); | ||
| const transcripts = new Map(); | ||
| const recordingLists = new Map(); | ||
| const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; | ||
| function isUuid(value) { | ||
| return /^[0-9a-f-]{36}$/i.test(value); | ||
| } | ||
| function toCookie(cookie) { | ||
| return { name: cookie.name, value: cookie.value, domain: cookie.domain, path: cookie.path, ...(cookie.expires > 0 ? { expires: cookie.expires } : {}), httpOnly: cookie.httpOnly, secure: cookie.secure, sameSite: cookie.sameSite }; | ||
| } | ||
| function toPlaywrightCookie(cookie) { | ||
| const sameSite = cookie.sameSite === 'Strict' || cookie.sameSite === 'Lax' || cookie.sameSite === 'None' | ||
| ? cookie.sameSite | ||
| : undefined; | ||
| return { | ||
| name: cookie.name, | ||
| value: cookie.value, | ||
| domain: cookie.domain, | ||
| path: cookie.path, | ||
| expires: cookie.expires ?? -1, | ||
| httpOnly: cookie.httpOnly, | ||
| secure: cookie.secure, | ||
| ...(sameSite ? { sameSite } : {}), | ||
| }; | ||
| } | ||
| function classExpiry(cookies) { | ||
| const future = cookies.map((cookie) => (cookie.expires ?? 0) * 1000).filter((value) => value > Date.now()); | ||
| return future.length ? Math.max(...future) : Date.now() + 90 * 60_000; | ||
| } | ||
| async function waitForClassFrame(page, courseId) { | ||
| await page.goto(`https://aulavirtual.upc.edu.pe/ultra/courses/${encodeURIComponent(courseId)}/outline`, { waitUntil: 'domcontentloaded', timeout: 45_000 }); | ||
| await page.getByRole('button', { name: /class/i }).first().click({ timeout: 20_000 }); | ||
| for (let attempt = 0; attempt < 90; attempt += 1) { | ||
| const frame = page.frames().find((candidate) => /^https:\/\/upc\.class\.com\/react\/lti\/([0-9a-f-]{36})/i.test(candidate.url())); | ||
| if (frame) | ||
| return frame; | ||
| await page.waitForTimeout(500); | ||
| } | ||
| throw new Error('Blackboard no expuso una sala de Class para este curso'); | ||
| } | ||
| async function captureClassSession(context, frame) { | ||
| const match = frame.url().match(/^https:\/\/upc\.class\.com\/react\/lti\/([0-9a-f-]{36})/i); | ||
| const classId = match?.[1] ?? ''; | ||
| if (!isUuid(classId)) | ||
| throw new Error('Class no devolvió una sala válida'); | ||
| let userUuid = ''; | ||
| for (let attempt = 0; attempt < 40; attempt += 1) { | ||
| userUuid = await frame.evaluate(() => window.localStorage.getItem('user_uuid') ?? ''); | ||
| if (isUuid(userUuid)) | ||
| break; | ||
| await new Promise((resolve) => setTimeout(resolve, 250)); | ||
| } | ||
| if (!isUuid(userUuid)) | ||
| throw new Error('Class no entregó una sesión de estudiante'); | ||
| const cookies = (await context.cookies()).filter((cookie) => /(^|\.)class\.com$/i.test(cookie.domain)).map(toCookie); | ||
| return { classId, session: { cookies, userUuid, capturedAt: Date.now(), expiresAt: classExpiry(cookies) } }; | ||
| } | ||
| /** Resolves Blackboard's Class LTI launch with the student's existing local | ||
| * SSO profile. The browser is used only to obtain the Class HTTP capability; | ||
| * video/audio are never downloaded. */ | ||
| async function classSessionForCourse(courseId) { | ||
| if (!/^_\d+_\d+$/.test(courseId)) | ||
| throw new Error('courseId debe tener formato Blackboard, por ejemplo _554422_1'); | ||
| const cached = sessions.get(courseId); | ||
| if (cached && cached.session.expiresAt > Date.now()) | ||
| return cached; | ||
| const blackboard = await (0, session_js_1.loadOrRefreshSession)(); | ||
| if (!(0, session_js_1.isSessionValid)(blackboard)) | ||
| throw new Error('Not authenticated. Ask the user to run: campus login'); | ||
| const context = await (0, browser_install_js_1.launchPersistentContextSafe)(PROFILE_DIR, { headless: true, userAgent: USER_AGENT }); | ||
| try { | ||
| await context.addCookies(blackboard.cookies.map(toPlaywrightCookie)); | ||
| const page = await context.newPage(); | ||
| const frame = await waitForClassFrame(page, courseId); | ||
| const captured = await captureClassSession(context, frame); | ||
| sessions.set(courseId, captured); | ||
| return captured; | ||
| } | ||
| finally { | ||
| await context.close(); | ||
| } | ||
| } | ||
| async function recordingsForCourse(courseId) { | ||
| const cached = recordingLists.get(courseId); | ||
| if (cached && cached.expiresAt > Date.now()) | ||
| return cached.recordings; | ||
| const { classId, session } = await classSessionForCourse(courseId); | ||
| if (!classId) | ||
| throw new Error('Class necesita abrirse una vez para esta grabación'); | ||
| const recordings = await (0, api_js_1.listRecordings)(session, classId); | ||
| recordingLists.set(courseId, { | ||
| recordings, | ||
| // Reuse metadata in this MCP process, rather than relaunching Class for | ||
| // every question. The source session itself still has its own expiry. | ||
| expiresAt: Math.min(session.expiresAt, Date.now() + 10 * 60_000), | ||
| }); | ||
| return recordings; | ||
| } | ||
| async function transcriptForCourse(courseId, recordingId) { | ||
| const recordings = await recordingsForCourse(courseId); | ||
| const recording = recordingId ? recordings.find((item) => item.recordingId === recordingId) : recordings[0]; | ||
| if (!recording) | ||
| throw new Error(recordingId ? 'La grabación no pertenece a este curso' : 'No hay grabaciones publicadas de Class para este curso'); | ||
| const key = `${courseId}:${recording.recordingId}`; | ||
| const cached = transcripts.get(key); | ||
| if (cached) | ||
| return cached; | ||
| const { session } = await classSessionForCourse(courseId); | ||
| const transcript = await (0, api_js_1.readTranscript)(session, recording); | ||
| transcripts.set(key, transcript); | ||
| return transcript; | ||
| } | ||
| function minute(seconds) { | ||
| if (seconds === null) | ||
| return 'sin marca'; | ||
| const value = Math.max(0, Math.floor(seconds)); | ||
| return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, '0')}`; | ||
| } | ||
| /** Supplies a small evidence window to the connected model. Returning the | ||
| * neighboring interventions is crucial: it prevents a candidate, example or | ||
| * preliminary vote from being interpreted as the final answer. */ | ||
| async function searchTranscript(courseId, query, recordingId) { | ||
| const transcript = await transcriptForCourse(courseId, recordingId); | ||
| const terms = query.toLocaleLowerCase('es').normalize('NFD').replace(/[\u0300-\u036f]/g, '').split(/[^\p{L}\p{N}]+/u).filter((term) => term.length > 2); | ||
| const score = (text) => { | ||
| const normalized = text.toLocaleLowerCase('es').normalize('NFD').replace(/[\u0300-\u036f]/g, ''); | ||
| return terms.reduce((total, term) => total + (normalized.includes(term) ? 1 : 0), 0); | ||
| }; | ||
| const hits = transcript.transcript.map((line, index) => ({ index, score: score(line.text) })).filter((hit) => hit.score > 0) | ||
| .sort((a, b) => b.score - a.score || a.index - b.index).slice(0, 8); | ||
| const used = new Set(); | ||
| const excerpts = hits.map(({ index }) => { | ||
| const lines = transcript.transcript.slice(Math.max(0, index - 2), Math.min(transcript.transcript.length, index + 3)) | ||
| .filter((line) => !used.has(transcript.transcript.indexOf(line))) | ||
| .map((line) => { | ||
| used.add(transcript.transcript.indexOf(line)); | ||
| return `[${minute(line.startSeconds)}]${line.speaker ? ` ${line.speaker}:` : ''} ${line.text}`; | ||
| }); | ||
| return lines.join('\n'); | ||
| }).filter(Boolean); | ||
| return { recording: transcript.recording, title: transcript.title, durationSeconds: transcript.durationSeconds, query, excerpts, hitCount: hits.length }; | ||
| } |
| import type { Cookie } from '../blackboard/types.js'; | ||
| export type UclassSession = { | ||
| cookies: Cookie[]; | ||
| userUuid: string; | ||
| expiresAt: number; | ||
| capturedAt: number; | ||
| }; | ||
| export type UclassRecording = { | ||
| classId: string; | ||
| recordingId: string; | ||
| url: string; | ||
| title: string | null; | ||
| durationSeconds: number | null; | ||
| publishedAt: string | null; | ||
| }; | ||
| export type UclassTranscriptItem = { | ||
| id: string; | ||
| startSeconds: number | null; | ||
| endSeconds: number | null; | ||
| speaker: string | null; | ||
| text: string; | ||
| }; | ||
| export type UclassTranscript = { | ||
| recording: Pick<UclassRecording, 'classId' | 'recordingId' | 'url'>; | ||
| title: string | null; | ||
| durationSeconds: number | null; | ||
| transcript: UclassTranscriptItem[]; | ||
| }; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); |
+12
-0
@@ -7,2 +7,14 @@ # Changelog | ||
| ## [2.1.0] — 2026-08-26 | ||
| ### Added | ||
| - `banner_get_weekly_schedule` — devuelve el horario semanal de Banner con días, horas, cursos y aulas; excluye matrículas retiradas o anuladas. | ||
| - Soporte para UClass y las tools asociadas, para consultar las integraciones académicas disponibles desde Campus. | ||
| - `blackboard_list_messages` y el comando equivalente para consultar mensajes de Blackboard sin abrir el navegador. | ||
| ### Changed | ||
| - El catálogo MCP diferencia explícitamente el horario de clases de Banner (`banner_get_weekly_schedule`) de la vista semanal de tareas y vencimientos de Blackboard. | ||
| --- | ||
| ## [2.0.0] — 2026-08-14 | ||
@@ -9,0 +21,0 @@ |
+1
-0
@@ -88,2 +88,3 @@ #!/usr/bin/env node | ||
| (0, courses_js_1.coursesCommand)(program); | ||
| (0, courses_js_1.messagesCommand)(program); | ||
| // Status / ping | ||
@@ -90,0 +91,0 @@ program |
+13
-0
@@ -7,2 +7,4 @@ "use strict"; | ||
| const mcp_tools_js_1 = require("../providers/blackboard/mcp-tools.js"); | ||
| const mcp_tools_js_2 = require("../providers/banner/mcp-tools.js"); | ||
| const mcp_tools_js_3 = require("../providers/uclass/mcp-tools.js"); | ||
| const analytics_js_1 = require("../analytics.js"); | ||
@@ -22,5 +24,14 @@ // La versión que anunciamos en el handshake sale del package.json. Estaba | ||
| Las herramientas uclass_* leen las transcripciones nativas de grabaciones UPC | ||
| Class publicadas para un curso Blackboard. Primero usa uclass_search_transcript: | ||
| devuelve evidencia con contexto y [m:ss]. Nunca conviertas candidatos, | ||
| propuestas o resultados parciales en decisiones sin verificar el tramo completo. | ||
| Flujo típico: blackboard_list_courses → blackboard_list_assignments / | ||
| blackboard_get_grades → blackboard_list_contents para materiales. | ||
| campus_get_weekly_schedule consulta la matrícula UPC en Banner y devuelve el | ||
| horario semanal de lunes a domingo. Úsala para responder qué clases tiene el | ||
| estudiante, a qué hora y en qué aula; acepta un código de período opcional. | ||
| Para entregas: blackboard_upload_attempt_file sube cada archivo/imagen y | ||
@@ -51,4 +62,6 @@ devuelve un fileUploadId; blackboard_save_attempt_draft guarda texto y/o | ||
| (0, mcp_tools_js_1.registerBlackboardTools)(server); | ||
| (0, mcp_tools_js_2.registerBannerTools)(server); | ||
| (0, mcp_tools_js_3.registerUclassTools)(server); | ||
| const transport = new stdio_js_1.StdioServerTransport(); | ||
| await server.connect(transport); | ||
| } |
@@ -17,2 +17,24 @@ import type { AxiosInstance } from 'axios'; | ||
| export declare function getCourseAnnouncements(client: AxiosInstance, courseId: string): Promise<PaginatedResponse<any>>; | ||
| /** Blackboard Ultra's authenticated inbox summary (not part of the public REST API). */ | ||
| export declare function getMessageCourseSummaries(client: AxiosInstance, opts?: { | ||
| limit?: number; | ||
| offset?: number; | ||
| }): Promise<PaginatedResponse<any>>; | ||
| /** Conversations for one course in Blackboard Ultra's authenticated UI API. */ | ||
| export declare function getCourseConversations(client: AxiosInstance, courseId: string, opts?: { | ||
| limit?: number; | ||
| offset?: number; | ||
| }): Promise<PaginatedResponse<any>>; | ||
| /** | ||
| * Read additional Ultra conversation pages without allowing one inbox request | ||
| * to grow without bound. Callers surface `truncated` so an assistant never | ||
| * mistakes the bounded result for a complete long-running conversation list. | ||
| */ | ||
| export declare function getCourseConversationsPageSet(client: AxiosInstance, courseId: string, opts?: { | ||
| limit?: number; | ||
| maxPages?: number; | ||
| }): Promise<{ | ||
| results: any[]; | ||
| truncated: boolean; | ||
| }>; | ||
| export declare function getGradeColumns(client: AxiosInstance, courseId: string): Promise<PaginatedResponse<any>>; | ||
@@ -19,0 +41,0 @@ export declare function getGrades(client: AxiosInstance, courseId: string, userId: string): Promise<PaginatedResponse<any>>; |
@@ -9,2 +9,5 @@ "use strict"; | ||
| exports.getCourseAnnouncements = getCourseAnnouncements; | ||
| exports.getMessageCourseSummaries = getMessageCourseSummaries; | ||
| exports.getCourseConversations = getCourseConversations; | ||
| exports.getCourseConversationsPageSet = getCourseConversationsPageSet; | ||
| exports.getGradeColumns = getGradeColumns; | ||
@@ -54,2 +57,42 @@ exports.getGrades = getGrades; | ||
| } | ||
| /** Blackboard Ultra's authenticated inbox summary (not part of the public REST API). */ | ||
| async function getMessageCourseSummaries(client, opts = {}) { | ||
| const params = { limit: opts.limit ?? 50 }; | ||
| if (opts.offset !== undefined) | ||
| params.offset = opts.offset; | ||
| const r = await client.get('/learn/api/v1/messages/summary', { params }); | ||
| return r.data; | ||
| } | ||
| /** Conversations for one course in Blackboard Ultra's authenticated UI API. */ | ||
| async function getCourseConversations(client, courseId, opts = {}) { | ||
| if (!/^_\d+_\d+$/.test(courseId)) { | ||
| throw new Error(`courseId must look like a Blackboard ID, e.g. _529580_1`); | ||
| } | ||
| const params = { limit: opts.limit ?? 100 }; | ||
| if (opts.offset !== undefined) | ||
| params.offset = opts.offset; | ||
| const r = await client.get(`/learn/api/v1/courses/${courseId}/conversations`, { params }); | ||
| return r.data; | ||
| } | ||
| /** | ||
| * Read additional Ultra conversation pages without allowing one inbox request | ||
| * to grow without bound. Callers surface `truncated` so an assistant never | ||
| * mistakes the bounded result for a complete long-running conversation list. | ||
| */ | ||
| async function getCourseConversationsPageSet(client, courseId, opts = {}) { | ||
| const maxPages = opts.maxPages ?? 5; | ||
| let page = await getCourseConversations(client, courseId, { limit: opts.limit ?? 100 }); | ||
| const results = [...page.results]; | ||
| let nextPage = page.paging?.nextPage; | ||
| for (let pageNumber = 1; nextPage && pageNumber < maxPages; pageNumber += 1) { | ||
| if (!nextPage.startsWith(`/learn/api/v1/courses/${courseId}/conversations?`)) { | ||
| throw new Error('Refusing an unexpected Blackboard conversation page'); | ||
| } | ||
| const response = await client.get(nextPage); | ||
| page = response.data; | ||
| results.push(...(page.results ?? [])); | ||
| nextPage = page.paging?.nextPage; | ||
| } | ||
| return { results, truncated: Boolean(nextPage) }; | ||
| } | ||
| async function getGradeColumns(client, courseId) { | ||
@@ -56,0 +99,0 @@ const r = await client.get(`/learn/api/public/v1/courses/${courseId}/gradebook/columns`, { |
@@ -39,4 +39,2 @@ "use strict"; | ||
| { method: 'GET', path: '/learn/api/public/v2/courses/{courseId}/coursemeetings', description: 'Course meetings/attendance (v2)' }, | ||
| // Messages | ||
| { method: 'GET', path: '/learn/api/public/v1/users/{userId}/messages/inbox', description: 'User inbox messages' }, | ||
| ]; | ||
@@ -43,0 +41,0 @@ function apiDocsCommand(program) { |
@@ -52,2 +52,3 @@ "use strict"; | ||
| } | ||
| const ASSIGNMENTS_LIST_PARALLELISM = 5; | ||
| function formatAssignment(col, grade, opts) { | ||
@@ -124,10 +125,21 @@ const possible = col.score?.possible ?? '?'; | ||
| const errors = []; | ||
| for (const course of availableCourses) { | ||
| spinner.text = `Fetching assignments: ${course.name}`; | ||
| try { | ||
| results.push(await loadCourseAssignments(course.id, course.name)); | ||
| } | ||
| catch (err) { | ||
| errors.push({ courseId: course.id, courseName: course.name, error: err.message }); | ||
| } | ||
| for (let i = 0; i < availableCourses.length; i += ASSIGNMENTS_LIST_PARALLELISM) { | ||
| const chunk = availableCourses.slice(i, i + ASSIGNMENTS_LIST_PARALLELISM); | ||
| const settled = await Promise.allSettled(chunk.map(async (course) => { | ||
| spinner.text = `Fetching assignments: ${course.name}`; | ||
| return loadCourseAssignments(course.id, course.name); | ||
| })); | ||
| settled.forEach((entry, idx) => { | ||
| const course = chunk[idx]; | ||
| if (entry.status === 'fulfilled') { | ||
| results.push(entry.value); | ||
| } | ||
| else { | ||
| errors.push({ | ||
| courseId: course.id, | ||
| courseName: course.name, | ||
| error: entry.reason instanceof Error ? entry.reason.message : String(entry.reason), | ||
| }); | ||
| } | ||
| }); | ||
| } | ||
@@ -134,0 +146,0 @@ const total = results.reduce((sum, r) => sum + r.columns.length, 0); |
| import { Command } from 'commander'; | ||
| export declare function coursesCommand(program: Command): void; | ||
| export declare function messagesCommand(program: Command): void; |
@@ -7,2 +7,3 @@ "use strict"; | ||
| exports.coursesCommand = coursesCommand; | ||
| exports.messagesCommand = messagesCommand; | ||
| const chalk_1 = __importDefault(require("chalk")); | ||
@@ -31,2 +32,4 @@ const ora_1 = __importDefault(require("ora")); | ||
| .description('Course operations'); | ||
| // Messages live in the student's inbox, not inside a particular course, so | ||
| // this command is registered at the program root below rather than here. | ||
| // List enrolled courses | ||
@@ -302,1 +305,80 @@ courses | ||
| } | ||
| function messagesCommand(program) { | ||
| program | ||
| .command('messages') | ||
| .description('List messages from your Blackboard inbox') | ||
| .option('--json', 'Output raw JSON') | ||
| .option('--course <courseId>', 'Only show messages associated with this course') | ||
| .option('--limit <n>', 'Max results (1-100)', '50') | ||
| .option('--offset <n>', 'Pagination offset', '0') | ||
| .action(async (opts) => { | ||
| const limit = Number.parseInt(opts.limit, 10); | ||
| const offset = Number.parseInt(opts.offset, 10); | ||
| if (!Number.isInteger(limit) || limit < 1 || limit > 100) | ||
| throw new Error('--limit must be an integer from 1 to 100'); | ||
| if (!Number.isInteger(offset) || offset < 0) | ||
| throw new Error('--offset must be a non-negative integer'); | ||
| const session = requireSession(); | ||
| const client = (0, client_js_1.createClient)(session); | ||
| const spinner = (0, ora_1.default)({ text: 'Fetching Blackboard messages...', stream: process.stderr }).start(); | ||
| try { | ||
| const summary = await (0, courses_js_1.getMessageCourseSummaries)(client, { limit: 100 }); | ||
| const courses = opts.course | ||
| ? summary.results.filter((course) => course.courseId === opts.course) | ||
| : summary.results; | ||
| const groups = []; | ||
| for (const course of courses) { | ||
| const { results: conversations, truncated } = await (0, courses_js_1.getCourseConversationsPageSet)(client, course.courseId, { limit: 100 }); | ||
| groups.push({ course, conversations, truncated }); | ||
| } | ||
| const all = groups.flatMap(({ course, conversations }) => conversations.map((conversation) => ({ | ||
| course: { id: course.courseId, name: course.courseName, unreadCount: course.numUnreadMessages ?? 0 }, | ||
| ...conversation, | ||
| }))); | ||
| const messages = all.slice(offset, offset + limit); | ||
| spinner.succeed(`${messages.length} message conversations`); | ||
| if (opts.json) { | ||
| console.log(JSON.stringify({ | ||
| results: messages, | ||
| paging: { limit, offset, count: all.length, nextPage: offset + limit < all.length ? String(offset + limit) : undefined }, | ||
| courseSummaries: groups.map(({ course, conversations, truncated }) => ({ | ||
| id: course.courseId, | ||
| name: course.courseName, | ||
| unreadCount: course.numUnreadMessages ?? 0, | ||
| conversationCount: conversations.length, | ||
| truncated, | ||
| })), | ||
| }, null, 2)); | ||
| return; | ||
| } | ||
| if (messages.length === 0) { | ||
| console.log(chalk_1.default.yellow('No message conversations found.')); | ||
| return; | ||
| } | ||
| console.log(''); | ||
| for (const message of messages) { | ||
| const latest = Array.isArray(message.messages) ? message.messages.at(-1) : undefined; | ||
| const subject = message.subject ?? message.title ?? message.name ?? latest?.subject ?? '(no subject)'; | ||
| const sender = (message.sender?.name ?? message.senderName ?? message.from | ||
| ?? [latest?.sender?.givenName, latest?.sender?.familyName].filter(Boolean).join(' ')) || latest?.sender?.userName || ''; | ||
| const date = message.created ?? message.modified ?? message.dateCreated ?? message.date ?? latest?.postDate; | ||
| const body = String(message.body ?? message.text ?? message.message ?? message.lastMessage?.body | ||
| ?? latest?.body?.rawText ?? latest?.body?.displayText ?? '') | ||
| .replace(/<[^>]+>/g, ' ') | ||
| .replace(/\s+/g, ' ') | ||
| .trim() | ||
| .slice(0, 240); | ||
| console.log(` ${chalk_1.default.bold(subject)}${sender ? chalk_1.default.gray(` — ${sender}`) : ''}${date ? chalk_1.default.gray(` ${new Date(date).toLocaleString()}`) : ''}`); | ||
| if (message.course?.name) | ||
| console.log(chalk_1.default.gray(` ${message.course.name}`)); | ||
| if (body) | ||
| console.log(` ${chalk_1.default.gray(body)}`); | ||
| console.log(''); | ||
| } | ||
| } | ||
| catch (err) { | ||
| spinner.fail(err.message); | ||
| process.exit(1); | ||
| } | ||
| }); | ||
| } |
@@ -17,2 +17,12 @@ "use strict"; | ||
| const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50MB | ||
| const MCP_MAX_PARALLELISM = 5; | ||
| async function mapWithConcurrency(items, limit, mapper) { | ||
| const output = []; | ||
| for (let i = 0; i < items.length; i += limit) { | ||
| const chunk = items.slice(i, i + limit); | ||
| const chunkResult = await Promise.all(chunk.map(mapper)); | ||
| output.push(...chunkResult); | ||
| } | ||
| return output; | ||
| } | ||
| // Blackboard's own IDs (course, content, column, attempt, file) always look like | ||
@@ -136,2 +146,33 @@ // `_529580_1`. These get interpolated straight into REST path templates below — | ||
| }); | ||
| // ── blackboard_list_messages ────────────────────────────────────────────────────────── | ||
| registerTrackedTool('blackboard_list_messages', { | ||
| description: 'Read conversation messages from the current student’s Blackboard inbox. Optionally restrict results to one Blackboard course ID.', | ||
| inputSchema: { | ||
| courseId: blackboardId('courseId').optional().describe('Only return messages associated with this course'), | ||
| limit: zod_1.z.number().int().min(1).max(100).optional().describe('Maximum conversations to return (default 50)'), | ||
| offset: zod_1.z.number().int().min(0).optional().describe('Offset in the combined conversation list'), | ||
| }, | ||
| }, async ({ courseId, limit, offset }) => { | ||
| const { client } = await getClient(); | ||
| const summaries = await (0, courses_js_1.getMessageCourseSummaries)(client, { limit: 100 }); | ||
| const courses = courseId | ||
| ? summaries.results.filter((course) => course.courseId === courseId) | ||
| : summaries.results; | ||
| const groups = await mapWithConcurrency(courses, MCP_MAX_PARALLELISM, async (course) => { | ||
| const { results: conversations, truncated } = await (0, courses_js_1.getCourseConversationsPageSet)(client, course.courseId, { limit: 100 }); | ||
| return { | ||
| course: { id: course.courseId, name: course.courseName, unreadCount: course.numUnreadMessages ?? 0 }, | ||
| conversations, | ||
| truncated, | ||
| }; | ||
| }); | ||
| const all = groups.flatMap(({ course, conversations }) => conversations.map((conversation) => ({ course, ...conversation }))); | ||
| const start = offset ?? 0; | ||
| const end = start + (limit ?? 50); | ||
| return { content: [{ type: 'text', text: JSON.stringify({ | ||
| results: all.slice(start, end), | ||
| paging: { limit: limit ?? 50, offset: start, count: all.length, nextPage: end < all.length ? String(end) : undefined }, | ||
| courseSummaries: groups.map(({ course, conversations, truncated }) => ({ ...course, conversationCount: conversations.length, truncated })), | ||
| }) }] }; | ||
| }); | ||
| // ── blackboard_list_people ───────────────────────────────────────────────────────────── | ||
@@ -471,3 +512,3 @@ // Announcements and grades carry an internal user id and nothing else, so | ||
| const assignments = await (0, assignments_js_1.listAssignments)(client, courseId); | ||
| const results = await Promise.all(assignments.map(async (col) => { | ||
| const results = await mapWithConcurrency(assignments, MCP_MAX_PARALLELISM, async (col) => { | ||
| try { | ||
@@ -513,3 +554,3 @@ const attempts = await (0, assignments_js_1.listAttempts)(client, courseId, col.id); | ||
| } | ||
| })); | ||
| }); | ||
| return { content: [{ type: 'text', text: JSON.stringify(results) }] }; | ||
@@ -516,0 +557,0 @@ }); |
+2
-1
| { | ||
| "name": "campus-cli", | ||
| "version": "2.0.0", | ||
| "version": "2.1.0", | ||
| "description": "Conecta Blackboard UPC con ChatGPT y Claude vía MCP, o úsalo desde la terminal — CLI/MCP no oficial para el campus universitario (Blackboard, Canvas, Moodle...)", | ||
@@ -17,2 +17,3 @@ "mcpName": "io.github.alejooroncoy/campus-cli", | ||
| "./mcp-tools": "./dist/providers/blackboard/mcp-tools.js", | ||
| "./banner-schedule": "./dist/providers/banner/schedule.js", | ||
| "./analytics": "./dist/analytics.js" | ||
@@ -19,0 +20,0 @@ }, |
+15
-3
@@ -8,3 +8,3 @@ # campus-cli | ||
| `campus-cli` (también conocido como **Campus** o **Campus CLI**, [campuscli.com](https://campuscli.com)) es un CLI y servidor MCP no oficial para estudiantes de UPC. Le da a asistentes de IA como ChatGPT y Claude acceso directo a tu **Blackboard Learn**: cursos, tareas, notas, anuncios y materiales, sin abrir el navegador. Canvas y Moodle están en el roadmap. | ||
| `campus-cli` (también conocido como **Campus** o **Campus CLI**, [campuscli.com](https://campuscli.com)) es un CLI y servidor MCP no oficial para estudiantes de UPC. Le da a asistentes de IA como ChatGPT y Claude acceso directo a tu **Blackboard Learn**: cursos, tareas, notas, anuncios, mensajes y materiales, sin abrir el navegador. Canvas y Moodle están en el roadmap. | ||
@@ -24,5 +24,6 @@ No confundir con: el paquete `campus-cli` de PyPI (Python, gestión de notebooks de Jupyter, proyecto no relacionado) ni con otras plataformas de "IA para programadores" o "resolver tareas con IA" que usan nombres parecidos — este proyecto es específicamente la integración de Blackboard con asistentes de IA vía MCP. | ||
| - Ver tus cursos del ciclo. | ||
| - Consultar tu horario semanal, con horas y aulas de tus cursos matriculados. | ||
| - Revisar tareas pendientes, fechas de entrega y notas. | ||
| - Descargar archivos y carpetas completas de Blackboard. | ||
| - Consultar anuncios, contenidos y calificaciones. | ||
| - Consultar anuncios, mensajes, contenidos y calificaciones. | ||
| - Usarlo desde Claude, Cursor, Copilot, Codex u otro cliente compatible con MCP. | ||
@@ -143,2 +144,4 @@ - Automatizar consultas con `--json` o con llamadas directas a la API de Blackboard. | ||
| campus courses grades <courseId> | ||
| campus messages | ||
| campus messages --course <courseId> | ||
| ``` | ||
@@ -198,2 +201,4 @@ | ||
| Además de las herramientas de Blackboard, el MCP incluye `campus_get_weekly_schedule`: consulta tu matrícula en Banner UPC y organiza las clases de lunes a domingo. Por defecto usa el período activo; también puedes pasar un código de período si quieres revisar un ciclo anterior. | ||
| ### Claude Code | ||
@@ -313,3 +318,3 @@ | ||
| Todas las herramientas actuales usan el prefijo `blackboard_` para evitar colisiones cuando se agreguen `canvas_*` o `moodle_*`. | ||
| Las herramientas de Aula Virtual usan el prefijo `blackboard_`; `campus_get_weekly_schedule` consulta la matrícula en Banner UPC. Las de UPC Class usan `uclass_`: entregan fuentes estructuradas para que la IA conectada (Codex, Claude, ChatGPT, etc.) las interprete, sin enviar la grabación a una IA propia del CLI. | ||
@@ -323,2 +328,3 @@ | Herramienta | Descripción | | ||
| | `blackboard_list_announcements` | Anuncios del curso | | ||
| | `blackboard_list_messages` | Mensajes de la bandeja de entrada de Blackboard | | ||
| | `blackboard_list_assignments` | Tareas con fechas y notas | | ||
@@ -338,5 +344,11 @@ | `blackboard_list_attempts` | Historial de entregas | | ||
| | `blackboard_raw_api` | API pública de Blackboard; los métodos que modifican datos piden confirmación directa | | ||
| | `campus_get_weekly_schedule` | Horario semanal UPC de la matrícula activa (horas, aulas, secciones y cursos sin clase presencial) | | ||
| | `uclass_list_recordings` | Grabaciones publicadas de UPC Class para un curso Blackboard | | ||
| | `uclass_search_transcript` | Fragmentos con contexto y marcas de tiempo de una transcripción de Class | | ||
| | `uclass_read_transcript` | Transcripción estructurada completa de una grabación de Class | | ||
| Las descargas MCP nunca escriben fuera de `~/Downloads/campus-cli`, no sobrescriben archivos y aplican límites de 100 MB por archivo y 500 MB para la raíz completa. Puedes elegir otra raíz al iniciar el servidor con `CAMPUS_DOWNLOAD_DIR=/ruta/segura`; el argumento `outputDir` de las tools solo crea subdirectorios relativos dentro de ella. Las subidas, entregas finales y llamadas raw que modifican datos requieren que el cliente soporte MCP elicitation; si no la soporta, la operación falla sin ejecutarse. | ||
| Las transcripciones de Class se consultan por HTTP desde la sesión SSO existente, no se descarga el video ni el audio. Durante la sesión MCP se reutilizan la lista de grabaciones y la transcripción ya leída; al cerrar el proceso esa caché en memoria desaparece. | ||
| Ejemplos de uso con un asistente: | ||
@@ -343,0 +355,0 @@ |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
305348
15.14%84
16.67%5395
15.13%486
2.53%14
27.27%