Imported from mpasternak/dspace-skills (
dspace-api/SKILL.md). Install upstream withnpx skills add mpasternak/dspace-skills --skill dspace-api. Copyright stays with the author.
DSpace REST API (read-only)
DSpace is an open-source repository platform for institutions (theses, articles,
datasets, images). Every DSpace 7 or newer instance exposes a HAL+JSON REST
API. This skill is the operational knowledge for querying that API read-only
and anonymously — enough to talk to any DSpace 7+ site with nothing but
curl or WebFetch.
The exhaustive per-endpoint table lives in
references/endpoints.md; copy-paste curl recipes are
in references/examples.md. Read them when you need the
detail — the operational essentials are below.
Read-only and anonymous — by construction
Everything here is a GET. No login, no token, no CSRF (DSpace requires a
CSRF token only for mutating methods and POST /authn/login, never for GET —
verified). Anonymous access means you cannot see what the public cannot:
embargoed items, workflow/submission items, and restricted collections stay
invisible, returning 401/403. That is a feature, not a fault — treat those
codes as "not public", never as "log in and retry" (you have no credentials).
Never issue a non-GET request through this skill. Writing to DSpace is a
whole separate API surface (/api/submission/workspaceitems, JSON-Patch,
/api/workflow/…) plus JWT and CSRF, and is out of scope.
The host is configurable
This skill is not tied to one server. Every DSpace deployment has its own host. In the recipes, set a variable and substitute the real target:
DSPACE="https://demo.dspace.org/server" # the REST base URL — replace it
Ask the user for their repository URL if they did not give one. The REST API
root is always <base-url>/api, and the base URL normally ends in /server
(e.g. https://demo.dspace.org/server → API at https://demo.dspace.org/server/api).
If someone gives you a bare host, probe <host>/server/api (see below). Paths in
this skill are written relative to the API root, e.g. $DSPACE/api/discover/....
How to call
- Method is
GET. Always. SendAccept: application/json. - Never send an
Originheader. With it, DSpace rejects even plain GETs with403(verified empirically). This mainly matters if you hand-build requests. - Follow redirects.
/api/pid/findanswers with a 302, and a bitstream's/contentoften 302-redirects to external storage (e.g. a presigned S3 URL).curl -L;WebFetchfollows automatically for the content itself but returns cross-host redirects to you — re-fetch theLocation. - Identify yourself. You are hitting someone else's repository in a loop;
anonymous traffic with no User-Agent gets IP-banned. Send a real
User-Agent(curl's default is fine). - Rate limits: on
429/503, stop and wait — do not auto-retry (you are the noisy client; retrying under a rate limit earns a ban). Be gentle when harvesting.
curl -sL -H "Accept: application/json" "$DSPACE/api"
WebFetch works too for public endpoints (it is a plain anonymous GET) — give
it the full URL.
Step 1 — probe the instance, then detect its capabilities
Always start with GET /api. It returns the instance's identity and, crucially,
whether the URL is even right:
curl -sL "$DSPACE/api" | jq '{name: .dspaceName, ui: .dspaceUI, version: .dspaceVersion}'
dspaceName,dspaceUI(the human web UI base — build user-facing links from it),dspaceServer, anddspaceVersion(a descriptive string like"DSpace 7.6.5"or"DSpace 10.1-SNAPSHOT", not a clean number — parse the first digits if you need the major/minor).- If this 404s, the base URL is probably missing
/server. Append it and retry:${DSPACE%/}/server/api. The MCP server does this automatically.
Do not branch behaviour on the version number. What actually varies between installations is the set of search filters, sort fields and facets — and that is configured per instance (
discovery.xml), so two sites on the same DSpace version can differ more than two versions of one site. Detect, don't assume:
# Which filters and sorts does THIS instance support?
curl -sL "$DSPACE/api/discover/search" \
| jq '{filters: [.filters[].filter], sorts: [.sortOptions[].name]}'
Using a filter the instance does not have returns 422. So consult this list
(or the facet list, below) before filtering, and tell the user what the instance
supports rather than letting a query 422.
Searching items — /api/discover/search/objects
The one search endpoint. Filter to items with dsoType=item.
curl -sL "$DSPACE/api/discover/search/objects?dsoType=item&query=climate&size=10&embed=owningCollection"
Key parameters:
| Want | Parameter |
|---|---|
| Full-text query | query=<text> |
| Restrict to a community/collection | scope=<UUID> |
| Year range | f.dateIssued=[2020 TO 2025],equals (use * for an open end) |
| Author (substring) | f.author=<name>,contains — contains, not equals; you rarely know the exact recorded form |
| Sort | sort=score,DESC | dc.date.issued,DESC | dc.date.issued,ASC | dc.title,ASC |
| Page size / page | size=N & page=P (0-based) |
| Owning collection inline | embed=owningCollection (fills the collection name without an N+1 request) |
- There is no
dc.typefilter in vanilla DSpace. Filtering by document type works only if the instance defines such a filter (e.g.itemtype) — check the capability list first, or use a facet. - Count only: to answer "how many?" cheaply, request
size=1and read the total (below) — do not download records to count them. Avoidsize=0; the REST contract lets servers reject it with400.
The result nesting is deep (this trips everyone up):
_embedded.searchResult._embedded.objects[i]._embedded.indexableObject ← each hit (the item)
_embedded.searchResult.page ← {size, number, totalPages, totalElements}
_embedded.facets ← top-level, NOT under searchResult
# hits (raw items) and the total:
curl -sL "$DSPACE/api/discover/search/objects?dsoType=item&query=climate&size=5" | jq '{
total: ._embedded.searchResult.page.totalElements,
hits: [._embedded.searchResult._embedded.objects[]._embedded.indexableObject.metadata."dc.title"[0].value]
}'
totalElements is the true match count — always report it, and note when you are
only showing the first page (totalElements > size), so you never quietly reason
about a whole repository from 10 records. Drop hitHighlights (it carries <em>
markup and HTML entities you do not need).
Fetching one item — by UUID, handle, or DOI
# By UUID (the direct, canonical route):
curl -sL "$DSPACE/api/core/items/<uuid>?embed=owningCollection,bundles/bitstreams"
If you have a handle or DOI instead, resolve it first, then always
re-fetch by UUID: /pid/find answers with a redirect, and the redirect drops
your ?embed=, so the same item would otherwise come back in a different shape
depending on which identifier you used.
- Handle (
123456789/42orhdl:123456789/42):GET /api/pid/find?id=hdl:123456789/42→ 302 → readuuid→ fetch/core/items/{uuid}. - DOI:
GET /api/pid/find?id=doi:10.1234/abcd. On404/501, fall back to search:query="10.1234/abcd"and matchdc.identifier.doi— on many instances a DOI lives only in the metadata, with no registered resolver. - Verify the type.
/pid/findresolves any DSpace object; check the resolved object'stypeis"item"(not a community/collection). Use thetypefield, neveruniqueType(that one is absent on vanilla 7.x/8.x/9.x).
Validate UUID shape yourself before requesting. A malformed UUID in the path
makes DSpace answer 401 "Authentication is required" — not 400 — which
would send you hunting for a login that does not exist. Match
^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ (case-insensitive)
first.
The metadata shape (DSpace 7+)
Item metadata is one format: a map of Dublin Core keys to lists of objects,
ordered by place:
"metadata": {
"dc.title": [{"value": "…", "language": "en", "place": 0}],
"dc.contributor.author": [{"value": "Kowalski, Jan", "place": 0}, {"value": "Nowak, Anna", "place": 1}],
"dc.date.issued": [{"value": "2025-03"}]
}
- To read a field: take the entries for the key, sort by
place, usevalue. Multi-valued fields (authors, subjects) keep every entry. - Common keys:
dc.title,dc.contributor.author,dc.date.issued,dc.type,dc.identifier.doi,dc.description.abstract,dc.subject*,dc.language.iso,dc.publisher,dc.relation.ispartof,dc.rights. - Do not over-interpret. Keep authors as their original strings
(
"Kowalski, Jan") — splitting into first/last mis-handles multi-part names and organisations-as-authors. Keepdc.typeraw ("Article","Rozprawa doktorska"); it is not mapped to any external vocabulary. - Year: derive it as the first four-digit run in
dc.date.issued, not with a strict ISO parser — real values include"2025","2025-03", and even"04/05/16"(from which no year is recoverable → treat as unknown). - DC keys are case-insensitive in practice: some 7.6.x instances write
dc.relation.isPartOf; match case-insensitively when a key seems missing.
Communities and collections (the tree)
curl -sL "$DSPACE/api/core/communities/search/top" # top-level communities
curl -sL "$DSPACE/api/core/communities/<uuid>/subcommunities"
curl -sL "$DSPACE/api/core/communities/<uuid>/collections"
curl -sL "$DSPACE/api/core/collections" # all collections
Each community/collection carries uuid, name, handle, and sometimes
archivedItemsCount (absent means "unknown", not zero). Walking the tree is one
request per node per level — keep depth small (≤3) and cap the total.
Files (bitstreams) and document text
An item's files hang off bundles (the ORIGINAL bundle holds the deposited
files; THUMBNAIL, LICENSE, TEXT are derived):
# item → bundles → the ORIGINAL bundle → its bitstreams (with MIME type):
BUNDLE=$(curl -sL "$DSPACE/api/core/items/<uuid>/bundles" \
| jq -r '._embedded.bundles[] | select(.name=="ORIGINAL") ._links.self.href')
curl -sL "$BUNDLE/bitstreams?embed=format"
Bitstream field names are irregular — take them literally: sizeBytes,
sequenceId, bundleName, and the checksum under checkSum (capital S:
{checkSumAlgorithm, value}). The MIME type is not on the bitstream — it is
in format.mimetype, so pull it with ?embed=format. The download link is
_links.content.href → /api/core/bitstreams/<uuid>/content.
Document text. Download /content (follow the possible S3 redirect) and
extract locally. The companion dspace-mcp server reads eight formats
in-process and this skill can teach the same from raw bytes:
| Family | MIME type | Where the text lives | Local tool |
|---|---|---|---|
application/pdf |
page stream | pdftotext (poppler), pypdf |
|
Word .docx |
…wordprocessingml.document |
word/document.xml (w:t) |
ZIP+XML python3 (see examples §5) |
PowerPoint .pptx |
…presentationml.presentation |
ppt/slides/slideN.xml (a:t) |
as above |
ODF text/pres. .odt/.odp |
application/vnd.oasis.opendocument.{text,presentation} |
content.xml |
as above |
Spreadsheets .xlsx/.ods |
…spreadsheetml.sheet, …opendocument.spreadsheet |
xl/worksheets/* + xl/sharedStrings.xml; ODF content.xml |
convert (libreoffice --convert-to csv, xlsx2csv) — cells index into shared strings, so flat scraping fails |
Legacy .doc |
application/msword |
OLE WordDocument stream |
antiword, catdoc, libreoffice --headless --convert-to txt |
The word-processing and presentation formats (.docx/.pptx/.odt/.odp) are
ZIP+XML with flowing text — a python3 stdlib snippet (see
examples §5, using itertext() so styled runs are not
missed) reads them. Spreadsheets (.xlsx/.ods) need shared-string/tabular
handling, so convert them (or use the MCP) rather than tag-scraping. The guards
are not optional:
- Cap the size first (
sizeBytesis a hint, sometimes wrong — limit the actual bytes; the MCP default is 20 MB). Over the cap → give the user the link. - Untrusted XML. Files come from arbitrary repositories.
dspace-mcpparses them withdefusedxmlagainst XXE / billion-laughs and caps each unzipped part (zip-bomb guard). A barexml.etree/unziprecipe drops that protection — say so, or preferdefusedxml/ a converter that does not resolve external entities. - Empty ≠ unreadable. A PDF (or scan) with no text layer, an encrypted file,
or an unsupported type → say that explicitly; never return
"", which reads as "the document is empty". OCR is out of scope. - If the MCP is connected, its
get_bitstream_textreturns{text, format, unit, units_processed, units_total, truncated}—unitispages/slides/sheets/paragraphs, ornullfor legacy.doc(no natural unit).
Cheap counting with facets — /api/discover/facets/{name}
Facets make the repository do the counting server-side (Solr) — the token-cheap way to answer "how many X, broken down by Y":
curl -sL "$DSPACE/api/discover/facets/author?size=20" # top authors + counts
curl -sL "$DSPACE/api/discover/facets/author?prefix=Kow&size=20" # narrow by prefix
curl -sL "$DSPACE/api/discover/facets/dateIssued?size=50" # counts per year
Each value is {label, count, authorityKey}. scope, query and prefix
narrow it. The facet endpoint gives no total — it never returns
totalElements; the only "there's more" signal is _links.next. List available
facet names with GET /api/discover/facets. An unknown facet returns
400/404/422.
Usage statistics
curl -sL "$DSPACE/api/statistics/usagereports/<item-uuid>_TotalVisits" \
| jq '.points[0].values.views'
View/download stats are public by default across 7.x/8.x/9.x/10.x (verified).
A 401/403 means this particular instance disabled anonymous stats — report
that, don't retry. (Search and workflow statistics are admin-only; leave them.)
Counting items / collections / communities
Use discovery, not /api/core/items (that endpoint is admin-only and returns
401 to anonymous callers):
for T in item collection community; do
echo -n "$T: "
curl -sL "$DSPACE/api/discover/search/objects?dsoType=$T&size=1" \
| jq '._embedded.searchResult.page.totalElements'
done
HAL pagination
Non-search list endpoints return {_embedded: {<key>: [...]}, page: {size, number, totalPages, totalElements}, _links}. To harvest, follow _links.next.href until
it is absent — but cap the number of requests (a buggy instance can loop
next forever) and stop when a page comes back empty. Notes:
- On search,
pageand_linksare nested inside_embedded.searchResult, not at the top level. - Parse
pageby key name, not position — key order varies between instances. - A
_linksrelation value is sometimes a list, not an object (e.g.workflowGroups); take the first entry'shref.
What this API does not do (honestly)
- No writing here (GET-only, by design) and no access to non-public data (anonymous).
- No full-text search outside
/discoverand nodc.typefilter in vanilla DSpace — discover which filters/facets exist per instance (Step 1). - The facet endpoint returns no total — only
_links.next. - DSpace 5/6 legacy
/restAPI is out of scope — this is the 7+/server/apionly. The flat{key, value}metadata format belongs to that dead API; you will not see it on 7+.
If the dspace-mcp MCP server is connected — prefer it
dspace-mcp is a read-only MCP server
that wraps exactly this API. If it is connected as an MCP server, use its tools
(search_items, get_item, list_communities, list_collections,
list_bitstreams, get_bitstream_text, list_facet_values,
get_item_statistics, get_repository_info) instead of hand-building requests:
it already does the capability detection, HAL flattening, UUID validation, error
mapping and multi-format text extraction (with size and XML-safety guards)
described above, and enforces GET-only in code. Call
get_repository_info first — it reports the version, counts, and the exact
filters/sorts/facets that instance supports. Use this raw-API knowledge when the
MCP server is not available, or to understand what its tools do underneath.