@imstudium/mcp 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env node
2
+ #!/usr/bin/env node
3
+
4
+ // src/index.ts
5
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import {
8
+ CallToolRequestSchema,
9
+ ListToolsRequestSchema
10
+ } from "@modelcontextprotocol/sdk/types.js";
11
+ import { createSdk, expandPath, extractSlug, normalizeStudiumWorkspace, pageRef, pickCourse, resolveCourseDir, agentSearchGuidance } from "@imstudium/sdk";
12
+ import { readdir, readFile } from "fs/promises";
13
+ import { basename, join } from "path";
14
+
15
+ // src/tools.ts
16
+ var MCP_TOOLS = [
17
+ {
18
+ name: "studium_overview",
19
+ description: "Full course map and semester context from local studium.json. Call this first for study planning.",
20
+ inputSchema: { type: "object", properties: {} }
21
+ },
22
+ {
23
+ name: "studium_klausuren",
24
+ description: "Exam timeline with days-remaining from klausuren.md / studium.json",
25
+ inputSchema: { type: "object", properties: {} }
26
+ },
27
+ {
28
+ name: "studium_course_brief",
29
+ description: "One course: schedule, materials count, exams, COURSE.md content",
30
+ inputSchema: {
31
+ type: "object",
32
+ properties: {
33
+ course: { type: "string", description: "Course title substring or id" }
34
+ },
35
+ required: ["course"]
36
+ }
37
+ },
38
+ {
39
+ name: "studium_find_materials",
40
+ description: "List synced files by course and optional filename query. For content search, grep `<course.path>/search/corpus.jsonl` with your native tools.",
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: {
44
+ course: { type: "string", description: "Course title substring" },
45
+ query: { type: "string", description: "Filename search" }
46
+ },
47
+ required: ["course"]
48
+ }
49
+ },
50
+ {
51
+ name: "studium_read_material",
52
+ description: "Extract one course file (PDF/PPTX/DOCX/images). Returns text + pageImages + viewerHtml. MANDATORY for agents: read every pageImage OR open viewerFileUrl in Playwright/browser before explaining slides \u2014 text alone misses diagrams and formulas.",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ course: { type: "string", description: "Course title substring" },
57
+ file: { type: "string", description: "Filename substring (e.g. 'vorlesung 3', 'klausur')" },
58
+ page: { type: "number", description: "Optional: only this page/slide (1-based)" }
59
+ },
60
+ required: ["course", "file"]
61
+ }
62
+ },
63
+ {
64
+ name: "imstudium_sync",
65
+ description: "Refresh workspace from live Stud.IP (requires login). Full sync + rebuild.",
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: {
69
+ course: { type: "string", description: "Optional single course filter" }
70
+ }
71
+ }
72
+ },
73
+ {
74
+ name: "imstudium_sync_dry_run",
75
+ description: "Preview pending file downloads without downloading",
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ course: { type: "string" }
80
+ }
81
+ }
82
+ },
83
+ {
84
+ name: "imstudium_list_courses",
85
+ description: "Live course list from Stud.IP API (when workspace is stale)",
86
+ inputSchema: { type: "object", properties: {} }
87
+ },
88
+ {
89
+ name: "imstudium_whoami",
90
+ description: "Check authentication status and current user",
91
+ inputSchema: { type: "object", properties: {} }
92
+ }
93
+ ];
94
+ var TOOL_NAMES = MCP_TOOLS.map((t) => t.name);
95
+
96
+ // src/visual.ts
97
+ import { buildAgentVisualPayload, buildSlideViewer } from "@imstudium/sdk";
98
+ async function materialVisualPayload(outDir, title) {
99
+ const { viewerHtml, pageImages } = await buildSlideViewer(outDir, title);
100
+ const visual = buildAgentVisualPayload(pageImages, pageImages.length > 0 ? viewerHtml : void 0);
101
+ return { ...visual, pageImages, viewerHtml: pageImages.length > 0 ? viewerHtml : void 0 };
102
+ }
103
+
104
+ // src/index.ts
105
+ var server = new Server(
106
+ { name: "imstudium-studium", version: "0.1.0" },
107
+ { capabilities: { tools: {} } }
108
+ );
109
+ var workspacePath = process.env.IMSTUDIUM_WORKSPACE || process.env.DIGICAMPUS_WORKSPACE || "";
110
+ var sdkPromise = null;
111
+ async function getSdk() {
112
+ if (!sdkPromise) sdkPromise = createSdk();
113
+ const sdk = await sdkPromise;
114
+ if (!workspacePath) workspacePath = expandPath(sdk.workspacePath);
115
+ return sdk;
116
+ }
117
+ function jsonText(data) {
118
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
119
+ }
120
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
121
+ tools: [...MCP_TOOLS]
122
+ }));
123
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
124
+ const { name, arguments: args } = request.params;
125
+ try {
126
+ const sdk = await getSdk();
127
+ switch (name) {
128
+ case "studium_overview": {
129
+ const ws = await loadWorkspace(sdk);
130
+ const focusId = ws.activeSemester?.id;
131
+ return jsonText({
132
+ activeSemester: ws.activeSemester,
133
+ otherSemesters: ws.otherSemesters,
134
+ search: ws.search,
135
+ searchGuidance: agentSearchGuidance(workspacePath, ws),
136
+ focusCourses: ws.courses.filter((c) => !focusId || c.semesterId === focusId),
137
+ allCourses: ws.courses,
138
+ nextDeadlines: ws.nextDeadlines,
139
+ hint: "Default to focusCourses. Find content: grep/semantic-search corpus.jsonl (see searchGuidance). Paths: course.path + search/materials.json."
140
+ });
141
+ }
142
+ case "studium_klausuren": {
143
+ const ws = await loadWorkspace(sdk);
144
+ const exams = ws.courses.flatMap(
145
+ (c) => c.examDates.map((e) => ({ ...e, courseTitle: c.title }))
146
+ );
147
+ return jsonText({ nextDeadlines: ws.nextDeadlines, exams });
148
+ }
149
+ case "studium_course_brief": {
150
+ const ws = await loadWorkspace(sdk);
151
+ const courseArg = String(args?.course || "");
152
+ const course = pickCourse(ws, courseArg);
153
+ if (!course) {
154
+ return jsonText({ error: "Course not found", available: ws.courses.map((c) => c.title) });
155
+ }
156
+ const courseDir = resolveCourseDir(workspacePath, course, ws);
157
+ let courseMd = "";
158
+ try {
159
+ courseMd = await readFile(join(courseDir, "COURSE.md"), "utf-8");
160
+ } catch {
161
+ }
162
+ let materials;
163
+ try {
164
+ materials = JSON.parse(await readFile(join(courseDir, "search", "materials.json"), "utf-8"));
165
+ } catch {
166
+ }
167
+ return jsonText({ course, courseMd, materials, activeSemester: ws.activeSemester });
168
+ }
169
+ case "studium_find_materials": {
170
+ const ws = await loadWorkspace(sdk);
171
+ const courseArg = String(args?.course || "");
172
+ const query = String(args?.query || "").toLowerCase();
173
+ const course = pickCourse(ws, courseArg);
174
+ if (!course) return jsonText({ error: "Course not found" });
175
+ const filesDir = join(resolveCourseDir(workspacePath, course, ws), "files");
176
+ const matches = await findFilesRecursive(filesDir, query);
177
+ return jsonText({ course: course.title, path: course.path, matches });
178
+ }
179
+ case "studium_read_material": {
180
+ const ws = await loadWorkspace(sdk);
181
+ const courseArg = String(args?.course || "");
182
+ const fileArg = String(args?.file || "").toLowerCase();
183
+ const page = args?.page;
184
+ const course = pickCourse(ws, courseArg);
185
+ if (!course) {
186
+ return jsonText({ error: "Course not found", available: ws.courses.map((c) => c.title) });
187
+ }
188
+ const courseDir = resolveCourseDir(workspacePath, course, ws);
189
+ const filesDir = join(courseDir, "files");
190
+ const candidates = await findFilesRecursive(filesDir, fileArg);
191
+ if (candidates.length === 0) {
192
+ return jsonText({
193
+ error: `No file matching "${fileArg}" in ${course.title}`,
194
+ path: course.path,
195
+ hint: "Use studium_find_materials to list files, or imstudium_sync to refresh"
196
+ });
197
+ }
198
+ const src = candidates[0];
199
+ const outDir = join(courseDir, "extracted", extractSlug(basename(src)));
200
+ const result = await sdk.extractor.extractFile(src, outDir);
201
+ if (result.error) {
202
+ return jsonText({ error: result.error, hint: result.hint, source: src });
203
+ }
204
+ let content = "";
205
+ try {
206
+ content = await readFile(join(outDir, "content.md"), "utf-8");
207
+ } catch {
208
+ }
209
+ if (page && content) {
210
+ const sections = content.split(/^## (?:Page|Slide) /m);
211
+ const match = sections.find((s) => s.startsWith(`${page}
212
+ `) || s.startsWith(`${page}\r`));
213
+ if (match) content = `## Page ${match}`;
214
+ }
215
+ const visual = await materialVisualPayload(outDir, basename(src));
216
+ let { pageImages, viewerHtml } = visual;
217
+ if (page) {
218
+ pageImages = pageImages.filter((p) => new RegExp(`-0*${page}\\.`).test(p));
219
+ }
220
+ return jsonText({
221
+ course: course.title,
222
+ source: src,
223
+ kind: result.kind,
224
+ pages: result.pages,
225
+ ref: page ? pageRef(course.path || course.slug, basename(src), page) : void 0,
226
+ citation: page ? `${course.title} \xB7 ${basename(src)} \xB7 p.${page}` : void 0,
227
+ content,
228
+ pageImages,
229
+ viewerHtml,
230
+ requiredAction: visual.requiredAction,
231
+ viewerFileUrl: visual.viewerFileUrl,
232
+ methods: visual.methods,
233
+ hint: pageImages.length ? void 0 : result.hint || "Install poppler for slide images: imstudium doctor",
234
+ otherMatches: candidates.slice(1)
235
+ });
236
+ }
237
+ case "imstudium_sync": {
238
+ const course = args?.course;
239
+ const semOpts = { semesterId: sdk.config.semesterId };
240
+ const report = await sdk.sync.sync({
241
+ outputDir: workspacePath,
242
+ include: ["wiki", "news", "calendar"],
243
+ ...semOpts,
244
+ courseFilter: course ? (c) => c.title.toLowerCase().includes(course.toLowerCase()) : void 0
245
+ });
246
+ const workspace = await sdk.studium.build(workspacePath, sdk.config.instanceOrigin, semOpts);
247
+ return jsonText({
248
+ report,
249
+ workspaceSummary: {
250
+ courses: workspace.courses.length,
251
+ activeSemester: workspace.activeSemester,
252
+ workspaceFolder: workspace.workspaceFolder,
253
+ otherSemesters: workspace.otherSemesters
254
+ }
255
+ });
256
+ }
257
+ case "imstudium_sync_dry_run": {
258
+ const course = args?.course;
259
+ const semOpts = { semesterId: sdk.config.semesterId };
260
+ const report = await sdk.sync.sync({
261
+ outputDir: workspacePath,
262
+ dryRun: true,
263
+ ...semOpts,
264
+ courseFilter: course ? (c) => c.title.toLowerCase().includes(course.toLowerCase()) : void 0
265
+ });
266
+ return jsonText(report);
267
+ }
268
+ case "imstudium_list_courses": {
269
+ const me = await sdk.courses.getMe();
270
+ const ctx = await sdk.courses.resolveSemesterContext({ semesterId: sdk.config.semesterId });
271
+ const courses = await sdk.courses.listMine(me.id, { semesterId: ctx?.semester.id });
272
+ return jsonText({
273
+ semester: ctx ? { id: ctx.semester.id, title: ctx.title, slug: ctx.slug, isCurrent: ctx.isCurrent } : void 0,
274
+ hint: "This defaults to the current semester. Pass allSemesters or sync --semester for older terms.",
275
+ courses: courses.map((c) => ({
276
+ id: c.id,
277
+ title: c.attributes?.title,
278
+ type: c.attributes?.["course-type"]
279
+ }))
280
+ });
281
+ }
282
+ case "imstudium_whoami": {
283
+ const session = await sdk.auth.loadSession();
284
+ if (!session) return jsonText({ authenticated: false, hint: "Run imstudium login" });
285
+ const me = await sdk.courses.getMe();
286
+ return jsonText({
287
+ authenticated: true,
288
+ user: { id: me.id, name: me.attributes?.["formatted-name"] },
289
+ workspace: workspacePath
290
+ });
291
+ }
292
+ default:
293
+ return jsonText({ error: `Unknown tool: ${name}` });
294
+ }
295
+ } catch (e) {
296
+ return jsonText({
297
+ error: e instanceof Error ? e.message : String(e),
298
+ hint: "Run imstudium login if not authenticated"
299
+ });
300
+ }
301
+ });
302
+ async function loadWorkspace(sdk) {
303
+ const ws = await sdk.studium.loadWorkspace(workspacePath);
304
+ if (!ws) {
305
+ throw new Error(
306
+ `No Studium workspace at ${workspacePath}. Run: imstudium init`
307
+ );
308
+ }
309
+ return normalizeStudiumWorkspace(ws);
310
+ }
311
+ async function findFilesRecursive(dir, query, results = []) {
312
+ try {
313
+ const entries = await readdir(dir, { withFileTypes: true });
314
+ for (const e of entries) {
315
+ const p = join(dir, e.name);
316
+ if (e.isDirectory()) {
317
+ await findFilesRecursive(p, query, results);
318
+ } else if (!query || e.name.toLowerCase().includes(query)) {
319
+ results.push(p);
320
+ }
321
+ }
322
+ } catch {
323
+ }
324
+ return results;
325
+ }
326
+ async function main() {
327
+ const transport = new StdioServerTransport();
328
+ await server.connect(transport);
329
+ }
330
+ main().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@imstudium/mcp",
3
+ "version": "0.1.1",
4
+ "description": "ImStudium MCP server — study planning tools for LLMs",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "author": "Patrick Schröppel",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/paddy-shrp/imstudium.git",
11
+ "directory": "packages/mcp"
12
+ },
13
+ "homepage": "https://github.com/paddy-shrp/imstudium#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/paddy-shrp/imstudium/issues"
16
+ },
17
+ "keywords": [
18
+ "imstudium",
19
+ "studip",
20
+ "studium",
21
+ "mcp",
22
+ "cursor",
23
+ "claude",
24
+ "codex",
25
+ "windsurf",
26
+ "opencode"
27
+ ],
28
+ "bin": {
29
+ "imstudium-mcp": "./dist/index.js"
30
+ },
31
+ "files": ["dist"],
32
+ "scripts": {
33
+ "build": "tsup",
34
+ "typecheck": "tsc --noEmit",
35
+ "clean": "rm -rf dist",
36
+ "prepublishOnly": "bun run build && bun run typecheck"
37
+ },
38
+ "dependencies": {
39
+ "@imstudium/sdk": "workspace:*",
40
+ "@modelcontextprotocol/sdk": "^1.12.1"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.15.21",
44
+ "tsup": "^8.5.0",
45
+ "typescript": "^5.8.3"
46
+ },
47
+ "engines": {
48
+ "node": ">=20"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "registry": "https://registry.npmjs.org/"
53
+ }
54
+ }