@immicore/immi-tools 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -2
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +1 -1
- package/dist/tools/noc_outlook.handler.d.ts +4 -0
- package/dist/tools/noc_outlook.handler.js +77 -0
- package/dist/tools/noc_outlook.tool.d.ts +40 -0
- package/dist/tools/noc_outlook.tool.js +107 -0
- package/dist/tools/unemployment_rate.handler.d.ts +36 -0
- package/dist/tools/unemployment_rate.handler.js +266 -0
- package/dist/tools/unemployment_rate.tool.d.ts +30 -0
- package/dist/tools/unemployment_rate.tool.js +78 -0
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -5,10 +5,10 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|
|
5
5
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
6
6
|
import express from 'express';
|
|
7
7
|
import { createToolConfig } from './common/tool-schema.js';
|
|
8
|
-
import { CLBConvertMcpTool,
|
|
8
|
+
import { CLBConvertMcpTool, UnemploymentRateMcpTool } from './tools/index.js';
|
|
9
9
|
const allTools = [
|
|
10
10
|
CLBConvertMcpTool,
|
|
11
|
-
|
|
11
|
+
UnemploymentRateMcpTool,
|
|
12
12
|
];
|
|
13
13
|
const SERVER_NAME = 'immicore-tools';
|
|
14
14
|
const SERVER_VERSION = '1.0.0';
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { CLBConvertMcpTool } from './clb_convert.tool.js';
|
|
2
|
-
export {
|
|
2
|
+
export { UnemploymentRateMcpTool } from './unemployment_rate.tool.js';
|
package/dist/tools/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { CLBConvertMcpTool } from './clb_convert.tool.js';
|
|
2
|
-
export {
|
|
2
|
+
export { UnemploymentRateMcpTool } from './unemployment_rate.tool.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { NocOutlookOutputSchema, OutlookInfoSchema } from './noc_outlook.tool.js';
|
|
3
|
+
export declare function nocOutlookHandler(nocCode: string): Promise<z.infer<typeof NocOutlookOutputSchema>>;
|
|
4
|
+
export declare function nocOutlookByProvinceHandler(nocCode: string, province: string): Promise<z.infer<typeof OutlookInfoSchema> | null>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
// Cache for outlook data
|
|
4
|
+
let outlookDataCache = null;
|
|
5
|
+
function getOutlookDataPath() {
|
|
6
|
+
const possiblePaths = [
|
|
7
|
+
path.join(process.cwd(), 'data', 'noc_outlooks_2021.json'),
|
|
8
|
+
path.join(process.cwd(), 'noc_outlooks_2021.json'),
|
|
9
|
+
path.join(process.env.HOME || '', 'Desktop', 'noc_outlooks_2021.json'),
|
|
10
|
+
];
|
|
11
|
+
for (const dataPath of possiblePaths) {
|
|
12
|
+
if (fs.existsSync(dataPath)) {
|
|
13
|
+
return dataPath;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return path.join(process.cwd(), 'data', 'noc_outlooks_2021.json');
|
|
17
|
+
}
|
|
18
|
+
function loadOutlookData() {
|
|
19
|
+
if (outlookDataCache) {
|
|
20
|
+
return outlookDataCache;
|
|
21
|
+
}
|
|
22
|
+
const dataPath = getOutlookDataPath();
|
|
23
|
+
if (!fs.existsSync(dataPath)) {
|
|
24
|
+
throw new Error(`Outlook data file not found at ${dataPath}`);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const rawData = fs.readFileSync(dataPath, 'utf8');
|
|
28
|
+
outlookDataCache = JSON.parse(rawData);
|
|
29
|
+
return outlookDataCache || [];
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
console.error('Error loading outlook data:', error);
|
|
33
|
+
throw new Error('Failed to load outlook data file');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function nocOutlookHandler(nocCode) {
|
|
37
|
+
try {
|
|
38
|
+
const data = loadOutlookData();
|
|
39
|
+
const nocEntry = data.find(entry => entry.noc === nocCode);
|
|
40
|
+
if (!nocEntry) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
return nocEntry.data.map(item => ({
|
|
44
|
+
noc_code: nocCode,
|
|
45
|
+
province: item.province,
|
|
46
|
+
outlook: item.outlook,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
console.error('Error retrieving outlook data:', error);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function nocOutlookByProvinceHandler(nocCode, province) {
|
|
55
|
+
try {
|
|
56
|
+
const data = loadOutlookData();
|
|
57
|
+
const nocEntry = data.find(entry => entry.noc === nocCode);
|
|
58
|
+
if (!nocEntry) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
// Case-insensitive partial match for province name
|
|
62
|
+
const provinceMatch = nocEntry.data.find(item => item.province.toLowerCase().includes(province.toLowerCase()) ||
|
|
63
|
+
province.toLowerCase().includes(item.province.toLowerCase()));
|
|
64
|
+
if (!provinceMatch) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
noc_code: nocCode,
|
|
69
|
+
province: provinceMatch.province,
|
|
70
|
+
outlook: provinceMatch.outlook,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
console.error('Error retrieving outlook by province:', error);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { Tool } from '../common/tool-schema.js';
|
|
3
|
+
export declare const NocOutlookInputSchema: z.ZodObject<{
|
|
4
|
+
noc_code: z.ZodString;
|
|
5
|
+
province: z.ZodOptional<z.ZodString>;
|
|
6
|
+
}, "strip", z.ZodTypeAny, {
|
|
7
|
+
noc_code: string;
|
|
8
|
+
province?: string | undefined;
|
|
9
|
+
}, {
|
|
10
|
+
noc_code: string;
|
|
11
|
+
province?: string | undefined;
|
|
12
|
+
}>;
|
|
13
|
+
export declare const OutlookInfoSchema: z.ZodObject<{
|
|
14
|
+
noc_code: z.ZodString;
|
|
15
|
+
province: z.ZodString;
|
|
16
|
+
outlook: z.ZodString;
|
|
17
|
+
}, "strip", z.ZodTypeAny, {
|
|
18
|
+
noc_code: string;
|
|
19
|
+
province: string;
|
|
20
|
+
outlook: string;
|
|
21
|
+
}, {
|
|
22
|
+
noc_code: string;
|
|
23
|
+
province: string;
|
|
24
|
+
outlook: string;
|
|
25
|
+
}>;
|
|
26
|
+
export declare const NocOutlookOutputSchema: z.ZodArray<z.ZodObject<{
|
|
27
|
+
noc_code: z.ZodString;
|
|
28
|
+
province: z.ZodString;
|
|
29
|
+
outlook: z.ZodString;
|
|
30
|
+
}, "strip", z.ZodTypeAny, {
|
|
31
|
+
noc_code: string;
|
|
32
|
+
province: string;
|
|
33
|
+
outlook: string;
|
|
34
|
+
}, {
|
|
35
|
+
noc_code: string;
|
|
36
|
+
province: string;
|
|
37
|
+
outlook: string;
|
|
38
|
+
}>, "many">;
|
|
39
|
+
export declare const NOC_OUTLOOK_DESCRIPTION = "Query job outlook (employment prospects) for National Occupational Classification (NOC) codes.\n\nReturns employment prospect ratings from the Government of Canada Job Bank data for each province and territory.\n\nOutlook Ratings:\n- **Good**: Strong employment prospects (3 stars)\n- **Moderate**: Fair employment prospects (2 stars)\n- **Limited**: Weak employment prospects (1 star)\n- **Undetermined**: Outlook cannot be determined\n\nParameters:\n- noc_code (required): NOC 2021 code (5 digits)\n- province (optional): Province/territory name. If omitted, returns data for all 13 provinces/territories.\n\nExample usage - all provinces:\n{\n \"noc_code\": \"21232\"\n}\n\nExample usage - specific province:\n{\n \"noc_code\": \"21232\",\n \"province\": \"Ontario\"\n}\n\nUse this tool when:\n- Assessing job market prospects for an occupation\n- Advising clients on employment opportunities by region\n- Comparing career prospects across different provinces\n- Supporting immigration planning with labour market information";
|
|
40
|
+
export declare const NocOutlookMcpTool: Tool;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { nocOutlookHandler, nocOutlookByProvinceHandler } from './noc_outlook.handler.js';
|
|
3
|
+
export const NocOutlookInputSchema = z.object({
|
|
4
|
+
noc_code: z.string().describe('NOC 2021 code (5 digits). Example: "21232" for Software Developers'),
|
|
5
|
+
province: z.string().optional().describe('Province or territory name (optional). If not provided, returns outlooks for all provinces. Example: "Ontario"'),
|
|
6
|
+
}).describe('Input for querying NOC job outlook information.');
|
|
7
|
+
export const OutlookInfoSchema = z.object({
|
|
8
|
+
noc_code: z.string().describe('NOC code'),
|
|
9
|
+
province: z.string().describe('Province or territory name'),
|
|
10
|
+
outlook: z.string().describe('Job outlook rating (e.g., "Good", "Moderate", "Limited", "Undetermined")'),
|
|
11
|
+
}).describe('Job outlook information for a NOC code in a specific province.');
|
|
12
|
+
export const NocOutlookOutputSchema = z.array(OutlookInfoSchema).describe('List of job outlook information for the NOC code across provinces.');
|
|
13
|
+
export const NOC_OUTLOOK_DESCRIPTION = `Query job outlook (employment prospects) for National Occupational Classification (NOC) codes.
|
|
14
|
+
|
|
15
|
+
Returns employment prospect ratings from the Government of Canada Job Bank data for each province and territory.
|
|
16
|
+
|
|
17
|
+
Outlook Ratings:
|
|
18
|
+
- **Good**: Strong employment prospects (3 stars)
|
|
19
|
+
- **Moderate**: Fair employment prospects (2 stars)
|
|
20
|
+
- **Limited**: Weak employment prospects (1 star)
|
|
21
|
+
- **Undetermined**: Outlook cannot be determined
|
|
22
|
+
|
|
23
|
+
Parameters:
|
|
24
|
+
- noc_code (required): NOC 2021 code (5 digits)
|
|
25
|
+
- province (optional): Province/territory name. If omitted, returns data for all 13 provinces/territories.
|
|
26
|
+
|
|
27
|
+
Example usage - all provinces:
|
|
28
|
+
{
|
|
29
|
+
"noc_code": "21232"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Example usage - specific province:
|
|
33
|
+
{
|
|
34
|
+
"noc_code": "21232",
|
|
35
|
+
"province": "Ontario"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Use this tool when:
|
|
39
|
+
- Assessing job market prospects for an occupation
|
|
40
|
+
- Advising clients on employment opportunities by region
|
|
41
|
+
- Comparing career prospects across different provinces
|
|
42
|
+
- Supporting immigration planning with labour market information`;
|
|
43
|
+
export const NocOutlookMcpTool = {
|
|
44
|
+
name: 'noc_outlook',
|
|
45
|
+
description: NOC_OUTLOOK_DESCRIPTION,
|
|
46
|
+
inputShape: NocOutlookInputSchema,
|
|
47
|
+
outputSchema: NocOutlookOutputSchema,
|
|
48
|
+
annotations: {
|
|
49
|
+
title: 'NOC Job Outlook Lookup',
|
|
50
|
+
readOnlyHint: true,
|
|
51
|
+
destructiveHint: false,
|
|
52
|
+
idempotentHint: true,
|
|
53
|
+
openWorldHint: true,
|
|
54
|
+
},
|
|
55
|
+
call: async (input) => {
|
|
56
|
+
try {
|
|
57
|
+
let results;
|
|
58
|
+
if (input.province) {
|
|
59
|
+
const result = await nocOutlookByProvinceHandler(input.noc_code, input.province);
|
|
60
|
+
results = result ? [result] : [];
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
results = await nocOutlookHandler(input.noc_code);
|
|
64
|
+
}
|
|
65
|
+
if (!results || results.length === 0) {
|
|
66
|
+
const msg = input.province
|
|
67
|
+
? `No outlook data found for NOC ${input.noc_code} in ${input.province}`
|
|
68
|
+
: `No outlook data found for NOC ${input.noc_code}`;
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: 'text', text: msg }],
|
|
71
|
+
structuredContent: { results: [], total: 0 },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// Group results by outlook rating for better readability
|
|
75
|
+
const grouped = results.reduce((acc, item) => {
|
|
76
|
+
const rating = item.outlook;
|
|
77
|
+
if (!acc[rating])
|
|
78
|
+
acc[rating] = [];
|
|
79
|
+
acc[rating].push(item.province);
|
|
80
|
+
return acc;
|
|
81
|
+
}, {});
|
|
82
|
+
const textLines = [];
|
|
83
|
+
const outlookOrder = ['Good', 'Moderate', 'Limited', 'Undetermined'];
|
|
84
|
+
for (const outlook of outlookOrder) {
|
|
85
|
+
if (grouped[outlook]) {
|
|
86
|
+
textLines.push(`\n${outlook}:`);
|
|
87
|
+
for (const province of grouped[outlook].sort()) {
|
|
88
|
+
textLines.push(` • ${province}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const header = `NOC ${input.noc_code} Job Outlook (${results.length} province${results.length > 1 ? 's' : ''})\n${'='.repeat(50)}\n`;
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: 'text', text: header + textLines.join('\n') }],
|
|
95
|
+
structuredContent: { results, total: results.length },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const errorMessage = error instanceof Error ? error.message : 'Failed to retrieve outlook data.';
|
|
100
|
+
console.error('Error in noc_outlook tool call:', error);
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
|
|
103
|
+
isError: true,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unemployment rate data for a single period
|
|
3
|
+
*/
|
|
4
|
+
export interface PeriodRate {
|
|
5
|
+
period: string;
|
|
6
|
+
startDate: Date;
|
|
7
|
+
endDate: Date;
|
|
8
|
+
rate: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* CMA (Census Metropolitan Area) unemployment data
|
|
12
|
+
*/
|
|
13
|
+
export interface CMAData {
|
|
14
|
+
cma: string;
|
|
15
|
+
city: string;
|
|
16
|
+
province: string;
|
|
17
|
+
rates: PeriodRate[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Result returned by the unemployment rate handler
|
|
21
|
+
*/
|
|
22
|
+
export interface UnemploymentRateResult {
|
|
23
|
+
cma: string;
|
|
24
|
+
province: string;
|
|
25
|
+
unemployment_rate: number;
|
|
26
|
+
period: string;
|
|
27
|
+
data_source: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Main handler function for the unemployment rate tool
|
|
31
|
+
*/
|
|
32
|
+
export declare function unemploymentRateHandler(location: string): Promise<UnemploymentRateResult>;
|
|
33
|
+
/**
|
|
34
|
+
* Clear the cache (useful for testing)
|
|
35
|
+
*/
|
|
36
|
+
export declare function clearCache(): void;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import * as cheerio from 'cheerio';
|
|
3
|
+
const DATA_SOURCE_URL = 'https://www.canada.ca/en/employment-social-development/services/foreign-workers/refusal.html';
|
|
4
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
5
|
+
let cache = null;
|
|
6
|
+
/**
|
|
7
|
+
* Parse date string like "July 11, 2025" into Date object
|
|
8
|
+
*/
|
|
9
|
+
function parseDate(dateStr) {
|
|
10
|
+
const cleaned = dateStr.trim().replace(/,/g, '');
|
|
11
|
+
const date = new Date(cleaned);
|
|
12
|
+
if (isNaN(date.getTime())) {
|
|
13
|
+
throw new Error(`Failed to parse date: ${dateStr}`);
|
|
14
|
+
}
|
|
15
|
+
return date;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse period string like "July 11, 2025, to October 9, 2025" into start/end dates
|
|
19
|
+
*/
|
|
20
|
+
function parsePeriod(periodStr) {
|
|
21
|
+
// Extract dates from header like "Unemployment rate (%) for applications submitted from July 11, 2025, to October 9, 2025"
|
|
22
|
+
const match = periodStr.match(/from\s+(.+?),?\s+to\s+(.+?)$/i);
|
|
23
|
+
if (match) {
|
|
24
|
+
return {
|
|
25
|
+
startDate: parseDate(match[1]),
|
|
26
|
+
endDate: parseDate(match[2]),
|
|
27
|
+
period: `${match[1].trim()} to ${match[2].trim()}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// Fallback: try to find any two dates
|
|
31
|
+
const datePattern = /([A-Z][a-z]+ \d{1,2},? \d{4})/g;
|
|
32
|
+
const dates = periodStr.match(datePattern);
|
|
33
|
+
if (dates && dates.length >= 2) {
|
|
34
|
+
return {
|
|
35
|
+
startDate: parseDate(dates[0]),
|
|
36
|
+
endDate: parseDate(dates[1]),
|
|
37
|
+
period: `${dates[0]} to ${dates[1]}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Could not parse period from: ${periodStr}`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Extract city and province from CMA name
|
|
44
|
+
* Examples:
|
|
45
|
+
* "Toronto, Ontario" -> { city: "Toronto", province: "Ontario" }
|
|
46
|
+
* "Ottawa-Gatineau, Ontario/Quebec" -> { city: "Ottawa-Gatineau", province: "Ontario/Quebec" }
|
|
47
|
+
* "St. John's, Newfoundland and Labrador" -> { city: "St. John's", province: "Newfoundland and Labrador" }
|
|
48
|
+
*/
|
|
49
|
+
function parseCMAName(cma) {
|
|
50
|
+
const parts = cma.split(',').map(p => p.trim());
|
|
51
|
+
if (parts.length >= 2) {
|
|
52
|
+
return {
|
|
53
|
+
city: parts[0],
|
|
54
|
+
province: parts.slice(1).join(', '),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// If no comma, treat the whole thing as city with unknown province
|
|
58
|
+
return { city: cma, province: '' };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Fetch and parse unemployment data from canada.ca
|
|
62
|
+
*/
|
|
63
|
+
async function fetchUnemploymentData() {
|
|
64
|
+
const response = await axios.get(DATA_SOURCE_URL, {
|
|
65
|
+
timeout: 30000,
|
|
66
|
+
headers: {
|
|
67
|
+
'User-Agent': 'Mozilla/5.0 (compatible; ImmiCore/1.0; +https://immicore.ca)',
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
const $ = cheerio.load(response.data);
|
|
71
|
+
// Find the unemployment rates table
|
|
72
|
+
const table = $('table').filter((_, el) => {
|
|
73
|
+
const caption = $(el).find('caption').text();
|
|
74
|
+
return caption.toLowerCase().includes('unemployment rates by cma');
|
|
75
|
+
});
|
|
76
|
+
if (table.length === 0) {
|
|
77
|
+
// Try finding by table header
|
|
78
|
+
const altTable = $('table').filter((_, el) => {
|
|
79
|
+
const firstTh = $(el).find('th').first().text();
|
|
80
|
+
return firstTh.toLowerCase().includes('census metropolitan area');
|
|
81
|
+
});
|
|
82
|
+
if (altTable.length === 0) {
|
|
83
|
+
throw new Error('Could not find unemployment rates table on the page');
|
|
84
|
+
}
|
|
85
|
+
return parseTable($, altTable.first());
|
|
86
|
+
}
|
|
87
|
+
return parseTable($, table.first());
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Parse the unemployment rates table
|
|
91
|
+
*/
|
|
92
|
+
function parseTable($, table) {
|
|
93
|
+
const results = [];
|
|
94
|
+
// Parse headers to get period information
|
|
95
|
+
const headers = [];
|
|
96
|
+
table.find('thead th').each((index, th) => {
|
|
97
|
+
if (index === 0)
|
|
98
|
+
return; // Skip first column (CMA name)
|
|
99
|
+
const headerText = $(th).text().trim();
|
|
100
|
+
try {
|
|
101
|
+
headers.push(parsePeriod(headerText));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
console.warn(`Could not parse header: ${headerText}`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
if (headers.length === 0) {
|
|
108
|
+
throw new Error('Could not parse any period headers from the table');
|
|
109
|
+
}
|
|
110
|
+
// Parse each row
|
|
111
|
+
table.find('tbody tr').each((_, tr) => {
|
|
112
|
+
const cells = $(tr).find('td');
|
|
113
|
+
if (cells.length === 0)
|
|
114
|
+
return;
|
|
115
|
+
// Get CMA name (might be in an <a> tag or direct text)
|
|
116
|
+
const firstCell = cells.first();
|
|
117
|
+
const cmaName = firstCell.find('a').text().trim() || firstCell.text().trim();
|
|
118
|
+
if (!cmaName)
|
|
119
|
+
return;
|
|
120
|
+
const { city, province } = parseCMAName(cmaName);
|
|
121
|
+
const rates = [];
|
|
122
|
+
// Parse rate values for each period
|
|
123
|
+
cells.slice(1).each((i, td) => {
|
|
124
|
+
if (i >= headers.length)
|
|
125
|
+
return;
|
|
126
|
+
// Rate might be wrapped in <strong> for high values
|
|
127
|
+
const rateText = $(td).text().trim();
|
|
128
|
+
const rate = parseFloat(rateText);
|
|
129
|
+
if (!isNaN(rate)) {
|
|
130
|
+
rates.push({
|
|
131
|
+
period: headers[i].period,
|
|
132
|
+
startDate: headers[i].startDate,
|
|
133
|
+
endDate: headers[i].endDate,
|
|
134
|
+
rate,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (rates.length > 0) {
|
|
139
|
+
results.push({
|
|
140
|
+
cma: cmaName,
|
|
141
|
+
city,
|
|
142
|
+
province,
|
|
143
|
+
rates,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
return results;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Get cached data or fetch fresh if cache expired
|
|
151
|
+
*/
|
|
152
|
+
async function getCMAData() {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
// Check if cache is valid
|
|
155
|
+
if (cache && (now - cache.fetchedAt) < CACHE_TTL_MS) {
|
|
156
|
+
return cache.data;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const data = await fetchUnemploymentData();
|
|
160
|
+
cache = { data, fetchedAt: now };
|
|
161
|
+
return data;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
// If fetch fails but we have stale cache, use it
|
|
165
|
+
if (cache) {
|
|
166
|
+
console.warn('Failed to refresh unemployment data, using stale cache:', error);
|
|
167
|
+
return cache.data;
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Determine which period is currently applicable based on today's date
|
|
174
|
+
*/
|
|
175
|
+
function getCurrentPeriodRate(rates) {
|
|
176
|
+
const now = new Date();
|
|
177
|
+
// Find the period that contains today's date
|
|
178
|
+
for (const rate of rates) {
|
|
179
|
+
if (now >= rate.startDate && now <= rate.endDate) {
|
|
180
|
+
return rate;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// If no period contains today, return the latest one
|
|
184
|
+
// (This handles the case where data hasn't been updated yet)
|
|
185
|
+
return rates.length > 0 ? rates[rates.length - 1] : null;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Normalize string by removing accents/diacritics
|
|
189
|
+
* e.g., "Montréal" → "montreal", "Québec" → "quebec", "Trois-Rivières" → "trois-rivieres"
|
|
190
|
+
*/
|
|
191
|
+
function normalizeString(str) {
|
|
192
|
+
return str
|
|
193
|
+
.toLowerCase()
|
|
194
|
+
.trim()
|
|
195
|
+
.normalize('NFD')
|
|
196
|
+
.replace(/[\u0300-\u036f]/g, ''); // Remove combining diacritical marks
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Find CMA matching the given location string
|
|
200
|
+
* Supports fuzzy matching and accent-insensitive search
|
|
201
|
+
*/
|
|
202
|
+
function findMatchingCMA(data, location) {
|
|
203
|
+
const searchLower = location.toLowerCase().trim();
|
|
204
|
+
const searchNormalized = normalizeString(location);
|
|
205
|
+
// 1. Exact match on full CMA name (case-insensitive)
|
|
206
|
+
const exactMatch = data.find(d => d.cma.toLowerCase() === searchLower);
|
|
207
|
+
if (exactMatch)
|
|
208
|
+
return exactMatch;
|
|
209
|
+
// 2. Exact match on city name (case-insensitive)
|
|
210
|
+
const cityMatch = data.find(d => d.city.toLowerCase() === searchLower);
|
|
211
|
+
if (cityMatch)
|
|
212
|
+
return cityMatch;
|
|
213
|
+
// 3. Normalized match (accent-insensitive) on CMA name
|
|
214
|
+
const normalizedCmaMatch = data.find(d => normalizeString(d.cma) === searchNormalized);
|
|
215
|
+
if (normalizedCmaMatch)
|
|
216
|
+
return normalizedCmaMatch;
|
|
217
|
+
// 4. Normalized match (accent-insensitive) on city name
|
|
218
|
+
const normalizedCityMatch = data.find(d => normalizeString(d.city) === searchNormalized);
|
|
219
|
+
if (normalizedCityMatch)
|
|
220
|
+
return normalizedCityMatch;
|
|
221
|
+
// 5. City starts with search term (normalized)
|
|
222
|
+
const startsWithMatch = data.find(d => normalizeString(d.city).startsWith(searchNormalized) ||
|
|
223
|
+
normalizeString(d.cma).startsWith(searchNormalized));
|
|
224
|
+
if (startsWithMatch)
|
|
225
|
+
return startsWithMatch;
|
|
226
|
+
// 6. CMA contains search term (normalized)
|
|
227
|
+
const containsMatch = data.find(d => normalizeString(d.cma).includes(searchNormalized) ||
|
|
228
|
+
normalizeString(d.city).includes(searchNormalized));
|
|
229
|
+
if (containsMatch)
|
|
230
|
+
return containsMatch;
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Get all available CMA names for error messages
|
|
235
|
+
*/
|
|
236
|
+
function getAllCMANames(data) {
|
|
237
|
+
return data.map(d => d.cma).sort();
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Main handler function for the unemployment rate tool
|
|
241
|
+
*/
|
|
242
|
+
export async function unemploymentRateHandler(location) {
|
|
243
|
+
const data = await getCMAData();
|
|
244
|
+
const matched = findMatchingCMA(data, location);
|
|
245
|
+
if (!matched) {
|
|
246
|
+
const availableCMAs = getAllCMANames(data);
|
|
247
|
+
throw new Error(`No CMA found matching "${location}". Available CMAs:\n${availableCMAs.join('\n')}`);
|
|
248
|
+
}
|
|
249
|
+
const currentRate = getCurrentPeriodRate(matched.rates);
|
|
250
|
+
if (!currentRate) {
|
|
251
|
+
throw new Error(`No unemployment rate data available for ${matched.cma}`);
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
cma: matched.cma,
|
|
255
|
+
province: matched.province,
|
|
256
|
+
unemployment_rate: currentRate.rate,
|
|
257
|
+
period: currentRate.period,
|
|
258
|
+
data_source: DATA_SOURCE_URL,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Clear the cache (useful for testing)
|
|
263
|
+
*/
|
|
264
|
+
export function clearCache() {
|
|
265
|
+
cache = null;
|
|
266
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { Tool } from '../common/tool-schema.js';
|
|
3
|
+
export declare const UnemploymentRateInputSchema: z.ZodObject<{
|
|
4
|
+
location: z.ZodString;
|
|
5
|
+
}, "strip", z.ZodTypeAny, {
|
|
6
|
+
location: string;
|
|
7
|
+
}, {
|
|
8
|
+
location: string;
|
|
9
|
+
}>;
|
|
10
|
+
export declare const UnemploymentRateOutputSchema: z.ZodObject<{
|
|
11
|
+
cma: z.ZodString;
|
|
12
|
+
province: z.ZodString;
|
|
13
|
+
unemployment_rate: z.ZodNumber;
|
|
14
|
+
period: z.ZodString;
|
|
15
|
+
data_source: z.ZodString;
|
|
16
|
+
}, "strip", z.ZodTypeAny, {
|
|
17
|
+
cma: string;
|
|
18
|
+
province: string;
|
|
19
|
+
unemployment_rate: number;
|
|
20
|
+
period: string;
|
|
21
|
+
data_source: string;
|
|
22
|
+
}, {
|
|
23
|
+
cma: string;
|
|
24
|
+
province: string;
|
|
25
|
+
unemployment_rate: number;
|
|
26
|
+
period: string;
|
|
27
|
+
data_source: string;
|
|
28
|
+
}>;
|
|
29
|
+
export declare const UNEMPLOYMENT_RATE_DESCRIPTION = "Query unemployment rates for Canadian Census Metropolitan Areas (CMAs).\n\nReturns the current unemployment rate from the Government of Canada Employment and Social Development website.\n\nParameters:\n- location (required): CMA or city name (e.g., \"Toronto\", \"Vancouver\", \"Montreal\")\n\nExample usage:\n{\n \"location\": \"Toronto\"\n}\n\nReturns:\n- cma: Full CMA name (e.g., \"Toronto, Ontario\")\n- province: Province or territory\n- unemployment_rate: Current unemployment rate (%)\n- period: The applicable period for this rate\n- data_source: Official data source URL\n\nUse this tool when:\n- Checking unemployment rates for LMIA applications\n- Verifying if a CMA qualifies for low-wage LMIA processing (rate < 6%)\n- Understanding regional labour market conditions\n- Advising clients on LMIA application timing\n\nNote: CMAs with unemployment rate >= 6% may have low-wage LMIA applications refused to process.";
|
|
30
|
+
export declare const UnemploymentRateMcpTool: Tool;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { unemploymentRateHandler } from './unemployment_rate.handler.js';
|
|
3
|
+
export const UnemploymentRateInputSchema = z.object({
|
|
4
|
+
location: z.string().describe('CMA (Census Metropolitan Area) name or city name. Examples: "Toronto", "Vancouver", "Ottawa-Gatineau"'),
|
|
5
|
+
}).describe('Input for querying CMA unemployment rate.');
|
|
6
|
+
export const UnemploymentRateOutputSchema = z.object({
|
|
7
|
+
cma: z.string().describe('Full CMA name'),
|
|
8
|
+
province: z.string().describe('Province or territory'),
|
|
9
|
+
unemployment_rate: z.number().describe('Unemployment rate (%)'),
|
|
10
|
+
period: z.string().describe('Applicable period for this rate'),
|
|
11
|
+
data_source: z.string().describe('Data source URL'),
|
|
12
|
+
}).describe('Unemployment rate information for a CMA.');
|
|
13
|
+
export const UNEMPLOYMENT_RATE_DESCRIPTION = `Query unemployment rates for Canadian Census Metropolitan Areas (CMAs).
|
|
14
|
+
|
|
15
|
+
Returns the current unemployment rate from the Government of Canada Employment and Social Development website.
|
|
16
|
+
|
|
17
|
+
Parameters:
|
|
18
|
+
- location (required): CMA or city name (e.g., "Toronto", "Vancouver", "Montreal")
|
|
19
|
+
|
|
20
|
+
Example usage:
|
|
21
|
+
{
|
|
22
|
+
"location": "Toronto"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
- cma: Full CMA name (e.g., "Toronto, Ontario")
|
|
27
|
+
- province: Province or territory
|
|
28
|
+
- unemployment_rate: Current unemployment rate (%)
|
|
29
|
+
- period: The applicable period for this rate
|
|
30
|
+
- data_source: Official data source URL
|
|
31
|
+
|
|
32
|
+
Use this tool when:
|
|
33
|
+
- Checking unemployment rates for LMIA applications
|
|
34
|
+
- Verifying if a CMA qualifies for low-wage LMIA processing (rate < 6%)
|
|
35
|
+
- Understanding regional labour market conditions
|
|
36
|
+
- Advising clients on LMIA application timing
|
|
37
|
+
|
|
38
|
+
Note: CMAs with unemployment rate >= 6% may have low-wage LMIA applications refused to process.`;
|
|
39
|
+
export const UnemploymentRateMcpTool = {
|
|
40
|
+
name: 'unemployment_rate',
|
|
41
|
+
description: UNEMPLOYMENT_RATE_DESCRIPTION,
|
|
42
|
+
inputShape: UnemploymentRateInputSchema,
|
|
43
|
+
outputSchema: UnemploymentRateOutputSchema,
|
|
44
|
+
annotations: {
|
|
45
|
+
title: 'CMA Unemployment Rate',
|
|
46
|
+
readOnlyHint: true,
|
|
47
|
+
destructiveHint: false,
|
|
48
|
+
idempotentHint: true,
|
|
49
|
+
openWorldHint: true,
|
|
50
|
+
},
|
|
51
|
+
call: async (input) => {
|
|
52
|
+
try {
|
|
53
|
+
const result = await unemploymentRateHandler(input.location);
|
|
54
|
+
const rateStatus = result.unemployment_rate >= 6
|
|
55
|
+
? '(>= 6% - Low-wage LMIA may be refused)'
|
|
56
|
+
: '(< 6% - Eligible for low-wage LMIA)';
|
|
57
|
+
const text = [
|
|
58
|
+
`CMA: ${result.cma}`,
|
|
59
|
+
`Province: ${result.province}`,
|
|
60
|
+
`Unemployment Rate: ${result.unemployment_rate}% ${rateStatus}`,
|
|
61
|
+
`Period: ${result.period}`,
|
|
62
|
+
`Data Source: ${result.data_source}`,
|
|
63
|
+
].join('\n');
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: 'text', text }],
|
|
66
|
+
structuredContent: result,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const errorMessage = error instanceof Error ? error.message : 'Failed to retrieve unemployment rate.';
|
|
71
|
+
console.error('Error in unemployment_rate tool call:', error);
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
|
|
74
|
+
isError: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@immicore/immi-tools",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "MCP server for immigration tools (CLB conversion, NOC wage lookup)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
28
28
|
"axios": "^1.8.4",
|
|
29
|
+
"cheerio": "^1.0.0",
|
|
29
30
|
"dotenv": "^17.2.3",
|
|
30
31
|
"express": "^4.21.2",
|
|
31
32
|
"zod": "^3.22.4"
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"@types/express": "^5.0.0",
|
|
35
36
|
"@types/node": "^22.15.2",
|
|
37
|
+
"domhandler": "^5.0.3",
|
|
36
38
|
"tsx": "^4.16.2",
|
|
37
39
|
"typescript": "^5.4.5"
|
|
38
40
|
}
|