@chrischall/pickuppatrol-mcp 1.1.2 → 1.1.4

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.
package/dist/client.d.ts CHANGED
@@ -17,6 +17,8 @@ export declare class PickUpPatrolClient {
17
17
  private readonly auth;
18
18
  private readonly fetchImpl;
19
19
  constructor(opts?: ClientOptions);
20
+ /** Where the credentials came from (`'env'`), or `null` — for the healthcheck. */
21
+ credentialSource(): string | null;
20
22
  /** Request a DTO by name. GET args go on the query string, others in the body. */
21
23
  call<T>(method: 'GET' | 'POST' | 'PUT' | 'PATCH', dto: string, args?: Record<string, unknown>): Promise<T>;
22
24
  private headers;
package/dist/client.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { dirname, join } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
- import { loadDotenvSafely, McpToolError, buildQueryString } from '@chrischall/mcp-utils';
4
- import { PickUpPatrolAuth, BASE_URL, BASE_PATH, REQUEST_TIMEOUT_MS, describeResponseStatus, } from './auth.js';
3
+ import { loadDotenvSafely, McpToolError, buildQueryString, EdgeBlockedError, detectEdgeBlock, } from '@chrischall/mcp-utils';
4
+ import { PickUpPatrolAuth, BASE_URL, BASE_PATH, REQUEST_TIMEOUT_MS, SERVICE, 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
@@ -29,6 +29,10 @@ export class PickUpPatrolClient {
29
29
  this.auth = opts.auth ?? new PickUpPatrolAuth(opts);
30
30
  this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
31
31
  }
32
+ /** Where the credentials came from (`'env'`), or `null` — for the healthcheck. */
33
+ credentialSource() {
34
+ return this.auth.credentialSource;
35
+ }
32
36
  /** Request a DTO by name. GET args go on the query string, others in the body. */
33
37
  async call(method, dto, args) {
34
38
  const query = method === 'GET' && args ? buildQueryString(args) : '';
@@ -39,7 +43,7 @@ export class PickUpPatrolClient {
39
43
  body: method === 'GET' || args === undefined ? undefined : JSON.stringify(args),
40
44
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
41
45
  }));
42
- return this.parse(res, dto);
46
+ return this.parse(res, method, dto);
43
47
  }
44
48
  headers(session) {
45
49
  const headers = {
@@ -52,9 +56,19 @@ export class PickUpPatrolClient {
52
56
  headers['Cookie'] = session.cookieHeader;
53
57
  return headers;
54
58
  }
55
- async parse(res, dto) {
59
+ async parse(res, method, dto) {
56
60
  const text = await res.text();
57
61
  if (!res.ok) {
62
+ // A CDN/WAF refusal page never reached PickUp Patrol: say so, rather than
63
+ // blaming the credentials (chrischall/mcp-host#1015).
64
+ const edge = detectEdgeBlock({ body: text, status: res.status, headers: res.headers });
65
+ if (edge !== null) {
66
+ throw new EdgeBlockedError(res.status, edge.vendor, {
67
+ service: SERVICE,
68
+ method,
69
+ path: `${BASE_PATH}/${dto}`,
70
+ });
71
+ }
58
72
  // ServiceStack returns its error envelope as JSON, but a gateway or an
59
73
  // auth redirect can return HTML — so the parse is best-effort and the
60
74
  // status is always part of the message.
@@ -6,3 +6,12 @@ export declare function summarizeStudent(student: Student): Record<string, unkno
6
6
  /** Order the weekly defaults Sunday→Saturday and label each day. */
7
7
  export declare function summarizeDefaultPlans(plans: DefaultPlan[] | null | undefined): unknown[];
8
8
  export declare function registerAccountTools(server: McpServer, client: PickUpPatrolClient): void;
9
+ /**
10
+ * Name the PickUp Patrol failures the shared status ladder cannot see: its
11
+ * sign-in rejection and two-factor errors carry no HTTP status. Anything else
12
+ * (an edge block, a timeout, a 5xx) falls through to the shared ladder.
13
+ */
14
+ export declare function classifyPupError(err: unknown): {
15
+ kind: string;
16
+ hint?: string;
17
+ } | undefined;
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
- import { messageOf, minifiedResult } from '@chrischall/mcp-utils';
2
+ import { minifiedResult } from '@chrischall/mcp-utils';
3
+ import { runCredentialHealthcheck } from '@chrischall/mcp-utils/healthcheck';
4
+ import { SignInRejectedError, UnusableSessionError } from '../auth.js';
3
5
  import { dayIdToName } from '../dates.js';
4
6
  import { VERSION } from '../version.js';
5
7
  /** Project a student down to the fields a parent actually asks about. */
@@ -75,23 +77,47 @@ export function registerAccountTools(server, client) {
75
77
  return minifiedResult(raw === true ? student : summarizeStudent(student));
76
78
  });
77
79
  server.registerTool('pup_healthcheck', {
78
- description: 'Verify the configured credentials sign in and the PickUp Patrol API answers. Reports the server version and the students the account can see.',
80
+ description: "Verify the configured credentials sign in and the PickUp Patrol API answers. Reports the server version, the students the account can see, and — when it fails — an error.kind saying which hop broke: no_credential (nothing configured), credential_rejected (PickUp Patrol refused the username/password), verification_pending (a two-factor account), edge_blocked (a CDN/WAF refused the request before PickUp Patrol saw it, so the credentials were never judged), timeout, transport or http. Read-only; never returns the credentials.",
79
81
  annotations: { readOnlyHint: true },
80
82
  }, async () => {
81
- try {
82
- const session = await client.getSession();
83
- return minifiedResult({
84
- ok: true,
85
- version: VERSION,
86
- signedInAs: session.Email ?? session.PrimaryEmail ?? session.DisplayName ?? null,
87
- studentCount: session.Children?.length ?? 0,
88
- });
89
- }
90
- catch (err) {
91
- // A healthcheck reports rather than throws: the whole point is to say
92
- // what is wrong, and an exception here reads to the host as the tool
93
- // itself being broken.
94
- return minifiedResult({ ok: false, version: VERSION, error: messageOf(err) });
95
- }
83
+ let session;
84
+ // A healthcheck reports rather than throws: the whole point is to say
85
+ // what is wrong, and an exception here reads to the host as the tool
86
+ // itself being broken. The shared ladder supplies the `kind`
87
+ // (chrischall/mcp-host#1015); the account facts ride beside it.
88
+ const shared = await runCredentialHealthcheck({
89
+ server,
90
+ prefix: 'pup',
91
+ hostLabel: 'app.pickuppatrol.net',
92
+ probePath: '/api/json/reply/GetSession',
93
+ resolveCredential: async () => ({ source: client.credentialSource() }),
94
+ probeFn: async () => {
95
+ session = await client.getSession();
96
+ },
97
+ classifyThrown: classifyPupError,
98
+ });
99
+ const result = JSON.parse(shared.content[0].text);
100
+ return minifiedResult({
101
+ ...result,
102
+ version: VERSION,
103
+ ...(session !== undefined
104
+ ? {
105
+ signedInAs: session.Email ?? session.PrimaryEmail ?? session.DisplayName ?? null,
106
+ studentCount: session.Children?.length ?? 0,
107
+ }
108
+ : {}),
109
+ });
96
110
  });
97
111
  }
112
+ /**
113
+ * Name the PickUp Patrol failures the shared status ladder cannot see: its
114
+ * sign-in rejection and two-factor errors carry no HTTP status. Anything else
115
+ * (an edge block, a timeout, a 5xx) falls through to the shared ladder.
116
+ */
117
+ export function classifyPupError(err) {
118
+ if (err instanceof SignInRejectedError)
119
+ return { kind: 'credential_rejected', hint: err.hint };
120
+ if (err instanceof UnusableSessionError)
121
+ return { kind: 'verification_pending', hint: err.hint };
122
+ return undefined;
123
+ }
@@ -1,10 +1,10 @@
1
1
  import { z } from 'zod';
2
- import { McpToolError, minifiedResult } from '@chrischall/mcp-utils';
2
+ import { CONFIRM_FLOW_SENTENCE, confirmTokenParam, confirmWrite, McpToolError, minifiedResult, } from '@chrischall/mcp-utils';
3
+ import { BASE_PATH } from '../auth.js';
3
4
  import { applyDefaultPlans, clearDefaultPlans } from '../plans.js';
4
5
  import { dayIdToName, nameToDayId } from '../dates.js';
5
6
  import { summarizeDefaultPlans } from './account.js';
6
7
  import { proofsMatch, resolveTransportation } from './plans.js';
7
- import { CONFIRM_FLOW, confirmTokenParam, confirmWrite } from './_confirm.js';
8
8
  /**
9
9
  * Accept weekdays as names ("Monday") or ids (1 = Sunday … 7 = Saturday).
10
10
  * A name that is not a weekday is rejected rather than coerced — silently
@@ -54,7 +54,7 @@ export function registerDefaultPlanTools(server, client) {
54
54
  });
55
55
  });
56
56
  server.registerTool('pup_set_default_plans', {
57
- description: `Change a student's weekly default dismissal plan for one or more weekdays, or clear every default. This is how the child leaves school on any date without a specific plan. ${CONFIRM_FLOW} Read pup_list_transportations first.`,
57
+ description: `Change a student's weekly default dismissal plan for one or more weekdays, or clear every default. This is how the child leaves school on any date without a specific plan. ${CONFIRM_FLOW_SENTENCE} Read pup_list_transportations first.`,
58
58
  inputSchema: z.object({
59
59
  student_id: z.number().int().describe('Student id, from pup_list_students'),
60
60
  days: z
@@ -91,12 +91,12 @@ export function registerDefaultPlanTools(server, client) {
91
91
  tool: 'pup_set_default_plans',
92
92
  action: 'default_plans.clear',
93
93
  summary: `Clear every weekday default for ${student.FirstName ?? 'the student'}`,
94
- method: 'PUT',
95
- dto: 'Student',
94
+ account: undefined,
96
95
  target: String(student_id),
97
96
  revision: student.ModifiedDate,
98
- payload,
97
+ request: { method: 'PUT', path: `${BASE_PATH}/Student`, body: payload },
99
98
  willSend: { StudentId: student.StudentId, DefaultPlans: [] },
99
+ preview: { dto: 'Student' },
100
100
  confirmToken,
101
101
  });
102
102
  if (gate)
@@ -133,16 +133,16 @@ export function registerDefaultPlanTools(server, client) {
133
133
  tool: 'pup_set_default_plans',
134
134
  action: 'default_plans.set',
135
135
  summary: action,
136
- method: 'PUT',
137
- dto: 'Student',
136
+ account: undefined,
138
137
  target: String(student_id),
139
138
  revision: student.ModifiedDate,
140
- payload,
139
+ request: { method: 'PUT', path: `${BASE_PATH}/Student`, body: payload },
141
140
  willSend: {
142
141
  StudentId: student.StudentId,
143
142
  DefaultPlans: payload.DefaultPlans,
144
143
  note: 'The whole student record is sent back with only DefaultPlans changed.',
145
144
  },
145
+ preview: { dto: 'Student' },
146
146
  confirmToken,
147
147
  });
148
148
  if (gate)
@@ -185,7 +185,7 @@ export function registerDefaultPlanTools(server, client) {
185
185
  });
186
186
  }));
187
187
  server.registerTool('pup_mark_defaults_reviewed', {
188
- description: `Mark a student's default plans as reviewed, clearing the school's 'needs review' prompt. ${CONFIRM_FLOW}`,
188
+ description: `Mark a student's default plans as reviewed, clearing the school's 'needs review' prompt. ${CONFIRM_FLOW_SENTENCE}`,
189
189
  inputSchema: z.object({
190
190
  student_id: z.number().int().describe('Student id, from pup_list_students'),
191
191
  reviewed: z.boolean().optional().describe('Defaults to true'),
@@ -197,10 +197,10 @@ export function registerDefaultPlanTools(server, client) {
197
197
  tool: 'pup_mark_defaults_reviewed',
198
198
  action: 'default_plans.mark_reviewed',
199
199
  summary: `Mark student ${student_id}'s defaults as ${value ? 'reviewed' : 'not reviewed'}`,
200
- method: 'PUT',
201
- dto: 'SetDefaultsReviewed',
200
+ account: undefined,
202
201
  target: String(student_id),
203
- payload: { StudentId: student_id, Reviewed: value },
202
+ request: { method: 'PUT', path: `${BASE_PATH}/SetDefaultsReviewed`, body: { StudentId: student_id, Reviewed: value } },
203
+ preview: { dto: 'SetDefaultsReviewed' },
204
204
  confirmToken,
205
205
  });
206
206
  if (gate)
@@ -1,8 +1,8 @@
1
1
  import { z } from 'zod';
2
- import { McpToolError, minifiedResult } from '@chrischall/mcp-utils';
2
+ import { CONFIRM_FLOW_SENTENCE, confirmTokenParam, confirmWrite, McpToolError, minifiedResult, } from '@chrischall/mcp-utils';
3
+ import { BASE_PATH } from '../auth.js';
3
4
  import { buildPlanUpdates } from '../plans.js';
4
5
  import { weekdayOf } from '../dates.js';
5
- import { CONFIRM_FLOW, confirmTokenParam, confirmWrite } from './_confirm.js';
6
6
  /**
7
7
  * Reduce a time of day to `HH:MM:SS` so a read-back can be compared with what
8
8
  * was sent. Accepts `H:MM`, `HH:MM:SS`, an ISO date-time, and the XSD duration
@@ -90,7 +90,7 @@ export function registerPlanTools(server, client) {
90
90
  }),
91
91
  }, async ({ student_id, date }) => minifiedResult(await client.getPlanEdit(date, student_id)));
92
92
  server.registerTool('pup_set_plan', {
93
- description: `Change how a student is dismissed on one or more specific dates, or clear those dates back to the student's weekly default. This changes how a child actually leaves school. ${CONFIRM_FLOW} The preview shows the exact payload. Read pup_list_transportations first — options differ in whether they require a note, a car number or an early-dismissal time.`,
93
+ description: `Change how a student is dismissed on one or more specific dates, or clear those dates back to the student's weekly default. This changes how a child actually leaves school. ${CONFIRM_FLOW_SENTENCE} The preview shows the exact payload. Read pup_list_transportations first — options differ in whether they require a note, a car number or an early-dismissal time.`,
94
94
  inputSchema: z.object({
95
95
  student_id: z.number().int().describe('Student id, from pup_list_students'),
96
96
  dates: z
@@ -139,10 +139,10 @@ export function registerPlanTools(server, client) {
139
139
  tool: 'pup_set_plan',
140
140
  action: 'plans.set',
141
141
  summary: action,
142
- method: 'PUT',
143
- dto: 'UpdatePlans',
142
+ account: undefined,
144
143
  target: String(student_id),
145
- payload: { Plans: plans },
144
+ request: { method: 'PUT', path: `${BASE_PATH}/UpdatePlans`, body: { Plans: plans } },
145
+ preview: { dto: 'UpdatePlans' },
146
146
  confirmToken,
147
147
  });
148
148
  if (gate)
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.1.2";
10
+ export declare const VERSION = "1.1.4";
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.1.2'; // x-release-please-version
10
+ export const VERSION = '1.1.4'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/pickuppatrol-mcp",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
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,13 +57,13 @@
57
57
  "test:watch": "vitest"
58
58
  },
59
59
  "dependencies": {
60
- "@chrischall/mcp-utils": "^2.6.0",
61
- "@modelcontextprotocol/server": "^2.0.0",
60
+ "@chrischall/mcp-utils": "^2.14.0",
61
+ "@modelcontextprotocol/server": "^2.3.0",
62
62
  "dotenv": "^18.0.2",
63
63
  "zod": "^4.6.5"
64
64
  },
65
65
  "devDependencies": {
66
- "@modelcontextprotocol/client": "^2.0.0",
66
+ "@modelcontextprotocol/client": "^2.3.0",
67
67
  "@types/node": "^26.1.1",
68
68
  "@vitest/coverage-v8": "^5.0.0",
69
69
  "esbuild": "^0.28.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.1.2",
9
+ "version": "1.1.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/pickuppatrol-mcp",
14
- "version": "1.1.2",
14
+ "version": "1.1.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }
@@ -1,37 +0,0 @@
1
- import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
2
- import { confirmTokenParam } from '@chrischall/mcp-utils';
3
- export { confirmTokenParam };
4
- /** What every gated tool's description says about the confirmation step. */
5
- export declare const CONFIRM_FLOW = "Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call returns a preview and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE).";
6
- export interface ConfirmWriteOptions {
7
- /** The tool name the token is bound to. */
8
- tool: string;
9
- /** `<service>.<verb>` identifier for the operation. */
10
- action: string;
11
- /** Human-readable sentence describing what will change. */
12
- summary: string;
13
- method: string;
14
- dto: string;
15
- /** The primary id acted on. */
16
- target: string;
17
- /** A version of the target that rotates on edit, when the API has one. */
18
- revision?: string | null | undefined;
19
- /** EXACTLY what the write will send — hashed into the token. */
20
- payload: unknown;
21
- /** What the preview shows as `willSend`; defaults to `payload`. */
22
- willSend?: unknown;
23
- /** The phase-2 token from the tool's input. */
24
- confirmToken: string | undefined;
25
- }
26
- /**
27
- * Confirm-gate for a mutating tool. A client that can show a prompt gets one;
28
- * one that cannot gets the two-step token flow (MCP_CONFIRM_MODE): phase 1
29
- * makes **no** write and returns the preview plus a token, phase 2 proceeds
30
- * only if the freshly rebuilt payload still matches what was previewed.
31
- * `undefined` means proceed; anything else is the result to return.
32
- *
33
- * The gate matters more here than in most of the fleet: these writes change
34
- * how a child leaves school. A hallucinated call must not silently put a
35
- * student on a different bus.
36
- */
37
- export declare function confirmWrite(ctx: ServerContext, options: ConfirmWriteOptions): Promise<InputRequiredResult | CallToolResult | undefined>;
@@ -1,32 +0,0 @@
1
- import { confirmationFromEnv, confirmTokenParam, requireConfirmationWithFallback, } from '@chrischall/mcp-utils';
2
- export { confirmTokenParam };
3
- /** What every gated tool's description says about the confirmation step. */
4
- export const CONFIRM_FLOW = 'Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call returns a preview and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE).';
5
- /**
6
- * Confirm-gate for a mutating tool. A client that can show a prompt gets one;
7
- * one that cannot gets the two-step token flow (MCP_CONFIRM_MODE): phase 1
8
- * makes **no** write and returns the preview plus a token, phase 2 proceeds
9
- * only if the freshly rebuilt payload still matches what was previewed.
10
- * `undefined` means proceed; anything else is the result to return.
11
- *
12
- * The gate matters more here than in most of the fleet: these writes change
13
- * how a child leaves school. A hallucinated call must not silently put a
14
- * student on a different bus.
15
- */
16
- export function confirmWrite(ctx, options) {
17
- const { tool, action, summary, method, dto, target, revision, payload, willSend, confirmToken } = options;
18
- const preview = { action: summary, method, dto, willSend: willSend ?? payload };
19
- return requireConfirmationWithFallback(ctx, confirmationFromEnv({
20
- action,
21
- message: `Review and confirm this change: ${summary}`,
22
- details: preview,
23
- tool,
24
- confirmToken,
25
- subject: () => ({
26
- target,
27
- ...(revision ? { revision } : {}),
28
- payload,
29
- preview,
30
- }),
31
- }));
32
- }