@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.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.js +94 -0
- package/dist/constants.d.ts +21 -0
- package/dist/constants.js +21 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +18 -0
- package/dist/services/dsbmobile.d.ts +81 -0
- package/dist/services/dsbmobile.js +341 -0
- package/dist/tools/documents.d.ts +8 -0
- package/dist/tools/documents.js +80 -0
- package/dist/tools/news.d.ts +8 -0
- package/dist/tools/news.js +88 -0
- package/dist/tools/substitutions.d.ts +9 -0
- package/dist/tools/substitutions.js +164 -0
- package/dist/tools/timetables.d.ts +9 -0
- package/dist/tools/timetables.js +91 -0
- package/dist/types.d.ts +106 -0
- package/dist/types.js +4 -0
- package/dist/utils/errors.d.ts +9 -0
- package/dist/utils/errors.js +50 -0
- package/package.json +82 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { DSB_API_BASE_URL, DSB_APP_VERSION, DSB_BUNDLE_ID, DSB_OS_VERSION, REQUEST_TIMEOUT_MS, } from '../constants.js';
|
|
3
|
+
import { createAuthError, handleApiError } from '../utils/errors.js';
|
|
4
|
+
/** Decode common HTML entities and trim whitespace. */
|
|
5
|
+
function decodeHtmlEntities(s) {
|
|
6
|
+
return s
|
|
7
|
+
.replaceAll(' ', ' ')
|
|
8
|
+
.replaceAll('&', '&')
|
|
9
|
+
.replaceAll('<', '<')
|
|
10
|
+
.replaceAll('>', '>')
|
|
11
|
+
.replaceAll('"', '"')
|
|
12
|
+
.trim();
|
|
13
|
+
}
|
|
14
|
+
/** Strip all HTML tags and trim whitespace. */
|
|
15
|
+
function stripHtmlTags(s) {
|
|
16
|
+
return s.replaceAll(/<[^>]+>/g, '').trim();
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Client for the DSBmobile Mobile API.
|
|
20
|
+
*
|
|
21
|
+
* Accepts explicit credentials via {@link DsbmobileConfig} and provides
|
|
22
|
+
* methods to fetch timetables, news, documents, and substitution plans.
|
|
23
|
+
*
|
|
24
|
+
* The authentication token is cached in memory for the session lifetime.
|
|
25
|
+
*/
|
|
26
|
+
export class DsbmobileClient {
|
|
27
|
+
username;
|
|
28
|
+
password;
|
|
29
|
+
http;
|
|
30
|
+
token;
|
|
31
|
+
constructor(config) {
|
|
32
|
+
this.username = config.username;
|
|
33
|
+
this.password = config.password;
|
|
34
|
+
this.http = axios.create({
|
|
35
|
+
baseURL: DSB_API_BASE_URL,
|
|
36
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
37
|
+
headers: {
|
|
38
|
+
Accept: 'application/json',
|
|
39
|
+
'User-Agent': `Dalvik/2.1.0 (Linux; U; Android 5.1; ${DSB_BUNDLE_ID})`,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Authenticates with DSBmobile and caches the token.
|
|
45
|
+
* The token is stable per username, so it can be cached indefinitely.
|
|
46
|
+
*
|
|
47
|
+
* @throws Error if credentials are invalid or the request fails
|
|
48
|
+
*/
|
|
49
|
+
async authenticate() {
|
|
50
|
+
const response = await this.http.get('/authid', {
|
|
51
|
+
params: {
|
|
52
|
+
bundleid: DSB_BUNDLE_ID,
|
|
53
|
+
appversion: DSB_APP_VERSION,
|
|
54
|
+
osversion: DSB_OS_VERSION,
|
|
55
|
+
pushid: '',
|
|
56
|
+
user: this.username,
|
|
57
|
+
password: this.password,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
// The API returns the token as a JSON string (with quotes), e.g. "uuid-here"
|
|
61
|
+
// An empty string "" means invalid credentials
|
|
62
|
+
const token = response.data;
|
|
63
|
+
if (!token || token === '""' || token === '') {
|
|
64
|
+
throw new Error(createAuthError());
|
|
65
|
+
}
|
|
66
|
+
// Remove surrounding quotes if present
|
|
67
|
+
this.token = token.replaceAll(/^"|"$/g, '');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Ensures a valid token is available, authenticating if necessary.
|
|
71
|
+
*/
|
|
72
|
+
async ensureAuthenticated() {
|
|
73
|
+
if (!this.token) {
|
|
74
|
+
await this.authenticate();
|
|
75
|
+
}
|
|
76
|
+
return this.token;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Fetches all available substitution plan (Vertretungsplan) entries.
|
|
80
|
+
*
|
|
81
|
+
* @returns Array of timetable entries with URLs to HTML plan pages
|
|
82
|
+
* @throws Error if authentication fails or the request fails
|
|
83
|
+
*/
|
|
84
|
+
async getTimetables() {
|
|
85
|
+
try {
|
|
86
|
+
const token = await this.ensureAuthenticated();
|
|
87
|
+
const response = await this.http.get('/dsbtimetables', {
|
|
88
|
+
params: { authid: token },
|
|
89
|
+
});
|
|
90
|
+
return this.parseTimetables(response.data);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof Error && error.message.startsWith('Error:')) {
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
throw new Error(handleApiError(error), { cause: error });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Fetches all news and announcements.
|
|
101
|
+
*
|
|
102
|
+
* @returns Array of news entries
|
|
103
|
+
* @throws Error if authentication fails or the request fails
|
|
104
|
+
*/
|
|
105
|
+
async getNews() {
|
|
106
|
+
try {
|
|
107
|
+
const token = await this.ensureAuthenticated();
|
|
108
|
+
const response = await this.http.get('/newstab', {
|
|
109
|
+
params: { authid: token },
|
|
110
|
+
});
|
|
111
|
+
return this.parseNews(response.data);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof Error && error.message.startsWith('Error:')) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
throw new Error(handleApiError(error), { cause: error });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Fetches all available documents.
|
|
122
|
+
*
|
|
123
|
+
* @returns Array of document entries with download URLs
|
|
124
|
+
* @throws Error if authentication fails or the request fails
|
|
125
|
+
*/
|
|
126
|
+
async getDocuments() {
|
|
127
|
+
try {
|
|
128
|
+
const token = await this.ensureAuthenticated();
|
|
129
|
+
const response = await this.http.get('/dsbdocuments', {
|
|
130
|
+
params: { authid: token },
|
|
131
|
+
});
|
|
132
|
+
return this.parseDocuments(response.data);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error instanceof Error && error.message.startsWith('Error:')) {
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(handleApiError(error), { cause: error });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Parses raw DSBmobile timetable items into structured TimetableEntry objects.
|
|
143
|
+
* Timetables have ConType=2 with children that contain the actual plan URLs.
|
|
144
|
+
* The parent item holds the descriptive title; children are individual pages.
|
|
145
|
+
*/
|
|
146
|
+
parseTimetables(items) {
|
|
147
|
+
const entries = [];
|
|
148
|
+
for (const item of items) {
|
|
149
|
+
if (item.ConType === 2 && item.Childs && item.Childs.length > 0) {
|
|
150
|
+
const validChildren = item.Childs.filter((c) => c.Detail);
|
|
151
|
+
const multiPage = validChildren.length > 1;
|
|
152
|
+
// Each child represents a page of the plan
|
|
153
|
+
for (const [index, validChild] of validChildren.entries()) {
|
|
154
|
+
const child = validChild;
|
|
155
|
+
// Use the parent's descriptive title; append page number if multi-page
|
|
156
|
+
const title = multiPage ? `${item.Title} (Seite ${index + 1})` : item.Title;
|
|
157
|
+
const entry = {
|
|
158
|
+
id: child.Id,
|
|
159
|
+
title,
|
|
160
|
+
date: item.Date,
|
|
161
|
+
url: child.Detail,
|
|
162
|
+
};
|
|
163
|
+
if (child.Preview) {
|
|
164
|
+
entry.previewUrl = `https://light.dsbcontrol.de/DSBlightWebsite/Data/${child.Preview}`;
|
|
165
|
+
}
|
|
166
|
+
entries.push(entry);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (item.Detail) {
|
|
170
|
+
// Direct URL in Detail field
|
|
171
|
+
const entry = {
|
|
172
|
+
id: item.Id,
|
|
173
|
+
title: item.Title,
|
|
174
|
+
date: item.Date,
|
|
175
|
+
url: item.Detail,
|
|
176
|
+
};
|
|
177
|
+
if (item.Preview) {
|
|
178
|
+
entry.previewUrl = `https://light.dsbcontrol.de/DSBlightWebsite/Data/${item.Preview}`;
|
|
179
|
+
}
|
|
180
|
+
entries.push(entry);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return entries;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Parses raw DSBmobile news items into structured NewsEntry objects.
|
|
187
|
+
*/
|
|
188
|
+
parseNews(items) {
|
|
189
|
+
const entries = [];
|
|
190
|
+
for (const item of items) {
|
|
191
|
+
entries.push({
|
|
192
|
+
id: item.Id,
|
|
193
|
+
title: item.Title,
|
|
194
|
+
detail: item.Detail,
|
|
195
|
+
date: item.Date,
|
|
196
|
+
tags: item.Tags,
|
|
197
|
+
});
|
|
198
|
+
// Also process nested children if present
|
|
199
|
+
if (item.Childs && item.Childs.length > 0) {
|
|
200
|
+
for (const child of item.Childs) {
|
|
201
|
+
entries.push({
|
|
202
|
+
id: child.Id,
|
|
203
|
+
title: child.Title || item.Title,
|
|
204
|
+
detail: child.Detail,
|
|
205
|
+
date: child.Date || item.Date,
|
|
206
|
+
tags: child.Tags || item.Tags,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return entries;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Fetches and parses all substitution plan pages, returning structured entries.
|
|
215
|
+
* This fetches the actual HTML content of each timetable page and parses the
|
|
216
|
+
* substitution table into structured data.
|
|
217
|
+
*
|
|
218
|
+
* @returns Array of parsed substitution plans (one per HTML page)
|
|
219
|
+
* @throws Error if authentication fails or the request fails
|
|
220
|
+
*/
|
|
221
|
+
async getSubstitutions() {
|
|
222
|
+
try {
|
|
223
|
+
const timetables = await this.getTimetables();
|
|
224
|
+
const plans = [];
|
|
225
|
+
for (const timetable of timetables) {
|
|
226
|
+
try {
|
|
227
|
+
// Use a standalone axios call with the full URL (not the base-URL-bound instance)
|
|
228
|
+
// responseType 'arraybuffer' lets us decode the latin-1 charset ourselves
|
|
229
|
+
const response = await axios.get(timetable.url, {
|
|
230
|
+
responseType: 'arraybuffer',
|
|
231
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
232
|
+
});
|
|
233
|
+
// The HTML is encoded in iso-8859-1/windows-1252 — decode it properly
|
|
234
|
+
const htmlText = new TextDecoder('windows-1252').decode(response.data);
|
|
235
|
+
const plan = parseSubstitutionHtml(htmlText, timetable.title, timetable.date, timetable.url);
|
|
236
|
+
plans.push(plan);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Skip pages that fail to load; don't abort the whole request
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return plans;
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error instanceof Error && error.message.startsWith('Error:')) {
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
throw new Error(handleApiError(error), { cause: error });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Parses raw DSBmobile document items into structured DocumentEntry objects.
|
|
253
|
+
*/
|
|
254
|
+
parseDocuments(items) {
|
|
255
|
+
const entries = [];
|
|
256
|
+
for (const item of items) {
|
|
257
|
+
if (item.ConType === 2 && item.Childs && item.Childs.length > 0) {
|
|
258
|
+
// Documents are often nested
|
|
259
|
+
for (const child of item.Childs) {
|
|
260
|
+
if (child.Detail) {
|
|
261
|
+
entries.push({
|
|
262
|
+
id: child.Id,
|
|
263
|
+
title: child.Title || item.Title,
|
|
264
|
+
url: child.Detail,
|
|
265
|
+
date: child.Date || item.Date,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else if (item.Detail) {
|
|
271
|
+
entries.push({
|
|
272
|
+
id: item.Id,
|
|
273
|
+
title: item.Title,
|
|
274
|
+
url: item.Detail,
|
|
275
|
+
date: item.Date,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return entries;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Parses the HTML content of a DSBmobile substitution plan page.
|
|
284
|
+
* The HTML is generated by Untis and has a consistent structure.
|
|
285
|
+
* Exported for unit testing.
|
|
286
|
+
*/
|
|
287
|
+
export function parseSubstitutionHtml(html, title, lastUpdated, url) {
|
|
288
|
+
// Extract plan date (e.g. "20.3.2026 Freitag (Seite 1 / 8)")
|
|
289
|
+
const dateMatch = /class="mon_title">(.*?)<\/div>/.exec(html);
|
|
290
|
+
const planDate = dateMatch ? decodeHtmlEntities(stripHtmlTags(dateMatch[1])) : '';
|
|
291
|
+
// Extract affected classes from info table
|
|
292
|
+
let affectedClasses = '';
|
|
293
|
+
const infoRows = html.matchAll(/<tr class="info"><td[^>]*>(.*?)<\/td><td[^>]*>(.*?)<\/td><\/tr>/gs);
|
|
294
|
+
for (const match of infoRows) {
|
|
295
|
+
const label = decodeHtmlEntities(stripHtmlTags(match[1]));
|
|
296
|
+
const value = decodeHtmlEntities(stripHtmlTags(match[2]));
|
|
297
|
+
if (label.toLowerCase().includes('klassen')) {
|
|
298
|
+
affectedClasses = value;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// Parse all table rows
|
|
302
|
+
const entries = [];
|
|
303
|
+
let currentClass = '';
|
|
304
|
+
const allRows = html.matchAll(/<tr[^>]*>(.*?)<\/tr>/gs);
|
|
305
|
+
for (const rowMatch of allRows) {
|
|
306
|
+
const rowHtml = rowMatch[1];
|
|
307
|
+
const cells = [...rowHtml.matchAll(/<t[dh][^>]*>(.*?)<\/t[dh]>/gs)].map((m) => decodeHtmlEntities(stripHtmlTags(m[1])));
|
|
308
|
+
if (cells.length === 0)
|
|
309
|
+
continue;
|
|
310
|
+
// Class header row: single cell with class name (e.g. "10a 10a" or just "10a")
|
|
311
|
+
if (cells.length === 1) {
|
|
312
|
+
currentClass = cells[0].split(/\s+/)[0] ?? '';
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
// Skip header row and info rows
|
|
316
|
+
if (cells.length < 5 || cells[0] === 'Art')
|
|
317
|
+
continue;
|
|
318
|
+
// Substitution row: Art | Stunde | Vertreter | Fach | Raum | Text
|
|
319
|
+
const [type = '', period = '', teacherField = '', subject = '', roomField = '', text = ''] = cells;
|
|
320
|
+
// Teacher field: "OriginalTeacher?SubstituteTeacher" or just "SubstituteTeacher"
|
|
321
|
+
const [originalTeacher = '', substituteTeacher = ''] = teacherField.includes('?')
|
|
322
|
+
? teacherField.split('?')
|
|
323
|
+
: ['', teacherField];
|
|
324
|
+
// Room field: "OriginalRoom?SubstituteRoom" or just "SubstituteRoom"
|
|
325
|
+
const [originalRoom = '', substituteRoom = ''] = roomField.includes('?')
|
|
326
|
+
? roomField.split('?')
|
|
327
|
+
: ['', roomField];
|
|
328
|
+
entries.push({
|
|
329
|
+
className: currentClass,
|
|
330
|
+
type,
|
|
331
|
+
period,
|
|
332
|
+
originalTeacher,
|
|
333
|
+
substituteTeacher,
|
|
334
|
+
subject,
|
|
335
|
+
originalRoom,
|
|
336
|
+
substituteRoom,
|
|
337
|
+
text: text === '\u00A0' ? '' : text,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return { title, planDate, lastUpdated, url, affectedClasses, entries };
|
|
341
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { DsbmobileClient } from '../services/dsbmobile.js';
|
|
3
|
+
/**
|
|
4
|
+
* Registers the get_documents tool with the MCP server.
|
|
5
|
+
*
|
|
6
|
+
* This tool retrieves all documents and files available on DSBmobile.
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerDocumentsTool(server: McpServer, client: DsbmobileClient): void;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CHARACTER_LIMIT } from '../constants.js';
|
|
3
|
+
/**
|
|
4
|
+
* Registers the get_documents tool with the MCP server.
|
|
5
|
+
*
|
|
6
|
+
* This tool retrieves all documents and files available on DSBmobile.
|
|
7
|
+
*/
|
|
8
|
+
export function registerDocumentsTool(server, client) {
|
|
9
|
+
server.registerTool('get_documents', {
|
|
10
|
+
title: 'Get DSBmobile Documents',
|
|
11
|
+
description: `Retrieves all documents and files available on DSBmobile.
|
|
12
|
+
|
|
13
|
+
Returns a list of document entries, each with:
|
|
14
|
+
- id: Unique identifier for the document
|
|
15
|
+
- title: Document name or title
|
|
16
|
+
- url: Download URL for the document (typically a PDF or image)
|
|
17
|
+
- date: Upload date in DD.MM.YYYY HH:MM format
|
|
18
|
+
|
|
19
|
+
Use this tool when:
|
|
20
|
+
- A user asks about school documents or files
|
|
21
|
+
- A user wants to download a specific document
|
|
22
|
+
- A user asks "Are there any documents on DSBmobile?"
|
|
23
|
+
|
|
24
|
+
Returns "No documents available" if no documents are currently published.
|
|
25
|
+
|
|
26
|
+
Error handling:
|
|
27
|
+
- Returns an error message if authentication fails (check DSB_USERNAME and DSB_PASSWORD)
|
|
28
|
+
- Returns an error message if the DSBmobile service is unavailable`,
|
|
29
|
+
inputSchema: z.object({}).strict(),
|
|
30
|
+
annotations: {
|
|
31
|
+
readOnlyHint: true,
|
|
32
|
+
destructiveHint: false,
|
|
33
|
+
idempotentHint: true,
|
|
34
|
+
openWorldHint: true,
|
|
35
|
+
},
|
|
36
|
+
}, async () => {
|
|
37
|
+
try {
|
|
38
|
+
const entries = await client.getDocuments();
|
|
39
|
+
if (entries.length === 0) {
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{
|
|
43
|
+
type: 'text',
|
|
44
|
+
text: 'Aktuell sind keine Dokumente auf DSBmobile verfügbar.',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const output = {
|
|
50
|
+
count: entries.length,
|
|
51
|
+
documents: entries,
|
|
52
|
+
};
|
|
53
|
+
const text = formatDocuments(entries);
|
|
54
|
+
const finalText = text.length > CHARACTER_LIMIT
|
|
55
|
+
? text.slice(0, CHARACTER_LIMIT) + '\n\n[Response truncated due to length.]'
|
|
56
|
+
: text;
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: 'text', text: finalText }],
|
|
59
|
+
structuredContent: output,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const message = error instanceof Error ? error.message : 'An unexpected error occurred.';
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: 'text', text: message }],
|
|
66
|
+
isError: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Formats document entries as human-readable markdown.
|
|
73
|
+
*/
|
|
74
|
+
function formatDocuments(entries) {
|
|
75
|
+
const lines = [`# DSBmobile Documents (${entries.length} available)`, ''];
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
lines.push(`## ${entry.title}`, `- **ID**: ${entry.id}`, `- **Date**: ${entry.date}`, `- **Download URL**: ${entry.url}`, '');
|
|
78
|
+
}
|
|
79
|
+
return lines.join('\n');
|
|
80
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { DsbmobileClient } from '../services/dsbmobile.js';
|
|
3
|
+
/**
|
|
4
|
+
* Registers the get_news tool with the MCP server.
|
|
5
|
+
*
|
|
6
|
+
* This tool retrieves all news and announcements from DSBmobile.
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerNewsTool(server: McpServer, client: DsbmobileClient): void;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CHARACTER_LIMIT } from '../constants.js';
|
|
3
|
+
/**
|
|
4
|
+
* Registers the get_news tool with the MCP server.
|
|
5
|
+
*
|
|
6
|
+
* This tool retrieves all news and announcements from DSBmobile.
|
|
7
|
+
*/
|
|
8
|
+
export function registerNewsTool(server, client) {
|
|
9
|
+
server.registerTool('get_news', {
|
|
10
|
+
title: 'Get DSBmobile News and Announcements',
|
|
11
|
+
description: `Retrieves all news and announcements posted on DSBmobile.
|
|
12
|
+
|
|
13
|
+
Returns a list of news items, each with:
|
|
14
|
+
- id: Unique identifier for the news item
|
|
15
|
+
- title: News headline or title
|
|
16
|
+
- detail: News content, URL to more details, or plain text announcement
|
|
17
|
+
- date: Publication date in DD.MM.YYYY HH:MM format
|
|
18
|
+
- tags: Associated tags or categories (may be empty)
|
|
19
|
+
|
|
20
|
+
Use this tool when:
|
|
21
|
+
- A user asks about school announcements or news
|
|
22
|
+
- A user wants to know about upcoming school events
|
|
23
|
+
- A user asks "What's new at school?"
|
|
24
|
+
|
|
25
|
+
Returns "No news available" if no news items are currently published.
|
|
26
|
+
|
|
27
|
+
Error handling:
|
|
28
|
+
- Returns an error message if authentication fails (check DSB_USERNAME and DSB_PASSWORD)
|
|
29
|
+
- Returns an error message if the DSBmobile service is unavailable`,
|
|
30
|
+
inputSchema: z.object({}).strict(),
|
|
31
|
+
annotations: {
|
|
32
|
+
readOnlyHint: true,
|
|
33
|
+
destructiveHint: false,
|
|
34
|
+
idempotentHint: true,
|
|
35
|
+
openWorldHint: true,
|
|
36
|
+
},
|
|
37
|
+
}, async () => {
|
|
38
|
+
try {
|
|
39
|
+
const entries = await client.getNews();
|
|
40
|
+
if (entries.length === 0) {
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: 'text',
|
|
45
|
+
text: 'Aktuell sind keine Neuigkeiten auf DSBmobile verfügbar.',
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const output = {
|
|
51
|
+
count: entries.length,
|
|
52
|
+
news: entries,
|
|
53
|
+
};
|
|
54
|
+
const text = formatNews(entries);
|
|
55
|
+
const finalText = text.length > CHARACTER_LIMIT
|
|
56
|
+
? text.slice(0, CHARACTER_LIMIT) + '\n\n[Response truncated due to length.]'
|
|
57
|
+
: text;
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: 'text', text: finalText }],
|
|
60
|
+
structuredContent: output,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const message = error instanceof Error ? error.message : 'An unexpected error occurred.';
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: 'text', text: message }],
|
|
67
|
+
isError: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Formats news entries as human-readable markdown.
|
|
74
|
+
*/
|
|
75
|
+
function formatNews(entries) {
|
|
76
|
+
const lines = [`# DSBmobile News (${entries.length} items)`, ''];
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
lines.push(`## ${entry.title}`, `- **ID**: ${entry.id}`, `- **Date**: ${entry.date}`);
|
|
79
|
+
if (entry.tags) {
|
|
80
|
+
lines.push(`- **Tags**: ${entry.tags}`);
|
|
81
|
+
}
|
|
82
|
+
if (entry.detail) {
|
|
83
|
+
lines.push(`- **Content**: ${entry.detail}`);
|
|
84
|
+
}
|
|
85
|
+
lines.push('');
|
|
86
|
+
}
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
@@ -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_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 declare function registerSubstitutionsTool(server: McpServer, client: DsbmobileClient): void;
|