@chrischall/pickuppatrol-mcp 1.0.1 → 1.1.0

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.0.1"
9
+ "version": "1.1.0"
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.1",
17
+ "version": "1.1.0",
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.1",
4
+ "version": "1.1.0",
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
@@ -60,8 +60,11 @@ 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 |
62
62
 
63
- **Writes** — every one requires `confirm: true`. Without it the tool makes no
64
- change and returns a dry-run of the exact payload it would send.
63
+ **Writes** — every one asks you to confirm first. A client that can show a
64
+ confirmation prompt (Claude Code) shows one. Otherwise the first call makes no
65
+ change and returns a preview of the exact payload it would send plus a
66
+ `confirmToken`, and only a repeat call with that token makes the change — see
67
+ [Confirmations](#confirmations).
65
68
 
66
69
  | Tool | What it changes |
67
70
  |---|---|
@@ -69,6 +72,20 @@ change and returns a dry-run of the exact payload it would send.
69
72
  | `pup_set_default_plans` | The weekly default plan, or clears every default |
70
73
  | `pup_mark_defaults_reviewed` | The school's "defaults need review" prompt |
71
74
 
75
+ ### Confirmations
76
+
77
+ | variable | default | |
78
+ |---|---|---|
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
+ | `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. |
82
+
83
+ A token is single-use and bound to the exact payload. The second call re-reads
84
+ the student and rebuilds the payload, so if anything moved between the preview
85
+ and the approval — a different date or option, or (for default plans, which
86
+ round-trip the whole student record) a change made to the record meanwhile —
87
+ nothing is sent and you get `DRAFT_CHANGED` with a fresh preview.
88
+
72
89
  ### Two things the tools do that the API does not
73
90
 
74
91
  **Rules are enforced before anything is sent.** Each dismissal option carries its
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
- return call(await this.ensure());
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
- const res = await this.fetchImpl(`${BASE_URL}${BASE_PATH}/Authenticate`, {
93
- method: 'POST',
94
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
95
- body: JSON.stringify({
96
- provider: 'credentials',
97
- UserName: this.username,
98
- Password: this.password,
99
- RememberMe: true,
100
- }),
101
- redirect: 'manual',
102
- });
103
- const body = (await res.json().catch(() => null));
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. Surface that plainly rather than letting
123
- // every subsequent read fail with an opaque 401.
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) {