social-services-il-mcp
Imports social service data exported from Airtable, including taxonomy trees, services, and organization data, by converting the export and pushing it through the public write API.
Serves the public API, admin API, write API, and MCP endpoints, as well as both single-page applications.
Provides Google sign-in for the admin interface, verified via signed identity tokens, with access managed through an allowlist or admin invitations.
Powers the public site and admin interface front-ends.
The implementation language used across the entire stack.
Build tooling for the public site and admin React applications.
social-services-il
An open platform for Israel's social services: a public site built for someone in distress on a phone, an admin for ingesting services from many kinds of source, a documented read/write API, and an MCP server so AI assistants can query the same data.
Live: https://social-services-il-zomerg.xhostd.app
Israel already has a national map of social services at kolsherut.org.il, operated by כל זכות, holding about 15,000 services across two taxonomy axes. This project is a separate, independent implementation over the same public data model, aimed at three gaps in that system:
There is no programmatic way to submit or update a service. An organisation must type its services into a third-party nonprofit registry and wait to be scraped, or send an email.
There is no documented public API and no agent interface. The endpoints exist but are undocumented and unstable, and the CKAN host that was meant to publish bulk dumps no longer resolves in DNS — so there is currently no bulk export of this data at all.
Free-text search does nothing server-side. Typing is first converted into taxonomy ids by an autocomplete index; anything that index does not recognise is unreachable. There is also no pagination, and location filtering happens in the browser over the entire result set.
docs/existing-system.md has the full research notes
— data model, ranking rules, ingestion sources, live API — and a table of what
this project keeps, changes and adds.
What works today
Loaded with the real corpus: 11,024 services from 2,534 organizations across 17,944 branches, producing 16,371 cards in 742 cities.
Public site | Hebrew, Arabic, Russian and English; mobile-first; results actionable without opening them; urgent helplines outside the ranked results; WCAG 2.2 AAA |
Smart search | A sentence instead of a keyword, resolved to categories through the same tools MCP exposes |
All-sources search | The site as an MCP client: registered servers are discovered at request time, so another corpus becomes searchable by adding a URL |
Search | Hebrew normalisation with prefix variants, synonyms, facets, distance ranking, collapsing, and a misspelling fallback. Free text answers in 95–300 ms |
Read API | Documented and versioned, with bulk NDJSON export and |
Write API | Idempotent push, |
MCP | Eight read-only tools, two resources, one guided prompt; no credentials |
Admin | Google sign-in by invitation, moderation, sources, API keys, diagnostics |
Importer | Converts the six-table export and pushes it through the public write API |
Deduplication |
|
Agreements | A prompt and a pipeline that read contracting agreements, decide whether each describes a service, and either push it or link it to the service already here — with models from Anthropic, OpenAI or Google, and a screen that reads one document with several at once and compares them |
Docs | Hebrew developer page at |
Four suites cover it end to end — 129 checks, all passing against production:
node scripts/smoke.mjs https://social-services-il-zomerg.xhostd.app
node scripts/smoke-mcp.mjs https://social-services-il-zomerg.xhostd.app
ADMIN_TOKEN=... node scripts/smoke-ingest.mjs https://social-services-il-zomerg.xhostd.app
ADMIN_TOKEN=... node scripts/smoke-agreements.mjs https://social-services-il-zomerg.xhostd.appAgreements as a source
A repository of contracting agreements is a description of the country's social services written for auditors: mostly furniture, construction and legal counsel, with real services scattered through it — and most of those already here, having arrived earlier from somewhere else.
node scripts/agreements.mjs ./agreements --out ./agreements-out # read
INGEST_KEY=... node scripts/agreements.mjs ./agreements --match # and ask the corpus
INGEST_KEY=... node scripts/agreements.mjs ./agreements --push --commit # and act on itReading is the model's job; deciding whether a service already exists is not.
POST /api/v1/ingest/match answers that from the corpus itself — the same
candidate always gets the same answer, with the score broken into name,
organisation, taxonomy, geography and contact, so a decision can be reproduced
and argued with. A confident match becomes a link rather than a second copy; an
uncertain one waits for a person instead of being forced into yes or no.
docs/agreements.md has the pipeline, the weights and the
reasoning.
What the import left behind
6,562 service-branch pairs are held back because their address never geocoded and they are not marked nationwide — a card with neither a point nor a nationwide flag cannot answer "where do I go". They are listed in the admin's diagnostics with the address that failed, which is the shortest route to fixing them. 793 services carry no response tag and 11,036 are inactive upstream; neither is imported.
Two fields are deliberately not imported. boost holds values up to 300, and
this system treats boost as a power of ten, so importing it verbatim would
produce infinities — editorial ranking is better re-established deliberately
than inherited with unknown semantics. And two services reference a taxonomy id
that does not exist in the export's own taxonomy tables, one of them visibly
corrupt (human_services:hehuman_services:health:...); they are reported rather
than guessed at.
Related MCP server: Data.gov.il MCP Server
Things only you can do
Smart search needs a model key. Everything else works without one, and the button hides itself until it is set:
set_env ANTHROPIC_API_KEY=sk-ant-... # secretReading agreements can use any of three providers; each is available on the screen once its key is set:
set_env OPENAI_API_KEY=sk-... # secret
set_env GEMINI_API_KEY=... # secretGoogle sign-in is already working and needed no credentials: the hosting platform mounts it on every channel, and the app verifies the signed identity token it sets. To grant someone access, either add their address to the allowlist or invite them from the admin:
set_env ADMIN_EMAILS=first@example.com,second@example.com # secretThe allowlist is the bootstrap — without it the first administrator could never
sign in to invite anyone. It is configuration rather than a database row so that
losing the database does not lock everyone out. ADMIN_TOKEN remains as a
recovery path and is what the test suites use.
(SESSION_SECRET, GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are no longer
read by anything — the platform's own sign-in replaced the hand-rolled OAuth
flow. SESSION_SECRET is still set on the app and can be deleted.)
Real data is loaded. To reload or update it:
# taxonomy first — the export's tree is a superset of the published one and
# carries 498 hand-built synonym terms that exist nowhere else
node scripts/import-airtable.mjs ./export --taxonomy --url https://... --admin <token>
# convert and read before writing anything
node scripts/import-airtable.mjs ./export --services --out payload.json
# then push
node scripts/import-airtable.mjs ./export --services --url https://... --key ssil_...It matches files by their columns rather than their names, and honours the
curation the export carries: final_responses and final_situations over the
raw columns, name_manual over name, and a hand-corrected coordinate over the
geocoder's.
Stack
TypeScript end to end. Express and Postgres (with PostGIS) on the server, React with Vite for the public site and the admin. Deployed on xhostd.
packages/
core/ shared types, Hebrew normalisation, card identity, ranking
db/ migrations, query helpers, the search query
ingest/ taxonomy loader, fixtures, connector definitions
api/ Express: public API, admin API, write API, MCP; serves both SPAs
web/ the public site and the developer documentation
admin/ the admin interface
docs/ existing-system.md, openapi.json
scripts/ smoke suites, the importer, the spec dumpDevelopment
npm install
npm run build
DATABASE_URL=postgres://... node packages/db/dist/migrate.js
PORT=3000 node packages/api/dist/server.jsWithout DATABASE_URL the server still starts and /api/health reports the
database as not-configured, which is enough for front-end work.
Notes on the design
A few decisions are load-bearing and easy to undo by accident:
Hebrew tokens are indexed in two forms, as written and with one leading particle removed, and every query term matches either. Stripping destructively cannot work:
מזוןloses itsמandלמזוןloses itsל, so the document and the query land on different tokens and never meet.Geography lives in its own table and keeps the geocoder's answer separate from an editor's correction, so re-running a geocoder cannot move a pin a human already fixed.
Tags carry an origin. Manual beats everything, and an LLM suggestion never reaches the public site until a person promotes it.
national_serviceis a fact, not missing data. A radius or viewport search always includes nationwide services.Cards record why a row was excluded instead of dropping it silently, so "why is my service not showing" is answerable in the admin.
A search records its outcome, not just its result count. Zero rows had been the only failure the log could express, and it conflated two different problems with two different owners: a search that ran correctly over a corpus with nothing in it, and a search that never ran — one that threw, that the model declined, that hit the rate limit, or that reached a source which was down. The second kind was worse than unmeasured: smart search logged only its successes and deep search logged nothing at all, so the searches most worth reading were exactly the ones missing. Every route now writes through one helper on every exit, and the admin's search log opens each row onto what it actually produced — the cards, the answer shown, the tools called, the error. Cards are resolved by id at read time rather than snapshotted, so one that has since been deleted shows as missing instead of as a row that still exists. Still no identifier: what was asked and what came back, never who asked, which is also why the log is swept on a timer rather than kept.
The scheduler runs in-process. Each channel on the host gets its own Postgres, so a separate worker channel would connect to an empty database.
The search predicate is assembled in JavaScript, not guarded with
$1 IS NULL OR .... That idiom hides from the planner which filters are present, which put the indexable full-text match inside an OR that can never use an index — 1.4 seconds per query on the real corpus, against ~120 ms.The tools are defined once, in
packages/api/src/tools.ts, and shared by the MCP server and smart search, so an assistant and the site's own search cannot answer the same question differently.MCP is used in both directions. The site publishes a server, and is also a client of a registry of servers (
mcp_servers). Pointing the client at our own server buys nothing on its own — same tools, longer path — and it is not claimed to. It is there so that the local corpus and an external one are reached by the same mechanism, and so a second source is a URL in the admin rather than an integration. The all-sources button only appears once a second server is enabled.The admin is a second SPA under
/adminand needs its own route before the catch-all. Static is served withindex: falseso that/reaches the router, which means a bare/admin/otherwise falls through and silently serves the public site — indistinguishable from the admin never having been built.Identity is verified, not trusted. RS256 is pinned rather than read from the token's own header, and the audience comes from the request host rather than a constant, so a token minted for one channel cannot be replayed at another.
scripts/check-auth.mjstries to forge its way in.Contrast is checked, not eyeballed.
npm run check:contrastfails the build's palette if any pair drops below AAA; the first palette failed six of thirteen pairs, all in the 5:1–7:1 range that looks fine.
Data and licensing
The service data this platform is designed to hold is public information published by nonprofits, government ministries and local authorities. The taxonomy derives from openeligibility, itself a fork of the US Open Eligibility Project.
This code is MIT licensed. The data it serves carries its own terms from each upstream source, recorded per record.
This server cannot be deployed
Maintenance
Related MCP Connectors
Agent-readable search over the NDIS Provider Register (CC BY 4.0 AU) by location and service.
Public Data Ukraine Mcp connects AI agents to real public APIs via MCP. Tools include
Resolve any government entity worldwide and submit service requests. Open civic data for AI agents.
Query the HokAI catalogue of AI tools, agents, models, companies and infrastructure services.
Related MCP Servers
- FlicenseNot gradedqualityCmaintenanceProvides access to Israel's OpenBudget API, allowing users to query and search various government budget datasets including budget items, contracts, and support payments.12-
- AlicenseCqualityBmaintenanceEnables AI assistants to search, discover, and analyze thousands of datasets from Israel's national open data portal. It provides tools for querying government ministries, municipalities, and public bodies using the CKAN API.953 npm110MIT
- AlicenseAqualityBmaintenanceEnables AI assistants to query bills, committees, and members from the Israeli Knesset parliamentary information API via MCP tools.927 npm2MIT
- FlicenseNot gradedqualityDmaintenanceProvides AI agents with comprehensive access to Israel's complete pharmaceutical database from the Ministry of Health, enabling drug information search, therapeutic comparisons, and safety assessments.18-