@chrischall/pickuppatrol-mcp 1.2.0 → 1.2.1

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.2.0"
9
+ "version": "1.2.1"
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.0",
17
+ "version": "1.2.1",
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.0",
4
+ "version": "1.2.1",
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
- "mcp": "./.mcp.json"
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
- /** Exchangeable for a fresh bearer token via `GetAccessToken`. */
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, and it is cached as permanent: otherwise every later call would
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
@@ -3,6 +3,15 @@ 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, and it is cached as permanent: otherwise every later call would
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 first = await call(await this.ensure());
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.invalidate();
157
- const replay = await call(await this.ensure());
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.invalidate();
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.');
163
- throw this.permanentError;
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);