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 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
+ }