@chrischall/pickuppatrol-mcp 1.2.0 → 1.2.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 +2 -2
- package/README.md +8 -0
- package/dist/auth.d.ts +34 -3
- package/dist/auth.js +55 -11
- package/dist/bundle.js +467 -89
- package/dist/client.d.ts +5 -3
- package/dist/client.js +8 -2
- package/dist/plans.d.ts +1 -0
- package/dist/plans.js +9 -0
- package/dist/tools/account.js +15 -12
- package/dist/tools/defaults.js +25 -10
- package/dist/tools/plans.d.ts +11 -1
- package/dist/tools/plans.js +87 -11
- package/dist/tools/school.d.ts +15 -1
- package/dist/tools/school.js +68 -13
- package/dist/tools/untrusted.d.ts +11 -0
- package/dist/tools/untrusted.js +10 -0
- package/dist/types.d.ts +50 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +19 -3
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "PickUp Patrol school-dismissal tools for Claude Code",
|
|
9
|
-
"version": "1.2.
|
|
9
|
+
"version": "1.2.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.2.
|
|
17
|
+
"version": "1.2.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.2.
|
|
4
|
+
"version": "1.2.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"
|
|
@@ -18,5 +18,5 @@
|
|
|
18
18
|
"mcp"
|
|
19
19
|
],
|
|
20
20
|
"skills": "./skills/",
|
|
21
|
-
"
|
|
21
|
+
"mcpServers": "./.mcp.json"
|
|
22
22
|
}
|
package/README.md
CHANGED
|
@@ -60,6 +60,14 @@ and only reports the configuration error on the first tool call.
|
|
|
60
60
|
| `pup_list_car_numbers` | Car numbers the school issued to this account |
|
|
61
61
|
| `pup_healthcheck` | Credentials sign in and the API answers; on failure, `error.kind` says which hop broke (`edge_blocked`, `credential_rejected`, …) |
|
|
62
62
|
|
|
63
|
+
Reads are projected to known fields. Plan notes can be written by any guardian
|
|
64
|
+
on the account, and option hints and school settings by school staff, so every
|
|
65
|
+
read that carries them is marked `untrusted_content` with a note telling the
|
|
66
|
+
model to treat the text as data, not instructions. `pup_list_plans`,
|
|
67
|
+
`pup_get_plan`, `pup_get_school` and `pup_get_student` take `raw: true` for the
|
|
68
|
+
untouched API record (still marked untrusted) — the only way to see fields such
|
|
69
|
+
as `SASId` or `SafetyFlag`.
|
|
70
|
+
|
|
63
71
|
**Writes** — every one asks you to confirm first. A client that can show a
|
|
64
72
|
confirmation prompt (Claude Code) shows one, unless `MCP_CONFIRM_ELICITATION=off`.
|
|
65
73
|
Otherwise the first call makes no
|
package/dist/auth.d.ts
CHANGED
|
@@ -4,6 +4,15 @@ export declare const BASE_URL = "https://app.pickuppatrol.net";
|
|
|
4
4
|
export declare const BASE_PATH = "/api/json/reply";
|
|
5
5
|
/** Upper bound on any one request to PickUp Patrol, sign-in included. */
|
|
6
6
|
export declare const REQUEST_TIMEOUT_MS = 30000;
|
|
7
|
+
/**
|
|
8
|
+
* How long a "sign-in accepted, fresh session rejected" result is held before
|
|
9
|
+
* the next call may sign in again. That result is inferred from two 401s, not
|
|
10
|
+
* judged by the service (a session-store blip during an Azure slot swap looks
|
|
11
|
+
* the same), so it must not brick a long-running server until restart — but
|
|
12
|
+
* holding it a while keeps a genuine two-factor account from spending two
|
|
13
|
+
* sign-ins on every call.
|
|
14
|
+
*/
|
|
15
|
+
export declare const UNUSABLE_SESSION_COOLDOWN_MS: number;
|
|
7
16
|
/** Minimal `fetch` seam so tests never open a socket. */
|
|
8
17
|
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
9
18
|
/**
|
|
@@ -17,7 +26,12 @@ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
|
17
26
|
export interface PupSession {
|
|
18
27
|
/** `Authorization: Bearer …`, when the deployment issues JWTs. */
|
|
19
28
|
bearerToken: string | null;
|
|
20
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* The login's `RefreshToken`, if any. Nothing in this package reads it.
|
|
31
|
+
*
|
|
32
|
+
* @deprecated Unused; kept only because `PupSession` is public library
|
|
33
|
+
* surface. It will be removed in the next major release.
|
|
34
|
+
*/
|
|
21
35
|
refreshToken: string | null;
|
|
22
36
|
/** `Cookie:` header value built from every `Set-Cookie` the login returned. */
|
|
23
37
|
cookieHeader: string;
|
|
@@ -28,6 +42,8 @@ export interface AuthOptions {
|
|
|
28
42
|
fetchImpl?: FetchLike;
|
|
29
43
|
/** Sign-in timeout; defaults to `REQUEST_TIMEOUT_MS`. A test seam. */
|
|
30
44
|
timeoutMs?: number;
|
|
45
|
+
/** Clock for the unusable-session cool-down; defaults to `Date.now`. A test seam. */
|
|
46
|
+
now?: () => number;
|
|
31
47
|
}
|
|
32
48
|
/**
|
|
33
49
|
* Pull the most useful message out of a ServiceStack error envelope. Field
|
|
@@ -70,9 +86,13 @@ export declare class PickUpPatrolAuth {
|
|
|
70
86
|
private readonly configError;
|
|
71
87
|
private readonly fetchImpl;
|
|
72
88
|
private readonly timeoutMs;
|
|
89
|
+
private readonly now;
|
|
73
90
|
private session;
|
|
74
91
|
private inFlight;
|
|
75
92
|
private permanentError;
|
|
93
|
+
/** The double-401 latch: held until `unusableUntil`, then lifted. */
|
|
94
|
+
private unusableError;
|
|
95
|
+
private unusableUntil;
|
|
76
96
|
constructor(opts?: AuthOptions);
|
|
77
97
|
/**
|
|
78
98
|
* Where the credentials came from, for the healthcheck — `'env'` when a
|
|
@@ -80,6 +100,13 @@ export declare class PickUpPatrolAuth {
|
|
|
80
100
|
* constructor), `null` when they are not. Never the credential itself.
|
|
81
101
|
*/
|
|
82
102
|
get credentialSource(): string | null;
|
|
103
|
+
/**
|
|
104
|
+
* The account writes run as — the configured username, case-folded so
|
|
105
|
+
* `Parent@x` and `parent@x` are one account — or `undefined` when none is
|
|
106
|
+
* configured. Bound into every confirmation token, so under a shared
|
|
107
|
+
* `MCP_CONFIRM_SECRET` an approval for one account never acts as another.
|
|
108
|
+
*/
|
|
109
|
+
get account(): string | undefined;
|
|
83
110
|
/** True once a login has succeeded — used by the healthcheck tool. */
|
|
84
111
|
get isAuthenticated(): boolean;
|
|
85
112
|
/**
|
|
@@ -89,6 +116,8 @@ export declare class PickUpPatrolAuth {
|
|
|
89
116
|
ensure(): Promise<PupSession>;
|
|
90
117
|
/** Drop the cached session so the next `ensure()` signs in again. */
|
|
91
118
|
invalidate(): void;
|
|
119
|
+
/** Drop the cached session only if it is still `session`. */
|
|
120
|
+
private invalidateIfCurrent;
|
|
92
121
|
/**
|
|
93
122
|
* Run an authenticated request. On a 401 the session is dropped, re-minted
|
|
94
123
|
* once and the call replayed exactly once — never more, so a server that
|
|
@@ -99,8 +128,10 @@ export declare class PickUpPatrolAuth {
|
|
|
99
128
|
* "succeeding" without producing a usable session — in practice a
|
|
100
129
|
* two-factor account. The login-time check cannot see that (Azure's
|
|
101
130
|
* ARRAffinity cookie means the jar is never empty), so this is where it is
|
|
102
|
-
* caught
|
|
103
|
-
* spend two more sign-ins against the account
|
|
131
|
+
* caught. It is held for {@link UNUSABLE_SESSION_COOLDOWN_MS} — otherwise
|
|
132
|
+
* every later call would spend two more sign-ins against the account — but
|
|
133
|
+
* not forever: it is inferred from a status code, not judged by the
|
|
134
|
+
* service, so a transient session-store failure must not brick the server.
|
|
104
135
|
*
|
|
105
136
|
* A 401 that is a CDN/WAF refusal page is NOT a rejected session: the
|
|
106
137
|
* request never reached PickUp Patrol, so the session is kept, no sign-in is
|
package/dist/auth.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import { readEnvVar, McpToolError, CookieJar, EdgeBlockedError, detectEdgeBlock, } from '@chrischall/mcp-utils';
|
|
1
|
+
import { readEnvVar, McpToolError, CookieJar, EdgeBlockedError, detectEdgeBlock, withAmbientCancellation, } from '@chrischall/mcp-utils';
|
|
2
2
|
export const BASE_URL = 'https://app.pickuppatrol.net';
|
|
3
3
|
export const BASE_PATH = '/api/json/reply';
|
|
4
4
|
/** Upper bound on any one request to PickUp Patrol, sign-in included. */
|
|
5
5
|
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
6
|
+
/**
|
|
7
|
+
* How long a "sign-in accepted, fresh session rejected" result is held before
|
|
8
|
+
* the next call may sign in again. That result is inferred from two 401s, not
|
|
9
|
+
* judged by the service (a session-store blip during an Azure slot swap looks
|
|
10
|
+
* the same), so it must not brick a long-running server until restart — but
|
|
11
|
+
* holding it a while keeps a genuine two-factor account from spending two
|
|
12
|
+
* sign-ins on every call.
|
|
13
|
+
*/
|
|
14
|
+
export const UNUSABLE_SESSION_COOLDOWN_MS = 10 * 60_000;
|
|
6
15
|
/**
|
|
7
16
|
* Pull the most useful message out of a ServiceStack error envelope. Field
|
|
8
17
|
* errors are more specific than the top-level message, so they win.
|
|
@@ -67,9 +76,13 @@ export class PickUpPatrolAuth {
|
|
|
67
76
|
configError;
|
|
68
77
|
fetchImpl;
|
|
69
78
|
timeoutMs;
|
|
79
|
+
now;
|
|
70
80
|
session = null;
|
|
71
81
|
inFlight = null;
|
|
72
82
|
permanentError = null;
|
|
83
|
+
/** The double-401 latch: held until `unusableUntil`, then lifted. */
|
|
84
|
+
unusableError = null;
|
|
85
|
+
unusableUntil = 0;
|
|
73
86
|
constructor(opts = {}) {
|
|
74
87
|
const username = opts.username ?? readEnvVar('PICKUPPATROL_USERNAME');
|
|
75
88
|
const password = opts.password ?? readEnvVar('PICKUPPATROL_PASSWORD');
|
|
@@ -90,6 +103,7 @@ export class PickUpPatrolAuth {
|
|
|
90
103
|
}
|
|
91
104
|
this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
92
105
|
this.timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
106
|
+
this.now = opts.now ?? Date.now;
|
|
93
107
|
}
|
|
94
108
|
/**
|
|
95
109
|
* Where the credentials came from, for the healthcheck — `'env'` when a
|
|
@@ -99,6 +113,15 @@ export class PickUpPatrolAuth {
|
|
|
99
113
|
get credentialSource() {
|
|
100
114
|
return this.configError ? null : 'env';
|
|
101
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* The account writes run as — the configured username, case-folded so
|
|
118
|
+
* `Parent@x` and `parent@x` are one account — or `undefined` when none is
|
|
119
|
+
* configured. Bound into every confirmation token, so under a shared
|
|
120
|
+
* `MCP_CONFIRM_SECRET` an approval for one account never acts as another.
|
|
121
|
+
*/
|
|
122
|
+
get account() {
|
|
123
|
+
return this.username === null ? undefined : this.username.trim().toLowerCase();
|
|
124
|
+
}
|
|
102
125
|
/** True once a login has succeeded — used by the healthcheck tool. */
|
|
103
126
|
get isAuthenticated() {
|
|
104
127
|
return this.session !== null;
|
|
@@ -112,6 +135,11 @@ export class PickUpPatrolAuth {
|
|
|
112
135
|
throw this.configError;
|
|
113
136
|
if (this.permanentError)
|
|
114
137
|
throw this.permanentError;
|
|
138
|
+
if (this.unusableError) {
|
|
139
|
+
if (this.now() < this.unusableUntil)
|
|
140
|
+
throw this.unusableError;
|
|
141
|
+
this.unusableError = null;
|
|
142
|
+
}
|
|
115
143
|
if (this.session)
|
|
116
144
|
return this.session;
|
|
117
145
|
if (this.inFlight)
|
|
@@ -130,6 +158,11 @@ export class PickUpPatrolAuth {
|
|
|
130
158
|
invalidate() {
|
|
131
159
|
this.session = null;
|
|
132
160
|
}
|
|
161
|
+
/** Drop the cached session only if it is still `session`. */
|
|
162
|
+
invalidateIfCurrent(session) {
|
|
163
|
+
if (this.session === session)
|
|
164
|
+
this.session = null;
|
|
165
|
+
}
|
|
133
166
|
/**
|
|
134
167
|
* Run an authenticated request. On a 401 the session is dropped, re-minted
|
|
135
168
|
* once and the call replayed exactly once — never more, so a server that
|
|
@@ -140,8 +173,10 @@ export class PickUpPatrolAuth {
|
|
|
140
173
|
* "succeeding" without producing a usable session — in practice a
|
|
141
174
|
* two-factor account. The login-time check cannot see that (Azure's
|
|
142
175
|
* ARRAffinity cookie means the jar is never empty), so this is where it is
|
|
143
|
-
* caught
|
|
144
|
-
* spend two more sign-ins against the account
|
|
176
|
+
* caught. It is held for {@link UNUSABLE_SESSION_COOLDOWN_MS} — otherwise
|
|
177
|
+
* every later call would spend two more sign-ins against the account — but
|
|
178
|
+
* not forever: it is inferred from a status code, not judged by the
|
|
179
|
+
* service, so a transient session-store failure must not brick the server.
|
|
145
180
|
*
|
|
146
181
|
* A 401 that is a CDN/WAF refusal page is NOT a rejected session: the
|
|
147
182
|
* request never reached PickUp Patrol, so the session is kept, no sign-in is
|
|
@@ -149,18 +184,24 @@ export class PickUpPatrolAuth {
|
|
|
149
184
|
* (chrischall/mcp-host#1015).
|
|
150
185
|
*/
|
|
151
186
|
async withAuth(call) {
|
|
152
|
-
const
|
|
187
|
+
const used = await this.ensure();
|
|
188
|
+
const first = await call(used);
|
|
153
189
|
if (first.status !== 401)
|
|
154
190
|
return first;
|
|
155
191
|
await this.throwIfEdgeBlocked(first);
|
|
156
|
-
this.
|
|
157
|
-
|
|
192
|
+
// Only drop the session this call actually used. A concurrent caller may
|
|
193
|
+
// already have re-minted it; throwing that fresh session away would spend
|
|
194
|
+
// another sign-in against the account for nothing.
|
|
195
|
+
this.invalidateIfCurrent(used);
|
|
196
|
+
const replaySession = await this.ensure();
|
|
197
|
+
const replay = await call(replaySession);
|
|
158
198
|
if (replay.status !== 401)
|
|
159
199
|
return replay;
|
|
160
200
|
await this.throwIfEdgeBlocked(replay);
|
|
161
|
-
this.
|
|
162
|
-
this.
|
|
163
|
-
|
|
201
|
+
this.invalidateIfCurrent(replaySession);
|
|
202
|
+
this.unusableError = new UnusableSessionError('PickUp Patrol accepted the sign-in but rejected the session it just issued', `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. To spare the account further sign-ins, the server waits ${UNUSABLE_SESSION_COOLDOWN_MS / 60_000} minutes before trying again.`);
|
|
203
|
+
this.unusableUntil = this.now() + UNUSABLE_SESSION_COOLDOWN_MS;
|
|
204
|
+
throw this.unusableError;
|
|
164
205
|
}
|
|
165
206
|
async throwIfEdgeBlocked(res) {
|
|
166
207
|
const edge = await edgeBlockOfResponse(res);
|
|
@@ -174,7 +215,10 @@ export class PickUpPatrolAuth {
|
|
|
174
215
|
// sign-in would otherwise stall every tool — the healthcheck included —
|
|
175
216
|
// for as long as undici's own ~300s default. The signal also covers the
|
|
176
217
|
// body read below.
|
|
177
|
-
|
|
218
|
+
// The running call's cancellation is folded in too, so a cancelled call
|
|
219
|
+
// does not leave the shared sign-in running for the full deadline.
|
|
220
|
+
const deadline = AbortSignal.timeout(this.timeoutMs);
|
|
221
|
+
const signal = withAmbientCancellation(deadline);
|
|
178
222
|
let res;
|
|
179
223
|
let text;
|
|
180
224
|
try {
|
|
@@ -199,7 +243,7 @@ export class PickUpPatrolAuth {
|
|
|
199
243
|
catch (err) {
|
|
200
244
|
// Transient: a timeout says nothing about the credentials, so it is
|
|
201
245
|
// never cached as permanentError and the next call signs in afresh.
|
|
202
|
-
if (
|
|
246
|
+
if (deadline.aborted) {
|
|
203
247
|
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.' });
|
|
204
248
|
}
|
|
205
249
|
throw err;
|