Directory Tools

Eleven tools that build and maintain a directory — a set of pages generated from your own business data, one per category, per city, and per business. They ship in @spideriq/mcp-publish (from 1.43.0) and in the kitchen-sink @spideriq/mcp (from 1.91.1). All accept an optional workspace and project.

A directory is two things: a category (the vertical, "Plumbers") and its listings (the businesses). Everything else — the URLs, the SEO titles, the sitemap entries, the city pages — is generated from those two.

The URL shape

/{directory_base}/{category_slug}                              → the category hub, a list of cities
/{directory_base}/{category_slug}/{city_slug}                  → listings in that city
/{directory_base}/{category_slug}/{city_slug}/{listing_slug}   → one business

directory_base defaults to directory and is renamable per tenant in the template config. city_slug is derived server-side from each listing's city and state as it is imported. You never pass one.

Categories

directory_create_category

Create the vertical the listings hang off. Required: name. Optional: slug (generated from name if omitted), description, icon, seo_title_template, seo_description_template, template, data_source, sort_order.

The two SEO templates accept {category}, {city} and {listing}. They render server-side on every public read and they generate the title and description of every sitemap.xml entry the category contributes, so a placeholder typo reaches search engines rather than one page.

directory_create_category({
  name: "Plumbers",
  slug: "plumbers",
  seo_title_template: "Best {category} in {city} | Acme Directory",
  seo_description_template: "Compare {category} in {city}. Ratings, reviews, hours."
})
// → { id: "9f2c4d1a", slug: "plumbers", listing_count: 0, city_count: 0 }

Errors:

Identifier

Trigger

Resolution

422 VALIDATION_ERROR

slug is not ^[a-z0-9][a-z0-9-]*$, or name is missing

send a hyphenated lowercase slug, or omit slug and let it generate

409 CONFLICT

a category with that slug already exists for this tenant

use directory_update_category, or pick another slug

directory_list_categories

Every category for the tenant, including empty ones. Optional: page, page_size. Page size defaults to 50, so a tenant with more verticals than that needs page.

directory_list_categories({ page_size: 50 })
// → { categories: [{ id, name, slug, listing_count: 118, city_count: 12 }], total: 3, page: 1 }

Errors:

Identifier

Trigger

Resolution

401 UNAUTHORIZED

the token does not carry content:read

re-issue the PAT with content scopes

422 VALIDATION_ERROR

page_size outside its accepted range

request a smaller page

directory_get_category

One category plus every city that currently has published listings in it. Required: slug. This is the read that tells you which city pages exist.

directory_get_category({ slug: "plumbers" })
// → { category: { slug: "plumbers", listing_count: 118 },
//     cities: [{ city: "Miami", city_slug: "miami-florida", listing_count: 24 }] }

Errors:

Identifier

Trigger

Resolution

404 NOT_FOUND

no category with that slug for this tenant

list categories first; slugs are per tenant

directory_update_category

Change any category field. Required: slug. Pass only what changes; everything else is left alone.

directory_update_category({
  slug: "plumbers",
  seo_title_template: "Licensed {category} in {city} | Acme Directory"
})
// → { slug: "plumbers", updated: true }

Errors:

Identifier

Trigger

Resolution

404 NOT_FOUND

no category with that slug

check the slug with directory_list_categories

422 VALIDATION_ERROR

an SEO template uses a placeholder that is not {category}, {city} or {listing}

correct the placeholder before it reaches the sitemap

directory_delete_category

Deletes the category and every listing in it, by cascade. Required: slug. There is no undo and no archive step here — to take listings out of circulation reversibly, set their status to archived instead.

directory_delete_category({ slug: "plumbers-old" })
// → { deleted: true, listings_deleted: 118 }

Errors:

Identifier

Trigger

Resolution

404 NOT_FOUND

no category with that slug

nothing to delete; confirm the slug first

Listings

directory_upsert_listing

One listing, created or updated. Required: category_slug, name. Optional: slug, description, city, state, country, address, latitude, longitude, phone, email, website, rating, review_count, data, source_job_id, status.

The upsert key is (category, slug). There is no external_id column and no on_conflict parameter; to carry an id from another system, put it in the data object or in source_job_id.

This is also how you archive a listing reversibly.

directory_upsert_listing({
  category_slug: "plumbers",
  name: "Reliable Rooter",
  slug: "reliable-rooter",
  city: "Miami",
  state: "Florida",
  phone: "+13055550142",
  rating: 4.6,
  status: "archived"
})
// → { slug: "reliable-rooter", inserted: false, updated: true }

Errors:

Identifier

Trigger

Resolution

404 NOT_FOUND

category_slug does not exist

create the category first; listings cannot exist outside one

422 VALIDATION_ERROR

rating out of range, or a malformed slug

send a rating between 0 and 5 and a hyphenated slug

directory_bulk_upsert_listings

Many listings in one transaction. Required: category_slug, listings. Maximum 5000 per call — paginate above that, because a larger single transaction risks a timeout. Each entry takes the same fields as directory_upsert_listing; a missing slug is generated from name.

Use this for a hand-built list, a third-party feed, or SpiderMaps job output. For the tenant's own corpus use directory_import_from_idap, which is one call instead of an export step.

directory_bulk_upsert_listings({
  category_slug: "plumbers",
  listings: [
    { name: "Reliable Rooter", city: "Miami", state: "Florida", rating: 4.6 },
    { name: "Bayside Plumbing", city: "Miami", state: "Florida", rating: 4.8 }
  ]
})
// → { upserted: 2, failed: 0, affected_cities: ["miami-florida"], failures: [] }

Errors:

Identifier

Trigger

Resolution

422 VALIDATION_ERROR

more than 5000 entries in one call

split the batch; the cap matches the import cap

404 NOT_FOUND

category_slug does not exist

create the category first

partial failures[]

individual rows failed validation while the rest committed

read failures, fix those rows, re-send only them

directory_import_from_idap

Populate a category from the tenant's own business corpus in one call. Required: category_slug. Filters, all optional: category_filter (matches the categories[] array), country_code (ISO-2, uppercase, exact), city (substring match), rating_min (inclusive), limit (hard cap 5000), prune.

It reads the corpus directly. There is no scheduler, no sync pipeline and no export step, and nothing keeps the directory current on its own — re-running the import is a decision someone makes.

Every column the corpus declares is carried through: into a typed column where one exists, otherwise into the listing's data object. The set is generated rather than hand-maintained, so a new column arrives without anyone editing a list. Read fields_mapped in the response to see what landed.

directory_import_from_idap({
  category_slug: "plumbers",
  category_filter: "Plumber",
  country_code: "US",
  rating_min: 4.0,
  limit: 5000,
  prune: true
})
// → { upserted: 118, inserted: 96, updated: 22, pruned: 4, failed: 0,
//     source_rows: 118, fields_mapped: 45, affected_cities: ["miami-florida"] }

prune is the only thing that removes a stale listing, and it is off by default. Both import paths are an upsert, so a business deleted from the corpus, or renamed such that it derives a different slug, keeps its old listing published forever. With prune: true every listing the import's own result set did not produce is set to archived — never deleted, and reversible with directory_upsert_listing.

Prune refuses rather than guess, and names the refusal in prune_skipped_reason. A refusal is an answer: re-running unchanged refuses identically, so change the inputs instead of retrying.

Errors:

Identifier

Trigger

Resolution

404 NOT_FOUND

category_slug does not exist

create the category first

hint in a 200

the tenant has no business corpus yet

the response explains the workaround rather than failing; import with directory_bulk_upsert_listings instead

prune_skipped_reason: source_returned_no_rows

the filters matched nothing, so pruning would archive the whole category

confirm the filter returns rows, then re-run

prune_skipped_reason: source_truncated_at_limit

the read hit limit, so absence from this page is pagination, not deletion

raise limit or narrow the filter

directory_list_listings

List listings, filtered. Optional: category_slug, city, status, page, page_size. This is the audit read — use it to check what an import actually wrote before you trust a page.

It is also the only read that sees a listing with an empty city. Such a listing appears on no city page at all, because it produces no city rollup row, so a check that walks city pages cannot find it. Absence of a page is not absence of a listing.

directory_list_listings({ category_slug: "plumbers", city: "Miami", page_size: 50 })
// → { listings: [{ slug: "reliable-rooter", city_slug: "miami-florida", status: "published" }],
//     total: 24, page: 1 }

Errors:

Identifier

Trigger

Resolution

404 NOT_FOUND

category_slug filter names a category that does not exist

omit the filter to list across every category

422 VALIDATION_ERROR

status is not a recognised value

filter on published or archived

directory_delete_listing

Remove one listing permanently and refresh the city rollup. Required: category_slug, listing_slug. To take a listing out of circulation reversibly, set its status to archived with directory_upsert_listing instead.

directory_delete_listing({ category_slug: "plumbers", listing_slug: "reliable-rooter" })
// → { deleted: true, affected_cities: ["miami-florida"] }

Errors:

Identifier

Trigger

Resolution

404 NOT_FOUND

no listing with that slug in that category

slugs are unique per category, not per site; check the category

Statistics

directory_refresh_stats

Rebuilds the city rollup that the category hub and every city page read to know which cities exist and how many listings each holds. No required parameters.

You rarely need it — every write path refreshes the rollup itself. Reach for it when a hub or city page shows a stale city list after a change made outside the normal write path.

It does not recompute a category's listing_count. Those are two numbers with similar names. listing_count lives on the category row and is maintained only by the write paths: bulk upsert, import, prune, and delete-listing. If one looks wrong, re-run the write that produced it.

directory_refresh_stats({})
// → { refreshed: true, rows: 12 }

Errors:

Identifier

Trigger

Resolution

401 UNAUTHORIZED

the token does not carry content:write

re-issue the PAT with content scopes

What is not here

  • There is no directory_archive_listing tool. Archive with directory_upsert_listing and status: "archived".

  • There is no external_id or on_conflict. The upsert key is (category, slug) and nothing else.

  • Nothing syncs on a schedule. A directory is as current as the last import someone ran.

See also

Publish