Skip to main content
Glama
zomer-g

social-services-il-mcp

by zomer-g

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 updated_since

Write API

Idempotent push, dry_run, per-item errors and warnings, trust-based publish or review

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

POST /api/v1/ingest/match answers "do you already have this", deterministically and with the score broken into its parts, before anything is written

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 /developers, machine contract at /api/openapi.json

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.app

Agreements 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 it

Reading 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-...   # secret

Reading 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=...             # secret

Google 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   # secret

The 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 dump

Development

npm install
npm run build
DATABASE_URL=postgres://... node packages/db/dist/migrate.js
PORT=3000 node packages/api/dist/server.js

Without 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_service is 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 /admin and needs its own route before the catch-all. Static is served with index: false so 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.mjs tries to forge its way in.

  • Contrast is checked, not eyeballed. npm run check:contrast fails 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.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    C
    maintenance
    Provides access to Israel's OpenBudget API, allowing users to query and search various government budget datasets including budget items, contracts, and support payments.
    12
    -
  • A
    license
    C
    quality
    B
    maintenance
    Enables 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.
    9
    53 npm
    110
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Enables AI assistants to query bills, committees, and members from the Israeli Knesset parliamentary information API via MCP tools.
    9
    27 npm
    2
    MIT