@chrischall/pickuppatrol-mcp 1.0.1 → 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.1"
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.1",
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.1",
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
@@ -58023,6 +58023,7 @@ import { fileURLToPath } from "url";
58023
58023
  // src/auth.ts
58024
58024
  var BASE_URL = "https://app.pickuppatrol.net";
58025
58025
  var BASE_PATH = "/api/json/reply";
58026
+ var REQUEST_TIMEOUT_MS = 3e4;
58026
58027
  function describeResponseStatus(status) {
58027
58028
  if (!status) return null;
58028
58029
  const fieldError = status.Errors?.find((e) => e?.Message);
@@ -58033,6 +58034,7 @@ var PickUpPatrolAuth = class {
58033
58034
  password;
58034
58035
  configError;
58035
58036
  fetchImpl;
58037
+ timeoutMs;
58036
58038
  session = null;
58037
58039
  inFlight = null;
58038
58040
  permanentError = null;
@@ -58054,6 +58056,7 @@ var PickUpPatrolAuth = class {
58054
58056
  this.configError = null;
58055
58057
  }
58056
58058
  this.fetchImpl = opts.fetchImpl ?? ((url4, init) => fetch(url4, init));
58059
+ this.timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
58057
58060
  }
58058
58061
  /** True once a login has succeeded — used by the healthcheck tool. */
58059
58062
  get isAuthenticated() {
@@ -58085,26 +58088,59 @@ var PickUpPatrolAuth = class {
58085
58088
  * once and the call replayed exactly once — never more, so a server that
58086
58089
  * answers 401 unconditionally cannot turn into a login loop against the
58087
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.
58088
58098
  */
58089
58099
  async withAuth(call) {
58090
58100
  const first = await call(await this.ensure());
58091
58101
  if (first.status !== 401) return first;
58092
58102
  this.invalidate();
58093
- 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;
58094
58113
  }
58095
58114
  async login() {
58096
- const res = await this.fetchImpl(`${BASE_URL}${BASE_PATH}/Authenticate`, {
58097
- method: "POST",
58098
- headers: { "Content-Type": "application/json", Accept: "application/json" },
58099
- body: JSON.stringify({
58100
- provider: "credentials",
58101
- UserName: this.username,
58102
- Password: this.password,
58103
- RememberMe: true
58104
- }),
58105
- redirect: "manual"
58106
- });
58107
- 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
+ }
58108
58144
  if (!res.ok) {
58109
58145
  const detail = describeResponseStatus(body?.ResponseStatus);
58110
58146
  const code = body?.ResponseStatus?.ErrorCode ?? "";
@@ -58145,7 +58181,6 @@ try {
58145
58181
  await loadDotenvSafely({ path: join(dir, "..", ".env"), override: false });
58146
58182
  } catch {
58147
58183
  }
58148
- var REQUEST_TIMEOUT_MS = 3e4;
58149
58184
  var PickUpPatrolClient = class {
58150
58185
  auth;
58151
58186
  fetchImpl;
@@ -58190,7 +58225,9 @@ var PickUpPatrolClient = class {
58190
58225
  throw new McpToolError(
58191
58226
  `PickUp Patrol ${dto} failed (HTTP ${res.status})${detail ? `: ${detail}` : ""}`,
58192
58227
  {
58193
- 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
58194
58231
  }
58195
58232
  );
58196
58233
  }
@@ -58279,7 +58316,7 @@ var PickUpPatrolClient = class {
58279
58316
  var client = new PickUpPatrolClient();
58280
58317
 
58281
58318
  // src/version.ts
58282
- var VERSION = "1.0.1";
58319
+ var VERSION = "1.0.2";
58283
58320
 
58284
58321
  // src/dates.ts
58285
58322
  var WEEKDAY_NAMES = [
@@ -58638,8 +58675,27 @@ function previewUnlessConfirmed(confirm, action, method, dto, body) {
58638
58675
  }
58639
58676
 
58640
58677
  // src/tools/plans.ts
58641
- function proofsMatch(a, b) {
58642
- 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;
58643
58699
  }
58644
58700
  function expectedPlanState(requested) {
58645
58701
  return requested.transportationId === null ? { transportationId: null, note: null } : requested;
@@ -58712,14 +58768,18 @@ function registerPlanTools(server, client2) {
58712
58768
  await client2.updatePlans(plans);
58713
58769
  const expected = expectedPlanState({
58714
58770
  transportationId: transportation_id,
58715
- note: plans[0]?.Note ?? null
58771
+ note: plans[0]?.Note ?? null,
58772
+ earlyDismissalTime: plans[0]?.EarlyDismissalTime,
58773
+ carNumber: plans[0]?.CarNumber
58716
58774
  });
58717
58775
  const verification = await Promise.all(
58718
58776
  dates.map(async (date5) => {
58719
58777
  const after = await client2.getPlanEdit(date5, student_id);
58720
58778
  const actual = {
58721
58779
  transportationId: after.TransportationId ?? null,
58722
- note: after.Note ?? null
58780
+ note: after.Note ?? null,
58781
+ earlyDismissalTime: after.EarlyDismissalTime ?? null,
58782
+ carNumber: after.CarNumber ?? null
58723
58783
  };
58724
58784
  return {
58725
58785
  date: date5,
@@ -58858,13 +58918,21 @@ function registerDefaultPlanTools(server, client2) {
58858
58918
  if (gate) return gate;
58859
58919
  await client2.updateStudent(payload);
58860
58920
  const after = await client2.getStudent(student_id);
58861
- const sentNote = payload.DefaultPlans?.find((p) => p.DayId === dayIds[0])?.Note ?? null;
58921
+ const sent = payload.DefaultPlans?.find((p) => p.DayId === dayIds[0]);
58862
58922
  const unchanged = [...new Set(dayIds)].filter((dayId) => {
58863
58923
  const plan = (after.DefaultPlans ?? []).find((p) => p.DayId === dayId);
58864
58924
  if (plan === void 0) return true;
58865
58925
  return !proofsMatch(
58866
- { transportationId: plan.TransportationId ?? null, note: plan.Note ?? null },
58867
- { 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
+ }
58868
58936
  );
58869
58937
  });
58870
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.1";
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.1'; // 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.1",
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)",
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.1",
9
+ "version": "1.0.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/pickuppatrol-mcp",
14
- "version": "1.0.1",
14
+ "version": "1.0.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }