@gera-services/mcp-gera-nexus 0.1.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/data.js ADDED
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Data layer for the GeraNexus action-rails MCP server.
3
+ *
4
+ * Three honest data strategies, in priority order, per marketplace:
5
+ *
6
+ * 1. LIVE Gera marketplace API (best-effort) — if the production backend is
7
+ * reachable and returns rows, we use them and label the source "live_api".
8
+ * 2. LIVE public open data — for restaurants we call the UK Food Standards
9
+ * Agency FHRS open API (api.ratings.food.gov.uk, no auth), which is
10
+ * genuinely live UK restaurant data. Labelled "live_fhrs".
11
+ * 3. Bundled real crawled seed data — the CSVs shipped in ./data were
12
+ * crawled from List.am/Spyur (Yerevan home-service providers) and from
13
+ * real UK recruitment agencies. Labelled "bundled_seed".
14
+ *
15
+ * Every result carries a `data_source` field so an agent can tell a user
16
+ * exactly where the data came from. We NEVER fabricate rows.
17
+ */
18
+ import { readFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { dirname, join } from 'node:path';
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ // dist/ sits next to data/ at the package root: dist/data.js -> ../data
23
+ const DATA_DIR = join(__dirname, '..', 'data');
24
+ // Production base URLs for the live Gera marketplace backends (Railway).
25
+ // These are real, but their databases may be empty/seeded, so every call is
26
+ // best-effort with a short timeout and a graceful fall-back to seed data.
27
+ export const MARKETPLACE_API = {
28
+ home: 'https://taskova-production-ace3.up.railway.app',
29
+ jobs: 'https://gerajobs-production.up.railway.app',
30
+ eats: 'https://fooddash-production-0c1e.up.railway.app',
31
+ };
32
+ const PUBLIC_SURFACE = {
33
+ home: 'https://gerahome.com',
34
+ jobs: 'https://gerajobs.com',
35
+ eats: 'https://geraeats.com',
36
+ };
37
+ // ─── Minimal CSV parser (handles quoted fields + embedded commas) ─────────────
38
+ function parseCsv(text) {
39
+ const rows = [];
40
+ let field = '';
41
+ let row = [];
42
+ let inQuotes = false;
43
+ for (let i = 0; i < text.length; i++) {
44
+ const c = text[i];
45
+ if (inQuotes) {
46
+ if (c === '"') {
47
+ if (text[i + 1] === '"') {
48
+ field += '"';
49
+ i++;
50
+ }
51
+ else {
52
+ inQuotes = false;
53
+ }
54
+ }
55
+ else {
56
+ field += c;
57
+ }
58
+ }
59
+ else if (c === '"') {
60
+ inQuotes = true;
61
+ }
62
+ else if (c === ',') {
63
+ row.push(field);
64
+ field = '';
65
+ }
66
+ else if (c === '\n' || c === '\r') {
67
+ if (c === '\r' && text[i + 1] === '\n')
68
+ i++;
69
+ if (field !== '' || row.length > 0) {
70
+ row.push(field);
71
+ rows.push(row);
72
+ row = [];
73
+ field = '';
74
+ }
75
+ }
76
+ else {
77
+ field += c;
78
+ }
79
+ }
80
+ if (field !== '' || row.length > 0) {
81
+ row.push(field);
82
+ rows.push(row);
83
+ }
84
+ if (rows.length === 0)
85
+ return [];
86
+ const header = rows[0];
87
+ return rows.slice(1).map((r) => {
88
+ const obj = {};
89
+ header.forEach((h, idx) => {
90
+ obj[h.trim()] = (r[idx] ?? '').trim();
91
+ });
92
+ return obj;
93
+ });
94
+ }
95
+ let _homeSeed = null;
96
+ let _jobsSeed = null;
97
+ function homeSeed() {
98
+ if (_homeSeed === null) {
99
+ try {
100
+ _homeSeed = parseCsv(readFileSync(join(DATA_DIR, 'gerahome_providers_yerevan.csv'), 'utf8'));
101
+ }
102
+ catch {
103
+ _homeSeed = [];
104
+ }
105
+ }
106
+ return _homeSeed;
107
+ }
108
+ function jobsSeed() {
109
+ if (_jobsSeed === null) {
110
+ try {
111
+ _jobsSeed = parseCsv(readFileSync(join(DATA_DIR, 'gerajobs_providers.csv'), 'utf8'));
112
+ }
113
+ catch {
114
+ _jobsSeed = [];
115
+ }
116
+ }
117
+ return _jobsSeed;
118
+ }
119
+ // ─── best-effort live fetch with timeout ──────────────────────────────────────
120
+ async function fetchJson(url, opts = {}) {
121
+ const controller = new AbortController();
122
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 4000);
123
+ try {
124
+ const res = await fetch(url, {
125
+ headers: opts.headers,
126
+ signal: controller.signal,
127
+ });
128
+ if (!res.ok)
129
+ return null;
130
+ return await res.json();
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ finally {
136
+ clearTimeout(timer);
137
+ }
138
+ }
139
+ // ─── HOME SERVICES ────────────────────────────────────────────────────────────
140
+ export async function searchHomeServices(args) {
141
+ // 1. Best-effort live GeraHome API.
142
+ const q = new URLSearchParams();
143
+ if (args.city)
144
+ q.set('city', args.city);
145
+ q.set('limit', String(args.limit));
146
+ const live = (await fetchJson(`${MARKETPLACE_API.home}/api/v1/providers?${q.toString()}`, { headers: { 'x-tenant-id': 'AM' } }));
147
+ const liveRows = (live?.data ?? live?.items ?? null);
148
+ if (Array.isArray(liveRows) && liveRows.length > 0) {
149
+ const providers = liveRows.slice(0, args.limit).map((r, i) => ({
150
+ id: String(r.id ?? `home-live-${i}`),
151
+ name: String(r.business_name ?? r.name ?? 'Provider'),
152
+ category: String(r.category ?? r.category_name ?? args.category ?? 'general'),
153
+ city: String(r.city ?? args.city ?? ''),
154
+ area: r.district ? String(r.district) : undefined,
155
+ phone: r.phone ? String(r.phone) : undefined,
156
+ }));
157
+ return {
158
+ data_source: 'live_api',
159
+ providers,
160
+ public_url: PUBLIC_SURFACE.home,
161
+ note: 'Live results from the GeraHome production API.',
162
+ };
163
+ }
164
+ // 2. Bundled real crawled seed (Yerevan providers).
165
+ let rows = homeSeed();
166
+ if (args.category) {
167
+ const cat = args.category.toLowerCase();
168
+ rows = rows.filter((r) => (r.category ?? '').toLowerCase().includes(cat));
169
+ }
170
+ if (args.city) {
171
+ const city = args.city.toLowerCase();
172
+ rows = rows.filter((r) => (r.full_address ?? '').toLowerCase().includes(city) ||
173
+ (r.district ?? '').toLowerCase().includes(city));
174
+ }
175
+ const providers = rows.slice(0, args.limit).map((r, i) => ({
176
+ id: `home-seed-${i}`,
177
+ name: r.business_name,
178
+ category: r.category,
179
+ city: 'Yerevan',
180
+ area: r.district || undefined,
181
+ phone: r.phone_e164 || undefined,
182
+ contact: r.email_or_whatsapp || undefined,
183
+ languages: r.languages_spoken || undefined,
184
+ years_in_business: r.years_in_business || undefined,
185
+ why_qualified: r.why_qualified || undefined,
186
+ source_url: r.source_evidence_url || undefined,
187
+ }));
188
+ return {
189
+ data_source: 'bundled_seed',
190
+ providers,
191
+ public_url: PUBLIC_SURFACE.home,
192
+ note: 'Bundled real crawled providers (Yerevan, sourced from List.am/Spyur). The live GeraHome API was unreachable or empty; these are verified seed records.',
193
+ };
194
+ }
195
+ // ─── JOBS ─────────────────────────────────────────────────────────────────────
196
+ export async function searchJobs(args) {
197
+ // 1. Best-effort live GeraJobs API.
198
+ const q = new URLSearchParams();
199
+ if (args.query)
200
+ q.set('query', args.query);
201
+ if (args.location)
202
+ q.set('location', args.location);
203
+ q.set('limit', String(args.limit));
204
+ const live = (await fetchJson(`${MARKETPLACE_API.jobs}/api/v1/jobs?${q.toString()}`, { headers: { 'x-tenant-id': 'GB' } }));
205
+ const liveRows = (live?.data ?? live?.items ?? null);
206
+ if (Array.isArray(liveRows) && liveRows.length > 0) {
207
+ const jobs = liveRows.slice(0, args.limit).map((r, i) => ({
208
+ id: String(r.id ?? `job-live-${i}`),
209
+ title: String(r.title ?? 'Job'),
210
+ organization: String(r.company_name ?? r.company ?? r.organization ?? ''),
211
+ location: String(r.location ?? args.location ?? ''),
212
+ category: r.category ? String(r.category) : undefined,
213
+ job_type: r.job_type ? String(r.job_type) : undefined,
214
+ salary: r.salary_min || r.salary_max
215
+ ? `${r.salary_min ?? ''}-${r.salary_max ?? ''}`
216
+ : undefined,
217
+ }));
218
+ return {
219
+ data_source: 'live_api',
220
+ jobs,
221
+ public_url: PUBLIC_SURFACE.jobs,
222
+ note: 'Live results from the GeraJobs production API.',
223
+ };
224
+ }
225
+ // 2. Bundled real seed — recruitment-agency supply partners. These are
226
+ // organizations that supply jobs, not individual postings, so we present
227
+ // them honestly as "recruiters / job sources", not as live vacancies.
228
+ let rows = jobsSeed();
229
+ if (args.query) {
230
+ const kw = args.query.toLowerCase();
231
+ rows = rows.filter((r) => (r.name ?? '').toLowerCase().includes(kw) ||
232
+ (r.notes ?? '').toLowerCase().includes(kw));
233
+ }
234
+ if (args.location) {
235
+ const loc = args.location.toLowerCase();
236
+ rows = rows.filter((r) => (r.city ?? '').toLowerCase().includes(loc) ||
237
+ (r.country ?? '').toLowerCase().includes(loc));
238
+ }
239
+ const jobs = rows.slice(0, args.limit).map((r, i) => ({
240
+ id: `job-seed-${i}`,
241
+ title: `Recruiter / job source: ${r.name}`,
242
+ organization: r.name,
243
+ location: [r.city, r.country].filter(Boolean).join(', '),
244
+ website: r.website || undefined,
245
+ notes: r.notes || undefined,
246
+ }));
247
+ return {
248
+ data_source: 'bundled_seed',
249
+ jobs,
250
+ public_url: PUBLIC_SURFACE.jobs,
251
+ note: 'The live GeraJobs API was unreachable or has no matching vacancies. These are real recruitment-agency / job-board supply partners (not individual vacancies) — use them as the place to find live openings.',
252
+ };
253
+ }
254
+ // ─── RESTAURANTS (live FHRS open data) ────────────────────────────────────────
255
+ export async function searchRestaurants(args) {
256
+ // LIVE: UK Food Standards Agency FHRS open API — real, no auth.
257
+ const q = new URLSearchParams();
258
+ if (args.name)
259
+ q.set('name', args.name);
260
+ if (args.city)
261
+ q.set('address', args.city);
262
+ // FHRS BusinessTypeId 1 = "Restaurant/Cafe/Canteen", 7843 = pub/bar; we
263
+ // leave type open and filter to food-serving types client-side.
264
+ q.set('pageSize', String(Math.min(args.limit * 3, 100)));
265
+ const data = (await fetchJson(`https://api.ratings.food.gov.uk/Establishments?${q.toString()}`, { headers: { 'x-api-version': '2', accept: 'application/json' }, timeoutMs: 6000 }));
266
+ if (data && Array.isArray(data.establishments)) {
267
+ let rows = data.establishments;
268
+ if (typeof args.min_rating === 'number') {
269
+ rows = rows.filter((r) => {
270
+ const v = parseInt(String(r.RatingValue), 10);
271
+ return !Number.isNaN(v) && v >= args.min_rating;
272
+ });
273
+ }
274
+ const restaurants = rows.slice(0, args.limit).map((r) => {
275
+ const geo = (r.geocode ?? {});
276
+ return {
277
+ id: `fhrs-${r.FHRSID}`,
278
+ name: String(r.BusinessName ?? ''),
279
+ business_type: r.BusinessType ? String(r.BusinessType) : undefined,
280
+ address: [r.AddressLine1, r.AddressLine2, r.AddressLine3, r.AddressLine4]
281
+ .filter(Boolean)
282
+ .map(String)
283
+ .join(', '),
284
+ postcode: r.PostCode ? String(r.PostCode) : undefined,
285
+ city: args.city,
286
+ hygiene_rating: r.RatingValue ? String(r.RatingValue) : undefined,
287
+ rating_date: r.RatingDate ? String(r.RatingDate).slice(0, 10) : undefined,
288
+ phone: r.Phone ? String(r.Phone) : undefined,
289
+ local_authority: r.LocalAuthorityName ? String(r.LocalAuthorityName) : undefined,
290
+ lat: geo.latitude ? String(geo.latitude) : undefined,
291
+ lng: geo.longitude ? String(geo.longitude) : undefined,
292
+ };
293
+ });
294
+ return {
295
+ data_source: 'live_fhrs',
296
+ restaurants,
297
+ public_url: PUBLIC_SURFACE.eats,
298
+ note: 'Live UK restaurant data with current Food Hygiene Ratings from the Food Standards Agency (api.ratings.food.gov.uk). GeraEats lists and delivers from establishments like these.',
299
+ };
300
+ }
301
+ return {
302
+ data_source: 'live_fhrs',
303
+ restaurants: [],
304
+ public_url: PUBLIC_SURFACE.eats,
305
+ note: 'The FHRS open data API was unreachable. No restaurants returned — try again or refine the city/name.',
306
+ };
307
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @gera-services/mcp-gera-nexus
3
+ *
4
+ * MCP server for GeraNexus — the agentic-commerce ACTION rails by Gera Systems.
5
+ * Re-exports the server factory for programmatic / HTTP use.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ export { server, createServer, registerTools, main } from './server.js';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @gera-services/mcp-gera-nexus
3
+ *
4
+ * MCP server for GeraNexus — the agentic-commerce ACTION rails by Gera Systems.
5
+ * Re-exports the server factory for programmatic / HTTP use.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ export { server, createServer, registerTools, main } from './server.js';
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Consent-first action intents for GeraNexus.
3
+ *
4
+ * An "intent" is a structured, human-confirmable lead. It is explicitly NOT an
5
+ * executed transaction: no payment is taken, no provider is committed. The
6
+ * intent records what the agent + user want, assigns a tracking id, builds a
7
+ * deep-link the human opens to confirm/pay on the real Gera surface, and
8
+ * appends to an in-process audit trail.
9
+ *
10
+ * This mirrors the GeraNexus protocol's ConsentGate / human-in-the-loop step
11
+ * (services/gera-nexus protocol.service.ts) — the spend-threshold gate that
12
+ * routes to out-of-band human approval. Here, EVERY action is gated, because
13
+ * an MCP server acting for a user must never silently spend their money.
14
+ *
15
+ * Storage is in-process (Map). It is intentionally ephemeral: a real
16
+ * deployment would persist these to the GeraNexus backend's audit log. We do
17
+ * not pretend otherwise — get_action_status only sees intents created in this
18
+ * same process/session.
19
+ */
20
+ export type ActionKind = 'home_booking' | 'job_application' | 'restaurant_order';
21
+ export type IntentStatus = 'pending_human_confirmation' | 'expired' | 'unknown';
22
+ export interface ActionIntent {
23
+ intent_id: string;
24
+ kind: ActionKind;
25
+ status: IntentStatus;
26
+ created_at: string;
27
+ expires_at: string;
28
+ summary: string;
29
+ target: {
30
+ marketplace: 'GeraHome' | 'GeraJobs' | 'GeraEats';
31
+ provider_id?: string;
32
+ provider_name?: string;
33
+ };
34
+ details: Record<string, unknown>;
35
+ confirmation_url: string;
36
+ audit_signature: string;
37
+ audit_trail: Array<{
38
+ at: string;
39
+ event: string;
40
+ }>;
41
+ }
42
+ export declare function createIntent(input: {
43
+ kind: ActionKind;
44
+ marketplace: ActionIntent['target']['marketplace'];
45
+ provider_id?: string;
46
+ provider_name?: string;
47
+ summary: string;
48
+ details: Record<string, unknown>;
49
+ }): ActionIntent;
50
+ export declare function getIntent(id: string): ActionIntent | null;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Consent-first action intents for GeraNexus.
3
+ *
4
+ * An "intent" is a structured, human-confirmable lead. It is explicitly NOT an
5
+ * executed transaction: no payment is taken, no provider is committed. The
6
+ * intent records what the agent + user want, assigns a tracking id, builds a
7
+ * deep-link the human opens to confirm/pay on the real Gera surface, and
8
+ * appends to an in-process audit trail.
9
+ *
10
+ * This mirrors the GeraNexus protocol's ConsentGate / human-in-the-loop step
11
+ * (services/gera-nexus protocol.service.ts) — the spend-threshold gate that
12
+ * routes to out-of-band human approval. Here, EVERY action is gated, because
13
+ * an MCP server acting for a user must never silently spend their money.
14
+ *
15
+ * Storage is in-process (Map). It is intentionally ephemeral: a real
16
+ * deployment would persist these to the GeraNexus backend's audit log. We do
17
+ * not pretend otherwise — get_action_status only sees intents created in this
18
+ * same process/session.
19
+ */
20
+ import { randomUUID, createHash } from 'node:crypto';
21
+ const store = new Map();
22
+ const PUBLIC_SURFACE = {
23
+ GeraHome: 'https://gerahome.com',
24
+ GeraJobs: 'https://gerajobs.com',
25
+ GeraEats: 'https://geraeats.com',
26
+ };
27
+ const INTENT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
28
+ function signature(payload) {
29
+ return ('sha256:' +
30
+ createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 32));
31
+ }
32
+ export function createIntent(input) {
33
+ const id = `intent_${randomUUID()}`;
34
+ const now = new Date();
35
+ const created_at = now.toISOString();
36
+ const expires_at = new Date(now.getTime() + INTENT_TTL_MS).toISOString();
37
+ // Deep-link to the relevant public surface, carrying the intent id so the
38
+ // human lands on the right place to review and confirm. The path differs per
39
+ // marketplace and matches the real public surfaces.
40
+ const base = PUBLIC_SURFACE[input.marketplace];
41
+ const pathByKind = {
42
+ home_booking: '/book',
43
+ job_application: '/apply',
44
+ restaurant_order: '/order',
45
+ };
46
+ const params = new URLSearchParams({ intent: id, source: 'mcp-gera-nexus' });
47
+ if (input.provider_id)
48
+ params.set('ref', input.provider_id);
49
+ const confirmation_url = `${base}${pathByKind[input.kind]}?${params.toString()}`;
50
+ const corePayload = {
51
+ id,
52
+ kind: input.kind,
53
+ marketplace: input.marketplace,
54
+ provider_id: input.provider_id,
55
+ details: input.details,
56
+ created_at,
57
+ };
58
+ const intent = {
59
+ intent_id: id,
60
+ kind: input.kind,
61
+ status: 'pending_human_confirmation',
62
+ created_at,
63
+ expires_at,
64
+ summary: input.summary,
65
+ target: {
66
+ marketplace: input.marketplace,
67
+ provider_id: input.provider_id,
68
+ provider_name: input.provider_name,
69
+ },
70
+ details: input.details,
71
+ confirmation_url,
72
+ audit_signature: signature(corePayload),
73
+ audit_trail: [
74
+ { at: created_at, event: 'intent_created_via_mcp (no payment taken)' },
75
+ { at: created_at, event: 'awaiting_human_confirmation' },
76
+ ],
77
+ };
78
+ store.set(id, intent);
79
+ return intent;
80
+ }
81
+ export function getIntent(id) {
82
+ const intent = store.get(id);
83
+ if (!intent)
84
+ return null;
85
+ if (intent.status === 'pending_human_confirmation' &&
86
+ Date.now() > new Date(intent.expires_at).getTime()) {
87
+ intent.status = 'expired';
88
+ intent.audit_trail.push({
89
+ at: new Date().toISOString(),
90
+ event: 'intent_expired_unconfirmed',
91
+ });
92
+ }
93
+ return intent;
94
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * GeraNexus MCP Server (stdio) — the agent ACTION rails.
3
+ *
4
+ * GeraNexus is "the Agentic Commerce Protocol": the transactional layer AI
5
+ * agents use to DO real-world things across Gera's marketplaces on a user's
6
+ * behalf. This MCP server is the agent-facing front door to that protocol —
7
+ * the "do-this-for-me-safely" layer.
8
+ *
9
+ * Two kinds of tools:
10
+ * • SEARCH tools (read-only) — find real home-service providers, jobs, and
11
+ * restaurants. Backed by live Gera APIs where reachable, the UK FSA FHRS
12
+ * open data for restaurants, and bundled real crawled seed data otherwise.
13
+ * Every result is labelled with its data_source.
14
+ * • ACTION tools (consent-first) — create_booking_intent records a
15
+ * structured, human-confirmable INTENT/lead and returns a confirmation id
16
+ * plus a deep-link the human opens to confirm. It does NOT charge, does NOT
17
+ * commit a provider, and NEVER reports a completed transaction. This is the
18
+ * ConsentGate / human-in-the-loop step of the GeraNexus protocol, applied
19
+ * to every action.
20
+ *
21
+ * Honesty contract: an action that cannot truly complete returns a clearly
22
+ * labelled "intent + next-step link", never a faked confirmation.
23
+ *
24
+ * Product: GeraNexus — agentic commerce protocol (a Gera Systems product).
25
+ */
26
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
27
+ export declare function registerTools(server: McpServer): void;
28
+ /** Construct a fully-configured GeraNexus McpServer (all tools registered). */
29
+ export declare function createServer(): McpServer;
30
+ export declare const server: McpServer;
31
+ export declare function main(): Promise<void>;