@chrischall/pickuppatrol-mcp 1.1.1 → 1.1.3

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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "PickUp Patrol school-dismissal tools for Claude Code",
9
- "version": "1.1.1"
9
+ "version": "1.1.3"
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.1",
17
+ "version": "1.1.3",
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.1",
4
+ "version": "1.1.3",
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 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
- });
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 body;
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
- body = (await res.json().catch((err) => {
193
+ text = await res.text().catch((err) => {
132
194
  if (signal.aborted)
133
195
  throw err;
134
- return null;
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 error = new McpToolError(`PickUp Patrol rejected the sign-in${detail ? `: ${detail}` : ` (HTTP ${res.status})`}`, {
149
- hint: 'Check PICKUPPATROL_USERNAME and PICKUPPATROL_PASSWORD against https://app.pickuppatrol.net/.',
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 (res.status >= 400 && res.status < 500 && code.startsWith('LOGIN-ERROR')) {
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 McpToolError('PickUp Patrol accepted the sign-in but returned no session token or cookie', {
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 };