@chrischall/pickuppatrol-mcp 0.0.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 +35 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/dist/auth.d.ts +72 -0
- package/dist/auth.js +147 -0
- package/dist/bundle.js +32156 -0
- package/dist/client.d.ts +57 -0
- package/dist/client.js +168 -0
- package/dist/dates.d.ts +28 -0
- package/dist/dates.js +51 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +24 -0
- package/dist/lib.d.ts +11 -0
- package/dist/lib.js +10 -0
- package/dist/plans.d.ts +57 -0
- package/dist/plans.js +165 -0
- package/dist/tools/_confirm.d.ts +13 -0
- package/dist/tools/_confirm.js +23 -0
- package/dist/tools/_errors.d.ts +14 -0
- package/dist/tools/_errors.js +26 -0
- package/dist/tools/account.d.ts +8 -0
- package/dist/tools/account.js +97 -0
- package/dist/tools/defaults.d.ts +9 -0
- package/dist/tools/defaults.js +169 -0
- package/dist/tools/plans.d.ts +15 -0
- package/dist/tools/plans.js +133 -0
- package/dist/tools/school.d.ts +10 -0
- package/dist/tools/school.js +77 -0
- package/dist/types.d.ts +142 -0
- package/dist/types.js +9 -0
- package/dist/version.d.ts +10 -0
- package/dist/version.js +10 -0
- package/package.json +71 -0
- package/server.json +20 -0
- package/skills/pickuppatrol-api/SKILL.md +107 -0
- package/skills/pickuppatrol-api/references/api.md +168 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chrischall",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "Chris Chall",
|
|
5
|
+
"email": "chris.c.hall@gmail.com"
|
|
6
|
+
},
|
|
7
|
+
"metadata": {
|
|
8
|
+
"description": "PickUp Patrol school-dismissal tools for Claude Code",
|
|
9
|
+
"version": "0.0.0"
|
|
10
|
+
},
|
|
11
|
+
"plugins": [
|
|
12
|
+
{
|
|
13
|
+
"name": "pickuppatrol",
|
|
14
|
+
"displayName": "PickUp Patrol",
|
|
15
|
+
"source": "./",
|
|
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": "0.0.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Chris Chall"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/chrischall/pickuppatrol-mcp",
|
|
22
|
+
"repository": "https://github.com/chrischall/pickuppatrol-mcp",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"pickuppatrol",
|
|
26
|
+
"school",
|
|
27
|
+
"dismissal",
|
|
28
|
+
"carpool",
|
|
29
|
+
"parents",
|
|
30
|
+
"mcp"
|
|
31
|
+
],
|
|
32
|
+
"category": "productivity"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pickuppatrol",
|
|
3
|
+
"displayName": "PickUp Patrol",
|
|
4
|
+
"version": "0.0.0",
|
|
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
|
+
"author": {
|
|
7
|
+
"name": "Chris Chall"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/chrischall/pickuppatrol-mcp",
|
|
10
|
+
"repository": "https://github.com/chrischall/pickuppatrol-mcp",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pickuppatrol",
|
|
14
|
+
"school",
|
|
15
|
+
"dismissal",
|
|
16
|
+
"carpool",
|
|
17
|
+
"parents",
|
|
18
|
+
"mcp"
|
|
19
|
+
],
|
|
20
|
+
"skills": "./skills/",
|
|
21
|
+
"mcp": "./.mcp.json"
|
|
22
|
+
}
|
package/.mcp.json
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris Hall
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# pickuppatrol-mcp
|
|
2
|
+
|
|
3
|
+
An MCP server for [PickUp Patrol](https://www.pickuppatrol.net/) — the school
|
|
4
|
+
dismissal app. Read and change how your children leave school: their weekly
|
|
5
|
+
default plan, one-off changes for specific dates, and the school's dismissal
|
|
6
|
+
options and cutoff times.
|
|
7
|
+
|
|
8
|
+
> Developed and maintained by AI (Claude Code). Use at your own discretion.
|
|
9
|
+
|
|
10
|
+
## What it talks to
|
|
11
|
+
|
|
12
|
+
`app.pickuppatrol.net` runs a [ServiceStack](https://servicestack.net/) JSON API
|
|
13
|
+
behind an Ionic/Vue SPA. There is no published API, but the service is reachable
|
|
14
|
+
server-side with an ordinary HTTPS request — no browser extension, no bot wall,
|
|
15
|
+
no captcha. The server signs in with your own email and password and holds the
|
|
16
|
+
resulting session in memory.
|
|
17
|
+
|
|
18
|
+
Every request shape is captured in [`docs/PICKUPPATROL-API.md`](docs/PICKUPPATROL-API.md),
|
|
19
|
+
read off the shipped client rather than guessed.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```jsonc
|
|
24
|
+
// .mcp.json
|
|
25
|
+
{
|
|
26
|
+
"mcpServers": {
|
|
27
|
+
"pickuppatrol": {
|
|
28
|
+
"command": "npx",
|
|
29
|
+
"args": ["-y", "@chrischall/pickuppatrol-mcp"],
|
|
30
|
+
"env": {
|
|
31
|
+
"PICKUPPATROL_USERNAME": "you@example.com",
|
|
32
|
+
"PICKUPPATROL_PASSWORD": "…"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For local development, copy `.env.example` to `.env` and fill it in.
|
|
40
|
+
|
|
41
|
+
The server starts without credentials — it answers the host's install-time probe
|
|
42
|
+
and only reports the configuration error on the first tool call.
|
|
43
|
+
|
|
44
|
+
## Tools
|
|
45
|
+
|
|
46
|
+
**Reads**
|
|
47
|
+
|
|
48
|
+
| Tool | What it gives you |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `pup_get_session` | The signed-in account and the students linked to it |
|
|
51
|
+
| `pup_list_students` | Every student with their weekly defaults and review flag |
|
|
52
|
+
| `pup_get_student` | One student in full |
|
|
53
|
+
| `pup_get_default_plans` | A student's weekly default plan, day by day |
|
|
54
|
+
| `pup_list_plans` | Day-by-day plans across a date range |
|
|
55
|
+
| `pup_get_plan` | One student, one date — including whether it is locked |
|
|
56
|
+
| `pup_list_transportations` | A school's dismissal options and the rules each imposes |
|
|
57
|
+
| `pup_get_school` | School profile, notify times, cutoff times, settings |
|
|
58
|
+
| `pup_list_non_school_days` | Dates no plan can be set for, and dates already changed |
|
|
59
|
+
| `pup_list_car_numbers` | Car numbers the school issued to this account |
|
|
60
|
+
| `pup_healthcheck` | Credentials sign in and the API answers |
|
|
61
|
+
|
|
62
|
+
**Writes** — every one requires `confirm: true`. Without it the tool makes no
|
|
63
|
+
change and returns a dry-run of the exact payload it would send.
|
|
64
|
+
|
|
65
|
+
| Tool | What it changes |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `pup_set_plan` | Dismissal for one or more specific dates, or clears them back to the default |
|
|
68
|
+
| `pup_set_default_plans` | The weekly default plan, or clears every default |
|
|
69
|
+
| `pup_mark_defaults_reviewed` | The school's "defaults need review" prompt |
|
|
70
|
+
|
|
71
|
+
### Two things the tools do that the API does not
|
|
72
|
+
|
|
73
|
+
**Rules are enforced before anything is sent.** Each dismissal option carries its
|
|
74
|
+
own requirements — a note, a car number, an early-dismissal time, or a
|
|
75
|
+
restriction to particular students. `pup_set_plan` checks them against the
|
|
76
|
+
school's own list and refuses with the school's wording, so a rejected write is
|
|
77
|
+
a validation message rather than an opaque 400.
|
|
78
|
+
|
|
79
|
+
**Writes are verified by re-reading.** A 2xx from PickUp Patrol is not proof: a
|
|
80
|
+
change made after the school's cutoff is accepted and silently ignored. Every
|
|
81
|
+
write re-reads the affected dates and compares the transportation id — never
|
|
82
|
+
`ModifiedDate`, which advances on its own and would make every write look
|
|
83
|
+
successful. The result says `verified: true/false`, and names the dates that did
|
|
84
|
+
not move.
|
|
85
|
+
|
|
86
|
+
## Without the MCP server
|
|
87
|
+
|
|
88
|
+
`skills/pickuppatrol-api/` is a shell-out skill covering the same API with
|
|
89
|
+
`curl` and `jq`, for scripts or a machine where the server is not installed.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npm install
|
|
95
|
+
npm run build
|
|
96
|
+
npm test # fast
|
|
97
|
+
npm run test:coverage # coverage-enforced at 100%
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Tests never touch the network: the transport is injected, and the MCP tools run
|
|
101
|
+
through a real in-memory client/server pair.
|
|
102
|
+
|
|
103
|
+
## Safety notes
|
|
104
|
+
|
|
105
|
+
- A rejected sign-in is **never retried**. PickUp Patrol counts failed attempts
|
|
106
|
+
against the account and a lockout clears only through their support desk, so
|
|
107
|
+
the error is cached and every later call fails instantly with the same message.
|
|
108
|
+
- The weekly-default write is a read-modify-write of the whole student record,
|
|
109
|
+
because the API has no default-plans endpoint. The server always reads the
|
|
110
|
+
student immediately before writing, and changes only `DefaultPlans`.
|
|
111
|
+
- Credentials live in `.env` (gitignored) or the MCP host's config, and are
|
|
112
|
+
never written to a result or a log.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ResponseStatus } from './types.js';
|
|
2
|
+
export declare const BASE_URL = "https://app.pickuppatrol.net";
|
|
3
|
+
export declare const BASE_PATH = "/api/json/reply";
|
|
4
|
+
/** Minimal `fetch` seam so tests never open a socket. */
|
|
5
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
6
|
+
/**
|
|
7
|
+
* What a successful login yields. PickUp Patrol's ServiceStack deployment can
|
|
8
|
+
* authenticate either way and the SPA is built for both: its client reads a
|
|
9
|
+
* `bearerToken` out of `localStorage` when one is present, and otherwise rides
|
|
10
|
+
* the `ss-id`/`ss-pid` session cookies it sets `credentials: 'include'` for.
|
|
11
|
+
* We keep whichever the server actually hands back and send both when we have
|
|
12
|
+
* them, so the client does not depend on which mode the deployment is in.
|
|
13
|
+
*/
|
|
14
|
+
export interface PupSession {
|
|
15
|
+
/** `Authorization: Bearer …`, when the deployment issues JWTs. */
|
|
16
|
+
bearerToken: string | null;
|
|
17
|
+
/** Exchangeable for a fresh bearer token via `GetAccessToken`. */
|
|
18
|
+
refreshToken: string | null;
|
|
19
|
+
/** `Cookie:` header value built from every `Set-Cookie` the login returned. */
|
|
20
|
+
cookieHeader: string;
|
|
21
|
+
}
|
|
22
|
+
export interface AuthOptions {
|
|
23
|
+
username?: string;
|
|
24
|
+
password?: string;
|
|
25
|
+
fetchImpl?: FetchLike;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Pull the most useful message out of a ServiceStack error envelope. Field
|
|
29
|
+
* errors are more specific than the top-level message, so they win.
|
|
30
|
+
*/
|
|
31
|
+
export declare function describeResponseStatus(status: ResponseStatus | null | undefined): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Owns the login lifecycle: one lazy, single-flight sign-in; a cached
|
|
34
|
+
* *permanent* failure for anything that means "these credentials will never
|
|
35
|
+
* work"; and exactly one re-login + replay when a request comes back 401.
|
|
36
|
+
*/
|
|
37
|
+
export declare class PickUpPatrolAuth {
|
|
38
|
+
private readonly username;
|
|
39
|
+
private readonly password;
|
|
40
|
+
private readonly configError;
|
|
41
|
+
private readonly fetchImpl;
|
|
42
|
+
private session;
|
|
43
|
+
private inFlight;
|
|
44
|
+
private permanentError;
|
|
45
|
+
constructor(opts?: AuthOptions);
|
|
46
|
+
/** True once a login has succeeded — used by the healthcheck tool. */
|
|
47
|
+
get isAuthenticated(): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* A valid session, logging in on first use. Concurrent callers share one
|
|
50
|
+
* in-flight login rather than racing several sign-ins at the same account.
|
|
51
|
+
*/
|
|
52
|
+
ensure(): Promise<PupSession>;
|
|
53
|
+
/** Drop the cached session so the next `ensure()` signs in again. */
|
|
54
|
+
invalidate(): void;
|
|
55
|
+
/**
|
|
56
|
+
* Run an authenticated request. On a 401 the session is dropped, re-minted
|
|
57
|
+
* once and the call replayed exactly once — never more, so a server that
|
|
58
|
+
* answers 401 unconditionally cannot turn into a login loop against the
|
|
59
|
+
* account.
|
|
60
|
+
*/
|
|
61
|
+
withAuth(call: (session: PupSession) => Promise<Response>): Promise<Response>;
|
|
62
|
+
private login;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build a `Cookie:` request header from every `Set-Cookie` on a response.
|
|
66
|
+
* ServiceStack sets more than one (`ss-id`, `ss-pid`, `ss-opt`) alongside
|
|
67
|
+
* Azure's `ARRAffinity` pair, and the session needs all of them — so this
|
|
68
|
+
* keeps the whole jar rather than picking a named cookie out of it. `CookieJar`
|
|
69
|
+
* also drops the deletion markers a login response mixes in, which some
|
|
70
|
+
* upstreams reject when echoed back.
|
|
71
|
+
*/
|
|
72
|
+
export declare function collectCookieHeader(res: Response): string;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readEnvVar, McpToolError, CookieJar } from '@chrischall/mcp-utils';
|
|
2
|
+
export const BASE_URL = 'https://app.pickuppatrol.net';
|
|
3
|
+
export const BASE_PATH = '/api/json/reply';
|
|
4
|
+
/**
|
|
5
|
+
* Pull the most useful message out of a ServiceStack error envelope. Field
|
|
6
|
+
* errors are more specific than the top-level message, so they win.
|
|
7
|
+
*/
|
|
8
|
+
export function describeResponseStatus(status) {
|
|
9
|
+
if (!status)
|
|
10
|
+
return null;
|
|
11
|
+
const fieldError = status.Errors?.find((e) => e?.Message);
|
|
12
|
+
return fieldError?.Message ?? status.Message ?? status.ErrorCode ?? null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Owns the login lifecycle: one lazy, single-flight sign-in; a cached
|
|
16
|
+
* *permanent* failure for anything that means "these credentials will never
|
|
17
|
+
* work"; and exactly one re-login + replay when a request comes back 401.
|
|
18
|
+
*/
|
|
19
|
+
export class PickUpPatrolAuth {
|
|
20
|
+
username;
|
|
21
|
+
password;
|
|
22
|
+
configError;
|
|
23
|
+
fetchImpl;
|
|
24
|
+
session = null;
|
|
25
|
+
inFlight = null;
|
|
26
|
+
permanentError = null;
|
|
27
|
+
constructor(opts = {}) {
|
|
28
|
+
const username = opts.username ?? readEnvVar('PICKUPPATROL_USERNAME');
|
|
29
|
+
const password = opts.password ?? readEnvVar('PICKUPPATROL_PASSWORD');
|
|
30
|
+
// Deferred config error: the server must still boot (and answer the host's
|
|
31
|
+
// install-time tools/list probe) with no credentials set. The error is
|
|
32
|
+
// raised at request time instead.
|
|
33
|
+
if (!username || !password) {
|
|
34
|
+
this.username = null;
|
|
35
|
+
this.password = null;
|
|
36
|
+
this.configError = new McpToolError('PICKUPPATROL_USERNAME and PICKUPPATROL_PASSWORD are required', {
|
|
37
|
+
hint: 'Set both in the MCP host config (or a local .env) to the email and password you use at https://app.pickuppatrol.net/.',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
this.username = username;
|
|
42
|
+
this.password = password;
|
|
43
|
+
this.configError = null;
|
|
44
|
+
}
|
|
45
|
+
this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
|
|
46
|
+
}
|
|
47
|
+
/** True once a login has succeeded — used by the healthcheck tool. */
|
|
48
|
+
get isAuthenticated() {
|
|
49
|
+
return this.session !== null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A valid session, logging in on first use. Concurrent callers share one
|
|
53
|
+
* in-flight login rather than racing several sign-ins at the same account.
|
|
54
|
+
*/
|
|
55
|
+
async ensure() {
|
|
56
|
+
if (this.configError)
|
|
57
|
+
throw this.configError;
|
|
58
|
+
if (this.permanentError)
|
|
59
|
+
throw this.permanentError;
|
|
60
|
+
if (this.session)
|
|
61
|
+
return this.session;
|
|
62
|
+
if (this.inFlight)
|
|
63
|
+
return this.inFlight;
|
|
64
|
+
this.inFlight = this.login()
|
|
65
|
+
.then((session) => {
|
|
66
|
+
this.session = session;
|
|
67
|
+
return session;
|
|
68
|
+
})
|
|
69
|
+
.finally(() => {
|
|
70
|
+
this.inFlight = null;
|
|
71
|
+
});
|
|
72
|
+
return this.inFlight;
|
|
73
|
+
}
|
|
74
|
+
/** Drop the cached session so the next `ensure()` signs in again. */
|
|
75
|
+
invalidate() {
|
|
76
|
+
this.session = null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Run an authenticated request. On a 401 the session is dropped, re-minted
|
|
80
|
+
* once and the call replayed exactly once — never more, so a server that
|
|
81
|
+
* answers 401 unconditionally cannot turn into a login loop against the
|
|
82
|
+
* account.
|
|
83
|
+
*/
|
|
84
|
+
async withAuth(call) {
|
|
85
|
+
const first = await call(await this.ensure());
|
|
86
|
+
if (first.status !== 401)
|
|
87
|
+
return first;
|
|
88
|
+
this.invalidate();
|
|
89
|
+
return call(await this.ensure());
|
|
90
|
+
}
|
|
91
|
+
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));
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const detail = describeResponseStatus(body?.ResponseStatus);
|
|
106
|
+
const code = body?.ResponseStatus?.ErrorCode ?? '';
|
|
107
|
+
const error = new McpToolError(`PickUp Patrol rejected the sign-in${detail ? `: ${detail}` : ` (HTTP ${res.status})`}`, {
|
|
108
|
+
hint: 'Check PICKUPPATROL_USERNAME and PICKUPPATROL_PASSWORD against https://app.pickuppatrol.net/.',
|
|
109
|
+
});
|
|
110
|
+
// A credential the service has *judged* is never retried. PickUp Patrol
|
|
111
|
+
// counts failures against the account and clearing a lockout goes
|
|
112
|
+
// through their support desk, so a retry loop would destroy the only
|
|
113
|
+
// auth path we have. Caching the error means every later call fails
|
|
114
|
+
// instantly with the same message instead of spending another attempt.
|
|
115
|
+
// A 5xx or a network blip is left transient so the next call retries.
|
|
116
|
+
if (res.status >= 400 && res.status < 500 && code.startsWith('LOGIN-ERROR')) {
|
|
117
|
+
this.permanentError = error;
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
// 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.
|
|
124
|
+
const cookieHeader = collectCookieHeader(res);
|
|
125
|
+
const bearerToken = body?.BearerToken ?? null;
|
|
126
|
+
if (!bearerToken && !cookieHeader) {
|
|
127
|
+
this.permanentError = new McpToolError('PickUp Patrol accepted the sign-in but returned no session token or cookie', {
|
|
128
|
+
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.',
|
|
129
|
+
});
|
|
130
|
+
throw this.permanentError;
|
|
131
|
+
}
|
|
132
|
+
return { bearerToken, refreshToken: body?.RefreshToken ?? null, cookieHeader };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Build a `Cookie:` request header from every `Set-Cookie` on a response.
|
|
137
|
+
* ServiceStack sets more than one (`ss-id`, `ss-pid`, `ss-opt`) alongside
|
|
138
|
+
* Azure's `ARRAffinity` pair, and the session needs all of them — so this
|
|
139
|
+
* keeps the whole jar rather than picking a named cookie out of it. `CookieJar`
|
|
140
|
+
* also drops the deletion markers a login response mixes in, which some
|
|
141
|
+
* upstreams reject when echoed back.
|
|
142
|
+
*/
|
|
143
|
+
export function collectCookieHeader(res) {
|
|
144
|
+
const jar = new CookieJar();
|
|
145
|
+
jar.absorb(res.headers);
|
|
146
|
+
return jar.header();
|
|
147
|
+
}
|