@chrischall/pickuppatrol-mcp 1.0.1 → 1.0.2
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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/auth.d.ts +12 -0
- package/dist/auth.js +57 -15
- package/dist/bundle.js +91 -23
- package/dist/client.js +4 -3
- package/dist/tools/defaults.js +12 -2
- package/dist/tools/plans.d.ts +24 -3
- package/dist/tools/plans.js +44 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "PickUp Patrol school-dismissal tools for Claude Code",
|
|
9
|
-
"version": "1.0.
|
|
9
|
+
"version": "1.0.2"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"displayName": "PickUp Patrol",
|
|
15
15
|
"source": "./",
|
|
16
16
|
"description": "Read and change your children's school dismissal plans in PickUp Patrol — defaults, day-by-day changes and school cutoff times — via MCP",
|
|
17
|
-
"version": "1.0.
|
|
17
|
+
"version": "1.0.2",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Chris Chall"
|
|
20
20
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pickuppatrol",
|
|
3
3
|
"displayName": "PickUp Patrol",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.2",
|
|
5
5
|
"description": "Read and change your children's school dismissal plans in PickUp Patrol — defaults, day-by-day changes and school cutoff times — via MCP",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Chris Chall"
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ResponseStatus } from './types.js';
|
|
2
2
|
export declare const BASE_URL = "https://app.pickuppatrol.net";
|
|
3
3
|
export declare const BASE_PATH = "/api/json/reply";
|
|
4
|
+
/** Upper bound on any one request to PickUp Patrol, sign-in included. */
|
|
5
|
+
export declare const REQUEST_TIMEOUT_MS = 30000;
|
|
4
6
|
/** Minimal `fetch` seam so tests never open a socket. */
|
|
5
7
|
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
6
8
|
/**
|
|
@@ -23,6 +25,8 @@ export interface AuthOptions {
|
|
|
23
25
|
username?: string;
|
|
24
26
|
password?: string;
|
|
25
27
|
fetchImpl?: FetchLike;
|
|
28
|
+
/** Sign-in timeout; defaults to `REQUEST_TIMEOUT_MS`. A test seam. */
|
|
29
|
+
timeoutMs?: number;
|
|
26
30
|
}
|
|
27
31
|
/**
|
|
28
32
|
* Pull the most useful message out of a ServiceStack error envelope. Field
|
|
@@ -39,6 +43,7 @@ export declare class PickUpPatrolAuth {
|
|
|
39
43
|
private readonly password;
|
|
40
44
|
private readonly configError;
|
|
41
45
|
private readonly fetchImpl;
|
|
46
|
+
private readonly timeoutMs;
|
|
42
47
|
private session;
|
|
43
48
|
private inFlight;
|
|
44
49
|
private permanentError;
|
|
@@ -57,6 +62,13 @@ export declare class PickUpPatrolAuth {
|
|
|
57
62
|
* once and the call replayed exactly once — never more, so a server that
|
|
58
63
|
* answers 401 unconditionally cannot turn into a login loop against the
|
|
59
64
|
* account.
|
|
65
|
+
*
|
|
66
|
+
* If the session minted for the replay is rejected too, the sign-in is
|
|
67
|
+
* "succeeding" without producing a usable session — in practice a
|
|
68
|
+
* two-factor account. The login-time check cannot see that (Azure's
|
|
69
|
+
* ARRAffinity cookie means the jar is never empty), so this is where it is
|
|
70
|
+
* caught, and it is cached as permanent: otherwise every later call would
|
|
71
|
+
* spend two more sign-ins against the account.
|
|
60
72
|
*/
|
|
61
73
|
withAuth(call: (session: PupSession) => Promise<Response>): Promise<Response>;
|
|
62
74
|
private login;
|
package/dist/auth.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readEnvVar, McpToolError, CookieJar } from '@chrischall/mcp-utils';
|
|
2
2
|
export const BASE_URL = 'https://app.pickuppatrol.net';
|
|
3
3
|
export const BASE_PATH = '/api/json/reply';
|
|
4
|
+
/** Upper bound on any one request to PickUp Patrol, sign-in included. */
|
|
5
|
+
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
4
6
|
/**
|
|
5
7
|
* Pull the most useful message out of a ServiceStack error envelope. Field
|
|
6
8
|
* errors are more specific than the top-level message, so they win.
|
|
@@ -21,6 +23,7 @@ export class PickUpPatrolAuth {
|
|
|
21
23
|
password;
|
|
22
24
|
configError;
|
|
23
25
|
fetchImpl;
|
|
26
|
+
timeoutMs;
|
|
24
27
|
session = null;
|
|
25
28
|
inFlight = null;
|
|
26
29
|
permanentError = null;
|
|
@@ -43,6 +46,7 @@ export class PickUpPatrolAuth {
|
|
|
43
46
|
this.configError = null;
|
|
44
47
|
}
|
|
45
48
|
this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
49
|
+
this.timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
46
50
|
}
|
|
47
51
|
/** True once a login has succeeded — used by the healthcheck tool. */
|
|
48
52
|
get isAuthenticated() {
|
|
@@ -80,27 +84,64 @@ export class PickUpPatrolAuth {
|
|
|
80
84
|
* once and the call replayed exactly once — never more, so a server that
|
|
81
85
|
* answers 401 unconditionally cannot turn into a login loop against the
|
|
82
86
|
* account.
|
|
87
|
+
*
|
|
88
|
+
* If the session minted for the replay is rejected too, the sign-in is
|
|
89
|
+
* "succeeding" without producing a usable session — in practice a
|
|
90
|
+
* two-factor account. The login-time check cannot see that (Azure's
|
|
91
|
+
* ARRAffinity cookie means the jar is never empty), so this is where it is
|
|
92
|
+
* caught, and it is cached as permanent: otherwise every later call would
|
|
93
|
+
* spend two more sign-ins against the account.
|
|
83
94
|
*/
|
|
84
95
|
async withAuth(call) {
|
|
85
96
|
const first = await call(await this.ensure());
|
|
86
97
|
if (first.status !== 401)
|
|
87
98
|
return first;
|
|
88
99
|
this.invalidate();
|
|
89
|
-
|
|
100
|
+
const replay = await call(await this.ensure());
|
|
101
|
+
if (replay.status !== 401)
|
|
102
|
+
return replay;
|
|
103
|
+
this.invalidate();
|
|
104
|
+
this.permanentError = new McpToolError('PickUp Patrol accepted the sign-in but rejected the session it just issued', {
|
|
105
|
+
hint: 'This usually means the account has two-factor authentication enabled, which this server does not yet complete. Sign in at https://app.pickuppatrol.net/ to check, then restart the server.',
|
|
106
|
+
});
|
|
107
|
+
throw this.permanentError;
|
|
90
108
|
}
|
|
91
109
|
async login() {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
110
|
+
// Bounded like every other request. `ensure()` shares this promise with
|
|
111
|
+
// every concurrent and later caller until it settles, so an unanswered
|
|
112
|
+
// sign-in would otherwise stall every tool — the healthcheck included —
|
|
113
|
+
// for as long as undici's own ~300s default. The signal also covers the
|
|
114
|
+
// body read below.
|
|
115
|
+
const signal = AbortSignal.timeout(this.timeoutMs);
|
|
116
|
+
let res;
|
|
117
|
+
let body;
|
|
118
|
+
try {
|
|
119
|
+
res = await this.fetchImpl(`${BASE_URL}${BASE_PATH}/Authenticate`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
provider: 'credentials',
|
|
124
|
+
UserName: this.username,
|
|
125
|
+
Password: this.password,
|
|
126
|
+
RememberMe: true,
|
|
127
|
+
}),
|
|
128
|
+
redirect: 'manual',
|
|
129
|
+
signal,
|
|
130
|
+
});
|
|
131
|
+
body = (await res.json().catch((err) => {
|
|
132
|
+
if (signal.aborted)
|
|
133
|
+
throw err;
|
|
134
|
+
return null;
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
// Transient: a timeout says nothing about the credentials, so it is
|
|
139
|
+
// never cached as permanentError and the next call signs in afresh.
|
|
140
|
+
if (signal.aborted) {
|
|
141
|
+
throw new McpToolError(`PickUp Patrol did not answer the sign-in within ${this.timeoutMs / 1000}s`, { hint: 'The service may be slow or down. Try again shortly.' });
|
|
142
|
+
}
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
104
145
|
if (!res.ok) {
|
|
105
146
|
const detail = describeResponseStatus(body?.ResponseStatus);
|
|
106
147
|
const code = body?.ResponseStatus?.ErrorCode ?? '';
|
|
@@ -119,8 +160,9 @@ export class PickUpPatrolAuth {
|
|
|
119
160
|
throw error;
|
|
120
161
|
}
|
|
121
162
|
// Two-factor accounts return a session that is not yet usable; the SPA
|
|
122
|
-
// routes them to /two-factor.
|
|
123
|
-
//
|
|
163
|
+
// routes them to /two-factor. A login with no token and no cookie at all
|
|
164
|
+
// is caught here; the live deployment always sets ARRAffinity, though, so
|
|
165
|
+
// the usual two-factor signal is the rejected fresh session in withAuth().
|
|
124
166
|
const cookieHeader = collectCookieHeader(res);
|
|
125
167
|
const bearerToken = body?.BearerToken ?? null;
|
|
126
168
|
if (!bearerToken && !cookieHeader) {
|
package/dist/bundle.js
CHANGED
|
@@ -58023,6 +58023,7 @@ import { fileURLToPath } from "url";
|
|
|
58023
58023
|
// src/auth.ts
|
|
58024
58024
|
var BASE_URL = "https://app.pickuppatrol.net";
|
|
58025
58025
|
var BASE_PATH = "/api/json/reply";
|
|
58026
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
58026
58027
|
function describeResponseStatus(status) {
|
|
58027
58028
|
if (!status) return null;
|
|
58028
58029
|
const fieldError = status.Errors?.find((e) => e?.Message);
|
|
@@ -58033,6 +58034,7 @@ var PickUpPatrolAuth = class {
|
|
|
58033
58034
|
password;
|
|
58034
58035
|
configError;
|
|
58035
58036
|
fetchImpl;
|
|
58037
|
+
timeoutMs;
|
|
58036
58038
|
session = null;
|
|
58037
58039
|
inFlight = null;
|
|
58038
58040
|
permanentError = null;
|
|
@@ -58054,6 +58056,7 @@ var PickUpPatrolAuth = class {
|
|
|
58054
58056
|
this.configError = null;
|
|
58055
58057
|
}
|
|
58056
58058
|
this.fetchImpl = opts.fetchImpl ?? ((url4, init) => fetch(url4, init));
|
|
58059
|
+
this.timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
58057
58060
|
}
|
|
58058
58061
|
/** True once a login has succeeded — used by the healthcheck tool. */
|
|
58059
58062
|
get isAuthenticated() {
|
|
@@ -58085,26 +58088,59 @@ var PickUpPatrolAuth = class {
|
|
|
58085
58088
|
* once and the call replayed exactly once — never more, so a server that
|
|
58086
58089
|
* answers 401 unconditionally cannot turn into a login loop against the
|
|
58087
58090
|
* account.
|
|
58091
|
+
*
|
|
58092
|
+
* If the session minted for the replay is rejected too, the sign-in is
|
|
58093
|
+
* "succeeding" without producing a usable session — in practice a
|
|
58094
|
+
* two-factor account. The login-time check cannot see that (Azure's
|
|
58095
|
+
* ARRAffinity cookie means the jar is never empty), so this is where it is
|
|
58096
|
+
* caught, and it is cached as permanent: otherwise every later call would
|
|
58097
|
+
* spend two more sign-ins against the account.
|
|
58088
58098
|
*/
|
|
58089
58099
|
async withAuth(call) {
|
|
58090
58100
|
const first = await call(await this.ensure());
|
|
58091
58101
|
if (first.status !== 401) return first;
|
|
58092
58102
|
this.invalidate();
|
|
58093
|
-
|
|
58103
|
+
const replay = await call(await this.ensure());
|
|
58104
|
+
if (replay.status !== 401) return replay;
|
|
58105
|
+
this.invalidate();
|
|
58106
|
+
this.permanentError = new McpToolError(
|
|
58107
|
+
"PickUp Patrol accepted the sign-in but rejected the session it just issued",
|
|
58108
|
+
{
|
|
58109
|
+
hint: "This usually means the account has two-factor authentication enabled, which this server does not yet complete. Sign in at https://app.pickuppatrol.net/ to check, then restart the server."
|
|
58110
|
+
}
|
|
58111
|
+
);
|
|
58112
|
+
throw this.permanentError;
|
|
58094
58113
|
}
|
|
58095
58114
|
async login() {
|
|
58096
|
-
const
|
|
58097
|
-
|
|
58098
|
-
|
|
58099
|
-
|
|
58100
|
-
|
|
58101
|
-
|
|
58102
|
-
|
|
58103
|
-
|
|
58104
|
-
|
|
58105
|
-
|
|
58106
|
-
|
|
58107
|
-
|
|
58115
|
+
const signal = AbortSignal.timeout(this.timeoutMs);
|
|
58116
|
+
let res;
|
|
58117
|
+
let body;
|
|
58118
|
+
try {
|
|
58119
|
+
res = await this.fetchImpl(`${BASE_URL}${BASE_PATH}/Authenticate`, {
|
|
58120
|
+
method: "POST",
|
|
58121
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
58122
|
+
body: JSON.stringify({
|
|
58123
|
+
provider: "credentials",
|
|
58124
|
+
UserName: this.username,
|
|
58125
|
+
Password: this.password,
|
|
58126
|
+
RememberMe: true
|
|
58127
|
+
}),
|
|
58128
|
+
redirect: "manual",
|
|
58129
|
+
signal
|
|
58130
|
+
});
|
|
58131
|
+
body = await res.json().catch((err) => {
|
|
58132
|
+
if (signal.aborted) throw err;
|
|
58133
|
+
return null;
|
|
58134
|
+
});
|
|
58135
|
+
} catch (err) {
|
|
58136
|
+
if (signal.aborted) {
|
|
58137
|
+
throw new McpToolError(
|
|
58138
|
+
`PickUp Patrol did not answer the sign-in within ${this.timeoutMs / 1e3}s`,
|
|
58139
|
+
{ hint: "The service may be slow or down. Try again shortly." }
|
|
58140
|
+
);
|
|
58141
|
+
}
|
|
58142
|
+
throw err;
|
|
58143
|
+
}
|
|
58108
58144
|
if (!res.ok) {
|
|
58109
58145
|
const detail = describeResponseStatus(body?.ResponseStatus);
|
|
58110
58146
|
const code = body?.ResponseStatus?.ErrorCode ?? "";
|
|
@@ -58145,7 +58181,6 @@ try {
|
|
|
58145
58181
|
await loadDotenvSafely({ path: join(dir, "..", ".env"), override: false });
|
|
58146
58182
|
} catch {
|
|
58147
58183
|
}
|
|
58148
|
-
var REQUEST_TIMEOUT_MS = 3e4;
|
|
58149
58184
|
var PickUpPatrolClient = class {
|
|
58150
58185
|
auth;
|
|
58151
58186
|
fetchImpl;
|
|
@@ -58190,7 +58225,9 @@ var PickUpPatrolClient = class {
|
|
|
58190
58225
|
throw new McpToolError(
|
|
58191
58226
|
`PickUp Patrol ${dto} failed (HTTP ${res.status})${detail ? `: ${detail}` : ""}`,
|
|
58192
58227
|
{
|
|
58193
|
-
|
|
58228
|
+
// A 401 never gets here: withAuth() re-signs-in once and turns a
|
|
58229
|
+
// second 401 into a permanent unusable-session error.
|
|
58230
|
+
hint: res.status === 403 ? "The session was rejected. Check PICKUPPATROL_USERNAME and PICKUPPATROL_PASSWORD." : void 0
|
|
58194
58231
|
}
|
|
58195
58232
|
);
|
|
58196
58233
|
}
|
|
@@ -58279,7 +58316,7 @@ var PickUpPatrolClient = class {
|
|
|
58279
58316
|
var client = new PickUpPatrolClient();
|
|
58280
58317
|
|
|
58281
58318
|
// src/version.ts
|
|
58282
|
-
var VERSION = "1.0.
|
|
58319
|
+
var VERSION = "1.0.2";
|
|
58283
58320
|
|
|
58284
58321
|
// src/dates.ts
|
|
58285
58322
|
var WEEKDAY_NAMES = [
|
|
@@ -58638,8 +58675,27 @@ function previewUnlessConfirmed(confirm, action, method, dto, body) {
|
|
|
58638
58675
|
}
|
|
58639
58676
|
|
|
58640
58677
|
// src/tools/plans.ts
|
|
58641
|
-
function
|
|
58642
|
-
|
|
58678
|
+
function normalizeTimeOfDay(time3) {
|
|
58679
|
+
const trimmed = time3.trim();
|
|
58680
|
+
const pad = (n) => (n ?? "0").padStart(2, "0");
|
|
58681
|
+
const duration3 = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)(?:\.\d+)?S)?$/.exec(trimmed);
|
|
58682
|
+
if (duration3 && trimmed !== "PT") return `${pad(duration3[1])}:${pad(duration3[2])}:${pad(duration3[3])}`;
|
|
58683
|
+
const clock = /(?:^|T)(\d{1,2}):(\d{2})(?::(\d{2}))?/.exec(trimmed);
|
|
58684
|
+
if (clock) return `${pad(clock[1])}:${pad(clock[2])}:${pad(clock[3])}`;
|
|
58685
|
+
return trimmed;
|
|
58686
|
+
}
|
|
58687
|
+
function proofsMatch(actual, expected) {
|
|
58688
|
+
if (actual.transportationId !== expected.transportationId) return false;
|
|
58689
|
+
if ((actual.note ?? "").trim() !== (expected.note ?? "").trim()) return false;
|
|
58690
|
+
if (expected.earlyDismissalTime !== void 0) {
|
|
58691
|
+
const want = expected.earlyDismissalTime === null ? "" : normalizeTimeOfDay(expected.earlyDismissalTime);
|
|
58692
|
+
const got = actual.earlyDismissalTime ? normalizeTimeOfDay(actual.earlyDismissalTime) : "";
|
|
58693
|
+
if (want !== got) return false;
|
|
58694
|
+
}
|
|
58695
|
+
if (expected.carNumber !== void 0) {
|
|
58696
|
+
if ((actual.carNumber ?? "").trim() !== (expected.carNumber ?? "").trim()) return false;
|
|
58697
|
+
}
|
|
58698
|
+
return true;
|
|
58643
58699
|
}
|
|
58644
58700
|
function expectedPlanState(requested) {
|
|
58645
58701
|
return requested.transportationId === null ? { transportationId: null, note: null } : requested;
|
|
@@ -58712,14 +58768,18 @@ function registerPlanTools(server, client2) {
|
|
|
58712
58768
|
await client2.updatePlans(plans);
|
|
58713
58769
|
const expected = expectedPlanState({
|
|
58714
58770
|
transportationId: transportation_id,
|
|
58715
|
-
note: plans[0]?.Note ?? null
|
|
58771
|
+
note: plans[0]?.Note ?? null,
|
|
58772
|
+
earlyDismissalTime: plans[0]?.EarlyDismissalTime,
|
|
58773
|
+
carNumber: plans[0]?.CarNumber
|
|
58716
58774
|
});
|
|
58717
58775
|
const verification = await Promise.all(
|
|
58718
58776
|
dates.map(async (date5) => {
|
|
58719
58777
|
const after = await client2.getPlanEdit(date5, student_id);
|
|
58720
58778
|
const actual = {
|
|
58721
58779
|
transportationId: after.TransportationId ?? null,
|
|
58722
|
-
note: after.Note ?? null
|
|
58780
|
+
note: after.Note ?? null,
|
|
58781
|
+
earlyDismissalTime: after.EarlyDismissalTime ?? null,
|
|
58782
|
+
carNumber: after.CarNumber ?? null
|
|
58723
58783
|
};
|
|
58724
58784
|
return {
|
|
58725
58785
|
date: date5,
|
|
@@ -58858,13 +58918,21 @@ function registerDefaultPlanTools(server, client2) {
|
|
|
58858
58918
|
if (gate) return gate;
|
|
58859
58919
|
await client2.updateStudent(payload);
|
|
58860
58920
|
const after = await client2.getStudent(student_id);
|
|
58861
|
-
const
|
|
58921
|
+
const sent = payload.DefaultPlans?.find((p) => p.DayId === dayIds[0]);
|
|
58862
58922
|
const unchanged = [...new Set(dayIds)].filter((dayId) => {
|
|
58863
58923
|
const plan = (after.DefaultPlans ?? []).find((p) => p.DayId === dayId);
|
|
58864
58924
|
if (plan === void 0) return true;
|
|
58865
58925
|
return !proofsMatch(
|
|
58866
|
-
{
|
|
58867
|
-
|
|
58926
|
+
{
|
|
58927
|
+
transportationId: plan.TransportationId ?? null,
|
|
58928
|
+
note: plan.Note ?? null,
|
|
58929
|
+
earlyDismissalTime: plan.EarlyDismissalTime ?? null
|
|
58930
|
+
},
|
|
58931
|
+
{
|
|
58932
|
+
transportationId: transportation.TransportationId,
|
|
58933
|
+
note: sent?.Note ?? null,
|
|
58934
|
+
earlyDismissalTime: sent?.EarlyDismissalTime ?? void 0
|
|
58935
|
+
}
|
|
58868
58936
|
);
|
|
58869
58937
|
});
|
|
58870
58938
|
return minifiedResult({
|
package/dist/client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { dirname, join } from 'path';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import { loadDotenvSafely, McpToolError, buildQueryString } from '@chrischall/mcp-utils';
|
|
4
|
-
import { PickUpPatrolAuth, BASE_URL, BASE_PATH, describeResponseStatus } from './auth.js';
|
|
4
|
+
import { PickUpPatrolAuth, BASE_URL, BASE_PATH, REQUEST_TIMEOUT_MS, describeResponseStatus, } from './auth.js';
|
|
5
5
|
// Load .env for local dev; silently skip when dotenv is unavailable (the mcpb
|
|
6
6
|
// bundle externalises it). The try/catch guards a runtime where
|
|
7
7
|
// `import.meta.url` is undefined and `fileURLToPath` would throw at module
|
|
@@ -13,7 +13,6 @@ try {
|
|
|
13
13
|
catch {
|
|
14
14
|
/* non-Node runtime: no .env to load */
|
|
15
15
|
}
|
|
16
|
-
const REQUEST_TIMEOUT_MS = 30_000;
|
|
17
16
|
/**
|
|
18
17
|
* Thin typed client over PickUp Patrol's ServiceStack API.
|
|
19
18
|
*
|
|
@@ -68,7 +67,9 @@ export class PickUpPatrolClient {
|
|
|
68
67
|
detail = null;
|
|
69
68
|
}
|
|
70
69
|
throw new McpToolError(`PickUp Patrol ${dto} failed (HTTP ${res.status})${detail ? `: ${detail}` : ''}`, {
|
|
71
|
-
|
|
70
|
+
// A 401 never gets here: withAuth() re-signs-in once and turns a
|
|
71
|
+
// second 401 into a permanent unusable-session error.
|
|
72
|
+
hint: res.status === 403
|
|
72
73
|
? 'The session was rejected. Check PICKUPPATROL_USERNAME and PICKUPPATROL_PASSWORD.'
|
|
73
74
|
: undefined,
|
|
74
75
|
});
|
package/dist/tools/defaults.js
CHANGED
|
@@ -131,12 +131,22 @@ export function registerDefaultPlanTools(server, client) {
|
|
|
131
131
|
// the comparison entirely: it advances by itself, which would make every
|
|
132
132
|
// write look successful.
|
|
133
133
|
const after = await client.getStudent(student_id);
|
|
134
|
-
|
|
134
|
+
// The early-dismissal time is part of the proof when one was sent: a
|
|
135
|
+
// time-only change keeps the option and note identical.
|
|
136
|
+
const sent = payload.DefaultPlans?.find((p) => p.DayId === dayIds[0]);
|
|
135
137
|
const unchanged = [...new Set(dayIds)].filter((dayId) => {
|
|
136
138
|
const plan = (after.DefaultPlans ?? []).find((p) => p.DayId === dayId);
|
|
137
139
|
if (plan === undefined)
|
|
138
140
|
return true;
|
|
139
|
-
return !proofsMatch({
|
|
141
|
+
return !proofsMatch({
|
|
142
|
+
transportationId: plan.TransportationId ?? null,
|
|
143
|
+
note: plan.Note ?? null,
|
|
144
|
+
earlyDismissalTime: plan.EarlyDismissalTime ?? null,
|
|
145
|
+
}, {
|
|
146
|
+
transportationId: transportation.TransportationId,
|
|
147
|
+
note: sent?.Note ?? null,
|
|
148
|
+
earlyDismissalTime: sent?.EarlyDismissalTime ?? undefined,
|
|
149
|
+
});
|
|
140
150
|
});
|
|
141
151
|
return minifiedResult({
|
|
142
152
|
action,
|
package/dist/tools/plans.d.ts
CHANGED
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import type { PickUpPatrolClient } from '../client.js';
|
|
3
3
|
import type { Transportation } from '../types.js';
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* The fields whose change proves a plan write actually landed.
|
|
6
|
+
*
|
|
7
|
+
* `earlyDismissalTime` and `carNumber` are part of the proof whenever the
|
|
8
|
+
* write sent them: moving an early dismissal from 14:30 to 13:00 keeps the
|
|
9
|
+
* option and note identical, so without the time a silently dropped write
|
|
10
|
+
* would read back as verified. Left `undefined` on the expected side, they
|
|
11
|
+
* are not compared — the write did not carry them.
|
|
12
|
+
*/
|
|
5
13
|
export interface PlanProof {
|
|
6
14
|
transportationId: number | null;
|
|
7
15
|
note: string | null;
|
|
16
|
+
earlyDismissalTime?: string | null | undefined;
|
|
17
|
+
carNumber?: string | null | undefined;
|
|
8
18
|
}
|
|
9
|
-
/**
|
|
10
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Reduce a time of day to `HH:MM:SS` so a read-back can be compared with what
|
|
21
|
+
* was sent. Accepts `H:MM`, `HH:MM:SS`, an ISO date-time, and the XSD duration
|
|
22
|
+
* ServiceStack uses for a `TimeSpan` (`PT13H30M`). Anything unrecognised is
|
|
23
|
+
* returned trimmed, so it can only ever compare unequal — a false "unchanged"
|
|
24
|
+
* is recoverable, a false "verified" is not.
|
|
25
|
+
*/
|
|
26
|
+
export declare function normalizeTimeOfDay(time: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Compare a read-back proof with the expected one, ignoring whitespace the
|
|
29
|
+
* service may normalise off a note or car number.
|
|
30
|
+
*/
|
|
31
|
+
export declare function proofsMatch(actual: PlanProof, expected: PlanProof): boolean;
|
|
11
32
|
/**
|
|
12
33
|
* What `GetPlanEdit` should report once a write has landed.
|
|
13
34
|
*
|
package/dist/tools/plans.js
CHANGED
|
@@ -3,9 +3,44 @@ import { McpToolError, minifiedResult } from '@chrischall/mcp-utils';
|
|
|
3
3
|
import { buildPlanUpdates } from '../plans.js';
|
|
4
4
|
import { weekdayOf } from '../dates.js';
|
|
5
5
|
import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Reduce a time of day to `HH:MM:SS` so a read-back can be compared with what
|
|
8
|
+
* was sent. Accepts `H:MM`, `HH:MM:SS`, an ISO date-time, and the XSD duration
|
|
9
|
+
* ServiceStack uses for a `TimeSpan` (`PT13H30M`). Anything unrecognised is
|
|
10
|
+
* returned trimmed, so it can only ever compare unequal — a false "unchanged"
|
|
11
|
+
* is recoverable, a false "verified" is not.
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeTimeOfDay(time) {
|
|
14
|
+
const trimmed = time.trim();
|
|
15
|
+
const pad = (n) => (n ?? '0').padStart(2, '0');
|
|
16
|
+
const duration = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)(?:\.\d+)?S)?$/.exec(trimmed);
|
|
17
|
+
if (duration && trimmed !== 'PT')
|
|
18
|
+
return `${pad(duration[1])}:${pad(duration[2])}:${pad(duration[3])}`;
|
|
19
|
+
const clock = /(?:^|T)(\d{1,2}):(\d{2})(?::(\d{2}))?/.exec(trimmed);
|
|
20
|
+
if (clock)
|
|
21
|
+
return `${pad(clock[1])}:${pad(clock[2])}:${pad(clock[3])}`;
|
|
22
|
+
return trimmed;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Compare a read-back proof with the expected one, ignoring whitespace the
|
|
26
|
+
* service may normalise off a note or car number.
|
|
27
|
+
*/
|
|
28
|
+
export function proofsMatch(actual, expected) {
|
|
29
|
+
if (actual.transportationId !== expected.transportationId)
|
|
30
|
+
return false;
|
|
31
|
+
if ((actual.note ?? '').trim() !== (expected.note ?? '').trim())
|
|
32
|
+
return false;
|
|
33
|
+
if (expected.earlyDismissalTime !== undefined) {
|
|
34
|
+
const want = expected.earlyDismissalTime === null ? '' : normalizeTimeOfDay(expected.earlyDismissalTime);
|
|
35
|
+
const got = actual.earlyDismissalTime ? normalizeTimeOfDay(actual.earlyDismissalTime) : '';
|
|
36
|
+
if (want !== got)
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if (expected.carNumber !== undefined) {
|
|
40
|
+
if ((actual.carNumber ?? '').trim() !== (expected.carNumber ?? '').trim())
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
9
44
|
}
|
|
10
45
|
/**
|
|
11
46
|
* What `GetPlanEdit` should report once a write has landed.
|
|
@@ -104,7 +139,8 @@ export function registerPlanTools(server, client) {
|
|
|
104
139
|
return gate;
|
|
105
140
|
await client.updatePlans(plans);
|
|
106
141
|
// A 2xx is not proof the change persisted — re-read each date and
|
|
107
|
-
// compare the
|
|
142
|
+
// compare the fields that prove it (option, note, and the time / car
|
|
143
|
+
// number when sent). ModifiedDate is deliberately not
|
|
108
144
|
// compared: it advances on its own, which would make every write look
|
|
109
145
|
// successful.
|
|
110
146
|
// One expectation for the whole call: every date in a single UpdatePlans
|
|
@@ -112,12 +148,16 @@ export function registerPlanTools(server, client) {
|
|
|
112
148
|
const expected = expectedPlanState({
|
|
113
149
|
transportationId: transportation_id,
|
|
114
150
|
note: plans[0]?.Note ?? null,
|
|
151
|
+
earlyDismissalTime: plans[0]?.EarlyDismissalTime,
|
|
152
|
+
carNumber: plans[0]?.CarNumber,
|
|
115
153
|
});
|
|
116
154
|
const verification = await Promise.all(dates.map(async (date) => {
|
|
117
155
|
const after = await client.getPlanEdit(date, student_id);
|
|
118
156
|
const actual = {
|
|
119
157
|
transportationId: after.TransportationId ?? null,
|
|
120
158
|
note: after.Note ?? null,
|
|
159
|
+
earlyDismissalTime: after.EarlyDismissalTime ?? null,
|
|
160
|
+
carNumber: after.CarNumber ?? null,
|
|
121
161
|
};
|
|
122
162
|
return {
|
|
123
163
|
date,
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/pickuppatrol-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.0.
|
|
9
|
+
"version": "1.0.2",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/pickuppatrol-mcp",
|
|
14
|
-
"version": "1.0.
|
|
14
|
+
"version": "1.0.2",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|