@gera-services/mcp-gera-verify 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/README.md +226 -0
- package/bin/cli.js +12 -0
- package/bin/http.js +12 -0
- package/dist/data/cqc.json +1 -0
- package/dist/data/doctors.json +1 -0
- package/dist/data/fhrs.json +1 -0
- package/dist/data/providers.json +1 -0
- package/dist/data.d.ts +105 -0
- package/dist/data.js +128 -0
- package/dist/http.d.ts +23 -0
- package/dist/http.js +148 -0
- package/dist/server.d.ts +35 -0
- package/dist/server.js +441 -0
- package/dist/sign.d.ts +15 -0
- package/dist/sign.js +91 -0
- package/package.json +60 -0
- package/server.json +22 -0
- package/src/data/cqc.json +1 -0
- package/src/data/doctors.json +1 -0
- package/src/data/fhrs.json +1 -0
- package/src/data/providers.json +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"meta":{"source":"Gera crawled verified-provider seed set","sourceUrl":null,"attribution":"Gera supplier crawler — verified provider contact records.","asOf":"2026-06-14T00:01:04.988Z"},"records":[{"name":"Nairi Medical Centre","type":"Hospital","city":"Yerevan","country":"Armenia","countryCode":"AM","website":"https://www.nairimedical.am","specialty":"Multi-specialty","source":"armenianmedicine_known","sourceUrl":"https://www.nairimedical.am","crawledAt":"2026-06-14T00:00:34.282Z"},{"name":"Erebuni Medical Centre","type":"Hospital","city":"Yerevan","country":"Armenia","countryCode":"AM","website":"https://erebuni.am","specialty":"Multi-specialty","source":"armenianmedicine_known","sourceUrl":"https://erebuni.am","crawledAt":"2026-06-14T00:00:34.282Z"},{"name":"MIKAYELYAN Institute of Surgery","type":"Hospital","city":"Yerevan","country":"Armenia","countryCode":"AM","website":"https://mikayelyan.am","specialty":"Surgery","source":"armenianmedicine_known","sourceUrl":"https://mikayelyan.am","crawledAt":"2026-06-14T00:00:34.282Z"},{"name":"Astghik Medical Centre","type":"Hospital","city":"Yerevan","country":"Armenia","countryCode":"AM","website":"https://astghikmed.am","specialty":"Multi-specialty","source":"armenianmedicine_known","sourceUrl":"https://astghikmed.am","crawledAt":"2026-06-14T00:00:34.282Z"},{"name":"Heratsi No.1 University Hospital","type":"Hospital","city":"Yerevan","country":"Armenia","countryCode":"AM","website":"https://med.am","specialty":"University Hospital","source":"armenianmedicine_known","sourceUrl":"https://med.am","crawledAt":"2026-06-14T00:00:34.282Z"},{"name":"Arabkir Medical Centre","type":"Hospital","city":"Yerevan","country":"Armenia","countryCode":"AM","website":"https://arabkir.am","specialty":"Paediatrics","source":"armenianmedicine_known","sourceUrl":"https://arabkir.am","crawledAt":"2026-06-14T00:00:34.282Z"},{"name":"Gyumri Medical Centre","type":"Hospital","city":"Gyumri","country":"Armenia","countryCode":"AM","website":null,"specialty":"Multi-specialty","source":"armenianmedicine_known","sourceUrl":"https://armenianmedicine.am","crawledAt":"2026-06-14T00:00:34.282Z"},{"name":"Evex Hospitals","type":"Hospital","city":"Tbilisi","country":"Georgia","countryCode":"GE","website":"https://evex.ge","specialty":"Multi-specialty","source":"georgia_known_clinics","sourceUrl":"https://evex.ge","crawledAt":"2026-06-14T00:00:38.388Z"},{"name":"Aversi Rational Medical Centre","type":"Clinic","city":"Tbilisi","country":"Georgia","countryCode":"GE","website":"https://aversi.ge","specialty":"Primary Care","source":"georgia_known_clinics","sourceUrl":"https://aversi.ge","crawledAt":"2026-06-14T00:00:38.388Z"},{"name":"Ingorokva High Medical Technology University Clinic","type":"Hospital","city":"Tbilisi","country":"Georgia","countryCode":"GE","website":"https://ingmed.ge","specialty":"Multi-specialty","source":"georgia_known_clinics","sourceUrl":"https://ingmed.ge","crawledAt":"2026-06-14T00:00:38.388Z"},{"name":"David Tvildiani Medical University Clinic","type":"Hospital","city":"Tbilisi","country":"Georgia","countryCode":"GE","website":"https://dtmu.ge","specialty":"University Hospital","source":"georgia_known_clinics","sourceUrl":"https://dtmu.ge","crawledAt":"2026-06-14T00:00:38.388Z"},{"name":"Batumi Reference Hospital","type":"Hospital","city":"Batumi","country":"Georgia","countryCode":"GE","website":null,"specialty":"Multi-specialty","source":"georgia_known_clinics","sourceUrl":"https://moh.gov.ge","crawledAt":"2026-06-14T00:00:38.388Z"},{"name":"New Hospitals Group Georgia","type":"Hospital","city":"Tbilisi","country":"Georgia","countryCode":"GE","website":"https://nh.ge","specialty":"Multi-specialty","source":"georgia_known_clinics","sourceUrl":"https://nh.ge","crawledAt":"2026-06-14T00:00:38.388Z"},{"name":"Caucasus Medical Centre","type":"Hospital","city":"Tbilisi","country":"Georgia","countryCode":"GE","website":"https://cmc.ge","specialty":"Oncology","source":"georgia_known_clinics","sourceUrl":"https://cmc.ge","crawledAt":"2026-06-14T00:00:38.388Z"},{"name":"Case Hospital Kampala","type":"Hospital","city":"Kampala","country":"Uganda","countryCode":"UG","website":"https://www.case.co.ug","specialty":"Multi-specialty","source":"uganda_known_hospitals","sourceUrl":"https://www.case.co.ug","crawledAt":"2026-06-14T00:00:42.191Z"},{"name":"International Hospital Kampala","type":"Hospital","city":"Kampala","country":"Uganda","countryCode":"UG","website":"https://ihk.co.ug","specialty":"Multi-specialty","source":"uganda_known_hospitals","sourceUrl":"https://ihk.co.ug","crawledAt":"2026-06-14T00:00:42.191Z"},{"name":"Victoria Hospital Kampala","type":"Hospital","city":"Kampala","country":"Uganda","countryCode":"UG","website":null,"specialty":"General","source":"uganda_known_hospitals","sourceUrl":"https://health.go.ug","crawledAt":"2026-06-14T00:00:42.191Z"},{"name":"Mulago National Referral Hospital","type":"Hospital","city":"Kampala","country":"Uganda","countryCode":"UG","website":null,"specialty":"National Referral","source":"uganda_known_hospitals","sourceUrl":"https://health.go.ug","crawledAt":"2026-06-14T00:00:42.191Z"},{"name":"Aga Khan Health Services Uganda","type":"Clinic","city":"Kampala","country":"Uganda","countryCode":"UG","website":"https://hospitals.agakhanhealth.org","specialty":"Primary Care","source":"uganda_known_hospitals","sourceUrl":"https://hospitals.agakhanhealth.org","crawledAt":"2026-06-14T00:00:42.191Z"},{"name":"Korle Bu Teaching Hospital","type":"Hospital","city":"Accra","country":"Ghana","countryCode":"GH","website":null,"specialty":"Teaching Hospital","source":"ghana_known_hospitals","sourceUrl":"https://ghanahealth.gov.gh","crawledAt":"2026-06-14T00:01:04.988Z"},{"name":"Komfo Anokye Teaching Hospital","type":"Hospital","city":"Kumasi","country":"Ghana","countryCode":"GH","website":null,"specialty":"Teaching Hospital","source":"ghana_known_hospitals","sourceUrl":"https://ghanahealth.gov.gh","crawledAt":"2026-06-14T00:01:04.988Z"},{"name":"Trust Hospital Accra","type":"Hospital","city":"Accra","country":"Ghana","countryCode":"GH","website":null,"specialty":"Multi-specialty","source":"ghana_known_hospitals","sourceUrl":"https://ghanahealth.gov.gh","crawledAt":"2026-06-14T00:01:04.988Z"},{"name":"Nyaho Medical Centre","type":"Clinic","city":"Accra","country":"Ghana","countryCode":"GH","website":"https://nyaho.com","specialty":"Primary Care","source":"ghana_known_hospitals","sourceUrl":"https://nyaho.com","crawledAt":"2026-06-14T00:01:04.988Z"},{"name":"Lister Hospital Accra","type":"Hospital","city":"Accra","country":"Ghana","countryCode":"GH","website":"https://listerhospital.com","specialty":"Multi-specialty","source":"ghana_known_hospitals","sourceUrl":"https://listerhospital.com","crawledAt":"2026-06-14T00:01:04.988Z"},{"name":"Annie Cropper Hospital Accra","type":"Hospital","city":"Accra","country":"Ghana","countryCode":"GH","website":null,"specialty":"General","source":"ghana_known_hospitals","sourceUrl":"https://ghanahealth.gov.gh","crawledAt":"2026-06-14T00:01:04.988Z"}]}
|
package/dist/data.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export interface DatasetMeta {
|
|
2
|
+
source: string;
|
|
3
|
+
sourceUrl: string | null;
|
|
4
|
+
attribution: string;
|
|
5
|
+
asOf: string | null;
|
|
6
|
+
note?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface FhrsRecord {
|
|
9
|
+
fhrsId: string;
|
|
10
|
+
name: string;
|
|
11
|
+
address: string | null;
|
|
12
|
+
postcode: string | null;
|
|
13
|
+
businessType: string | null;
|
|
14
|
+
rating: number | null;
|
|
15
|
+
ratingLabel: string | null;
|
|
16
|
+
ratingDate: string | null;
|
|
17
|
+
authority: string;
|
|
18
|
+
scheme: 'FHRS' | 'FHIS';
|
|
19
|
+
}
|
|
20
|
+
export interface CqcRecord {
|
|
21
|
+
cqcLocationId: string;
|
|
22
|
+
name: string;
|
|
23
|
+
providerName: string | null;
|
|
24
|
+
address: string | null;
|
|
25
|
+
postcode: string | null;
|
|
26
|
+
phone: string | null;
|
|
27
|
+
website: string | null;
|
|
28
|
+
serviceTypes: string[];
|
|
29
|
+
lastInspected: string | null;
|
|
30
|
+
authority: string;
|
|
31
|
+
region: string | null;
|
|
32
|
+
}
|
|
33
|
+
export interface DoctorRecord {
|
|
34
|
+
slug: string;
|
|
35
|
+
name: string;
|
|
36
|
+
specialty: string | null;
|
|
37
|
+
city: string | null;
|
|
38
|
+
country: string | null;
|
|
39
|
+
countryCode: string | null;
|
|
40
|
+
area: string | null;
|
|
41
|
+
languages: string[];
|
|
42
|
+
availableOnline: boolean;
|
|
43
|
+
sourceUrl: string | null;
|
|
44
|
+
}
|
|
45
|
+
export interface ProviderRecord {
|
|
46
|
+
name: string;
|
|
47
|
+
type: string | null;
|
|
48
|
+
city: string | null;
|
|
49
|
+
country: string | null;
|
|
50
|
+
countryCode: string | null;
|
|
51
|
+
website: string | null;
|
|
52
|
+
specialty: string | null;
|
|
53
|
+
source: string | null;
|
|
54
|
+
sourceUrl: string | null;
|
|
55
|
+
crawledAt: string | null;
|
|
56
|
+
}
|
|
57
|
+
interface Dataset<T> {
|
|
58
|
+
meta: DatasetMeta;
|
|
59
|
+
records: T[];
|
|
60
|
+
}
|
|
61
|
+
export declare const fhrs: Dataset<FhrsRecord>;
|
|
62
|
+
export declare const cqc: Dataset<CqcRecord>;
|
|
63
|
+
export declare const doctors: Dataset<DoctorRecord>;
|
|
64
|
+
export declare const providers: Dataset<ProviderRecord>;
|
|
65
|
+
/** Lower-case, strip punctuation/extra whitespace for fuzzy name matching. */
|
|
66
|
+
export declare function norm(s: string | null | undefined): string;
|
|
67
|
+
/** Normalise a UK postcode for comparison (uppercase, no spaces). */
|
|
68
|
+
export declare function normPostcode(s: string | null | undefined): string;
|
|
69
|
+
/** Outward code (first half) of a UK postcode, e.g. "B7 5SA" -> "B7". */
|
|
70
|
+
export declare function outwardCode(s: string | null | undefined): string;
|
|
71
|
+
/**
|
|
72
|
+
* Score how well a query name matches a record name.
|
|
73
|
+
* 3 = exact (normalised) equality
|
|
74
|
+
* 2 = one is a substring/contains-all-tokens of the other
|
|
75
|
+
* 1 = the query tokens are a subset of the record tokens (or vice-versa)
|
|
76
|
+
* 0 = no match
|
|
77
|
+
*/
|
|
78
|
+
export declare function nameMatchScore(query: string, candidate: string): number;
|
|
79
|
+
export interface LocationFilter {
|
|
80
|
+
city?: string;
|
|
81
|
+
postcode?: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Does a record's location (city/address/postcode) satisfy the filter?
|
|
85
|
+
* Returns true when no filter is given (location is optional).
|
|
86
|
+
*/
|
|
87
|
+
export declare function locationMatches(filter: LocationFilter, rec: {
|
|
88
|
+
city?: string | null;
|
|
89
|
+
address?: string | null;
|
|
90
|
+
postcode?: string | null;
|
|
91
|
+
authority?: string;
|
|
92
|
+
}): boolean;
|
|
93
|
+
/** Generic best-match search over a named-record dataset. */
|
|
94
|
+
export declare function findByName<T extends {
|
|
95
|
+
name: string;
|
|
96
|
+
}>(records: T[], name: string, location: LocationFilter, locOf: (r: T) => {
|
|
97
|
+
city?: string | null;
|
|
98
|
+
address?: string | null;
|
|
99
|
+
postcode?: string | null;
|
|
100
|
+
authority?: string;
|
|
101
|
+
}, limit?: number): Array<{
|
|
102
|
+
record: T;
|
|
103
|
+
score: number;
|
|
104
|
+
}>;
|
|
105
|
+
export {};
|
package/dist/data.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* data.ts — load and index the REAL verified datasets snapshotted into
|
|
3
|
+
* src/data/*.json by scripts/build-data.mjs. Everything here is offline:
|
|
4
|
+
* the JSON is read once from disk at module load, indexed in memory, and
|
|
5
|
+
* queried by the MCP tools. No network, no Neon, no fabrication.
|
|
6
|
+
*
|
|
7
|
+
* Each dataset carries its own provenance (`meta`) so every tool response can
|
|
8
|
+
* attribute the source + as-of date. When a lookup misses, callers return a
|
|
9
|
+
* clear "not in our verified records" — never a guessed value.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
// Built server runs from dist/ → dist/data/. Fall back to the committed
|
|
16
|
+
// source snapshot (src/data/) so the package also works pre-copy and in dev.
|
|
17
|
+
const CANDIDATE_DIRS = [
|
|
18
|
+
join(__dirname, 'data'),
|
|
19
|
+
join(__dirname, '..', 'src', 'data'),
|
|
20
|
+
join(__dirname, '..', 'data'),
|
|
21
|
+
];
|
|
22
|
+
const DATA_DIR = CANDIDATE_DIRS.find((d) => existsSync(join(d, 'fhrs.json'))) ?? CANDIDATE_DIRS[0];
|
|
23
|
+
function load(file) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(join(DATA_DIR, file), 'utf8'));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Missing data file: degrade gracefully to an empty, clearly-attributed set
|
|
29
|
+
// rather than crashing the server. Tools then honestly report "unknown".
|
|
30
|
+
return {
|
|
31
|
+
meta: {
|
|
32
|
+
source: 'unavailable',
|
|
33
|
+
sourceUrl: null,
|
|
34
|
+
attribution: 'Dataset not built — run scripts/build-data.mjs.',
|
|
35
|
+
asOf: null,
|
|
36
|
+
},
|
|
37
|
+
records: [],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export const fhrs = load('fhrs.json');
|
|
42
|
+
export const cqc = load('cqc.json');
|
|
43
|
+
export const doctors = load('doctors.json');
|
|
44
|
+
export const providers = load('providers.json');
|
|
45
|
+
// ── Normalisation + matching helpers ────────────────────────────────────────
|
|
46
|
+
/** Lower-case, strip punctuation/extra whitespace for fuzzy name matching. */
|
|
47
|
+
export function norm(s) {
|
|
48
|
+
return (s ?? '')
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/&/g, ' and ')
|
|
51
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
52
|
+
.trim();
|
|
53
|
+
}
|
|
54
|
+
/** Normalise a UK postcode for comparison (uppercase, no spaces). */
|
|
55
|
+
export function normPostcode(s) {
|
|
56
|
+
return (s ?? '').toUpperCase().replace(/\s+/g, '');
|
|
57
|
+
}
|
|
58
|
+
/** Outward code (first half) of a UK postcode, e.g. "B7 5SA" -> "B7". */
|
|
59
|
+
export function outwardCode(s) {
|
|
60
|
+
const p = normPostcode(s);
|
|
61
|
+
if (!p)
|
|
62
|
+
return '';
|
|
63
|
+
// Inward code is always the last 3 chars (digit + 2 letters).
|
|
64
|
+
return p.length > 3 ? p.slice(0, p.length - 3) : p;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Score how well a query name matches a record name.
|
|
68
|
+
* 3 = exact (normalised) equality
|
|
69
|
+
* 2 = one is a substring/contains-all-tokens of the other
|
|
70
|
+
* 1 = the query tokens are a subset of the record tokens (or vice-versa)
|
|
71
|
+
* 0 = no match
|
|
72
|
+
*/
|
|
73
|
+
export function nameMatchScore(query, candidate) {
|
|
74
|
+
const q = norm(query);
|
|
75
|
+
const c = norm(candidate);
|
|
76
|
+
if (!q || !c)
|
|
77
|
+
return 0;
|
|
78
|
+
if (q === c)
|
|
79
|
+
return 3;
|
|
80
|
+
if (c.includes(q) || q.includes(c))
|
|
81
|
+
return 2;
|
|
82
|
+
const qt = new Set(q.split(' '));
|
|
83
|
+
const ct = new Set(c.split(' '));
|
|
84
|
+
let overlap = 0;
|
|
85
|
+
for (const t of qt)
|
|
86
|
+
if (ct.has(t))
|
|
87
|
+
overlap++;
|
|
88
|
+
// Require the smaller token set to be (almost) fully covered.
|
|
89
|
+
const minSize = Math.min(qt.size, ct.size);
|
|
90
|
+
if (minSize > 0 && overlap >= minSize)
|
|
91
|
+
return 1;
|
|
92
|
+
// Partial token overlap (>=2 shared meaningful tokens) still counts weakly.
|
|
93
|
+
if (overlap >= 2)
|
|
94
|
+
return 1;
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Does a record's location (city/address/postcode) satisfy the filter?
|
|
99
|
+
* Returns true when no filter is given (location is optional).
|
|
100
|
+
*/
|
|
101
|
+
export function locationMatches(filter, rec) {
|
|
102
|
+
const wantCity = norm(filter.city);
|
|
103
|
+
const wantPc = outwardCode(filter.postcode);
|
|
104
|
+
let ok = true;
|
|
105
|
+
if (wantCity) {
|
|
106
|
+
const hay = norm([rec.city, rec.address, rec.authority].filter(Boolean).join(' '));
|
|
107
|
+
ok = ok && hay.includes(wantCity);
|
|
108
|
+
}
|
|
109
|
+
if (wantPc) {
|
|
110
|
+
const recPc = outwardCode(rec.postcode);
|
|
111
|
+
ok = ok && recPc === wantPc;
|
|
112
|
+
}
|
|
113
|
+
return ok;
|
|
114
|
+
}
|
|
115
|
+
/** Generic best-match search over a named-record dataset. */
|
|
116
|
+
export function findByName(records, name, location, locOf, limit = 5) {
|
|
117
|
+
const scored = [];
|
|
118
|
+
for (const r of records) {
|
|
119
|
+
const score = nameMatchScore(name, r.name);
|
|
120
|
+
if (score === 0)
|
|
121
|
+
continue;
|
|
122
|
+
if (!locationMatches(location, locOf(r)))
|
|
123
|
+
continue;
|
|
124
|
+
scored.push({ record: r, score });
|
|
125
|
+
}
|
|
126
|
+
scored.sort((a, b) => b.score - a.score);
|
|
127
|
+
return scored.slice(0, limit);
|
|
128
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gera Verify MCP Server — HOSTED transport (Streamable HTTP / SSE)
|
|
3
|
+
*
|
|
4
|
+
* Serves the exact same Proof-of-Real tools as the stdio server, but over the
|
|
5
|
+
* MCP Streamable HTTP transport, so remote MCP clients and ChatGPT Apps can
|
|
6
|
+
* reach it at a public URL (e.g. https://verify.gera.services/mcp) instead of
|
|
7
|
+
* spawning a local process. The tool handlers are reused unchanged via
|
|
8
|
+
* `createServer()` from server.ts.
|
|
9
|
+
*
|
|
10
|
+
* Runs stateless (a fresh McpServer + transport per request) — correct for a
|
|
11
|
+
* read-only, offline lookup service with no cross-call state, and safe to host
|
|
12
|
+
* behind a serverless function or a horizontally-scaled container.
|
|
13
|
+
*
|
|
14
|
+
* Endpoint contract (MCP Streamable HTTP, 2025-03-26 spec):
|
|
15
|
+
* POST /mcp → JSON-RPC request/response (and SSE streaming when used)
|
|
16
|
+
* GET /mcp → 405 (no server-initiated streams in stateless mode)
|
|
17
|
+
* GET /health → plain-text liveness probe (exempt, never blocked)
|
|
18
|
+
*
|
|
19
|
+
* Product: Gera Verify — a Gera Systems product (gera.services).
|
|
20
|
+
*/
|
|
21
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
22
|
+
export declare function createHttpRequestListener(): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
23
|
+
export declare function main(): Promise<void>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gera Verify MCP Server — HOSTED transport (Streamable HTTP / SSE)
|
|
3
|
+
*
|
|
4
|
+
* Serves the exact same Proof-of-Real tools as the stdio server, but over the
|
|
5
|
+
* MCP Streamable HTTP transport, so remote MCP clients and ChatGPT Apps can
|
|
6
|
+
* reach it at a public URL (e.g. https://verify.gera.services/mcp) instead of
|
|
7
|
+
* spawning a local process. The tool handlers are reused unchanged via
|
|
8
|
+
* `createServer()` from server.ts.
|
|
9
|
+
*
|
|
10
|
+
* Runs stateless (a fresh McpServer + transport per request) — correct for a
|
|
11
|
+
* read-only, offline lookup service with no cross-call state, and safe to host
|
|
12
|
+
* behind a serverless function or a horizontally-scaled container.
|
|
13
|
+
*
|
|
14
|
+
* Endpoint contract (MCP Streamable HTTP, 2025-03-26 spec):
|
|
15
|
+
* POST /mcp → JSON-RPC request/response (and SSE streaming when used)
|
|
16
|
+
* GET /mcp → 405 (no server-initiated streams in stateless mode)
|
|
17
|
+
* GET /health → plain-text liveness probe (exempt, never blocked)
|
|
18
|
+
*
|
|
19
|
+
* Product: Gera Verify — a Gera Systems product (gera.services).
|
|
20
|
+
*/
|
|
21
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
22
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
23
|
+
import { createServer as createMcpServer } from './server.js';
|
|
24
|
+
const MCP_PATH = '/mcp';
|
|
25
|
+
function readBody(req) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
let size = 0;
|
|
29
|
+
const MAX = 1_000_000; // 1 MB — lookup payloads are tiny.
|
|
30
|
+
req.on('data', (chunk) => {
|
|
31
|
+
size += chunk.length;
|
|
32
|
+
if (size > MAX) {
|
|
33
|
+
reject(new Error('Request body too large'));
|
|
34
|
+
req.destroy();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
chunks.push(chunk);
|
|
38
|
+
});
|
|
39
|
+
req.on('end', () => {
|
|
40
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
41
|
+
if (!raw) {
|
|
42
|
+
resolve(undefined);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
resolve(JSON.parse(raw));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
reject(new Error('Invalid JSON body'));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
req.on('error', reject);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function jsonError(res, status, message) {
|
|
56
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
57
|
+
res.end(JSON.stringify({
|
|
58
|
+
jsonrpc: '2.0',
|
|
59
|
+
error: { code: -32000, message },
|
|
60
|
+
id: null,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Handle a single MCP request in stateless mode: spin up a fresh server +
|
|
65
|
+
* transport, wire them, dispatch, and tear down when the response closes.
|
|
66
|
+
*/
|
|
67
|
+
async function handleMcpPost(req, res) {
|
|
68
|
+
let body;
|
|
69
|
+
try {
|
|
70
|
+
body = await readBody(req);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
jsonError(res, 400, err instanceof Error ? err.message : 'Bad request');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const server = createMcpServer();
|
|
77
|
+
const transport = new StreamableHTTPServerTransport({
|
|
78
|
+
// Stateless: no session id, no cross-request state.
|
|
79
|
+
sessionIdGenerator: undefined,
|
|
80
|
+
});
|
|
81
|
+
res.on('close', () => {
|
|
82
|
+
void transport.close();
|
|
83
|
+
void server.close();
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
await server.connect(transport);
|
|
87
|
+
await transport.handleRequest(req, res, body);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
console.error('[gera-verify:http] request error:', err);
|
|
91
|
+
if (!res.headersSent) {
|
|
92
|
+
jsonError(res, 500, 'Internal server error');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function createHttpRequestListener() {
|
|
97
|
+
return async (req, res) => {
|
|
98
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
99
|
+
const path = url.pathname;
|
|
100
|
+
// Liveness probe — exempt, never gated (Lesson 5 parity for hosted MCP).
|
|
101
|
+
if (path === '/health' && req.method === 'GET') {
|
|
102
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
103
|
+
res.end('ok');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (path === MCP_PATH) {
|
|
107
|
+
if (req.method === 'POST') {
|
|
108
|
+
await handleMcpPost(req, res);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Streamable HTTP allows GET for server-initiated SSE; we are stateless,
|
|
112
|
+
// so there is no standalone stream to open.
|
|
113
|
+
res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'POST' });
|
|
114
|
+
res.end(JSON.stringify({
|
|
115
|
+
jsonrpc: '2.0',
|
|
116
|
+
error: { code: -32000, message: 'Method not allowed. Use POST ' + MCP_PATH + '.' },
|
|
117
|
+
id: null,
|
|
118
|
+
}));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
122
|
+
res.end(JSON.stringify({ error: 'Not found', mcp_endpoint: MCP_PATH }));
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export async function main() {
|
|
126
|
+
const port = Number(process.env.PORT ?? process.env.MCP_HTTP_PORT ?? 3400);
|
|
127
|
+
const host = process.env.HOST ?? '0.0.0.0';
|
|
128
|
+
const httpServer = createHttpServer(createHttpRequestListener());
|
|
129
|
+
await new Promise((resolve) => httpServer.listen(port, host, resolve));
|
|
130
|
+
console.error(`Gera Verify MCP server (hosted) listening on http://${host}:${port}${MCP_PATH} — POST JSON-RPC; GET /health`);
|
|
131
|
+
const shutdown = () => {
|
|
132
|
+
httpServer.close(() => process.exit(0));
|
|
133
|
+
};
|
|
134
|
+
process.on('SIGINT', shutdown);
|
|
135
|
+
process.on('SIGTERM', shutdown);
|
|
136
|
+
}
|
|
137
|
+
// Auto-run ONLY when invoked directly as the built server (node dist/http.js).
|
|
138
|
+
// Must NOT match bin/http.js — that entry imports and calls main() itself, so a
|
|
139
|
+
// loose /http\.js$/ would double-invoke main() and crash with EADDRINUSE.
|
|
140
|
+
const isMain = typeof process !== 'undefined' &&
|
|
141
|
+
process.argv[1] != null &&
|
|
142
|
+
/[\\/]dist[\\/]http\.js$/.test(process.argv[1]);
|
|
143
|
+
if (isMain) {
|
|
144
|
+
main().catch((err) => {
|
|
145
|
+
console.error('Fatal:', err);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
});
|
|
148
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gera Verify MCP Server (stdio) — "Proof-of-Real"
|
|
3
|
+
*
|
|
4
|
+
* A Model Context Protocol server any AI agent (Claude, ChatGPT-with-tools,
|
|
5
|
+
* Cursor, any MCP client) can call to answer "is this real / how trustworthy
|
|
6
|
+
* is this?" about a UK business, food establishment, or care provider.
|
|
7
|
+
*
|
|
8
|
+
* It is seeded with REAL verified data Gera already owns and renders on its
|
|
9
|
+
* products: the FSA Food Hygiene Rating Scheme (used by GeraEats), the CQC
|
|
10
|
+
* care-provider registry (used by GeraClinic), public doctor listings, and
|
|
11
|
+
* Gera's crawled verified-provider set. Everything is offline — the data is a
|
|
12
|
+
* committed on-disk snapshot, so the server gives the same verified answer the
|
|
13
|
+
* Gera products give and runs anywhere with no backend, network, or auth.
|
|
14
|
+
*
|
|
15
|
+
* Honesty contract: every signal is source-attributed with an as-of date. When
|
|
16
|
+
* a subject is not in our records, the tools say so plainly ("not in our
|
|
17
|
+
* verified records") — they NEVER invent a rating or a status. Unavailable
|
|
18
|
+
* signals are reported as "unknown", not guessed.
|
|
19
|
+
*
|
|
20
|
+
* Product: Gera Verify — a Gera Systems product (gera.services).
|
|
21
|
+
*/
|
|
22
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
23
|
+
/**
|
|
24
|
+
* Register all Gera Verify tools on a McpServer instance. Transport-agnostic:
|
|
25
|
+
* the same handlers serve stdio (local) and Streamable HTTP / SSE (hosted).
|
|
26
|
+
*/
|
|
27
|
+
export declare function registerTools(server: McpServer): void;
|
|
28
|
+
/**
|
|
29
|
+
* Construct a fully-configured Gera Verify McpServer (all tools registered).
|
|
30
|
+
* Each call returns a fresh instance — used by the hosted HTTP transport in
|
|
31
|
+
* stateless mode (one server per request) and by the stdio server below.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createServer(): McpServer;
|
|
34
|
+
export declare const server: McpServer;
|
|
35
|
+
export declare function main(): Promise<void>;
|