@chrischall/pickuppatrol-mcp 1.0.0 → 1.0.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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "PickUp Patrol school-dismissal tools for Claude Code",
9
- "version": "1.0.0"
9
+ "version": "1.0.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.0.0",
17
+ "version": "1.0.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.0.0",
4
+ "version": "1.0.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"
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) {
package/dist/bundle.js CHANGED
@@ -23302,6 +23302,20 @@ var require_v4 = __commonJS({
23302
23302
  }
23303
23303
  });
23304
23304
 
23305
+ // node_modules/@chrischall/mcp-utils/dist/cancel/index.js
23306
+ import { AsyncLocalStorage } from "node:async_hooks";
23307
+ var storage = new AsyncLocalStorage();
23308
+ function withCallSignal(signal, fn, request) {
23309
+ return signal ? storage.run({ signal, ...request ? { request } : {} }, fn) : fn();
23310
+ }
23311
+
23312
+ // node_modules/@chrischall/mcp-utils/dist/caller/index.js
23313
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
23314
+ var storage2 = new AsyncLocalStorage2();
23315
+ function withCallerCapabilities(capabilities, fn) {
23316
+ return capabilities ? storage2.run(capabilities, fn) : fn();
23317
+ }
23318
+
23305
23319
  // node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
23306
23320
  var __create2 = Object.create;
23307
23321
  var __defProp2 = Object.defineProperty;
@@ -37977,13 +37991,28 @@ var API_KEY_RE = new RegExp([
37977
37991
  "whsec_[A-Za-z0-9]{16,}\\b"
37978
37992
  // webhook signing secret (Stripe-style)
37979
37993
  ].map((p) => `\\b${p}`).join("|"), "g");
37980
- var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
37994
+ var QUERY_SECRET_RE = /([?&](?:(?:access|refresh|id|auth|session|csrf|xsrf)[_-]?token|client[_-]?secret|api[_-]?secret|api[_-]?key|password|passwd|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
37995
+ var OAUTH_CODE_RE = /([?&]code=)(?=[^&#\s"'<>`]{16,})[^&#\s"'<>`]+/gi;
37996
+ var FORM_PASSWORD_RE = /((?:^|[\s,;:"'(\[{])(?:password|passwd)=)[^&#\s"'<>`]+/gim;
37997
+ var HEADER_SECRET_RE = /(\b(?:x[-_][a-z0-9_-]*?(?:api[-_]?key|token|secret|auth)[a-z0-9_-]*|api[-_]?key)\s*:\s*)[^\s,;"'<>`]+/gi;
37981
37998
  var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
37982
- var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
37983
- var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
37984
- var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
37999
+ var JSON_SECRET_KEYS = [
38000
+ "(?:access|refresh|id|auth|session|bearer|csrf|xsrf)[_-]?token",
38001
+ "client[_-]?secret",
38002
+ "api[_-]?secret",
38003
+ "(?:x[_-])?api[_-]?key",
38004
+ "private[_-]?key",
38005
+ "x[_-][a-z0-9_-]*?(?:api[_-]?key|token|secret|auth)[a-z0-9_-]*",
38006
+ "(?:proxy-)?authorization",
38007
+ "password",
38008
+ "passwd",
38009
+ "secret",
38010
+ "token"
38011
+ ].join("|");
38012
+ var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")(?:[^"\\\\]|\\\\.)*(")`, "gi");
38013
+ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')(?:[^'\\\\]|\\\\.)*(')`, "gi");
37985
38014
  function redactSecrets(text) {
37986
- return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
38015
+ return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(HEADER_SECRET_RE, "$1[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(OAUTH_CODE_RE, "$1[REDACTED]").replace(FORM_PASSWORD_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
37987
38016
  }
37988
38017
  function messageOf(err) {
37989
38018
  if (err instanceof Error)
@@ -57690,17 +57719,73 @@ Hint: ${err.hint}`);
57690
57719
  }
57691
57720
  throw err;
57692
57721
  }
57722
+ function mcpRequestFrom(args) {
57723
+ const ctx = args.at(-1);
57724
+ if (typeof ctx !== "object" || ctx === null)
57725
+ return void 0;
57726
+ const req = ctx.mcpReq;
57727
+ if (typeof req !== "object" || req === null)
57728
+ return void 0;
57729
+ return typeof req.notify === "function" ? req : void 0;
57730
+ }
57731
+ function callSignalFrom(args) {
57732
+ const ctx = args.at(-1);
57733
+ if (typeof ctx !== "object" || ctx === null)
57734
+ return void 0;
57735
+ const req = ctx.mcpReq;
57736
+ if (typeof req !== "object" || req === null)
57737
+ return void 0;
57738
+ const signal = req.signal;
57739
+ return signal instanceof AbortSignal ? signal : void 0;
57740
+ }
57741
+ function declaredCapabilitiesFrom(server) {
57742
+ const inner = server.server;
57743
+ if (typeof inner !== "object" || inner === null)
57744
+ return void 0;
57745
+ const read = inner.getClientCapabilities;
57746
+ if (typeof read !== "function")
57747
+ return void 0;
57748
+ try {
57749
+ const declared = read.call(inner);
57750
+ return typeof declared === "object" && declared !== null ? declared : void 0;
57751
+ } catch {
57752
+ return void 0;
57753
+ }
57754
+ }
57693
57755
  function surfaceToolHints(server) {
57694
57756
  const register2 = server.registerTool.bind(server);
57695
- server.registerTool = (name, config2, cb) => register2(name, config2, (...args) => {
57696
- let result;
57697
- try {
57698
- result = cb(...args);
57699
- } catch (err) {
57700
- return hintResultOrRethrow(err);
57701
- }
57702
- return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
57703
- });
57757
+ server.registerTool = (name, config2, cb) => register2(name, config2, (...args) => (
57758
+ // THE CALLER'S CANCELLATION, made ambient for the whole handler
57759
+ // (`cancel/index.ts`). The SDK delivers it and the fleet ignored it:
57760
+ // measured against @modelcontextprotocol/server 2.0.0, a cancelled
57761
+ // call aborts `ctx.mcpReq.signal` with the caller's reason and the
57762
+ // handler runs to completion regardless — so the HTTP request stays
57763
+ // in flight, the child keeps burning metered CPU, and the upstream
57764
+ // keeps being hit for somebody who has gone. claude.ai sent 101
57765
+ // cancellations in the week to 2026-09-20.
57766
+ //
57767
+ // Here because this is the one wrapper every tool already passes
57768
+ // through: threading the signal by hand would mean editing several
57769
+ // hundred handlers and missing exactly the ones nobody edits.
57770
+ withCallSignal(callSignalFrom(args), () => (
57771
+ // WHAT THE CALLER CAN DO, made ambient for the same reason and in
57772
+ // the same place (`caller/index.ts`). Only the 2025-era half needs
57773
+ // the wrapper — a 2026-07-28 request answers for itself off its own
57774
+ // envelope — but a guarded tool that cannot tell whether the caller
57775
+ // can be shown a prompt returns one the SDK then refuses to deliver,
57776
+ // and that refusal reaches the caller as an unexplained protocol
57777
+ // error it had no chance to catch.
57778
+ withCallerCapabilities(declaredCapabilitiesFrom(server), () => {
57779
+ let result;
57780
+ try {
57781
+ result = cb(...args);
57782
+ } catch (err) {
57783
+ return hintResultOrRethrow(err);
57784
+ }
57785
+ return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
57786
+ })
57787
+ ), mcpRequestFrom(args))
57788
+ ));
57704
57789
  }
57705
57790
  async function createMcpServer(opts) {
57706
57791
  const server = new McpServer({ name: opts.name, version: opts.version }, { supportedProtocolVersions: [...SERVER_PROTOCOL_VERSIONS] });
@@ -57715,13 +57800,47 @@ async function createMcpServer(opts) {
57715
57800
  }
57716
57801
  return server;
57717
57802
  }
57803
+ var DEFAULT_SHUTDOWN_TIMEOUT_MS = 5e3;
57804
+ var DEFAULT_REPEAT_SIGNAL_GRACE_MS = 500;
57718
57805
  function withGracefulShutdown(target, opts = {}) {
57719
57806
  const shouldExit = opts.exit ?? true;
57807
+ if (opts.timeoutMs !== void 0 && !(Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0)) {
57808
+ throw new RangeError("withGracefulShutdown: timeoutMs must be a finite number greater than 0.");
57809
+ }
57810
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
57811
+ if (opts.repeatSignalGraceMs !== void 0 && !(Number.isFinite(opts.repeatSignalGraceMs) && opts.repeatSignalGraceMs >= 0)) {
57812
+ throw new RangeError("withGracefulShutdown: repeatSignalGraceMs must be a finite number >= 0.");
57813
+ }
57814
+ const repeatGraceMs = opts.repeatSignalGraceMs ?? DEFAULT_REPEAT_SIGNAL_GRACE_MS;
57720
57815
  let shuttingDown = false;
57816
+ let firstSignalAt = 0;
57817
+ let exited = false;
57818
+ const exitOnce = () => {
57819
+ if (exited)
57820
+ return;
57821
+ exited = true;
57822
+ process.exit(0);
57823
+ };
57721
57824
  const handler = (signal) => {
57722
- if (shuttingDown)
57825
+ if (shuttingDown) {
57826
+ if (Date.now() - firstSignalAt < repeatGraceMs)
57827
+ return;
57828
+ if (shouldExit) {
57829
+ console.error(`[mcp-utils] second ${signal} during shutdown \u2014 exiting now`);
57830
+ exitOnce();
57831
+ }
57723
57832
  return;
57833
+ }
57724
57834
  shuttingDown = true;
57835
+ firstSignalAt = Date.now();
57836
+ let timer;
57837
+ if (shouldExit) {
57838
+ timer = setTimeout(() => {
57839
+ console.error(`[mcp-utils] graceful shutdown on ${signal} did not finish in ${timeoutMs}ms \u2014 exiting`);
57840
+ exitOnce();
57841
+ }, timeoutMs);
57842
+ timer.unref?.();
57843
+ }
57725
57844
  void (async () => {
57726
57845
  try {
57727
57846
  if (opts.onSignal)
@@ -57730,8 +57849,10 @@ function withGracefulShutdown(target, opts = {}) {
57730
57849
  } catch (err) {
57731
57850
  console.error(`[mcp-utils] error during graceful shutdown on ${signal}: ${err instanceof Error ? err.message : String(err)}`);
57732
57851
  } finally {
57852
+ if (timer !== void 0)
57853
+ clearTimeout(timer);
57733
57854
  if (shouldExit)
57734
- process.exit(0);
57855
+ exitOnce();
57735
57856
  }
57736
57857
  })();
57737
57858
  };
@@ -57902,6 +58023,7 @@ import { fileURLToPath } from "url";
57902
58023
  // src/auth.ts
57903
58024
  var BASE_URL = "https://app.pickuppatrol.net";
57904
58025
  var BASE_PATH = "/api/json/reply";
58026
+ var REQUEST_TIMEOUT_MS = 3e4;
57905
58027
  function describeResponseStatus(status) {
57906
58028
  if (!status) return null;
57907
58029
  const fieldError = status.Errors?.find((e) => e?.Message);
@@ -57912,6 +58034,7 @@ var PickUpPatrolAuth = class {
57912
58034
  password;
57913
58035
  configError;
57914
58036
  fetchImpl;
58037
+ timeoutMs;
57915
58038
  session = null;
57916
58039
  inFlight = null;
57917
58040
  permanentError = null;
@@ -57933,6 +58056,7 @@ var PickUpPatrolAuth = class {
57933
58056
  this.configError = null;
57934
58057
  }
57935
58058
  this.fetchImpl = opts.fetchImpl ?? ((url4, init) => fetch(url4, init));
58059
+ this.timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
57936
58060
  }
57937
58061
  /** True once a login has succeeded — used by the healthcheck tool. */
57938
58062
  get isAuthenticated() {
@@ -57964,26 +58088,59 @@ var PickUpPatrolAuth = class {
57964
58088
  * once and the call replayed exactly once — never more, so a server that
57965
58089
  * answers 401 unconditionally cannot turn into a login loop against the
57966
58090
  * account.
58091
+ *
58092
+ * If the session minted for the replay is rejected too, the sign-in is
58093
+ * "succeeding" without producing a usable session — in practice a
58094
+ * two-factor account. The login-time check cannot see that (Azure's
58095
+ * ARRAffinity cookie means the jar is never empty), so this is where it is
58096
+ * caught, and it is cached as permanent: otherwise every later call would
58097
+ * spend two more sign-ins against the account.
57967
58098
  */
57968
58099
  async withAuth(call) {
57969
58100
  const first = await call(await this.ensure());
57970
58101
  if (first.status !== 401) return first;
57971
58102
  this.invalidate();
57972
- return call(await this.ensure());
58103
+ const replay = await call(await this.ensure());
58104
+ if (replay.status !== 401) return replay;
58105
+ this.invalidate();
58106
+ this.permanentError = new McpToolError(
58107
+ "PickUp Patrol accepted the sign-in but rejected the session it just issued",
58108
+ {
58109
+ 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."
58110
+ }
58111
+ );
58112
+ throw this.permanentError;
57973
58113
  }
57974
58114
  async login() {
57975
- const res = await this.fetchImpl(`${BASE_URL}${BASE_PATH}/Authenticate`, {
57976
- method: "POST",
57977
- headers: { "Content-Type": "application/json", Accept: "application/json" },
57978
- body: JSON.stringify({
57979
- provider: "credentials",
57980
- UserName: this.username,
57981
- Password: this.password,
57982
- RememberMe: true
57983
- }),
57984
- redirect: "manual"
57985
- });
57986
- const body = await res.json().catch(() => null);
58115
+ const signal = AbortSignal.timeout(this.timeoutMs);
58116
+ let res;
58117
+ let body;
58118
+ try {
58119
+ res = await this.fetchImpl(`${BASE_URL}${BASE_PATH}/Authenticate`, {
58120
+ method: "POST",
58121
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
58122
+ body: JSON.stringify({
58123
+ provider: "credentials",
58124
+ UserName: this.username,
58125
+ Password: this.password,
58126
+ RememberMe: true
58127
+ }),
58128
+ redirect: "manual",
58129
+ signal
58130
+ });
58131
+ body = await res.json().catch((err) => {
58132
+ if (signal.aborted) throw err;
58133
+ return null;
58134
+ });
58135
+ } catch (err) {
58136
+ if (signal.aborted) {
58137
+ throw new McpToolError(
58138
+ `PickUp Patrol did not answer the sign-in within ${this.timeoutMs / 1e3}s`,
58139
+ { hint: "The service may be slow or down. Try again shortly." }
58140
+ );
58141
+ }
58142
+ throw err;
58143
+ }
57987
58144
  if (!res.ok) {
57988
58145
  const detail = describeResponseStatus(body?.ResponseStatus);
57989
58146
  const code = body?.ResponseStatus?.ErrorCode ?? "";
@@ -58024,7 +58181,6 @@ try {
58024
58181
  await loadDotenvSafely({ path: join(dir, "..", ".env"), override: false });
58025
58182
  } catch {
58026
58183
  }
58027
- var REQUEST_TIMEOUT_MS = 3e4;
58028
58184
  var PickUpPatrolClient = class {
58029
58185
  auth;
58030
58186
  fetchImpl;
@@ -58069,7 +58225,9 @@ var PickUpPatrolClient = class {
58069
58225
  throw new McpToolError(
58070
58226
  `PickUp Patrol ${dto} failed (HTTP ${res.status})${detail ? `: ${detail}` : ""}`,
58071
58227
  {
58072
- hint: res.status === 401 || res.status === 403 ? "The session was rejected. Check PICKUPPATROL_USERNAME and PICKUPPATROL_PASSWORD." : void 0
58228
+ // A 401 never gets here: withAuth() re-signs-in once and turns a
58229
+ // second 401 into a permanent unusable-session error.
58230
+ hint: res.status === 403 ? "The session was rejected. Check PICKUPPATROL_USERNAME and PICKUPPATROL_PASSWORD." : void 0
58073
58231
  }
58074
58232
  );
58075
58233
  }
@@ -58158,7 +58316,7 @@ var PickUpPatrolClient = class {
58158
58316
  var client = new PickUpPatrolClient();
58159
58317
 
58160
58318
  // src/version.ts
58161
- var VERSION = "1.0.0";
58319
+ var VERSION = "1.0.2";
58162
58320
 
58163
58321
  // src/dates.ts
58164
58322
  var WEEKDAY_NAMES = [
@@ -58517,8 +58675,27 @@ function previewUnlessConfirmed(confirm, action, method, dto, body) {
58517
58675
  }
58518
58676
 
58519
58677
  // src/tools/plans.ts
58520
- function proofsMatch(a, b) {
58521
- return a.transportationId === b.transportationId && (a.note ?? "").trim() === (b.note ?? "").trim();
58678
+ function normalizeTimeOfDay(time3) {
58679
+ const trimmed = time3.trim();
58680
+ const pad = (n) => (n ?? "0").padStart(2, "0");
58681
+ const duration3 = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)(?:\.\d+)?S)?$/.exec(trimmed);
58682
+ if (duration3 && trimmed !== "PT") return `${pad(duration3[1])}:${pad(duration3[2])}:${pad(duration3[3])}`;
58683
+ const clock = /(?:^|T)(\d{1,2}):(\d{2})(?::(\d{2}))?/.exec(trimmed);
58684
+ if (clock) return `${pad(clock[1])}:${pad(clock[2])}:${pad(clock[3])}`;
58685
+ return trimmed;
58686
+ }
58687
+ function proofsMatch(actual, expected) {
58688
+ if (actual.transportationId !== expected.transportationId) return false;
58689
+ if ((actual.note ?? "").trim() !== (expected.note ?? "").trim()) return false;
58690
+ if (expected.earlyDismissalTime !== void 0) {
58691
+ const want = expected.earlyDismissalTime === null ? "" : normalizeTimeOfDay(expected.earlyDismissalTime);
58692
+ const got = actual.earlyDismissalTime ? normalizeTimeOfDay(actual.earlyDismissalTime) : "";
58693
+ if (want !== got) return false;
58694
+ }
58695
+ if (expected.carNumber !== void 0) {
58696
+ if ((actual.carNumber ?? "").trim() !== (expected.carNumber ?? "").trim()) return false;
58697
+ }
58698
+ return true;
58522
58699
  }
58523
58700
  function expectedPlanState(requested) {
58524
58701
  return requested.transportationId === null ? { transportationId: null, note: null } : requested;
@@ -58591,14 +58768,18 @@ function registerPlanTools(server, client2) {
58591
58768
  await client2.updatePlans(plans);
58592
58769
  const expected = expectedPlanState({
58593
58770
  transportationId: transportation_id,
58594
- note: plans[0]?.Note ?? null
58771
+ note: plans[0]?.Note ?? null,
58772
+ earlyDismissalTime: plans[0]?.EarlyDismissalTime,
58773
+ carNumber: plans[0]?.CarNumber
58595
58774
  });
58596
58775
  const verification = await Promise.all(
58597
58776
  dates.map(async (date5) => {
58598
58777
  const after = await client2.getPlanEdit(date5, student_id);
58599
58778
  const actual = {
58600
58779
  transportationId: after.TransportationId ?? null,
58601
- note: after.Note ?? null
58780
+ note: after.Note ?? null,
58781
+ earlyDismissalTime: after.EarlyDismissalTime ?? null,
58782
+ carNumber: after.CarNumber ?? null
58602
58783
  };
58603
58784
  return {
58604
58785
  date: date5,
@@ -58737,13 +58918,21 @@ function registerDefaultPlanTools(server, client2) {
58737
58918
  if (gate) return gate;
58738
58919
  await client2.updateStudent(payload);
58739
58920
  const after = await client2.getStudent(student_id);
58740
- const sentNote = payload.DefaultPlans?.find((p) => p.DayId === dayIds[0])?.Note ?? null;
58921
+ const sent = payload.DefaultPlans?.find((p) => p.DayId === dayIds[0]);
58741
58922
  const unchanged = [...new Set(dayIds)].filter((dayId) => {
58742
58923
  const plan = (after.DefaultPlans ?? []).find((p) => p.DayId === dayId);
58743
58924
  if (plan === void 0) return true;
58744
58925
  return !proofsMatch(
58745
- { transportationId: plan.TransportationId ?? null, note: plan.Note ?? null },
58746
- { transportationId: transportation.TransportationId, note: sentNote }
58926
+ {
58927
+ transportationId: plan.TransportationId ?? null,
58928
+ note: plan.Note ?? null,
58929
+ earlyDismissalTime: plan.EarlyDismissalTime ?? null
58930
+ },
58931
+ {
58932
+ transportationId: transportation.TransportationId,
58933
+ note: sent?.Note ?? null,
58934
+ earlyDismissalTime: sent?.EarlyDismissalTime ?? void 0
58935
+ }
58747
58936
  );
58748
58937
  });
58749
58938
  return minifiedResult({
package/dist/client.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { dirname, join } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { loadDotenvSafely, McpToolError, buildQueryString } from '@chrischall/mcp-utils';
4
- import { PickUpPatrolAuth, BASE_URL, BASE_PATH, describeResponseStatus } from './auth.js';
4
+ import { PickUpPatrolAuth, BASE_URL, BASE_PATH, REQUEST_TIMEOUT_MS, describeResponseStatus, } from './auth.js';
5
5
  // Load .env for local dev; silently skip when dotenv is unavailable (the mcpb
6
6
  // bundle externalises it). The try/catch guards a runtime where
7
7
  // `import.meta.url` is undefined and `fileURLToPath` would throw at module
@@ -13,7 +13,6 @@ try {
13
13
  catch {
14
14
  /* non-Node runtime: no .env to load */
15
15
  }
16
- const REQUEST_TIMEOUT_MS = 30_000;
17
16
  /**
18
17
  * Thin typed client over PickUp Patrol's ServiceStack API.
19
18
  *
@@ -68,7 +67,9 @@ export class PickUpPatrolClient {
68
67
  detail = null;
69
68
  }
70
69
  throw new McpToolError(`PickUp Patrol ${dto} failed (HTTP ${res.status})${detail ? `: ${detail}` : ''}`, {
71
- hint: res.status === 401 || res.status === 403
70
+ // A 401 never gets here: withAuth() re-signs-in once and turns a
71
+ // second 401 into a permanent unusable-session error.
72
+ hint: res.status === 403
72
73
  ? 'The session was rejected. Check PICKUPPATROL_USERNAME and PICKUPPATROL_PASSWORD.'
73
74
  : undefined,
74
75
  });
@@ -131,12 +131,22 @@ export function registerDefaultPlanTools(server, client) {
131
131
  // the comparison entirely: it advances by itself, which would make every
132
132
  // write look successful.
133
133
  const after = await client.getStudent(student_id);
134
- const sentNote = payload.DefaultPlans?.find((p) => p.DayId === dayIds[0])?.Note ?? null;
134
+ // The early-dismissal time is part of the proof when one was sent: a
135
+ // time-only change keeps the option and note identical.
136
+ const sent = payload.DefaultPlans?.find((p) => p.DayId === dayIds[0]);
135
137
  const unchanged = [...new Set(dayIds)].filter((dayId) => {
136
138
  const plan = (after.DefaultPlans ?? []).find((p) => p.DayId === dayId);
137
139
  if (plan === undefined)
138
140
  return true;
139
- return !proofsMatch({ transportationId: plan.TransportationId ?? null, note: plan.Note ?? null }, { transportationId: transportation.TransportationId, note: sentNote });
141
+ return !proofsMatch({
142
+ transportationId: plan.TransportationId ?? null,
143
+ note: plan.Note ?? null,
144
+ earlyDismissalTime: plan.EarlyDismissalTime ?? null,
145
+ }, {
146
+ transportationId: transportation.TransportationId,
147
+ note: sent?.Note ?? null,
148
+ earlyDismissalTime: sent?.EarlyDismissalTime ?? undefined,
149
+ });
140
150
  });
141
151
  return minifiedResult({
142
152
  action,
@@ -1,13 +1,34 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
2
  import type { PickUpPatrolClient } from '../client.js';
3
3
  import type { Transportation } from '../types.js';
4
- /** The fields whose change proves a plan write actually landed. */
4
+ /**
5
+ * The fields whose change proves a plan write actually landed.
6
+ *
7
+ * `earlyDismissalTime` and `carNumber` are part of the proof whenever the
8
+ * write sent them: moving an early dismissal from 14:30 to 13:00 keeps the
9
+ * option and note identical, so without the time a silently dropped write
10
+ * would read back as verified. Left `undefined` on the expected side, they
11
+ * are not compared — the write did not carry them.
12
+ */
5
13
  export interface PlanProof {
6
14
  transportationId: number | null;
7
15
  note: string | null;
16
+ earlyDismissalTime?: string | null | undefined;
17
+ carNumber?: string | null | undefined;
8
18
  }
9
- /** Compare two proofs, ignoring whitespace the service may normalise off a note. */
10
- export declare function proofsMatch(a: PlanProof, b: PlanProof): boolean;
19
+ /**
20
+ * Reduce a time of day to `HH:MM:SS` so a read-back can be compared with what
21
+ * was sent. Accepts `H:MM`, `HH:MM:SS`, an ISO date-time, and the XSD duration
22
+ * ServiceStack uses for a `TimeSpan` (`PT13H30M`). Anything unrecognised is
23
+ * returned trimmed, so it can only ever compare unequal — a false "unchanged"
24
+ * is recoverable, a false "verified" is not.
25
+ */
26
+ export declare function normalizeTimeOfDay(time: string): string;
27
+ /**
28
+ * Compare a read-back proof with the expected one, ignoring whitespace the
29
+ * service may normalise off a note or car number.
30
+ */
31
+ export declare function proofsMatch(actual: PlanProof, expected: PlanProof): boolean;
11
32
  /**
12
33
  * What `GetPlanEdit` should report once a write has landed.
13
34
  *
@@ -3,9 +3,44 @@ import { McpToolError, minifiedResult } from '@chrischall/mcp-utils';
3
3
  import { buildPlanUpdates } from '../plans.js';
4
4
  import { weekdayOf } from '../dates.js';
5
5
  import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
6
- /** Compare two proofs, ignoring whitespace the service may normalise off a note. */
7
- export function proofsMatch(a, b) {
8
- return a.transportationId === b.transportationId && (a.note ?? '').trim() === (b.note ?? '').trim();
6
+ /**
7
+ * Reduce a time of day to `HH:MM:SS` so a read-back can be compared with what
8
+ * was sent. Accepts `H:MM`, `HH:MM:SS`, an ISO date-time, and the XSD duration
9
+ * ServiceStack uses for a `TimeSpan` (`PT13H30M`). Anything unrecognised is
10
+ * returned trimmed, so it can only ever compare unequal — a false "unchanged"
11
+ * is recoverable, a false "verified" is not.
12
+ */
13
+ export function normalizeTimeOfDay(time) {
14
+ const trimmed = time.trim();
15
+ const pad = (n) => (n ?? '0').padStart(2, '0');
16
+ const duration = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)(?:\.\d+)?S)?$/.exec(trimmed);
17
+ if (duration && trimmed !== 'PT')
18
+ return `${pad(duration[1])}:${pad(duration[2])}:${pad(duration[3])}`;
19
+ const clock = /(?:^|T)(\d{1,2}):(\d{2})(?::(\d{2}))?/.exec(trimmed);
20
+ if (clock)
21
+ return `${pad(clock[1])}:${pad(clock[2])}:${pad(clock[3])}`;
22
+ return trimmed;
23
+ }
24
+ /**
25
+ * Compare a read-back proof with the expected one, ignoring whitespace the
26
+ * service may normalise off a note or car number.
27
+ */
28
+ export function proofsMatch(actual, expected) {
29
+ if (actual.transportationId !== expected.transportationId)
30
+ return false;
31
+ if ((actual.note ?? '').trim() !== (expected.note ?? '').trim())
32
+ return false;
33
+ if (expected.earlyDismissalTime !== undefined) {
34
+ const want = expected.earlyDismissalTime === null ? '' : normalizeTimeOfDay(expected.earlyDismissalTime);
35
+ const got = actual.earlyDismissalTime ? normalizeTimeOfDay(actual.earlyDismissalTime) : '';
36
+ if (want !== got)
37
+ return false;
38
+ }
39
+ if (expected.carNumber !== undefined) {
40
+ if ((actual.carNumber ?? '').trim() !== (expected.carNumber ?? '').trim())
41
+ return false;
42
+ }
43
+ return true;
9
44
  }
10
45
  /**
11
46
  * What `GetPlanEdit` should report once a write has landed.
@@ -104,7 +139,8 @@ export function registerPlanTools(server, client) {
104
139
  return gate;
105
140
  await client.updatePlans(plans);
106
141
  // A 2xx is not proof the change persisted — re-read each date and
107
- // compare the one field that proves it. ModifiedDate is deliberately not
142
+ // compare the fields that prove it (option, note, and the time / car
143
+ // number when sent). ModifiedDate is deliberately not
108
144
  // compared: it advances on its own, which would make every write look
109
145
  // successful.
110
146
  // One expectation for the whole call: every date in a single UpdatePlans
@@ -112,12 +148,16 @@ export function registerPlanTools(server, client) {
112
148
  const expected = expectedPlanState({
113
149
  transportationId: transportation_id,
114
150
  note: plans[0]?.Note ?? null,
151
+ earlyDismissalTime: plans[0]?.EarlyDismissalTime,
152
+ carNumber: plans[0]?.CarNumber,
115
153
  });
116
154
  const verification = await Promise.all(dates.map(async (date) => {
117
155
  const after = await client.getPlanEdit(date, student_id);
118
156
  const actual = {
119
157
  transportationId: after.TransportationId ?? null,
120
158
  note: after.Note ?? null,
159
+ earlyDismissalTime: after.EarlyDismissalTime ?? null,
160
+ carNumber: after.CarNumber ?? null,
121
161
  };
122
162
  return {
123
163
  date,
package/dist/version.d.ts CHANGED
@@ -7,4 +7,4 @@
7
7
  * annotation, so there is exactly one line to keep in sync and
8
8
  * `versionSyncTest` has exactly one line to check.
9
9
  */
10
- export declare const VERSION = "1.0.0";
10
+ export declare const VERSION = "1.0.2";
package/dist/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  * annotation, so there is exactly one line to keep in sync and
8
8
  * `versionSyncTest` has exactly one line to check.
9
9
  */
10
- export const VERSION = '1.0.0'; // x-release-please-version
10
+ export const VERSION = '1.0.2'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/pickuppatrol-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "license": "MIT",
5
5
  "mcpName": "io.github.chrischall/pickuppatrol-mcp",
6
6
  "description": "PickUp Patrol MCP server for Claude — developed and maintained by AI (Claude Code)",
@@ -57,10 +57,10 @@
57
57
  "test:watch": "vitest"
58
58
  },
59
59
  "dependencies": {
60
- "@chrischall/mcp-utils": "^1.0.0",
60
+ "@chrischall/mcp-utils": "^2.4.0",
61
61
  "@modelcontextprotocol/server": "^2.0.0",
62
62
  "dotenv": "^17.4.0",
63
- "zod": "^4.6.2"
63
+ "zod": "^4.6.5"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@modelcontextprotocol/client": "^2.0.0",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/pickuppatrol-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.0.0",
9
+ "version": "1.0.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/pickuppatrol-mcp",
14
- "version": "1.0.0",
14
+ "version": "1.0.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }