@chrischall/pickuppatrol-mcp 1.1.2 → 1.1.4
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/README.md +2 -2
- package/dist/auth.d.ts +38 -0
- package/dist/auth.js +97 -15
- package/dist/bundle.js +798 -193
- package/dist/client.d.ts +2 -0
- package/dist/client.js +18 -4
- package/dist/tools/account.d.ts +9 -0
- package/dist/tools/account.js +43 -17
- package/dist/tools/defaults.js +13 -13
- package/dist/tools/plans.js +6 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
- package/server.json +2 -2
- package/dist/tools/_confirm.d.ts +0 -37
- package/dist/tools/_confirm.js +0 -32
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "PickUp Patrol school-dismissal tools for Claude Code",
|
|
9
|
-
"version": "1.1.
|
|
9
|
+
"version": "1.1.4"
|
|
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.1.
|
|
17
|
+
"version": "1.1.4",
|
|
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.1.
|
|
4
|
+
"version": "1.1.4",
|
|
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/README.md
CHANGED
|
@@ -58,7 +58,7 @@ and only reports the configuration error on the first tool call.
|
|
|
58
58
|
| `pup_get_school` | School profile, notify times, cutoff times, settings |
|
|
59
59
|
| `pup_list_non_school_days` | Dates no plan can be set for, and dates already changed |
|
|
60
60
|
| `pup_list_car_numbers` | Car numbers the school issued to this account |
|
|
61
|
-
| `pup_healthcheck` | Credentials sign in and the API answers |
|
|
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
63
|
**Writes** — every one asks you to confirm first. A client that can show a
|
|
64
64
|
confirmation prompt (Claude Code) shows one. Otherwise the first call makes no
|
|
@@ -78,7 +78,7 @@ change and returns a preview of the exact payload it would send plus a
|
|
|
78
78
|
|---|---|---|
|
|
79
79
|
| `MCP_CONFIRM_MODE` | `ask-user` | What a write does on a client that cannot show a confirmation prompt (claude.ai, Claude Desktop). `ask-user`: two steps — the first call does nothing and returns a preview plus a token, and the model must get your approval in chat before calling again with it. `auto`: the same two steps, but the model may use the token after reviewing the preview itself. `refuse`: writes are refused on such clients. A client that can show prompts (Claude Code) always gets the real prompt. An unrecognised value is treated as `refuse`. |
|
|
80
80
|
| `MCP_CONFIRM_TTL_SECONDS` | `600` | How long a token stays valid. |
|
|
81
|
-
| `MCP_CONFIRM_SECRET` | random per process | Signing key; set it only if tokens must survive a server restart. |
|
|
81
|
+
| `MCP_CONFIRM_SECRET` | random per process | Signing key; set it only if tokens must survive a server restart. On mcp-host the host supplies a stable per-child key (`MCP_HOST_CONFIRM_SECRET`) and spent tokens are recorded under `MCP_DATA_DIR`, so an approval survives an idle restart. |
|
|
82
82
|
|
|
83
83
|
A token is single-use and bound to the exact payload. The second call re-reads
|
|
84
84
|
the student and rebuilds the payload, so if anything moved between the preview
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { McpToolError } from '@chrischall/mcp-utils';
|
|
1
2
|
import type { ResponseStatus } from './types.js';
|
|
2
3
|
export declare const BASE_URL = "https://app.pickuppatrol.net";
|
|
3
4
|
export declare const BASE_PATH = "/api/json/reply";
|
|
@@ -33,6 +34,31 @@ export interface AuthOptions {
|
|
|
33
34
|
* errors are more specific than the top-level message, so they win.
|
|
34
35
|
*/
|
|
35
36
|
export declare function describeResponseStatus(status: ResponseStatus | null | undefined): string | null;
|
|
37
|
+
/** The service name {@link EdgeBlockedError} messages carry. */
|
|
38
|
+
export declare const SERVICE = "PickUp Patrol";
|
|
39
|
+
/**
|
|
40
|
+
* PickUp Patrol JUDGED the username and password and refused them. Its own
|
|
41
|
+
* class so the healthcheck can say `credential_rejected` without matching
|
|
42
|
+
* message prose.
|
|
43
|
+
*/
|
|
44
|
+
export declare class SignInRejectedError extends McpToolError {
|
|
45
|
+
constructor(message: string, hint: string);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The sign-in "succeeded" but produced no usable session — in practice a
|
|
49
|
+
* two-factor account, which this server does not complete.
|
|
50
|
+
*/
|
|
51
|
+
export declare class UnusableSessionError extends McpToolError {
|
|
52
|
+
constructor(message: string, hint: string);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Was this response refused by a CDN/WAF in front of PickUp Patrol rather than
|
|
56
|
+
* by PickUp Patrol? Reads a CLONE, so the caller's body stays readable. A JSON
|
|
57
|
+
* body is never a refusal page, so it is not read.
|
|
58
|
+
*/
|
|
59
|
+
export declare function edgeBlockOfResponse(res: Response): Promise<{
|
|
60
|
+
vendor: string;
|
|
61
|
+
} | null>;
|
|
36
62
|
/**
|
|
37
63
|
* Owns the login lifecycle: one lazy, single-flight sign-in; a cached
|
|
38
64
|
* *permanent* failure for anything that means "these credentials will never
|
|
@@ -48,6 +74,12 @@ export declare class PickUpPatrolAuth {
|
|
|
48
74
|
private inFlight;
|
|
49
75
|
private permanentError;
|
|
50
76
|
constructor(opts?: AuthOptions);
|
|
77
|
+
/**
|
|
78
|
+
* Where the credentials came from, for the healthcheck — `'env'` when a
|
|
79
|
+
* username and password are configured (from the environment or the
|
|
80
|
+
* constructor), `null` when they are not. Never the credential itself.
|
|
81
|
+
*/
|
|
82
|
+
get credentialSource(): string | null;
|
|
51
83
|
/** True once a login has succeeded — used by the healthcheck tool. */
|
|
52
84
|
get isAuthenticated(): boolean;
|
|
53
85
|
/**
|
|
@@ -69,8 +101,14 @@ export declare class PickUpPatrolAuth {
|
|
|
69
101
|
* ARRAffinity cookie means the jar is never empty), so this is where it is
|
|
70
102
|
* caught, and it is cached as permanent: otherwise every later call would
|
|
71
103
|
* spend two more sign-ins against the account.
|
|
104
|
+
*
|
|
105
|
+
* A 401 that is a CDN/WAF refusal page is NOT a rejected session: the
|
|
106
|
+
* request never reached PickUp Patrol, so the session is kept, no sign-in is
|
|
107
|
+
* spent, and the block is reported as an {@link EdgeBlockedError}
|
|
108
|
+
* (chrischall/mcp-host#1015).
|
|
72
109
|
*/
|
|
73
110
|
withAuth(call: (session: PupSession) => Promise<Response>): Promise<Response>;
|
|
111
|
+
private throwIfEdgeBlocked;
|
|
74
112
|
private login;
|
|
75
113
|
}
|
|
76
114
|
/**
|
package/dist/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readEnvVar, McpToolError, CookieJar } from '@chrischall/mcp-utils';
|
|
1
|
+
import { readEnvVar, McpToolError, CookieJar, EdgeBlockedError, detectEdgeBlock, } 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. */
|
|
@@ -13,6 +13,49 @@ export function describeResponseStatus(status) {
|
|
|
13
13
|
const fieldError = status.Errors?.find((e) => e?.Message);
|
|
14
14
|
return fieldError?.Message ?? status.Message ?? status.ErrorCode ?? null;
|
|
15
15
|
}
|
|
16
|
+
/** The service name {@link EdgeBlockedError} messages carry. */
|
|
17
|
+
export const SERVICE = 'PickUp Patrol';
|
|
18
|
+
/**
|
|
19
|
+
* PickUp Patrol JUDGED the username and password and refused them. Its own
|
|
20
|
+
* class so the healthcheck can say `credential_rejected` without matching
|
|
21
|
+
* message prose.
|
|
22
|
+
*/
|
|
23
|
+
export class SignInRejectedError extends McpToolError {
|
|
24
|
+
constructor(message, hint) {
|
|
25
|
+
super(message, { hint });
|
|
26
|
+
this.name = 'SignInRejectedError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The sign-in "succeeded" but produced no usable session — in practice a
|
|
31
|
+
* two-factor account, which this server does not complete.
|
|
32
|
+
*/
|
|
33
|
+
export class UnusableSessionError extends McpToolError {
|
|
34
|
+
constructor(message, hint) {
|
|
35
|
+
super(message, { hint });
|
|
36
|
+
this.name = 'UnusableSessionError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Was this response refused by a CDN/WAF in front of PickUp Patrol rather than
|
|
41
|
+
* by PickUp Patrol? Reads a CLONE, so the caller's body stays readable. A JSON
|
|
42
|
+
* body is never a refusal page, so it is not read.
|
|
43
|
+
*/
|
|
44
|
+
export async function edgeBlockOfResponse(res) {
|
|
45
|
+
const byHeaders = detectEdgeBlock({ status: res.status, headers: res.headers });
|
|
46
|
+
if (byHeaders !== null)
|
|
47
|
+
return byHeaders;
|
|
48
|
+
if (/json/i.test(String(res.headers.get('content-type'))))
|
|
49
|
+
return null;
|
|
50
|
+
let body;
|
|
51
|
+
try {
|
|
52
|
+
body = await res.clone().text();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return detectEdgeBlock({ body, status: res.status, headers: res.headers });
|
|
58
|
+
}
|
|
16
59
|
/**
|
|
17
60
|
* Owns the login lifecycle: one lazy, single-flight sign-in; a cached
|
|
18
61
|
* *permanent* failure for anything that means "these credentials will never
|
|
@@ -48,6 +91,14 @@ export class PickUpPatrolAuth {
|
|
|
48
91
|
this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
49
92
|
this.timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
50
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Where the credentials came from, for the healthcheck — `'env'` when a
|
|
96
|
+
* username and password are configured (from the environment or the
|
|
97
|
+
* constructor), `null` when they are not. Never the credential itself.
|
|
98
|
+
*/
|
|
99
|
+
get credentialSource() {
|
|
100
|
+
return this.configError ? null : 'env';
|
|
101
|
+
}
|
|
51
102
|
/** True once a login has succeeded — used by the healthcheck tool. */
|
|
52
103
|
get isAuthenticated() {
|
|
53
104
|
return this.session !== null;
|
|
@@ -91,21 +142,32 @@ export class PickUpPatrolAuth {
|
|
|
91
142
|
* ARRAffinity cookie means the jar is never empty), so this is where it is
|
|
92
143
|
* caught, and it is cached as permanent: otherwise every later call would
|
|
93
144
|
* spend two more sign-ins against the account.
|
|
145
|
+
*
|
|
146
|
+
* A 401 that is a CDN/WAF refusal page is NOT a rejected session: the
|
|
147
|
+
* request never reached PickUp Patrol, so the session is kept, no sign-in is
|
|
148
|
+
* spent, and the block is reported as an {@link EdgeBlockedError}
|
|
149
|
+
* (chrischall/mcp-host#1015).
|
|
94
150
|
*/
|
|
95
151
|
async withAuth(call) {
|
|
96
152
|
const first = await call(await this.ensure());
|
|
97
153
|
if (first.status !== 401)
|
|
98
154
|
return first;
|
|
155
|
+
await this.throwIfEdgeBlocked(first);
|
|
99
156
|
this.invalidate();
|
|
100
157
|
const replay = await call(await this.ensure());
|
|
101
158
|
if (replay.status !== 401)
|
|
102
159
|
return replay;
|
|
160
|
+
await this.throwIfEdgeBlocked(replay);
|
|
103
161
|
this.invalidate();
|
|
104
|
-
this.permanentError = new
|
|
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
|
-
});
|
|
162
|
+
this.permanentError = 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, then restart the server.');
|
|
107
163
|
throw this.permanentError;
|
|
108
164
|
}
|
|
165
|
+
async throwIfEdgeBlocked(res) {
|
|
166
|
+
const edge = await edgeBlockOfResponse(res);
|
|
167
|
+
if (edge !== null) {
|
|
168
|
+
throw new EdgeBlockedError(res.status, edge.vendor, { service: SERVICE });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
109
171
|
async login() {
|
|
110
172
|
// Bounded like every other request. `ensure()` shares this promise with
|
|
111
173
|
// every concurrent and later caller until it settles, so an unanswered
|
|
@@ -114,7 +176,7 @@ export class PickUpPatrolAuth {
|
|
|
114
176
|
// body read below.
|
|
115
177
|
const signal = AbortSignal.timeout(this.timeoutMs);
|
|
116
178
|
let res;
|
|
117
|
-
let
|
|
179
|
+
let text;
|
|
118
180
|
try {
|
|
119
181
|
res = await this.fetchImpl(`${BASE_URL}${BASE_PATH}/Authenticate`, {
|
|
120
182
|
method: 'POST',
|
|
@@ -128,11 +190,11 @@ export class PickUpPatrolAuth {
|
|
|
128
190
|
redirect: 'manual',
|
|
129
191
|
signal,
|
|
130
192
|
});
|
|
131
|
-
|
|
193
|
+
text = await res.text().catch((err) => {
|
|
132
194
|
if (signal.aborted)
|
|
133
195
|
throw err;
|
|
134
|
-
return
|
|
135
|
-
})
|
|
196
|
+
return '';
|
|
197
|
+
});
|
|
136
198
|
}
|
|
137
199
|
catch (err) {
|
|
138
200
|
// Transient: a timeout says nothing about the credentials, so it is
|
|
@@ -142,19 +204,41 @@ export class PickUpPatrolAuth {
|
|
|
142
204
|
}
|
|
143
205
|
throw err;
|
|
144
206
|
}
|
|
207
|
+
let body = null;
|
|
208
|
+
try {
|
|
209
|
+
body = JSON.parse(text);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Not JSON: a gateway page, or a CDN/WAF refusal page. A refusal never
|
|
213
|
+
// reached PickUp Patrol, so the credentials were not judged — it is
|
|
214
|
+
// reported as a block and never cached as permanent, so the next call
|
|
215
|
+
// signs in afresh (chrischall/mcp-host#1015).
|
|
216
|
+
const edge = detectEdgeBlock({ body: text, status: res.status, headers: res.headers });
|
|
217
|
+
if (edge !== null) {
|
|
218
|
+
throw new EdgeBlockedError(res.status, edge.vendor, {
|
|
219
|
+
service: SERVICE,
|
|
220
|
+
method: 'POST',
|
|
221
|
+
path: `${BASE_PATH}/Authenticate`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
145
225
|
if (!res.ok) {
|
|
146
226
|
const detail = describeResponseStatus(body?.ResponseStatus);
|
|
147
227
|
const code = body?.ResponseStatus?.ErrorCode ?? '';
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
228
|
+
const message = `PickUp Patrol rejected the sign-in${detail ? `: ${detail}` : ` (HTTP ${res.status})`}`;
|
|
229
|
+
const hint = 'Check PICKUPPATROL_USERNAME and PICKUPPATROL_PASSWORD against https://app.pickuppatrol.net/.';
|
|
230
|
+
// Only a credential the service JUDGED is a rejection. A 5xx or an
|
|
231
|
+
// unexplained 4xx stays a plain error, so the healthcheck reports it by
|
|
232
|
+
// status rather than sending someone to change a working password.
|
|
233
|
+
const judged = res.status >= 400 && res.status < 500 && code.startsWith('LOGIN-ERROR');
|
|
234
|
+
const error = judged ? new SignInRejectedError(message, hint) : new McpToolError(message, { hint });
|
|
151
235
|
// A credential the service has *judged* is never retried. PickUp Patrol
|
|
152
236
|
// counts failures against the account and clearing a lockout goes
|
|
153
237
|
// through their support desk, so a retry loop would destroy the only
|
|
154
238
|
// auth path we have. Caching the error means every later call fails
|
|
155
239
|
// instantly with the same message instead of spending another attempt.
|
|
156
240
|
// A 5xx or a network blip is left transient so the next call retries.
|
|
157
|
-
if (
|
|
241
|
+
if (judged) {
|
|
158
242
|
this.permanentError = error;
|
|
159
243
|
}
|
|
160
244
|
throw error;
|
|
@@ -166,9 +250,7 @@ export class PickUpPatrolAuth {
|
|
|
166
250
|
const cookieHeader = collectCookieHeader(res);
|
|
167
251
|
const bearerToken = body?.BearerToken ?? null;
|
|
168
252
|
if (!bearerToken && !cookieHeader) {
|
|
169
|
-
this.permanentError = new
|
|
170
|
-
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.',
|
|
171
|
-
});
|
|
253
|
+
this.permanentError = new UnusableSessionError('PickUp Patrol accepted the sign-in but returned no session token or cookie', '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.');
|
|
172
254
|
throw this.permanentError;
|
|
173
255
|
}
|
|
174
256
|
return { bearerToken, refreshToken: body?.RefreshToken ?? null, cookieHeader };
|