libapps-mcp
# libapps-mcp
Read-only MCP server for **LibGuides** sites, built on the Springshare LibApps
API v1.2. It lets an AI assistant browse published research guides (including
every page and sub-page as clean markdown), guide subjects, the A-Z database
list, and public subject-librarian profiles. Runs over **stdio**.
Nothing institution-specific is built in: the API region host, public site URL,
and credentials are all configuration. Without credentials the server still
starts, and every tool returns `config_missing`.
## What this does **not** do
- Write anything (no create/update tools; the app needs GET scopes only)
- Show unpublished guides, Internal or Template guides, or draft content
- Index content or keep its own search index
- Other Springshare products (LibCal, LibAnswers, LibWizard)
- Hosted / remote access (see [Phase 2](#phase-2-hosted-later))
## Requirements
- A **LibGuides CMS** site
- A **read-only LibApps v1.2 application** with GET scopes for Guides,
Subjects, A-Z, Accounts, and Assets
- [uv](https://docs.astral.sh/uv/) and Python 3.11+
## Credentials
In LibApps, go to **Tools > API > Applications** and create (or reuse) an
application with only the GET scopes above. That page also shows your
region's API host (for example `lgapi-us.libapps.com`); use it for
`LIBAPPS_API_BASE`.
Keep the client secret out of git, tickets, and chat. Put it in your MCP host
config or a local, git-ignored env file. `.env.example` lists the variables
with placeholder values. Rotate the secret if it is ever exposed.
## Install
```bash
cd /path/to/libapps-mcp
uv sync --extra dev
```
## Claude / MCP host config
Point Claude Desktop (or another stdio MCP host) at the checkout with
`uv run --directory`. Replace `/path/to/libapps-mcp` with your clone path
and the placeholder values with your own:
```json
{
"mcpServers": {
"libapps": {
"command": "uv",
"args": [
"run",
"--directory",
"/path/to/libapps-mcp",
"python",
"-m",
"libapps_mcp"
],
"env": {
"LIBAPPS_API_BASE": "https://lgapi-us.libapps.com",
"LIBAPPS_CLIENT_ID": "your-client-id",
"LIBAPPS_CLIENT_SECRET": "your-client-secret",
"LIBGUIDES_SITE_URL": "https://guides.lib.uchicago.edu"
}
}
}
}
```
You can also run the console script `uv run libapps-mcp` from a shell for a
quick smoke check.
## Configuration
All settings are environment variables. They are read at the first tool call.
| Variable | Default | Purpose |
|---|---|---|
| `LIBAPPS_CLIENT_ID` / `LIBAPPS_CLIENT_SECRET` | (required) | OAuth client credentials for the v1.2 app |
| `LIBAPPS_API_BASE` | `https://lgapi-us.libapps.com` | Region host: `lgapi-{us,ca,eu,au}.libapps.com`. Other hosts need `LIBAPPS_ALLOW_CUSTOM_API_BASE=1` |
| `LIBGUIDES_SITE_URL` | derived from guide URLs | Public site origin, used for page fetches |
| `LIBGUIDES_ALLOWED_HOSTS` | (none) | Extra comma-separated hosts the page fetcher may use |
| `LIBAPPS_HTML_FETCH` | `1` | Read page content from public guide pages. `0` falls back to unplaced API text |
| `LIBAPPS_INCLUDE_PRIVATE` | `0` | Include Private guides and hidden pages/boxes |
| `LIBAPPS_EXPOSE_EMAIL` | `0` | Include the public-profile contact email for librarians |
| `LIBAPPS_CACHE_TTL_LIST` / `_REF` / `_CONTENT` | `1800` / `86400` / `900` | Cache lifetimes (seconds) for the guide list; subjects, accounts, and A-Z; and guide outlines, pages, and searches |
| `LIBAPPS_TIMEOUT` | `30` | HTTP timeout (seconds) |
| `LIBAPPS_MAX_CONCURRENCY` | `4` | Maximum concurrent upstream requests |
| `LIBAPPS_MAX_CHARS` | `20000` | Default markdown budget per `get_guide_content` call |
| `LIBAPPS_USER_AGENT` | `libapps-mcp/0.1.0 (+repo URL)` | User agent for all requests |
| `LIBAPPS_LOG_LEVEL` | `WARNING` | Log level; logs go to stderr only |
## Tools
Every tool returns `{"ok": true, ...}` or
`{"ok": false, "error": "...", "code": "..."}`. Codes: `config_missing`,
`config_invalid`, `auth_failed`, `scope_missing`, `not_found`, `not_public`,
`upstream_error`, `rate_limited`, `fetch_blocked`, `invalid_input`. List tools
take `limit` (max 50) and `offset` and return `total` and `next_offset`
(`null` when done).
### Reading guides: `get_guide` then `get_guide_content`
1. `search_guides` finds a guide.
2. `get_guide(guide_id)` returns its metadata and the **page tree**: every
visible page and sub-page with `page_id`, `name`, `url`, and box names.
3. `get_guide_content(guide_id, page_id, include_subpages)` returns that page
as markdown, one entry per content box in display order. With
`include_subpages=true` it adds the page's sub-pages. Each response also
includes the full `outline`, so the assistant can move to any other page.
### `search_guides`
| Argument | Notes |
|---|---|
| `query` | Uses the site's relevance-ranked guide search (`source: "server"`). Without a query, or if that search fails, matches name, subjects, description, and owner locally (`source: "local"`) |
| `subject` | Subject name (substring) or id |
| `owner` | Owner name; only owners with a public profile can match |
| `guide_type` | `subject`, `course`, `topic`, `general`, or a type id |
| `limit` / `offset` | Default 10 / 0 |
Returns `id`, `name`, `url`, `description`, `type`, `subjects`, `updated`,
`rank`, and `owner` (name and profile link) when the owner has a public profile.
### `get_guide`
| Argument | Notes |
|---|---|
| `guide_id` | Numeric guide id |
Pages have `page_id`, `name`, `url`, `parent_id`, `box_count`, `boxes`
(`box_id`, `name`, `column`, `position`), and `subpages`. Link pages carry
`redirect_url` and are never followed.
### `get_guide_content`
| Argument | Notes |
|---|---|
| `guide_id` | Numeric guide id |
| `page_id` | Defaults to the guide's first page |
| `include_subpages` | Add the page's sub-pages (default `false`) |
| `source` | `auto` (default), `html` (public page), or `api` (whole-guide text blocks with `placement: "unknown"`) |
| `max_chars` | Markdown budget (default `LIBAPPS_MAX_CHARS`, max 100000) |
| `offset` | Box index to resume from (use `next_offset`) |
Boxes that are in the outline but not on the page are marked `missing`. A box
cut to fit the budget is marked `truncated`. Embedded widgets become
`[embedded: ...]` placeholders.
### `list_subjects`
`with_published_guides` (default `true`) limits the list to subjects that have
published guides. Returns `id`, `name`, `slug`, and `parent_id`.
### `search_databases`
| Argument | Notes |
|---|---|
| `query` | Matches name, alternate names, vendor, and description |
| `subject` / `az_type` | Name (substring) or id |
| `limit` / `offset` | Default 20 / 0 |
Returns `id`, `name`, `url`, `proxied`, `vendor`, a short `description`,
`subjects`, `types`, and `alt_names` / `new` / `trial` / `popular` when available.
### `get_database`
`database_id`: the same fields with the full description and `more_info`.
### `find_subject_librarians`
`subject`: name or id. Returns librarians with a public profile for that
subject: `name`, `title`, `pronouns`, `profile_url`, `image_url`, `subjects`,
and the contact details (`phone`, `address`, `website`) they chose to show
publicly.
## Privacy and safety
- **Published only.** Unpublished, Internal, and Template guides are never
returned. Private guides and hidden pages/boxes need `LIBAPPS_INCLUDE_PRIVATE=1`.
- **Allowlisted output.** Every result is built field by field. Login emails,
account ids, widget code, and A-Z internal notes and reviews are never
returned. Librarians without a public profile are excluded. Contact details
appear only when the profile shows them, and the profile email only with
`LIBAPPS_EXPOSE_EMAIL=1`.
- **No email addresses by default.** Unless `LIBAPPS_EXPOSE_EMAIL=1`, email
addresses in page text and descriptions are replaced with `[email removed]`.
- **Secrets stay private.** The client secret and access token are never logged
or returned; settings `repr()` hides the credentials.
- **Safe page fetches.** Only `https` URLs from API data, on the site host or
`LIBGUIDES_ALLOWED_HOSTS`. Redirects are re-checked (max 3), pages are capped
at 2 MB and must be `text/html`, and no cookies or auth headers are sent.
- **Polite client.** Cached responses, a concurrency cap, `gzip`, and
backoff on 429/5xx.
## Development
```bash
uv run pytest
```
Unit tests mock all HTTP with synthetic fixtures. An opt-in live smoke test
reads credentials from the environment and asserts only on shapes and counts:
```bash
LIBAPPS_LIVE_TESTS=1 uv run pytest -m live
```
Notes on the API behaviors this server relies on are in
[`docs/api-notes.md`](docs/api-notes.md).
To verify a running server end to end against the live API (stdio startup,
every tool, privacy and status filters, error shapes), follow
[`.claude/skills/verify-libapps-mcp/SKILL.md`](.claude/skills/verify-libapps-mcp/SKILL.md).
Its driver reads credentials from `~/.config/libapps-mcp/env`. If only
`~/.config/uc-libapps-mcp/env` exists (the path used before this project was
renamed from `uc-libapps-mcp`), it reads that instead.
## Phase 2: hosted (later)
A later phase adds a streamable HTTP transport served at `/mcp` behind TLS and
an authenticating proxy, with per-deployment LibApps credentials kept
server-side.
## License
MIT
TDQS
Scored across 7 tools
Most tools target clearly distinct resources and actions, with search_guides (find), get_guide (metadata+outline), and get_guide_content (page text) forming a coherent retrieval pipeline. The get_guide vs get_guide_content pair is the only mild overlap, but descriptions clearly delineate their roles.
All seven tools follow a consistent verb_noun pattern (list_subjects, search_guides, get_guide, get_guide_content, search_databases, get_database, find_subject_librarians). Verb variety (list/search/get/find) reflects genuinely different operations rather than inconsistency.
Seven tools is well-scoped for an A-Z database and research guide server, with each tool earning its place across the subjects, guides, databases, and librarians domains. No redundant or trivial tools.
The surface covers the core read workflows: subjects, guide discovery and content, databases, and subject librarians, with pagination handled throughout. As a read-only surface it lacks write operations and some minor browse helpers (e.g., listing guide types), but core research workflows are covered.