Skip to content

Releases: mpasternak/dspace-mcp

v0.5.0 — two profiles, and a link that actually opens

Choose a tag to compare

@mpasternak mpasternak released this 25 Jul 01:11

The mode this server runs in is now its identity, not its state. Whether you configure an account picks one of two profiles, and the profile shapes the server's name, the description the assistant reads at startup, the tool list, and the tool signatures.

This started as a report from use: the assistant kept losing track of which mode it was in, and got it wrong in both directions — talking about "the logged-in account" on an installation that had none, and handing over embargoed material without mentioning it was not public.

Breaking

  • continue_anonymously is gone, along with the whole decision state machine behind it. It existed so a failed login could be negotiated mid-conversation, which turned out to be a question nobody could answer usefully — including the person who had configured the server.
  • A failed login now stops the process (exit code 3) instead of blocking every tool and asking. Fix the credentials and restart, or remove them to run the public profile deliberately. The choice belongs to configuration, not to a conversation.
  • The MCP server reports a new name: dspace-mcp-public or dspace-mcp-account. Your client's own registration name is unaffected.

Two profiles

public account
tools 11 13
as_anonymous absent from every schema and description present where it means something
extra tools compare_access, get_download_link

The server now sends instructions at startup, so the assistant knows whose eyes it is looking through before it calls anything. Previously that was discoverable only by calling get_repository_info and noticing.

In the public profile the as_anonymous argument is not merely unused — it is untrue, because it implies an account to contrast with. It is now absent from the schemas entirely, so an anonymous installation neither sees it nor pays tokens for it.

A new dspace-mcp-public command guarantees the public profile: given credentials through a flag or the environment, it refuses to start rather than ignore them. Register the server twice to have both views side by side — the README shows the configuration.

Getting a restricted file to a person

The server could already read restricted files. What did not work was the link it handed over: the bare /content address is refused in a browser even when you are logged into the website.

get_download_link fixes that, and measures before it acts:

  1. ask anonymously for the plain address — if it answers, the file is public and you get a clean, permanent link, with no token minted at all;
  2. only on refusal, try again with an authentication token in the URL;
  3. if that fails too, hand back the item's page instead.

Since most files in a repository are public, step 1 usually ends it and no credential ever leaves the process. When one does, the tool says plainly what it is: the URL carries a credential for the whole account, not for one file, and it expires.

Two limits stated in the README rather than hidden: the check reflects what the server can reach from its own network position, so an instance granting access by IP range may still refuse your browser; and httpx logs full URLs at INFO level, so a debug-logging MCP client will write the token to its log.

Also

  • Files in listings now carry item_url, and an item without a handle finally gets a /items/<uuid> address instead of null.
  • A URL never reaches an error message with its query string attached — it could be carrying that token.
  • Fixed: the version-consistency test failed to collect on Python 3.10 (tomllib is 3.11+), which had left CI red on main for several commits.

v0.4.1 — resolve a file back to its item

Choose a tag to compare

@mpasternak mpasternak released this 24 Jul 23:27

Adds get_bitstream_item: given a bitstream UUID, return the item it belongs to.

Reported from use. Only list_bitstreams(item) existed, so an assistant holding a file UUID — from a download URL, a search result or an earlier listing — had no way to ask which record it came from, and walked the REST chain /core/bitstreams/{uuid}/bundle/item outside the server, losing every guarantee the server provides.

Two things came out of measuring against live repositories rather than assuming:

  • One request, not three. ?embed=item on the bundle returns the whole answer at once. For a tool called in a loop over a file listing, that is a threefold difference.
  • A restricted file answers 401, not 404. Without telling those apart the assistant would report "no such file" about a file that exists and is merely closed. The message now says the bundle is not public, which is the actual situation.

Returns the usual compact record — title, handle, authors, year, state flags — plus which bundle the file sits in. Honours as_anonymous like every other read tool.

v0.4.0 — ask as the public, use any discovery filter

Choose a tag to compare

@mpasternak mpasternak released this 24 Jul 22:53

Driven entirely by a report from real use: an agent working through a logged-in server could not obtain the public view of a repository, so it bypassed the server and used curl instead. That is the worst possible outcome — a request made outside this server has no enforced GET-only guarantee, no error mapping, no download size limit, and no response flattening.

Everything here closes a gap that pushed it there.

Fixed: compare_access failed in its own use case

It listed files from the ORIGINAL bundle, and that call raises when the bundle does not exist — so the tool built to answer "the user says files are missing" refused to answer exactly when files were missing. Confirmed against a live repository, where publicly visible records frequently carry only a THUMBNAIL. It now compares across every bundle, and each file reports which bundle it came from.

New: as_anonymous on every read tool

Ask as the anonymous public instead of the logged-in account, without logging out. This reverses a decision made in 0.3.0, which rejected the parameter on the grounds that it costs tokens in every conversation. That arithmetic was wrong: the cost of not having it was the server being bypassed altogether. A few dozen tokens are cheaper than losing every guarantee the server exists to provide.

Anonymous and account installs still expose byte-for-byte identical tool schemas.

New: filters on search_items

get_repository_info has always advertised which discovery filters an instance supports; now they can actually be used:

search_items(filters={"access_status": "restricted"})
search_items(filters={"title": "cancer,contains"})

Names are validated against what the instance declares, so an unknown filter gets a clear sentence instead of a raw 422. Values take a default equals operator; an explicit one is recognised by name rather than by the presence of a comma, so "Smith, John" is not mistaken for a value plus operator.

A trap this removes: query="access_status:restricted" looks like it works and quietly returns zero, because query is full-text search and never reaches the filters. The tool description now says so.

New: list_bundles, and item state flags

  • list_bundles reports which bundles an item actually has. Previously that was discoverable only from an error message — an outage doubling as an interface.
  • get_item and search results now carry withdrawn, discoverable and in_archive, so a withdrawn record can be told apart from one you simply cannot see. null means the instance did not say, not false.

Verified against a live repository

access_status=restricted returns 9153 records where MCP previously offered no way to ask at all.

v0.3.2 — log in to instances that issue the CSRF token late

Choose a tag to compare

@mpasternak mpasternak released this 24 Jul 22:13

Bugfix release, reported from a real deployment: logging in failed with 403 — the repository refused the login request (CSRF token rejected) even though the credentials were correct.

What was wrong

The client assumed the CSRF token can always be fetched in advance, from the header or cookie of GET /api/authn/status. On the reporting instance (DSpace 9.1) that endpoint returns no token at all — neither header nor cookie. The token is generated lazily and only arrives with the 403 that rejects the token-less login attempt.

So the client posted without X-XSRF-TOKEN, got a 403, and stopped with a message that sounded like a configuration problem — while the password was fine all along.

The fix

After a 403, check whether the response carried a token. If it did, retry the login exactly once with it. If it did not, the server still stops and asks, so no retry loop can form.

No branching on the reported DSpace version: the client reacts to what the instance actually answered, not to what it calls itself — the same rule that already governs search filters and facets.

Worth noting

The full test suite was green, and so were the contract tests. demo.dspace.org issues the token immediately, so the lazy path never ran there. This one could only surface against a real deployment. Fixtures captured from that instance are now in the repository, so it stays covered.

Verified against the reporting instance: with a deliberately wrong password the failure reason changed from CSRF token rejected to rejected that username or password, which is the correct answer and shows the CSRF hurdle is gone.

v0.3.1 — correct version in --version and User-Agent

Choose a tag to compare

@mpasternak mpasternak released this 24 Jul 22:01

Bugfix release. No functional change to any tool.

__version__ had been hard-coded to 0.1.0 since the first commit and was never bumped, while pyproject.toml moved on. Two consequences, both visible from outside and both present in 0.3.0:

  • dspace-mcp --version reported 0.1.0, even in the package installed from PyPI as 0.3.0.
  • The User-Agent header sent to every repository the server queries announced dspace-mcp/0.1.0.

The second one is why this is worth a release rather than a footnote. This server identifies itself deliberately, so that an instance administrator can see who is querying them and does not mistake it for an anonymous nuisance worth blocking by IP. A wrong version number undermines the point of that identification.

The version now comes from the installed package metadata, leaving pyproject.toml as the single source of truth — the same way the .mcpb packaging workflow already treats it. A test asserts the two stay in agreement, so this cannot drift again silently.

The defect predates 0.3.0; it surfaced while verifying that release against PyPI.

v0.3.0 — optional read-only authentication

Choose a tag to compare

@mpasternak mpasternak released this 24 Jul 21:37

Lets you optionally give the server a DSpace account, so it can also read material that is not public: embargoed items, restricted collections and closed files.

Without an account nothing changes — the same nine tools, the same requests, byte-for-byte the same tool schemas as 0.2.0. Logging in is never required, and it never grants the ability to modify anything.

Read-only, still by construction

Every request that carries data is a GET. The only other request in the entire codebase is the login POST, to one hard-coded path, in a method with no path parameter. Two tests hold this in place: one asserts that the only non-GET request goes to exactly <base-url>/api/authn/login (equality, not endswith — so https://evil.test/authn/login cannot pass), the other reads the package source and requires exactly one .post( and zero put/patch/delete/request.

The login request runs with follow_redirects=False: httpx replays 307/308 with the request body, so a redirect could otherwise hand your password to another host.

New

  • Optional login via DSPACE_USERNAME / DSPACE_PASSWORD (or --username / --password). In the .mcpb bundle the password is stored in your operating system's keychain.
  • compare_access — answers "the user says files are missing". Asks as your account and anonymously, through separate connections with separate cookie jars, and returns only the difference: which files the public cannot reach.
  • continue_anonymously — reachable only after a failed login.
  • get_repository_info now reports whether the server is querying anonymously or as an account.

Both new tools are registered only if an account is configured, so an anonymous install pays nothing for them.

If the login fails, the server stops and asks

It does not quietly fall back to anonymous access. That would make the assistant report "no such record" for material you can plainly see. Instead every tool returns a question, and the assistant puts the choice to you: fix the credentials and restart, or explicitly continue with public data only.

Only password login is supported. Repositories authenticating solely through ORCID or Shibboleth need a browser; the server detects this from the instance itself and says so rather than failing obscurely.

Notes for repository owners

The configured account is an ordinary DSpace account with ordinary permissions — an administrator account gives the assistant an administrator's view. Use the least-privileged account that covers what you need. It cannot modify anything either way.

Under the hood

Three measurements against a live instance shaped this release:

  • An invalid token does not produce a 401 on public endpoints — DSpace silently answers 200 with anonymous data. Waiting for a 401 to refresh would therefore never trigger, and after ~30 minutes searches would quietly start losing restricted records. Tokens are refreshed proactively, from the exp claim, with a margin.
  • The DSPACE-XSRF-TOKEN header is sent only when a new token is issued; afterwards the value lives in the cookie alone. Reading only the header worked exactly once, so the second login returned 403.
  • A floor between logins prevents a skewed clock, or an instance answering 401 instead of 403, from turning the server into a login machine and earning an IP ban.

447 offline tests plus 12 contract tests against demo.dspace.org.

v0.2.0 — multi-format text extraction

Choose a tag to compare

@mpasternak mpasternak released this 24 Jul 16:54

Multi-format text extraction

get_bitstream_text now extracts text from eight formats instead of PDF only: pdf, docx, legacy doc, odt, ods, odp, pptx, xlsx. Nothing to opt into — uvx dspace-mcp reads them all.

Highlights

  • New dspace_mcp/extractors/ package with a mimetype → extractor registry and dispatch() (filename-extension fallback). Each extractor is a pure bytes → text function.
  • OOXML (docx/pptx/xlsx) and ODF (odt/ods/odp) parsed with the standard library (zipfile + xml.etree); legacy .doc via olefile (best-effort).
  • Security: all XML goes through defusedxml (XXE / billion-laughs safe) and every ZIP member read is size-guarded (zip-bomb). Any extraction failure returns a clear ExtractError message — never a raw traceback. Read-only, GET-only guarantee unchanged.

New dependencies

Only olefile>=0.47 and defusedxml>=0.7 — both small, pure-Python. No lxml/Pillow/python-docx/python-pptx.

Changes to be aware of

  • get_bitstream_text result shape: pages_processed/pages_totalunit (pages/slides/sheets/paragraphs) + units_processed/units_total.
  • Size-limit config renamed pdf_max_mbextract_max_mb (DSPACE_EXTRACT_MAX_MB / --extract-max-mb). The old DSPACE_PDF_MAX_MB / --pdf-max-mb still work as aliases.

Full design and rationale: docs/superpowers/specs/2026-07-23-multiformat-text-extraction-design.md.

v0.1.1 — documentation corrections

Choose a tag to compare

@mpasternak mpasternak released this 22 Jul 18:04

Documentation-only release. No changes to the server, its tools or its behaviour — nothing here is a reason to upgrade beyond having accurate docs on the package page.

Corrected the Compatibility section. The previous wording ("Verified against DSpace 7.2.1, 7.5, ... 11.0-SNAPSHOT") conflated two different things and read as if the code had been test-run on all eight versions. It had not. The section now separates them:

  • the automated tests run against recorded responses from a live DSpace 10.1-SNAPSHOT covering every endpoint the tool uses, plus root/search responses from a vanilla 7.6.5, a DSpace-CRIS 8.2 and an 11.0-SNAPSHOT for comparing response shape;
  • the eight-version list was a by-hand survey of the REST API made while designing the tool — a check of the API, not a test run of this code.

It also makes the more useful point explicit: nothing in the tool branches on a DSpace version number. It asks each instance which search filters, sort fields and facets it supports, because that is configured per installation.

Fixed the PyPI and Python badges, which were serving a cached "package or version not found" from before the package existed.

Reports from versions and configurations I could not test remain very welcome.

v0.1.0

Choose a tag to compare

@mpasternak mpasternak released this 22 Jul 16:11