@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +19 -2
- package/dist/auth.d.ts +12 -0
- package/dist/auth.js +57 -15
- package/dist/bundle.js +635 -95
- package/dist/client.js +4 -3
- package/dist/tools/_confirm.d.ts +31 -7
- package/dist/tools/_confirm.js +24 -15
- package/dist/tools/defaults.js +60 -17
- package/dist/tools/plans.d.ts +24 -3
- package/dist/tools/plans.js +63 -13
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "PickUp Patrol school-dismissal tools for Claude Code",
|
|
9
|
-
"version": "1.0
|
|
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
|
|
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
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
123
|
-
//
|
|
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) {
|