Skip to main content
Glama
gouda-tijdmachine

Omeka S MCP server (Gouda Tijdmachine) — omeka-gtm MCP server

Omeka S MCP server (Gouda Tijdmachine)

An MCP server that lets Claude Code (or any other MCP client) read and write the Omeka S REST API of the Gouda Tijdmachine: https://www.goudatijdmachine.nl/omeka/api.

It runs locally over stdio: the MCP client starts it as a child process. There is no HTTP endpoint and nothing to deploy on Apache.

Tools

Tool

Kind

What it does

omeka_search

read

Search/list items, media, item sets, sites, site pages, vocabularies, properties, resource classes, resource templates, assets and users. Full-text search, property filters by term (sdo:name), class by term, template, item set, site, sorting, paging. Returns the total and one page of compact results.

omeka_get

read

One resource by type + ID, compact (simplified values) or full (raw JSON-LD).

omeka_get_template

read

A resource template with per property: term, label, required/private, allowed data types.

omeka_lookup_term

read

Find properties or resource classes by term, local name or label.

omeka_get_page

read

A site page by ID or site + slug, with its blocks (by index), public URL and modified timestamp. Long block strings are truncated unless asked otherwise.

omeka_create_item

write

Create an item or item set with template, class, item sets, sites, visibility and values.

omeka_update_resource

write

Change values (set / add / remove / annotate per term), visibility, template, class, item sets or sites of an item, item set or media. Always GET + merge + PUT.

omeka_add_media

write

Attach media to an item via the url, iiif or html ingester.

omeka_create_page

write

Create a site page, optionally with blocks.

omeka_update_page

write

Change a page's title, slug, visibility, layout and blocks (replace all, or append/insert/replace/update/delete/move by index). Always GET + modify + PUT, with an optional expect_modified guard.

omeka_delete

write

Permanently delete an item, item set, media or site page.

Write tools are only registered when an API key is configured and OMEKA_READ_ONLY is not set. Every write is followed by a read-back; values Omeka silently dropped are returned under warnings.

Related MCP server: zotero-mcp

Requirements

  • Node.js ≥ 20.6 (for --env-file; this server runs Node 24) and npm.

  • Network access to the Omeka S API.

  • An Omeka S API key (see below). Without a key the server still starts, read-only and anonymous.

Omeka user & API key requirements

An API key always belongs to an Omeka user and has exactly that user's permissions.

Creating a key: log in to the Omeka admin → Users → edit the user → tab API keys → enter a label → Save. The key_identity and key_credential are shown once; copy them straight into .env.

Reads use the key too. On this installation anonymous API responses are rewritten by the RdfDispatch setup (no o: fields such as o:id, o:resource_template), and private resources are invisible without a key. The read tools depend on those fields.

Which role do you need? (Omeka S core roles)

Role

Read (incl. private)

Create items/media

Edit/delete own resources

Edit/delete resources of others

Vocabularies, templates, sites

Researcher

yes

–

–

–

–

Author

yes

yes

yes

–

–

Reviewer

yes

yes

yes

edit only

–

Editor

yes

yes

yes

yes

–

Site admin

yes

yes

yes

yes

sites

Global admin

yes

yes

yes

yes

yes

Recommendations:

  • For the write tools use a dedicated user with role Editor. Author is not enough: almost all existing items are owned by user 2 ("Gouda Tijdmachine Beheerder"), and authors can only edit their own resources. New items are owned by the key's user.

  • With the Teams module enabled, the user must also be a member of the team that owns the resources (and have a team role that allows editing).

  • For read-only use, a Researcher key plus OMEKA_READ_ONLY=1 is sufficient.

  • OMEKA_READ_ONLY=1 also hides the write tools when you reuse a more powerful key.

  • A permission error is returned as HTTP 403 with a pointer to this section.

Install & build

git clone https://github.com/gouda-tijdmachine/Omeka-S-local-MCP-server.git
cd Omeka-S-local-MCP-server   # on the GTM server this lives in /home/http/goudatijdmachine.nl/mcp
npm ci                        # or: npm install
cp .env.example .env
chmod 600 .env                # the file contains the API credential
$EDITOR .env                  # fill in OMEKA_KEY_IDENTITY and OMEKA_KEY_CREDENTIAL
npm run build                 # compiles src/ to dist/
npm test                      # unit tests + a read-only live test (skipped without .env)

.env settings:

Variable

Default

Meaning

OMEKA_API_URL

https://www.goudatijdmachine.nl/omeka/api

API base URL, no trailing slash

OMEKA_KEY_IDENTITY

–

API key identity

OMEKA_KEY_CREDENTIAL

–

API key credential

OMEKA_READ_ONLY

0

1 registers the read tools only

OMEKA_READBACK_DELAY_MS

1500

Wait before the read-back after a write (database replica lag)

The directory is outside every Apache DocumentRoot, so .env is not reachable over the web. Keep it that way if you move the server.

Run / try it out

The server talks JSON-RPC on stdin/stdout and waits for a client, so running it directly only shows the startup line on stderr:

npm start                     # = node --env-file=.env dist/server.js
npm run dev                   # same, from the TypeScript sources via tsx (no build needed)

To click through the tools interactively, use the MCP Inspector (opens a local web UI):

npx @modelcontextprotocol/inspector node --env-file=.env dist/server.js

Register in Claude Code

Project scope (only when Claude Code is started in /home/http/goudatijdmachine.nl or below): create /home/http/goudatijdmachine.nl/.mcp.json:

{
  "mcpServers": {
    "omeka-gtm": {
      "command": "node",
      "args": [
        "--env-file=/home/http/goudatijdmachine.nl/mcp/.env",
        "/home/http/goudatijdmachine.nl/mcp/dist/server.js"
      ]
    }
  }
}

Claude Code asks once to approve project MCP servers from .mcp.json.

User scope (available in every directory):

claude mcp add omeka-gtm --scope user -- \
  node --env-file=/home/http/goudatijdmachine.nl/mcp/.env \
  /home/http/goudatijdmachine.nl/mcp/dist/server.js

Then restart Claude Code and check with /mcp that omeka-gtm is connected and lists its tools. The API key stays in .env; it never ends up in .mcp.json or ~/.claude.json.

After changing the code: npm run build, then reconnect the server via /mcp (or restart Claude Code).

Claude Desktop (same machine): add the same command/args block under mcpServers in claude_desktop_config.json.

Value notation

The write tools accept, and the read tools return, property values in a simplified notation, keyed by term:

{
  "dcterms:title":     [{ "value": "Markt", "lang": "nl" }],
  "owl:sameAs":        [{ "uri": "https://n2t.net/ark:/60537/b03ms0m" }],
  "sdo:url":           [{ "uri": "https://example.org/", "label": "Example" }],
  "sdo:isPartOf":      [{ "resource": 3076353 }],
  "sdo:about":         [{ "resource": 2, "type": "resource:itemset" }],
  "sdo:datePublished": [{ "value": "1917-12-01", "type": "numeric:timestamp" }],
  "sdo:geo":           [{ "value": "52.0115,4.7105", "type": "geography:coordinates" }],
  "sdo:keywords":      [{ "value": "intern", "public": false }]
}
  • No type: resource → resource:item, uri → uri, otherwise literal.

  • Any other data type (custom vocab customvocab:<id>, numeric:*, geography:*, geometry:*, value suggest, ...) is passed as type. Use omeka_get_template to see the allowed data types of a property.

  • lang on a URI value is stored as the URI's language (o:lang).

  • Read output adds title to resource links and omits type when it is the default.

Value annotations

Any value can carry an annotation: property values about that value, keyed by term, in the same notation (Omeka S 4 value annotations, @annotation in the API). Typical use is the source of a statement:

{
  "sdo:description": [{
    "value": "Vermeld in 1757", "lang": "nl",
    "annotation": {
      "prov:hadPrimarySource": [{ "resource": 266234 }],
      "sdo:citation": [{ "uri": "https://gis.gouda.nl/geoserver/wfs", "label": "Gemeente Gouda, GIS-laag", "lang": "nl" }]
    }
  }]
}

Annotations cannot be nested. Read tools return them the same way.

Updating, per term:

{
  "sdo:keywords": { "mode": "add",    "values": [{ "value": "Markt", "lang": "nl" }] },
  "sdo:name":     { "mode": "set",    "values": [{ "value": "Grote Markt", "lang": "nl" }] },
  "sdo:text":     { "mode": "remove", "values": [] }
}

remove with values removes only those values (matched on value + language, URI, or linked resource ID); with an empty list it removes the term entirely.

Existing annotations are always kept by add, remove and by updates of other terms. add skips a value that already exists, including any annotation given with it. To change the annotation of an existing value use annotate: the value is matched the same way as for remove, its annotation is replaced by the given one, and omitting annotation removes it:

{
  "sdo:description": { "mode": "annotate", "values": [
    { "value": "Vermeld in 1757", "lang": "nl", "annotation": { "prov:hadPrimarySource": [{ "resource": 310639 }] } },
    { "value": "Tweede omschrijving", "lang": "nl" }
  ] }
}

Site pages and blocks

Pages are addressed by ID or by site (ID or slug, e.g. data) + page slug. Blocks have no ID in the API; they are addressed by their 0-based index in the page, as shown by omeka_get_page.

A block in simplified notation:

{
  "layout": "html",
  "data": { "html": "<p>Text</p>" },
  "layout_data": { "grid_column_position": "auto", "grid_column_span": "12" },
  "attachments": [{ "item": 123, "media": 456, "caption": "Caption" }]
}

data depends on the layout: {"html": ...} for html, {"markdown": ...} for markdown (the module adds html and fingerprint itself), {} for pageTitle, and the module-specific keys for geoJsonMap, sparql, browsePreview, verhaalhalen, ... Copy the shape from an existing block of the same layout (omeka_get_page with block_index).

Editing blocks with omeka_update_page, operations run in order and each index refers to the list as it is at that moment:

{
  "id": 73,
  "expect_modified": "2025-11-08T23:35:17+00:00",
  "block_ops": [
    { "op": "update",  "index": 2, "data": { "html": "<p>New text</p>" } },
    { "op": "append",  "block": { "layout": "markdown", "data": { "markdown": "## Sources" } } },
    { "op": "move",    "index": 3, "to": 1 },
    { "op": "delete",  "index": 0 }
  ]
}

update merges the given data/layout_data keys into the block and replaces its attachments only when given; replace swaps in a whole new block; blocks (instead of block_ops) replaces all blocks. Pass the modified value from omeka_get_page as expect_modified: pages are sometimes edited by hand in the admin, and the update is refused when the page changed in between. New pages are not added to the site navigation.

Design choices & Omeka pitfalls

  • Never PATCH. Omeka's value hydrator treats a PATCH that contains property values as a full replacement of all values: every value not included is deleted (including owl:sameAs/ARK permalinks). omeka_update_resource therefore always does GET → merge → PUT of the complete representation, and the client has no PATCH method at all. Media, item sets, owner and all unmentioned terms are sent back unchanged.

  • Read-back verification. Omeka silently drops values with an unknown or invalid data type instead of returning an error. After each write the resource is fetched again and every value that was sent but is not stored is listed under warnings.

  • Replica lag. Reads directly after a write can hit a database replica that is a moment behind; the read-back waits OMEKA_READBACK_DELAY_MS first.

  • Pages: PUT re-saves every block. A PATCH with o:block appends blocks, so pages are always updated with GET + modify + PUT of the whole page. That re-saves all blocks, also the ones you did not touch, and each block layout's save hook runs again. The core html layout passes its HTML through HTMLPurifier when the global setting use_htmlpurifier is on, which strips inline <style> and <script>. Several pages here depend on inline code (e.g. Hofstedegeldnummers, page 73; Goudse archivarissen). On this installation the setting is off (checked 2026-10-17: setting.use_htmlpurifier = "0"; a test page with a <script> block survived two saves unchanged). As a safety net the read-back compares every block, touched or not, with what was sent and reports any change under warnings. If you ever switch the purifier on, do not edit such pages through the API.

  • Deletes that report an error. On this installation the Comment module used to throw an HTTP 500 after an item was already deleted. omeka_delete therefore checks with a GET after a 5xx and reports the resource as deleted if it is gone.

  • Terms instead of IDs. Property and class terms are resolved to IDs via a per-process cache of /properties and /resource_classes. A new property or class added in Omeka is picked up after reconnecting the server.

  • Small responses. Search results omit values unless include_values is set, paging is explicit (max 100 per page), and the JSON-LD @context is stripped.

  • stdio only. Logging goes to stderr; stdout is reserved for MCP messages.

Project layout

src/server.ts        entry point: configuration, tool registration, stdio transport
src/omeka.ts         API client (key on every request, PHP-style query serialisation)
src/terms.ts         term <-> ID cache for properties and resource classes
src/values.ts        simplified value notation (incl. annotations), merge logic, read-back check, compact output
src/pages.ts         simplified block notation, block operations, page read-back check
src/tools/read.ts    read tools
src/tools/write.ts   write tools
src/tools/pages.ts   site page tools (read + write)
tests/               unit tests (values, annotations, blocks, query building) and a live read-only stdio test

TODO

  • Bulk update tool (same term update on all results of a search, with dry run)

  • Item set membership by search (add/remove many items at once)

  • Site pages and page blocks

  • Value annotations (@annotation) in the simplified notation

  • Add pages to / remove pages from the site navigation

  • Media file upload from a local path (multipart)

  • Automatic data type for resource links that point to item sets or media

  • Resource template validation (required properties, allowed data types) before writing

License

GPL-3.0-or-later. See LICENSE.

Related MCP Connectors

Related MCP Servers