@udondan/dsbmobile 1.1.0

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,164 @@
1
+ import { z } from 'zod';
2
+ import { CHARACTER_LIMIT, ENV_CLASS } from '../constants.js';
3
+ /**
4
+ * Registers the get_substitutions tool with the MCP server.
5
+ *
6
+ * This tool fetches and parses the actual substitution plan HTML pages,
7
+ * returning structured substitution entries grouped by class.
8
+ */
9
+ export function registerSubstitutionsTool(server, client) {
10
+ server.registerTool('get_substitutions', {
11
+ title: 'Get DSBmobile Substitution Entries',
12
+ description: `Fetches and parses the actual substitution plan (Vertretungsplan) pages from DSBmobile,
13
+ returning structured substitution entries for each class.
14
+
15
+ Returns a list of substitution plans (one per page), each containing:
16
+ - title: Plan name (e.g., "V-Homepage heute - subst_001 (Seite 1)")
17
+ - planDate: Date shown on the plan (e.g., "20.3.2026 Freitag (Seite 1 / 8)")
18
+ - lastUpdated: When the plan was last updated
19
+ - affectedClasses: Comma-separated list of affected class names
20
+ - entries: Array of substitution entries, each with:
21
+ - className: The class (e.g., "05b", "Q2_Kra")
22
+ - type: Substitution type (e.g., "Vertretung", "Statt-Vertretung", "Entfall")
23
+ - period: Lesson period(s) (e.g., "3" or "5 - 6")
24
+ - originalTeacher: Abbreviation of the absent teacher
25
+ - substituteTeacher: Abbreviation of the substitute teacher
26
+ - subject: Subject abbreviation (e.g., "SPO", "ETHI", "E")
27
+ - originalRoom: Original room
28
+ - substituteRoom: Substitute room
29
+ - text: Additional notes
30
+
31
+ Use this tool when:
32
+ - A user asks "Do I have any substitutions today?"
33
+ - A user wants to know which teacher is substituting for whom
34
+ - A user asks "Is lesson X cancelled?"
35
+ - A user wants to filter substitutions by class name
36
+
37
+ This tool downloads and parses all plan pages, which may take a few seconds.
38
+ For just the list of plan URLs, use get_timetables instead.
39
+
40
+ The className parameter is optional. If omitted, the DSB_CLASS environment variable
41
+ is used as the default filter. If neither is set, all classes are returned.
42
+
43
+ Error handling:
44
+ - Returns an error message if authentication fails (check DSB_USERNAME and DSB_PASSWORD)
45
+ - Returns an error message if the DSBmobile service is unavailable`,
46
+ inputSchema: z.object({
47
+ className: z
48
+ .string()
49
+ .optional()
50
+ .describe(`Optional: filter results to a specific class name (e.g. '05b', 'Q2_Kra'). Case-insensitive. Defaults to the DSB_CLASS environment variable if set.`),
51
+ }),
52
+ annotations: {
53
+ readOnlyHint: true,
54
+ destructiveHint: false,
55
+ idempotentHint: true,
56
+ openWorldHint: true,
57
+ },
58
+ }, async ({ className }) => {
59
+ try {
60
+ const plans = await client.getSubstitutions();
61
+ // Use the provided className, fall back to DSB_CLASS env var, or show all
62
+ const effectiveClass = className ?? process.env[ENV_CLASS];
63
+ if (plans.length === 0) {
64
+ return {
65
+ content: [
66
+ {
67
+ type: 'text',
68
+ text: 'Aktuell sind keine Vertretungspläne auf DSBmobile verfügbar.',
69
+ },
70
+ ],
71
+ };
72
+ }
73
+ // Apply class filter if provided (explicit param takes priority over env var)
74
+ const filter = effectiveClass?.toLowerCase();
75
+ const filtered = filter
76
+ ? plans.map((plan) => ({
77
+ ...plan,
78
+ entries: plan.entries.filter((entry) => entry.className.toLowerCase().includes(filter)),
79
+ }))
80
+ : plans;
81
+ const totalEntries = filtered.reduce((sum, p) => sum + p.entries.length, 0);
82
+ if (filter && totalEntries === 0) {
83
+ return {
84
+ content: [
85
+ {
86
+ type: 'text',
87
+ text: `Keine Vertretungen für Klasse "${effectiveClass}" gefunden.`,
88
+ },
89
+ ],
90
+ };
91
+ }
92
+ const output = {
93
+ planCount: filtered.length,
94
+ totalEntries,
95
+ plans: filtered,
96
+ };
97
+ const text = formatSubstitutions(filtered, effectiveClass);
98
+ const finalText = text.length > CHARACTER_LIMIT
99
+ ? text.slice(0, CHARACTER_LIMIT) +
100
+ '\n\n[Response truncated. Use the className filter to narrow results.]'
101
+ : text;
102
+ return {
103
+ content: [{ type: 'text', text: finalText }],
104
+ structuredContent: output,
105
+ };
106
+ }
107
+ catch (error) {
108
+ const message = error instanceof Error ? error.message : 'An unexpected error occurred.';
109
+ return {
110
+ content: [{ type: 'text', text: message }],
111
+ isError: true,
112
+ };
113
+ }
114
+ });
115
+ }
116
+ /**
117
+ * Formats substitution plans as human-readable markdown.
118
+ */
119
+ function formatSubstitutions(plans, filter) {
120
+ const lines = [];
121
+ const totalEntries = plans.reduce((sum, p) => sum + p.entries.length, 0);
122
+ const heading = filter
123
+ ? `# Substitutions for class "${filter}" (${totalEntries} entries)`
124
+ : `# DSBmobile Substitution Plans (${totalEntries} total entries)`;
125
+ lines.push(heading, '');
126
+ for (const plan of plans) {
127
+ if (plan.entries.length === 0)
128
+ continue;
129
+ lines.push(`## ${plan.title}`, `**Date**: ${plan.planDate}`, `**Last Updated**: ${plan.lastUpdated}`);
130
+ if (plan.affectedClasses) {
131
+ lines.push(`**Affected Classes**: ${plan.affectedClasses}`);
132
+ }
133
+ lines.push('');
134
+ // Group entries by class
135
+ const byClass = new Map();
136
+ for (const entry of plan.entries) {
137
+ const list = byClass.get(entry.className) ?? [];
138
+ list.push(entry);
139
+ byClass.set(entry.className, list);
140
+ }
141
+ for (const [cls, entries] of byClass) {
142
+ lines.push(`### Class ${cls}`);
143
+ for (const substitution of entries) {
144
+ const teacher = substitution.originalTeacher && substitution.substituteTeacher
145
+ ? `${substitution.originalTeacher} → ${substitution.substituteTeacher}`
146
+ : substitution.substituteTeacher || substitution.originalTeacher;
147
+ const room = substitution.originalRoom && substitution.substituteRoom
148
+ ? `${substitution.originalRoom} → ${substitution.substituteRoom}`
149
+ : substitution.substituteRoom || substitution.originalRoom;
150
+ const parts = [
151
+ `**${substitution.type}**`,
152
+ `Period ${substitution.period}`,
153
+ substitution.subject && `Subject: ${substitution.subject}`,
154
+ teacher && `Teacher: ${teacher}`,
155
+ room && `Room: ${room}`,
156
+ substitution.text && `Note: ${substitution.text}`,
157
+ ].filter(Boolean);
158
+ lines.push(`- ${parts.join(' | ')}`);
159
+ }
160
+ lines.push('');
161
+ }
162
+ }
163
+ return lines.join('\n');
164
+ }
@@ -0,0 +1,9 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { DsbmobileClient } from '../services/dsbmobile.js';
3
+ /**
4
+ * Registers the get_timetables tool with the MCP server.
5
+ *
6
+ * This tool retrieves all available substitution plan (Vertretungsplan) entries
7
+ * from DSBmobile, including URLs to the HTML plan pages.
8
+ */
9
+ export declare function registerTimetablesTool(server: McpServer, client: DsbmobileClient): void;
@@ -0,0 +1,91 @@
1
+ import { z } from 'zod';
2
+ import { CHARACTER_LIMIT } from '../constants.js';
3
+ /**
4
+ * Registers the get_timetables tool with the MCP server.
5
+ *
6
+ * This tool retrieves all available substitution plan (Vertretungsplan) entries
7
+ * from DSBmobile, including URLs to the HTML plan pages.
8
+ */
9
+ export function registerTimetablesTool(server, client) {
10
+ server.registerTool('get_timetables', {
11
+ title: 'Get DSBmobile Substitution Plans',
12
+ description: `Retrieves all available substitution plan (Vertretungsplan) entries from DSBmobile.
13
+
14
+ Returns a list of substitution plan entries, each with:
15
+ - id: Unique identifier for the plan entry
16
+ - title: Plan name (e.g., "Vertretungen-heute" for today's substitutions)
17
+ - date: Last updated timestamp in DD.MM.YYYY HH:MM format
18
+ - url: URL to the HTML page containing the actual substitution plan table
19
+ - previewUrl: URL to a preview image of the plan (optional)
20
+
21
+ The substitution plan HTML pages contain the detailed schedule showing which
22
+ classes have substitutions, which teachers are replaced, which rooms are used, etc.
23
+ The HTML format varies by school, so the URL is returned for further processing.
24
+
25
+ Use this tool when:
26
+ - A user asks "Do I have any substitutions today?"
27
+ - A user wants to check the school's substitution schedule
28
+ - A user asks about teacher absences or room changes
29
+
30
+ Returns "No substitution plans available" if no plans are currently published.
31
+
32
+ Error handling:
33
+ - Returns an error message if authentication fails (check DSB_USERNAME and DSB_PASSWORD)
34
+ - Returns an error message if the DSBmobile service is unavailable`,
35
+ inputSchema: z.object({}).strict(),
36
+ annotations: {
37
+ readOnlyHint: true,
38
+ destructiveHint: false,
39
+ idempotentHint: true,
40
+ openWorldHint: true,
41
+ },
42
+ }, async () => {
43
+ try {
44
+ const entries = await client.getTimetables();
45
+ if (entries.length === 0) {
46
+ return {
47
+ content: [
48
+ {
49
+ type: 'text',
50
+ text: 'Aktuell sind keine Vertretungspläne auf DSBmobile verfügbar.',
51
+ },
52
+ ],
53
+ };
54
+ }
55
+ const output = {
56
+ count: entries.length,
57
+ timetables: entries,
58
+ };
59
+ const text = formatTimetables(entries);
60
+ const finalText = text.length > CHARACTER_LIMIT
61
+ ? text.slice(0, CHARACTER_LIMIT) +
62
+ '\n\n[Response truncated. Use the plan URLs to access the full content.]'
63
+ : text;
64
+ return {
65
+ content: [{ type: 'text', text: finalText }],
66
+ structuredContent: output,
67
+ };
68
+ }
69
+ catch (error) {
70
+ const message = error instanceof Error ? error.message : 'An unexpected error occurred.';
71
+ return {
72
+ content: [{ type: 'text', text: message }],
73
+ isError: true,
74
+ };
75
+ }
76
+ });
77
+ }
78
+ /**
79
+ * Formats timetable entries as human-readable markdown.
80
+ */
81
+ function formatTimetables(entries) {
82
+ const lines = [`# DSBmobile Substitution Plans (${entries.length} available)`, ''];
83
+ for (const entry of entries) {
84
+ lines.push(`## ${entry.title}`, `- **ID**: ${entry.id}`, `- **Last Updated**: ${entry.date}`, `- **Plan URL**: ${entry.url}`);
85
+ if (entry.previewUrl) {
86
+ lines.push(`- **Preview**: ${entry.previewUrl}`);
87
+ }
88
+ lines.push('');
89
+ }
90
+ return lines.join('\n');
91
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * TypeScript interfaces for DSBmobile API data structures
3
+ */
4
+ /**
5
+ * Raw item structure returned by the DSBmobile API.
6
+ * ConType determines how data is encoded:
7
+ * - 2: Data is in Childs array (nested items)
8
+ * - 4: Detail contains a link to an HTML web page
9
+ * - 5: Detail contains plain text
10
+ * - 6: Detail contains a link to an image/file
11
+ */
12
+ export interface DsbItem {
13
+ Id: string;
14
+ Date: string;
15
+ Title: string;
16
+ Detail: string;
17
+ Tags: string;
18
+ ConType: number;
19
+ Prio: number;
20
+ Index: number;
21
+ Childs: DsbItem[];
22
+ Preview: string;
23
+ }
24
+ /**
25
+ * A processed timetable/substitution plan entry
26
+ */
27
+ export interface TimetableEntry {
28
+ /** Unique identifier */
29
+ id: string;
30
+ /** Plan name (e.g., "Vertretungen-heute") */
31
+ title: string;
32
+ /** Last updated date in DD.MM.YYYY HH:MM format */
33
+ date: string;
34
+ /** URL to the HTML plan page */
35
+ url: string;
36
+ /** URL to the preview image (if available) */
37
+ previewUrl?: string;
38
+ }
39
+ /**
40
+ * A processed news/announcement entry
41
+ */
42
+ export interface NewsEntry {
43
+ /** Unique identifier */
44
+ id: string;
45
+ /** News headline */
46
+ title: string;
47
+ /** News content or URL */
48
+ detail: string;
49
+ /** Publication date in DD.MM.YYYY HH:MM format */
50
+ date: string;
51
+ /** Associated tags */
52
+ tags: string;
53
+ }
54
+ /**
55
+ * A single substitution entry parsed from a timetable HTML page
56
+ */
57
+ export interface SubstitutionEntry {
58
+ /** Class name (e.g., "05b") */
59
+ className: string;
60
+ /** Type of substitution (e.g., "Vertretung", "Statt-Vertretung", "Entfall") */
61
+ type: string;
62
+ /** Lesson period(s) (e.g., "3" or "5 - 6") */
63
+ period: string;
64
+ /** Original teacher abbreviation */
65
+ originalTeacher: string;
66
+ /** Substitute teacher abbreviation */
67
+ substituteTeacher: string;
68
+ /** Subject */
69
+ subject: string;
70
+ /** Original room */
71
+ originalRoom: string;
72
+ /** Substitute room */
73
+ substituteRoom: string;
74
+ /** Additional notes */
75
+ text: string;
76
+ }
77
+ /**
78
+ * A fully parsed substitution plan page
79
+ */
80
+ export interface SubstitutionPlan {
81
+ /** Plan title including date (e.g., "V-Homepage heute - subst_001") */
82
+ title: string;
83
+ /** Date string from the plan (e.g., "20.3.2026 Freitag (Seite 1 / 8)") */
84
+ planDate: string;
85
+ /** Last updated timestamp */
86
+ lastUpdated: string;
87
+ /** URL the plan was fetched from */
88
+ url: string;
89
+ /** Affected classes */
90
+ affectedClasses: string;
91
+ /** All substitution entries */
92
+ entries: SubstitutionEntry[];
93
+ }
94
+ /**
95
+ * A processed document entry
96
+ */
97
+ export interface DocumentEntry {
98
+ /** Unique identifier */
99
+ id: string;
100
+ /** Document name */
101
+ title: string;
102
+ /** Download URL */
103
+ url: string;
104
+ /** Upload date in DD.MM.YYYY HH:MM format */
105
+ date: string;
106
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * TypeScript interfaces for DSBmobile API data structures
3
+ */
4
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Converts an API error into a human-readable, actionable error message.
3
+ * Credentials are never included in error messages.
4
+ */
5
+ export declare function handleApiError(error: unknown): string;
6
+ /**
7
+ * Creates an error message for authentication failure (empty token response).
8
+ */
9
+ export declare function createAuthError(): string;
@@ -0,0 +1,50 @@
1
+ import { AxiosError } from 'axios';
2
+ /**
3
+ * Converts an API error into a human-readable, actionable error message.
4
+ * Credentials are never included in error messages.
5
+ */
6
+ export function handleApiError(error) {
7
+ if (error instanceof AxiosError) {
8
+ if (error.response) {
9
+ switch (error.response.status) {
10
+ case 401: {
11
+ return 'Error: Authentication failed. Please verify your credentials are correct.';
12
+ }
13
+ case 403: {
14
+ return 'Error: Access denied. Your account may not have permission to access this resource.';
15
+ }
16
+ case 404: {
17
+ return 'Error: Resource not found. The DSBmobile API endpoint may have changed.';
18
+ }
19
+ case 429: {
20
+ return 'Error: Rate limit exceeded. Please wait a moment before making more requests.';
21
+ }
22
+ case 500:
23
+ case 502:
24
+ case 503:
25
+ case 504: {
26
+ return 'Error: DSBmobile service is temporarily unavailable. Please try again later.';
27
+ }
28
+ default: {
29
+ return `Error: API request failed with status ${error.response.status}. Please try again.`;
30
+ }
31
+ }
32
+ }
33
+ else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
34
+ return 'Error: Request timed out. The DSBmobile service may be slow or unavailable. Please try again.';
35
+ }
36
+ else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
37
+ return 'Error: Cannot connect to DSBmobile. Please check your internet connection and try again.';
38
+ }
39
+ }
40
+ if (error instanceof Error) {
41
+ return `Error: ${error.message}`;
42
+ }
43
+ return 'Error: An unexpected error occurred. Please try again.';
44
+ }
45
+ /**
46
+ * Creates an error message for authentication failure (empty token response).
47
+ */
48
+ export function createAuthError() {
49
+ return 'Error: Authentication failed. DSBmobile rejected the provided credentials. Please verify your credentials are correct.';
50
+ }
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@udondan/dsbmobile",
3
+ "version": "1.1.0",
4
+ "description": "SDK, CLI, and MCP server for DSBmobile — access German school substitution plans (Vertretungspläne)",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/udondan/dsbmobile-mcp.git"
8
+ },
9
+ "author": {
10
+ "name": "Daniel Schroeder",
11
+ "email": "deemes79@googlemail.com",
12
+ "url": "https://udondan.com/"
13
+ },
14
+ "license": "MIT",
15
+ "funding": {
16
+ "type": "github",
17
+ "url": "https://github.com/sponsors/udondan"
18
+ },
19
+ "keywords": [
20
+ "mcp",
21
+ "mcp-server",
22
+ "cli",
23
+ "sdk",
24
+ "dsbmobile",
25
+ "vertretungsplan",
26
+ "school",
27
+ "substitution-plan"
28
+ ],
29
+ "type": "module",
30
+ "main": "dist/index.js",
31
+ "types": "dist/index.d.ts",
32
+ "bin": {
33
+ "dsbmobile": "dist/cli.js"
34
+ },
35
+ "exports": {
36
+ ".": {
37
+ "import": "./dist/index.js",
38
+ "types": "./dist/index.d.ts"
39
+ }
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "LICENSE"
44
+ ],
45
+ "scripts": {
46
+ "build": "tsc",
47
+ "dev": "tsc --watch",
48
+ "start": "node dist/cli.js mcp",
49
+ "pretest": "tsc",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "typecheck": "tsc --noEmit -p tsconfig.lint.json",
53
+ "lint": "eslint src/ tests/",
54
+ "lint:fix": "eslint --fix src/ tests/",
55
+ "format": "prettier --write src/ tests/",
56
+ "format:check": "prettier --check src/ tests/",
57
+ "markdownlint": "markdownlint-cli2 '**/*.md' '#node_modules' '#.claude' '#.vibe' '#CHANGELOG.md'"
58
+ },
59
+ "engines": {
60
+ "node": ">=22"
61
+ },
62
+ "dependencies": {
63
+ "@modelcontextprotocol/sdk": "1.29.0",
64
+ "axios": "1.16.1",
65
+ "commander": "14.0.3",
66
+ "zod": "4.4.3"
67
+ },
68
+ "devDependencies": {
69
+ "@eslint/js": "10.0.1",
70
+ "@modelcontextprotocol/inspector": "0.21.2",
71
+ "@types/node": "22.0.0",
72
+ "eslint": "10.4.0",
73
+ "eslint-config-prettier": "10.1.8",
74
+ "eslint-plugin-prettier": "5.5.5",
75
+ "eslint-plugin-unicorn": "64.0.0",
76
+ "prettier": "3.8.3",
77
+ "typescript": "6.0.3",
78
+ "typescript-eslint": "8.59.4",
79
+ "markdownlint-cli2": "0.22.1",
80
+ "vitest": "4.1.7"
81
+ }
82
+ }