urbitools-mcp 0.0.0-stage → 0.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/README.md +34 -2
- package/dist/http.js +315 -0
- package/dist/stdio.js +297 -0
- package/package.json +16 -4
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# urbitools-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Free MCP server for UK employment calculators from [urbitools.com](https://urbitools.com), using 2026/27 statutory rates. It lets any MCP-compatible assistant run the calculations directly.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
| Tool | What it does |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `calculate_redundancy_pay` | Statutory redundancy pay: age bands, 20-year cap, 2-year eligibility, £751 (GB) / £783 (NI) weekly cap, optional enhanced multiplier |
|
|
10
|
+
| `calculate_paternity_pay` | Paternity leave and pay eligibility, weekly rate (£194.32 or 90% of earnings), total and claim deadline |
|
|
11
|
+
| `calculate_notice_pay` | PILON: higher of statutory and contractual notice, uncapped weekly pay, net estimate |
|
|
12
|
+
|
|
13
|
+
Results are estimates, not legal, tax or financial advice. Calculations run locally and nothing you enter is sent anywhere.
|
|
14
|
+
|
|
15
|
+
## Use with Claude Desktop / Claude Code
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"urbitools": { "command": "npx", "args": ["-y", "urbitools-mcp"] }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Claude Code: `claude mcp add urbitools -- npx -y urbitools-mcp`
|
|
26
|
+
|
|
27
|
+
## Develop
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install
|
|
31
|
+
npm test # builds and runs the smoke test over stdio
|
|
32
|
+
node dist/http.js # optional: stateless Streamable HTTP on :3000/mcp (PORT to change)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The calculation code is shared with the website (`../lib/calc-*.ts`) and bundled at build time.
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// src/http.ts
|
|
2
|
+
import { createServer as createHttp } from "node:http";
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
|
|
5
|
+
// src/server.ts
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
// ../lib/calc-redundancy.ts
|
|
10
|
+
var REDUNDANCY_CAPS = { gb: 751, ni: 783 };
|
|
11
|
+
function parseLocalDate(s) {
|
|
12
|
+
const [y, m, d] = s.split("-").map(Number);
|
|
13
|
+
return new Date(y, m - 1, d);
|
|
14
|
+
}
|
|
15
|
+
function fullYears(from, to) {
|
|
16
|
+
let y = to.getFullYear() - from.getFullYear();
|
|
17
|
+
const m = to.getMonth() - from.getMonth();
|
|
18
|
+
if (m < 0 || m === 0 && to.getDate() < from.getDate()) y--;
|
|
19
|
+
return y;
|
|
20
|
+
}
|
|
21
|
+
function addYears(d, n) {
|
|
22
|
+
const x = new Date(d);
|
|
23
|
+
x.setFullYear(x.getFullYear() + n);
|
|
24
|
+
return x;
|
|
25
|
+
}
|
|
26
|
+
function formatGBDate(d) {
|
|
27
|
+
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
|
|
28
|
+
}
|
|
29
|
+
function calculateRedundancy(input) {
|
|
30
|
+
const { dob, start, end, weeklyPay, jurisdiction, multiplier } = input;
|
|
31
|
+
if (!dob || !start) return { ok: false, error: "Please enter your date of birth and employment start date." };
|
|
32
|
+
if (!isFinite(weeklyPay) || weeklyPay <= 0) return { ok: false, error: "Please enter your gross weekly pay." };
|
|
33
|
+
const dobD = parseLocalDate(dob);
|
|
34
|
+
const startD = parseLocalDate(start);
|
|
35
|
+
const endD = end ? parseLocalDate(end) : /* @__PURE__ */ new Date();
|
|
36
|
+
endD.setHours(0, 0, 0, 0);
|
|
37
|
+
if (endD < startD) return { ok: false, error: "Employment end date can't be before the start date." };
|
|
38
|
+
if (dobD >= endD) return { ok: false, error: "Date of birth must be before the employment end date." };
|
|
39
|
+
const cap = REDUNDANCY_CAPS[jurisdiction];
|
|
40
|
+
const cappedPay = Math.min(weeklyPay, cap);
|
|
41
|
+
const serviceYears = Math.min(fullYears(startD, endD), 20);
|
|
42
|
+
if (serviceYears < 2) {
|
|
43
|
+
return {
|
|
44
|
+
ok: true,
|
|
45
|
+
eligible: false,
|
|
46
|
+
serviceYears: fullYears(startD, endD),
|
|
47
|
+
countedYears: 0,
|
|
48
|
+
cap,
|
|
49
|
+
cappedPay,
|
|
50
|
+
rows: [],
|
|
51
|
+
total: 0,
|
|
52
|
+
enhanced: 0,
|
|
53
|
+
multiplier,
|
|
54
|
+
capped: weeklyPay > cap
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const rows = [];
|
|
58
|
+
let total = 0;
|
|
59
|
+
let counted = 0;
|
|
60
|
+
for (let i = 0; i < serviceYears; i++) {
|
|
61
|
+
const yearEnd = addYears(endD, -i);
|
|
62
|
+
const age = fullYears(dobD, yearEnd);
|
|
63
|
+
if (age < 18) continue;
|
|
64
|
+
counted++;
|
|
65
|
+
let weeks;
|
|
66
|
+
let band;
|
|
67
|
+
if (age >= 41) {
|
|
68
|
+
weeks = 1.5;
|
|
69
|
+
band = "41+ \xB7 1.5 weeks";
|
|
70
|
+
} else if (age >= 22) {
|
|
71
|
+
weeks = 1;
|
|
72
|
+
band = "22\u201340 \xB7 1 week";
|
|
73
|
+
} else {
|
|
74
|
+
weeks = 0.5;
|
|
75
|
+
band = "18\u201321 \xB7 0.5 weeks";
|
|
76
|
+
}
|
|
77
|
+
const amount = weeks * cappedPay;
|
|
78
|
+
total += amount;
|
|
79
|
+
rows.push({
|
|
80
|
+
year: i + 1,
|
|
81
|
+
fromLabel: formatGBDate(addYears(yearEnd, -1)),
|
|
82
|
+
toLabel: formatGBDate(yearEnd),
|
|
83
|
+
age,
|
|
84
|
+
band,
|
|
85
|
+
weeks,
|
|
86
|
+
amount
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
eligible: true,
|
|
92
|
+
serviceYears,
|
|
93
|
+
countedYears: counted,
|
|
94
|
+
cap,
|
|
95
|
+
cappedPay,
|
|
96
|
+
rows,
|
|
97
|
+
total,
|
|
98
|
+
enhanced: total * Math.max(1, multiplier || 1),
|
|
99
|
+
multiplier: Math.max(1, multiplier || 1),
|
|
100
|
+
capped: weeklyPay > cap
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ../lib/calc-paternity.ts
|
|
105
|
+
var SPP_RATE = 194.32;
|
|
106
|
+
var SPP_LEL = 129;
|
|
107
|
+
var DAY_ONE_LEAVE = "2026-04-06";
|
|
108
|
+
function parseLocalDate2(s) {
|
|
109
|
+
const [y, m, d] = s.split("-").map(Number);
|
|
110
|
+
return new Date(y, m - 1, d);
|
|
111
|
+
}
|
|
112
|
+
function addDays(d, n) {
|
|
113
|
+
const x = new Date(d);
|
|
114
|
+
x.setDate(x.getDate() + n);
|
|
115
|
+
return x;
|
|
116
|
+
}
|
|
117
|
+
function weekStart(x) {
|
|
118
|
+
const y = new Date(x);
|
|
119
|
+
y.setDate(y.getDate() - y.getDay());
|
|
120
|
+
y.setHours(0, 0, 0, 0);
|
|
121
|
+
return y;
|
|
122
|
+
}
|
|
123
|
+
function formatGBDate2(d) {
|
|
124
|
+
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
|
|
125
|
+
}
|
|
126
|
+
function calculatePaternity(input) {
|
|
127
|
+
const { due, start, averageWeeklyEarnings, leaveWeeks } = input;
|
|
128
|
+
if (!due || !start) return { ok: false, error: "Please enter the due date and your employment start date." };
|
|
129
|
+
if (!isFinite(averageWeeklyEarnings) || averageWeeklyEarnings < 0)
|
|
130
|
+
return { ok: false, error: "Please enter your average weekly earnings." };
|
|
131
|
+
const dueD = parseLocalDate2(due);
|
|
132
|
+
const startD = parseLocalDate2(start);
|
|
133
|
+
if (startD > dueD) return { ok: false, error: "Employment start date can't be after the due date." };
|
|
134
|
+
const ewc = weekStart(dueD);
|
|
135
|
+
const qualWeekStart = addDays(ewc, -15 * 7);
|
|
136
|
+
const qualWeekEnd = addDays(qualWeekStart, 6);
|
|
137
|
+
const serviceCutoff = addDays(qualWeekEnd, -26 * 7 + 1);
|
|
138
|
+
const serviceOK = startD <= serviceCutoff;
|
|
139
|
+
const earnOK = averageWeeklyEarnings >= SPP_LEL;
|
|
140
|
+
const payOK = serviceOK && earnOK;
|
|
141
|
+
const dayOneApplies = dueD >= parseLocalDate2(DAY_ONE_LEAVE);
|
|
142
|
+
const leaveOK = dayOneApplies ? true : serviceOK;
|
|
143
|
+
const weeklyRate = Math.min(SPP_RATE, averageWeeklyEarnings * 0.9);
|
|
144
|
+
const total = payOK ? weeklyRate * leaveWeeks : 0;
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
leaveEligible: leaveOK,
|
|
148
|
+
payEligible: payOK,
|
|
149
|
+
weeklyRate,
|
|
150
|
+
total,
|
|
151
|
+
qualifyingWeekStart: formatGBDate2(qualWeekStart),
|
|
152
|
+
qualifyingWeekEnd: formatGBDate2(qualWeekEnd),
|
|
153
|
+
claimDeadline: formatGBDate2(addDays(dueD, -15 * 7)),
|
|
154
|
+
belowCap: averageWeeklyEarnings * 0.9 < SPP_RATE,
|
|
155
|
+
dayOneApplies
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ../lib/calc-notice.ts
|
|
160
|
+
function calculateNotice(input) {
|
|
161
|
+
const { annualSalary, yearsService, contractualWeeks, workedWeeks, taxRate } = input;
|
|
162
|
+
if (!isFinite(annualSalary) || annualSalary <= 0)
|
|
163
|
+
return { ok: false, error: "Please enter your gross annual salary." };
|
|
164
|
+
if (!isFinite(yearsService) || yearsService < 0)
|
|
165
|
+
return { ok: false, error: "Please enter your complete years of service." };
|
|
166
|
+
if (!isFinite(workedWeeks) || workedWeeks < 0)
|
|
167
|
+
return { ok: false, error: "Weeks already worked can't be negative." };
|
|
168
|
+
if (contractualWeeks != null && (!isFinite(contractualWeeks) || contractualWeeks < 0))
|
|
169
|
+
return { ok: false, error: "Contractual notice weeks can't be negative." };
|
|
170
|
+
const noStatutoryMinimum = yearsService < 1;
|
|
171
|
+
const statutoryWeeks = noStatutoryMinimum ? 0 : Math.min(12, Math.max(1, Math.floor(yearsService)));
|
|
172
|
+
const applicableWeeks = Math.max(statutoryWeeks, contractualWeeks ?? 0);
|
|
173
|
+
const unworkedWeeks = Math.max(0, applicableWeeks - workedWeeks);
|
|
174
|
+
const weeklyPay = annualSalary / 52;
|
|
175
|
+
const gross = weeklyPay * unworkedWeeks;
|
|
176
|
+
const tax = gross * taxRate;
|
|
177
|
+
const net = gross - tax;
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
statutoryWeeks,
|
|
181
|
+
contractualWeeks,
|
|
182
|
+
applicableWeeks,
|
|
183
|
+
workedWeeks,
|
|
184
|
+
unworkedWeeks,
|
|
185
|
+
weeklyPay,
|
|
186
|
+
gross,
|
|
187
|
+
tax,
|
|
188
|
+
net,
|
|
189
|
+
taxRate,
|
|
190
|
+
noStatutoryMinimum
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/server.ts
|
|
195
|
+
var SITE = "https://urbitools.com";
|
|
196
|
+
var DISCLAIMER = "Estimate only, not legal, tax or financial advice. Rates are for the 2026/27 tax year.";
|
|
197
|
+
var date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD");
|
|
198
|
+
function reply(result, page, summary) {
|
|
199
|
+
if (!result.ok) {
|
|
200
|
+
return { isError: true, content: [{ type: "text", text: result.error ?? "Calculation failed." }] };
|
|
201
|
+
}
|
|
202
|
+
const text = `${summary(result)}
|
|
203
|
+
|
|
204
|
+
Full working and an interactive version: ${SITE}${page}
|
|
205
|
+
${DISCLAIMER}`;
|
|
206
|
+
return {
|
|
207
|
+
content: [{ type: "text", text }],
|
|
208
|
+
structuredContent: { ...result, source: `${SITE}${page}`, disclaimer: DISCLAIMER }
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
var gbp = (n) => "\xA3" + n.toLocaleString("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
212
|
+
function createServer() {
|
|
213
|
+
const server = new McpServer({ name: "urbitools", version: "0.1.0" });
|
|
214
|
+
server.registerTool(
|
|
215
|
+
"calculate_redundancy_pay",
|
|
216
|
+
{
|
|
217
|
+
title: "UK statutory redundancy pay",
|
|
218
|
+
description: "Calculate UK statutory redundancy pay (2026/27): age-band formula (0.5/1/1.5 weeks' pay per year), 20-year cap, 2-year eligibility, weekly pay cap of \xA3751 (GB) or \xA3783 (NI), with a year-by-year breakdown and optional enhanced-scheme multiplier. Source: urbitools.com.",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
date_of_birth: date.describe("Employee date of birth, YYYY-MM-DD"),
|
|
221
|
+
employment_start: date.describe("Employment start date, YYYY-MM-DD"),
|
|
222
|
+
employment_end: date.optional().describe("Last day of employment, YYYY-MM-DD. Defaults to today."),
|
|
223
|
+
weekly_pay: z.number().positive().describe("Gross weekly pay in GBP (before tax)"),
|
|
224
|
+
jurisdiction: z.enum(["gb", "ni"]).default("gb").describe("gb = Great Britain, ni = Northern Ireland"),
|
|
225
|
+
enhanced_multiplier: z.number().min(1).default(1).describe("1 = statutory minimum; e.g. 2 for a scheme paying double")
|
|
226
|
+
},
|
|
227
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
228
|
+
},
|
|
229
|
+
async (a) => reply(
|
|
230
|
+
calculateRedundancy({
|
|
231
|
+
dob: a.date_of_birth,
|
|
232
|
+
start: a.employment_start,
|
|
233
|
+
end: a.employment_end ?? null,
|
|
234
|
+
weeklyPay: a.weekly_pay,
|
|
235
|
+
jurisdiction: a.jurisdiction,
|
|
236
|
+
multiplier: a.enhanced_multiplier
|
|
237
|
+
}),
|
|
238
|
+
"/redundancy-pay-calculator",
|
|
239
|
+
(r) => r.eligible ? `Statutory redundancy pay: ${gbp(r.total)} (${r.countedYears} counted years, weekly pay used ${gbp(r.cappedPay)}${r.capped ? `, capped at ${gbp(r.cap)}` : ""}).` + (r.multiplier !== 1 ? ` With a ${r.multiplier}x enhanced scheme: ${gbp(r.enhanced)}.` : "") + ` Statutory redundancy pay is normally tax-free within the \xA330,000 termination-payments allowance.` : "Not eligible for statutory redundancy pay: it needs at least 2 years' continuous service."
|
|
240
|
+
)
|
|
241
|
+
);
|
|
242
|
+
server.registerTool(
|
|
243
|
+
"calculate_paternity_pay",
|
|
244
|
+
{
|
|
245
|
+
title: "UK statutory paternity pay",
|
|
246
|
+
description: "Check UK paternity leave and pay eligibility and calculate statutory paternity pay (2026/27): \xA3194.32/week or 90% of average weekly earnings if lower, \xA3129/week lower earnings limit, 26 weeks' service by the 15th week before the due date. Source: urbitools.com.",
|
|
247
|
+
inputSchema: {
|
|
248
|
+
due_date: date.describe("Baby's due date, YYYY-MM-DD"),
|
|
249
|
+
employment_start: date.describe("Employment start date, YYYY-MM-DD"),
|
|
250
|
+
average_weekly_earnings: z.number().min(0).describe("Average gross weekly earnings in GBP"),
|
|
251
|
+
leave_weeks: z.union([z.literal(1), z.literal(2)]).default(2).describe("Weeks of paternity leave: 1 or 2")
|
|
252
|
+
},
|
|
253
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
254
|
+
},
|
|
255
|
+
async (a) => reply(
|
|
256
|
+
calculatePaternity({
|
|
257
|
+
due: a.due_date,
|
|
258
|
+
start: a.employment_start,
|
|
259
|
+
averageWeeklyEarnings: a.average_weekly_earnings,
|
|
260
|
+
leaveWeeks: a.leave_weeks
|
|
261
|
+
}),
|
|
262
|
+
"/paternity-pay-calculator",
|
|
263
|
+
(r) => `Paternity leave eligible: ${r.leaveEligible ? "yes" : "no"}. Paternity pay eligible: ${r.payEligible ? "yes" : "no"}.` + (r.payEligible ? ` Weekly rate ${gbp(r.weeklyRate)}${r.belowCap ? " (90% of earnings, below the flat rate)" : ""}, total ${gbp(r.total)}. Claim deadline ${r.claimDeadline}.` : "")
|
|
264
|
+
)
|
|
265
|
+
);
|
|
266
|
+
server.registerTool(
|
|
267
|
+
"calculate_notice_pay",
|
|
268
|
+
{
|
|
269
|
+
title: "UK notice pay / PILON",
|
|
270
|
+
description: "Estimate UK payment in lieu of notice (PILON): uses the higher of statutory notice (1 week per complete year, max 12) and contractual notice, uncapped weekly pay (salary / 52), with a net estimate. PILON is fully taxable as earnings; the \xA330,000 allowance does not apply. Source: urbitools.com.",
|
|
271
|
+
inputSchema: {
|
|
272
|
+
annual_salary: z.number().positive().describe("Gross annual salary in GBP"),
|
|
273
|
+
years_service: z.number().min(0).describe("Complete years of service"),
|
|
274
|
+
contractual_notice_weeks: z.number().min(0).optional().describe("Contractual notice in weeks, if any"),
|
|
275
|
+
weeks_already_worked: z.number().min(0).default(0).describe("Notice weeks already worked"),
|
|
276
|
+
marginal_tax_rate: z.union([z.literal(0.2), z.literal(0.4), z.literal(0.45)]).default(0.2).describe("Marginal income tax rate for the net estimate")
|
|
277
|
+
},
|
|
278
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
279
|
+
},
|
|
280
|
+
async (a) => reply(
|
|
281
|
+
calculateNotice({
|
|
282
|
+
annualSalary: a.annual_salary,
|
|
283
|
+
yearsService: a.years_service,
|
|
284
|
+
contractualWeeks: a.contractual_notice_weeks ?? null,
|
|
285
|
+
workedWeeks: a.weeks_already_worked,
|
|
286
|
+
taxRate: a.marginal_tax_rate
|
|
287
|
+
}),
|
|
288
|
+
"/notice-pay-calculator",
|
|
289
|
+
(r) => `Notice period applying: ${r.applicableWeeks} weeks (statutory ${r.statutoryWeeks}${r.contractualWeeks != null ? `, contractual ${r.contractualWeeks}` : ""}). Unworked: ${r.unworkedWeeks} weeks at ${gbp(r.weeklyPay)}/week. PILON gross ${gbp(r.gross)}, estimated tax ${gbp(r.tax)}, net ${gbp(r.net)}.` + (r.noStatutoryMinimum ? " Note: under 1 year of service may mean no statutory minimum notice." : "")
|
|
290
|
+
)
|
|
291
|
+
);
|
|
292
|
+
return server;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/http.ts
|
|
296
|
+
createHttp(async (req, res) => {
|
|
297
|
+
if (req.url?.split("?")[0] !== "/mcp") {
|
|
298
|
+
res.writeHead(404).end("Not found");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (req.method !== "POST") {
|
|
302
|
+
res.writeHead(405, { Allow: "POST" }).end("Method not allowed");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const chunks = [];
|
|
306
|
+
for await (const c of req) chunks.push(c);
|
|
307
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
|
|
308
|
+
const server = createServer();
|
|
309
|
+
res.on("close", () => {
|
|
310
|
+
transport.close();
|
|
311
|
+
server.close();
|
|
312
|
+
});
|
|
313
|
+
await server.connect(transport);
|
|
314
|
+
await transport.handleRequest(req, res, JSON.parse(Buffer.concat(chunks).toString() || "{}"));
|
|
315
|
+
}).listen(Number(process.env.PORT ?? 3e3));
|
package/dist/stdio.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/stdio.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/server.ts
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
// ../lib/calc-redundancy.ts
|
|
11
|
+
var REDUNDANCY_CAPS = { gb: 751, ni: 783 };
|
|
12
|
+
function parseLocalDate(s) {
|
|
13
|
+
const [y, m, d] = s.split("-").map(Number);
|
|
14
|
+
return new Date(y, m - 1, d);
|
|
15
|
+
}
|
|
16
|
+
function fullYears(from, to) {
|
|
17
|
+
let y = to.getFullYear() - from.getFullYear();
|
|
18
|
+
const m = to.getMonth() - from.getMonth();
|
|
19
|
+
if (m < 0 || m === 0 && to.getDate() < from.getDate()) y--;
|
|
20
|
+
return y;
|
|
21
|
+
}
|
|
22
|
+
function addYears(d, n) {
|
|
23
|
+
const x = new Date(d);
|
|
24
|
+
x.setFullYear(x.getFullYear() + n);
|
|
25
|
+
return x;
|
|
26
|
+
}
|
|
27
|
+
function formatGBDate(d) {
|
|
28
|
+
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
|
|
29
|
+
}
|
|
30
|
+
function calculateRedundancy(input) {
|
|
31
|
+
const { dob, start, end, weeklyPay, jurisdiction, multiplier } = input;
|
|
32
|
+
if (!dob || !start) return { ok: false, error: "Please enter your date of birth and employment start date." };
|
|
33
|
+
if (!isFinite(weeklyPay) || weeklyPay <= 0) return { ok: false, error: "Please enter your gross weekly pay." };
|
|
34
|
+
const dobD = parseLocalDate(dob);
|
|
35
|
+
const startD = parseLocalDate(start);
|
|
36
|
+
const endD = end ? parseLocalDate(end) : /* @__PURE__ */ new Date();
|
|
37
|
+
endD.setHours(0, 0, 0, 0);
|
|
38
|
+
if (endD < startD) return { ok: false, error: "Employment end date can't be before the start date." };
|
|
39
|
+
if (dobD >= endD) return { ok: false, error: "Date of birth must be before the employment end date." };
|
|
40
|
+
const cap = REDUNDANCY_CAPS[jurisdiction];
|
|
41
|
+
const cappedPay = Math.min(weeklyPay, cap);
|
|
42
|
+
const serviceYears = Math.min(fullYears(startD, endD), 20);
|
|
43
|
+
if (serviceYears < 2) {
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
eligible: false,
|
|
47
|
+
serviceYears: fullYears(startD, endD),
|
|
48
|
+
countedYears: 0,
|
|
49
|
+
cap,
|
|
50
|
+
cappedPay,
|
|
51
|
+
rows: [],
|
|
52
|
+
total: 0,
|
|
53
|
+
enhanced: 0,
|
|
54
|
+
multiplier,
|
|
55
|
+
capped: weeklyPay > cap
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const rows = [];
|
|
59
|
+
let total = 0;
|
|
60
|
+
let counted = 0;
|
|
61
|
+
for (let i = 0; i < serviceYears; i++) {
|
|
62
|
+
const yearEnd = addYears(endD, -i);
|
|
63
|
+
const age = fullYears(dobD, yearEnd);
|
|
64
|
+
if (age < 18) continue;
|
|
65
|
+
counted++;
|
|
66
|
+
let weeks;
|
|
67
|
+
let band;
|
|
68
|
+
if (age >= 41) {
|
|
69
|
+
weeks = 1.5;
|
|
70
|
+
band = "41+ \xB7 1.5 weeks";
|
|
71
|
+
} else if (age >= 22) {
|
|
72
|
+
weeks = 1;
|
|
73
|
+
band = "22\u201340 \xB7 1 week";
|
|
74
|
+
} else {
|
|
75
|
+
weeks = 0.5;
|
|
76
|
+
band = "18\u201321 \xB7 0.5 weeks";
|
|
77
|
+
}
|
|
78
|
+
const amount = weeks * cappedPay;
|
|
79
|
+
total += amount;
|
|
80
|
+
rows.push({
|
|
81
|
+
year: i + 1,
|
|
82
|
+
fromLabel: formatGBDate(addYears(yearEnd, -1)),
|
|
83
|
+
toLabel: formatGBDate(yearEnd),
|
|
84
|
+
age,
|
|
85
|
+
band,
|
|
86
|
+
weeks,
|
|
87
|
+
amount
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
eligible: true,
|
|
93
|
+
serviceYears,
|
|
94
|
+
countedYears: counted,
|
|
95
|
+
cap,
|
|
96
|
+
cappedPay,
|
|
97
|
+
rows,
|
|
98
|
+
total,
|
|
99
|
+
enhanced: total * Math.max(1, multiplier || 1),
|
|
100
|
+
multiplier: Math.max(1, multiplier || 1),
|
|
101
|
+
capped: weeklyPay > cap
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ../lib/calc-paternity.ts
|
|
106
|
+
var SPP_RATE = 194.32;
|
|
107
|
+
var SPP_LEL = 129;
|
|
108
|
+
var DAY_ONE_LEAVE = "2026-04-06";
|
|
109
|
+
function parseLocalDate2(s) {
|
|
110
|
+
const [y, m, d] = s.split("-").map(Number);
|
|
111
|
+
return new Date(y, m - 1, d);
|
|
112
|
+
}
|
|
113
|
+
function addDays(d, n) {
|
|
114
|
+
const x = new Date(d);
|
|
115
|
+
x.setDate(x.getDate() + n);
|
|
116
|
+
return x;
|
|
117
|
+
}
|
|
118
|
+
function weekStart(x) {
|
|
119
|
+
const y = new Date(x);
|
|
120
|
+
y.setDate(y.getDate() - y.getDay());
|
|
121
|
+
y.setHours(0, 0, 0, 0);
|
|
122
|
+
return y;
|
|
123
|
+
}
|
|
124
|
+
function formatGBDate2(d) {
|
|
125
|
+
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
|
|
126
|
+
}
|
|
127
|
+
function calculatePaternity(input) {
|
|
128
|
+
const { due, start, averageWeeklyEarnings, leaveWeeks } = input;
|
|
129
|
+
if (!due || !start) return { ok: false, error: "Please enter the due date and your employment start date." };
|
|
130
|
+
if (!isFinite(averageWeeklyEarnings) || averageWeeklyEarnings < 0)
|
|
131
|
+
return { ok: false, error: "Please enter your average weekly earnings." };
|
|
132
|
+
const dueD = parseLocalDate2(due);
|
|
133
|
+
const startD = parseLocalDate2(start);
|
|
134
|
+
if (startD > dueD) return { ok: false, error: "Employment start date can't be after the due date." };
|
|
135
|
+
const ewc = weekStart(dueD);
|
|
136
|
+
const qualWeekStart = addDays(ewc, -15 * 7);
|
|
137
|
+
const qualWeekEnd = addDays(qualWeekStart, 6);
|
|
138
|
+
const serviceCutoff = addDays(qualWeekEnd, -26 * 7 + 1);
|
|
139
|
+
const serviceOK = startD <= serviceCutoff;
|
|
140
|
+
const earnOK = averageWeeklyEarnings >= SPP_LEL;
|
|
141
|
+
const payOK = serviceOK && earnOK;
|
|
142
|
+
const dayOneApplies = dueD >= parseLocalDate2(DAY_ONE_LEAVE);
|
|
143
|
+
const leaveOK = dayOneApplies ? true : serviceOK;
|
|
144
|
+
const weeklyRate = Math.min(SPP_RATE, averageWeeklyEarnings * 0.9);
|
|
145
|
+
const total = payOK ? weeklyRate * leaveWeeks : 0;
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
leaveEligible: leaveOK,
|
|
149
|
+
payEligible: payOK,
|
|
150
|
+
weeklyRate,
|
|
151
|
+
total,
|
|
152
|
+
qualifyingWeekStart: formatGBDate2(qualWeekStart),
|
|
153
|
+
qualifyingWeekEnd: formatGBDate2(qualWeekEnd),
|
|
154
|
+
claimDeadline: formatGBDate2(addDays(dueD, -15 * 7)),
|
|
155
|
+
belowCap: averageWeeklyEarnings * 0.9 < SPP_RATE,
|
|
156
|
+
dayOneApplies
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ../lib/calc-notice.ts
|
|
161
|
+
function calculateNotice(input) {
|
|
162
|
+
const { annualSalary, yearsService, contractualWeeks, workedWeeks, taxRate } = input;
|
|
163
|
+
if (!isFinite(annualSalary) || annualSalary <= 0)
|
|
164
|
+
return { ok: false, error: "Please enter your gross annual salary." };
|
|
165
|
+
if (!isFinite(yearsService) || yearsService < 0)
|
|
166
|
+
return { ok: false, error: "Please enter your complete years of service." };
|
|
167
|
+
if (!isFinite(workedWeeks) || workedWeeks < 0)
|
|
168
|
+
return { ok: false, error: "Weeks already worked can't be negative." };
|
|
169
|
+
if (contractualWeeks != null && (!isFinite(contractualWeeks) || contractualWeeks < 0))
|
|
170
|
+
return { ok: false, error: "Contractual notice weeks can't be negative." };
|
|
171
|
+
const noStatutoryMinimum = yearsService < 1;
|
|
172
|
+
const statutoryWeeks = noStatutoryMinimum ? 0 : Math.min(12, Math.max(1, Math.floor(yearsService)));
|
|
173
|
+
const applicableWeeks = Math.max(statutoryWeeks, contractualWeeks ?? 0);
|
|
174
|
+
const unworkedWeeks = Math.max(0, applicableWeeks - workedWeeks);
|
|
175
|
+
const weeklyPay = annualSalary / 52;
|
|
176
|
+
const gross = weeklyPay * unworkedWeeks;
|
|
177
|
+
const tax = gross * taxRate;
|
|
178
|
+
const net = gross - tax;
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
statutoryWeeks,
|
|
182
|
+
contractualWeeks,
|
|
183
|
+
applicableWeeks,
|
|
184
|
+
workedWeeks,
|
|
185
|
+
unworkedWeeks,
|
|
186
|
+
weeklyPay,
|
|
187
|
+
gross,
|
|
188
|
+
tax,
|
|
189
|
+
net,
|
|
190
|
+
taxRate,
|
|
191
|
+
noStatutoryMinimum
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/server.ts
|
|
196
|
+
var SITE = "https://urbitools.com";
|
|
197
|
+
var DISCLAIMER = "Estimate only, not legal, tax or financial advice. Rates are for the 2026/27 tax year.";
|
|
198
|
+
var date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD");
|
|
199
|
+
function reply(result, page, summary) {
|
|
200
|
+
if (!result.ok) {
|
|
201
|
+
return { isError: true, content: [{ type: "text", text: result.error ?? "Calculation failed." }] };
|
|
202
|
+
}
|
|
203
|
+
const text = `${summary(result)}
|
|
204
|
+
|
|
205
|
+
Full working and an interactive version: ${SITE}${page}
|
|
206
|
+
${DISCLAIMER}`;
|
|
207
|
+
return {
|
|
208
|
+
content: [{ type: "text", text }],
|
|
209
|
+
structuredContent: { ...result, source: `${SITE}${page}`, disclaimer: DISCLAIMER }
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
var gbp = (n) => "\xA3" + n.toLocaleString("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
213
|
+
function createServer() {
|
|
214
|
+
const server = new McpServer({ name: "urbitools", version: "0.1.0" });
|
|
215
|
+
server.registerTool(
|
|
216
|
+
"calculate_redundancy_pay",
|
|
217
|
+
{
|
|
218
|
+
title: "UK statutory redundancy pay",
|
|
219
|
+
description: "Calculate UK statutory redundancy pay (2026/27): age-band formula (0.5/1/1.5 weeks' pay per year), 20-year cap, 2-year eligibility, weekly pay cap of \xA3751 (GB) or \xA3783 (NI), with a year-by-year breakdown and optional enhanced-scheme multiplier. Source: urbitools.com.",
|
|
220
|
+
inputSchema: {
|
|
221
|
+
date_of_birth: date.describe("Employee date of birth, YYYY-MM-DD"),
|
|
222
|
+
employment_start: date.describe("Employment start date, YYYY-MM-DD"),
|
|
223
|
+
employment_end: date.optional().describe("Last day of employment, YYYY-MM-DD. Defaults to today."),
|
|
224
|
+
weekly_pay: z.number().positive().describe("Gross weekly pay in GBP (before tax)"),
|
|
225
|
+
jurisdiction: z.enum(["gb", "ni"]).default("gb").describe("gb = Great Britain, ni = Northern Ireland"),
|
|
226
|
+
enhanced_multiplier: z.number().min(1).default(1).describe("1 = statutory minimum; e.g. 2 for a scheme paying double")
|
|
227
|
+
},
|
|
228
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
229
|
+
},
|
|
230
|
+
async (a) => reply(
|
|
231
|
+
calculateRedundancy({
|
|
232
|
+
dob: a.date_of_birth,
|
|
233
|
+
start: a.employment_start,
|
|
234
|
+
end: a.employment_end ?? null,
|
|
235
|
+
weeklyPay: a.weekly_pay,
|
|
236
|
+
jurisdiction: a.jurisdiction,
|
|
237
|
+
multiplier: a.enhanced_multiplier
|
|
238
|
+
}),
|
|
239
|
+
"/redundancy-pay-calculator",
|
|
240
|
+
(r) => r.eligible ? `Statutory redundancy pay: ${gbp(r.total)} (${r.countedYears} counted years, weekly pay used ${gbp(r.cappedPay)}${r.capped ? `, capped at ${gbp(r.cap)}` : ""}).` + (r.multiplier !== 1 ? ` With a ${r.multiplier}x enhanced scheme: ${gbp(r.enhanced)}.` : "") + ` Statutory redundancy pay is normally tax-free within the \xA330,000 termination-payments allowance.` : "Not eligible for statutory redundancy pay: it needs at least 2 years' continuous service."
|
|
241
|
+
)
|
|
242
|
+
);
|
|
243
|
+
server.registerTool(
|
|
244
|
+
"calculate_paternity_pay",
|
|
245
|
+
{
|
|
246
|
+
title: "UK statutory paternity pay",
|
|
247
|
+
description: "Check UK paternity leave and pay eligibility and calculate statutory paternity pay (2026/27): \xA3194.32/week or 90% of average weekly earnings if lower, \xA3129/week lower earnings limit, 26 weeks' service by the 15th week before the due date. Source: urbitools.com.",
|
|
248
|
+
inputSchema: {
|
|
249
|
+
due_date: date.describe("Baby's due date, YYYY-MM-DD"),
|
|
250
|
+
employment_start: date.describe("Employment start date, YYYY-MM-DD"),
|
|
251
|
+
average_weekly_earnings: z.number().min(0).describe("Average gross weekly earnings in GBP"),
|
|
252
|
+
leave_weeks: z.union([z.literal(1), z.literal(2)]).default(2).describe("Weeks of paternity leave: 1 or 2")
|
|
253
|
+
},
|
|
254
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
255
|
+
},
|
|
256
|
+
async (a) => reply(
|
|
257
|
+
calculatePaternity({
|
|
258
|
+
due: a.due_date,
|
|
259
|
+
start: a.employment_start,
|
|
260
|
+
averageWeeklyEarnings: a.average_weekly_earnings,
|
|
261
|
+
leaveWeeks: a.leave_weeks
|
|
262
|
+
}),
|
|
263
|
+
"/paternity-pay-calculator",
|
|
264
|
+
(r) => `Paternity leave eligible: ${r.leaveEligible ? "yes" : "no"}. Paternity pay eligible: ${r.payEligible ? "yes" : "no"}.` + (r.payEligible ? ` Weekly rate ${gbp(r.weeklyRate)}${r.belowCap ? " (90% of earnings, below the flat rate)" : ""}, total ${gbp(r.total)}. Claim deadline ${r.claimDeadline}.` : "")
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
server.registerTool(
|
|
268
|
+
"calculate_notice_pay",
|
|
269
|
+
{
|
|
270
|
+
title: "UK notice pay / PILON",
|
|
271
|
+
description: "Estimate UK payment in lieu of notice (PILON): uses the higher of statutory notice (1 week per complete year, max 12) and contractual notice, uncapped weekly pay (salary / 52), with a net estimate. PILON is fully taxable as earnings; the \xA330,000 allowance does not apply. Source: urbitools.com.",
|
|
272
|
+
inputSchema: {
|
|
273
|
+
annual_salary: z.number().positive().describe("Gross annual salary in GBP"),
|
|
274
|
+
years_service: z.number().min(0).describe("Complete years of service"),
|
|
275
|
+
contractual_notice_weeks: z.number().min(0).optional().describe("Contractual notice in weeks, if any"),
|
|
276
|
+
weeks_already_worked: z.number().min(0).default(0).describe("Notice weeks already worked"),
|
|
277
|
+
marginal_tax_rate: z.union([z.literal(0.2), z.literal(0.4), z.literal(0.45)]).default(0.2).describe("Marginal income tax rate for the net estimate")
|
|
278
|
+
},
|
|
279
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
280
|
+
},
|
|
281
|
+
async (a) => reply(
|
|
282
|
+
calculateNotice({
|
|
283
|
+
annualSalary: a.annual_salary,
|
|
284
|
+
yearsService: a.years_service,
|
|
285
|
+
contractualWeeks: a.contractual_notice_weeks ?? null,
|
|
286
|
+
workedWeeks: a.weeks_already_worked,
|
|
287
|
+
taxRate: a.marginal_tax_rate
|
|
288
|
+
}),
|
|
289
|
+
"/notice-pay-calculator",
|
|
290
|
+
(r) => `Notice period applying: ${r.applicableWeeks} weeks (statutory ${r.statutoryWeeks}${r.contractualWeeks != null ? `, contractual ${r.contractualWeeks}` : ""}). Unworked: ${r.unworkedWeeks} weeks at ${gbp(r.weeklyPay)}/week. PILON gross ${gbp(r.gross)}, estimated tax ${gbp(r.tax)}, net ${gbp(r.net)}.` + (r.noStatutoryMinimum ? " Note: under 1 year of service may mean no statutory minimum notice." : "")
|
|
291
|
+
)
|
|
292
|
+
);
|
|
293
|
+
return server;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/stdio.ts
|
|
297
|
+
await createServer().connect(new StdioServerTransport());
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "urbitools-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Free MCP server for UK employment calculators (statutory redundancy pay, paternity pay, notice pay/PILON) with 2026/27 rates, from urbitools.com",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "urbitools-mcp": "dist/stdio.js" },
|
|
7
|
+
"files": ["dist", "README.md"],
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "esbuild src/stdio.ts src/http.ts --bundle --platform=node --format=esm --target=node18 --outdir=dist --packages=external && chmod +x dist/stdio.js",
|
|
10
|
+
"test": "npm run build && node test/smoke.mjs"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["mcp", "uk", "redundancy", "paternity", "notice", "pilon", "calculator"],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"homepage": "https://urbitools.com",
|
|
15
|
+
"engines": { "node": ">=18" },
|
|
16
|
+
"dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "zod": "^3.23.0" },
|
|
17
|
+
"devDependencies": { "esbuild": "^0.25.0", "@types/node": "latest", "typescript": "latest" }
|
|
18
|
+
}
|