@chrischall/pickuppatrol-mcp 0.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/.claude-plugin/marketplace.json +35 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/dist/auth.d.ts +72 -0
- package/dist/auth.js +147 -0
- package/dist/bundle.js +32156 -0
- package/dist/client.d.ts +57 -0
- package/dist/client.js +168 -0
- package/dist/dates.d.ts +28 -0
- package/dist/dates.js +51 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +24 -0
- package/dist/lib.d.ts +11 -0
- package/dist/lib.js +10 -0
- package/dist/plans.d.ts +57 -0
- package/dist/plans.js +165 -0
- package/dist/tools/_confirm.d.ts +13 -0
- package/dist/tools/_confirm.js +23 -0
- package/dist/tools/_errors.d.ts +14 -0
- package/dist/tools/_errors.js +26 -0
- package/dist/tools/account.d.ts +8 -0
- package/dist/tools/account.js +97 -0
- package/dist/tools/defaults.d.ts +9 -0
- package/dist/tools/defaults.js +169 -0
- package/dist/tools/plans.d.ts +15 -0
- package/dist/tools/plans.js +133 -0
- package/dist/tools/school.d.ts +10 -0
- package/dist/tools/school.js +77 -0
- package/dist/types.d.ts +142 -0
- package/dist/types.js +9 -0
- package/dist/version.d.ts +10 -0
- package/dist/version.js +10 -0
- package/package.json +71 -0
- package/server.json +20 -0
- package/skills/pickuppatrol-api/SKILL.md +107 -0
- package/skills/pickuppatrol-api/references/api.md +168 -0
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { PickUpPatrolAuth } from './auth.js';
|
|
2
|
+
import type { AuthOptions } from './auth.js';
|
|
3
|
+
import type { DefaultsReviewNeeded, PlanEdit, PlanUpdate, School, SchoolNotifyTimes, SessionResponse, Student, Transportation } from './types.js';
|
|
4
|
+
export interface ClientOptions extends AuthOptions {
|
|
5
|
+
auth?: PickUpPatrolAuth;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Thin typed client over PickUp Patrol's ServiceStack API.
|
|
9
|
+
*
|
|
10
|
+
* Not `createApiClient`: that helper only emits `Authorization: Bearer …`,
|
|
11
|
+
* while this deployment may authenticate by session cookie instead (see
|
|
12
|
+
* `src/auth.ts`). Requests therefore carry whichever of the two the login
|
|
13
|
+
* produced, and errors are unwrapped from ServiceStack's `ResponseStatus`
|
|
14
|
+
* envelope rather than a plain status line.
|
|
15
|
+
*/
|
|
16
|
+
export declare class PickUpPatrolClient {
|
|
17
|
+
private readonly auth;
|
|
18
|
+
private readonly fetchImpl;
|
|
19
|
+
constructor(opts?: ClientOptions);
|
|
20
|
+
/** Request a DTO by name. GET args go on the query string, others in the body. */
|
|
21
|
+
call<T>(method: 'GET' | 'POST' | 'PUT' | 'PATCH', dto: string, args?: Record<string, unknown>): Promise<T>;
|
|
22
|
+
private headers;
|
|
23
|
+
private parse;
|
|
24
|
+
getSession(): Promise<SessionResponse>;
|
|
25
|
+
getChildren(): Promise<Student[]>;
|
|
26
|
+
getStudent(studentId: number): Promise<Student>;
|
|
27
|
+
getDefaultPlansReviewNeeded(): Promise<DefaultsReviewNeeded[]>;
|
|
28
|
+
getParentPlans(startDate: string, endDate: string): Promise<unknown[]>;
|
|
29
|
+
getPlanEdit(planDate: string, studentId: number): Promise<PlanEdit>;
|
|
30
|
+
getTransportations(schoolId: number): Promise<Transportation[]>;
|
|
31
|
+
getCarNumbers(schoolId: number): Promise<string[]>;
|
|
32
|
+
getSchool(schoolId: number): Promise<School>;
|
|
33
|
+
getSchoolNotifyTimes(schoolId: number): Promise<SchoolNotifyTimes>;
|
|
34
|
+
getSchoolSettings(schoolId: number): Promise<Record<string, unknown>>;
|
|
35
|
+
getInvalidPlanDates(schoolId: number): Promise<string[]>;
|
|
36
|
+
getBoldedDates(startDate: string, endDate: string): Promise<string[]>;
|
|
37
|
+
/**
|
|
38
|
+
* The one-off (calendar) plan write. One array element per date, so a single
|
|
39
|
+
* call can set a run of dates. A `null` TransportationId reverts the date to
|
|
40
|
+
* the student's default plan.
|
|
41
|
+
*/
|
|
42
|
+
updatePlans(plans: PlanUpdate[]): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* The default-plans write. PickUp Patrol has no dedicated endpoint: the SPA
|
|
45
|
+
* PUTs the WHOLE student record back with `DefaultPlans` replaced, so callers
|
|
46
|
+
* must pass a `GetStudent` result with only that field changed.
|
|
47
|
+
*/
|
|
48
|
+
updateStudent(student: Student): Promise<void>;
|
|
49
|
+
setDefaultsReviewed(studentId: number, reviewed: boolean): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Module-level singleton shared by every tool module. Built here (not in
|
|
53
|
+
* `index.ts`) so the deferred-config-error pattern holds: the server boots and
|
|
54
|
+
* answers the host's install-time `tools/list` probe with no credentials set,
|
|
55
|
+
* and the configuration error only surfaces on the first tool call.
|
|
56
|
+
*/
|
|
57
|
+
export declare const client: PickUpPatrolClient;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { dirname, join } from 'path';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { loadDotenvSafely, McpToolError, buildQueryString } from '@chrischall/mcp-utils';
|
|
4
|
+
import { PickUpPatrolAuth, BASE_URL, BASE_PATH, describeResponseStatus } from './auth.js';
|
|
5
|
+
// Load .env for local dev; silently skip when dotenv is unavailable (the mcpb
|
|
6
|
+
// bundle externalises it). The try/catch guards a runtime where
|
|
7
|
+
// `import.meta.url` is undefined and `fileURLToPath` would throw at module
|
|
8
|
+
// init — such a runtime has no filesystem or .env to read anyway.
|
|
9
|
+
try {
|
|
10
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
await loadDotenvSafely({ path: join(dir, '..', '.env'), override: false });
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
/* non-Node runtime: no .env to load */
|
|
15
|
+
}
|
|
16
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
17
|
+
/**
|
|
18
|
+
* Thin typed client over PickUp Patrol's ServiceStack API.
|
|
19
|
+
*
|
|
20
|
+
* Not `createApiClient`: that helper only emits `Authorization: Bearer …`,
|
|
21
|
+
* while this deployment may authenticate by session cookie instead (see
|
|
22
|
+
* `src/auth.ts`). Requests therefore carry whichever of the two the login
|
|
23
|
+
* produced, and errors are unwrapped from ServiceStack's `ResponseStatus`
|
|
24
|
+
* envelope rather than a plain status line.
|
|
25
|
+
*/
|
|
26
|
+
export class PickUpPatrolClient {
|
|
27
|
+
auth;
|
|
28
|
+
fetchImpl;
|
|
29
|
+
constructor(opts = {}) {
|
|
30
|
+
this.auth = opts.auth ?? new PickUpPatrolAuth(opts);
|
|
31
|
+
this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
32
|
+
}
|
|
33
|
+
/** Request a DTO by name. GET args go on the query string, others in the body. */
|
|
34
|
+
async call(method, dto, args) {
|
|
35
|
+
const query = method === 'GET' && args ? buildQueryString(args) : '';
|
|
36
|
+
const url = `${BASE_URL}${BASE_PATH}/${dto}${query}`;
|
|
37
|
+
const res = await this.auth.withAuth((session) => this.fetchImpl(url, {
|
|
38
|
+
method,
|
|
39
|
+
headers: this.headers(session),
|
|
40
|
+
body: method === 'GET' || args === undefined ? undefined : JSON.stringify(args),
|
|
41
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
42
|
+
}));
|
|
43
|
+
return this.parse(res, dto);
|
|
44
|
+
}
|
|
45
|
+
headers(session) {
|
|
46
|
+
const headers = {
|
|
47
|
+
Accept: 'application/json',
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
};
|
|
50
|
+
if (session.bearerToken)
|
|
51
|
+
headers['Authorization'] = `Bearer ${session.bearerToken}`;
|
|
52
|
+
if (session.cookieHeader)
|
|
53
|
+
headers['Cookie'] = session.cookieHeader;
|
|
54
|
+
return headers;
|
|
55
|
+
}
|
|
56
|
+
async parse(res, dto) {
|
|
57
|
+
const text = await res.text();
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
// ServiceStack returns its error envelope as JSON, but a gateway or an
|
|
60
|
+
// auth redirect can return HTML — so the parse is best-effort and the
|
|
61
|
+
// status is always part of the message.
|
|
62
|
+
let detail = null;
|
|
63
|
+
try {
|
|
64
|
+
detail = describeResponseStatus(JSON.parse(text)
|
|
65
|
+
.ResponseStatus);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
detail = null;
|
|
69
|
+
}
|
|
70
|
+
throw new McpToolError(`PickUp Patrol ${dto} failed (HTTP ${res.status})${detail ? `: ${detail}` : ''}`, {
|
|
71
|
+
hint: res.status === 401 || res.status === 403
|
|
72
|
+
? 'The session was rejected. Check PICKUPPATROL_USERNAME and PICKUPPATROL_PASSWORD.'
|
|
73
|
+
: undefined,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// Several write DTOs (`UpdatePlans`, `SetDefaultsReviewed`) declare no
|
|
77
|
+
// response and answer with an empty body.
|
|
78
|
+
if (text.trim() === '')
|
|
79
|
+
return undefined;
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(text);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new McpToolError(`PickUp Patrol ${dto} returned a non-JSON response`, {
|
|
85
|
+
hint: 'This usually means the request was redirected to the sign-in page. Check the credentials and try again.',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ---- reads -------------------------------------------------------------
|
|
90
|
+
getSession() {
|
|
91
|
+
return this.call('GET', 'GetSession');
|
|
92
|
+
}
|
|
93
|
+
getChildren() {
|
|
94
|
+
return this.call('GET', 'GetChildren');
|
|
95
|
+
}
|
|
96
|
+
getStudent(studentId) {
|
|
97
|
+
return this.call('GET', 'GetStudent', { StudentId: studentId });
|
|
98
|
+
}
|
|
99
|
+
getDefaultPlansReviewNeeded() {
|
|
100
|
+
return this.call('GET', 'GetDefaultPlansReviewNeeded');
|
|
101
|
+
}
|
|
102
|
+
getParentPlans(startDate, endDate) {
|
|
103
|
+
return this.call('GET', 'GetParentPlans', {
|
|
104
|
+
StartDate: startDate,
|
|
105
|
+
EndDate: endDate,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
getPlanEdit(planDate, studentId) {
|
|
109
|
+
return this.call('GET', 'GetPlanEdit', {
|
|
110
|
+
PlanDate: planDate,
|
|
111
|
+
StudentId: studentId,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
getTransportations(schoolId) {
|
|
115
|
+
return this.call('GET', 'GetTransportations', { SchoolId: schoolId });
|
|
116
|
+
}
|
|
117
|
+
getCarNumbers(schoolId) {
|
|
118
|
+
return this.call('GET', 'GetCarNumbers', { SchoolId: schoolId });
|
|
119
|
+
}
|
|
120
|
+
getSchool(schoolId) {
|
|
121
|
+
return this.call('GET', 'GetSchool', { SchoolId: schoolId });
|
|
122
|
+
}
|
|
123
|
+
getSchoolNotifyTimes(schoolId) {
|
|
124
|
+
return this.call('GET', 'GetSchoolNotifyTimes', { SchoolId: schoolId });
|
|
125
|
+
}
|
|
126
|
+
getSchoolSettings(schoolId) {
|
|
127
|
+
return this.call('GET', 'GetSchoolSettings', { SchoolId: schoolId });
|
|
128
|
+
}
|
|
129
|
+
getInvalidPlanDates(schoolId) {
|
|
130
|
+
return this.call('GET', 'GetInvalidPlanDates', { SchoolId: schoolId });
|
|
131
|
+
}
|
|
132
|
+
getBoldedDates(startDate, endDate) {
|
|
133
|
+
return this.call('GET', 'GetBoldedDates', {
|
|
134
|
+
StartDate: startDate,
|
|
135
|
+
EndDate: endDate,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// ---- writes ------------------------------------------------------------
|
|
139
|
+
/**
|
|
140
|
+
* The one-off (calendar) plan write. One array element per date, so a single
|
|
141
|
+
* call can set a run of dates. A `null` TransportationId reverts the date to
|
|
142
|
+
* the student's default plan.
|
|
143
|
+
*/
|
|
144
|
+
updatePlans(plans) {
|
|
145
|
+
return this.call('PUT', 'UpdatePlans', { Plans: plans });
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The default-plans write. PickUp Patrol has no dedicated endpoint: the SPA
|
|
149
|
+
* PUTs the WHOLE student record back with `DefaultPlans` replaced, so callers
|
|
150
|
+
* must pass a `GetStudent` result with only that field changed.
|
|
151
|
+
*/
|
|
152
|
+
updateStudent(student) {
|
|
153
|
+
return this.call('PUT', 'Student', student);
|
|
154
|
+
}
|
|
155
|
+
setDefaultsReviewed(studentId, reviewed) {
|
|
156
|
+
return this.call('PUT', 'SetDefaultsReviewed', {
|
|
157
|
+
StudentId: studentId,
|
|
158
|
+
Reviewed: reviewed,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Module-level singleton shared by every tool module. Built here (not in
|
|
164
|
+
* `index.ts`) so the deferred-config-error pattern holds: the server boots and
|
|
165
|
+
* answers the host's install-time `tools/list` probe with no credentials set,
|
|
166
|
+
* and the configuration error only surfaces on the first tool call.
|
|
167
|
+
*/
|
|
168
|
+
export const client = new PickUpPatrolClient();
|
package/dist/dates.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weekday helpers for PickUp Patrol's `DayId`.
|
|
3
|
+
*
|
|
4
|
+
* `DayId` is 1-based with **Sunday = 1** — verified against a live record
|
|
5
|
+
* (`DayId: 2` carrying `WeekDayName: "Monday"`), and matching the SPA, which
|
|
6
|
+
* renders the label as `dayNamesMin[DayId - 1]`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const WEEKDAY_NAMES: readonly ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
9
|
+
export type WeekdayName = (typeof WEEKDAY_NAMES)[number];
|
|
10
|
+
/** `DayId` → weekday name, or `null` when the id is outside 1–7. */
|
|
11
|
+
export declare function dayIdToName(dayId: number): WeekdayName | null;
|
|
12
|
+
/**
|
|
13
|
+
* Weekday name → `DayId`. Case-insensitive; returns `null` for anything that
|
|
14
|
+
* is not a weekday, so a typo surfaces as a validation error rather than
|
|
15
|
+
* silently writing to Sunday.
|
|
16
|
+
*/
|
|
17
|
+
export declare function nameToDayId(name: string): number | null;
|
|
18
|
+
/** `YYYY-MM-DD` → `DayId`, treating the date as a plain calendar date (UTC). */
|
|
19
|
+
export declare function dateToDayId(isoDate: string): number | null;
|
|
20
|
+
/**
|
|
21
|
+
* `YYYY-MM-DD` → weekday name, or `null` when the string is not a real date.
|
|
22
|
+
*
|
|
23
|
+
* Exists so callers get the name in one expression: folding the two steps
|
|
24
|
+
* together at each call site leaves a `?? 0` fallback that is unreachable
|
|
25
|
+
* wherever the date has already been validated, and an unreachable branch is
|
|
26
|
+
* one nothing can prove the behaviour of.
|
|
27
|
+
*/
|
|
28
|
+
export declare function weekdayOf(isoDate: string): WeekdayName | null;
|
package/dist/dates.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weekday helpers for PickUp Patrol's `DayId`.
|
|
3
|
+
*
|
|
4
|
+
* `DayId` is 1-based with **Sunday = 1** — verified against a live record
|
|
5
|
+
* (`DayId: 2` carrying `WeekDayName: "Monday"`), and matching the SPA, which
|
|
6
|
+
* renders the label as `dayNamesMin[DayId - 1]`.
|
|
7
|
+
*/
|
|
8
|
+
export const WEEKDAY_NAMES = [
|
|
9
|
+
'Sunday',
|
|
10
|
+
'Monday',
|
|
11
|
+
'Tuesday',
|
|
12
|
+
'Wednesday',
|
|
13
|
+
'Thursday',
|
|
14
|
+
'Friday',
|
|
15
|
+
'Saturday',
|
|
16
|
+
];
|
|
17
|
+
/** `DayId` → weekday name, or `null` when the id is outside 1–7. */
|
|
18
|
+
export function dayIdToName(dayId) {
|
|
19
|
+
return WEEKDAY_NAMES[dayId - 1] ?? null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Weekday name → `DayId`. Case-insensitive; returns `null` for anything that
|
|
23
|
+
* is not a weekday, so a typo surfaces as a validation error rather than
|
|
24
|
+
* silently writing to Sunday.
|
|
25
|
+
*/
|
|
26
|
+
export function nameToDayId(name) {
|
|
27
|
+
const index = WEEKDAY_NAMES.findIndex((d) => d.toLowerCase() === name.trim().toLowerCase());
|
|
28
|
+
return index === -1 ? null : index + 1;
|
|
29
|
+
}
|
|
30
|
+
/** `YYYY-MM-DD` → `DayId`, treating the date as a plain calendar date (UTC). */
|
|
31
|
+
export function dateToDayId(isoDate) {
|
|
32
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
|
|
33
|
+
if (!match)
|
|
34
|
+
return null;
|
|
35
|
+
const ms = Date.parse(`${isoDate}T00:00:00Z`);
|
|
36
|
+
if (Number.isNaN(ms))
|
|
37
|
+
return null;
|
|
38
|
+
return new Date(ms).getUTCDay() + 1;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* `YYYY-MM-DD` → weekday name, or `null` when the string is not a real date.
|
|
42
|
+
*
|
|
43
|
+
* Exists so callers get the name in one expression: folding the two steps
|
|
44
|
+
* together at each call site leaves a `?? 0` fallback that is unreachable
|
|
45
|
+
* wherever the date has already been validated, and an unreachable branch is
|
|
46
|
+
* one nothing can prove the behaviour of.
|
|
47
|
+
*/
|
|
48
|
+
export function weekdayOf(isoDate) {
|
|
49
|
+
const dayId = dateToDayId(isoDate);
|
|
50
|
+
return dayId === null ? null : dayIdToName(dayId);
|
|
51
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runMcp } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from './client.js';
|
|
4
|
+
import { VERSION } from './version.js';
|
|
5
|
+
import { registerAccountTools } from './tools/account.js';
|
|
6
|
+
import { registerSchoolTools } from './tools/school.js';
|
|
7
|
+
import { registerPlanTools } from './tools/plans.js';
|
|
8
|
+
import { registerDefaultPlanTools } from './tools/defaults.js';
|
|
9
|
+
// The client is a module-level singleton built in ./client.js, not here, so
|
|
10
|
+
// the deferred-config-error pattern holds: the server boots and answers the
|
|
11
|
+
// host's install-time tools/list probe even with no credentials set, and the
|
|
12
|
+
// configuration error only surfaces on the first tool call.
|
|
13
|
+
await runMcp({
|
|
14
|
+
name: 'pickuppatrol-mcp',
|
|
15
|
+
version: VERSION,
|
|
16
|
+
deps: client,
|
|
17
|
+
banner: '[pickuppatrol-mcp] This project was developed and is maintained by AI. Use at your own discretion.',
|
|
18
|
+
tools: [
|
|
19
|
+
registerAccountTools,
|
|
20
|
+
registerSchoolTools,
|
|
21
|
+
registerPlanTools,
|
|
22
|
+
registerDefaultPlanTools,
|
|
23
|
+
],
|
|
24
|
+
});
|
package/dist/lib.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library surface. Importing `@chrischall/pickuppatrol-mcp` gives the typed
|
|
3
|
+
* client and the plan-building rules without starting an MCP server, so
|
|
4
|
+
* another tool can reuse the capture rather than re-deriving it.
|
|
5
|
+
*/
|
|
6
|
+
export { PickUpPatrolClient, type ClientOptions } from './client.js';
|
|
7
|
+
export { PickUpPatrolAuth, describeResponseStatus, collectCookieHeader, BASE_URL, BASE_PATH, type AuthOptions, type PupSession, type FetchLike, } from './auth.js';
|
|
8
|
+
export { buildPlanUpdates, applyDefaultPlans, clearDefaultPlans, assertTransportationAllowed, normalizeNote, normalizeEarlyDismissal, normalizeCarNumber, DEFAULT_PLAN_LABEL, type PlanInput, type DefaultPlanInput, } from './plans.js';
|
|
9
|
+
export { dayIdToName, nameToDayId, dateToDayId, WEEKDAY_NAMES, type WeekdayName } from './dates.js';
|
|
10
|
+
export { VERSION } from './version.js';
|
|
11
|
+
export type * from './types.js';
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library surface. Importing `@chrischall/pickuppatrol-mcp` gives the typed
|
|
3
|
+
* client and the plan-building rules without starting an MCP server, so
|
|
4
|
+
* another tool can reuse the capture rather than re-deriving it.
|
|
5
|
+
*/
|
|
6
|
+
export { PickUpPatrolClient } from './client.js';
|
|
7
|
+
export { PickUpPatrolAuth, describeResponseStatus, collectCookieHeader, BASE_URL, BASE_PATH, } from './auth.js';
|
|
8
|
+
export { buildPlanUpdates, applyDefaultPlans, clearDefaultPlans, assertTransportationAllowed, normalizeNote, normalizeEarlyDismissal, normalizeCarNumber, DEFAULT_PLAN_LABEL, } from './plans.js';
|
|
9
|
+
export { dayIdToName, nameToDayId, dateToDayId, WEEKDAY_NAMES } from './dates.js';
|
|
10
|
+
export { VERSION } from './version.js';
|
package/dist/plans.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { PlanUpdate, Student, Transportation } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Display text the SPA sends when a date is reverted to the student's default
|
|
4
|
+
* plan. `TransportationId: null` is what the server acts on; the name is
|
|
5
|
+
* cosmetic, but it is sent, so we send it too.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_PLAN_LABEL = "Default plan";
|
|
8
|
+
export interface PlanInput {
|
|
9
|
+
student: Student;
|
|
10
|
+
dates: string[];
|
|
11
|
+
/** `null` reverts each date to the student's weekly default. */
|
|
12
|
+
transportation: Transportation | null;
|
|
13
|
+
note?: string | undefined;
|
|
14
|
+
earlyDismissalTime?: string | undefined;
|
|
15
|
+
carNumber?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Turn a plan request into the exact `UpdatePlans` array the SPA would send,
|
|
19
|
+
* enforcing the same rules it enforces client-side. Every rejection is a
|
|
20
|
+
* `McpToolError` with a hint, because these are all things the caller can fix
|
|
21
|
+
* by reading `pup_list_transportations` first.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildPlanUpdates(input: PlanInput): PlanUpdate[];
|
|
24
|
+
/**
|
|
25
|
+
* A student may be restricted to a subset of the school's options. The SPA
|
|
26
|
+
* only ever offers those, so sending another one is a request the parent is
|
|
27
|
+
* not entitled to make.
|
|
28
|
+
*/
|
|
29
|
+
export declare function assertTransportationAllowed(student: Student, transportation: Transportation): void;
|
|
30
|
+
/** Enforce the option's note rule and normalise blank input to `null`. */
|
|
31
|
+
export declare function normalizeNote(transportation: Transportation, note: string | undefined): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Early-dismissal options require a time; every other option must NOT carry
|
|
34
|
+
* one — the SPA clears the field before sending, so a leftover time would be a
|
|
35
|
+
* value the real client never sends.
|
|
36
|
+
*/
|
|
37
|
+
export declare function normalizeEarlyDismissal(transportation: Transportation, time: string | undefined): string | undefined;
|
|
38
|
+
/** Car numbers are sent only for options that use them. */
|
|
39
|
+
export declare function normalizeCarNumber(transportation: Transportation, carNumber: string | undefined): string | undefined;
|
|
40
|
+
export interface DefaultPlanInput {
|
|
41
|
+
student: Student;
|
|
42
|
+
dayIds: number[];
|
|
43
|
+
transportation: Transportation;
|
|
44
|
+
note?: string | undefined;
|
|
45
|
+
earlyDismissalTime?: string | undefined;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build the `Student` record to PUT back when changing weekly defaults.
|
|
49
|
+
*
|
|
50
|
+
* PickUp Patrol has no default-plans endpoint: the SPA read-modify-writes the
|
|
51
|
+
* whole student, so this returns a copy of the record it was given with only
|
|
52
|
+
* `DefaultPlans` changed. Callers must pass a **freshly read** student, or the
|
|
53
|
+
* PUT will also roll back whatever else changed since.
|
|
54
|
+
*/
|
|
55
|
+
export declare function applyDefaultPlans(input: DefaultPlanInput): Student;
|
|
56
|
+
/** Clear every weekday default, the SPA's "start over" action. */
|
|
57
|
+
export declare function clearDefaultPlans(student: Student): Student;
|
package/dist/plans.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { McpToolError } from '@chrischall/mcp-utils';
|
|
2
|
+
import { dateToDayId } from './dates.js';
|
|
3
|
+
/**
|
|
4
|
+
* Display text the SPA sends when a date is reverted to the student's default
|
|
5
|
+
* plan. `TransportationId: null` is what the server acts on; the name is
|
|
6
|
+
* cosmetic, but it is sent, so we send it too.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_PLAN_LABEL = 'Default plan';
|
|
9
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
10
|
+
const TIME_OF_DAY = /^\d{2}:\d{2}(:\d{2})?$/;
|
|
11
|
+
/**
|
|
12
|
+
* Turn a plan request into the exact `UpdatePlans` array the SPA would send,
|
|
13
|
+
* enforcing the same rules it enforces client-side. Every rejection is a
|
|
14
|
+
* `McpToolError` with a hint, because these are all things the caller can fix
|
|
15
|
+
* by reading `pup_list_transportations` first.
|
|
16
|
+
*/
|
|
17
|
+
export function buildPlanUpdates(input) {
|
|
18
|
+
const { student, dates, transportation } = input;
|
|
19
|
+
if (dates.length === 0) {
|
|
20
|
+
throw new McpToolError('No dates given', { hint: 'Pass at least one YYYY-MM-DD date.' });
|
|
21
|
+
}
|
|
22
|
+
for (const date of dates) {
|
|
23
|
+
// Shape AND reality: `2026-13-45` matches the pattern but is not a date,
|
|
24
|
+
// and sending it would put an impossible plan in front of the school.
|
|
25
|
+
if (!ISO_DATE.test(date) || dateToDayId(date) === null) {
|
|
26
|
+
throw new McpToolError(`"${date}" is not a YYYY-MM-DD date`, {
|
|
27
|
+
hint: 'Plan dates are plain calendar dates, e.g. 2026-08-17.',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const duplicate = dates.find((date, i) => dates.indexOf(date) !== i);
|
|
32
|
+
if (duplicate !== undefined) {
|
|
33
|
+
throw new McpToolError(`Date ${duplicate} is listed more than once`, {
|
|
34
|
+
hint: 'Each date may appear only once per call.',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
// Revert-to-default: a null transportation, no note, nothing else.
|
|
38
|
+
if (transportation === null) {
|
|
39
|
+
return dates.map((date) => ({
|
|
40
|
+
StudentId: student.StudentId,
|
|
41
|
+
SchoolId: student.SchoolId,
|
|
42
|
+
PlanDate: date,
|
|
43
|
+
TransportationId: null,
|
|
44
|
+
TransportationName: DEFAULT_PLAN_LABEL,
|
|
45
|
+
Note: null,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
assertTransportationAllowed(student, transportation);
|
|
49
|
+
const note = normalizeNote(transportation, input.note);
|
|
50
|
+
const earlyDismissalTime = normalizeEarlyDismissal(transportation, input.earlyDismissalTime);
|
|
51
|
+
const carNumber = normalizeCarNumber(transportation, input.carNumber);
|
|
52
|
+
return dates.map((date) => ({
|
|
53
|
+
StudentId: student.StudentId,
|
|
54
|
+
SchoolId: student.SchoolId,
|
|
55
|
+
PlanDate: date,
|
|
56
|
+
TransportationId: transportation.TransportationId,
|
|
57
|
+
TransportationName: transportation.Name,
|
|
58
|
+
Note: note,
|
|
59
|
+
// Omit rather than send null: the SPA leaves both keys off when they do
|
|
60
|
+
// not apply, and an undocumented API is not the place to test whether an
|
|
61
|
+
// explicit null means "clear" or "invalid".
|
|
62
|
+
...(earlyDismissalTime !== undefined ? { EarlyDismissalTime: earlyDismissalTime } : {}),
|
|
63
|
+
...(carNumber !== undefined ? { CarNumber: carNumber } : {}),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A student may be restricted to a subset of the school's options. The SPA
|
|
68
|
+
* only ever offers those, so sending another one is a request the parent is
|
|
69
|
+
* not entitled to make.
|
|
70
|
+
*/
|
|
71
|
+
export function assertTransportationAllowed(student, transportation) {
|
|
72
|
+
if (student.AllowPlans === false) {
|
|
73
|
+
throw new McpToolError(`${student.FirstName ?? 'This student'} is not allowed to have plans changed`, { hint: 'The school has turned off parent plan changes for this student.' });
|
|
74
|
+
}
|
|
75
|
+
if (transportation.IsLimited !== true)
|
|
76
|
+
return;
|
|
77
|
+
const allowed = student.LimitedIds ?? [];
|
|
78
|
+
if (!allowed.includes(transportation.TransportationId)) {
|
|
79
|
+
throw new McpToolError(`"${transportation.Name}" is restricted and not available to this student`, { hint: 'Call pup_list_transportations and pick an option without isLimited, or one the school has granted this student.' });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Enforce the option's note rule and normalise blank input to `null`. */
|
|
83
|
+
export function normalizeNote(transportation, note) {
|
|
84
|
+
const trimmed = note?.trim() ?? '';
|
|
85
|
+
if (transportation.IsNoteRequired === true && trimmed === '') {
|
|
86
|
+
throw new McpToolError(`"${transportation.Name}" requires a note`, {
|
|
87
|
+
hint: transportation.NoteHint
|
|
88
|
+
? `The school describes it as: ${transportation.NoteHint}`
|
|
89
|
+
: 'Pass note with the detail the school expects, e.g. who is collecting the student.',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return trimmed === '' ? null : trimmed;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Early-dismissal options require a time; every other option must NOT carry
|
|
96
|
+
* one — the SPA clears the field before sending, so a leftover time would be a
|
|
97
|
+
* value the real client never sends.
|
|
98
|
+
*/
|
|
99
|
+
export function normalizeEarlyDismissal(transportation, time) {
|
|
100
|
+
if (transportation.IsEarlyDismissal !== true)
|
|
101
|
+
return undefined;
|
|
102
|
+
if (!time) {
|
|
103
|
+
throw new McpToolError(`"${transportation.Name}" is an early dismissal and needs a time`, {
|
|
104
|
+
hint: 'Pass early_dismissal_time as HH:MM (24-hour), within the school\'s dismissal window.',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (!TIME_OF_DAY.test(time)) {
|
|
108
|
+
throw new McpToolError(`"${time}" is not an HH:MM time`, {
|
|
109
|
+
hint: 'Use 24-hour HH:MM, e.g. 14:30.',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return time.length === 5 ? `${time}:00` : time;
|
|
113
|
+
}
|
|
114
|
+
/** Car numbers are sent only for options that use them. */
|
|
115
|
+
export function normalizeCarNumber(transportation, carNumber) {
|
|
116
|
+
if (transportation.UseCarNumbers !== true)
|
|
117
|
+
return undefined;
|
|
118
|
+
const trimmed = carNumber?.trim() ?? '';
|
|
119
|
+
return trimmed === '' ? undefined : trimmed;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Build the `Student` record to PUT back when changing weekly defaults.
|
|
123
|
+
*
|
|
124
|
+
* PickUp Patrol has no default-plans endpoint: the SPA read-modify-writes the
|
|
125
|
+
* whole student, so this returns a copy of the record it was given with only
|
|
126
|
+
* `DefaultPlans` changed. Callers must pass a **freshly read** student, or the
|
|
127
|
+
* PUT will also roll back whatever else changed since.
|
|
128
|
+
*/
|
|
129
|
+
export function applyDefaultPlans(input) {
|
|
130
|
+
const { student, dayIds, transportation } = input;
|
|
131
|
+
if (dayIds.length === 0) {
|
|
132
|
+
throw new McpToolError('No weekdays given', {
|
|
133
|
+
hint: 'Pass the weekdays to change, e.g. ["Monday","Tuesday"].',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
for (const dayId of dayIds) {
|
|
137
|
+
if (!Number.isInteger(dayId) || dayId < 1 || dayId > 7) {
|
|
138
|
+
throw new McpToolError(`${dayId} is not a weekday id (1 = Sunday … 7 = Saturday)`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
assertTransportationAllowed(student, transportation);
|
|
142
|
+
const note = normalizeNote(transportation, input.note);
|
|
143
|
+
const earlyDismissalTime = normalizeEarlyDismissal(transportation, input.earlyDismissalTime);
|
|
144
|
+
const plans = [...(student.DefaultPlans ?? [])].map((plan) => ({ ...plan }));
|
|
145
|
+
for (const dayId of new Set(dayIds)) {
|
|
146
|
+
const existing = plans.find((plan) => plan.DayId === dayId);
|
|
147
|
+
const fields = {
|
|
148
|
+
TransportationId: transportation.TransportationId,
|
|
149
|
+
TransportationName: transportation.Name,
|
|
150
|
+
Note: note,
|
|
151
|
+
EarlyDismissalTime: earlyDismissalTime,
|
|
152
|
+
};
|
|
153
|
+
if (existing) {
|
|
154
|
+
Object.assign(existing, fields);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
plans.push({ DayId: dayId, ...fields });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { ...student, DefaultPlans: plans };
|
|
161
|
+
}
|
|
162
|
+
/** Clear every weekday default, the SPA's "start over" action. */
|
|
163
|
+
export function clearDefaultPlans(student) {
|
|
164
|
+
return { ...student, DefaultPlans: [] };
|
|
165
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { schemaConfirm } from '@chrischall/mcp-utils';
|
|
3
|
+
export { schemaConfirm };
|
|
4
|
+
/**
|
|
5
|
+
* Confirm-gate for a mutating tool (the fleet convention). Without
|
|
6
|
+
* `confirm: true` the tool makes **no** network call and returns a dry-run
|
|
7
|
+
* preview of exactly what would be sent.
|
|
8
|
+
*
|
|
9
|
+
* The gate matters more here than in most of the fleet: these writes change
|
|
10
|
+
* how a child leaves school. A hallucinated call must not silently put a
|
|
11
|
+
* student on a different bus.
|
|
12
|
+
*/
|
|
13
|
+
export declare function previewUnlessConfirmed(confirm: boolean | undefined, action: string, method: string, dto: string, body?: unknown): CallToolResult | null;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { schemaConfirm, textResult } from '@chrischall/mcp-utils';
|
|
2
|
+
export { schemaConfirm };
|
|
3
|
+
/**
|
|
4
|
+
* Confirm-gate for a mutating tool (the fleet convention). Without
|
|
5
|
+
* `confirm: true` the tool makes **no** network call and returns a dry-run
|
|
6
|
+
* preview of exactly what would be sent.
|
|
7
|
+
*
|
|
8
|
+
* The gate matters more here than in most of the fleet: these writes change
|
|
9
|
+
* how a child leaves school. A hallucinated call must not silently put a
|
|
10
|
+
* student on a different bus.
|
|
11
|
+
*/
|
|
12
|
+
export function previewUnlessConfirmed(confirm, action, method, dto, body) {
|
|
13
|
+
if (confirm === true)
|
|
14
|
+
return null;
|
|
15
|
+
return textResult({
|
|
16
|
+
dryRun: true,
|
|
17
|
+
action,
|
|
18
|
+
method,
|
|
19
|
+
dto,
|
|
20
|
+
...(body !== undefined ? { willSend: body } : {}),
|
|
21
|
+
note: 'Re-run with confirm: true to execute.',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Surface an `McpToolError`'s `hint` to the caller.
|
|
4
|
+
*
|
|
5
|
+
* The MCP tool boundary turns a thrown error into a result carrying only
|
|
6
|
+
* `message`, so a `hint` — which is where the actionable half lives here ("the
|
|
7
|
+
* available options are …", "the school describes the note as …") — is
|
|
8
|
+
* otherwise dropped on the floor. Wrapping the handler folds it into the text
|
|
9
|
+
* the caller actually sees.
|
|
10
|
+
*
|
|
11
|
+
* Only `McpToolError` is handled: an unexpected error keeps propagating, so a
|
|
12
|
+
* genuine bug still reads as one instead of being flattened into advice.
|
|
13
|
+
*/
|
|
14
|
+
export declare function withHints<A>(handler: (args: A) => Promise<CallToolResult>): (args: A) => Promise<CallToolResult>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { McpToolError, errorResult } from '@chrischall/mcp-utils';
|
|
2
|
+
/**
|
|
3
|
+
* Surface an `McpToolError`'s `hint` to the caller.
|
|
4
|
+
*
|
|
5
|
+
* The MCP tool boundary turns a thrown error into a result carrying only
|
|
6
|
+
* `message`, so a `hint` — which is where the actionable half lives here ("the
|
|
7
|
+
* available options are …", "the school describes the note as …") — is
|
|
8
|
+
* otherwise dropped on the floor. Wrapping the handler folds it into the text
|
|
9
|
+
* the caller actually sees.
|
|
10
|
+
*
|
|
11
|
+
* Only `McpToolError` is handled: an unexpected error keeps propagating, so a
|
|
12
|
+
* genuine bug still reads as one instead of being flattened into advice.
|
|
13
|
+
*/
|
|
14
|
+
export function withHints(handler) {
|
|
15
|
+
return async (args) => {
|
|
16
|
+
try {
|
|
17
|
+
return await handler(args);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
if (err instanceof McpToolError && err.hint) {
|
|
21
|
+
return errorResult(`${err.message}\n\nHint: ${err.hint}`);
|
|
22
|
+
}
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|