starthub-mcp 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/build/auth.js +30 -0
- package/build/index.js +42 -0
- package/build/tools/candidate.js +226 -0
- package/build/tools/startup.js +231 -0
- package/package.json +27 -0
package/build/auth.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
const SUPABASE_URL = process.env.SUPABASE_URL ?? 'https://emiwzjxgdkxvgsitukqg.supabase.co';
|
|
3
|
+
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY ?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVtaXd6anhnZGt4dmdzaXR1a3FnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzg0Mjg3MzQsImV4cCI6MjA5NDAwNDczNH0.QUDXSYhJrjkFW5P9Ge2yIj5vBOyizER6zAr6vbzhEKk';
|
|
4
|
+
export function getSupabase() {
|
|
5
|
+
return createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Valida la STARTHUB_KEY e restituisce il tipo di entità + id.
|
|
9
|
+
* Chiama la funzione SQL validate_mcp_key (SECURITY DEFINER) —
|
|
10
|
+
* sicura con anon key, non espone altri dati.
|
|
11
|
+
*/
|
|
12
|
+
export async function validateKey(key) {
|
|
13
|
+
const supabase = getSupabase();
|
|
14
|
+
const { data, error } = await supabase.rpc('validate_mcp_key', { p_key: key });
|
|
15
|
+
if (error || !data)
|
|
16
|
+
return null;
|
|
17
|
+
return data;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Verifica che la startup abbia un piano attivo.
|
|
21
|
+
* Lancia un errore con messaggio user-friendly se non ce l'ha.
|
|
22
|
+
*/
|
|
23
|
+
export function requireActivePlan(auth) {
|
|
24
|
+
if (auth.type !== 'startup')
|
|
25
|
+
return;
|
|
26
|
+
if (auth.plan !== 'active') {
|
|
27
|
+
throw new Error('⚠️ Il tuo piano StartHub non è attivo.\n' +
|
|
28
|
+
'Attiva il tuo account su https://starthub.it/upgrade per accedere a tutti i tool.');
|
|
29
|
+
}
|
|
30
|
+
}
|
package/build/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { validateKey, getSupabase } from './auth.js';
|
|
5
|
+
import { registerCandidateTools } from './tools/candidate.js';
|
|
6
|
+
import { registerStartupTools } from './tools/startup.js';
|
|
7
|
+
const STARTHUB_KEY = process.env.STARTHUB_KEY ?? '';
|
|
8
|
+
async function main() {
|
|
9
|
+
// ── Auth ──────────────────────────────────────────────────
|
|
10
|
+
if (!STARTHUB_KEY) {
|
|
11
|
+
process.stderr.write('Errore: variabile STARTHUB_KEY non configurata.\n' +
|
|
12
|
+
'Ottieni la tua chiave su https://starthub.it dopo la registrazione.\n');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
const auth = await validateKey(STARTHUB_KEY);
|
|
16
|
+
if (!auth) {
|
|
17
|
+
process.stderr.write('Errore: chiave STARTHUB_KEY non valida o scaduta.\n');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
// ── Server ────────────────────────────────────────────────
|
|
21
|
+
const isCandidate = auth.type === 'candidate';
|
|
22
|
+
const server = new McpServer({
|
|
23
|
+
name: isCandidate ? 'starthub-candidate' : 'starthub-recruiter',
|
|
24
|
+
version: '0.1.0',
|
|
25
|
+
});
|
|
26
|
+
const supabase = getSupabase();
|
|
27
|
+
if (isCandidate) {
|
|
28
|
+
registerCandidateTools(server, supabase, auth.id);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
registerStartupTools(server, supabase, auth);
|
|
32
|
+
}
|
|
33
|
+
// ── Transport ─────────────────────────────────────────────
|
|
34
|
+
const transport = new StdioServerTransport();
|
|
35
|
+
await server.connect(transport);
|
|
36
|
+
process.stderr.write(`StartHub MCP avviato — ${isCandidate ? '👤 Candidato' : '🏢 Startup'}` +
|
|
37
|
+
(auth.type === 'startup' ? ` [${auth.plan}]` : '') + '\n');
|
|
38
|
+
}
|
|
39
|
+
main().catch((err) => {
|
|
40
|
+
process.stderr.write(`Errore fatale: ${err.message}\n`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const ROLE_AREAS = [
|
|
3
|
+
'product', 'growth', 'operations', 'sales', 'tech',
|
|
4
|
+
'marketing', 'finance', 'people', 'chief-of-staff', 'altro',
|
|
5
|
+
];
|
|
6
|
+
const WORK_MODES = ['remote', 'hybrid', 'onsite'];
|
|
7
|
+
const APP_STAGES = [
|
|
8
|
+
'applied', 'screening', 'interview-1', 'interview-2',
|
|
9
|
+
'offer', 'hired', 'rejected',
|
|
10
|
+
];
|
|
11
|
+
export function registerCandidateTools(server, supabase, candidateId) {
|
|
12
|
+
// ── get_profile ────────────────────────────────────────────
|
|
13
|
+
server.registerTool('get_profile', {
|
|
14
|
+
description: 'Leggi i tuoi dati profilo: bio, ruoli cercati, RAL, preferenze, visibilità.',
|
|
15
|
+
inputSchema: {},
|
|
16
|
+
}, async () => {
|
|
17
|
+
const { data, error } = await supabase
|
|
18
|
+
.from('candidates')
|
|
19
|
+
.select('id, full_name, email, bio, role_targets, ral_min, ral_max, work_mode_pref, cities_pref, linkedin_url, is_searchable, created_at')
|
|
20
|
+
.eq('id', candidateId)
|
|
21
|
+
.single();
|
|
22
|
+
if (error || !data)
|
|
23
|
+
throw new Error('Profilo non trovato.');
|
|
24
|
+
return {
|
|
25
|
+
content: [{
|
|
26
|
+
type: 'text',
|
|
27
|
+
text: JSON.stringify({
|
|
28
|
+
...data,
|
|
29
|
+
visibilita: data.is_searchable ? '🟢 Visibile alle startup' : '🔒 Nascosto',
|
|
30
|
+
}, null, 2),
|
|
31
|
+
}],
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
// ── update_profile ─────────────────────────────────────────
|
|
35
|
+
server.registerTool('update_profile', {
|
|
36
|
+
description: 'Aggiorna il tuo profilo. Usa ogni volta che qualcosa cambia.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
bio: z.string().optional(),
|
|
39
|
+
role_targets: z.array(z.enum(ROLE_AREAS)).optional(),
|
|
40
|
+
ral_min: z.number().optional(),
|
|
41
|
+
ral_max: z.number().optional(),
|
|
42
|
+
work_mode_pref: z.array(z.enum(WORK_MODES)).optional(),
|
|
43
|
+
cities_pref: z.array(z.string()).optional(),
|
|
44
|
+
linkedin_url: z.string().url().optional(),
|
|
45
|
+
},
|
|
46
|
+
}, async (args) => {
|
|
47
|
+
const updates = {};
|
|
48
|
+
for (const [k, v] of Object.entries(args)) {
|
|
49
|
+
if (v !== undefined)
|
|
50
|
+
updates[k] = v;
|
|
51
|
+
}
|
|
52
|
+
if (Object.keys(updates).length === 0) {
|
|
53
|
+
return { content: [{ type: 'text', text: 'Nessun campo da aggiornare.' }] };
|
|
54
|
+
}
|
|
55
|
+
const { error } = await supabase.from('candidates').update(updates).eq('id', candidateId);
|
|
56
|
+
if (error)
|
|
57
|
+
throw new Error(error.message);
|
|
58
|
+
return { content: [{ type: 'text', text: `✅ Profilo aggiornato: ${Object.keys(updates).join(', ')}.` }] };
|
|
59
|
+
});
|
|
60
|
+
// ── toggle_visibility ──────────────────────────────────────
|
|
61
|
+
server.registerTool('toggle_visibility', {
|
|
62
|
+
description: 'Attiva o disattiva la visibilità del tuo profilo alle startup.',
|
|
63
|
+
inputSchema: {
|
|
64
|
+
visible: z.boolean().describe('true = visibile alle startup | false = nascosto'),
|
|
65
|
+
},
|
|
66
|
+
}, async ({ visible }) => {
|
|
67
|
+
const { error } = await supabase
|
|
68
|
+
.from('candidates')
|
|
69
|
+
.update({ is_searchable: visible })
|
|
70
|
+
.eq('id', candidateId);
|
|
71
|
+
if (error)
|
|
72
|
+
throw new Error(error.message);
|
|
73
|
+
const text = visible
|
|
74
|
+
? '✅ Profilo ora visibile alle startup.'
|
|
75
|
+
: '🔒 Profilo nascosto. Le startup non ti vedono.';
|
|
76
|
+
return { content: [{ type: 'text', text }] };
|
|
77
|
+
});
|
|
78
|
+
// ── search_jobs ────────────────────────────────────────────
|
|
79
|
+
server.registerTool('search_jobs', {
|
|
80
|
+
description: 'Cerca offerte di lavoro nelle startup italiane. Filtra per ruolo, modalità, RAL.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
role_area: z.enum(ROLE_AREAS).optional(),
|
|
83
|
+
work_mode: z.enum(WORK_MODES).optional(),
|
|
84
|
+
ral_min: z.number().optional().describe('RAL minima accettabile (es. 35000)'),
|
|
85
|
+
city: z.string().optional(),
|
|
86
|
+
limit: z.number().min(1).max(25).default(10),
|
|
87
|
+
},
|
|
88
|
+
}, async ({ role_area, work_mode, ral_min, city, limit }) => {
|
|
89
|
+
let query = supabase
|
|
90
|
+
.from('job_postings')
|
|
91
|
+
.select('id, role_title, role_area, work_mode, city, ral_min, ral_max, description, startups(name, stage, city)')
|
|
92
|
+
.eq('is_active', true);
|
|
93
|
+
if (role_area)
|
|
94
|
+
query = query.eq('role_area', role_area);
|
|
95
|
+
if (work_mode)
|
|
96
|
+
query = query.eq('work_mode', work_mode);
|
|
97
|
+
if (ral_min)
|
|
98
|
+
query = query.gte('ral_max', ral_min);
|
|
99
|
+
if (city)
|
|
100
|
+
query = query.ilike('city', `%${city}%`);
|
|
101
|
+
const { data, error } = await query.order('created_at', { ascending: false }).limit(limit);
|
|
102
|
+
if (error)
|
|
103
|
+
throw new Error(error.message);
|
|
104
|
+
if (!data?.length)
|
|
105
|
+
return { content: [{ type: 'text', text: 'Nessuna offerta trovata con questi filtri.' }] };
|
|
106
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
107
|
+
});
|
|
108
|
+
// ── get_job ────────────────────────────────────────────────
|
|
109
|
+
server.registerTool('get_job', {
|
|
110
|
+
description: 'Dettaglio completo di un\'offerta: descrizione, RAL, profilo startup.',
|
|
111
|
+
inputSchema: {
|
|
112
|
+
job_id: z.string().uuid().describe('ID dell\'offerta'),
|
|
113
|
+
},
|
|
114
|
+
}, async ({ job_id }) => {
|
|
115
|
+
const { data, error } = await supabase
|
|
116
|
+
.from('job_postings')
|
|
117
|
+
.select('*, startups(*)')
|
|
118
|
+
.eq('id', job_id)
|
|
119
|
+
.eq('is_active', true)
|
|
120
|
+
.single();
|
|
121
|
+
if (error || !data)
|
|
122
|
+
throw new Error('Offerta non trovata o non più attiva.');
|
|
123
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
124
|
+
});
|
|
125
|
+
// ── get_startup ────────────────────────────────────────────
|
|
126
|
+
server.registerTool('get_startup', {
|
|
127
|
+
description: 'Profilo completo di una startup e le sue posizioni aperte.',
|
|
128
|
+
inputSchema: {
|
|
129
|
+
startup_id: z.string().uuid().optional(),
|
|
130
|
+
slug: z.string().optional(),
|
|
131
|
+
},
|
|
132
|
+
}, async ({ startup_id, slug }) => {
|
|
133
|
+
if (!startup_id && !slug)
|
|
134
|
+
throw new Error('Fornisci startup_id o slug.');
|
|
135
|
+
let query = supabase.from('startups').select('*').eq('is_active', true);
|
|
136
|
+
query = startup_id ? query.eq('id', startup_id) : query.eq('slug', slug);
|
|
137
|
+
const { data: startup, error } = await query.single();
|
|
138
|
+
if (error || !startup)
|
|
139
|
+
throw new Error('Startup non trovata.');
|
|
140
|
+
const { data: jobs } = await supabase
|
|
141
|
+
.from('job_postings')
|
|
142
|
+
.select('id, role_title, role_area, work_mode, city, ral_min, ral_max')
|
|
143
|
+
.eq('startup_id', startup.id)
|
|
144
|
+
.eq('is_active', true);
|
|
145
|
+
return {
|
|
146
|
+
content: [{
|
|
147
|
+
type: 'text',
|
|
148
|
+
text: JSON.stringify({ ...startup, posizioni_aperte: jobs ?? [] }, null, 2),
|
|
149
|
+
}],
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
// ── apply ──────────────────────────────────────────────────
|
|
153
|
+
server.registerTool('apply', {
|
|
154
|
+
description: 'Invia la candidatura per un\'offerta.',
|
|
155
|
+
inputSchema: {
|
|
156
|
+
job_id: z.string().uuid(),
|
|
157
|
+
cover_note: z.string().max(2000).optional().describe('Lettera di presentazione (consigliata)'),
|
|
158
|
+
},
|
|
159
|
+
}, async ({ job_id, cover_note }) => {
|
|
160
|
+
const { data: job } = await supabase
|
|
161
|
+
.from('job_postings')
|
|
162
|
+
.select('id, role_title, startups(name)')
|
|
163
|
+
.eq('id', job_id)
|
|
164
|
+
.eq('is_active', true)
|
|
165
|
+
.single();
|
|
166
|
+
if (!job)
|
|
167
|
+
throw new Error('Offerta non trovata o non più attiva.');
|
|
168
|
+
const { data: existing } = await supabase
|
|
169
|
+
.from('applications')
|
|
170
|
+
.select('id, stage')
|
|
171
|
+
.eq('candidate_id', candidateId)
|
|
172
|
+
.eq('job_posting_id', job_id)
|
|
173
|
+
.single();
|
|
174
|
+
if (existing) {
|
|
175
|
+
return { content: [{ type: 'text', text: `Hai già applicato. Stage attuale: ${existing.stage}.` }] };
|
|
176
|
+
}
|
|
177
|
+
const { error } = await supabase.from('applications').insert({
|
|
178
|
+
candidate_id: candidateId,
|
|
179
|
+
job_posting_id: job_id,
|
|
180
|
+
cover_note: cover_note ?? null,
|
|
181
|
+
stage: 'applied',
|
|
182
|
+
});
|
|
183
|
+
if (error)
|
|
184
|
+
throw new Error(error.message);
|
|
185
|
+
const startupName = job.startups?.name ?? 'la startup';
|
|
186
|
+
return { content: [{ type: 'text', text: `✅ Candidatura inviata per ${job.role_title} @ ${startupName}. In bocca al lupo!` }] };
|
|
187
|
+
});
|
|
188
|
+
// ── get_applications ───────────────────────────────────────
|
|
189
|
+
server.registerTool('get_applications', {
|
|
190
|
+
description: 'Mostra le tue candidature con il relativo stage nel processo di selezione.',
|
|
191
|
+
inputSchema: {
|
|
192
|
+
stage: z.enum(APP_STAGES).optional().describe('Filtra per stage (opzionale)'),
|
|
193
|
+
},
|
|
194
|
+
}, async ({ stage }) => {
|
|
195
|
+
let query = supabase
|
|
196
|
+
.from('applications')
|
|
197
|
+
.select('id, stage, applied_at, cover_note, job_postings(id, role_title, role_area, work_mode, startups(name))')
|
|
198
|
+
.eq('candidate_id', candidateId)
|
|
199
|
+
.order('applied_at', { ascending: false });
|
|
200
|
+
if (stage)
|
|
201
|
+
query = query.eq('stage', stage);
|
|
202
|
+
const { data, error } = await query;
|
|
203
|
+
if (error)
|
|
204
|
+
throw new Error(error.message);
|
|
205
|
+
if (!data?.length) {
|
|
206
|
+
return { content: [{ type: 'text', text: 'Nessuna candidatura. Usa search_jobs e apply per candidarti.' }] };
|
|
207
|
+
}
|
|
208
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
209
|
+
});
|
|
210
|
+
// ── get_salary_benchmark ───────────────────────────────────
|
|
211
|
+
server.registerTool('get_salary_benchmark', {
|
|
212
|
+
description: 'RAL tipica per un ruolo nelle startup italiane, per seniority.',
|
|
213
|
+
inputSchema: {
|
|
214
|
+
area: z.enum(ROLE_AREAS),
|
|
215
|
+
},
|
|
216
|
+
}, async ({ area }) => {
|
|
217
|
+
const { data, error } = await supabase
|
|
218
|
+
.from('role_profiles')
|
|
219
|
+
.select('title, area, ral_junior, ral_mid, ral_senior, tagline')
|
|
220
|
+
.eq('area', area);
|
|
221
|
+
if (error || !data?.length) {
|
|
222
|
+
return { content: [{ type: 'text', text: `Benchmark non ancora disponibile per "${area}". Dataset in costruzione.` }] };
|
|
223
|
+
}
|
|
224
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { requireActivePlan } from '../auth.js';
|
|
3
|
+
const ROLE_AREAS = [
|
|
4
|
+
'product', 'growth', 'operations', 'sales', 'tech',
|
|
5
|
+
'marketing', 'finance', 'people', 'chief-of-staff', 'altro',
|
|
6
|
+
];
|
|
7
|
+
const WORK_MODES = ['remote', 'hybrid', 'onsite'];
|
|
8
|
+
const APP_STAGES = [
|
|
9
|
+
'screening', 'interview-1', 'interview-2', 'offer', 'hired', 'rejected',
|
|
10
|
+
];
|
|
11
|
+
export function registerStartupTools(server, supabase, auth) {
|
|
12
|
+
const startupId = auth.id;
|
|
13
|
+
// Helper che impone il plan check prima di eseguire i tool a pagamento
|
|
14
|
+
function paid(handler) {
|
|
15
|
+
return (args) => {
|
|
16
|
+
requireActivePlan(auth);
|
|
17
|
+
return handler(args);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
// ── get_profile ────────────────────────────────────────────
|
|
21
|
+
// Tool gratuito: la startup può sempre vedere il proprio profilo
|
|
22
|
+
server.registerTool('get_profile', {
|
|
23
|
+
description: 'Leggi il profilo della tua startup.',
|
|
24
|
+
inputSchema: {},
|
|
25
|
+
}, async () => {
|
|
26
|
+
const { data, error } = await supabase
|
|
27
|
+
.from('startups')
|
|
28
|
+
.select('id, name, slug, description, city, stage, website_url, is_active, plan, created_at')
|
|
29
|
+
.eq('id', startupId)
|
|
30
|
+
.single();
|
|
31
|
+
if (error || !data)
|
|
32
|
+
throw new Error('Profilo startup non trovato.');
|
|
33
|
+
const planInfo = data.plan === 'active'
|
|
34
|
+
? '✅ Piano attivo — accesso completo'
|
|
35
|
+
: `⚠️ Piano: ${data.plan} — alcuni tool non disponibili. Attiva su starthub.it/upgrade`;
|
|
36
|
+
return {
|
|
37
|
+
content: [{
|
|
38
|
+
type: 'text',
|
|
39
|
+
text: JSON.stringify({ ...data, stato_piano: planInfo }, null, 2),
|
|
40
|
+
}],
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
// ── update_profile ─────────────────────────────────────────
|
|
44
|
+
server.registerTool('update_profile', {
|
|
45
|
+
description: 'Aggiorna il profilo della tua startup.',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
description: z.string().optional(),
|
|
48
|
+
city: z.string().optional(),
|
|
49
|
+
stage: z.string().optional(),
|
|
50
|
+
website_url: z.string().url().optional(),
|
|
51
|
+
},
|
|
52
|
+
}, paid(async (args) => {
|
|
53
|
+
const updates = {};
|
|
54
|
+
for (const [k, v] of Object.entries(args)) {
|
|
55
|
+
if (v !== undefined)
|
|
56
|
+
updates[k] = v;
|
|
57
|
+
}
|
|
58
|
+
const { error } = await supabase.from('startups').update(updates).eq('id', startupId);
|
|
59
|
+
if (error)
|
|
60
|
+
throw new Error(error.message);
|
|
61
|
+
return { content: [{ type: 'text', text: `✅ Profilo aggiornato: ${Object.keys(updates).join(', ')}.` }] };
|
|
62
|
+
}));
|
|
63
|
+
// ── post_job ───────────────────────────────────────────────
|
|
64
|
+
server.registerTool('post_job', {
|
|
65
|
+
description: 'Pubblica una nuova offerta di lavoro. Descrivi il ruolo — penso a strutturarlo.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
role_title: z.string().describe('Titolo del ruolo (es. "Product Manager")'),
|
|
68
|
+
role_area: z.enum(ROLE_AREAS),
|
|
69
|
+
work_mode: z.enum(WORK_MODES),
|
|
70
|
+
city: z.string().optional(),
|
|
71
|
+
ral_min: z.number().optional(),
|
|
72
|
+
ral_max: z.number().optional(),
|
|
73
|
+
description: z.string().optional().describe('Descrizione del ruolo e del contesto'),
|
|
74
|
+
},
|
|
75
|
+
}, paid(async ({ role_title, role_area, work_mode, city, ral_min, ral_max, description }) => {
|
|
76
|
+
const { data, error } = await supabase
|
|
77
|
+
.from('job_postings')
|
|
78
|
+
.insert({
|
|
79
|
+
startup_id: startupId,
|
|
80
|
+
role_title,
|
|
81
|
+
role_area,
|
|
82
|
+
work_mode,
|
|
83
|
+
city: city ?? null,
|
|
84
|
+
ral_min: ral_min ?? null,
|
|
85
|
+
ral_max: ral_max ?? null,
|
|
86
|
+
description: description ?? null,
|
|
87
|
+
is_active: true,
|
|
88
|
+
})
|
|
89
|
+
.select('id, role_title')
|
|
90
|
+
.single();
|
|
91
|
+
if (error)
|
|
92
|
+
throw new Error(error.message);
|
|
93
|
+
return {
|
|
94
|
+
content: [{
|
|
95
|
+
type: 'text',
|
|
96
|
+
text: [
|
|
97
|
+
`✅ Offerta pubblicata: ${data.role_title}`,
|
|
98
|
+
`ID: ${data.id}`,
|
|
99
|
+
'',
|
|
100
|
+
'I candidati possono trovarla subito. Usa get_pipeline per monitorare le candidature.',
|
|
101
|
+
].join('\n'),
|
|
102
|
+
}],
|
|
103
|
+
};
|
|
104
|
+
}));
|
|
105
|
+
// ── update_job ─────────────────────────────────────────────
|
|
106
|
+
server.registerTool('update_job', {
|
|
107
|
+
description: 'Modifica un\'offerta esistente o chiudila (is_active: false).',
|
|
108
|
+
inputSchema: {
|
|
109
|
+
job_id: z.string().uuid(),
|
|
110
|
+
is_active: z.boolean().optional().describe('false = chiude la posizione'),
|
|
111
|
+
ral_min: z.number().optional(),
|
|
112
|
+
ral_max: z.number().optional(),
|
|
113
|
+
description: z.string().optional(),
|
|
114
|
+
},
|
|
115
|
+
}, paid(async ({ job_id, ...fields }) => {
|
|
116
|
+
const { data: job } = await supabase
|
|
117
|
+
.from('job_postings')
|
|
118
|
+
.select('id')
|
|
119
|
+
.eq('id', job_id)
|
|
120
|
+
.eq('startup_id', startupId)
|
|
121
|
+
.single();
|
|
122
|
+
if (!job)
|
|
123
|
+
throw new Error('Offerta non trovata o non appartiene alla tua startup.');
|
|
124
|
+
const updates = {};
|
|
125
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
126
|
+
if (v !== undefined)
|
|
127
|
+
updates[k] = v;
|
|
128
|
+
}
|
|
129
|
+
const { error } = await supabase.from('job_postings').update(updates).eq('id', job_id);
|
|
130
|
+
if (error)
|
|
131
|
+
throw new Error(error.message);
|
|
132
|
+
const text = fields.is_active === false
|
|
133
|
+
? '🔒 Posizione chiusa.'
|
|
134
|
+
: '✅ Offerta aggiornata.';
|
|
135
|
+
return { content: [{ type: 'text', text }] };
|
|
136
|
+
}));
|
|
137
|
+
// ── get_pipeline ───────────────────────────────────────────
|
|
138
|
+
server.registerTool('get_pipeline', {
|
|
139
|
+
description: 'Vedi le candidature ricevute per una posizione, con stage e profilo candidato.',
|
|
140
|
+
inputSchema: {
|
|
141
|
+
job_id: z.string().uuid(),
|
|
142
|
+
stage: z.enum(APP_STAGES).optional(),
|
|
143
|
+
},
|
|
144
|
+
}, paid(async ({ job_id, stage }) => {
|
|
145
|
+
const { data: job } = await supabase
|
|
146
|
+
.from('job_postings')
|
|
147
|
+
.select('id, role_title')
|
|
148
|
+
.eq('id', job_id)
|
|
149
|
+
.eq('startup_id', startupId)
|
|
150
|
+
.single();
|
|
151
|
+
if (!job)
|
|
152
|
+
throw new Error('Posizione non trovata o non appartiene alla tua startup.');
|
|
153
|
+
let query = supabase
|
|
154
|
+
.from('applications')
|
|
155
|
+
.select('id, stage, applied_at, cover_note, candidates(id, full_name, bio, role_targets, ral_min, ral_max, linkedin_url)')
|
|
156
|
+
.eq('job_posting_id', job_id)
|
|
157
|
+
.order('applied_at', { ascending: false });
|
|
158
|
+
if (stage)
|
|
159
|
+
query = query.eq('stage', stage);
|
|
160
|
+
const { data, error } = await query;
|
|
161
|
+
if (error)
|
|
162
|
+
throw new Error(error.message);
|
|
163
|
+
const byStage = (data ?? []).reduce((acc, a) => {
|
|
164
|
+
acc[a.stage] = (acc[a.stage] ?? 0) + 1;
|
|
165
|
+
return acc;
|
|
166
|
+
}, {});
|
|
167
|
+
return {
|
|
168
|
+
content: [{
|
|
169
|
+
type: 'text',
|
|
170
|
+
text: JSON.stringify({
|
|
171
|
+
ruolo: job.role_title,
|
|
172
|
+
totale: data?.length ?? 0,
|
|
173
|
+
per_stage: byStage,
|
|
174
|
+
candidature: data,
|
|
175
|
+
}, null, 2),
|
|
176
|
+
}],
|
|
177
|
+
};
|
|
178
|
+
}));
|
|
179
|
+
// ── move_application ───────────────────────────────────────
|
|
180
|
+
server.registerTool('move_application', {
|
|
181
|
+
description: 'Sposta un candidato al prossimo stage del processo di selezione.',
|
|
182
|
+
inputSchema: {
|
|
183
|
+
application_id: z.string().uuid(),
|
|
184
|
+
stage: z.enum(APP_STAGES),
|
|
185
|
+
},
|
|
186
|
+
}, paid(async ({ application_id, stage }) => {
|
|
187
|
+
const { data: app } = await supabase
|
|
188
|
+
.from('applications')
|
|
189
|
+
.select('id, stage, job_postings(startup_id)')
|
|
190
|
+
.eq('id', application_id)
|
|
191
|
+
.single();
|
|
192
|
+
const jobStartupId = app?.job_postings?.startup_id;
|
|
193
|
+
if (!app || jobStartupId !== startupId)
|
|
194
|
+
throw new Error('Candidatura non trovata.');
|
|
195
|
+
const { error } = await supabase
|
|
196
|
+
.from('applications')
|
|
197
|
+
.update({ stage })
|
|
198
|
+
.eq('id', application_id);
|
|
199
|
+
if (error)
|
|
200
|
+
throw new Error(error.message);
|
|
201
|
+
return { content: [{ type: 'text', text: `✅ Stage aggiornato: ${app.stage} → ${stage}.` }] };
|
|
202
|
+
}));
|
|
203
|
+
// ── search_candidates ──────────────────────────────────────
|
|
204
|
+
server.registerTool('search_candidates', {
|
|
205
|
+
description: 'Cerca candidati nel pool StartHub che hanno attivato la visibilità.',
|
|
206
|
+
inputSchema: {
|
|
207
|
+
role_area: z.enum(ROLE_AREAS).optional(),
|
|
208
|
+
work_mode: z.enum(WORK_MODES).optional(),
|
|
209
|
+
ral_max: z.number().optional().describe('RAL massima che puoi offrire'),
|
|
210
|
+
limit: z.number().min(1).max(20).default(10),
|
|
211
|
+
},
|
|
212
|
+
}, paid(async ({ role_area, work_mode, ral_max, limit }) => {
|
|
213
|
+
let query = supabase
|
|
214
|
+
.from('candidates')
|
|
215
|
+
.select('id, full_name, role_targets, ral_min, ral_max, work_mode_pref, cities_pref, bio, linkedin_url')
|
|
216
|
+
.eq('is_searchable', true);
|
|
217
|
+
if (role_area)
|
|
218
|
+
query = query.contains('role_targets', [role_area]);
|
|
219
|
+
if (work_mode)
|
|
220
|
+
query = query.contains('work_mode_pref', [work_mode]);
|
|
221
|
+
if (ral_max)
|
|
222
|
+
query = query.lte('ral_min', ral_max);
|
|
223
|
+
const { data, error } = await query.limit(limit);
|
|
224
|
+
if (error)
|
|
225
|
+
throw new Error(error.message);
|
|
226
|
+
if (!data?.length) {
|
|
227
|
+
return { content: [{ type: 'text', text: 'Nessun candidato trovato. Il pool è in crescita — allarga i filtri.' }] };
|
|
228
|
+
}
|
|
229
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
230
|
+
}));
|
|
231
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "starthub-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "StartHub MCP server — trova lavoro nelle startup italiane via Claude",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"starthub-mcp": "build/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"build"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev": "tsx src/index.ts",
|
|
15
|
+
"start": "node build/index.js"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
19
|
+
"@supabase/supabase-js": "^2.47.0",
|
|
20
|
+
"zod": "^3.25.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.0.0",
|
|
24
|
+
"tsx": "^4.19.0",
|
|
25
|
+
"typescript": "^5.7.0"
|
|
26
|
+
}
|
|
27
|
+
}
|