@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,8 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { PickUpPatrolClient } from '../client.js';
|
|
3
|
+
import type { DefaultPlan, Student } from '../types.js';
|
|
4
|
+
/** Project a student down to the fields a parent actually asks about. */
|
|
5
|
+
export declare function summarizeStudent(student: Student): Record<string, unknown>;
|
|
6
|
+
/** Order the weekly defaults Sunday→Saturday and label each day. */
|
|
7
|
+
export declare function summarizeDefaultPlans(plans: DefaultPlan[] | null | undefined): unknown[];
|
|
8
|
+
export declare function registerAccountTools(server: McpServer, client: PickUpPatrolClient): void;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult, messageOf } from '@chrischall/mcp-utils';
|
|
3
|
+
import { dayIdToName } from '../dates.js';
|
|
4
|
+
import { VERSION } from '../version.js';
|
|
5
|
+
/** Project a student down to the fields a parent actually asks about. */
|
|
6
|
+
export function summarizeStudent(student) {
|
|
7
|
+
return {
|
|
8
|
+
studentId: student.StudentId,
|
|
9
|
+
firstName: student.FirstName,
|
|
10
|
+
lastName: student.LastName,
|
|
11
|
+
schoolId: student.SchoolId,
|
|
12
|
+
schoolName: student.SchoolName,
|
|
13
|
+
allowPlans: student.AllowPlans ?? null,
|
|
14
|
+
defaultCarNumber: student.DefaultCarNumber ?? null,
|
|
15
|
+
defaultsReviewedDate: student.DefaultsReviewedDate ?? null,
|
|
16
|
+
defaultPlans: summarizeDefaultPlans(student.DefaultPlans),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Order the weekly defaults Sunday→Saturday and label each day. */
|
|
20
|
+
export function summarizeDefaultPlans(plans) {
|
|
21
|
+
return [...(plans ?? [])]
|
|
22
|
+
.sort((a, b) => a.DayId - b.DayId)
|
|
23
|
+
.map((plan) => ({
|
|
24
|
+
dayId: plan.DayId,
|
|
25
|
+
weekday: plan.WeekDayName ?? dayIdToName(plan.DayId),
|
|
26
|
+
transportationId: plan.TransportationId ?? null,
|
|
27
|
+
transportation: plan.TransportationName ?? null,
|
|
28
|
+
note: plan.Note ?? null,
|
|
29
|
+
earlyDismissalTime: plan.EarlyDismissalTime ?? null,
|
|
30
|
+
carNumber: plan.UseCarNumbers ? (plan.CarNumber ?? null) : null,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
export function registerAccountTools(server, client) {
|
|
34
|
+
server.registerTool('pup_get_session', {
|
|
35
|
+
description: 'The signed-in PickUp Patrol parent account: name, email, last sign-in, and the students linked to it. Start here to discover student and school ids.',
|
|
36
|
+
annotations: { readOnlyHint: true },
|
|
37
|
+
}, async () => {
|
|
38
|
+
const session = await client.getSession();
|
|
39
|
+
return textResult({
|
|
40
|
+
userId: session.UserId ?? null,
|
|
41
|
+
name: session.DisplayName ?? [session.FirstName, session.LastName].filter(Boolean).join(' '),
|
|
42
|
+
email: session.Email ?? session.PrimaryEmail ?? null,
|
|
43
|
+
lastLoginDate: session.LastLoginDate ?? null,
|
|
44
|
+
sendPlanConfirmEmails: session.SendPlanConfirmEmails ?? null,
|
|
45
|
+
hasAcceptedLatestTerms: session.HasAcceptedLatestTerms ?? null,
|
|
46
|
+
children: session.Children ?? [],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
server.registerTool('pup_list_students', {
|
|
50
|
+
description: 'Every student on the account, each with their weekly default dismissal plan and whether those defaults still need a parent review.',
|
|
51
|
+
annotations: { readOnlyHint: true },
|
|
52
|
+
}, async () => {
|
|
53
|
+
const [students, review] = await Promise.all([
|
|
54
|
+
client.getChildren(),
|
|
55
|
+
client.getDefaultPlansReviewNeeded(),
|
|
56
|
+
]);
|
|
57
|
+
const needsReview = new Map(review.map((r) => [r.StudentId, r.NeedsReview]));
|
|
58
|
+
return textResult(students.map((student) => ({
|
|
59
|
+
...summarizeStudent(student),
|
|
60
|
+
needsDefaultsReview: needsReview.get(student.StudentId) ?? false,
|
|
61
|
+
})));
|
|
62
|
+
});
|
|
63
|
+
server.registerTool('pup_get_student', {
|
|
64
|
+
description: 'One student in full, including the default dismissal plan for each weekday. Pass raw: true for the untouched API record.',
|
|
65
|
+
annotations: { readOnlyHint: true },
|
|
66
|
+
inputSchema: {
|
|
67
|
+
student_id: z.number().int().describe('Student id, from pup_list_students'),
|
|
68
|
+
raw: z
|
|
69
|
+
.boolean()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe('Return the unprojected API record instead of the summary'),
|
|
72
|
+
},
|
|
73
|
+
}, async ({ student_id, raw }) => {
|
|
74
|
+
const student = await client.getStudent(student_id);
|
|
75
|
+
return textResult(raw === true ? student : summarizeStudent(student));
|
|
76
|
+
});
|
|
77
|
+
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.',
|
|
79
|
+
annotations: { readOnlyHint: true },
|
|
80
|
+
}, async () => {
|
|
81
|
+
try {
|
|
82
|
+
const session = await client.getSession();
|
|
83
|
+
return textResult({
|
|
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 textResult({ ok: false, version: VERSION, error: messageOf(err) });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { PickUpPatrolClient } from '../client.js';
|
|
3
|
+
/**
|
|
4
|
+
* Accept weekdays as names ("Monday") or ids (1 = Sunday … 7 = Saturday).
|
|
5
|
+
* A name that is not a weekday is rejected rather than coerced — silently
|
|
6
|
+
* landing on Sunday would be a plan change nobody asked for.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseWeekdays(days: Array<string | number>): number[];
|
|
9
|
+
export declare function registerDefaultPlanTools(server: McpServer, client: PickUpPatrolClient): void;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { McpToolError, textResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import { applyDefaultPlans, clearDefaultPlans } from '../plans.js';
|
|
4
|
+
import { dayIdToName, nameToDayId } from '../dates.js';
|
|
5
|
+
import { summarizeDefaultPlans } from './account.js';
|
|
6
|
+
import { resolveTransportation } from './plans.js';
|
|
7
|
+
import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
8
|
+
import { withHints } from './_errors.js';
|
|
9
|
+
/**
|
|
10
|
+
* Accept weekdays as names ("Monday") or ids (1 = Sunday … 7 = Saturday).
|
|
11
|
+
* A name that is not a weekday is rejected rather than coerced — silently
|
|
12
|
+
* landing on Sunday would be a plan change nobody asked for.
|
|
13
|
+
*/
|
|
14
|
+
export function parseWeekdays(days) {
|
|
15
|
+
return days.map((day) => {
|
|
16
|
+
if (typeof day === 'number') {
|
|
17
|
+
if (!Number.isInteger(day) || day < 1 || day > 7) {
|
|
18
|
+
throw new McpToolError(`${day} is not a weekday id (1 = Sunday … 7 = Saturday)`);
|
|
19
|
+
}
|
|
20
|
+
return day;
|
|
21
|
+
}
|
|
22
|
+
const asNumber = /^[1-7]$/.test(day.trim()) ? Number(day.trim()) : null;
|
|
23
|
+
if (asNumber !== null)
|
|
24
|
+
return asNumber;
|
|
25
|
+
const dayId = nameToDayId(day);
|
|
26
|
+
if (dayId === null) {
|
|
27
|
+
throw new McpToolError(`"${day}" is not a weekday`, {
|
|
28
|
+
hint: 'Use a name like "Monday", or an id 1–7 where 1 is Sunday.',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return dayId;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function registerDefaultPlanTools(server, client) {
|
|
35
|
+
server.registerTool('pup_get_default_plans', {
|
|
36
|
+
description: "A student's weekly default dismissal plan — how they normally leave school on each day of the week — and whether the defaults still need a parent review.",
|
|
37
|
+
annotations: { readOnlyHint: true },
|
|
38
|
+
inputSchema: {
|
|
39
|
+
student_id: z.number().int().describe('Student id, from pup_list_students'),
|
|
40
|
+
},
|
|
41
|
+
}, async ({ student_id }) => {
|
|
42
|
+
const [student, review] = await Promise.all([
|
|
43
|
+
client.getStudent(student_id),
|
|
44
|
+
client.getDefaultPlansReviewNeeded(),
|
|
45
|
+
]);
|
|
46
|
+
return textResult({
|
|
47
|
+
studentId: student.StudentId,
|
|
48
|
+
name: [student.FirstName, student.LastName].filter(Boolean).join(' '),
|
|
49
|
+
schoolId: student.SchoolId,
|
|
50
|
+
schoolName: student.SchoolName,
|
|
51
|
+
allowPlans: student.AllowPlans ?? null,
|
|
52
|
+
defaultsReviewedDate: student.DefaultsReviewedDate ?? null,
|
|
53
|
+
needsDefaultsReview: review.find((r) => r.StudentId === student_id)?.NeedsReview ?? false,
|
|
54
|
+
defaultPlans: summarizeDefaultPlans(student.DefaultPlans),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
server.registerTool('pup_set_default_plans', {
|
|
58
|
+
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, so it requires confirm: true; without it you get a dry-run. Read pup_list_transportations first.",
|
|
59
|
+
inputSchema: {
|
|
60
|
+
student_id: z.number().int().describe('Student id, from pup_list_students'),
|
|
61
|
+
days: z
|
|
62
|
+
.array(z.union([z.string(), z.number().int()]))
|
|
63
|
+
.optional()
|
|
64
|
+
.describe('Weekdays to change, as names ("Monday") or ids (1 = Sunday … 7 = Saturday)'),
|
|
65
|
+
transportation_id: z
|
|
66
|
+
.number()
|
|
67
|
+
.int()
|
|
68
|
+
.optional()
|
|
69
|
+
.describe('Dismissal option id from pup_list_transportations'),
|
|
70
|
+
note: z.string().optional().describe('Note for the school; required by some options'),
|
|
71
|
+
early_dismissal_time: z
|
|
72
|
+
.string()
|
|
73
|
+
.optional()
|
|
74
|
+
.describe('HH:MM, required when the option is an early dismissal'),
|
|
75
|
+
clear_all: z
|
|
76
|
+
.boolean()
|
|
77
|
+
.optional()
|
|
78
|
+
.describe('Remove every weekday default instead of setting one (days is ignored)'),
|
|
79
|
+
confirm: schemaConfirm,
|
|
80
|
+
},
|
|
81
|
+
}, withHints(async ({ student_id, days, transportation_id, note, early_dismissal_time, clear_all, confirm }) => {
|
|
82
|
+
// Read-modify-write: PickUp Patrol has no default-plans endpoint, so the
|
|
83
|
+
// whole student record round-trips. Reading it here (before the confirm
|
|
84
|
+
// gate) is what makes the dry-run show the real payload; it mutates
|
|
85
|
+
// nothing.
|
|
86
|
+
const student = await client.getStudent(student_id);
|
|
87
|
+
if (clear_all === true) {
|
|
88
|
+
const payload = clearDefaultPlans(student);
|
|
89
|
+
const gate = previewUnlessConfirmed(confirm, `Clear every weekday default for ${student.FirstName ?? 'the student'}`, 'PUT', 'Student', { StudentId: student.StudentId, DefaultPlans: [] });
|
|
90
|
+
if (gate)
|
|
91
|
+
return gate;
|
|
92
|
+
await client.updateStudent(payload);
|
|
93
|
+
const after = await client.getStudent(student_id);
|
|
94
|
+
const remaining = after.DefaultPlans ?? [];
|
|
95
|
+
return textResult({
|
|
96
|
+
action: 'Cleared every weekday default',
|
|
97
|
+
defaultPlans: summarizeDefaultPlans(remaining),
|
|
98
|
+
verified: remaining.length === 0,
|
|
99
|
+
...(remaining.length > 0
|
|
100
|
+
? { warning: 'PickUp Patrol accepted the request but defaults are still set.' }
|
|
101
|
+
: {}),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (days === undefined || transportation_id === undefined) {
|
|
105
|
+
throw new McpToolError('days and transportation_id are both required', {
|
|
106
|
+
hint: 'Pass clear_all: true to remove every default instead.',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const dayIds = parseWeekdays(days);
|
|
110
|
+
const transportation = await resolveTransportation(client, student.SchoolId, transportation_id);
|
|
111
|
+
const payload = applyDefaultPlans({
|
|
112
|
+
student,
|
|
113
|
+
dayIds,
|
|
114
|
+
transportation,
|
|
115
|
+
note,
|
|
116
|
+
earlyDismissalTime: early_dismissal_time,
|
|
117
|
+
});
|
|
118
|
+
const dayNames = dayIds.map((id) => dayIdToName(id)).join(', ');
|
|
119
|
+
const action = `Set ${student.FirstName ?? 'the student'}'s default plan on ${dayNames} to "${transportation.Name}"`;
|
|
120
|
+
const gate = previewUnlessConfirmed(confirm, action, 'PUT', 'Student', {
|
|
121
|
+
StudentId: student.StudentId,
|
|
122
|
+
DefaultPlans: payload.DefaultPlans,
|
|
123
|
+
note: 'The whole student record is sent back with only DefaultPlans changed.',
|
|
124
|
+
});
|
|
125
|
+
if (gate)
|
|
126
|
+
return gate;
|
|
127
|
+
await client.updateStudent(payload);
|
|
128
|
+
// Re-read and check the weekdays we changed actually hold the new
|
|
129
|
+
// option. DefaultsModifiedDate is not compared — it advances by itself.
|
|
130
|
+
const after = await client.getStudent(student_id);
|
|
131
|
+
const changed = new Set(dayIds);
|
|
132
|
+
const unchanged = [...changed].filter((dayId) => {
|
|
133
|
+
const plan = (after.DefaultPlans ?? []).find((p) => p.DayId === dayId);
|
|
134
|
+
return plan?.TransportationId !== transportation.TransportationId;
|
|
135
|
+
});
|
|
136
|
+
return textResult({
|
|
137
|
+
action,
|
|
138
|
+
defaultPlans: summarizeDefaultPlans(after.DefaultPlans),
|
|
139
|
+
verified: unchanged.length === 0,
|
|
140
|
+
...(unchanged.length > 0
|
|
141
|
+
? {
|
|
142
|
+
warning: 'PickUp Patrol accepted the request but these weekdays did not change — the school may not run default plans on them.',
|
|
143
|
+
unchanged: unchanged.map((id) => dayIdToName(id)),
|
|
144
|
+
}
|
|
145
|
+
: {}),
|
|
146
|
+
});
|
|
147
|
+
}));
|
|
148
|
+
server.registerTool('pup_mark_defaults_reviewed', {
|
|
149
|
+
description: "Mark a student's default plans as reviewed, clearing the school's 'needs review' prompt. Requires confirm: true.",
|
|
150
|
+
inputSchema: {
|
|
151
|
+
student_id: z.number().int().describe('Student id, from pup_list_students'),
|
|
152
|
+
reviewed: z.boolean().optional().describe('Defaults to true'),
|
|
153
|
+
confirm: schemaConfirm,
|
|
154
|
+
},
|
|
155
|
+
}, async ({ student_id, reviewed, confirm }) => {
|
|
156
|
+
const value = reviewed ?? true;
|
|
157
|
+
const gate = previewUnlessConfirmed(confirm, `Mark student ${student_id}'s defaults as ${value ? 'reviewed' : 'not reviewed'}`, 'PUT', 'SetDefaultsReviewed', { StudentId: student_id, Reviewed: value });
|
|
158
|
+
if (gate)
|
|
159
|
+
return gate;
|
|
160
|
+
await client.setDefaultsReviewed(student_id, value);
|
|
161
|
+
const review = await client.getDefaultPlansReviewNeeded();
|
|
162
|
+
const needsReview = review.find((r) => r.StudentId === student_id)?.NeedsReview ?? false;
|
|
163
|
+
return textResult({
|
|
164
|
+
studentId: student_id,
|
|
165
|
+
needsDefaultsReview: needsReview,
|
|
166
|
+
verified: needsReview === !value,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { PickUpPatrolClient } from '../client.js';
|
|
3
|
+
import type { Student, Transportation } from '../types.js';
|
|
4
|
+
/**
|
|
5
|
+
* The transportation id a date should show once a write has landed.
|
|
6
|
+
*
|
|
7
|
+
* For a normal change that is the id we asked for. For a revert
|
|
8
|
+
* (`TransportationId: null`) the date falls back to the student's default for
|
|
9
|
+
* that weekday, so the id to expect is that default's — and when the student
|
|
10
|
+
* has no default for the day there is nothing to assert, hence `undefined`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function expectedTransportationId(student: Student, planDate: string, requested: number | null): number | null | undefined;
|
|
13
|
+
/** Resolve a transportation id against the school's list, or fail with the options. */
|
|
14
|
+
export declare function resolveTransportation(client: PickUpPatrolClient, schoolId: number, transportationId: number): Promise<Transportation>;
|
|
15
|
+
export declare function registerPlanTools(server: McpServer, client: PickUpPatrolClient): void;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { McpToolError, textResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import { buildPlanUpdates } from '../plans.js';
|
|
4
|
+
import { dateToDayId, weekdayOf } from '../dates.js';
|
|
5
|
+
import { previewUnlessConfirmed, schemaConfirm } from './_confirm.js';
|
|
6
|
+
import { withHints } from './_errors.js';
|
|
7
|
+
/**
|
|
8
|
+
* The transportation id a date should show once a write has landed.
|
|
9
|
+
*
|
|
10
|
+
* For a normal change that is the id we asked for. For a revert
|
|
11
|
+
* (`TransportationId: null`) the date falls back to the student's default for
|
|
12
|
+
* that weekday, so the id to expect is that default's — and when the student
|
|
13
|
+
* has no default for the day there is nothing to assert, hence `undefined`.
|
|
14
|
+
*/
|
|
15
|
+
export function expectedTransportationId(student, planDate, requested) {
|
|
16
|
+
if (requested !== null)
|
|
17
|
+
return requested;
|
|
18
|
+
const dayId = dateToDayId(planDate);
|
|
19
|
+
if (dayId === null)
|
|
20
|
+
return undefined;
|
|
21
|
+
const fallback = (student.DefaultPlans ?? []).find((plan) => plan.DayId === dayId);
|
|
22
|
+
return fallback === undefined ? undefined : (fallback.TransportationId ?? null);
|
|
23
|
+
}
|
|
24
|
+
/** Resolve a transportation id against the school's list, or fail with the options. */
|
|
25
|
+
export async function resolveTransportation(client, schoolId, transportationId) {
|
|
26
|
+
const options = await client.getTransportations(schoolId);
|
|
27
|
+
const match = options.find((o) => o.TransportationId === transportationId);
|
|
28
|
+
if (!match) {
|
|
29
|
+
throw new McpToolError(`No dismissal option ${transportationId} at school ${schoolId}`, {
|
|
30
|
+
hint: `Available: ${options.map((o) => `${o.TransportationId} (${o.Name})`).join(', ')}`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return match;
|
|
34
|
+
}
|
|
35
|
+
export function registerPlanTools(server, client) {
|
|
36
|
+
server.registerTool('pup_list_plans', {
|
|
37
|
+
description: 'Day-by-day dismissal plans across a date range for every student on the account, as PickUp Patrol returns them.',
|
|
38
|
+
annotations: { readOnlyHint: true },
|
|
39
|
+
inputSchema: {
|
|
40
|
+
start_date: z.string().describe('YYYY-MM-DD'),
|
|
41
|
+
end_date: z.string().describe('YYYY-MM-DD'),
|
|
42
|
+
},
|
|
43
|
+
}, async ({ start_date, end_date }) => textResult(await client.getParentPlans(start_date, end_date)));
|
|
44
|
+
server.registerTool('pup_get_plan', {
|
|
45
|
+
description: 'The dismissal plan for one student on one date — the option in force, any note, the early-dismissal time, and whether the date is locked because the cutoff has passed.',
|
|
46
|
+
annotations: { readOnlyHint: true },
|
|
47
|
+
inputSchema: {
|
|
48
|
+
student_id: z.number().int().describe('Student id, from pup_list_students'),
|
|
49
|
+
date: z.string().describe('YYYY-MM-DD'),
|
|
50
|
+
},
|
|
51
|
+
}, async ({ student_id, date }) => textResult(await client.getPlanEdit(date, student_id)));
|
|
52
|
+
server.registerTool('pup_set_plan', {
|
|
53
|
+
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, so it requires confirm: true; without it you get a dry-run of the exact payload. Read pup_list_transportations first — options differ in whether they require a note, a car number or an early-dismissal time.",
|
|
54
|
+
inputSchema: {
|
|
55
|
+
student_id: z.number().int().describe('Student id, from pup_list_students'),
|
|
56
|
+
dates: z
|
|
57
|
+
.array(z.string())
|
|
58
|
+
.min(1)
|
|
59
|
+
.describe('One or more YYYY-MM-DD dates to apply this plan to'),
|
|
60
|
+
transportation_id: z
|
|
61
|
+
.number()
|
|
62
|
+
.int()
|
|
63
|
+
.nullable()
|
|
64
|
+
.describe("Dismissal option id from pup_list_transportations, or null to clear these dates back to the student's default plan"),
|
|
65
|
+
note: z.string().optional().describe('Note for the school; required by some options'),
|
|
66
|
+
early_dismissal_time: z
|
|
67
|
+
.string()
|
|
68
|
+
.optional()
|
|
69
|
+
.describe('HH:MM, required when the option is an early dismissal'),
|
|
70
|
+
car_number: z
|
|
71
|
+
.string()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe('Car number, for options where usesCarNumbers is true'),
|
|
74
|
+
confirm: schemaConfirm,
|
|
75
|
+
},
|
|
76
|
+
}, withHints(async ({ student_id, dates, transportation_id, note, early_dismissal_time, car_number, confirm }) => {
|
|
77
|
+
// The reads below resolve and validate the payload; they mutate nothing.
|
|
78
|
+
// Running them before the confirm gate is deliberate: it makes the
|
|
79
|
+
// dry-run show the exact bytes that would be sent, already checked
|
|
80
|
+
// against this school's rules, instead of an unvalidated echo of the
|
|
81
|
+
// arguments.
|
|
82
|
+
const student = await client.getStudent(student_id);
|
|
83
|
+
const transportation = transportation_id === null
|
|
84
|
+
? null
|
|
85
|
+
: await resolveTransportation(client, student.SchoolId, transportation_id);
|
|
86
|
+
const plans = buildPlanUpdates({
|
|
87
|
+
student,
|
|
88
|
+
dates,
|
|
89
|
+
transportation,
|
|
90
|
+
note,
|
|
91
|
+
earlyDismissalTime: early_dismissal_time,
|
|
92
|
+
carNumber: car_number,
|
|
93
|
+
});
|
|
94
|
+
const action = transportation === null
|
|
95
|
+
? `Clear ${dates.length} date(s) back to ${student.FirstName ?? 'the student'}'s default plan`
|
|
96
|
+
: `Set ${dates.length} date(s) for ${student.FirstName ?? 'the student'} to "${transportation.Name}"`;
|
|
97
|
+
const gate = previewUnlessConfirmed(confirm, action, 'PUT', 'UpdatePlans', { Plans: plans });
|
|
98
|
+
if (gate)
|
|
99
|
+
return gate;
|
|
100
|
+
await client.updatePlans(plans);
|
|
101
|
+
// A 2xx is not proof the change persisted — re-read each date and
|
|
102
|
+
// compare the one field that proves it. ModifiedDate is deliberately not
|
|
103
|
+
// compared: it advances on its own, which would make every write look
|
|
104
|
+
// successful.
|
|
105
|
+
const verification = await Promise.all(dates.map(async (date) => {
|
|
106
|
+
const after = await client.getPlanEdit(date, student_id);
|
|
107
|
+
const expected = expectedTransportationId(student, date, transportation_id);
|
|
108
|
+
const actual = after.TransportationId ?? null;
|
|
109
|
+
return {
|
|
110
|
+
date,
|
|
111
|
+
weekday: weekdayOf(date),
|
|
112
|
+
transportationId: actual,
|
|
113
|
+
transportation: after.TransportationName ?? null,
|
|
114
|
+
note: after.Note ?? null,
|
|
115
|
+
earlyDismissalTime: after.EarlyDismissalTime ?? null,
|
|
116
|
+
locked: after.IsLocked ?? false,
|
|
117
|
+
verified: expected === undefined ? null : actual === expected,
|
|
118
|
+
};
|
|
119
|
+
}));
|
|
120
|
+
const failed = verification.filter((v) => v.verified === false);
|
|
121
|
+
return textResult({
|
|
122
|
+
action,
|
|
123
|
+
applied: verification,
|
|
124
|
+
verified: failed.length === 0,
|
|
125
|
+
...(failed.length > 0
|
|
126
|
+
? {
|
|
127
|
+
warning: 'PickUp Patrol accepted the request but these dates did not change — they are usually past the school cutoff, or the date is not a school day.',
|
|
128
|
+
unchanged: failed.map((v) => v.date),
|
|
129
|
+
}
|
|
130
|
+
: {}),
|
|
131
|
+
});
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { PickUpPatrolClient } from '../client.js';
|
|
3
|
+
import type { Transportation } from '../types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Project a transportation to its identity plus the rules that decide what a
|
|
6
|
+
* plan using it must carry — an agent choosing an option needs those rules up
|
|
7
|
+
* front, not after a rejected write.
|
|
8
|
+
*/
|
|
9
|
+
export declare function summarizeTransportation(option: Transportation): Record<string, unknown>;
|
|
10
|
+
export declare function registerSchoolTools(server: McpServer, client: PickUpPatrolClient): void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
3
|
+
/**
|
|
4
|
+
* Project a transportation to its identity plus the rules that decide what a
|
|
5
|
+
* plan using it must carry — an agent choosing an option needs those rules up
|
|
6
|
+
* front, not after a rejected write.
|
|
7
|
+
*/
|
|
8
|
+
export function summarizeTransportation(option) {
|
|
9
|
+
return {
|
|
10
|
+
transportationId: option.TransportationId,
|
|
11
|
+
name: option.Name,
|
|
12
|
+
noteRequired: option.IsNoteRequired ?? false,
|
|
13
|
+
noteHint: option.NoteHint ?? null,
|
|
14
|
+
usesCarNumbers: option.UseCarNumbers ?? false,
|
|
15
|
+
isEarlyDismissal: option.IsEarlyDismissal ?? false,
|
|
16
|
+
isLimited: option.IsLimited ?? false,
|
|
17
|
+
cutoffTime: option.CutoffTime ?? null,
|
|
18
|
+
active: option.IsActive ?? true,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function registerSchoolTools(server, client) {
|
|
22
|
+
server.registerTool('pup_list_transportations', {
|
|
23
|
+
description: 'The dismissal options a school offers (bus, car pickup, walker, absent …) with the rules each one imposes: whether a note is required, whether it takes a car number, whether it is an early dismissal, and the daily cutoff time. Read this before setting a plan.',
|
|
24
|
+
annotations: { readOnlyHint: true },
|
|
25
|
+
inputSchema: {
|
|
26
|
+
school_id: z.number().int().describe('School id, from pup_list_students'),
|
|
27
|
+
include_inactive: z
|
|
28
|
+
.boolean()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Include options the school has deactivated (default false)'),
|
|
31
|
+
},
|
|
32
|
+
}, async ({ school_id, include_inactive }) => {
|
|
33
|
+
const options = await client.getTransportations(school_id);
|
|
34
|
+
const visible = include_inactive === true ? options : options.filter((o) => o.IsActive !== false);
|
|
35
|
+
return textResult(visible.map(summarizeTransportation));
|
|
36
|
+
});
|
|
37
|
+
server.registerTool('pup_get_school', {
|
|
38
|
+
description: 'A school profile together with its per-weekday notify times and plan cutoff times, and the settings that decide whether parents may set plans at all.',
|
|
39
|
+
annotations: { readOnlyHint: true },
|
|
40
|
+
inputSchema: {
|
|
41
|
+
school_id: z.number().int().describe('School id, from pup_list_students'),
|
|
42
|
+
},
|
|
43
|
+
}, async ({ school_id }) => {
|
|
44
|
+
const [school, notifyTimes, settings] = await Promise.all([
|
|
45
|
+
client.getSchool(school_id),
|
|
46
|
+
client.getSchoolNotifyTimes(school_id),
|
|
47
|
+
client.getSchoolSettings(school_id),
|
|
48
|
+
]);
|
|
49
|
+
return textResult({ school, notifyTimes, settings });
|
|
50
|
+
});
|
|
51
|
+
server.registerTool('pup_list_non_school_days', {
|
|
52
|
+
description: 'Dates a plan cannot be set for at a school (holidays, closures, weekends), and optionally the dates in a range that already differ from the student defaults.',
|
|
53
|
+
annotations: { readOnlyHint: true },
|
|
54
|
+
inputSchema: {
|
|
55
|
+
school_id: z.number().int().describe('School id, from pup_list_students'),
|
|
56
|
+
start_date: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe('YYYY-MM-DD; with end_date, also return dates that differ from the default'),
|
|
60
|
+
end_date: z.string().optional().describe('YYYY-MM-DD'),
|
|
61
|
+
},
|
|
62
|
+
}, async ({ school_id, start_date, end_date }) => {
|
|
63
|
+
const invalidDates = await client.getInvalidPlanDates(school_id);
|
|
64
|
+
if (start_date === undefined || end_date === undefined) {
|
|
65
|
+
return textResult({ invalidDates });
|
|
66
|
+
}
|
|
67
|
+
const changedDates = await client.getBoldedDates(start_date, end_date);
|
|
68
|
+
return textResult({ invalidDates, changedDates });
|
|
69
|
+
});
|
|
70
|
+
server.registerTool('pup_list_car_numbers', {
|
|
71
|
+
description: 'The car numbers a school has issued to this account, for dismissal options where usesCarNumbers is true.',
|
|
72
|
+
annotations: { readOnlyHint: true },
|
|
73
|
+
inputSchema: {
|
|
74
|
+
school_id: z.number().int().describe('School id, from pup_list_students'),
|
|
75
|
+
},
|
|
76
|
+
}, async ({ school_id }) => textResult(await client.getCarNumbers(school_id)));
|
|
77
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types for the PickUp Patrol ServiceStack API.
|
|
3
|
+
*
|
|
4
|
+
* Every field here was read off the shipped SPA's own request/response DTO
|
|
5
|
+
* classes or observed on a live response — see `docs/PICKUPPATROL-API.md` for
|
|
6
|
+
* the capture. Responses are typed loosely on purpose: the API is
|
|
7
|
+
* undocumented, so an unexpected extra field must not break a read.
|
|
8
|
+
*/
|
|
9
|
+
/** ServiceStack's error envelope, present on any non-2xx JSON body. */
|
|
10
|
+
export interface ResponseStatus {
|
|
11
|
+
ErrorCode?: string | null;
|
|
12
|
+
Message?: string | null;
|
|
13
|
+
Errors?: Array<{
|
|
14
|
+
ErrorCode?: string | null;
|
|
15
|
+
FieldName?: string | null;
|
|
16
|
+
Message?: string | null;
|
|
17
|
+
}> | null;
|
|
18
|
+
}
|
|
19
|
+
export interface AuthenticateResponse {
|
|
20
|
+
UserId?: string | number | null;
|
|
21
|
+
SessionId?: string | null;
|
|
22
|
+
UserName?: string | null;
|
|
23
|
+
DisplayName?: string | null;
|
|
24
|
+
BearerToken?: string | null;
|
|
25
|
+
RefreshToken?: string | null;
|
|
26
|
+
ResponseStatus?: ResponseStatus | null;
|
|
27
|
+
}
|
|
28
|
+
/** One weekday of a student's recurring dismissal plan. */
|
|
29
|
+
export interface DefaultPlan {
|
|
30
|
+
/** 1-based, **Sunday = 1** — the SPA renders `dayNamesMin[DayId - 1]`. */
|
|
31
|
+
DayId: number;
|
|
32
|
+
StudentId?: number;
|
|
33
|
+
TransportationId?: number | null;
|
|
34
|
+
TransportationName?: string | null;
|
|
35
|
+
Note?: string | null;
|
|
36
|
+
WeekDayName?: string | null;
|
|
37
|
+
EarlyDismissalTime?: string | null;
|
|
38
|
+
CarNumber?: string | null;
|
|
39
|
+
UseCarNumbers?: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface Student {
|
|
42
|
+
StudentId: number;
|
|
43
|
+
SchoolId: number;
|
|
44
|
+
SchoolName?: string | null;
|
|
45
|
+
FirstName?: string | null;
|
|
46
|
+
LastName?: string | null;
|
|
47
|
+
IsActive?: boolean;
|
|
48
|
+
SASId?: string | null;
|
|
49
|
+
TeacherId?: number | null;
|
|
50
|
+
AllowPlans?: boolean;
|
|
51
|
+
CreateDate?: string | null;
|
|
52
|
+
CreatedBy?: number | null;
|
|
53
|
+
ModifiedDate?: string | null;
|
|
54
|
+
ModifiedBy?: number | null;
|
|
55
|
+
DefaultsModifiedDate?: string | null;
|
|
56
|
+
DefaultPlanModifiedBy?: number | null;
|
|
57
|
+
DefaultsReviewedDate?: string | null;
|
|
58
|
+
DefaultsReviewedBy?: number | null;
|
|
59
|
+
DefaultPlans?: DefaultPlan[] | null;
|
|
60
|
+
SafetyFlag?: boolean | null;
|
|
61
|
+
DefaultCarNumber?: string | null;
|
|
62
|
+
LimitedIds?: number[] | null;
|
|
63
|
+
}
|
|
64
|
+
export interface Transportation {
|
|
65
|
+
TransportationId: number;
|
|
66
|
+
SchoolId: number;
|
|
67
|
+
Name: string;
|
|
68
|
+
NoteHint?: string | null;
|
|
69
|
+
IsNoteRequired?: boolean;
|
|
70
|
+
UseCarNumbers?: boolean;
|
|
71
|
+
IsNotePrivate?: boolean;
|
|
72
|
+
IsActive?: boolean;
|
|
73
|
+
Sequence?: number | null;
|
|
74
|
+
IsEarlyDismissal?: boolean;
|
|
75
|
+
IsLimited?: boolean;
|
|
76
|
+
CutoffTime?: string | null;
|
|
77
|
+
AllowParentCheck?: boolean;
|
|
78
|
+
}
|
|
79
|
+
export interface PlanEdit {
|
|
80
|
+
PlanDate?: string | null;
|
|
81
|
+
StudentId?: number;
|
|
82
|
+
FirstName?: string | null;
|
|
83
|
+
LastName?: string | null;
|
|
84
|
+
SchoolId?: number;
|
|
85
|
+
TransportationId?: number | null;
|
|
86
|
+
TransportationName?: string | null;
|
|
87
|
+
Note?: string | null;
|
|
88
|
+
IsLocked?: boolean;
|
|
89
|
+
SchoolName?: string | null;
|
|
90
|
+
BusRouteUrl?: string | null;
|
|
91
|
+
ValidationErrors?: unknown;
|
|
92
|
+
EarlyDismissalTime?: string | null;
|
|
93
|
+
CarNumber?: string | null;
|
|
94
|
+
LimitedIds?: number[] | null;
|
|
95
|
+
IsNotePrivate?: boolean;
|
|
96
|
+
}
|
|
97
|
+
/** One element of the `UpdatePlans` request array. */
|
|
98
|
+
export interface PlanUpdate {
|
|
99
|
+
StudentId: number;
|
|
100
|
+
SchoolId: number;
|
|
101
|
+
PlanDate: string;
|
|
102
|
+
TransportationId: number | null;
|
|
103
|
+
TransportationName: string;
|
|
104
|
+
Note: string | null;
|
|
105
|
+
EarlyDismissalTime?: string;
|
|
106
|
+
CarNumber?: string;
|
|
107
|
+
}
|
|
108
|
+
export interface SessionResponse {
|
|
109
|
+
UserId?: number | null;
|
|
110
|
+
FirstName?: string | null;
|
|
111
|
+
LastName?: string | null;
|
|
112
|
+
DisplayName?: string | null;
|
|
113
|
+
Email?: string | null;
|
|
114
|
+
PrimaryEmail?: string | null;
|
|
115
|
+
LastLoginDate?: string | null;
|
|
116
|
+
HasAcceptedLatestTerms?: boolean;
|
|
117
|
+
SendPlanConfirmEmails?: boolean;
|
|
118
|
+
Children?: Array<{
|
|
119
|
+
StudentId: number;
|
|
120
|
+
SchoolId: number;
|
|
121
|
+
}> | null;
|
|
122
|
+
}
|
|
123
|
+
export interface School {
|
|
124
|
+
SchoolId: number;
|
|
125
|
+
Name?: string | null;
|
|
126
|
+
IsActive?: boolean;
|
|
127
|
+
TimeZoneId?: string | null;
|
|
128
|
+
HelpPhone?: string | null;
|
|
129
|
+
HelpEmail?: string | null;
|
|
130
|
+
BusRouteUrl?: string | null;
|
|
131
|
+
AllowPlans?: boolean;
|
|
132
|
+
AllowDefaultPlans?: boolean;
|
|
133
|
+
[key: string]: unknown;
|
|
134
|
+
}
|
|
135
|
+
export interface SchoolNotifyTimes {
|
|
136
|
+
SchoolId: number;
|
|
137
|
+
[key: string]: unknown;
|
|
138
|
+
}
|
|
139
|
+
export interface DefaultsReviewNeeded {
|
|
140
|
+
StudentId: number;
|
|
141
|
+
NeedsReview: boolean;
|
|
142
|
+
}
|