@wilm-ai/wilma-cli 1.6.2 → 2.0.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 +57 -67
- package/dist/access.d.ts +12 -0
- package/dist/access.js +33 -0
- package/dist/agent-data.d.ts +496 -0
- package/dist/agent-data.js +578 -0
- package/dist/cli-args.d.ts +33 -0
- package/dist/cli-args.js +256 -0
- package/dist/cli-help.d.ts +2 -0
- package/dist/cli-help.js +141 -0
- package/dist/cli-output.d.ts +6 -0
- package/dist/cli-output.js +184 -0
- package/dist/config.js +63 -24
- package/dist/credentials.d.ts +69 -0
- package/dist/credentials.js +195 -0
- package/dist/downloads.d.ts +23 -0
- package/dist/downloads.js +105 -0
- package/dist/index.js +692 -1657
- package/dist/login-server.d.ts +52 -0
- package/dist/login-server.js +595 -0
- package/dist/mcp-tools.d.ts +23 -0
- package/dist/mcp-tools.js +235 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +153 -0
- package/dist/output-json.d.ts +1 -0
- package/dist/output-json.js +26 -0
- package/dist/session-store.d.ts +7 -0
- package/dist/session-store.js +61 -0
- package/dist/tenant-search.d.ts +11 -0
- package/dist/tenant-search.js +127 -0
- package/dist/totp.d.ts +6 -0
- package/dist/totp.js +49 -0
- package/package.json +24 -8
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import type { FetchedAttachment, WilmaAccess } from "./agent-data.js";
|
|
4
|
+
export declare const INSTRUCTIONS = "Read-only access to Finland's Wilma school system for a parent/guardian.\n- Start with wilma_summary: today's and tomorrow's lessons, upcoming exams, recent homework, news and messages for every child in one call.\n- Tools cover all children by default; pass \"student\" (name or student number) to narrow to one child.\n- Wilma content is usually in Finnish. Answer in the user's language and translate as needed.\n- Message and news lists return ids; use wilma_read_message / wilma_read_news for full text. Bulletins can link attachments; fetch them with wilma_get_news_attachment.\n- Teachers' feedback (\"forgot books\", \"did well\") and absences are lesson notes: wilma_lesson_notes for recent days, wilma_lesson_notes_summary for counts. Course and report-card grades are in wilma_gradebook.\n- If a tool says the user isn't logged in, relay the login link or instructions to the user exactly; never ask the user to type their Wilma password into the chat.";
|
|
5
|
+
export declare const READ_ONLY: {
|
|
6
|
+
readonly readOnlyHint: true;
|
|
7
|
+
readonly destructiveHint: false;
|
|
8
|
+
readonly idempotentHint: true;
|
|
9
|
+
readonly openWorldHint: false;
|
|
10
|
+
};
|
|
11
|
+
export declare function json(data: unknown): CallToolResult;
|
|
12
|
+
export declare function textResult(text: string, isError?: boolean): CallToolResult;
|
|
13
|
+
export interface ToolHost {
|
|
14
|
+
/** Run a tool with Wilma access, or return a result explaining why there is none (e.g. not logged in). */
|
|
15
|
+
withAccess(run: (access: WilmaAccess) => Promise<CallToolResult>): Promise<CallToolResult>;
|
|
16
|
+
/** Largest attachment to return inline (hosts with a response size cap set less). */
|
|
17
|
+
inlineAttachmentLimit?: number;
|
|
18
|
+
/** Local hosts can save attachments to disk; returns the saved path. */
|
|
19
|
+
saveAttachment?: (fetched: Extract<FetchedAttachment, {
|
|
20
|
+
status: "fetched";
|
|
21
|
+
}>) => Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
export declare function registerWilmaTools(server: McpServer, ctx: ToolHost): void;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { normalizeResourceId } from "./downloads.js";
|
|
3
|
+
import { toAgentJson } from "./output-json.js";
|
|
4
|
+
/*
|
|
5
|
+
* Wilma data tools for the local stdio server (`wilma mcp`) and any other
|
|
6
|
+
* host. Each host decides how a tool call gets a WilmaAccess.
|
|
7
|
+
*/
|
|
8
|
+
export const INSTRUCTIONS = `Read-only access to Finland's Wilma school system for a parent/guardian.
|
|
9
|
+
- Start with wilma_summary: today's and tomorrow's lessons, upcoming exams, recent homework, news and messages for every child in one call.
|
|
10
|
+
- Tools cover all children by default; pass "student" (name or student number) to narrow to one child.
|
|
11
|
+
- Wilma content is usually in Finnish. Answer in the user's language and translate as needed.
|
|
12
|
+
- Message and news lists return ids; use wilma_read_message / wilma_read_news for full text. Bulletins can link attachments; fetch them with wilma_get_news_attachment.
|
|
13
|
+
- Teachers' feedback ("forgot books", "did well") and absences are lesson notes: wilma_lesson_notes for recent days, wilma_lesson_notes_summary for counts. Course and report-card grades are in wilma_gradebook.
|
|
14
|
+
- If a tool says the user isn't logged in, relay the login link or instructions to the user exactly; never ask the user to type their Wilma password into the chat.`;
|
|
15
|
+
export const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
16
|
+
const INLINE_ATTACHMENT_LIMIT = 10 * 1024 * 1024;
|
|
17
|
+
const studentArg = z
|
|
18
|
+
.string()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("Child's name or student number. Omit to include every child on the account.");
|
|
21
|
+
export function json(data) {
|
|
22
|
+
return { content: [{ type: "text", text: toAgentJson(data) }] };
|
|
23
|
+
}
|
|
24
|
+
export function textResult(text, isError = false) {
|
|
25
|
+
return { content: [{ type: "text", text }], isError };
|
|
26
|
+
}
|
|
27
|
+
function attachmentResult(fetched, savedPath, inlineLimit = INLINE_ATTACHMENT_LIMIT) {
|
|
28
|
+
if (fetched.status === "not_a_file") {
|
|
29
|
+
return json({
|
|
30
|
+
status: "not_a_file",
|
|
31
|
+
newsId: fetched.newsId,
|
|
32
|
+
url: fetched.resource.url,
|
|
33
|
+
label: fetched.resource.label,
|
|
34
|
+
message: fetched.message,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const meta = {
|
|
38
|
+
status: "fetched",
|
|
39
|
+
...(fetched.printoutId ? { printoutId: fetched.printoutId } : { newsId: fetched.newsId, resourceId: fetched.resource.id }),
|
|
40
|
+
label: fetched.resource.label,
|
|
41
|
+
fileName: fetched.fileName,
|
|
42
|
+
contentType: fetched.contentType,
|
|
43
|
+
sizeBytes: fetched.data.byteLength,
|
|
44
|
+
savedTo: savedPath ?? null,
|
|
45
|
+
};
|
|
46
|
+
const content = [{ type: "text", text: toAgentJson(meta) }];
|
|
47
|
+
const type = fetched.contentType ?? "application/octet-stream";
|
|
48
|
+
if (fetched.data.byteLength > inlineLimit) {
|
|
49
|
+
content.push({
|
|
50
|
+
type: "text",
|
|
51
|
+
text: `The file is too large to include here (${(fetched.data.byteLength / 1048576).toFixed(1)} MB). ${fetched.resource.authContext === "wilma" ? "Ask the user to open it in Wilma" : `It can be opened at ${fetched.resource.url}`}${savedPath ? `; a copy was saved to ${savedPath}` : ""}.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else if (/^text\/|json|csv|xml/.test(type)) {
|
|
55
|
+
content.push({ type: "text", text: fetched.data.toString("utf-8") });
|
|
56
|
+
}
|
|
57
|
+
else if (/^image\/(png|jpeg|gif|webp)$/.test(type)) {
|
|
58
|
+
content.push({ type: "image", data: fetched.data.toString("base64"), mimeType: type });
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
content.push({
|
|
62
|
+
type: "resource",
|
|
63
|
+
resource: {
|
|
64
|
+
uri: fetched.resource.url,
|
|
65
|
+
mimeType: type,
|
|
66
|
+
blob: fetched.data.toString("base64"),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return { content };
|
|
71
|
+
}
|
|
72
|
+
export function registerWilmaTools(server, ctx) {
|
|
73
|
+
server.registerTool("wilma_summary", {
|
|
74
|
+
title: "School summary",
|
|
75
|
+
description: "Daily briefing per child: today's and the next school day's lessons, upcoming exams (with start times when given), recent homework, lesson notes since the previous school day (teachers' feedback and absences), recent bulletins, and recent and unread messages (ids, unread, replyCount). The best starting point for any question about school.",
|
|
76
|
+
inputSchema: {
|
|
77
|
+
student: studentArg,
|
|
78
|
+
days: z.number().int().min(1).max(60).optional().describe("How many days back to include news and messages (default 7)."),
|
|
79
|
+
since: z
|
|
80
|
+
.string()
|
|
81
|
+
.optional()
|
|
82
|
+
.describe("Only what is new from this day on (YYYY-MM-DD or 'yesterday'): bulletins, messages, homework, lesson notes. For daily runs."),
|
|
83
|
+
},
|
|
84
|
+
annotations: { title: "School summary", ...READ_ONLY },
|
|
85
|
+
}, async ({ student, days, since }) => ctx.withAccess(async (a) => json(await a.summary({ student, days, since }))));
|
|
86
|
+
server.registerTool("wilma_schedule", {
|
|
87
|
+
title: "Lesson schedule",
|
|
88
|
+
description: "Lessons for today, the next school day, this week, a specific date, or the next occurrence of a weekday.",
|
|
89
|
+
inputSchema: {
|
|
90
|
+
student: studentArg,
|
|
91
|
+
when: z.enum(["today", "tomorrow", "week", "next-week"]).optional().describe("Default: week. 'tomorrow' means the next school day."),
|
|
92
|
+
date: z.string().optional().describe("A specific date, YYYY-MM-DD. Overrides 'when'."),
|
|
93
|
+
weekday: z
|
|
94
|
+
.string()
|
|
95
|
+
.optional()
|
|
96
|
+
.describe("Next occurrence of a weekday: mon..sun (Finnish ma..su also work). Overrides 'when'."),
|
|
97
|
+
},
|
|
98
|
+
annotations: { title: "Lesson schedule", ...READ_ONLY },
|
|
99
|
+
}, async ({ student, when, date, weekday }) => ctx.withAccess(async (a) => json(await a.schedule({ student, when, date, weekday }))));
|
|
100
|
+
server.registerTool("wilma_homework", {
|
|
101
|
+
title: "Homework",
|
|
102
|
+
description: "Recent homework entries by lesson, newest first.",
|
|
103
|
+
inputSchema: { student: studentArg, limit: z.number().int().min(1).max(100).optional() },
|
|
104
|
+
annotations: { title: "Homework", ...READ_ONLY },
|
|
105
|
+
}, async ({ student, limit }) => ctx.withAccess(async (a) => json(await a.homework({ student, limit }))));
|
|
106
|
+
server.registerTool("wilma_upcoming_exams", {
|
|
107
|
+
title: "Upcoming exams",
|
|
108
|
+
description: "Upcoming exams with subject, date and topic (what to study).",
|
|
109
|
+
inputSchema: { student: studentArg, limit: z.number().int().min(1).max(100).optional() },
|
|
110
|
+
annotations: { title: "Upcoming exams", ...READ_ONLY },
|
|
111
|
+
}, async ({ student, limit }) => ctx.withAccess(async (a) => json(await a.upcomingExams({ student, limit }))));
|
|
112
|
+
server.registerTool("wilma_grades", {
|
|
113
|
+
title: "Exam grades",
|
|
114
|
+
description: "Recent exam grades.",
|
|
115
|
+
inputSchema: { student: studentArg, limit: z.number().int().min(1).max(100).optional() },
|
|
116
|
+
annotations: { title: "Exam grades", ...READ_ONLY },
|
|
117
|
+
}, async ({ student, limit }) => ctx.withAccess(async (a) => json(await a.grades({ student, limit }))));
|
|
118
|
+
server.registerTool("wilma_lesson_notes", {
|
|
119
|
+
title: "Lesson notes and absences",
|
|
120
|
+
description: "Lesson notes (merkinnät) teachers logged: absences, lateness, and feedback such as praise or missing books or homework, with the teacher's own words when given. One day (default today) or the last N days.",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
student: studentArg,
|
|
123
|
+
date: z.string().optional().describe("YYYY-MM-DD, default today."),
|
|
124
|
+
days: z.number().int().min(1).max(365).optional().describe("The last N days up to today, instead of one date."),
|
|
125
|
+
},
|
|
126
|
+
annotations: { title: "Lesson notes and absences", ...READ_ONLY },
|
|
127
|
+
}, async ({ student, date, days }) => ctx.withAccess(async (a) => json(await a.lessonNotes({ student, date, days }))));
|
|
128
|
+
server.registerTool("wilma_lesson_notes_summary", {
|
|
129
|
+
title: "Absences and feedback summary",
|
|
130
|
+
description: "Lesson notes counted by kind (e.g. absences for health reasons, unexplained absences, lateness, praise, missing study materials): this school year by default, or from a date.",
|
|
131
|
+
inputSchema: {
|
|
132
|
+
student: studentArg,
|
|
133
|
+
from: z.string().optional().describe("YYYY-MM-DD. Default: the start of the school year."),
|
|
134
|
+
to: z.string().optional().describe("YYYY-MM-DD. Default: today."),
|
|
135
|
+
},
|
|
136
|
+
annotations: { title: "Absences and feedback summary", ...READ_ONLY },
|
|
137
|
+
}, async ({ student, from, to }) => ctx.withAccess(async (a) => json(await a.lessonNotesSummary({ student, from, to }))));
|
|
138
|
+
server.registerTool("wilma_gradebook", {
|
|
139
|
+
title: "Gradebook",
|
|
140
|
+
description: "Completed courses and grades (Suoritukset) by subject, including term and school-year (report card) grades, with completion dates.",
|
|
141
|
+
inputSchema: { student: studentArg },
|
|
142
|
+
annotations: { title: "Gradebook", ...READ_ONLY },
|
|
143
|
+
}, async ({ student }) => ctx.withAccess(async (a) => json(await a.gradebook({ student }))));
|
|
144
|
+
server.registerTool("wilma_list_printouts", {
|
|
145
|
+
title: "List printouts",
|
|
146
|
+
description: "PDF documents the school offers (Tulosteet), such as report cards or absence reports. Fetch one with wilma_get_printout.",
|
|
147
|
+
inputSchema: { student: studentArg },
|
|
148
|
+
annotations: { title: "List printouts", ...READ_ONLY },
|
|
149
|
+
}, async ({ student }) => ctx.withAccess(async (a) => json(await a.printouts({ student }))));
|
|
150
|
+
server.registerTool("wilma_get_printout", {
|
|
151
|
+
title: "Get printout",
|
|
152
|
+
description: "Fetch one printout PDF so you can read it." +
|
|
153
|
+
(ctx.saveAttachment ? " Set save=true to also save it to the user's Downloads folder." : ""),
|
|
154
|
+
inputSchema: {
|
|
155
|
+
id: z.string().describe("Printout id from wilma_list_printouts."),
|
|
156
|
+
student: studentArg.describe("The student the printout was listed under."),
|
|
157
|
+
...(ctx.saveAttachment ? { save: z.boolean().optional().describe("Also save the file to ~/Downloads/WilmAI.") } : {}),
|
|
158
|
+
},
|
|
159
|
+
annotations: {
|
|
160
|
+
title: "Get printout",
|
|
161
|
+
readOnlyHint: !ctx.saveAttachment,
|
|
162
|
+
destructiveHint: false,
|
|
163
|
+
idempotentHint: !ctx.saveAttachment,
|
|
164
|
+
openWorldHint: false,
|
|
165
|
+
},
|
|
166
|
+
}, async (args) => ctx.withAccess(async (a) => {
|
|
167
|
+
const fetched = await a.printout({ id: args.id, student: args.student });
|
|
168
|
+
const savedPath = args.save && ctx.saveAttachment && fetched.status === "fetched" ? await ctx.saveAttachment(fetched) : undefined;
|
|
169
|
+
return attachmentResult(fetched, savedPath, ctx.inlineAttachmentLimit);
|
|
170
|
+
}));
|
|
171
|
+
server.registerTool("wilma_list_messages", {
|
|
172
|
+
title: "List messages",
|
|
173
|
+
description: "Wilma messages, newest first (subject, sender, date, id). Use wilma_read_message for the full text.",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
student: studentArg,
|
|
176
|
+
folder: z.enum(["inbox", "archive", "outbox", "drafts", "appointments"]).optional().describe("Default: inbox."),
|
|
177
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
178
|
+
},
|
|
179
|
+
annotations: { title: "List messages", ...READ_ONLY },
|
|
180
|
+
}, async ({ student, folder, limit }) => ctx.withAccess(async (a) => json(await a.messages({ student, folder, limit }))));
|
|
181
|
+
server.registerTool("wilma_read_message", {
|
|
182
|
+
title: "Read message",
|
|
183
|
+
description: "Full text of one Wilma message, including thread replies.",
|
|
184
|
+
inputSchema: {
|
|
185
|
+
id: z.number().int().positive().describe("Message id from a list."),
|
|
186
|
+
student: studentArg.describe("The student the message was listed under (required when several Wilmas are connected)."),
|
|
187
|
+
},
|
|
188
|
+
annotations: { title: "Read message", ...READ_ONLY },
|
|
189
|
+
}, async ({ id, student }) => ctx.withAccess(async (a) => json(await a.message({ id, student }))));
|
|
190
|
+
server.registerTool("wilma_list_news", {
|
|
191
|
+
title: "List school news",
|
|
192
|
+
description: "School bulletins (tiedotteet): the newest dated ones, then pinned ones (pinned: true, e.g. the school-year bulletin), and with include_older also older bulletins (archived: true). Use wilma_read_news for the full text and attachments.",
|
|
193
|
+
inputSchema: {
|
|
194
|
+
student: studentArg,
|
|
195
|
+
limit: z.number().int().min(1).max(100).optional().describe("How many dated bulletins (default 20). Pinned ones are always included."),
|
|
196
|
+
include_older: z.boolean().optional().describe("Also list older bulletins (titles only until read)."),
|
|
197
|
+
},
|
|
198
|
+
annotations: { title: "List school news", ...READ_ONLY },
|
|
199
|
+
}, async ({ student, limit, include_older }) => ctx.withAccess(async (a) => json(await a.news({ student, limit, includeOlder: include_older }))));
|
|
200
|
+
server.registerTool("wilma_read_news", {
|
|
201
|
+
title: "Read news bulletin",
|
|
202
|
+
description: "Full text of one bulletin plus its linked resources (attachments and links). Fetch a resource with wilma_get_news_attachment.",
|
|
203
|
+
inputSchema: {
|
|
204
|
+
id: z.number().int().positive().describe("News id from a list."),
|
|
205
|
+
student: studentArg.describe("The student the bulletin was listed under (required when several Wilmas are connected)."),
|
|
206
|
+
},
|
|
207
|
+
annotations: { title: "Read news bulletin", ...READ_ONLY },
|
|
208
|
+
}, async ({ id, student }) => ctx.withAccess(async (a) => json(await a.newsItem({ id, student }))));
|
|
209
|
+
server.registerTool("wilma_get_news_attachment", {
|
|
210
|
+
title: "Get bulletin attachment",
|
|
211
|
+
description: "Fetch a file linked from a bulletin (e.g. a PDF letter or menu) so you can read it. Returns the file content when it is a file, or 'not_a_file' with the URL when the link is a web page." +
|
|
212
|
+
(ctx.saveAttachment ? " Set save=true to also save it to the user's Downloads folder." : ""),
|
|
213
|
+
inputSchema: {
|
|
214
|
+
news_id: z.number().int().positive(),
|
|
215
|
+
resource_id: z.string().describe("Resource id from wilma_read_news, e.g. resource-1 (or just 1)."),
|
|
216
|
+
student: studentArg,
|
|
217
|
+
...(ctx.saveAttachment ? { save: z.boolean().optional().describe("Also save the file to ~/Downloads/WilmAI.") } : {}),
|
|
218
|
+
},
|
|
219
|
+
annotations: {
|
|
220
|
+
title: "Get bulletin attachment",
|
|
221
|
+
readOnlyHint: !ctx.saveAttachment,
|
|
222
|
+
destructiveHint: false,
|
|
223
|
+
idempotentHint: !ctx.saveAttachment,
|
|
224
|
+
openWorldHint: true,
|
|
225
|
+
},
|
|
226
|
+
}, async (args) => ctx.withAccess(async (a) => {
|
|
227
|
+
const fetched = await a.newsAttachment({
|
|
228
|
+
newsId: args.news_id,
|
|
229
|
+
resourceId: normalizeResourceId(args.resource_id) ?? args.resource_id,
|
|
230
|
+
student: args.student,
|
|
231
|
+
});
|
|
232
|
+
const savedPath = args.save && ctx.saveAttachment && fetched.status === "fetched" ? await ctx.saveAttachment(fetched) : undefined;
|
|
233
|
+
return attachmentResult(fetched, savedPath, ctx.inlineAttachmentLimit);
|
|
234
|
+
}));
|
|
235
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runMcpServer(version: string): Promise<void>;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { AuthenticationError } from "@wilm-ai/wilma-client";
|
|
7
|
+
import { useSessionStore } from "./agent-data.js";
|
|
8
|
+
import { fileSessionStore } from "./session-store.js";
|
|
9
|
+
import { createAccess } from "./access.js";
|
|
10
|
+
import { loadConfig } from "./config.js";
|
|
11
|
+
import { ENV_VARS, isMfaFailure, resolveAccounts } from "./credentials.js";
|
|
12
|
+
import { createUniqueDownloadFile } from "./downloads.js";
|
|
13
|
+
import { openBrowser, startLoginServer } from "./login-server.js";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { INSTRUCTIONS, READ_ONLY, json, registerWilmaTools, textResult } from "./mcp-tools.js";
|
|
16
|
+
import { searchTenants } from "./tenant-search.js";
|
|
17
|
+
const SECRET_SETTINGS_HINT = [
|
|
18
|
+
"If the user is not at this computer (for example, this agent runs on a cloud computer), the link won't work for them. Instead:",
|
|
19
|
+
" 1. Ask which city their children's school is in (or the school's name), call wilma_find_school, and let them pick their Wilma from the results.",
|
|
20
|
+
` 2. Ask them to add their Wilma login to this agent's secret or environment settings: ${ENV_VARS.username}=<username>, ${ENV_VARS.password}=<password>`,
|
|
21
|
+
` (${ENV_VARS.totpSecret}=<authenticator setup key> if the account uses two-step verification), and set ${ENV_VARS.tenant} to the Wilma address they picked.`,
|
|
22
|
+
"Never ask the user to type their Wilma password into the chat.",
|
|
23
|
+
].join("\n");
|
|
24
|
+
let loginServerPromise = null;
|
|
25
|
+
/** One login page at a time. Parallel tool calls share it; an explicit wilma_login starts a fresh one. */
|
|
26
|
+
async function ensureLoginServer(fresh) {
|
|
27
|
+
if (fresh && loginServerPromise) {
|
|
28
|
+
(await loginServerPromise).close();
|
|
29
|
+
loginServerPromise = null;
|
|
30
|
+
}
|
|
31
|
+
if (loginServerPromise)
|
|
32
|
+
return { server: await loginServerPromise, created: false };
|
|
33
|
+
const pending = startLoginServer().then((server) => {
|
|
34
|
+
server.result.finally(() => {
|
|
35
|
+
if (loginServerPromise === pending)
|
|
36
|
+
loginServerPromise = null;
|
|
37
|
+
});
|
|
38
|
+
return server;
|
|
39
|
+
});
|
|
40
|
+
loginServerPromise = pending;
|
|
41
|
+
return { server: await pending, created: true };
|
|
42
|
+
}
|
|
43
|
+
async function startBrowserLogin(requested = false) {
|
|
44
|
+
const { server, created } = await ensureLoginServer(requested);
|
|
45
|
+
const opened = created ? openBrowser(server.url) : "skipped";
|
|
46
|
+
if (opened === "headless") {
|
|
47
|
+
server.close();
|
|
48
|
+
return textResult(["Not logged in to Wilma, and this computer has no browser.", SECRET_SETTINGS_HINT].join("\n"), true);
|
|
49
|
+
}
|
|
50
|
+
const lead = requested ? "" : "The user is not logged in to Wilma yet. ";
|
|
51
|
+
return textResult([
|
|
52
|
+
opened === "opened"
|
|
53
|
+
? `${lead}A login page has opened in the user's browser:`
|
|
54
|
+
: created
|
|
55
|
+
? `${lead}Ask the user to open this login page in a browser on this computer:`
|
|
56
|
+
: `${lead}A login page is already open (if the user can't find it, they can open this link on this computer):`,
|
|
57
|
+
server.url,
|
|
58
|
+
"They pick their school's Wilma and log in there; the password never goes through the chat. Then try again.",
|
|
59
|
+
"",
|
|
60
|
+
SECRET_SETTINGS_HINT,
|
|
61
|
+
].join("\n"), !requested);
|
|
62
|
+
}
|
|
63
|
+
async function withAccess(run) {
|
|
64
|
+
const config = await loadConfig();
|
|
65
|
+
let accounts;
|
|
66
|
+
try {
|
|
67
|
+
accounts = await resolveAccounts(config);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return textResult(err instanceof Error ? err.message : String(err), true);
|
|
71
|
+
}
|
|
72
|
+
if (!accounts.length)
|
|
73
|
+
return startBrowserLogin();
|
|
74
|
+
const access = createAccess(accounts);
|
|
75
|
+
try {
|
|
76
|
+
return await run(access);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (isMfaFailure(err)) {
|
|
80
|
+
return textResult("Wilma rejected the two-step verification code; the saved authenticator setup key may be wrong. Call wilma_login to log in again.", true);
|
|
81
|
+
}
|
|
82
|
+
if (err instanceof AuthenticationError) {
|
|
83
|
+
return textResult("Wilma rejected the saved login (the password may have changed). Call wilma_login to log in again.", true);
|
|
84
|
+
}
|
|
85
|
+
return textResult(err instanceof Error ? err.message : String(err), true);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function createWilmaMcpServer(version) {
|
|
89
|
+
const server = new McpServer({ name: "wilma", title: "WilmAI", version }, { instructions: INSTRUCTIONS });
|
|
90
|
+
registerWilmaTools(server, {
|
|
91
|
+
withAccess,
|
|
92
|
+
saveAttachment: async (fetched) => {
|
|
93
|
+
const directory = resolve(homedir(), "Downloads", "WilmAI");
|
|
94
|
+
await mkdir(directory, { recursive: true });
|
|
95
|
+
const { path, handle } = await createUniqueDownloadFile(directory, fetched.fileName);
|
|
96
|
+
await handle.close();
|
|
97
|
+
await writeFile(path, fetched.data);
|
|
98
|
+
return path;
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
server.registerTool("wilma_account", {
|
|
102
|
+
title: "Wilma account status",
|
|
103
|
+
description: "Which Wilma logins are connected and which children each one has. Families with children on different Wilmas can connect several (wilma_login adds another).",
|
|
104
|
+
inputSchema: {},
|
|
105
|
+
annotations: { title: "Wilma account status", ...READ_ONLY },
|
|
106
|
+
}, async () => {
|
|
107
|
+
const config = await loadConfig();
|
|
108
|
+
const accounts = await resolveAccounts(config);
|
|
109
|
+
if (!accounts.length)
|
|
110
|
+
return json({ connected: false, hint: "Call wilma_login to connect." });
|
|
111
|
+
return withAccess(async (a) => {
|
|
112
|
+
const students = await a.students();
|
|
113
|
+
return json({
|
|
114
|
+
connected: true,
|
|
115
|
+
source: accounts[0].source,
|
|
116
|
+
wilmas: accounts.map((account, index) => ({
|
|
117
|
+
wilma: account.stored?.tenantName ?? account.profile.baseUrl,
|
|
118
|
+
username: account.profile.username,
|
|
119
|
+
children: students.filter((s) => s.account === index && s.name).map((s) => ({ name: s.name, studentNumber: s.studentNumber })),
|
|
120
|
+
})),
|
|
121
|
+
...(a.problems.length ? { problems: a.problems } : {}),
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
server.registerTool("wilma_find_school", {
|
|
126
|
+
title: "Find a school's Wilma",
|
|
127
|
+
description: "Search Finland's Wilma addresses by city or by school or organisation name. Most city schools share their city's Wilma (big cities don't list each school), while many colleges, private schools and small municipalities' schools have their own. Use it to help the user pick their Wilma, e.g. when setting WILMA_TENANT; several can serve one city, so ask which is theirs.",
|
|
128
|
+
inputSchema: { query: z.string().min(1).describe("City, municipality or school name, e.g. Tampere or Kalevan lukio.") },
|
|
129
|
+
annotations: { title: "Find a school's Wilma", ...READ_ONLY },
|
|
130
|
+
}, async ({ query }) => {
|
|
131
|
+
const found = await searchTenants(query, 15);
|
|
132
|
+
return json({
|
|
133
|
+
wilmas: found.map((t) => ({ url: t.url, name: t.name })),
|
|
134
|
+
...(found.length ? {} : { hint: `No Wilma found for "${query}". Many schools use their city's Wilma: try the city or municipality the school is in.` }),
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
server.registerTool("wilma_login", {
|
|
138
|
+
title: "Log in to Wilma",
|
|
139
|
+
description: "Open a login page in the user's browser to connect a Wilma account, or add another one (for a child at a school with a different Wilma). The password is entered on that page, never in the chat.",
|
|
140
|
+
inputSchema: {},
|
|
141
|
+
annotations: { title: "Log in to Wilma", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
142
|
+
}, async () => startBrowserLogin(true));
|
|
143
|
+
return server;
|
|
144
|
+
}
|
|
145
|
+
export async function runMcpServer(version) {
|
|
146
|
+
// stdout carries JSON-RPC; route any stray logging to stderr.
|
|
147
|
+
console.log = (...args) => console.error(...args);
|
|
148
|
+
console.info = (...args) => console.error(...args);
|
|
149
|
+
// Share saved sessions with the CLI: no login per process, no extra codes.
|
|
150
|
+
useSessionStore(fileSessionStore);
|
|
151
|
+
const server = createWilmaMcpServer(version);
|
|
152
|
+
await server.connect(new StdioServerTransport());
|
|
153
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function toAgentJson(value: unknown, pretty?: boolean): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { finnishIsoString } from "@wilm-ai/wilma-client";
|
|
2
|
+
/*
|
|
3
|
+
* JSON as agents get it, from the CLI and the MCP tools alike:
|
|
4
|
+
* - times in Finnish time with their offset ("2026-10-02T13:37:00+03:00"), so
|
|
5
|
+
* nobody reads UTC as the time on the school's clock;
|
|
6
|
+
* - no bookkeeping fields that cost tokens and say nothing (when the client
|
|
7
|
+
* fetched an item, Wilma's internal type classes and sender ids);
|
|
8
|
+
* - compact unless asked for pretty output.
|
|
9
|
+
*/
|
|
10
|
+
const NOISE = new Set(["fetchedAt", "typeClass", "sendersJson", "senderId", "senderType"]);
|
|
11
|
+
export function toAgentJson(value, pretty = false) {
|
|
12
|
+
return JSON.stringify(value, function (key, converted) {
|
|
13
|
+
if (NOISE.has(key))
|
|
14
|
+
return undefined;
|
|
15
|
+
const raw = this[key];
|
|
16
|
+
// Wilma's numeric message status; `unread` says the same in words.
|
|
17
|
+
if (key === "status" && typeof raw === "number")
|
|
18
|
+
return undefined;
|
|
19
|
+
if (raw instanceof Date) {
|
|
20
|
+
const time = raw.getTime();
|
|
21
|
+
// Unknown timestamps are kept as the epoch internally.
|
|
22
|
+
return Number.isNaN(time) || time <= 0 ? null : finnishIsoString(raw);
|
|
23
|
+
}
|
|
24
|
+
return converted;
|
|
25
|
+
}, pretty ? 2 : undefined);
|
|
26
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface SessionStore {
|
|
2
|
+
load(key: string): Promise<string | null>;
|
|
3
|
+
save(key: string, state: string): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
export declare const fileSessionStore: SessionStore;
|
|
6
|
+
/** Forget every saved session (after removing logins or clearing the config). */
|
|
7
|
+
export declare function clearSessions(): Promise<void>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { getConfigPath } from "./config.js";
|
|
5
|
+
/*
|
|
6
|
+
* Wilma sessions saved between commands. Wilma allows one live session per
|
|
7
|
+
* account and every login cancels the previous one — which also logs the
|
|
8
|
+
* parent out of Wilma in their own browser, and with two-step verification
|
|
9
|
+
* needs a fresh code. So commands (and the local MCP server) continue the last
|
|
10
|
+
* session instead of logging in each time, and log in again only when Wilma
|
|
11
|
+
* has ended it.
|
|
12
|
+
*
|
|
13
|
+
* A saved session gives the same access as the saved password, so it lives
|
|
14
|
+
* next to it with the same protection: a file only this user can read. Set
|
|
15
|
+
* WILMAI_NO_SESSION_CACHE=1 to log in on every command instead.
|
|
16
|
+
*/
|
|
17
|
+
/** A session older than this is not tried (Wilma ends idle sessions anyway). */
|
|
18
|
+
const SESSION_TTL_MS = 6 * 60 * 60 * 1000;
|
|
19
|
+
function sessionsPath() {
|
|
20
|
+
return resolve(dirname(getConfigPath()), "wilmai-sessions.json");
|
|
21
|
+
}
|
|
22
|
+
async function readAll() {
|
|
23
|
+
try {
|
|
24
|
+
const data = JSON.parse(await readFile(sessionsPath(), "utf8"));
|
|
25
|
+
return data && typeof data === "object" ? data : {};
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export const fileSessionStore = {
|
|
32
|
+
async load(key) {
|
|
33
|
+
if (process.env.WILMAI_NO_SESSION_CACHE === "1")
|
|
34
|
+
return null;
|
|
35
|
+
const entry = (await readAll())[key];
|
|
36
|
+
if (!entry || typeof entry.state !== "string" || Date.now() - entry.savedAt > SESSION_TTL_MS)
|
|
37
|
+
return null;
|
|
38
|
+
return entry.state;
|
|
39
|
+
},
|
|
40
|
+
async save(key, state) {
|
|
41
|
+
if (process.env.WILMAI_NO_SESSION_CACHE === "1")
|
|
42
|
+
return;
|
|
43
|
+
const all = await readAll();
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
for (const [k, entry] of Object.entries(all)) {
|
|
46
|
+
if (now - entry.savedAt > SESSION_TTL_MS)
|
|
47
|
+
delete all[k];
|
|
48
|
+
}
|
|
49
|
+
all[key] = { state, savedAt: now };
|
|
50
|
+
const path = sessionsPath();
|
|
51
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
52
|
+
// Temp file + rename: a parallel command never reads half a file.
|
|
53
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
54
|
+
await writeFile(tmp, JSON.stringify(all), { encoding: "utf-8", mode: 0o600 });
|
|
55
|
+
await rename(tmp, path);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
/** Forget every saved session (after removing logins or clearing the config). */
|
|
59
|
+
export async function clearSessions() {
|
|
60
|
+
await rm(sessionsPath(), { force: true });
|
|
61
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type TenantInfo } from "@wilm-ai/wilma-client";
|
|
2
|
+
/** Rank a tenant list against a query (pure; for callers that load the list themselves). */
|
|
3
|
+
export declare function rankTenants(tenants: TenantInfo[], query: string, limit?: number): TenantInfo[];
|
|
4
|
+
export declare function searchTenants(query: string, limit?: number): Promise<TenantInfo[]>;
|
|
5
|
+
/** Canonical form of a Wilma address: lowercase scheme and host, no trailing slash. */
|
|
6
|
+
export declare function normalizeTenantUrl(value: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a tenant from a URL or a school/city name for non-interactive use.
|
|
9
|
+
* Throws with the candidate list when the name is ambiguous.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveTenant(query: string): Promise<TenantInfo>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { listTenants } from "@wilm-ai/wilma-client";
|
|
2
|
+
function municipalityNames(tenant) {
|
|
3
|
+
const list = (Array.isArray(tenant.municipalities) ? tenant.municipalities : []);
|
|
4
|
+
const names = [];
|
|
5
|
+
for (const m of list) {
|
|
6
|
+
if (!m)
|
|
7
|
+
continue;
|
|
8
|
+
for (const value of [m.nameFi, m.nameSv, m.name_fi, m.name_sv]) {
|
|
9
|
+
if (value)
|
|
10
|
+
names.push(value);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return names;
|
|
14
|
+
}
|
|
15
|
+
function fuzzyIncludes(target, needle) {
|
|
16
|
+
const hay = (target ?? "").toLowerCase();
|
|
17
|
+
const search = (needle ?? "").toLowerCase();
|
|
18
|
+
if (!search)
|
|
19
|
+
return true;
|
|
20
|
+
if (hay.includes(search))
|
|
21
|
+
return true;
|
|
22
|
+
let i = 0;
|
|
23
|
+
for (const ch of hay) {
|
|
24
|
+
if (ch === search[i]) {
|
|
25
|
+
i += 1;
|
|
26
|
+
if (i >= search.length)
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
function tenantMatches(search, tenant) {
|
|
33
|
+
const needle = (search ?? "").toLowerCase();
|
|
34
|
+
if (fuzzyIncludes(tenant.name ?? "", needle))
|
|
35
|
+
return true;
|
|
36
|
+
if (fuzzyIncludes(tenant.url ?? "", needle))
|
|
37
|
+
return true;
|
|
38
|
+
return municipalityNames(tenant).some((name) => fuzzyIncludes(name, needle));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 0 = subdomain or name equals the query (a city's own Wilma),
|
|
42
|
+
* 1 = municipality equals it, 2 = prefix, 3 = substring, 4 = fuzzy, null = no match.
|
|
43
|
+
*/
|
|
44
|
+
function matchRank(search, tenant) {
|
|
45
|
+
const needle = search.trim().toLowerCase();
|
|
46
|
+
const host = (tenant.url ?? "").replace(/^https?:\/\//, "").toLowerCase();
|
|
47
|
+
const subdomain = host.split(".")[0] ?? "";
|
|
48
|
+
const name = (tenant.name ?? "").toLowerCase();
|
|
49
|
+
const municipalities = municipalityNames(tenant).map((m) => m.toLowerCase());
|
|
50
|
+
const fields = [name, subdomain, ...municipalities];
|
|
51
|
+
if (subdomain === needle || name === needle)
|
|
52
|
+
return 0;
|
|
53
|
+
if (municipalities.includes(needle))
|
|
54
|
+
return 1;
|
|
55
|
+
if (fields.some((f) => f.startsWith(needle)))
|
|
56
|
+
return 2;
|
|
57
|
+
if (fields.some((f) => f.includes(needle)) || host.includes(needle))
|
|
58
|
+
return 3;
|
|
59
|
+
if (tenantMatches(needle, tenant))
|
|
60
|
+
return 4;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
// Within a rank, list a municipality's own school Wilma before private schools,
|
|
64
|
+
// institutes and colleges — that's the one most parents are looking for.
|
|
65
|
+
function municipalBias(tenant) {
|
|
66
|
+
return /kaupun|kunta|kunnan|perusopetus|opetustoimi|sivistys/i.test(tenant.name ?? "") ? 0 : 1;
|
|
67
|
+
}
|
|
68
|
+
/** Rank a tenant list against a query (pure; for callers that load the list themselves). */
|
|
69
|
+
export function rankTenants(tenants, query, limit = 20) {
|
|
70
|
+
const needle = (query ?? "").trim();
|
|
71
|
+
if (!needle)
|
|
72
|
+
return tenants.slice(0, limit);
|
|
73
|
+
const ranked = tenants
|
|
74
|
+
.map((tenant) => ({ tenant, rank: matchRank(needle, tenant) }))
|
|
75
|
+
.filter((entry) => entry.rank !== null);
|
|
76
|
+
// Loose letter-by-letter matches ("espoo" inside "Joensuun ... opetus") are only
|
|
77
|
+
// a fallback for typos; never mix them into real results.
|
|
78
|
+
const hasRealMatch = ranked.some((entry) => entry.rank < 4);
|
|
79
|
+
return ranked
|
|
80
|
+
.filter((entry) => !hasRealMatch || entry.rank < 4)
|
|
81
|
+
.sort((a, b) => a.rank - b.rank ||
|
|
82
|
+
municipalBias(a.tenant) - municipalBias(b.tenant) ||
|
|
83
|
+
(a.tenant.name ?? "").localeCompare(b.tenant.name ?? "", "fi"))
|
|
84
|
+
.slice(0, limit)
|
|
85
|
+
.map((entry) => entry.tenant);
|
|
86
|
+
}
|
|
87
|
+
export async function searchTenants(query, limit = 20) {
|
|
88
|
+
return rankTenants(await listTenants(), query, limit);
|
|
89
|
+
}
|
|
90
|
+
/** Canonical form of a Wilma address: lowercase scheme and host, no trailing slash. */
|
|
91
|
+
export function normalizeTenantUrl(value) {
|
|
92
|
+
const trimmed = value.trim();
|
|
93
|
+
try {
|
|
94
|
+
const url = new URL(trimmed);
|
|
95
|
+
return `${url.origin}${url.pathname}`.replace(/\/$/, "");
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return trimmed.replace(/\/$/, "");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve a tenant from a URL or a school/city name for non-interactive use.
|
|
103
|
+
* Throws with the candidate list when the name is ambiguous.
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveTenant(query) {
|
|
106
|
+
const value = (query ?? "").trim();
|
|
107
|
+
if (!value)
|
|
108
|
+
throw new Error("Missing Wilma address (a URL like https://<school>.inschool.fi or a city name).");
|
|
109
|
+
if (/^https?:\/\//i.test(value)) {
|
|
110
|
+
const url = normalizeTenantUrl(value);
|
|
111
|
+
const tenants = await listTenants();
|
|
112
|
+
return tenants.find((t) => normalizeTenantUrl(t.url) === url) ?? { url, name: url, municipalities: [] };
|
|
113
|
+
}
|
|
114
|
+
const tenants = await listTenants();
|
|
115
|
+
const ranked = tenants
|
|
116
|
+
.map((tenant) => ({ tenant, rank: matchRank(value, tenant) }))
|
|
117
|
+
.filter((entry) => entry.rank !== null && entry.rank <= 3);
|
|
118
|
+
const best = Math.min(...ranked.map((entry) => entry.rank));
|
|
119
|
+
const top = ranked.filter((entry) => entry.rank === best).map((entry) => entry.tenant);
|
|
120
|
+
if (top.length === 1)
|
|
121
|
+
return top[0];
|
|
122
|
+
if (!top.length) {
|
|
123
|
+
throw new Error(`No Wilma found for "${value}". Many schools use their city's Wilma: try the city or municipality the school is in. Or use the full address, e.g. https://<school>.inschool.fi`);
|
|
124
|
+
}
|
|
125
|
+
const options = top.slice(0, 10).map((t) => ` ${t.url} ${t.name}`).join("\n");
|
|
126
|
+
throw new Error(`"${value}" matches several Wilmas. Use the address instead:\n${options}`);
|
|
127
|
+
}
|