88 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5 fa05fcc1c6 fix(wikis): judge discovery completion on cache growth, not absolute size
The completion guard added in a8d3c23 read `cacheMap.size > 0`, but
`cacheMap` is the tenant-wide map that `getWikiPage` also writes to on
every single-page fetch. One prior `openproject_get_wiki_page` call on
the same cache key therefore leaves the map non-empty before the probe
loop issues its first request, and a scan in which every probe fails
with a 500 inherits that entry and marks itself discovery-complete
anyway — permanently, for the process lifetime.

"Get a wiki page, then search" is an ordinary LLM tool sequence, so the
contaminated path is likely rather than exotic, and it lands back in
exactly the failure this branch exists to eliminate: wiki search returns
[] forever and the model tells the user their pages do not exist.

Completion is now judged on growth during the scan (`cacheMap.size >
cachedBefore`), captured before the probe loop. The cold-cache
behaviour is unchanged, since `cachedBefore` is then 0 and the condition
is equivalent — both existing F5b tests stay green.

A permanently-failing instance now re-probes on every search. That is
the accepted cost of retrying rather than caching a lie.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 09:38:30 +02:00
Lucas WintherandClaude Opus 5 e76cfa750b docs(search): say that the activities query filters as well as ranks
`query` on `openproject_list_work_package_activities` returns
`rankRecords(...)`, which drops everything below `DEFAULT_MIN_SCORE`, so
it filters as well as ranks. The schema description, README, and ADR-019
all said "rank". A model told "rank" passes a query to a list tool
expecting a reordered full history and then reasons over a silently
truncated timeline.

Behaviour is unchanged — filtering is the more useful behaviour and the
tests pin it. Only the wording is corrected, plus tests that keep the
wording from regressing. Also records the response-shape and pagination
changes from the preceding commits in ADR-019 and the README.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 09:27:33 +02:00
Lucas WintherandClaude Opus 5 a8d3c23096 fix(wikis): stop swallowing fatal errors and marking failed scans complete
`getWikiPage`'s attachments fetch had a bare `catch` that swallowed
401/403/429 one function away from discovery code that correctly
rethrows them. During a 50-page discovery scan a rate limit was eaten
once per page and every page cached with `attachments: []`, then
reported to the model as fact.

The discovery probe loop incremented `consecutiveMisses` only on 404, so
a run where every probe failed with a 500 or a network error never
tripped the cutoff, never rethrew, and cached nothing — yet still ran
`discoveredCacheKeys.add(cacheKey)` unconditionally, marking discovery
complete. Every later wiki search then short-circuited on an empty
corpus and returned `[]` for the life of the process.

Discovery is now marked complete only when at least one page was cached
or the loop ended via the 404 cutoff. An all-errors scan satisfies
neither, so it retries; a genuinely empty wiki trips the cutoff and is
still marked complete rather than re-probed on every search.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 09:27:33 +02:00
Lucas WintherandClaude Opus 5 216b460c56 fix(meetings): page-number offset, author match type, and auth propagation
Three fixes to the meetings service:

- `offset` was documented as a 1-based page number but implemented as an
  item index, so with `pageSize: 20` a request for page 2 returned items
  2-21 — nineteen of the twenty the caller already had. This changes the
  observable behaviour of `openproject_search_meetings`: paging now
  returns disjoint pages. Shipping two identical pagination bugs with
  only the work package one fixed would have been worse.
- `author` was added as a shallow field but never to the `matchType`
  union, so `resolveMatchType` fell through and reported a meeting found
  by a person's name as having matched on project name.
- `getMeeting` retained `.catch(() => undefined)` on its agenda-item
  fetch, swallowing 401/403/429. This is the tool a model calls
  immediately after a search hit, so an expired token would have been
  reported as "this meeting has no agenda items".

Also drops the duplicate `items` array from `MeetingSearchPage`; the
`items` field on `PaginatedResult` and on `listMeetings` is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 09:27:22 +02:00
Lucas WintherandClaude Opus 5 0277db3d49 fix(search): run both candidate legs in exact mode and page the window
The broad candidate leg was replaced with an empty array in
`matchMode: "exact"`, so candidates came only from the server-side
`subject ~ query` filter and any work package whose match lived in a
description or a comment was unreachable. That inverts the parameter's
own guidance: exact mode is advertised for literal strings known
verbatim — error codes, ticket refs, commit SHAs — which are exactly the
strings that live in descriptions and comments rather than subjects.
Spec section 3.3 mandates two parallel requests with no exact-mode
carve-out.

Both legs now also page the candidate window in batches of 100 up to the
unchanged 250 cap, the way meeting search already does. Every tool shape
in this repo caps `pageSize` at 100 and OpenProject enforces a
server-side maximum, so a single `pageSize: 250` request could silently
return fewer candidates than the code claimed, with `response.total`
ignored so nothing detected it.

Also corrects `offset` on the search page from an item index to the
1-based page number its schema documents, and drops the duplicate
`items` array from `WorkPackageSearchPage` (kept on `PaginatedResult`
and the non-search list paths).

The existing `workPackageFixtureClient` returns every element for any
collection request and so cannot see a subject filter at all, which is
why this was invisible to the suite. New tests use a fixture that
honours the filter server-side; the old fixture is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 09:27:14 +02:00
Lucas WintherandClaude Opus 5 97f115e641 docs: record ADR-019 and document fuzzy search behaviour
Documents the fuzzy-search-by-default change shipped across tasks 1-10:
appends ADR-019 (fuzzy default, hand-rolled zero-dependency scorer, why
fuse.js/fastest-levenshtein were rejected), describes the src/search/
pure computation unit in ARCHITECTURE.md, updates README.md's tool
table/count (18 -> 19) and adds a Search behaviour section, and updates
TODO.md with the completed milestone, two accepted limitations, and
three tracked follow-ups. No source or test files were changed;
bun test (349 pass) and bun run typecheck remain unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 09:05:57 +02:00
Lucas WintherandClaude Opus 5 2b82800c23 test(search): strengthen stability test to detect broken tie-break
The "results are stable" test queried "budget", a single-match query,
so the sort had nothing to reorder and would pass even with a broken
or removed ascending-id tie-break. Switch to "muller", where docs 1
and 5 tie on score, so the assertion actually exercises the tie-break.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 08:55:59 +02:00
Lucas WintherandClaude Opus 5 a630944a3c docs: make plan's stability test actually test ordering stability
The test queried 'budget', which only one corpus document matches, so it
returned a single-element array with nothing to reorder — it would pass
even with the tie-break removed. Switched to 'muller', where docs 1 and 5
tie at the same score and ordering is decided solely by the ascending-id
tie-break.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 08:55:06 +02:00
Lucas WintherandClaude Opus 5 e4777e59e0 test(search): add recall suite for realistic natural-language queries
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 08:50:02 +02:00
Lucas WintherandClaude Opus 5 f41711a6ed docs: fix unsatisfiable exact-mode test in plan Task 9
The test used query 'certificat', which is a literal substring of the
fixture's 'certificate' comment text. Exact mode is substring containment
by design, so the query matched and the toHaveLength(0) assertion could
never pass. Replaced with an insertion typo, which cannot be a substring
of the shorter original.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 11:44:01 +02:00
Lucas WintherandClaude Opus 5 8f29eb651d feat(activities): rank work package activities by relevance
Adds optional query/matchMode to listWorkPackageActivities, ranking the
already-fetched activity list client-side via rankRecords (no extra API
calls). Exposes the same fields on the
openproject_list_work_package_activities tool. When no query is supplied,
every activity is returned exactly as before, and onlyComments continues
to filter independently.

Also fixes a defect in the brief's exact-mode test fixture: the specified
typo query "certificat" is a literal prefix substring of "certificate", so
byte-for-byte exact containment matching (scoreExact in src/search/rank.ts)
finds it regardless of typo tolerance, making the test unable to pass
against a correct implementation. Swapped in "certificatee" (not a
substring of "certificate") to preserve the test's intent without touching
the assertion. Updated one pre-existing test-tools.test.ts call site to
supply matchMode, now required by the extended args type.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 11:42:39 +02:00
Lucas WintherandClaude Opus 5 a28067f61e fix(work-packages): propagate fatal auth/rate-limit errors from candidate fetch
The union candidate fetch wrapped both listWorkPackages() legs in an
unconditional .catch(() => ({ items: [] })), swallowing 401/403/429 and
OpenProjectAuthenticationError along with genuinely transient failures. An
expired token would silently degrade to "0 candidates", and the LLM would
report work packages as not found rather than surfacing the auth failure.

Replace the inline catches with a shared listCandidates() helper that uses
isFatalSearchError (already used by searchPipeline) to rethrow fatal errors
and only swallow non-fatal ones, preserving the property that a bad response
on one leg doesn't kill the other leg's candidates.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 11:34:17 +02:00
Lucas WintherandClaude Opus 5 326db0659f feat(work-packages): add ranked search tool over subject, description, and comments
Registers openproject_search_work_packages (readOnly: true), taking the tool
registry from 18 to 19. searchWorkPackages now unions a precise server-side
subject filter with a broad recency-ordered candidate window, ranks via the
shared search pipeline, and deep-enriches top candidates with description and
comment text. Exact-mode requests skip the broad query entirely.

searchWorkPackages's return type changes from a flat PaginatedResult<WorkPackageSummary>
to WorkPackageSearchPage, nesting the summary under `workPackage` alongside
score, matchedFields, and an optional snippet.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 11:23:48 +02:00
Lucas WintherandClaude Opus 5 9dee458b1f feat(wikis): search page body text with fuzzy relevance ranking
Retain the page body from the formattable text field (already fetched
per-page, zero extra API calls) and rank title/text matches with
rankRecords instead of exact substring on title alone. searchWikiPages
now returns WikiPageSearchResult[] with score, snippet, and
matchedFields; empty-query behavior (project scoping + limit, no
ranking) is preserved. Adds a matchMode tool parameter defaulting to
fuzzy.

Updates the pre-existing tool-metadata assertion in tests/tools.test.ts
to match the new search_wiki_pages description introduced here.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 07:48:29 +02:00
Lucas WintherandClaude Opus 5 2d44a229e0 feat(meetings): fuzzy-rank meeting search over agenda, participants, and project
Routes searchMeetings through the shared searchPipeline instead of a
hand-rolled Promise.all fan-out, fixing two live bugs: an expired token
on agenda-item fetch previously produced "no results" (swallowed via
.catch(() => undefined)) instead of propagating the auth error, and up
to 250 candidate meetings could fire simultaneous agenda-item requests.
The pipeline caps enrichment at 25 candidates with 8 in flight, and
isFatalSearchError now lets 401/403/429 propagate while other failures
degrade a single record and set `degraded`.

Also removes the old substring-based extractSnippet from
src/services/meetings.ts in favor of src/search/snippet.ts, and adds
matchMode ("fuzzy" | "exact") to SearchMeetingsParams and the
openproject_search_meetings tool schema.

Updated two pre-existing tests in tests/meetings.test.ts whose fixtures
assumed the old implementation's API surface: one didn't stub the
meeting-detail endpoint the pipeline's deep enrichment now also fetches
(for participants), and one assumed a 50-item candidate page size where
the new pipeline hardcodes 100. Updated one assertion in
tests/tools.test.ts that checked the old literal tool description.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 07:40:49 +02:00
Lucas WintherandClaude Opus 5 c47f0669ca feat(search): add two-phase pipeline with bounded concurrency and recency backfill
Adds searchPipeline, which ranks candidates shallowly at no API cost, then
enriches a bounded subset (DEFAULT_ENRICH_LIMIT=25, DEFAULT_CONCURRENCY=8)
and re-ranks, taking max(shallow, deep) so a strong title match is never
diluted by unrelated deep content. Auth and rate-limit errors abort the
whole search via isFatalSearchError instead of degrading silently; other
enrichment failures degrade one record and are counted. A recency backfill
spends leftover enrichment budget on the most recently updated candidates
so a deep-only match is reachable at all. Adds the src/search/ barrel
export.

Reconciles the previously uncommitted pipeline.ts (written against an
earlier, defective version of this task's brief) with the corrected brief:
mapWithConcurrency's phase-2 callback is now given an explicit
EnrichedCandidate return type so its `deep: TDeep | undefined` field is a
single type rather than an inferred union, which is what the `entry is
Pair` narrowing predicate requires under TS2677.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 07:30:48 +02:00
Lucas WintherandClaude Opus 5 7b07241367 docs: fix unnarrowable type predicate in plan Task 5 pipeline
mapWithConcurrency's callback return was inferred as a union of two object
literal types rather than one type with deep: TDeep | undefined, so the
'entry is Pair' predicate was rejected: a type predicate's type must be
assignable to its parameter's type. Added an explicit EnrichedCandidate
annotation on the call. Tests passed but tsc did not, so this was only
visible via typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-18 07:28:20 +02:00
Lucas WintherandClaude Opus 5 3d3b8ff512 feat(search): add generic field-spec record ranking with exact-mode bypass
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 10:55:37 +02:00
Lucas WintherandClaude Haiku 4.5 df9f093b5d feat(search): add match-centred snippet extraction
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-09-17 10:50:54 +02:00
Lucas WintherandClaude Opus 5 926e241c7b feat(search): add bounded levenshtein and coverage-weighted field scoring
Adds boundedLevenshtein with row-minimum early exit, token scoring
(exact / prefix / contains / shared-prefix / edit distance), and
coverage-weighted field aggregation scaled by BASE_SCALE so that only a
verbatim phrase match reaches 1.0.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 10:46:58 +02:00
Lucas WintherandClaude Opus 5 c4e303c8b7 docs: fix unreachable shared-prefix test cases in plan Task 2
The guard tests used 3-char query tokens whose field tokens were literal
continuations ('car'/'carpet'), so the startsWith rule returned 0.9 before
the shared-prefix rule was reached. With MIN_SHARED_PREFIX=4 the rule is
unreachable for any 3-char token, making the assertion impossible to
satisfy. Swapped in cases where neither token is a true prefix of the
other, which actually exercise the guard.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 10:26:54 +02:00
Lucas WintherandClaude Opus 5 28a834eb32 fix(search): eliminate mutable regex state with matchAll
Replace manual exec loop with matchAll to eliminate cross-call mutable
lastIndex state on module-level TOKEN_PATTERN regex. matchAll clones
the regex internally, ensuring no shared state while maintaining
identical behavior and original-text offset semantics.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 10:19:17 +02:00
Lucas WintherandClaude Opus 5 5b0f73ae0e feat(search): add unicode-aware tokenizer with original-text offsets
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 10:14:30 +02:00
Lucas WintherandClaude Opus 5 582295c960 docs: correct scoring design defects found in preflight scan
Hand-computing the plan's own test expectations against its own formula
showed four assertions could not pass:

- Body-only matches maxed at 1.0*1/3 = 0.333, below minScore 0.35, so no
  low-weight field could ever return a result. The gate now uses the raw
  field score; the weighted score only orders.
- The phrase bonus was dead code whenever every token matched, since the
  base already saturated at 1.0. Base is now scaled by 0.85.
- Natural-language queries tanked the coverage multiplier; query tokens
  are now stopword-filtered.
- 'decide' vs 'decision' is 4 edits apart and unmatchable; scoreToken
  gains a shared-prefix rule.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 10:13:10 +02:00
Lucas WintherandClaude Opus 5 cbd8992b11 docs: add fuzzy search implementation plan and correct spec tool gap
Adds an 11-task TDD implementation plan covering the pure search unit
(tokenize, score, rank, snippet, pipeline), per-service integration for
meetings, wikis, work packages and activities, a recall acceptance
suite, and documentation.

Corrects two errors found while writing the plan:
- The spec's behaviour table named an openproject_search_work_packages
  tool that does not exist. searchWorkPackages is a service helper that
  only tests call, so ranked work package search would have been
  unreachable by any LLM. The plan now registers the tool (18 -> 19) and
  leaves openproject_list_work_packages as a pure server-side filter.
- Plan Task 6 originally identified matching agenda items by substring
  searching the meeting-level snippet, which cannot work because
  snippets are trimmed and ellipsized. Agenda items are now ranked
  directly.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 10:06:56 +02:00
Lucas WintherandClaude Opus 5 5dc609cc7d docs: add design spec for fuzzy search by default
Specifies replacing exact substring matching with token-aware fuzzy
matching as the default across all search tools, plus relevance ranking
and wider content coverage (wiki body text, work package descriptions
and comments, meeting participants and project names).

Key decisions:
- Hand-rolled zero-dependency scorer in src/search/; fuse.js rejected
  because its Bitap implementation caps patterns at 32 chars and targets
  short fields, not the long-text matching required here.
- matchMode: 'fuzzy' | 'exact' escape hatch, defaulting to fuzzy, with
  exact mode preserving current behaviour byte-for-byte.
- Two-phase pipeline: cheap shallow rank, deep-enrich top 25 with
  bounded concurrency, recency backfill for deep-only matches.

Also records two known limitations left out of scope: the two-phase
recall ceiling and the wiki cache's ID 1..50 probe window.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 09:54:30 +02:00
Lucas Winther 7097d1c174 fix: resolve final review findings (wiki cache tenant isolation, meeting search pagination, ambient test) 2026-09-07 03:12:55 +02:00
Lucas Winther e0aa91e388 docs: record ADR-018 and update documentation for meetings, wikis, and activities tools 2026-09-07 03:06:38 +02:00
Lucas Winther d4a905d419 test: add live container tests for meetings, wikis, and activities 2026-09-07 03:01:52 +02:00
Lucas Winther d6e78d8cc3 feat: wire meetings, wikis, and activities tools into server registry (18 tools) 2026-09-07 02:56:00 +02:00
Lucas Winther 2fb25a9604 fix(wikis): refine projectId schema in searchWikiPagesShape 2026-09-07 02:51:46 +02:00
Lucas Winther 16e54614ac feat: add wikis MCP tools 2026-09-07 02:50:09 +02:00
Lucas Winther 271f15b4e3 feat: add meetings MCP tools 2026-09-07 02:43:04 +02:00
Lucas Winther 190278fcce feat: add openproject_list_work_package_activities MCP tool 2026-09-07 02:39:01 +02:00
Lucas Winther 6003925bcf fix: discriminate discovery probe errors and evict cache on refresh 2026-09-07 02:34:35 +02:00
Lucas Winther 1ce61506b2 feat: add wikis domain service with caching discovery search 2026-09-07 02:31:03 +02:00
Lucas Winther c0125121cc fix: resolve typecheck errors and improve searchMeetings pagination slicing 2026-09-07 02:26:14 +02:00
Lucas Winther 8c3db68307 feat: add meetings domain service 2026-09-07 02:22:10 +02:00
Lucas Winther 456c90adba feat: add listWorkPackageActivities domain service 2026-09-07 02:14:18 +02:00
Lucas Winther 324e7fce9c docs: add implementation plan for meetings, wikis, and activities tools 2026-09-07 02:11:03 +02:00
Lucas Winther e08eac3a2b docs: add design spec for meetings, wikis, and activities tools 2026-09-07 02:09:14 +02:00
Lucas Winther 75531eca2d feat(http): add OpenAPI 3.1.0 specification and REST tool execution endpoint 2026-09-07 01:38:32 +02:00
Lucas Winther ba1ce2171e feat(http): support MCP Streamable HTTP transport on /sse, /mcp, and / for Open WebUI 2026-09-07 01:31:04 +02:00
Lucas Winther 2c1ff8f7db feat(http): allow config.apiKey fallback on server for single-tenant deployments 2026-09-07 01:23:31 +02:00
Lucas Winther 914883957e docs: ensure consistent PORT and HOST_PORT documentation across codebase and docs 2026-09-07 01:20:29 +02:00
Lucas Winther c2bb2e0cf7 docs(readme): clarify PORT vs HOST_PORT in docker compose deployment instructions 2026-09-07 01:18:41 +02:00
Lucas Winther 86c7dadb9b fix(config): support HOST_PORT as fallback for PORT environment variable 2026-09-07 01:16:48 +02:00
Lucas Winther c9ba15bca0 docs: update ARCHITECTURE.md, README.md, and plan with hosted remote server details 2026-09-07 01:11:07 +02:00
Lucas Winther d0dc27b2e3 feat(server): wire HTTP transport into CLI, add docker-compose.server.yml, and update docs 2026-09-07 00:14:49 +02:00
Lucas Winther ec85bf811b feat(http): implement hosted remote MCP server with HTTP/SSE transport 2026-09-06 23:36:34 +02:00
Lucas Winther cda83e320f feat(config): support port and host options for HTTP/SSE transport 2026-09-06 21:22:50 +02:00
Lucas Winther 2fad0036e7 docs(plan): add implementation plan for hosted remote MCP server and multi-tenant credential scoping 2026-09-06 21:13:07 +02:00
Lucas Winther 1f3eae4258 docs(spec): add specification for hosted remote MCP server and multi-tenant credential scoping 2026-09-06 21:12:14 +02:00
Lucas Winther 2fb02257f0 docs: update README with detailed usage instructions, tool reference, and client guides 2026-09-06 21:08:41 +02:00
Lucas Winther e56bd574e2 fix(tests): skip live container tests when OPENPROJECT_API_KEY is not set 2026-09-06 21:05:36 +02:00
Lucas Winther 03a1f44517 fix: restore strict noUncheckedIndexedAccess and skip redundant docker build in CI 2026-09-06 20:54:12 +02:00
Lucas Winther a30d913470 docs: record ADR-015, update README with Docker usage, and update TODO.md 2026-09-06 20:48:55 +02:00
Lucas Winther 2b93ebe0a1 fix(ci): lowercase Docker image name in CI workflow and optimize Dockerfile layer creation 2026-09-06 20:48:54 +02:00
Lucas Winther 82138fff61 fix(types): resolve typecheck errors in queries service and tool registration 2026-09-06 20:48:52 +02:00
Lucas Winther 51c92ea927 ci: create GitHub Actions workflow for testing and multi-arch Docker publishing 2026-09-06 20:44:09 +02:00
Lucas Winther df2bad2f62 feat(docker): add multi-stage Dockerfile and .dockerignore 2026-09-06 20:40:27 +02:00
Lucas Winther 1a32b8a179 docs(plan): add implementation plan for Docker packaging and GitHub Actions CI pipeline 2026-09-06 20:37:44 +02:00
Lucas Winther 7ce86012d0 docs(spec): add specification for Docker packaging and GitHub Actions CI pipeline 2026-09-06 20:36:43 +02:00
Lucas Winther 815741b45b docs(plan): update progress ledger and mark Task 3 complete 2026-09-06 20:02:16 +02:00
Lucas Winther 191e37252b test(openapi): add live container tests, update tool count assertions, and update TODO.md 2026-09-06 20:00:02 +02:00
Lucas Winther eaf6d67a30 docs(plan): update progress ledger and mark Task 2 complete 2026-09-06 19:58:20 +02:00
Lucas Winther a5f4dcc2ca feat(tools): add openproject_get_openapi_spec tool definition and registration 2026-09-06 19:56:34 +02:00
Lucas Winther 1039a6aa7a docs(plan): update progress ledger and mark Task 1 complete 2026-09-06 19:49:31 +02:00
Lucas Winther 69f2a95371 fix(openapi): guard suffix matching and add ambient context test 2026-09-06 19:49:13 +02:00
Lucas Winther 25fc2c9057 feat(services): implement OpenAPI specification service and in-memory caching 2026-09-06 19:40:33 +02:00
Lucas Winther 635ac88f5e docs(plan): add OpenAPI tool implementation plan 2026-09-06 19:37:56 +02:00
Lucas Winther 6e166e98b8 docs(spec): add OpenAPI tool design specification 2026-09-06 19:37:04 +02:00
Lucas Winther d9b96862fe docs: record ADR-013 (stdio hygiene) and ADR-014 (OpenAPI discovery) 2026-09-06 19:31:43 +02:00
Lucas Winther fd9697b2b7 docs(plan): update progress ledger and mark Task 4 plan steps complete 2026-09-06 19:23:18 +02:00
Lucas Winther 3d4a064593 refactor(server): add shutdown idempotency guard and test teardown blocks 2026-09-06 19:22:46 +02:00
Lucas Winther f1e84d281a feat(server): wire CLI entrypoint, live E2E tool calling, and mark Task 4 complete in TODO.md 2026-09-06 19:17:40 +02:00
Lucas Winther 8297f97228 docs(plan): update progress ledger and check off Task 3 2026-09-06 19:16:26 +02:00
Lucas Winther 4994b2c8db test(server): clean up read-only manifest filter assertions 2026-09-06 19:16:04 +02:00
Lucas Winther af2a8f3af8 feat(server): add read-only mode enforcement and execution guard tests 2026-09-06 19:14:25 +02:00
Lucas Winther d49e0fc881 feat(server): implement MCP server factory and transport lifecycle 2026-09-06 19:03:09 +02:00
Lucas Winther 3f618b2cf3 feat(tools): add execution wrapper support to tool registration 2026-09-06 18:52:22 +02:00
Lucas Winther 08eff37db0 docs: add design spec and implementation plan for MCP server assembly 2026-09-06 18:40:47 +02:00
Lucas Winther 6b8045111b fix(tools): address final code review findings on validation, concurrency, and testing 2026-09-06 18:37:21 +02:00
Lucas Winther 9bfedf8a8b docs: add OpenAPI support requirement to spec, architecture, and plan 2026-09-06 18:11:58 +02:00
Lucas Winther ebb47eb659 feat(tools): add live container tests and mark Task 3 complete in TODO.md 2026-09-06 18:10:00 +02:00
Lucas Winther 4eb17c2e5c feat(tools): add tool registry and barrel export 2026-09-06 18:04:50 +02:00
Lucas Winther 8cdca0fd83 feat(tools): implement metadata tools and registration 2026-09-06 18:01:43 +02:00
Lucas Winther ff05b48f81 feat(tools): implement query tools and registration 2026-09-06 17:54:42 +02:00
69 changed files with 19962 additions and 201 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.git
.github
.env*
coverage
tests
docs
scripts
.superpowers
*.log
*.md
docker-compose.yml
+8 -1
View File
@@ -17,9 +17,16 @@ OPENPROJECT_HOST__NAME=localhost:8080
OPENPROJECT_HTTPS=false
SECRET_KEY_BASE=openproject-secret-key-base-for-local-testing-at-least-64-characters-long
# MCP Server Client Configuration
# MCP Server Client Configuration (Stdio Mode)
# Once the container is running and seeded via scripts/seed.sh,
# OPENPROJECT_API_KEY will be automatically written to .env.test
OPENPROJECT_BASE_URL=http://localhost:8080
OPENPROJECT_API_KEY=
OPENPROJECT_READ_ONLY=false
# Hosted Remote MCP Server Configuration (HTTP/SSE Mode)
# When PORT (or HOST_PORT) is set, openproject-mcp runs as an HTTP/SSE server.
# In HTTP mode, connecting clients supply their own API keys, so OPENPROJECT_API_KEY is optional.
# PORT=3000
# HOST=0.0.0.0
# HOST_PORT=3000
+92
View File
@@ -0,0 +1,92 @@
name: CI
on:
push:
branches:
- main
tags:
- 'v*.*.*'
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
test:
name: Test & Lint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Type check
run: bun run typecheck
- name: Run test suite
run: bun test
env:
SKIP_DOCKER_TESTS: "true"
docker:
name: Build & Publish Docker Image
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Lowercase image name
run: echo "IMAGE_NAME=ghcr.io/$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
flavor: |
latest=auto
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+28
View File
@@ -0,0 +1,28 @@
# Stage 1: Dependency resolution and production prune
FROM oven/bun:1-slim AS builder
WORKDIR /app
# Copy dependency manifests
COPY package.json bun.lock ./
# Install production dependencies only with strict lockfile
RUN bun install --frozen-lockfile --production
# Stage 2: Minimal runtime image
FROM oven/bun:1-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy installed production node_modules from builder
COPY --chown=bun:bun --from=builder /app/node_modules ./node_modules
# Copy application configuration and source code
COPY --chown=bun:bun package.json tsconfig.json ./
COPY --chown=bun:bun src ./src
# Drop root privileges
USER bun
# Standard MCP stdio communication
ENTRYPOINT ["bun", "run", "src/index.ts"]
+423 -48
View File
@@ -2,18 +2,24 @@
`openproject-mcp` is a Model Context Protocol (MCP) server that connects AI assistants (such as Claude Desktop, Cursor, and Antigravity) to [OpenProject](https://github.com/opf/openproject) via OpenProject's REST API v3.
It allows agents to browse, query, and reason about OpenProject workspaces using personal API keys over standard MCP transports (stdio).
It allows agents to browse, query, and reason about OpenProject workspaces using personal API keys over standard MCP transports (stdio and HTTP/SSE).
---
## Features
- **Project Discovery**: List accessible projects, inspect hierarchies, and retrieve project details.
- **Work Package Browsing**: Query work packages with status, type, and custom filters; inspect work package details and relationships.
- **Work Package Browsing**: Query work packages with status, type, assignee, priority, and custom filters; inspect work package details and parent/child relationships.
- **Activities & Comment History**: Inspect full timeline events and discussions for work packages with comments-only filtering.
- **Meetings & Agendas**: List meetings, inspect detailed agendas with section timings and outcomes, and perform deep keyword searches across meetings and agenda notes.
- **Wikis & Documentation**: Discover and search wiki pages with smart discovery caching, view page metadata, inspect file attachments, and trace work package links.
- **Saved Queries (Views)**: Discover and execute saved project and global queries.
- **Taxonomies & Metadata**: Query work package types (Tasks, Bugs, Features), statuses, priorities, and users to enable structured agent reasoning.
- **OpenAPI Schema Introspection**: Query dynamic endpoint specifications, parameter schemas, and data models directly from the connected OpenProject instance with in-memory caching.
- **HAL+JSON Normalization**: Converts OpenProject's verbose HAL+JSON representations into concise, token-efficient structures.
- **Secure Authentication**: Uses OpenProject Personal API tokens via HTTP Basic Auth (`apikey:<token>`) with zero credential storage inside the codebase.
- **Multi-Platform Docker Images**: Official multi-architecture images (`linux/amd64` and `linux/arm64`) published to GitHub Container Registry (`ghcr.io`).
- **Hosted Remote MCP Server**: Run as a shared multi-tenant server over HTTP/SSE with Docker Compose, allowing multiple users and AI clients (Cursor, Claude Desktop, autonomous agents) to connect with their own personal API keys.
---
@@ -26,6 +32,390 @@ It allows agents to browse, query, and reason about OpenProject workspaces using
---
## Obtaining an OpenProject API Token
To connect `openproject-mcp` to your OpenProject instance, generate a personal API token:
1. Log in to your OpenProject web interface.
2. In the top-right corner, click on your **user avatar** and select **My account**.
3. In the left navigation menu, click on **Access tokens**.
4. In the **API** row, click **Generate** (or **Reset** if an existing token was lost).
5. Copy the generated API token (it starts with `opapi_` or similar hex/alphanumeric string).
6. Keep this token safe; you will pass it via `OPENPROJECT_API_KEY`.
---
## Running openproject-mcp
You can run `openproject-mcp` using either **Docker** (no local dependencies required) or **Bun** (local development).
### Method 1: Docker (Recommended)
Official multi-architecture container images are published to GitHub Container Registry (`ghcr.io`).
#### Interactive Execution
```bash
docker run -i --rm \
-e OPENPROJECT_BASE_URL="https://openproject.example.com" \
-e OPENPROJECT_API_KEY="your-api-key" \
ghcr.io/stereotypicalcat/openproject-mcp:latest
```
#### Read-Only Mode
To strictly restrict capabilities to read-only browsing:
```bash
docker run -i --rm \
-e OPENPROJECT_BASE_URL="https://openproject.example.com" \
-e OPENPROJECT_API_KEY="your-api-key" \
ghcr.io/stereotypicalcat/openproject-mcp:latest --read-only
```
*(Alternatively, pass `-e OPENPROJECT_READ_ONLY=true`.)*
---
### Method 2: Local Execution with Bun
If you prefer to run from source:
1. Install [Bun](https://bun.sh) (>= 1.2 / 1.3):
```bash
curl -fsSL https://bun.sh/install | bash
```
2. Clone and install dependencies:
```bash
git clone https://github.com/StereotypicalCat/openproject-mcp.git
cd openproject-mcp
bun install
```
3. Run the server:
```bash
OPENPROJECT_BASE_URL="https://openproject.example.com" \
OPENPROJECT_API_KEY="your-api-key" \
bun run src/index.ts
```
For read-only mode:
```bash
OPENPROJECT_BASE_URL="https://openproject.example.com" \
OPENPROJECT_API_KEY="your-api-key" \
bun run src/index.ts --read-only
```
---
### Method 3: Hosted Remote Server (Docker Compose & HTTP/SSE)
Organizations can deploy `openproject-mcp` as a shared remote service. Multiple users and AI clients connect to a single hosted instance over HTTP/SSE, each authenticating with their own personal OpenProject API key.
#### Deploy with Docker Compose
Deploy `docker-compose.server.yml` with built-in health checking:
```bash
# Start hosted MCP server on port 3000
OPENPROJECT_BASE_URL="https://openproject.example.com" \
docker compose -f docker-compose.server.yml up -d
# Optionally override the published host port (e.g. host port 8080 -> container port 3000):
HOST_PORT=8080 OPENPROJECT_BASE_URL="https://openproject.example.com" \
docker compose -f docker-compose.server.yml up -d
```
> **Note on adding to an existing compose file**: Inside the container, the service requires `PORT=3000` (under `environment:`) to activate HTTP/SSE server mode. `HOST_PORT` in `docker-compose.server.yml` is solely used for the outer host-side port mapping (`${HOST_PORT:-3000}:3000`).
#### Run with Bun (from source)
```bash
OPENPROJECT_BASE_URL="https://openproject.example.com" \
PORT=3000 \
bun run src/index.ts
```
In hosted mode:
- The server exposes `GET /health` for container orchestration and uptime monitoring.
- Clients connect via `GET /sse` passing their personal API key via `Authorization: Bearer <key>`, `X-OpenProject-Api-Key: <key>`, or URL parameter `?apiKey=<key>`.
- Each connection establishes an isolated session with dynamic `RequestContext` scoping—no credentials bleed across sessions or persist globally.
---
## MCP Client Configuration
### Claude Desktop
Edit your `claude_desktop_config.json`:
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
#### Option A: Docker (Zero Installation)
```json
{
"mcpServers": {
"openproject": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e", "OPENPROJECT_BASE_URL=https://openproject.example.com",
"-e", "OPENPROJECT_API_KEY=your-api-key",
"ghcr.io/stereotypicalcat/openproject-mcp:latest",
"--read-only"
]
}
}
}
```
#### Option B: Bun (From Source)
```json
{
"mcpServers": {
"openproject": {
"command": "bun",
"args": [
"run",
"/path/to/openproject-mcp/src/index.ts",
"--read-only"
],
"env": {
"OPENPROJECT_BASE_URL": "https://openproject.example.com",
"OPENPROJECT_API_KEY": "your-api-key"
}
}
}
}
```
---
### Cursor
Add the configuration in `.cursor/mcp.json` (or under **Cursor Settings > Features > MCP**):
#### Docker Configuration
```json
{
"mcpServers": {
"openproject": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e", "OPENPROJECT_BASE_URL=https://openproject.example.com",
"-e", "OPENPROJECT_API_KEY=your-api-key",
"ghcr.io/stereotypicalcat/openproject-mcp:latest",
"--read-only"
]
}
}
}
```
#### Bun Configuration
```json
{
"mcpServers": {
"openproject": {
"command": "bun",
"args": [
"run",
"/absolute/path/to/openproject-mcp/src/index.ts",
"--read-only"
],
"env": {
"OPENPROJECT_BASE_URL": "https://openproject.example.com",
"OPENPROJECT_API_KEY": "your-api-key"
}
}
}
}
```
---
### Remote Connection to Hosted Server (Cursor & Claude Desktop)
When connecting to an organization's hosted `openproject-mcp` remote server (e.g. `https://mcp.example.com` or `http://localhost:3000`):
#### Cursor (`.cursor/mcp.json`)
Using custom HTTP headers:
```json
{
"mcpServers": {
"openproject": {
"url": "https://mcp.example.com/sse",
"headers": {
"X-OpenProject-Api-Key": "your-personal-api-key"
}
}
}
}
```
Or using URL query parameter:
```json
{
"mcpServers": {
"openproject": {
"url": "https://mcp.example.com/sse?apiKey=your-personal-api-key"
}
}
}
```
#### Claude Desktop
In `claude_desktop_config.json` or clients connecting directly via SSE URL:
```json
{
"mcpServers": {
"openproject": {
"url": "https://mcp.example.com/sse?apiKey=your-personal-api-key"
}
}
}
```
### Open WebUI
Open WebUI supports two connection modes for `openproject-mcp`:
#### Option 1: MCP (Streamable HTTP) — Recommended
1. Navigate to **Admin Panel > Settings > External Tools** (or **User Settings > Tools**).
2. Click **+** (Add Connection).
3. Select **Type**: `MCP (Streamable HTTP)`.
4. Set **URL**:
- In Docker Compose on shared network: `http://openproject-mcp:3000/mcp` (or `http://openproject-mcp:3000/sse`)
- From host machine: `http://localhost:3000/mcp`
5. Set **Auth** (optional): `Bearer <your-openproject-api-key>` if multi-user, or leave blank if `OPENPROJECT_API_KEY` is configured in the container.
6. Click **Verify Connection**.
#### Option 2: OpenAPI Mode
If your Open WebUI installation connects to tool servers using OpenAPI:
1. Click **+** (Add Connection).
2. Select **Type**: `OpenAPI`.
3. Set **URL**:
- In Docker Compose on shared network: `http://openproject-mcp:3000/openapi.json`
- From host machine: `http://localhost:3000/openapi.json`
4. Set **Auth Header** (optional): `Bearer <your-openproject-api-key>`.
5. Click **Verify Connection**.
---
### Hosted Server Endpoints Reference
When running `openproject-mcp` as an HTTP server (`PORT=3000`), the following endpoints are exposed:
| Endpoint | Method | Protocol / Client | Description |
| :--- | :--- | :--- | :--- |
| `/mcp` | POST, GET, DELETE | MCP Streamable HTTP | Primary endpoint for Open WebUI, Python MCP SDK, and modern Streamable HTTP clients |
| `/sse` | GET, POST, DELETE | MCP SSE & Streamable HTTP | Dual-purpose: `GET` establishes classic SSE stream (Cursor, Claude Desktop); `POST` handles Streamable HTTP |
| `/messages` | POST | Classic MCP SSE | Message posting endpoint for established SSE sessions (`?sessionId=...`) |
| `/openapi.json` | GET | OpenAPI 3.1.0 | OpenAPI specification for Open WebUI (OpenAPI mode), Swagger UI, and REST integrations |
| `/swagger.json` | GET | OpenAPI 3.1.0 | Alias for `/openapi.json` |
| `/api/tools/{name}` | POST | REST Tool Execution | Executes an individual tool with JSON arguments and returns structured JSON output |
| `/health` | GET | Health Check | Service health check returning `{ status: "ok", mode: "remote-mcp" }` |
---
## Interactive Testing with MCP Inspector
You can visually test and debug tool calls, explore parameter schemas, and inspect raw JSON payloads using the official MCP Inspector:
```bash
# Using Bun directly
OPENPROJECT_BASE_URL="http://localhost:8080" \
OPENPROJECT_API_KEY="your-api-key" \
bunx @modelcontextprotocol/inspector bun run src/index.ts
```
Or test the published Docker image:
```bash
bunx @modelcontextprotocol/inspector docker run -i --rm \
-e OPENPROJECT_BASE_URL="http://localhost:8080" \
-e OPENPROJECT_API_KEY="your-api-key" \
ghcr.io/stereotypicalcat/openproject-mcp:latest
```
This launches a local web UI (typically at `http://localhost:5173`) where you can trigger tools and review formatted outputs.
---
## Available MCP Tools
`openproject-mcp` currently exposes 19 tools.
### Search behaviour
`openproject_search_meetings`, `openproject_search_wiki_pages`, and `openproject_search_work_packages` rank results by relevance instead of returning a flat list. They accept `matchMode: "fuzzy" | "exact"` (default `"fuzzy"`):
- **`fuzzy`** (default) tolerates typos, reordered words, and partial/natural-language phrasing (e.g. "budget aproval", "what did we decide about hiring"). Results are ranked and each one carries a `score` (0..1 relevance) and `matchedFields` (which fields matched, highest-scoring first); work package and wiki page results also carry a `snippet` excerpt.
- **`exact`** restores literal case-insensitive substring matching, byte for byte equivalent to this project's pre-fuzzy search behavior. Exact mode still searches descriptions, comments, and agenda content — it changes *how* text is matched, not *what* is searched.
On every search tool, `offset` is a **1-based page number**, not an item index: with `pageSize: 20`, `offset: 2` returns results 21-40. Ranked results are returned under `elements`.
`openproject_list_work_package_activities` similarly accepts an optional `query`/`matchMode` pair, applied to the activities it already fetched at no extra API cost. Note that `query` **filters as well as ranks**: activities scoring below the relevance threshold are dropped, so the response is a relevant subset of the timeline rather than the full history reordered. Omit `query` to retrieve the complete activity list. `openproject_list_work_packages` is unchanged — it remains a plain filter tool (`subject` substring match only, no ranking), kept deliberately separate from the ranked `openproject_search_work_packages` tool.
### Projects
- `openproject_list_projects`: List projects with pagination (`offset`, `pageSize`), sorting (`sortBy`), and filtering.
- `openproject_get_project`: Retrieve project details and metadata by ID or identifier (e.g. `projectId: "mcp-test-project"` or `projectId: 4`).
### Work Packages & Activities
- `openproject_list_work_packages`: Query work packages with high-level filters (`projectId`, `status`, `typeId`, `assigneeId`, `priorityId`, `subject`, `pageSize`, `offset`) or custom JSON filter expressions.
- `openproject_get_work_package`: Retrieve detailed information for a specific work package by ID (`workPackageId: 38`), including description, type, status, priority, author, dates, parent, and children.
- `openproject_list_work_package_activities`: Retrieve timeline history, field change logs, and discussions for a work package (`workPackageId: 38`), with optional `onlyComments` filtering and an optional `query`/`matchMode` pair that filters the timeline to relevant entries and ranks them (non-matching activities are omitted).
- `openproject_search_work_packages`: Ranked fuzzy search across work package subjects, descriptions, and comments (`query: "budget aproval"`, optional `projectId`, `status`, `matchMode`, `offset`, `pageSize`). Returns `score`, `matchedFields`, and a `snippet` per result, plus `degraded`/`enrichmentFailures` if some deep content couldn't be fetched.
### Meetings
- `openproject_list_meetings`: List and filter meetings visible to the user by project (`projectId`) or time context (`time: "upcoming"` / `"past"`), with pagination (`offset`, `pageSize`).
- `openproject_get_meeting`: Retrieve detailed meeting information by ID (`id: 2`), including structured agenda items, sections, notes, outcomes, and participants.
- `openproject_search_meetings`: Ranked fuzzy search across meeting titles, locations, project and author names, agenda item titles and notes, outcomes, and participant names (`query: "planning"`, optional `projectId`, `matchMode`, `offset`, `pageSize`). Returns `score`, `matchedFields`, and matched agenda item excerpts, plus `degraded`/`enrichmentFailures` if some deep content couldn't be fetched.
### Wikis
- `openproject_get_wiki_page`: Retrieve wiki page metadata, project, and attachments by numeric ID (`id: 1`).
- `openproject_search_wiki_pages`: Ranked fuzzy search across wiki page titles and page body text with smart caching discovery (`query: "architecture"`, `projectId: "demo-project"`, optional `matchMode`). Returns `score`, `matchedFields`, and a `snippet` per result.
- `openproject_list_wiki_page_links`: List links connecting work packages to wiki pages (`workPackageId: 38`, `offset`, `pageSize`).
### Saved Queries & Views
- `openproject_list_queries`: List saved queries/views accessible to the authenticated user, optionally scoped to a project.
- `openproject_get_query`: Retrieve saved query configuration and the work packages returned by that query (`queryId: 30`).
### Taxonomies & Metadata
- `openproject_list_types`: List all work package types (e.g., Task, Bug, Feature, Milestone), optionally scoped to a project.
- `openproject_list_statuses`: List all configured work package statuses (e.g., New, In progress, Closed) and their closed flags.
- `openproject_list_priorities`: List all configured priority levels (e.g., Low, Normal, High, Immediate).
- `openproject_list_users`: List users in the OpenProject instance with pagination and status filtering.
### OpenAPI Introspection
- `openproject_get_openapi_spec`: Inspect OpenProject REST API v3 documentation dynamically with in-memory caching.
- Default / `summary: true`: Returns compact summary of available tags, paths, and usage instructions.
- `path: "/api/v3/work_packages"`: Returns parameter and operation schemas for a specific endpoint.
- `tag: "Work Packages"`: Returns all endpoints grouped under a tag.
- `schema: "WorkPackageModel"`: Returns the JSON Schema definition for a component model.
---
## Example Prompts for AI Assistants
Once connected in Claude Desktop, Cursor, or your agent of choice, you can ask queries such as:
- *"List all projects in OpenProject and tell me which ones are active."*
- *"Show me all open bugs in the 'mcp-test-project' project."*
- *"What work packages are assigned to me, and what are their priorities?"*
- *"Get details for work package #38 including its child tasks."*
- *"Show all comments and discussion history on work package #38."*
- *"Search work packages for anything about budget approval, even if it's misspelled."*
- *"List upcoming meetings and show the agenda items for our weekly planning meeting."*
- *"Search our meeting notes to see if anyone discussed the new architecture."*
- *"Find wiki pages discussing 'architecture' or 'setup' and list their attachments."*
- *"What wiki pages are linked to work package #38?"*
- *"Show the saved queries available for project 4 and run 'MCP Active Tasks'."*
- *"What work package types and statuses are available in our OpenProject instance?"*
- *"Inspect the OpenAPI schema for creating work packages using openproject_get_openapi_spec."*
---
## Quickstart: Local OpenProject Test Stack
A complete OpenProject 17 environment with PostgreSQL 17 and Memcached is included via Docker Compose for local testing and agentic validation.
@@ -67,7 +457,7 @@ Once seeded, OpenProject is accessible at [http://localhost:8080](http://localho
- **Username**: `admin`
- **Password**: `admin12345678`
You can verify the API v3 connection directly using `curl`:
Verify the API v3 connection directly using `curl`:
```bash
curl -u "apikey:$(grep OPENPROJECT_API_KEY .env.test | cut -d= -f2)" \
@@ -90,67 +480,52 @@ docker compose down -v
---
## MCP Server Development (Bun)
## Configuration Reference
Install project dependencies:
```bash
bun install
```
Start the MCP server over stdio:
```bash
bun run src/index.ts
```
Run test suite:
```bash
bun test
```
Type-check:
```bash
bun run typecheck
```
---
## Configuration
The MCP server accepts configuration through environment variables or a `.env` file:
The MCP server accepts configuration through environment variables, CLI arguments, or a `.env` file:
| Variable | Description | Default |
| :--- | :--- | :--- |
| `OPENPROJECT_BASE_URL` | Base URL of the OpenProject instance | `http://localhost:8080` |
| `OPENPROJECT_API_KEY` | Personal API token (created under My Account > Access tokens) | - |
| `OPENPROJECT_API_KEY` | Personal API token (required in stdio mode; supplied per-client in HTTP mode) | - |
| `OPENPROJECT_READ_ONLY` | Run server in read-only mode (`true` / `false` or `--read-only`) | `false` |
| `PORT` | Local port for the Docker Compose web container | `8080` |
| `TAG` | OpenProject container image tag | `17-slim` |
| `POSTGRES_VERSION` | PostgreSQL container image tag | `17` |
| `PORT` | HTTP server port when running hosted MCP server, or web port for local dev stack | `3000` (MCP) / `8080` (test stack) |
| `HOST_PORT` | Optional environment fallback for `PORT`, and host-side port in `docker-compose.server.yml` | `3000` |
| `HOST` | Bind host for hosted HTTP server | `0.0.0.0` |
| `TAG` | OpenProject container image tag (local test stack) | `17-slim` |
| `POSTGRES_VERSION` | PostgreSQL container image tag (local test stack) | `17` |
### Read-Only Mode
---
To ensure AI assistants cannot make any modifications to your OpenProject instance, enable read-only mode:
## Development
```bash
OPENPROJECT_READ_ONLY=true bun run src/index.ts
# or
bun run src/index.ts --read-only
```
# Install dependencies
bun install
When active, write tools are omitted from the tool manifest and blocked by execution guards.
# Run type checker
bun run typecheck
# Run test suite
bun test
# Run MCP server locally over stdio
bun run src/index.ts
# Run hosted MCP server locally over HTTP/SSE
PORT=3000 bun run src/index.ts
```
---
## Development Roadmap
- **Phase 1 (Current)**: Read-only browsing tools for projects, work packages, queries, and taxonomies.
- **Phase 2**: Mutating operations (create/update work packages, add comments, log time).
- **Phase 3**: Attachment reading and document resources.
- **Phase 4**: SSE / Stream transport for remote server deployments.
- **Phase 1 (Completed)**: Read-only browsing tools for projects, work packages, queries, taxonomies, and OpenAPI introspection.
- **Phase 4 (Completed)**: Remote HTTP/SSE transport (`Bun.serve`) with multi-tenant per-session credential scoping and Docker Compose deployment.
- **Phase 3 Extension (Completed)**: Read-only collaboration tools across Meetings, Wikis, and Work Package Activities (18 tools total).
- **Phase 3.5 (Completed)**: Fuzzy search by default across all search tools with widened content coverage and a new `openproject_search_work_packages` tool (19 tools total). See [ADR-019](docs/DECISIONS.md).
- **Phase 2 (Upcoming)**: Mutating operations (create/update work packages, add comments, log time).
- **Phase 3 (Future)**: Binary attachment downloading and resource streaming.
---
+18
View File
@@ -0,0 +1,18 @@
version: "3.8"
services:
openproject-mcp:
image: ghcr.io/stereotypicalcat/openproject-mcp:latest
container_name: openproject-mcp-hosted
restart: unless-stopped
ports:
- "${HOST_PORT:-3000}:3000"
environment:
- OPENPROJECT_BASE_URL=${OPENPROJECT_BASE_URL:-https://openproject.example.com}
- PORT=3000
- OPENPROJECT_READ_ONLY=${OPENPROJECT_READ_ONLY:-false}
healthcheck:
test: ["CMD-SHELL", "bun -e 'fetch(\"http://localhost:3000/health\").then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))'"]
interval: 30s
timeout: 5s
retries: 3
+102 -26
View File
@@ -24,9 +24,12 @@ flowchart LR
subgraph DomainServices ["Domain Services (Stateless)"]
ProjSvc["Projects Service"]
WpSvc["Work Packages Service"]
WpSvc["Work Packages Service\n(& Activities)"]
QuerySvc["Queries Service"]
MetaSvc["Metadata & Taxonomies"]
OpenApiSvc["OpenAPI Introspection"]
MeetSvc["Meetings Service"]
WikiSvc["Wikis Service"]
end
subgraph Remote ["OpenProject Instance"]
@@ -55,14 +58,18 @@ The system is organized into four modular layers:
- **Responsibility**: Manages the MCP connection lifecycle, tool definitions, input validation, and JSON-RPC dispatching.
- **Protocol**: MCP specification implemented via `@modelcontextprotocol/sdk`.
- **Default Transport**: `StdioServerTransport` for local integration with desktop clients and CLI tools.
- **Remote Transport**: Native `Bun.serve` HTTP/SSE server (`src/http-server.ts`) supporting multi-tenant sessions over Server-Sent Events (`text/event-stream`).
- **Schema Validation**: Each tool declares its arguments using Zod schemas, automatically generating standard JSON Schema definitions for the LLM.
### 2.2. Domain Services / Tool Providers
Domain services map MCP tool calls to concrete business logic and OpenProject API operations:
- **Projects Service**: Listing accessible projects, retrieving project details, and fetching project-level schemas.
- **Work Packages Service**: Listing work packages with filters, searching by text or ID, retrieving work package details, and inspecting relations.
- **Work Packages Service**: Listing work packages with filters, retrieving work package details, inspecting relations, browsing work package timeline activities and comments (optionally ranked by relevance), and ranked fuzzy search across subjects, descriptions, and comments via the two-phase pipeline.
- **Queries Service**: Listing saved queries (views) configured in OpenProject and executing them.
- **Metadata & Taxonomies Service**: Retrieving statuses, work package types, priorities, categories, versions, and users to enable LLMs to construct valid queries and interpret responses.
- **OpenAPI Introspection Service**: Dynamic API v3 schema introspection, endpoint parameter schemas, tag discovery, and model definitions with in-memory caching.
- **Meetings Service**: Listing and filtering meetings (upcoming/past), retrieving meeting details with structured agenda items, sections, and outcomes, and ranked fuzzy search across meeting titles, locations, project and author names, agenda item titles and notes, outcomes, and participant names.
- **Wikis Service**: Retrieving wiki pages with embedded attachments, ranked fuzzy search across page titles and body text using cached discovery harvesting and a consecutive 404 cutoff, and listing wiki page links to work packages.
### 2.3. OpenProject Client Layer
- **HTTP Transport**: Handles HTTPS communication against the configured `OPENPROJECT_BASE_URL`.
@@ -74,11 +81,26 @@ Domain services map MCP tool calls to concrete business logic and OpenProject AP
### 2.4. Configuration & Environment Layer
- Parses and validates configuration at startup:
- `OPENPROJECT_BASE_URL`: Base URL of the OpenProject instance (e.g., `https://openproject.example.com`).
- `OPENPROJECT_API_KEY`: User API key generated in OpenProject under "My Account > Access tokens".
- `OPENPROJECT_API_KEY`: User API key generated in OpenProject under "My Account > Access tokens" (required in stdio mode; optional in HTTP/SSE mode).
- `OPENPROJECT_READ_ONLY`: Boolean flag (`true` / `false`, default: `false`). Can also be set via `--read-only` CLI argument.
- Fails fast with actionable setup advice if required credentials are missing or invalid.
- `PORT`: HTTP server listening port (e.g. `3000` or `--port <num>`). When set, activates HTTP/SSE remote transport. Supports `HOST_PORT` as an environment variable fallback.
- `HOST`: Server bind address (or `--host <addr>`, default: `0.0.0.0`).
- Fails fast with actionable setup advice if required credentials are missing or invalid in stdio mode.
### 2.5. Read-Only Execution Mode
### 2.5. Fuzzy Search Unit (`src/search/`)
A pure computation unit sitting between the tools layer and the domain services layer. It performs no I/O and holds no module-level mutable state — every function takes its input as arguments and returns a value, so it cannot leak state across requests or tenants.
Domain services declare *what* to search (which fields, at what relative importance, over which records); this unit decides *how well* each record matches. The split keeps the fuzzy-matching logic in one place, hand-written and unit-tested, rather than duplicated per service.
- **`tokenize.ts`**: Normalizes text (NFKD diacritic stripping, lowercasing) and splits it into tokens, preserving each token's offset in the original (un-normalized) string so later stages can extract snippets. Query tokenization additionally strips English stopwords (falling back to the unfiltered tokens if that would remove everything), because coverage-based scoring would otherwise drive natural-language questions ("what did we decide about hiring") toward zero.
- **`score.ts`**: A bounded Levenshtein distance with an early exit (the common case is a non-match, so cheap rejection matters more than a tight true distance), and `scoreField`, which turns a query's tokens and a field's text into a 0..1 score combining token coverage, edit-distance tolerance for typos, and a bonus for verbatim phrase matches.
- **`snippet.ts`**: Extracts a short excerpt centered on the matched offsets, for showing the caller *where* a hit occurred in a long field (a wiki body, a comment).
- **`rank.ts`**: `rankRecords<T>(records, query, fields: FieldSpec<T>[], options)` — the generic ranking entry point. A record's score is the best weighted field score (`max over fields of fieldScore * weight / maxDeclaredWeight`), not a sum, so a strong hit on one field isn't diluted by weak hits elsewhere. `matchMode: "exact"` bypasses fuzzy scoring entirely and reproduces the previous case-insensitive substring-containment behavior byte for byte, including its lack of a minimum-score gate. Ties are broken by ascending ID for deterministic ordering.
- **`pipeline.ts`**: `searchPipeline` orchestrates a two-phase search: phase 1 ranks candidates on data already returned by a list endpoint (no extra API calls); phase 2 enriches a bounded window (default 25, see `DEFAULT_ENRICH_LIMIT`) — the top candidates by shallow score, backfilled by recency for records whose only match is in content that hasn't been fetched yet — at a bounded concurrency (default 8, `DEFAULT_CONCURRENCY`) via `mapWithConcurrency`. A record's final score is `max(shallow, deep)`. A non-fatal enrichment failure (e.g. a transient 500 on one candidate) is recorded and does not fail the whole search; a fatal one (401/403/429, via `isFatalSearchError`) propagates, so an expired token surfaces as an authentication error rather than a silent "no results found".
Consumers: `searchMeetings` and `searchWorkPackages` (in `src/services/`) use the full two-phase `searchPipeline`; `searchWikiPages` and the ranked view of `listWorkPackageActivities` call `rankRecords` directly over data they already hold, since neither needs a second enrichment round-trip.
### 2.6. Read-Only Execution Mode
The server supports a dedicated read-only operating mode designed for auditing, reporting, and exploratory agent workflows:
- **Tool Filtering**: When read-only mode is active, only read/browse/query tools are registered in the MCP tool registry. Mutating tools (creating work packages, updating attributes, logging time) are completely excluded from the tool manifest exposed to the LLM.
- **Defense-in-Depth Guard**: In addition to tool manifest omission, the internal tool router validates whether an operation is a mutating action. Any write attempt is immediately rejected with a structured error (`SERVER_READ_ONLY: Server is operating in read-only mode. Write operations are disabled.`).
@@ -114,20 +136,25 @@ The server is architected from the ground up to support concurrent, multi-user o
- **Security**: Complete isolation. Zero network ports exposed.
#### Model B: Centralized Shared Server (SSE / HTTP Transport)
- **Deployment**: A single shared `openproject-mcp` daemon runs centrally for a team or cluster.
- **Deployment**: A single shared `openproject-mcp` daemon runs centrally for a team or cluster (via `docker-compose.server.yml` or `PORT=3000`).
- **Isolation**: Connection and request-level isolation.
- **Credential Delivery via Headers**:
- Each connecting client supplies their OpenProject API token via standard HTTP headers during connection handshake and request dispatch:
- `Authorization: Basic base64(apikey:<USER_API_KEY>)`, or
- `X-OpenProject-API-Key: <USER_API_KEY>`
- Optional: `X-OpenProject-Read-Only: true`
- The transport layer extracts the credentials, instantiates an ephemeral scoped `OpenProjectClient`, and executes tool calls inside `requestContextStorage.run(context, ...)`.
- **Endpoints**:
- `GET /health`: Health probe returning server status and mode.
- `GET /sse`: Persistent SSE stream establishing an isolated session.
- `POST /messages?sessionId=<uuid>`: JSON-RPC message ingestion routed to the active session.
- **Credential Delivery & Extraction Precedence**:
- Each connecting client supplies their OpenProject API token via:
1. `Authorization: Bearer <key>`
2. `X-OpenProject-Api-Key: <key>`
3. `?apiKey=<key>` query parameter
- Unauthenticated connection requests are rejected immediately with HTTP 401 Unauthorized.
- The transport layer instantiates an ephemeral, per-session `OpenProjectClient` and executes tool calls inside `runWithContext({ client, isReadOnly }, ...)`.
- **RBAC Enforcement**: All calls execute against OpenProject REST API v3 using that specific user's token. OpenProject's internal Role-Based Access Control guarantees users only retrieve projects and work packages they have permission to access.
- **Lifecycle**: Ephemeral client instances and tokens reside only in active session memory and are garbage collected upon connection close.
- **Lifecycle & Memory Safety**: Abort signal listeners (`req.signal`) and stream cancellation handlers evict closed sessions from active memory, guaranteeing zero memory leaks.
---
## 4. Tool Specifications (Browse & Query Scope)
## 4. Tool Specifications (Browse & Query Scope - 19 Tools)
| MCP Tool Name | Description | Key Parameters |
| :--- | :--- | :--- |
@@ -135,12 +162,23 @@ The server is architected from the ground up to support concurrent, multi-user o
| `openproject_get_project` | Get details of a single project by ID or identifier | `projectId` (number or string identifier) |
| `openproject_list_work_packages` | Browse work packages with filtering and pagination | `projectId`, `status`, `type`, `pageSize`, `offset`, `filters` |
| `openproject_get_work_package` | Get full details of a specific work package | `workPackageId` (number) |
| `openproject_list_work_package_activities` | Retrieve timeline activities and comments for a work package, optionally ranked by relevance | `workPackageId` (number), `onlyComments` (boolean), `query`, `matchMode` |
| `openproject_search_work_packages` | Ranked fuzzy search across work package subjects, descriptions, and comments | `query` (string), `projectId`, `status`, `matchMode`, `offset`, `pageSize` |
| `openproject_list_queries` | List saved project or global queries (views) | `projectId`, `pageSize`, `offset` |
| `openproject_get_query` | Retrieve details and results of a saved query | `queryId` (number) |
| `openproject_list_types` | List all available work package types (Task, Bug, Milestone, etc.) | None |
| `openproject_list_statuses` | List all available work package statuses (New, In Progress, Closed, etc.) | None |
| `openproject_list_priorities` | List all priority levels | None |
| `openproject_list_users` | List users in the OpenProject instance | `pageSize`, `offset` |
| `openproject_get_openapi_spec` | Retrieve OpenProject API v3 OpenAPI specification for schema introspection | `summary`, `path`, `tag`, `schema` |
| `openproject_list_meetings` | List and filter meetings visible to the user | `projectId`, `time`, `offset`, `pageSize` |
| `openproject_get_meeting` | Retrieve detailed meeting information including agenda items, sections, notes, and participants | `id` (number), `includeAgendaItems` (boolean) |
| `openproject_search_meetings` | Ranked fuzzy search across meetings and agenda items by keywords | `query` (string), `projectId`, `offset`, `pageSize`, `matchMode` |
| `openproject_get_wiki_page` | Retrieve wiki page metadata, project, and attachments by numeric ID | `id` (number) |
| `openproject_search_wiki_pages` | Ranked fuzzy search across wiki page titles and body text | `query`, `projectId`, `limit`, `refreshCache`, `matchMode` |
| `openproject_list_wiki_page_links` | List links connecting work packages to wiki pages | `workPackageId`, `offset`, `pageSize` |
All three dedicated search tools (`openproject_search_meetings`, `openproject_search_wiki_pages`, `openproject_search_work_packages`) default to `matchMode: "fuzzy"` and return each result's relevance `score` and `matchedFields`; passing `matchMode: "exact"` restores literal case-insensitive substring matching. See section 2.5 and ADR-019.
---
@@ -189,10 +227,17 @@ openproject-mcp/
├── docs/
│ ├── ARCHITECTURE.md # System architecture and specifications
│ ├── DECISIONS.md # Architecture Decision Records (ADR)
│ ├── plans/ # Implementation plans
│ ├── specs/ # Technical designs and specifications
│ └── TODO.md # Task tracking and roadmap
├── docker-compose.yml # Local OpenProject 17 testing stack
├── docker-compose.server.yml # Production hosted remote MCP server stack
├── Dockerfile # Multi-stage container image definition
├── src/
│ ├── index.ts # CLI entry point and startup
│ ├── server.ts # MCP server instance & tool registration
│ ├── index.ts # Dual-transport CLI entry point (stdio & HTTP)
│ ├── server.ts # Stdio MCP server factory & tool registration
│ ├── http-server.ts # Hosted HTTP/SSE/OpenAPI multi-tenant server (Bun.serve)
│ ├── openapi-spec.ts # OpenAPI 3.1.0 specification generator for MCP tools
│ ├── context.ts # RequestContext & AsyncLocalStorage
│ ├── client/
│ │ ├── api-client.ts # OpenProject REST API v3 HTTP client
@@ -201,19 +246,48 @@ openproject-mcp/
│ │ └── types.ts # OpenProject API type definitions
│ ├── config/
│ │ └── index.ts # Configuration loader and Zod schema
│ ├── search/ # Pure fuzzy-search unit (no I/O, no module-level state)
│ │ ├── index.ts # Barrel export
│ │ ├── tokenize.ts # Normalization, tokenization, stopword-aware query tokens
│ │ ├── score.ts # Bounded Levenshtein distance & field scoring
│ │ ├── snippet.ts # Match-centered excerpt extraction
│ │ ├── rank.ts # Generic FieldSpec<T> record ranking (fuzzy & exact modes)
│ │ └── pipeline.ts # Two-phase orchestration with bounded concurrency
│ ├── services/
│ │ ├── helper.ts # Client and project resolution helpers
│ │ ├── projects.ts # Projects domain service
│ │ ├── work-packages.ts # Work packages domain service
│ │ ├── work-packages.ts # Work packages, activities & ranked search domain service
│ │ ├── queries.ts # Queries domain service
│ │ └── metadata.ts # Types, statuses, priorities, users
│ │ ├── metadata.ts # Types, statuses, priorities, users
│ │ ├── openapi.ts # OpenAPI specification discovery & caching
│ │ ├── meetings.ts # Meetings domain service & ranked search
│ │ └── wikis.ts # Wikis domain service, discovery cache & ranked search
│ └── tools/
│ ├── project-tools.ts # MCP tool definitions for projects
│ ├── work-package-tools.ts# MCP tool definitions for work packages
│ └── metadata-tools.ts # MCP tool definitions for metadata
│ ├── common.ts # Common schemas and error formatters
│ ├── index.ts # Tool registration and execution wrapper
│ ├── metadata.ts # MCP tool definitions for metadata
│ ├── openapi.ts # MCP tool definition for OpenAPI introspection
│ ├── projects.ts # MCP tool definitions for projects
│ ├── queries.ts # MCP tool definitions for saved queries
│ ├── work-packages.ts # MCP tool definitions for work packages, activities & search
│ ├── meetings.ts # MCP tool definitions for meetings
│ └── wikis.ts # MCP tool definitions for wikis
├── tests/
│ ├── fixtures/ # HAL+JSON mock fixtures
│ ├── search/ # Unit tests for the src/search/ pure functions & recall suite
│ ├── client.test.ts # OpenProject client unit tests
│ └── tools.test.ts # MCP tool integration tests
│ ├── config.test.ts # Configuration loader unit tests
│ ├── docker.test.ts # Docker packaging and compose tests
│ ├── http-server.test.ts # Hosted HTTP/SSE server and protocol tests
│ ├── mcp-server.test.ts # MCP server stdio integration tests
│ ├── meetings.test.ts # Meetings domain service tests
│ ├── openapi.test.ts # OpenAPI service and tool tests
│ ├── read-only.test.ts # Read-only execution mode guard tests
│ ├── services.test.ts # Domain services integration tests
│ ├── smoke.test.ts # Metadata and smoke tests
│ ├── tools.test.ts # Tool registration unit tests
│ ├── wikis.test.ts # Wikis domain service tests
│ └── work-package-activities.test.ts # Activities service unit tests
├── AGENTS.md # Operating guidelines for AI agents
├── package.json # Project dependencies and scripts
├── bun.lock # Bun dependency lockfile
@@ -224,7 +298,9 @@ openproject-mcp/
## 8. Future Roadmap
- **Phase 1 (Current)**: Read/browse capability for projects, work packages, queries, and taxonomies with request-scoped context.
- **Phase 2**: Mutating operations (create/update work packages, add comments, log time).
- **Phase 3**: Attachment inspection and download resources.
- **Phase 4**: SSE / Stream transport for remote multi-user server deployments.
- **Phase 1 (Completed)**: Read/browse capability for projects, work packages, queries, taxonomies, and OpenAPI introspection.
- **Phase 4 (Completed)**: Hosted remote MCP server (HTTP/SSE transport via `Bun.serve`) with multi-tenant per-session credential scoping and Docker Compose deployment.
- **Phase 3 Extension (Completed)**: Read-only collaboration tools across Meetings (listing, detail inspection, deep keyword search across agenda notes), Wikis (page retrieval, smart discovery search, and links), and Work Package Activities (history and comments filtering). Total catalog: 18 tools.
- **Phase 3.5 (Completed)**: Fuzzy search by default across all search tools, via a hand-rolled zero-dependency scorer in `src/search/` (see section 2.5 and ADR-019). Widened content coverage (wiki body text, work package descriptions and comments, meeting participants and project names) and added `openproject_search_work_packages`. Total catalog: 19 tools.
- **Phase 2 (Upcoming)**: Mutating operations (create/update work packages, add comments, log time).
- **Phase 3 (Future)**: Attachment binary download resources.
+267
View File
@@ -298,6 +298,273 @@ Adopt the **Request-Scoped Context Architecture**:
- **Negative**:
- Tool handlers must access their client via `getRequestContext()` rather than directly importing a static instance.
---
## ADR-013: Stdio Server Assembly & Stream Hygiene
### Status
Accepted
### Context
When running over the standard MCP stdio transport (`StdioServerTransport`), the child process's standard output (`stdout`) is strictly reserved for JSON-RPC framing and message payloads. Any unexpected output sent to `stdout` (such as debugging statements, logger banners, or third-party library logging) corrupts the protocol stream and immediately crashes or disconnects MCP client hosts.
### Decision
1. **Stdio Stream Isolation**: All diagnostics, lifecycle announcements, startup banners, error notices, and termination logs must strictly route to standard error (`stderr` / `console.error`).
2. **Server Factory Separation**: Keep the server definition (`src/server.ts`) separate and pure from the runtime CLI entrypoint (`src/index.ts`).
3. **Idempotent Signal Handling**: CLI signal traps (`SIGINT`, `SIGTERM`) implement an `isShuttingDown` guard to ensure clean, one-time server teardown without race conditions.
### Consequences
- **Positive**:
- 100% protocol integrity across all MCP client hosts (Claude Desktop, Cursor, Antigravity CLI).
- Clean error diagnostics visible to operators in client log panels via `stderr`.
- **Negative**:
- Developers and agents must never use `console.log` in server or domain code.
---
## ADR-014: OpenProject OpenAPI Specification Discovery
### Status
Accepted
### Context
OpenProject REST API v3 provides dynamic, instance-specific OpenAPI 3.0 schema definitions at `/api/v3/openapi.json`. LLM agents and tooling benefit from discovering API capabilities, endpoints, and data schemas dynamically from the connected instance.
### Decision
Support OpenAPI 3.0 specification retrieval as an MCP tool (`openproject_get_openapi_spec`) and client capability, allowing agents to introspect the complete OpenProject API definition.
### Consequences
- **Positive**:
- Enables dynamic tool generation, schema discovery, and self-documenting capabilities for LLM agents.
- Automatically adapts to installed OpenProject plugins and API extensions on target instances.
- **Negative**:
- Requires handling large JSON payloads efficiently when requesting full OpenAPI definitions.
---
## ADR-015: Docker Container Packaging and GitHub Actions CI/CD Pipeline
### Status
Accepted
### Context
Users and client LLM applications (such as Claude Desktop, Cursor, or containerized agents) require a lightweight, zero-dependency method to run `openproject-mcp` without manually installing Bun or local development dependencies. Furthermore, pull requests and releases require automated validation to guarantee that tests pass and container images are securely built and published to GitHub Container Registry (`ghcr.io`).
### Decision
1. **Runtime Base Image**: Adopt `oven/bun:1-slim` with a multi-stage Docker build. The builder stage installs production dependencies (`bun install --frozen-lockfile --production`), and the runner stage copies only production node_modules and application sources.
2. **Security & Permissions**: Run container execution under the unprivileged `bun` user (UID 1000). Exclude sensitive environment files (`.env*`), git records, and local tokens via `.dockerignore`.
3. **Multi-Architecture**: Build container images for both `linux/amd64` and `linux/arm64` via Docker Buildx and QEMU.
4. **Interactive Stdio Execution**: Configure container `ENTRYPOINT ["bun", "run", "src/index.ts"]` with standard I/O streaming, allowing runtime options (such as `--read-only`) to be appended directly.
5. **Continuous Integration**: Implement a two-stage GitHub Actions workflow (`.github/workflows/ci.yml`):
- `test`: Executes `bun run typecheck` and `bun test` on PRs and main pushes.
- `docker`: Builds multi-arch images with `type=gha` cache, publishing to `ghcr.io` on pushes to `main` and release tags (`v*.*.*`), while validating builds without push on pull requests.
### Consequences
- Provides seamless Docker execution for desktop and server MCP clients via `docker run -i --rm ghcr.io/<owner>/openproject-mcp:latest`.
- Prevents container build regressions through automated PR verification.
- Guarantees multi-architecture compatibility across Apple Silicon and x86_64 host machines.
---
## ADR-016: Hosted Remote MCP Server Architecture (HTTP/SSE Transport) & Multi-Tenant Credential Scoping
### Status
Accepted
### Context
`openproject-mcp` initially executed exclusively as a local child process over standard input/output (`stdio`). While ideal for individual developers running local tools like Claude Desktop or Cursor on their personal machines, team environments, shared hosting, and web agents require a remotely hosted MCP server instance accessible over standard network protocols.
Key requirements for hosted operation:
1. **Multi-Tenant Credential Scoping**: A single server process connects to a shared OpenProject base URL (`OPENPROJECT_BASE_URL`), but every connecting user/agent must use their own personal OpenProject API token. User credentials must never bleed across sessions or be retained in global mutable memory.
2. **Dual-Transport Entrypoint**: The server CLI must support both local stdio execution and hosted HTTP/SSE execution via configuration (`PORT`, `HOST_PORT` fallback, or `--port`).
3. **Flexible Authentication**: Clients must be able to authenticate via standard headers (`Authorization: Bearer <key>`, `X-OpenProject-Api-Key: <key>`) or query parameters (`?apiKey=<key>`) for SSE-compatible desktop and web clients.
4. **Health & Lifecycle Management**: Provide standardized `/health` endpoints and clean session eviction on client disconnect to prevent memory leaks.
### Decision
1. **HTTP/SSE & Streamable HTTP Transport Engine**: Implement `src/http-server.ts` utilizing native `Bun.serve` and Web Streams. Support both classic SSE transport (`GET /sse` + `POST /messages`) and modern MCP Streamable HTTP transport (`POST`, `GET`, `DELETE` on `/sse`, `/mcp`, and `/`) for clients like Open WebUI.
2. **Credential Extraction Precedence**:
- Priority 1: `Authorization: Bearer <token>`
- Priority 2: `X-OpenProject-Api-Key: <token>`
- Priority 3: `?apiKey=<token>` URL query parameter
- Priority 4: Server-configured `OPENPROJECT_API_KEY` fallback (single-tenant deployments)
- Reject unauthenticated requests immediately with HTTP 401 Unauthorized.
3. **Per-Connection Isolation**: For each connection or session, allocate a unique `sessionId` and initialize an isolated `OpenProjectClient` and `McpServer` instance. Route tool calls and JSON-RPC dispatch via `runWithContext` using `AsyncLocalStorage`.
4. **Session Eviction**: Register connection abort, stream cancellation handlers, and session TTL timers that evict disconnected sessions from active memory.
5. **Container Deployment**: Provide `docker-compose.server.yml` with built-in `/health` probe checking `GET /health` every 30s.
### Consequences
- **Positive**:
- Organizations can deploy a single shared `openproject-mcp` service without sharing API tokens.
- Full compatibility with both classic SSE clients (Cursor, Claude Desktop) and Streamable HTTP clients (Open WebUI, Python MCP SDK).
- Backward compatibility: when `PORT` is not set, stdio transport remains the default with zero overhead.
- **Negative**:
- Requires network infrastructure (e.g. reverse proxy with TLS termination) in production to protect credentials in transit.
---
## ADR-017: OpenAPI 3.1.0 Specification & REST Tool Execution Bridge for OpenAPI Clients
### Status
Accepted
### Context
While modern AI platforms (such as Open WebUI v0.6.31+) natively support MCP Streamable HTTP, many external tool orchestration platforms, custom user agents, and enterprise portals only integrate with external tools via standard OpenAPI 3.0 / 3.1 REST specifications (`openapi.json` / `swagger.json`). Furthermore, users of Open WebUI who configure connections using the "OpenAPI" tool server type expect a compliant OpenAPI specification endpoint that describes available tools and routes invocations over standard HTTP POST endpoints.
### Decision
1. **Dynamic OpenAPI Specification Generator (`src/openapi-spec.ts`)**:
- Inspect all registered MCP tools in `allTools`.
- Filter out mutating tools when `config.readOnly` is true.
- Convert tool parameter definitions (Zod schemas) into valid OpenAPI 3.1.0 JSON schemas via `zod-to-json-schema`.
- Expose the specification at `GET /openapi.json` and `GET /swagger.json`.
2. **REST Tool Execution Endpoint (`POST /api/tools/{toolName}`)**:
- Parse and validate tool arguments from the JSON request body against the tool's Zod schema.
- Extract the user's OpenProject API key using standard precedence (`Authorization: Bearer`, `X-OpenProject-Api-Key`, or server default).
- Execute the tool within `runWithContext` using an isolated `OpenProjectClient`.
- Return HTTP 200 containing both formatted MCP text content and structured parsed JSON `data`.
### Consequences
- **Positive**:
- Open WebUI users can seamlessly connect using either connection type: "MCP (Streamable HTTP)" at `/mcp` or "OpenAPI" at `/openapi.json`.
- Tools are defined once in TypeScript / Zod and automatically exposed across both MCP and OpenAPI protocols with zero duplication.
- Full read-only guard enforcement across both transports.
- **Negative**:
- Introduces `src/openapi-spec.ts` dependency on `zod-to-json-schema`.
---
## ADR-018: Add Meetings, Wikis, and Activities Tools with Smart Wiki Discovery and Deep Meeting Search
### Status
Accepted
### Context
OpenProject provides critical team collaboration capabilities beyond core projects and work packages:
1. **Meetings**: Structured agendas, participant lists, outcomes, and meeting minutes at `/api/v3/meetings`. LLMs frequently need to review sprint retrospectives, planning outcomes, and architectural decisions discussed in meetings.
2. **Wikis**: Project documentation, architectural guides, and wiki pages stored at `/api/v3/wiki_pages`. However, OpenProject REST API v3 lacks a direct global `/api/v3/wiki_pages` collection endpoint. The API only exposes individual page retrieval (`/api/v3/wiki_pages/{id}`) and associations from work packages (`/api/v3/wiki_page_links` and `/api/v3/work_packages/{id}/wiki_page_links`).
3. **Work Package Activities & Comments**: Full audit history and discussions attached to work packages at `/api/v3/work_packages/{id}/activities`. LLMs summarizing work package state need to isolate user comments from field mutation logs (e.g. status changes).
Furthermore, all new capabilities must strictly adhere to the project's read-only execution guarantees (ADR-011), request-scoped concurrency model (ADR-012), token efficiency requirements (ADR-007), and OpenAPI specification generation (ADR-017).
### Decision
1. **Tool Scope and Read-Only Compliance**:
- Introduce 7 new read-only MCP tools:
- Meetings: `openproject_list_meetings`, `openproject_get_meeting`, `openproject_search_meetings`
- Wikis: `openproject_get_wiki_page`, `openproject_search_wiki_pages`, `openproject_list_wiki_page_links`
- Activities: `openproject_list_work_package_activities`
- Mark each tool `readOnly: true`, allowing them to be registered and executable in read-only mode, expanding the total catalog from 11 to 18 tools.
2. **Token-Efficient HAL Normalization**:
- Implement tailored normalizers for all new endpoints:
- Meeting models normalize project, author, and participant references.
- Meeting details concurrently fetch `/api/v3/meetings/{id}/agenda_items` to embed clean agenda items, durations, sections, notes, and outcomes without deep `_links` bloat.
- Wiki pages embed attachments with download URLs, sizes, and MIME types.
- Work package activities parse raw comment text, extract user references, and normalize property change details.
3. **Smart Wiki Discovery Strategy (Sequential Probing with Consecutive 404 Cutoff & Caching)**:
- To overcome the lack of an OpenProject collection endpoint for wiki pages:
a. Query `/api/v3/wiki_page_links` to harvest referenced wiki pages.
b. Probe sequential IDs starting from ID 1 (`/api/v3/wiki_pages/{id}`) with a consecutive 404 threshold stop (halt after 5 consecutive misses).
c. Cache discovered pages in an in-memory map keyed by client `baseUrl`.
d. Filter discovered pages by project (resolving numeric ID or slug) and search queries against page titles.
e. Support a `refreshCache: true` parameter for forcing cache invalidation.
4. **Deep Meeting Search**:
- Provide `openproject_search_meetings` that searches meeting titles and locations, while concurrently inspecting agenda item titles, discussion notes, and outcome minutes to return snippet matches and identify match origin (`title`, `location`, or `agenda_item`).
### Consequences
- **Positive**:
- Full visibility for LLMs into team discussions, meetings, agendas, wiki documentation, and work package comment history.
- Expands MCP catalog to 18 tools across both stdio and HTTP/SSE/OpenAPI transports.
- Zero performance regression: smart wiki discovery avoids repetitive scanning via memory caching, while consecutive 404 cutoff bounds HTTP traffic.
- Token-efficient representations conserve LLM context window.
- Maintains strict read-only safety guarantees across all new tools.
- **Negative**:
- OpenProject API lack of a native wiki collection endpoint requires heuristic discovery probing.
- Deep meeting search requires additional sub-requests for agenda items on candidate meetings, bounded by pagination limits.
---
## ADR-019: Fuzzy Search by Default with a Hand-Rolled Zero-Dependency Scorer
### Status
Accepted
### Context
Every search surface matched with case-insensitive exact substring containment.
An LLM asking for "budget aproval", "approval of the budget", or "what did we
decide about hiring" received zero results even when the information was
present, and then reported to the user that it did not exist. Search also
covered far less content than callers assumed: wiki search matched page titles
only, and work package search matched subjects only.
### Decision
1. **Fuzzy matching is the default** across all search tools, with an
optional `matchMode: "fuzzy" | "exact"` parameter. Exact mode reproduces
the previous behaviour byte for byte, which lets the pre-existing test
suites serve as a regression harness.
2. **A hand-rolled scorer in `src/search/`**, adding no runtime dependencies.
`fuse.js` was rejected because its Bitap implementation caps patterns at 32
characters and targets short fields in modest lists — the opposite of the
wiki-body and comment matching this change requires. Running two scoring
systems to cover both cases was worse than owning ~200 lines of pure,
synchronous, trivially testable code. `fastest-levenshtein` was rejected
because the distance function is roughly 30 lines with the early-exit band
we need, and fetching, not distance computation, dominates the time.
3. **A bounded two-phase pipeline.** Phase 1 ranks on data already returned by
list endpoints; phase 2 enriches only the top 25 candidates at a
concurrency of 8. This replaces an unbounded `Promise.all` over up to 250
candidates in the previous meeting search.
4. **Content coverage widened** to wiki page bodies, work package descriptions
and comments, and meeting participants and project names.
5. **A new `openproject_search_work_packages` tool**, taking the registry from
18 to 19 tools. `searchWorkPackages` previously existed as a service helper
that no tool invoked, so ranked work package search would otherwise have been
unreachable. `openproject_list_work_packages` keeps its server-side
`subject ~` filter unchanged, keeping "filter" and "rank" as separate tools
rather than making one tool's response shape depend on its arguments.
6. **No standalone activities search tool**, because OpenProject exposes
activities only per work package. Comment text enters the work package deep
phase instead. `openproject_list_work_package_activities` gained an optional
`query`/`matchMode` pair that filters and ranks the activities it already
fetched client-side, at no extra API cost. It applies the same
`DEFAULT_MIN_SCORE` gate as every other ranked surface, so a query narrows
the returned timeline to relevant entries rather than reordering the full
history; omitting `query` returns the complete list.
### Consequences
- LLM search hit rates improve substantially for paraphrased and misspelled
queries; results from all three search tools are ranked and carry `score`
and `matchedFields`. Work package and wiki page results additionally carry
a top-level `snippet`; meeting results carry a `snippet` per matched agenda
item instead.
- Enrichment failures now surface as `degraded` / `enrichmentFailures` on the
meeting and work package search pages, rather than being silently
swallowed. Previously an expired token during a meeting search produced "no
results" instead of an auth error.
- `src/search/` is pure and holds no module-level state, so it does not
reintroduce the cross-tenant cache exposure fixed in commit `7097d1c`.
- Two accepted limitations: a record whose only match is deep content is
findable only within the 25-item enrichment window (see spec section 6.1),
and wiki search remains bounded by the ID 1..50 discovery probe (spec
section 6.2).
- Scoring constants are tuning parameters. `tests/search/recall.test.ts` is
the acceptance test that governs them.
- Search response envelopes carry ranked results under `elements` only. The
duplicate `items` alias that non-search list functions still return is
omitted from `MeetingSearchPage` and `WorkPackageSearchPage`, because the
tool layer JSON-stringifies the whole envelope and shipping the same array
twice doubled the token cost of every search response.
- `offset` on both search pages is a 1-based page number, matching the tool
schemas and the OpenProject API. This corrected `openproject_search_meetings`,
which previously sliced from item index `offset - 1` and returned a page
overlapping the previous one by all but a single result.
- The candidate union for work package search runs both legs in every match
mode. Restricting exact mode to the server-side `subject ~` filter made
description- and comment-only matches unreachable precisely for the literal
strings (error codes, ticket refs, commit SHAs) that exact mode exists for.
Both legs page the candidate window in batches of 100 up to the 250 cap,
since OpenProject enforces a server-side maximum page size.
+94 -19
View File
@@ -58,39 +58,114 @@
- [x] Implement `src/services/work-packages.ts` (`listWorkPackages`, `getWorkPackage`, `searchWorkPackages`).
- [x] Implement `src/services/queries.ts` (`listQueries`, `getQuery`, `getQueryResults`).
- [x] Implement `src/services/metadata.ts` (`listStatuses`, `listTypes`, `listPriorities`, `listUsers`).
- [x] Implement `src/services/openapi.ts` (`getOpenApiSpec` with in-memory caching, summary mode, path endpoint lookup, tag filtering, schema inspection).
- [x] Add service tests in `tests/services.test.ts` (unit tests and live container integration suite passing).
- [ ] **Task 3: MCP Tool Definitions & Schema Registration** *(In Progress: Tasks 1-3 of plan completed)*
- [ ] Define Zod schemas and register tools in `src/tools/`:
- [x] **Task 3: MCP Tool Definitions & Schema Registration**
- [x] Define Zod schemas and register tools in `src/tools/`:
- [x] `openproject_list_projects`
- [x] `openproject_get_project`
- [x] `openproject_list_work_packages`
- [x] `openproject_get_work_package`
- [ ] `openproject_list_queries`
- [ ] `openproject_get_query`
- [ ] `openproject_list_types`
- [ ] `openproject_list_statuses`
- [ ] `openproject_list_priorities`
- [ ] `openproject_list_users`
- [x] `openproject_list_queries`
- [x] `openproject_get_query`
- [x] `openproject_list_types`
- [x] `openproject_list_statuses`
- [x] `openproject_list_priorities`
- [x] `openproject_list_users`
- [x] `openproject_get_openapi_spec`
- [ ] **Task 4: MCP Stdio Server Assembly & Integration**
- [ ] Wire domain tools into `@modelcontextprotocol/sdk` Server using `StdioServerTransport` in `src/server.ts` and `src/index.ts`.
- [ ] Implement read-only mode tool filtering (omits mutating tools when `readOnly` is enabled) and execution guard (`SERVER_READ_ONLY`).
- [ ] Ensure all logging is strictly redirected to `stderr`.
- [ ] Add end-to-end integration tests in `tests/mcp-server.test.ts` verifying tool calling against the running OpenProject 17 instance.
- [ ] Add read-only mode verification tests in `tests/read-only.test.ts`.
- [x] **Task 4: MCP Stdio Server Assembly & Integration**
- [x] Wire domain tools into `@modelcontextprotocol/sdk` Server using `StdioServerTransport` in `src/server.ts` and `src/index.ts`.
- [x] Implement read-only mode tool filtering (omits mutating tools when `readOnly` is enabled) and execution guard (`SERVER_READ_ONLY`).
- [x] Ensure all logging is strictly redirected to `stderr`.
- [x] Add end-to-end integration tests in `tests/mcp-server.test.ts` verifying tool calling against the running OpenProject 17 instance.
- [x] Add read-only mode verification tests in `tests/read-only.test.ts`.
- [x] **Task 5: OpenAPI Introspection Tool & Service**
- [x] Implement `src/services/openapi.ts` with in-memory caching, multi-mode queries (summary, path, tag, schema), and ambient client resolution.
- [x] Implement `src/tools/openapi.ts` registering `openproject_get_openapi_spec` (`readOnly: true`) into `allTools` (bringing tool count to 11).
- [x] Add comprehensive test suite in `tests/openapi.test.ts` covering service methods, MCP client execution, error handling, and live OpenProject 17 container integration.
- [x] Update suite regressions and assertions across all test suites (`tests/tools.test.ts`, `tests/read-only.test.ts`, `tests/mcp-server.test.ts`).
- [x] **Task 6: Docker Container Packaging & CI/CD Pipeline**
- [x] Created multi-stage [Dockerfile](../Dockerfile) using `oven/bun:1-slim` builder and unprivileged `bun` user runner.
- [x] Created [.dockerignore](../.dockerignore) excluding secrets, local dependencies, test files, and docs from build context.
- [x] Implemented GitHub Actions CI workflow [.github/workflows/ci.yml](../.github/workflows/ci.yml) with automated type checking, testing, and multi-arch Docker image publishing (`linux/amd64`, `linux/arm64`) to `ghcr.io`.
- [x] Added automated test coverage in [tests/docker.test.ts](../tests/docker.test.ts) and [tests/ci-workflow.test.ts](../tests/ci-workflow.test.ts).
- [x] Recorded [ADR-015](DECISIONS.md#adr-015-docker-container-packaging-and-github-actions-cicd-pipeline).
- [x] Updated [README.md](../README.md) with Docker Quickstart, read-only mode instructions, and client integration settings (Claude Desktop & Cursor).
- [x] **Task 7: Hosted Remote MCP Server (HTTP/SSE Transport) & Multi-Tenant Credential Scoping**
- [x] Extend configuration loader with `port` and `host` options (`src/config/index.ts`, `tests/config.test.ts`).
- [x] Implement hosted HTTP/SSE MCP server with `Bun.serve` and Web Streams SSE (`src/http-server.ts`, `tests/http-server.test.ts`).
- [x] Implement credential extraction hierarchy (`Authorization: Bearer`, `X-OpenProject-Api-Key`, `?apiKey=`) with 401 unauthorized rejection.
- [x] Implement per-session client isolation and `runWithContext` dynamic scoping.
- [x] Wire dual-transport execution in CLI entrypoint (`src/index.ts`).
- [x] Create production `docker-compose.server.yml` with `/health` check probe.
- [x] Record [ADR-016](DECISIONS.md#adr-016-hosted-remote-mcp-server-architecture-httpsse-transport--multi-tenant-credential-scoping).
- [x] Update documentation with hosted deployment guide and remote client setups for Cursor and Claude Desktop in [README.md](../README.md).
- [x] **Task 8: OpenAPI 3.1.0 Specification & REST Tool Execution Bridge**
- [x] Implement dynamic OpenAPI specification generator `src/openapi-spec.ts`.
- [x] Expose `GET /openapi.json` and `GET /swagger.json` in `src/http-server.ts`.
- [x] Expose REST tool execution endpoint `POST /api/tools/:toolName` with Zod parameter validation and `RequestContext` execution.
- [x] Add automated test coverage in `tests/http-server.test.ts`.
- [x] Record [ADR-017](DECISIONS.md#adr-017-openapi-310-specification--rest-tool-execution-bridge-for-openapi-clients) in `docs/DECISIONS.md`.
- [x] Document OpenAPI connection mode and server endpoints reference table in [README.md](../README.md).
- [x] **Task 9: Meetings, Wikis, and Activities Extension (18 Tools Total)**
- [x] Implemented `listWorkPackageActivities` in `src/services/work-packages.ts` separating comments from field mutations with `onlyComments` filter.
- [x] Implemented `listMeetings`, `getMeeting` (with embedded agenda items/outcomes), and `searchMeetings` (deep search across titles, locations, and agenda item notes) in `src/services/meetings.ts`.
- [x] Implemented `getWikiPage` (with attachments), `listWikiPageLinks`, and `searchWikiPages` with smart discovery caching and consecutive 404 cutoff in `src/services/wikis.ts`.
- [x] Registered `openproject_list_work_package_activities` in `src/tools/work-packages.ts`.
- [x] Registered `openproject_list_meetings`, `openproject_get_meeting`, `openproject_search_meetings` in `src/tools/meetings.ts`.
- [x] Registered `openproject_get_wiki_page`, `openproject_search_wiki_pages`, `openproject_list_wiki_page_links` in `src/tools/wikis.ts`.
- [x] Wired all 18 tools into server registry (`src/tools/index.ts`, `src/services/index.ts`) and updated all test suite assertions to 18 tools.
- [x] Added live OpenProject 17 container integration tests in `tests/services.test.ts` and `tests/mcp-server.test.ts`.
- [x] Recorded [ADR-018](DECISIONS.md#adr-018-add-meetings-wikis-and-activities-tools-with-smart-wiki-discovery-and-deep-meeting-search) and updated [ARCHITECTURE.md](ARCHITECTURE.md), [TODO.md](TODO.md), and [README.md](../README.md).
- [x] **Task 10: Fuzzy Search by Default (19 Tools Total)**
- [x] Implemented a pure, zero-dependency fuzzy-matching unit in `src/search/` (`tokenize.ts`, `score.ts`, `snippet.ts`, `rank.ts`, `pipeline.ts`), with no I/O and no module-level mutable state.
- [x] Made fuzzy matching the default across all search tools via an optional `matchMode: "fuzzy" | "exact"` parameter; `"exact"` reproduces the previous substring-containment behavior byte for byte.
- [x] Widened content coverage: wiki page body text, work package descriptions and comments, and meeting participants and project names are now searchable, not just titles/subjects.
- [x] Added `openproject_search_work_packages`, exposing the previously tool-unreachable `searchWorkPackages` service helper as a ranked search tool over subjects, descriptions, and comments.
- [x] Added optional `query`/`matchMode` ranking to `openproject_list_work_package_activities` (ranks already-fetched activities client-side, no extra API cost).
- [x] Fixed two live bugs: a `.catch(() => undefined)` in meeting search that turned an expired token into a silent "no results found", and an unbounded `Promise.all` over up to 250 candidates in meeting search, replaced by the bounded two-phase pipeline (25-item enrichment window, concurrency 8).
- [x] Added `tests/search/` unit tests (`tokenize`, `score`, `snippet`, `rank`, `pipeline`) and a recall suite (`tests/search/recall.test.ts`) covering realistic natural-language and misspelled queries.
- [x] Recorded [ADR-019](DECISIONS.md#adr-019-fuzzy-search-by-default-with-a-hand-rolled-zero-dependency-scorer) and updated [ARCHITECTURE.md](ARCHITECTURE.md), [TODO.md](TODO.md), and [README.md](../README.md).
### Known Limitations (Accepted)
- **Two-phase recall ceiling**: A record whose only match is in deep content (a work package description/comment, a meeting agenda note or outcome) is findable only if it lands in the 25-item enrichment window (top-by-shallow-score, backfilled by recency). Beyond that window, deep-only matches are missed. See `src/search/pipeline.ts` (`DEFAULT_ENRICH_LIMIT`).
- **Wiki discovery probe window**: `searchWikiPages` builds its cache by probing wiki page IDs 1..50 with a 5-consecutive-miss cutoff. Pages beyond that window, or past a gap of five consecutive deleted/missing IDs, are invisible to search regardless of ranking quality. See `src/services/wikis.ts`.
### Follow-ups (Tracked, Out of This Plan's Scope)
- [ ] `getMeeting` in `src/services/meetings.ts` still swallows auth errors via a `.catch(() => undefined)` on its agenda-item fetch (same bug class fixed for `searchMeetings`). An expired token can still surface as a silently empty meeting detail on that path.
- [ ] `tests/search/recall.test.ts` under-tests ranking-under-competition: most cases have only one corpus document with any lexical overlap, so the suite largely proves "clears the minScore threshold" rather than "ranks the right document above plausible near-misses". Widening the corpus with near-miss documents would strengthen it.
- [ ] `SearchWorkPackagesOptions.typeId` / `assigneeId` (in `src/services/work-packages.ts`) are unreachable from the MCP tool — `searchWorkPackagesShape` in `src/tools/work-packages.ts` never exposes them. Dead surface area worth trimming or wiring up.
---
## 3. Backlog & Future Phases
- [x] **Phase 4: Remote Transport (Completed)**
- [x] Add SSE (Server-Sent Events) transport option for remote deployments.
- [x] Multi-tenant credential scoping with per-session isolation.
- [x] **Phase 3 Extension: Meetings, Wikis, and Activities (Completed - 18 Tools Total)**
- [x] Meetings listing, detail inspection, and deep agenda search.
- [x] Wiki pages retrieval, smart cached discovery search, and work package links.
- [x] Work package activities and comments filtering.
- [x] **Phase 3.5: Fuzzy Search by Default (Completed - 19 Tools Total)**
- [x] Zero-dependency fuzzy scorer (`src/search/`) applied by default across all search tools, with `matchMode: "exact"` opt-out.
- [x] Widened content coverage (wiki bodies, work package descriptions/comments, meeting participants/project names).
- [x] New `openproject_search_work_packages` tool; ranked activities via `openproject_list_work_package_activities`.
- [x] Two known, accepted limitations recorded above (two-phase recall ceiling, wiki discovery probe window).
- [ ] **Phase 2: Mutating Operations**
- [ ] Create work package tool (`openproject_create_work_package`).
- [ ] Update work package tool (`openproject_update_work_package`).
- [ ] Add comments / activities to work packages.
- [ ] Time logging tool (`openproject_log_time`).
- [ ] **Phase 3: Attachments & Documents**
- [ ] Inspect and download attachment resources.
- [ ] Wiki pages and project documents browsing.
- [ ] **Phase 4: Remote Transport**
- [ ] Add SSE (Server-Sent Events) transport option for remote deployments.
- [ ] **Phase 3 (Future): Binary Attachments & File Resources**
- [ ] Inspect and download binary attachment file contents via MCP resources.
@@ -0,0 +1,431 @@
# Docker Packaging & GitHub Actions CI/CD Pipeline Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Package `openproject-mcp` into an official, multi-platform Docker container image and build an automated GitHub Actions CI/CD pipeline that runs tests, builds multi-arch container images (`linux/amd64`, `linux/arm64`), and publishes them to GitHub Container Registry (`ghcr.io`).
**Architecture:** A multi-stage `Dockerfile` based on `oven/bun:1-slim` installs production dependencies in a builder stage, copies them into a minimal runner stage running as non-root user `bun`, and executes `src/index.ts` over stdio. A unified GitHub Actions workflow `.github/workflows/ci.yml` coordinates testing (`bun run typecheck`, `bun test`) with multi-arch Docker image compilation via QEMU and Buildx, publishing to GHCR on main branch commits and release tags.
**Tech Stack:** Docker, Bun (v1.3.14), GitHub Actions (`setup-bun`, `setup-qemu`, `setup-buildx`, `metadata-action`, `login-action`, `build-push-action`), GHCR.
**Spec:** `docs/specs/2026-09-06-docker-ci-pipeline-design.md`
## Global Constraints
- Runtime & Package Manager: Bun (>= 1.2 / 1.3), `bun test` for test execution.
- Base Container Image: `oven/bun:1-slim` with multi-stage build and unprivileged user `bun` (UID 1000).
- Transport: Interactive stdio execution (`ENTRYPOINT ["bun", "run", "src/index.ts"]`), allowing CLI flags like `--read-only` to be passed directly.
- Context Hygiene: `.dockerignore` must exclude `.env*`, `node_modules`, `tests`, `docs`, `scripts`, `.git`, and temporary files.
- CI Workflow: Single workflow file `.github/workflows/ci.yml` with `test` and `docker` jobs (`needs: test`).
- CI Platforms: Multi-arch `linux/amd64,linux/arm64` with GitHub Actions cache (`type=gha`).
- Stdio stream hygiene: Container stdout strictly reserved for JSON-RPC, diagnostics sent to stderr.
---
### Task 1: Multi-Stage Dockerfile & .dockerignore
**Files:**
- Create: `Dockerfile`
- Create: `.dockerignore`
- Create: `tests/docker.test.ts`
**Interfaces:**
- Consumes: `package.json`, `bun.lock`, `tsconfig.json`, `src/index.ts`
- Produces: Production Docker image artifact and local build verification test suite
- [ ] **Step 1: Write the failing test in `tests/docker.test.ts`**
```typescript
import { describe, test, expect } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const ROOT_DIR = resolve(import.meta.dir, "..");
describe("Docker Container Packaging", () => {
test("Dockerfile exists and defines multi-stage build with non-root user", () => {
const dockerfilePath = resolve(ROOT_DIR, "Dockerfile");
expect(existsSync(dockerfilePath)).toBe(true);
const content = readFileSync(dockerfilePath, "utf-8");
expect(content).toContain("FROM oven/bun:1-slim AS builder");
expect(content).toContain("FROM oven/bun:1-slim AS runner");
expect(content).toContain("bun install --frozen-lockfile --production");
expect(content).toContain("USER bun");
expect(content).toContain('ENTRYPOINT ["bun", "run", "src/index.ts"]');
});
test(".dockerignore exists and excludes sensitive and unnecessary files", () => {
const ignorePath = resolve(ROOT_DIR, ".dockerignore");
expect(existsSync(ignorePath)).toBe(true);
const content = readFileSync(ignorePath, "utf-8");
expect(content).toContain("node_modules");
expect(content).toContain(".env*");
expect(content).toContain(".git");
expect(content).toContain("tests");
expect(content).toContain("docs");
expect(content).toContain("scripts");
});
test("Docker image builds and starts up cleanly in container", async () => {
// Check if docker daemon is available in test environment
const proc = Bun.spawn(["which", "docker"], { stdout: "pipe", stderr: "pipe" });
const exitCode = await proc.exited;
if (exitCode !== 0) {
console.warn("Docker CLI not available, skipping container build execution test");
return;
}
// Build test image
const buildProc = Bun.spawn(["docker", "build", "-t", "openproject-mcp:test", "."], {
cwd: ROOT_DIR,
stdout: "pipe",
stderr: "pipe",
});
const buildExit = await buildProc.exited;
expect(buildExit).toBe(0);
// Run container with test env and check stderr banner
const runProc = Bun.spawn(
[
"docker",
"run",
"-i",
"--rm",
"-e",
"OPENPROJECT_BASE_URL=http://localhost:8080",
"-e",
"OPENPROJECT_API_KEY=test-token",
"openproject-mcp:test",
"--read-only",
],
{
cwd: ROOT_DIR,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
}
);
// Give it 1.5s to start and write banner to stderr
await Bun.sleep(1500);
runProc.kill();
await runProc.exited;
const stderrText = await new Response(runProc.stderr).text();
expect(stderrText).toContain("[openproject-mcp] Server started (readOnly=true)");
}, 60000);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test tests/docker.test.ts`
Expected: FAIL because `Dockerfile` and `.dockerignore` do not exist yet.
- [ ] **Step 3: Implement `Dockerfile` and `.dockerignore`**
Create `Dockerfile`:
```dockerfile
# Stage 1: Dependency resolution and production prune
FROM oven/bun:1-slim AS builder
WORKDIR /app
# Copy dependency manifests
COPY package.json bun.lock ./
# Install production dependencies only with strict lockfile
RUN bun install --frozen-lockfile --production
# Stage 2: Minimal runtime image
FROM oven/bun:1-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy installed production node_modules from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application configuration and source code
COPY package.json tsconfig.json ./
COPY src ./src
# Set ownership and drop root privileges
RUN chown -R bun:bun /app
USER bun
# Standard MCP stdio communication
ENTRYPOINT ["bun", "run", "src/index.ts"]
```
Create `.dockerignore`:
```
node_modules
.git
.github
.env*
coverage
tests
docs
scripts
.superpowers
*.log
*.md
docker-compose.yml
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bun test tests/docker.test.ts`
Expected: PASS (all 3 tests pass including local docker build and container execution).
- [ ] **Step 5: Commit**
```bash
git add Dockerfile .dockerignore tests/docker.test.ts
git commit -m "feat(docker): add multi-stage Dockerfile and .dockerignore"
```
---
### Task 2: GitHub Actions CI/CD Pipeline Workflow
**Files:**
- Create: `.github/workflows/ci.yml`
- Create: `tests/ci-workflow.test.ts`
**Interfaces:**
- Consumes: `Dockerfile`, `.dockerignore`, `package.json`, `tests/`
- Produces: Automated GitHub Actions CI workflow triggered on PRs, main pushes, tags, and workflow_dispatch
- [ ] **Step 1: Write the failing test in `tests/ci-workflow.test.ts`**
```typescript
import { describe, test, expect } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const ROOT_DIR = resolve(import.meta.dir, "..");
const WORKFLOW_PATH = resolve(ROOT_DIR, ".github/workflows/ci.yml");
describe("GitHub Actions CI Workflow", () => {
test("ci.yml exists and has proper triggers and permissions", () => {
expect(existsSync(WORKFLOW_PATH)).toBe(true);
const content = readFileSync(WORKFLOW_PATH, "utf-8");
// Triggers
expect(content).toContain("pull_request:");
expect(content).toContain("push:");
expect(content).toContain("workflow_dispatch:");
expect(content).toContain("- main");
expect(content).toContain("- 'v*.*.*'");
// Permissions
expect(content).toContain("permissions:");
expect(content).toContain("contents: read");
expect(content).toContain("packages: write");
});
test("ci.yml defines test job with Bun 1.3.14 and strict checks", () => {
const content = readFileSync(WORKFLOW_PATH, "utf-8");
expect(content).toContain("test:");
expect(content).toContain("runs-on: ubuntu-latest");
expect(content).toContain("oven-sh/setup-bun@v2");
expect(content).toContain("bun-version: 1.3.14");
expect(content).toContain("bun install --frozen-lockfile");
expect(content).toContain("bun run typecheck");
expect(content).toContain("bun test");
});
test("ci.yml defines docker job with multi-arch buildx and ghcr publishing", () => {
const content = readFileSync(WORKFLOW_PATH, "utf-8");
expect(content).toContain("docker:");
expect(content).toContain("needs: test");
expect(content).toContain("docker/setup-qemu-action@v3");
expect(content).toContain("docker/setup-buildx-action@v3");
expect(content).toContain("docker/metadata-action@v5");
expect(content).toContain("ghcr.io/${{ github.repository }}");
expect(content).toContain("docker/login-action@v3");
expect(content).toContain("registry: ghcr.io");
expect(content).toContain("docker/build-push-action@v6");
expect(content).toContain("platforms: linux/amd64,linux/arm64");
expect(content).toContain("push: ${{ github.event_name != 'pull_request' }}");
expect(content).toContain("type=gha");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test tests/ci-workflow.test.ts`
Expected: FAIL because `.github/workflows/ci.yml` does not exist yet.
- [ ] **Step 3: Implement `.github/workflows/ci.yml`**
Create `.github/workflows/ci.yml`:
```yaml
name: CI
on:
push:
branches:
- main
tags:
- 'v*.*.*'
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
test:
name: Test & Lint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Type check
run: bun run typecheck
- name: Run test suite
run: bun test
docker:
name: Build & Publish Docker Image
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
flavor: |
latest=auto
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bun test tests/ci-workflow.test.ts`
Expected: PASS (all tests pass).
- [ ] **Step 5: Commit**
```bash
git add .github/workflows/ci.yml tests/ci-workflow.test.ts
git commit -m "ci: create GitHub Actions workflow for testing and multi-arch Docker publishing"
```
---
### Task 3: Documentation, ADR-015 & Roadmap Tracking
**Files:**
- Modify: `docs/DECISIONS.md`
- Modify: `docs/TODO.md`
- Modify: `README.md`
**Interfaces:**
- Consumes: Docker packaging artifacts and CI workflow
- Produces: Updated architectural records, user documentation, and project roadmap tracking
- [ ] **Step 1: Add ADR-015 to `docs/DECISIONS.md`**
Append ADR-015 to `docs/DECISIONS.md`:
```markdown
## ADR-015: Docker Container Packaging and GitHub Actions CI/CD Pipeline
### Context
Users and client LLM applications (such as Claude Desktop, Cursor, or containerized agents) require a lightweight, zero-dependency method to run `openproject-mcp` without manually installing Bun or local development dependencies. Furthermore, pull requests and releases require automated validation to guarantee that tests pass and container images are securely built and published to GitHub Container Registry (`ghcr.io`).
### Decision
1. **Runtime Base Image**: Adopt `oven/bun:1-slim` with a multi-stage Docker build. The builder stage installs production dependencies (`bun install --frozen-lockfile --production`), and the runner stage copies only production node_modules and application sources.
2. **Security & Permissions**: Run container execution under the unprivileged `bun` user (UID 1000). Exclude sensitive environment files (`.env*`), git records, and local tokens via `.dockerignore`.
3. **Multi-Architecture**: Build container images for both `linux/amd64` and `linux/arm64` via Docker Buildx and QEMU.
4. **Interactive Stdio Execution**: Configure container `ENTRYPOINT ["bun", "run", "src/index.ts"]` with standard I/O streaming, allowing runtime options (such as `--read-only`) to be appended directly.
5. **Continuous Integration**: Implement a two-stage GitHub Actions workflow (`.github/workflows/ci.yml`):
- `test`: Executes `bun run typecheck` and `bun test` on PRs and main pushes.
- `docker`: Builds multi-arch images with `type=gha` cache, publishing to `ghcr.io` on pushes to `main` and release tags (`v*.*.*`), while validating builds without push on pull requests.
### Consequences
- Provides seamless Docker execution for desktop and server MCP clients via `docker run -i --rm ghcr.io/<owner>/openproject-mcp:latest`.
- Prevents container build regressions through automated PR verification.
- Guarantees multi-architecture compatibility across Apple Silicon and x86_64 host machines.
```
- [ ] **Step 2: Update `README.md` with Docker Quickstart**
Add a Docker section in `README.md` explaining:
- How to pull and run via `docker run -i --rm`
- Environment variables (`OPENPROJECT_BASE_URL`, `OPENPROJECT_API_KEY`)
- How to enable `--read-only` mode
- Configuration snippets for Claude Desktop and Cursor
- [ ] **Step 3: Update `docs/TODO.md`**
Record the Docker Container Packaging and GitHub Actions CI/CD Pipeline under completed tasks in `docs/TODO.md`.
- [ ] **Step 4: Run full test suite**
Run: `bun test`
Expected: PASS (all test files pass, 0 failures).
- [ ] **Step 5: Commit**
```bash
git add docs/DECISIONS.md docs/TODO.md README.md
git commit -m "docs: record ADR-015, update README with Docker usage, and update TODO.md"
```
@@ -0,0 +1,365 @@
# Hosted Remote MCP Server Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a hosted remote MCP server over HTTP/SSE (`Bun.serve`) allowing multiple users to connect from their AI clients (Cursor, Claude Desktop, web agents) with their own OpenProject API keys against a shared hosted OpenProject instance, complete with a `docker-compose.server.yml` deployment file.
**Architecture:** Extend `AppConfig` to support `port` and `host`. When `port` is provided, `src/index.ts` boots an HTTP server (`src/http-server.ts`) via `Bun.serve`. Incoming `GET /sse` connections extract the user's OpenProject API key from `Authorization: Bearer`, `X-OpenProject-Api-Key`, or `?apiKey=`, rejecting missing keys with HTTP 401. Each valid connection establishes an isolated session with its own `OpenProjectClient`, and incoming `POST /messages` JSON-RPC requests execute tools inside `runWithContext`. Provide a `docker-compose.server.yml` file and document client setups in `README.md`.
**Tech Stack:** Bun (v1.3.14), TypeScript, `@modelcontextprotocol/sdk`, Server-Sent Events (SSE), Docker Compose.
**Spec:** `docs/specs/2026-09-06-hosted-remote-server-design.md`
## Global Constraints
- Runtime & Package Manager: Bun (>= 1.2 / 1.3), `bun test` for test execution.
- Strict TypeScript: `strict: true`, `"noUncheckedIndexedAccess": true`, no `any`.
- Multi-Tenant Credential Scoping: User credentials must never be stored in global mutable variables. Each session must use its own `OpenProjectClient` inside `runWithContext`.
- Credential Extraction Precedence:
1. `Authorization: Bearer <key>`
2. `X-OpenProject-Api-Key: <key>`
3. `?apiKey=<key>` query parameter
- Rejection: Missing API keys must return HTTP 401 with JSON error `{ error: "..." }`.
- Backward Compatibility: Default behavior when `PORT` is not set must remain local stdio transport.
- Stream Hygiene: All logging in stdio mode must use `console.error`. In HTTP mode, standard server logging to stderr/console is permitted.
---
### Task 1: Configuration Extension for HTTP/SSE Transport
**Files:**
- Modify: `src/config/index.ts`
- Modify: `tests/client.test.ts` (or add tests in `tests/config.test.ts`)
**Interfaces:**
- Consumes: `process.env`, CLI `process.argv`
- Produces: Updated `AppConfig` interface and `loadConfig()` function supporting `port?: number`, `host?: string`, and optional `apiKey` when `port` is set.
- [x] **Step 1: Write failing tests in `tests/config.test.ts`**
```typescript
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { loadConfig } from "../src/config/index.ts";
describe("Configuration Loader (HTTP & Stdio Support)", () => {
const originalEnv = { ...process.env };
const originalArgv = [...process.argv];
beforeEach(() => {
process.env = { ...originalEnv };
process.argv = [...originalArgv.slice(0, 2)];
});
afterEach(() => {
process.env = originalEnv;
process.argv = originalArgv;
});
test("loads stdio configuration when PORT is unset and OPENPROJECT_API_KEY is provided", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
process.env.OPENPROJECT_API_KEY = "test-key";
delete process.env.PORT;
const config = loadConfig();
expect(config.baseUrl).toBe("https://openproject.example.com");
expect(config.apiKey).toBe("test-key");
expect(config.readOnly).toBe(false);
expect(config.port).toBeUndefined();
});
test("throws error if OPENPROJECT_API_KEY is missing in stdio mode", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
delete process.env.OPENPROJECT_API_KEY;
delete process.env.PORT;
expect(() => loadConfig()).toThrow();
});
test("loads HTTP configuration when PORT environment variable is set without API key", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
delete process.env.OPENPROJECT_API_KEY;
process.env.PORT = "3000";
const config = loadConfig();
expect(config.baseUrl).toBe("https://openproject.example.com");
expect(config.port).toBe(3000);
expect(config.apiKey).toBeUndefined();
});
test("parses --port and --host CLI arguments", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
delete process.env.OPENPROJECT_API_KEY;
delete process.env.PORT;
process.argv.push("--port", "8080", "--host", "127.0.0.1", "--read-only");
const config = loadConfig();
expect(config.port).toBe(8080);
expect(config.host).toBe("127.0.0.1");
expect(config.readOnly).toBe(true);
});
});
```
- [x] **Step 2: Run test to verify it fails**
Run: `bun test tests/config.test.ts`
Expected: FAIL because `port`, `host`, and optional `apiKey` logic are not yet implemented in `src/config/index.ts`.
- [x] **Step 3: Update `src/config/index.ts`**
Update `AppConfig` and `loadConfig` in `src/config/index.ts`:
- Add `port?: number` and `host?: string` to `AppConfig`.
- In `loadConfig`:
- Check for `--port <number>` in `process.argv` or `process.env.PORT`.
- Check for `--host <string>` in `process.argv` or `process.env.HOST`.
- If `port` is defined, `apiKey` is optional (does not throw if missing).
- If `port` is undefined, require `apiKey` as before.
- [x] **Step 4: Run test to verify it passes**
Run: `bun test tests/config.test.ts`
Expected: PASS (all 4 tests pass).
- [x] **Step 5: Commit**
```bash
git add src/config/index.ts tests/config.test.ts
git commit -m "feat(config): support port and host options for HTTP/SSE transport"
```
---
### Task 2: Hosted HTTP/SSE Server Implementation (`src/http-server.ts`)
**Files:**
- Create: `src/http-server.ts`
- Create: `tests/http-server.test.ts`
**Interfaces:**
- Consumes: `src/config/index.ts` (`AppConfig`), `src/context.ts` (`runWithContext`), `src/client/api-client.ts` (`OpenProjectClient`), `src/tools/index.ts` (`registerAllTools`, `allTools`)
- Produces:
```typescript
export interface HttpServerInstance {
server: import("bun").Server<unknown>;
port: number;
stop(): Promise<void>;
}
export function startHttpServer(config: AppConfig): Promise<HttpServerInstance>;
```
- [x] **Step 1: Write failing tests in `tests/http-server.test.ts`**
```typescript
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { startHttpServer, type HttpServerInstance } from "../src/http-server.ts";
describe("Hosted Remote MCP Server (HTTP/SSE)", () => {
let instance: HttpServerInstance;
let serverUrl: string;
beforeAll(async () => {
instance = await startHttpServer({
baseUrl: "https://mock.openproject.example.com",
readOnly: true,
port: 0, // OS assigns random available port
host: "127.0.0.1",
});
serverUrl = `http://127.0.0.1:${instance.port}`;
});
afterAll(async () => {
await instance.stop();
});
test("GET /health returns HTTP 200 with server status", async () => {
const res = await fetch(`${serverUrl}/health`);
expect(res.status).toBe(200);
const data = (await res.json()) as { status: string; mode: string; openproject: string; readOnly: boolean };
expect(data.status).toBe("ok");
expect(data.mode).toBe("remote-mcp");
expect(data.openproject).toBe("https://mock.openproject.example.com");
expect(data.readOnly).toBe(true);
});
test("GET /sse without API key returns HTTP 401 Unauthorized", async () => {
const res = await fetch(`${serverUrl}/sse`);
expect(res.status).toBe(401);
const data = (await res.json()) as { error: string };
expect(data.error).toContain("Missing OpenProject API key");
});
test("GET /sse with X-OpenProject-Api-Key header initiates SSE stream", async () => {
const res = await fetch(`${serverUrl}/sse`, {
headers: {
"X-OpenProject-Api-Key": "test-key-header",
Accept: "text/event-stream",
},
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/event-stream");
const reader = res.body?.getReader();
expect(reader).toBeDefined();
const { value } = await reader!.read();
const text = new TextDecoder().decode(value);
expect(text).toContain("event: endpoint");
expect(text).toContain("/messages?sessionId=");
await reader!.cancel();
});
test("GET /sse with Authorization: Bearer header initiates SSE stream", async () => {
const res = await fetch(`${serverUrl}/sse`, {
headers: {
Authorization: "Bearer test-key-bearer",
Accept: "text/event-stream",
},
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/event-stream");
const reader = res.body?.getReader();
const { value } = await reader!.read();
const text = new TextDecoder().decode(value);
expect(text).toContain("event: endpoint");
await reader!.cancel();
});
test("GET /sse with ?apiKey= query parameter initiates SSE stream", async () => {
const res = await fetch(`${serverUrl}/sse?apiKey=test-key-query`, {
headers: {
Accept: "text/event-stream",
},
});
expect(res.status).toBe(200);
const reader = res.body?.getReader();
const { value } = await reader!.read();
const text = new TextDecoder().decode(value);
expect(text).toContain("event: endpoint");
await reader!.cancel();
});
test("POST /messages with invalid sessionId returns HTTP 404", async () => {
const res = await fetch(`${serverUrl}/messages?sessionId=non-existent-id`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "ping", id: 1 }),
});
expect(res.status).toBe(404);
});
});
```
- [x] **Step 2: Run test to verify it fails**
Run: `bun test tests/http-server.test.ts`
Expected: FAIL because `src/http-server.ts` does not exist yet.
- [x] **Step 3: Implement `src/http-server.ts`**
Implement `src/http-server.ts`:
- Parse API key from request:
- Check `req.headers.get("authorization")` (strip `Bearer `).
- Check `req.headers.get("x-openproject-api-key")`.
- Check `url.searchParams.get("apiKey")`.
- Implement endpoints:
- `/health`: Return JSON status.
- `/sse`: Validate key -> create session -> return `text/event-stream` with `/messages?sessionId=${sessionId}` endpoint.
- `/messages`: Validate `sessionId` -> route JSON-RPC message into session handler inside `runWithContext`.
- Cleanup sessions on client disconnect.
- Export `startHttpServer`.
- [x] **Step 4: Run test to verify it passes**
Run: `bun test tests/http-server.test.ts`
Expected: PASS (all tests pass).
- [x] **Step 5: Commit**
```bash
git add src/http-server.ts tests/http-server.test.ts
git commit -m "feat(http): implement hosted remote MCP server with HTTP/SSE transport"
```
---
### Task 3: CLI Entrypoint, Docker Compose Setup & Documentation
**Files:**
- Modify: `src/index.ts`
- Create: `docker-compose.server.yml`
- Modify: `docs/DECISIONS.md` (add ADR-016)
- Modify: `docs/TODO.md`
- Modify: `README.md`
**Interfaces:**
- Consumes: `src/http-server.ts`, `src/server.ts`, `src/config/index.ts`
- Produces: Unified CLI entrypoint supporting both stdio and HTTP transports, production Docker Compose configuration, and updated documentation.
- [x] **Step 1: Wire `src/index.ts` for dual-transport execution**
In `src/index.ts`:
- Load config via `loadConfig()`.
- If `config.port !== undefined`:
- Log to stderr: `[openproject-mcp] Starting hosted HTTP server on ${config.host || "0.0.0.0"}:${config.port}...`
- Await `startHttpServer(config)`.
- Else:
- Run existing stdio server lifecycle.
- [x] **Step 2: Create `docker-compose.server.yml`**
Create `docker-compose.server.yml`:
```yaml
version: "3.8"
services:
openproject-mcp:
image: ghcr.io/stereotypicalcat/openproject-mcp:latest
container_name: openproject-mcp-hosted
restart: unless-stopped
ports:
- "${HOST_PORT:-3000}:3000"
environment:
- OPENPROJECT_BASE_URL=${OPENPROJECT_BASE_URL:-https://openproject.example.com}
- PORT=3000
- OPENPROJECT_READ_ONLY=${OPENPROJECT_READ_ONLY:-false}
healthcheck:
test: ["CMD-SHELL", "bun -e 'fetch(\"http://localhost:3000/health\").then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))'"]
interval: 30s
timeout: 5s
retries: 3
```
- [x] **Step 3: Record ADR-016 in `docs/DECISIONS.md`**
Record ADR-016 documenting the hosted remote server architecture, credential extraction strategy, and session lifecycle.
- [x] **Step 4: Update `README.md` and `docs/TODO.md`**
In `README.md`:
- Add "Hosted Remote MCP Server (Docker Compose)" section showing how to deploy `docker-compose.server.yml`.
- Add remote MCP client configuration instructions for Cursor and Claude Desktop using the hosted URL (`https://mcp.company.com/sse`) and personal API key.
In `docs/TODO.md`:
- Mark Phase 4 (Remote Transport) completed.
- [x] **Step 5: Run full test suite and type check**
Run:
`bun run typecheck`
`bun test`
Expected: 0 type errors, all tests pass.
- [x] **Step 6: Commit**
```bash
git add src/index.ts docker-compose.server.yml docs/DECISIONS.md docs/TODO.md README.md
git commit -m "feat(server): wire HTTP transport into CLI, add docker-compose.server.yml, and update docs"
```
+560
View File
@@ -0,0 +1,560 @@
# MCP Stdio Server Assembly & Integration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Assemble the OpenProject MCP server with `StdioServerTransport`, scoped request context execution, read-only mode enforcement, and end-to-end client verification.
**Architecture:** A modular server factory (`src/server.ts`) initializes `McpServer` and `OpenProjectClient`, registers all 10 tools with execution wrappers running inside `runWithContext`, connects to `StdioServerTransport` in CLI entrypoint (`src/index.ts`), and redirects all diagnostics to `stderr`.
**Tech Stack:** Bun, TypeScript, `@modelcontextprotocol/sdk` (McpServer, StdioServerTransport, InMemoryTransport, Client), Zod, `bun:test`.
**Spec:** [docs/specs/2026-09-06-mcp-server-design.md](file:///home/user/openproject-mcp/docs/specs/2026-09-06-mcp-server-design.md)
## Global Constraints
- Bun runtime (>= 1.3), `bun test` for verification.
- Strict TypeScript (`strict: true`), no `any` (use explicit Zod types or `unknown`).
- Stateless execution: resolve ambient client via `RequestContext` (`runWithContext`).
- All server diagnostics and logging must use `console.error` (never `console.log` on stdout).
- Read-only mode must omit mutating tools from manifest and reject unauthorized operations with `SERVER_READ_ONLY`.
---
### Task 1: Execution Wrapper & Read-Only Guard in Tool Registration
**Files:**
- Modify: `src/tools/common.ts`
- Modify: `src/tools/index.ts`
- Modify: `tests/tools.test.ts`
**Interfaces:**
- Consumes: `src/tools/common.ts`, `src/tools/index.ts`
- Produces:
- `RegisterToolOptions`: `{ wrapExecute?: (fn: () => Promise<McpToolResponse>, tool: ToolDefinition<any>, args: Record<string, unknown>) => Promise<McpToolResponse> }`
- `registerTool(server, tool, options)`: passes execution through `wrapExecute` if supplied.
- `registerAllTools(server, options)`: forwards `wrapExecute` to each tool registration.
- [x] **Step 1: Write failing tests for tool execution wrapping**
In `tests/tools.test.ts`:
```typescript
describe("Tool Execution Wrapper", () => {
test("registerTool passes execution through wrapExecute when provided", async () => {
const server = new McpServer({ name: "test", version: "1" });
let wrapperCalled = false;
const dummyTool: ToolDefinition = {
name: "wrapped_tool",
description: "A wrapped tool",
readOnly: true,
execute: async () => ({ content: [{ type: "text", text: "success" }] }),
};
registerTool(server, dummyTool, {
wrapExecute: async (fn) => {
wrapperCalled = true;
return fn();
},
});
const registered = (server as unknown as { _registeredTools: Record<string, any> })._registeredTools["wrapped_tool"];
const result = await registered.handler({});
expect(wrapperCalled).toBe(true);
expect(result.content[0].text).toBe("success");
});
});
```
- [x] **Step 2: Run test to verify it fails**
Run: `bun test tests/tools.test.ts`
Expected: FAIL (options parameter or wrapExecute behavior not implemented).
- [x] **Step 3: Implement wrapExecute in `src/tools/common.ts` and `src/tools/index.ts`**
In `src/tools/common.ts`:
Update `registerTool`:
```typescript
export interface RegisterToolOptions<
TShape extends ZodRawShape = ZodRawShape,
TArgs = Record<string, unknown>,
> {
wrapExecute?: (
fn: () => Promise<McpToolResponse>,
tool: ToolDefinition<TShape, TArgs>,
args: TArgs
) => Promise<McpToolResponse>;
}
export function registerTool<
TShape extends ZodRawShape = ZodRawShape,
TArgs = Record<string, unknown>,
>(
server: McpServer,
tool: ToolDefinition<TShape, TArgs>,
options?: RegisterToolOptions<TShape, TArgs>
): void {
const executeFn = (args: TArgs) => {
if (options?.wrapExecute) {
return options.wrapExecute(() => tool.execute(args), tool, args);
}
return tool.execute(args);
};
if (tool.parameters) {
server.tool(tool.name, tool.description, tool.parameters, async (args) => {
return executeFn(args as unknown as TArgs);
});
} else {
server.tool(tool.name, tool.description, async () => {
return executeFn({} as unknown as TArgs);
});
}
}
```
In `src/tools/index.ts`:
Update `RegisterToolsOptions`:
```typescript
export interface RegisterToolsOptions {
readOnly?: boolean;
tools?: AnyToolDefinition[];
wrapExecute?: (
fn: () => Promise<McpToolResponse>,
tool: AnyToolDefinition,
args: Record<string, unknown>
) => Promise<McpToolResponse>;
}
```
And pass `options` to each tool registration.
- [x] **Step 4: Run test to verify it passes**
Run: `bun test tests/tools.test.ts`
Expected: PASS.
- [x] **Step 5: Commit**
```bash
git add src/tools/common.ts src/tools/index.ts tests/tools.test.ts
git commit -m "feat(tools): add execution wrapper support to tool registration"
```
---
### Task 2: Server Factory Implementation (`src/server.ts`)
**Files:**
- Create: `src/server.ts`
- Create: `tests/mcp-server.test.ts`
**Interfaces:**
- Consumes: `src/config/index.ts`, `src/client/api-client.ts`, `src/context.ts`, `src/tools/index.ts`, `@modelcontextprotocol/sdk`
- Produces:
- `OpenProjectMcpServer`: `{ server: McpServer; client: OpenProjectClient; start: (transport?: Transport) => Promise<void>; stop: () => Promise<void> }`
- `createServer(config: AppConfig): OpenProjectMcpServer`
- [x] **Step 1: Write failing tests for server factory**
In `tests/mcp-server.test.ts`:
```typescript
import { describe, expect, test } from "bun:test";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { createServer } from "../src/server";
describe("MCP Server Factory", () => {
test("creates server and lists all 10 tools via InMemoryTransport", async () => {
const config = {
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: false,
};
const mcpServer = createServer(config);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
const toolsResult = await client.listTools();
expect(toolsResult.tools).toHaveLength(10);
const names = toolsResult.tools.map((t) => t.name);
expect(names).toContain("openproject_list_projects");
expect(names).toContain("openproject_get_work_package");
await client.close();
await mcpServer.stop();
});
});
```
- [x] **Step 2: Run test to verify it fails**
Run: `bun test tests/mcp-server.test.ts`
Expected: FAIL with module `../src/server` not found.
- [x] **Step 3: Implement `src/server.ts`**
In `src/server.ts`:
```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { AppConfig } from "./config";
import { OpenProjectClient } from "./client/api-client";
import { runWithContext } from "./context";
import { registerAllTools } from "./tools";
import { formatToolError } from "./tools/common";
import { OpenProjectError } from "./client/errors";
export interface OpenProjectMcpServer {
server: McpServer;
client: OpenProjectClient;
start: (transport?: Transport) => Promise<void>;
stop: () => Promise<void>;
}
export const SERVER_NAME = "openproject-mcp";
export const SERVER_VERSION = "0.1.0";
export function createServer(config: AppConfig): OpenProjectMcpServer {
const server = new McpServer({
name: SERVER_NAME,
version: SERVER_VERSION,
});
const client = new OpenProjectClient({
baseUrl: config.baseUrl,
apiKey: config.apiKey,
});
// Register all tools with ambient context execution and read-only guards
registerAllTools(server, {
readOnly: config.readOnly,
wrapExecute: async (fn, tool) => {
if (config.readOnly && !tool.readOnly) {
return formatToolError(
new OpenProjectError(
"SERVER_READ_ONLY",
"Operation rejected. OpenProject MCP server is running in read-only mode."
)
);
}
return runWithContext({ client, isReadOnly: config.readOnly }, fn);
},
});
return {
server,
client,
start: async (transport?: Transport) => {
const activeTransport = transport ?? new StdioServerTransport();
await server.connect(activeTransport);
console.error(`[${SERVER_NAME}] Server started (readOnly=${config.readOnly})`);
},
stop: async () => {
await server.close();
console.error(`[${SERVER_NAME}] Server stopped`);
},
};
}
```
- [x] **Step 4: Run test to verify it passes**
Run: `bun test tests/mcp-server.test.ts`
Expected: PASS.
- [x] **Step 5: Commit**
```bash
git add src/server.ts tests/mcp-server.test.ts
git commit -m "feat(server): implement MCP server factory and transport lifecycle"
```
---
### Task 3: Read-Only Mode Enforcement & Guard Tests
**Files:**
- Create: `tests/read-only.test.ts`
**Interfaces:**
- Consumes: `src/server.ts`, `src/config/index.ts`, `src/tools/common.ts`, `@modelcontextprotocol/sdk`
- Produces: Complete verification of manifest filtering and execution guards under read-only mode.
- [x] **Step 1: Write read-only mode verification tests**
In `tests/read-only.test.ts`:
```typescript
import { describe, expect, test } from "bun:test";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { createServer } from "../src/server";
import { parseConfig } from "../src/config";
import type { ToolDefinition } from "../src/tools/common";
import { allTools } from "../src/tools";
describe("Read-Only Mode Enforcement", () => {
test("parseConfig parses read-only flags from env and argv", () => {
const configEnv = parseConfig({
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
OPENPROJECT_READ_ONLY: "true",
}, []);
expect(configEnv.readOnly).toBe(true);
const configArg = parseConfig({
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
}, ["--read-only"]);
expect(configArg.readOnly).toBe(true);
});
test("read-only server lists all 10 Phase 1 tools (all are readOnly: true)", async () => {
const mcpServer = createServer({
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: true,
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "client", version: "1" });
await client.connect(clientTransport);
const toolsResult = await client.listTools();
expect(toolsResult.tools).toHaveLength(10);
await client.close();
await mcpServer.stop();
});
test("read-only mode filters out mutating tools from tools/list", async () => {
const syntheticMutatingTool: ToolDefinition = {
name: "openproject_create_work_package_synthetic",
description: "Synthetic mutating tool",
readOnly: false,
execute: async () => ({ content: [{ type: "text", text: "created" }] }),
};
const mcpServer = createServer({
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: true,
});
// Manually register a mutating tool through the server to test filtering
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "client", version: "1" });
await client.connect(clientTransport);
const toolsResult = await client.listTools();
const names = toolsResult.tools.map((t) => t.name);
expect(names).not.toContain("openproject_create_work_package_synthetic");
await client.close();
await mcpServer.stop();
});
test("execution guard returns SERVER_READ_ONLY if mutating tool is called in read-only mode", async () => {
let executionAttempted = false;
const syntheticMutatingTool: ToolDefinition = {
name: "openproject_synthetic_write",
description: "Write tool",
readOnly: false,
execute: async () => {
executionAttempted = true;
return { content: [{ type: "text", text: "written" }] };
},
};
const mcpServer = createServer({
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: true,
});
// Register with mutating tool included
const { registerTool } = await import("../src/tools/common");
registerTool(mcpServer.server, syntheticMutatingTool, {
wrapExecute: async (fn, tool) => {
if (true && !tool.readOnly) {
const { OpenProjectError } = await import("../src/client/errors");
const { formatToolError } = await import("../src/tools/common");
return formatToolError(
new OpenProjectError("SERVER_READ_ONLY", "Operation rejected. OpenProject MCP server is running in read-only mode.")
);
}
return fn();
},
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "client", version: "1" });
await client.connect(clientTransport);
const callRes = await client.callTool({
name: "openproject_synthetic_write",
arguments: {},
});
expect(callRes.isError).toBe(true);
expect((callRes.content[0] as { text: string }).text).toContain("SERVER_READ_ONLY");
expect(executionAttempted).toBe(false);
await client.close();
await mcpServer.stop();
});
});
```
- [x] **Step 2: Run tests to verify they pass**
Run: `bun test tests/read-only.test.ts`
Expected: PASS (4 pass).
- [x] **Step 3: Commit**
```bash
git add tests/read-only.test.ts
git commit -m "feat(server): add read-only mode enforcement and execution guard tests"
```
---
### Task 4: CLI Entrypoint, End-to-End Live Tool Calling & TODO Checkoff
**Files:**
- Modify: `src/index.ts`
- Modify: `tests/mcp-server.test.ts`
- Modify: `docs/TODO.md`
**Interfaces:**
- Consumes: `src/server.ts`, `src/config/index.ts`, Live OpenProject 17 container
- Produces: Executable stdio server CLI, full E2E tool verification, and completed Phase 1 roadmap.
- [x] **Step 1: Add live container E2E tool calling tests to `tests/mcp-server.test.ts`**
In `tests/mcp-server.test.ts`:
```typescript
describe("End-to-End Live Tool Calling over MCP Client", () => {
const baseUrl = process.env.OPENPROJECT_BASE_URL || "http://localhost:8080";
const apiKey = process.env.OPENPROJECT_API_KEY || "";
test("calls openproject_list_projects and openproject_get_work_package via Client", async () => {
const mcpServer = createServer({ baseUrl, apiKey, readOnly: false });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-runner", version: "1" });
await client.connect(clientTransport);
// Call openproject_list_projects
const projRes = await client.callTool({
name: "openproject_list_projects",
arguments: { pageSize: 5 },
});
expect(projRes.isError).toBeUndefined();
const projData = JSON.parse((projRes.content[0] as { text: string }).text);
expect(projData.projects.length).toBeGreaterThan(0);
// Call openproject_get_work_package
const wpRes = await client.callTool({
name: "openproject_get_work_package",
arguments: { workPackageId: 38 },
});
expect(wpRes.isError).toBeUndefined();
const wpData = JSON.parse((wpRes.content[0] as { text: string }).text);
expect(wpData.id).toBe(38);
await client.close();
await mcpServer.stop();
});
test("tool error propagation returns isError: true without crashing client", async () => {
const mcpServer = createServer({ baseUrl, apiKey, readOnly: false });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-runner", version: "1" });
await client.connect(clientTransport);
const errRes = await client.callTool({
name: "openproject_get_project",
arguments: { projectId: 999999 },
});
expect(errRes.isError).toBe(true);
expect((errRes.content[0] as { text: string }).text).toContain("OPENPROJECT_NOT_FOUND");
await client.close();
await mcpServer.stop();
});
});
```
- [x] **Step 2: Implement production CLI entrypoint in `src/index.ts`**
In `src/index.ts`:
```typescript
#!/usr/bin/env bun
/**
* openproject-mcp - Model Context Protocol Server for OpenProject
*/
import { parseConfig } from "./config";
import { createServer } from "./server";
async function main(): Promise<void> {
try {
const config = parseConfig(process.env, process.argv);
const mcpServer = createServer(config);
// Bind graceful termination
const shutdown = async () => {
console.error("\n[openproject-mcp] Received termination signal, shutting down...");
await mcpServer.stop();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// Start server over stdio transport
await mcpServer.start();
} catch (error) {
console.error("[openproject-mcp] Fatal startup error:", error instanceof Error ? error.message : error);
process.exit(1);
}
}
// Only execute when invoked directly as a script
if (import.meta.main) {
main();
}
export { createServer } from "./server";
export { SERVER_NAME, SERVER_VERSION } from "./server";
```
- [x] **Step 3: Run full test suite**
Run: `bun test`
Expected: All tests pass across all 5 test files (`smoke`, `client`, `services`, `tools`, `mcp-server`, `read-only`).
- [x] **Step 4: Update `docs/TODO.md`**
Mark Task 4 as completed in `docs/TODO.md`.
- [x] **Step 5: Commit**
```bash
git add src/index.ts tests/mcp-server.test.ts docs/TODO.md
git commit -m "feat(server): wire CLI entrypoint, live E2E tool calling, and mark Task 4 complete in TODO.md"
```
+19 -24
View File
@@ -584,7 +584,7 @@ git commit -m "feat(tools): implement work package tools and registration"
- `queryTools`: ToolDefinition[]
- `registerQueryTools(server: McpServer): void`
- [ ] **Step 1: Write failing tests for query tools**
- [x] **Step 1: Write failing tests for query tools**
In `tests/tools.test.ts`:
```typescript
@@ -692,12 +692,12 @@ describe("Query Tools", () => {
});
```
- [ ] **Step 2: Run test to verify it fails**
- [x] **Step 2: Run test to verify it fails**
Run: `bun test tests/tools.test.ts`
Expected: FAIL with module `../src/tools/queries` not found.
- [ ] **Step 3: Implement query tools**
- [x] **Step 3: Implement query tools**
In `src/tools/queries.ts`:
```typescript
@@ -765,12 +765,12 @@ export function registerQueryTools(server: McpServer): void {
}
```
- [ ] **Step 4: Run test to verify it passes**
- [x] **Step 4: Run test to verify it passes**
Run: `bun test tests/tools.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**
```bash
git add src/tools/queries.ts tests/tools.test.ts
@@ -799,7 +799,7 @@ git commit -m "feat(tools): implement query tools and registration"
- `metadataTools`: ToolDefinition[]
- `registerMetadataTools(server: McpServer): void`
- [ ] **Step 1: Write failing tests for metadata tools**
- [x] **Step 1: Write failing tests for metadata tools**
In `tests/tools.test.ts`:
```typescript
@@ -919,12 +919,12 @@ describe("Metadata Tools", () => {
});
```
- [ ] **Step 2: Run test to verify it fails**
- [x] **Step 2: Run test to verify it fails**
Run: `bun test tests/tools.test.ts`
Expected: FAIL with module `../src/tools/metadata` not found.
- [ ] **Step 3: Implement metadata tools**
- [x] **Step 3: Implement metadata tools**
In `src/tools/metadata.ts`:
```typescript
@@ -1028,12 +1028,12 @@ export function registerMetadataTools(server: McpServer): void {
}
```
- [ ] **Step 4: Run test to verify it passes**
- [x] **Step 4: Run test to verify it passes**
Run: `bun test tests/tools.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**
```bash
git add src/tools/metadata.ts tests/tools.test.ts
@@ -1055,7 +1055,7 @@ git commit -m "feat(tools): implement metadata tools and registration"
- `registerAllTools(server: McpServer, options?: { readOnly?: boolean }): void`
- Re-exports of all individual tools, shapes, and handlers
- [ ] **Step 1: Write failing tests for tool registry**
- [x] **Step 1: Write failing tests for tool registry**
In `tests/tools.test.ts`:
```typescript
@@ -1094,12 +1094,12 @@ describe("Tool Registry", () => {
});
```
- [ ] **Step 2: Run test to verify it fails**
- [x] **Step 2: Run test to verify it fails**
Run: `bun test tests/tools.test.ts`
Expected: FAIL with module `../src/tools/index` not found.
- [ ] **Step 3: Implement tool registry**
- [x] **Step 3: Implement tool registry**
In `src/tools/index.ts`:
```typescript
@@ -1140,12 +1140,12 @@ export function registerAllTools(server: McpServer, options?: RegisterToolsOptio
}
```
- [ ] **Step 4: Run test to verify it passes**
- [x] **Step 4: Run test to verify it passes**
Run: `bun test tests/tools.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**
```bash
git add src/tools/index.ts tests/tools.test.ts
@@ -1159,12 +1159,7 @@ git commit -m "feat(tools): add tool registry and barrel export"
**Files:**
- Modify: `tests/tools.test.ts`
- Modify: `docs/TODO.md`
**Interfaces:**
- Consumes: Live OpenProject 17 container on `http://localhost:8080`, `OpenProjectClient`, `runWithContext`, `allTools`
- Produces: Live end-to-end integration tests verifying every tool against real OpenProject data.
- [ ] **Step 1: Add live integration tests for all 10 tools**
- [x] **Step 1: Add live integration tests for all 10 tools**
In `tests/tools.test.ts`:
```typescript
@@ -1260,16 +1255,16 @@ describe("Live Container Integration (All 10 MCP Tools)", () => {
});
```
- [ ] **Step 2: Run all tests against live container**
- [x] **Step 2: Run all tests against live container**
Run: `bun test`
Expected: All tests pass (client, smoke, services, tools).
- [ ] **Step 3: Update `docs/TODO.md`**
- [x] **Step 3: Update `docs/TODO.md`**
Mark Task 3 as completed in `docs/TODO.md`.
- [ ] **Step 4: Commit**
- [x] **Step 4: Commit**
```bash
git add tests/tools.test.ts docs/TODO.md
+678
View File
@@ -0,0 +1,678 @@
# OpenAPI Introspection Tool Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement the `openproject_get_openapi_spec` MCP tool and supporting `OpenApiService` to enable dynamic OpenAPI 3.0 introspection with in-memory caching and token-efficient scoping (summary, path, tag, schema, full).
**Architecture:** A domain service `getOpenApiSpec` fetches `GET /api/v3/openapi.json` using the ambient `OpenProjectClient`, caches the parsed ~1.18 MB document in memory by `baseUrl`, and filters/formats the response into compact JSON structures for LLM consumption. The tool definition in `src/tools/openapi.ts` registers `openproject_get_openapi_spec` with `readOnly: true`, plugged into `allTools`.
**Tech Stack:** Bun (v1.3.14), TypeScript (strict mode), `@modelcontextprotocol/sdk`, `zod`, `bun:test`.
**Spec:** `docs/specs/2026-09-06-openapi-tool-design.md`
## Global Constraints
- Runtime & Package Manager: Bun (>= 1.2 / 1.3), `bun test` for test execution.
- Strict TypeScript: `strict: true`, no `any` (use explicit types or `unknown` with type guards).
- Stateless execution & RequestContext: Resolve client using `resolveClient(client)` from `src/services/helper.ts`.
- In-memory caching: Cache parsed OpenAPI spec keyed by `client.baseUrl`, invalidated only when `refresh: true`.
- Stdio stream hygiene: All diagnostics and lifecycle logs use `console.error` (never `console.log`).
- Read-only compatibility: Tool marked `readOnly: true`, registered in both read-only and read-write modes.
---
### Task 1: OpenAPI Domain Service & In-Memory Caching
**Files:**
- Create: `src/services/openapi.ts`
- Modify: `src/services/index.ts`
- Create: `tests/openapi.test.ts`
**Interfaces:**
- Consumes: `src/services/helper.ts` (`resolveClient`), `src/client/api-client.ts` (`OpenProjectClient`, `OpenProjectNotFoundError`)
- Produces:
```typescript
export interface OpenApiQueryOptions {
path?: string;
tag?: string;
schema?: string;
full?: boolean;
refresh?: boolean;
}
export interface OpenApiSummary {
title: string;
version: string;
openapi: string;
totalPaths: number;
tags: Array<{ name: string; endpointCount: number }>;
availablePaths: string[];
instructions: string;
}
export async function getOpenApiSpec(
options?: OpenApiQueryOptions,
client?: OpenProjectClient
): Promise<unknown>;
export function clearOpenApiCache(): void;
```
- [x] **Step 1: Write failing service tests in `tests/openapi.test.ts`**
```typescript
import { describe, expect, test, beforeEach } from "bun:test";
import {
getOpenApiSpec,
clearOpenApiCache,
type OpenApiSummary,
} from "../src/services/openapi";
import { OpenProjectClient } from "../src/client/api-client";
import { OpenProjectNotFoundError } from "../src/client/errors";
describe("OpenApi Service", () => {
const mockSpec = {
openapi: "3.0.3",
info: { title: "OpenProject API V3 (Test)", version: "3" },
paths: {
"/api/v3/work_packages": {
get: {
summary: "List work packages",
operationId: "list_work_packages",
tags: ["Work Packages"],
parameters: [{ name: "offset", in: "query" }],
responses: { "200": { description: "OK" } },
},
post: {
summary: "Create work package",
operationId: "create_work_package",
tags: ["Work Packages"],
responses: { "201": { description: "Created" } },
},
},
"/api/v3/projects": {
get: {
summary: "List projects",
operationId: "list_projects",
tags: ["Projects"],
responses: { "200": { description: "OK" } },
},
},
},
components: {
schemas: {
WorkPackageModel: {
type: "object",
properties: { id: { type: "integer" }, subject: { type: "string" } },
},
},
},
};
let getCallCount = 0;
const mockClient = {
baseUrl: "http://mock-openproject.local",
get: async (path: string) => {
getCallCount++;
if (path.includes("openapi.json")) {
return mockSpec;
}
throw new Error("Not found");
},
} as unknown as OpenProjectClient;
beforeEach(() => {
clearOpenApiCache();
getCallCount = 0;
});
test("summary mode returns overview and instructions when no filters passed", async () => {
const result = (await getOpenApiSpec({}, mockClient)) as OpenApiSummary;
expect(result.title).toBe("OpenProject API V3 (Test)");
expect(result.version).toBe("3");
expect(result.openapi).toBe("3.0.3");
expect(result.totalPaths).toBe(2);
expect(result.tags).toHaveLength(2);
expect(result.tags[0]).toEqual({ name: "Projects", endpointCount: 1 });
expect(result.tags[1]).toEqual({ name: "Work Packages", endpointCount: 1 });
expect(result.availablePaths).toContain("/api/v3/work_packages");
expect(result.instructions).toContain("Call openproject_get_openapi_spec");
});
test("caches spec in memory and avoids duplicate HTTP GET calls", async () => {
await getOpenApiSpec({}, mockClient);
expect(getCallCount).toBe(1);
await getOpenApiSpec({}, mockClient);
expect(getCallCount).toBe(1); // Served from cache
await getOpenApiSpec({ refresh: true }, mockClient);
expect(getCallCount).toBe(2); // Evicted and re-fetched
});
test("path mode resolves exact and normalized path endpoints", async () => {
const res1 = (await getOpenApiSpec({ path: "/api/v3/work_packages" }, mockClient)) as {
path: string;
operations: Record<string, unknown>;
};
expect(res1.path).toBe("/api/v3/work_packages");
expect(res1.operations.get).toBeDefined();
expect(res1.operations.post).toBeDefined();
// Normalization test without /api/v3 prefix
const res2 = (await getOpenApiSpec({ path: "work_packages" }, mockClient)) as {
path: string;
operations: Record<string, unknown>;
};
expect(res2.path).toBe("/api/v3/work_packages");
});
test("path mode throws OpenProjectNotFoundError when path does not exist", async () => {
expect(getOpenApiSpec({ path: "/api/v3/nonexistent" }, mockClient)).rejects.toThrow(
OpenProjectNotFoundError
);
});
test("tag mode filters paths by functional domain tag", async () => {
const result = (await getOpenApiSpec({ tag: "Work Packages" }, mockClient)) as {
tag: string;
totalPaths: number;
paths: Record<string, unknown>;
};
expect(result.tag).toBe("Work Packages");
expect(result.totalPaths).toBe(1);
expect(result.paths["/api/v3/work_packages"]).toBeDefined();
expect(result.paths["/api/v3/projects"]).toBeUndefined();
});
test("tag mode throws OpenProjectNotFoundError when tag does not exist", async () => {
expect(getOpenApiSpec({ tag: "NonExistentTag" }, mockClient)).rejects.toThrow(
OpenProjectNotFoundError
);
});
test("schema mode extracts component model from components.schemas", async () => {
const result = (await getOpenApiSpec({ schema: "WorkPackageModel" }, mockClient)) as {
schemaName: string;
schema: { type: string; properties: Record<string, unknown> };
};
expect(result.schemaName).toBe("WorkPackageModel");
expect(result.schema.type).toBe("object");
expect(result.schema.properties.subject).toBeDefined();
});
test("schema mode throws OpenProjectNotFoundError when schema does not exist", async () => {
expect(getOpenApiSpec({ schema: "UnknownModel" }, mockClient)).rejects.toThrow(
OpenProjectNotFoundError
);
});
test("full mode returns the complete raw specification", async () => {
const result = (await getOpenApiSpec({ full: true }, mockClient)) as typeof mockSpec;
expect(result.openapi).toBe("3.0.3");
expect(result.paths).toBeDefined();
expect(result.components).toBeDefined();
});
});
```
- [x] **Step 2: Run test to verify it fails**
Run: `bun test tests/openapi.test.ts`
Expected: FAIL (Cannot find module `../src/services/openapi`).
- [x] **Step 3: Implement `src/services/openapi.ts` and export from `src/services/index.ts`**
In `src/services/openapi.ts`:
```typescript
import type { OpenProjectClient } from "../client/api-client";
import { OpenProjectNotFoundError } from "../client/errors";
import { resolveClient } from "./helper";
export interface OpenApiQueryOptions {
path?: string;
tag?: string;
schema?: string;
full?: boolean;
refresh?: boolean;
}
export interface OpenApiSummary {
title: string;
version: string;
openapi: string;
totalPaths: number;
tags: Array<{ name: string; endpointCount: number }>;
availablePaths: string[];
instructions: string;
}
interface RawOpenApiDoc {
openapi: string;
info: { title: string; version: string; description?: string };
paths: Record<string, Record<string, { tags?: string[]; summary?: string; operationId?: string; [key: string]: unknown }>>;
components?: {
schemas?: Record<string, unknown>;
[key: string]: unknown;
};
[key: string]: unknown;
}
// In-memory cache keyed by baseUrl
const openApiCache = new Map<string, RawOpenApiDoc>();
export function clearOpenApiCache(): void {
openApiCache.clear();
}
/**
* Normalizes an API path for flexible querying.
*/
function normalizePath(path: string): string {
let clean = path.trim();
if (!clean.startsWith("/")) {
clean = `/${clean}`;
}
if (!clean.startsWith("/api/v3")) {
clean = `/api/v3${clean}`;
}
return clean.toLowerCase();
}
/**
* Fetches and filters OpenProject OpenAPI 3.0 specification.
*/
export async function getOpenApiSpec(
options?: OpenApiQueryOptions,
client?: OpenProjectClient
): Promise<unknown> {
const activeClient = resolveClient(client);
const cacheKey = activeClient.baseUrl;
if (options?.refresh) {
openApiCache.delete(cacheKey);
}
let doc = openApiCache.get(cacheKey);
if (!doc) {
doc = await activeClient.get<RawOpenApiDoc>("openapi.json");
openApiCache.set(cacheKey, doc);
}
// 1. Full mode
if (options?.full) {
return doc;
}
const allPaths = Object.keys(doc.paths || {});
// 2. Specific path lookup
if (options?.path) {
const target = normalizePath(options.path);
const matchedKey = allPaths.find((p) => p.toLowerCase() === target) ||
allPaths.find((p) => p.toLowerCase().endsWith(target.replace(/^\/api\/v3/, "")));
if (!matchedKey || !doc.paths[matchedKey]) {
const sample = allPaths.slice(0, 10).join(", ");
throw new OpenProjectNotFoundError(
`OpenAPI path '${options.path}' not found. Available paths include: ${sample}...`,
{ errorIdentifier: "urn:openproject-org:api:v3:errors:NotFound" }
);
}
return {
path: matchedKey,
operations: doc.paths[matchedKey],
};
}
// 3. Tag filtering
if (options?.tag) {
const targetTag = options.tag.trim().toLowerCase();
const matchedPaths: Record<string, unknown> = {};
for (const [pathKey, methods] of Object.entries(doc.paths || {})) {
let pathMatched = false;
for (const op of Object.values(methods)) {
if (op && Array.isArray(op.tags) && op.tags.some((t: string) => t.toLowerCase() === targetTag)) {
pathMatched = true;
break;
}
}
if (pathMatched) {
matchedPaths[pathKey] = methods;
}
}
const matchedCount = Object.keys(matchedPaths).length;
if (matchedCount === 0) {
const availableTags = Array.from(
new Set(
Object.values(doc.paths || {}).flatMap((methods) =>
Object.values(methods).flatMap((op) => (Array.isArray(op?.tags) ? op.tags : []))
)
)
).sort();
throw new OpenProjectNotFoundError(
`OpenAPI tag '${options.tag}' not found. Available tags include: ${availableTags.slice(0, 15).join(", ")}...`,
{ errorIdentifier: "urn:openproject-org:api:v3:errors:NotFound" }
);
}
return {
tag: options.tag,
totalPaths: matchedCount,
paths: matchedPaths,
};
}
// 4. Schema model lookup
if (options?.schema) {
const targetSchema = options.schema.trim().toLowerCase();
const schemas = doc.components?.schemas || {};
const matchedKey = Object.keys(schemas).find((s) => s.toLowerCase() === targetSchema);
if (!matchedKey || !schemas[matchedKey]) {
const availableSchemas = Object.keys(schemas).slice(0, 15).join(", ");
throw new OpenProjectNotFoundError(
`OpenAPI schema '${options.schema}' not found in components.schemas. Available schemas include: ${availableSchemas}...`,
{ errorIdentifier: "urn:openproject-org:api:v3:errors:NotFound" }
);
}
return {
schemaName: matchedKey,
schema: schemas[matchedKey],
};
}
// 5. Default: Summary Overview
const tagCounts = new Map<string, number>();
for (const methods of Object.values(doc.paths || {})) {
const seenInPath = new Set<string>();
for (const op of Object.values(methods)) {
if (op && Array.isArray(op.tags)) {
for (const t of op.tags) {
seenInPath.add(t);
}
}
}
for (const t of seenInPath) {
tagCounts.set(t, (tagCounts.get(t) || 0) + 1);
}
}
const sortedTags = Array.from(tagCounts.entries())
.map(([name, endpointCount]) => ({ name, endpointCount }))
.sort((a, b) => a.name.localeCompare(b.name));
const summary: OpenApiSummary = {
title: doc.info?.title || "OpenProject API V3",
version: doc.info?.version || "3",
openapi: doc.openapi || "3.0.0",
totalPaths: allPaths.length,
tags: sortedTags,
availablePaths: allPaths,
instructions:
"Call openproject_get_openapi_spec with 'path' (e.g. '/api/v3/work_packages'), 'tag' (e.g. 'Work Packages'), or 'schema' (e.g. 'WorkPackageModel') to inspect detailed schemas and operations without requesting the full payload.",
};
return summary;
}
```
In `src/services/index.ts`:
Export `getOpenApiSpec`, `clearOpenApiCache`, `OpenApiQueryOptions`, `OpenApiSummary` from `./openapi`.
- [x] **Step 4: Run test to verify it passes**
Run: `bun test tests/openapi.test.ts`
Expected: PASS (9 pass).
- [x] **Step 5: Commit**
```bash
git add src/services/openapi.ts src/services/index.ts tests/openapi.test.ts
git commit -m "feat(services): implement OpenAPI specification service and in-memory caching"
```
---
### Task 2: MCP Tool Definition, Schema & Registration
**Files:**
- Create: `src/tools/openapi.ts`
- Modify: `src/tools/index.ts`
- Modify: `tests/openapi.test.ts`
**Interfaces:**
- Consumes: `src/services/openapi.ts` (`getOpenApiSpec`), `src/tools/common.ts` (`formatToolSuccess`, `formatToolError`, `ToolDefinition`)
- Produces:
```typescript
export const getOpenApiSpecSchema: ZodRawShape;
export const getOpenApiSpecTool: ToolDefinition;
export const openApiTools: ToolDefinition[];
export function registerOpenApiTools(server: McpServer, options?: RegisterToolOptions): void;
```
- [x] **Step 1: Add tool tests to `tests/openapi.test.ts`**
```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { runWithContext } from "../src/context";
import {
getOpenApiSpecTool,
registerOpenApiTools,
} from "../src/tools/openapi";
import { allTools } from "../src/tools";
describe("OpenApi MCP Tool Registration & Execution", () => {
test("getOpenApiSpecTool is marked readOnly: true and exposes proper schema", () => {
expect(getOpenApiSpecTool.name).toBe("openproject_get_openapi_spec");
expect(getOpenApiSpecTool.readOnly).toBe(true);
expect(getOpenApiSpecTool.parameters).toBeDefined();
});
test("executes openproject_get_openapi_spec tool via MCP Client inside RequestContext", async () => {
const server = new McpServer({ name: "openapi-test", version: "1.0.0" });
registerOpenApiTools(server);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "client", version: "1.0.0" });
await client.connect(clientTransport);
const res = (await runWithContext({ client: mockClient, isReadOnly: true }, async () => {
return client.callTool({
name: "openproject_get_openapi_spec",
arguments: { tag: "Work Packages" },
});
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(res.isError).toBeFalsy();
const data = JSON.parse(res.content[0]?.text ?? "{}");
expect(data.tag).toBe("Work Packages");
expect(data.totalPaths).toBe(1);
await client.close();
await server.close();
});
test("allTools includes openproject_get_openapi_spec (total: 11 tools)", () => {
const toolNames = allTools.map((t) => t.name);
expect(toolNames).toContain("openproject_get_openapi_spec");
expect(allTools).toHaveLength(11);
});
});
```
- [x] **Step 2: Run test to verify it fails**
Run: `bun test tests/openapi.test.ts`
Expected: FAIL (Cannot find module `../src/tools/openapi`).
- [x] **Step 3: Implement `src/tools/openapi.ts` and update `src/tools/index.ts`**
In `src/tools/openapi.ts`:
```typescript
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { getOpenApiSpec } from "../services/openapi";
import {
formatToolSuccess,
formatToolError,
registerTool,
type ToolDefinition,
type RegisterToolOptions,
} from "./common";
export const getOpenApiSpecSchema = {
path: z
.string()
.optional()
.describe("Specific endpoint path to inspect (e.g. '/api/v3/work_packages' or 'projects')."),
tag: z
.string()
.optional()
.describe("Tag or category to filter endpoints by (e.g. 'Work Packages', 'Queries', 'Projects')."),
schema: z
.string()
.optional()
.describe("Component schema model name to inspect (e.g. 'WorkPackageModel')."),
full: z
.boolean()
.optional()
.default(false)
.describe("Whether to return the complete raw OpenAPI 3.0 document. Warning: very large (~1.18 MB)."),
refresh: z
.boolean()
.optional()
.default(false)
.describe("Whether to bypass the in-memory cache and fetch a fresh specification from the server."),
};
export const getOpenApiSpecTool: ToolDefinition<typeof getOpenApiSpecSchema> = {
name: "openproject_get_openapi_spec",
description:
"Retrieve OpenProject API v3 OpenAPI 3.0 specification (/api/v3/openapi.json) for dynamic endpoint discovery, parameter schemas, and entity models. Supports smart filtering by path, tag, or schema, or returns a token-efficient summary overview when called without parameters.",
readOnly: true,
parameters: getOpenApiSpecSchema,
execute: async (args) => {
try {
const data = await getOpenApiSpec(args);
return formatToolSuccess(data);
} catch (error) {
return formatToolError(error);
}
},
};
export const openApiTools: ToolDefinition[] = [
getOpenApiSpecTool as unknown as ToolDefinition,
];
export function registerOpenApiTools(
server: McpServer,
options?: RegisterToolOptions
): void {
for (const tool of openApiTools) {
registerTool(server, tool, options);
}
}
```
In `src/tools/index.ts`:
Import `openApiTools` and `registerOpenApiTools` from `./openapi`.
Add `...openApiTools` to `allTools`.
Call `registerOpenApiTools(server, options)` in `registerAllTools`.
- [x] **Step 4: Run test to verify it passes**
Run: `bun test tests/openapi.test.ts`
Expected: PASS.
- [x] **Step 5: Commit**
```bash
git add src/tools/openapi.ts src/tools/index.ts tests/openapi.test.ts
git commit -m "feat(tools): add openproject_get_openapi_spec tool definition and registration"
```
---
### Task 3: Live Container Integration, Suite Regression Updates & Roadmap Checkoff
**Files:**
- Modify: `tests/openapi.test.ts`
- Modify: `tests/tools.test.ts`
- Modify: `tests/read-only.test.ts`
- Modify: `tests/mcp-server.test.ts`
- Modify: `docs/TODO.md`
**Interfaces:**
- Consumes: Live OpenProject 17 container on `http://localhost:8080`, `src/server.ts`, `allTools`
- Produces: Updated test suites expecting 11 tools, end-to-end verified live OpenAPI querying, and updated project tracking.
- [x] **Step 1: Add live container integration test to `tests/openapi.test.ts`**
```typescript
describe("Live Container OpenAPI Integration", () => {
const baseUrl = process.env.OPENPROJECT_BASE_URL || "http://localhost:8080";
const apiKey = process.env.OPENPROJECT_API_KEY || "";
test("live: getOpenApiSpec returns live OpenProject 17 summary", async () => {
const liveClient = new OpenProjectClient({ baseUrl, apiKey });
const summary = (await getOpenApiSpec({}, liveClient)) as OpenApiSummary;
expect(summary.title).toContain("OpenProject API");
expect(summary.totalPaths).toBeGreaterThan(200);
expect(summary.tags.length).toBeGreaterThan(30);
expect(summary.availablePaths).toContain("/api/v3/work_packages");
});
test("live: getOpenApiSpec filters by path /api/v3/work_packages", async () => {
const liveClient = new OpenProjectClient({ baseUrl, apiKey });
const result = (await getOpenApiSpec({ path: "/api/v3/work_packages" }, liveClient)) as {
path: string;
operations: Record<string, { summary?: string; parameters?: Array<{ name: string }> }>;
};
expect(result.path).toBe("/api/v3/work_packages");
expect(result.operations.get).toBeDefined();
const paramNames = result.operations.get?.parameters?.map((p) => p.name);
expect(paramNames).toContain("offset");
expect(paramNames).toContain("pageSize");
});
});
```
- [x] **Step 2: Update tool count expectations in existing tests**
- In `tests/tools.test.ts`:
- `allTools contains exactly 10 Phase 1 tools` -> update to `allTools contains exactly 11 tools including OpenAPI spec` and `expect(allTools).toHaveLength(11)`.
- `registerAllTools registers all 10 tools on McpServer` -> update length expectation from 10 to 11.
- `registerAllTools filters out non-readOnly tools when readOnly is true` -> update length expectation from 10 to 11.
- In `tests/read-only.test.ts`:
- `read-only server lists all 10 Phase 1 tools` -> update expectation from 10 to 11.
- `read-only mode filters out mutating tools from tools/list` -> update expectation from 10 to 11.
- In `tests/mcp-server.test.ts`:
- `read-only mode filters tools and maintains read-only status in context` -> update length expectation from 10 to 11.
- [x] **Step 3: Run full test suite**
Run: `bun test`
Expected: All tests pass cleanly across all test files (`smoke`, `client`, `services`, `tools`, `mcp-server`, `read-only`, `openapi`).
- [x] **Step 4: Update `docs/TODO.md`**
Add `openproject_get_openapi_spec` tool under completed roadmap and record OpenAPI tool implementation.
- [x] **Step 5: Commit**
```bash
git add tests/openapi.test.ts tests/tools.test.ts tests/read-only.test.ts tests/mcp-server.test.ts docs/TODO.md
git commit -m "test(openapi): add live container tests, update tool count assertions, and update TODO.md"
```
@@ -0,0 +1,868 @@
# Meetings, Wikis, and Work Package Activities Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extend the OpenProject MCP server with 7 new read-only tools covering Meetings (listing, inspection, and deep search), Wikis (page retrieval, smart discovery search, and links), and Work Package Activities (history and comments filtering).
**Architecture:** Implement domain services in `src/services/meetings.ts`, `src/services/wikis.ts`, and `src/services/work-packages.ts`. Implement corresponding MCP tool modules in `src/tools/meetings.ts`, `src/tools/wikis.ts`, and `src/tools/work-packages.ts`. Register all tools into `src/tools/index.ts` and `src/services/index.ts`, expanding total tools from 11 to 18. All tools are strictly `readOnly: true`.
**Tech Stack:** Bun (v1.3+), TypeScript (strict mode), `@modelcontextprotocol/sdk`, `zod`, native `fetch`, `bun:test`.
**Spec:** [docs/specs/2026-09-07-meetings-wikis-activities-tools-design.md](../specs/2026-09-07-meetings-wikis-activities-tools-design.md)
## Global Constraints
- Target runtime is Bun (>= 1.2/1.3). Commands use `bun test` and `bun run`.
- No external HTTP libraries; use native `fetch` via `OpenProjectClient`.
- Tool handlers and domain services must retrieve client via ambient `resolveClient(client)`.
- All 7 new tools are marked `readOnly: true` and must remain registered in read-only mode.
- Output JSON must be clean and token-efficient for LLM consumption.
---
### Task 1: Domain Service: Work Package Activities & Comments
**Files:**
- Modify: `src/services/work-packages.ts`
- Test: `tests/work-package-activities.test.ts`
**Interfaces:**
- Consumes: `resolveClient` from `src/services/helper.ts`, `OpenProjectClient` from `src/client/api-client.ts`.
- Produces:
```typescript
export interface ActivityDetail {
format: string;
raw: string;
html?: string;
}
export interface WorkPackageActivity {
id: number;
version: number;
createdAt: string;
user?: { id: number; name: string };
comment?: string;
details: ActivityDetail[];
isComment: boolean;
}
export interface ListWorkPackageActivitiesOptions {
workPackageId: number;
onlyComments?: boolean;
}
export async function listWorkPackageActivities(
options: ListWorkPackageActivitiesOptions,
client?: OpenProjectClient
): Promise<WorkPackageActivity[]>;
```
- [ ] **Step 1: Write failing unit tests for `listWorkPackageActivities`**
```typescript
// tests/work-package-activities.test.ts
import { describe, expect, test } from "bun:test";
import { OpenProjectClient } from "../src/client/api-client.ts";
import { listWorkPackageActivities } from "../src/services/work-packages.ts";
describe("Work Package Activities Service", () => {
test("parses activities and separates comments from field changes", async () => {
const mockClient = {
get: async (path: string) => {
expect(path).toBe("/api/v3/work_packages/38/activities");
return {
_type: "Collection",
total: 2,
count: 2,
_embedded: {
elements: [
{
_type: "Activity",
id: 60,
version: 1,
createdAt: "2026-09-05T14:24:56Z",
comment: { raw: "", html: "" },
details: [
{ format: "custom", raw: "Status set to In progress" }
],
_links: {
user: { href: "/api/v3/users/2", title: "Admin User" }
}
},
{
_type: "Activity",
id: 61,
version: 2,
createdAt: "2026-09-05T15:00:00Z",
comment: { raw: "Fixed in commit abc1234", html: "<p>Fixed</p>" },
details: [],
_links: {
user: { href: "/api/v3/users/3", title: "Dev User" }
}
}
]
}
};
}
} as unknown as OpenProjectClient;
const all = await listWorkPackageActivities({ workPackageId: 38 }, mockClient);
expect(all).toHaveLength(2);
expect(all[0].isComment).toBe(false);
expect(all[1].isComment).toBe(true);
expect(all[1].comment).toBe("Fixed in commit abc1234");
expect(all[1].user?.name).toBe("Dev User");
const commentsOnly = await listWorkPackageActivities({ workPackageId: 38, onlyComments: true }, mockClient);
expect(commentsOnly).toHaveLength(1);
expect(commentsOnly[0].id).toBe(61);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test tests/work-package-activities.test.ts`
Expected: FAIL (`listWorkPackageActivities` is not exported from `src/services/work-packages.ts`).
- [ ] **Step 3: Implement `listWorkPackageActivities` in `src/services/work-packages.ts`**
Add interfaces and `listWorkPackageActivities`:
```typescript
export interface ActivityDetail {
format: string;
raw: string;
html?: string;
}
export interface WorkPackageActivity {
id: number;
version: number;
createdAt: string;
user?: { id: number; name: string };
comment?: string;
details: ActivityDetail[];
isComment: boolean;
}
export interface ListWorkPackageActivitiesOptions {
workPackageId: number;
onlyComments?: boolean;
}
export async function listWorkPackageActivities(
options: ListWorkPackageActivitiesOptions,
client?: OpenProjectClient
): Promise<WorkPackageActivity[]> {
const opClient = resolveClient(client);
const response = await opClient.get<Record<string, unknown>>(
`/api/v3/work_packages/${options.workPackageId}/activities`
);
const elements = (response._embedded as { elements?: unknown[] })?.elements ?? [];
const activities: WorkPackageActivity[] = [];
for (const item of elements) {
const rawItem = item as Record<string, unknown>;
const commentObj = rawItem.comment as { raw?: string } | undefined;
const commentRaw = typeof commentObj?.raw === "string" ? commentObj.raw.trim() : "";
const isComment = commentRaw.length > 0;
if (options.onlyComments && !isComment) {
continue;
}
const links = (rawItem._links as Record<string, { href?: string; title?: string }>) ?? {};
const userLink = links.user;
let user: { id: number; name: string } | undefined;
if (userLink?.href) {
const match = userLink.href.match(/\/users\/(\d+)/);
if (match) {
user = {
id: Number(match[1]),
name: userLink.title ?? `User #${match[1]}`
};
}
}
const rawDetails = (rawItem.details as Array<{ format?: string; raw?: string; html?: string }>) ?? [];
const details: ActivityDetail[] = rawDetails.map((d) => ({
format: d.format ?? "custom",
raw: d.raw ?? "",
html: d.html
}));
activities.push({
id: Number(rawItem.id),
version: Number(rawItem.version ?? 1),
createdAt: String(rawItem.createdAt ?? ""),
user,
comment: commentRaw || undefined,
details,
isComment
});
}
return activities;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bun test tests/work-package-activities.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/services/work-packages.ts tests/work-package-activities.test.ts
git commit -m "feat: add listWorkPackageActivities domain service"
```
---
### Task 2: Domain Service: Meetings
**Files:**
- Create: `src/services/meetings.ts`
- Test: `tests/meetings.test.ts`
**Interfaces:**
- Consumes: `resolveClient`, `resolveProjectId` from `src/services/helper.ts`, `OpenProjectClient` from `src/client/api-client.ts`.
- Produces:
```typescript
export interface MeetingSummary {
id: number;
title: string;
state: string;
startTime: string;
endTime: string;
duration?: string;
project: { id: number; name: string };
location?: string;
author?: { id: number; name: string };
}
export interface AgendaItemOutcome {
id: number;
notes: string;
}
export interface AgendaItem {
id: number;
title: string;
durationInMinutes?: number;
notes?: string;
itemType: string;
position: number;
author?: { id: number; name: string };
presenter?: { id: number; name: string };
section?: { id: number; title: string };
outcomes: AgendaItemOutcome[];
}
export interface MeetingDetail extends MeetingSummary {
template: boolean;
notify: boolean;
participants: Array<{ id: number; name: string }>;
agendaItems?: AgendaItem[];
sections?: Array<{ id: number; title: string; position: number }>;
}
export interface MeetingSearchResult {
meeting: MeetingSummary;
matchType: "title" | "location" | "agenda_item";
matchedAgendaItems?: Array<{
id: number;
title: string;
snippet?: string;
}>;
}
export async function listMeetings(
params?: { projectId?: string | number; time?: string; offset?: number; pageSize?: number },
client?: OpenProjectClient
): Promise<PaginatedResult<MeetingSummary>>;
export async function getMeeting(
id: number,
options?: { includeAgendaItems?: boolean },
client?: OpenProjectClient
): Promise<MeetingDetail>;
export async function searchMeetings(
params: { query: string; projectId?: string | number; offset?: number; pageSize?: number },
client?: OpenProjectClient
): Promise<PaginatedResult<MeetingSearchResult>>;
```
- [ ] **Step 1: Write failing tests for Meetings service**
```typescript
// tests/meetings.test.ts
import { describe, expect, test } from "bun:test";
import { OpenProjectClient } from "../src/client/api-client.ts";
import { listMeetings, getMeeting, searchMeetings } from "../src/services/meetings.ts";
describe("Meetings Service", () => {
test("listMeetings serializes project filter and parses meeting items", async () => {
const mockClient = {
get: async (path: string) => {
expect(path).toContain("/api/v3/meetings");
return {
_type: "Collection",
total: 1,
count: 1,
pageSize: 20,
offset: 1,
_embedded: {
elements: [
{
_type: "Meeting",
id: 2,
title: "Weekly Planning",
state: "open",
startTime: "2026-09-08T00:22:53Z",
endTime: "2026-09-08T01:22:53Z",
duration: "PT1H",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo project" },
author: { href: "/api/v3/users/4", title: "Admin" }
}
}
]
}
};
}
} as unknown as OpenProjectClient;
const res = await listMeetings({ projectId: 1 }, mockClient);
expect(res.total).toBe(1);
expect(res.elements[0].title).toBe("Weekly Planning");
expect(res.elements[0].project.name).toBe("Demo project");
});
test("getMeeting fetches details and embeds agenda items", async () => {
const mockClient = {
get: async (path: string) => {
if (path === "/api/v3/meetings/2") {
return {
_type: "Meeting",
id: 2,
title: "Weekly Planning",
state: "open",
startTime: "2026-09-08T00:22:53Z",
endTime: "2026-09-08T01:22:53Z",
template: false,
notify: false,
_links: {
project: { href: "/api/v3/projects/1", title: "Demo project" },
participants: [{ href: "/api/v3/users/4", title: "Admin" }]
}
};
}
if (path === "/api/v3/meetings/2/agenda_items") {
return {
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
_type: "MeetingAgendaItem",
id: 10,
title: "Good news",
position: 1,
itemType: "simple",
durationInMinutes: 5,
notes: { raw: "What went well this week?" },
_embedded: { outcomes: [] },
_links: { outcomes: [] }
}
]
}
};
}
throw new Error(`Unexpected path: ${path}`);
}
} as unknown as OpenProjectClient;
const meeting = await getMeeting(2, { includeAgendaItems: true }, mockClient);
expect(meeting.title).toBe("Weekly Planning");
expect(meeting.agendaItems).toHaveLength(1);
expect(meeting.agendaItems![0].title).toBe("Good news");
expect(meeting.agendaItems![0].notes).toBe("What went well this week?");
});
test("searchMeetings finds matches in agenda notes", async () => {
const mockClient = {
get: async (path: string) => {
if (path.startsWith("/api/v3/meetings?")) {
return {
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
_type: "Meeting",
id: 2,
title: "Weekly Planning",
state: "open",
startTime: "2026-09-08T00:22:53Z",
endTime: "2026-09-08T01:22:53Z",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo project" }
}
}
]
}
};
}
if (path === "/api/v3/meetings/2/agenda_items") {
return {
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
_type: "MeetingAgendaItem",
id: 10,
title: "Sprint Review",
notes: { raw: "Discussed critical architecture improvements." }
}
]
}
};
}
throw new Error(`Unexpected path: ${path}`);
}
} as unknown as OpenProjectClient;
const res = await searchMeetings({ query: "architecture" }, mockClient);
expect(res.total).toBe(1);
expect(res.elements[0].matchType).toBe("agenda_item");
expect(res.elements[0].matchedAgendaItems![0].snippet).toContain("architecture");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test tests/meetings.test.ts`
Expected: FAIL (`src/services/meetings.ts` does not exist).
- [ ] **Step 3: Implement `src/services/meetings.ts`**
Write `src/services/meetings.ts` implementing `listMeetings`, `getMeeting`, and `searchMeetings` with clean helper parsers and ambient client resolution.
- [ ] **Step 4: Run test to verify it passes**
Run: `bun test tests/meetings.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/services/meetings.ts tests/meetings.test.ts
git commit -m "feat: add meetings domain service"
```
---
### Task 3: Domain Service: Wikis
**Files:**
- Create: `src/services/wikis.ts`
- Test: `tests/wikis.test.ts`
**Interfaces:**
- Consumes: `resolveClient`, `resolveProjectId` from `src/services/helper.ts`, `OpenProjectClient` from `src/client/api-client.ts`.
- Produces:
```typescript
export interface WikiPageAttachment {
id: number;
fileName: string;
fileSize: number;
contentType: string;
downloadUrl: string;
}
export interface WikiPageDetail {
id: number;
title: string;
project: { id: number; identifier?: string; name?: string };
attachments: WikiPageAttachment[];
}
export interface WikiPageSummary {
id: number;
title: string;
project: { id: number; identifier?: string; name?: string };
attachmentsCount: number;
}
export interface WikiPageLink {
id: number;
pageTitle?: string;
pageUrl?: string;
provider?: string;
workPackage?: { id: number; title?: string };
}
export async function getWikiPage(
id: number,
client?: OpenProjectClient
): Promise<WikiPageDetail>;
export async function listWikiPageLinks(
params?: { workPackageId?: number; offset?: number; pageSize?: number },
client?: OpenProjectClient
): Promise<PaginatedResult<WikiPageLink>>;
export async function searchWikiPages(
params?: { query?: string; projectId?: string | number; limit?: number },
client?: OpenProjectClient
): Promise<WikiPageSummary[]>;
```
- [ ] **Step 1: Write failing tests for Wikis service**
```typescript
// tests/wikis.test.ts
import { describe, expect, test } from "bun:test";
import { OpenProjectClient } from "../src/client/api-client.ts";
import { getWikiPage, listWikiPageLinks, searchWikiPages } from "../src/services/wikis.ts";
describe("Wikis Service", () => {
test("getWikiPage retrieves page and attaches files", async () => {
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path === "/api/v3/wiki_pages/1") {
return {
_type: "WikiPage",
id: 1,
title: "Project Wiki",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo Project" }
}
};
}
if (path === "/api/v3/wiki_pages/1/attachments") {
return {
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
_type: "Attachment",
id: 12,
fileName: "architecture.png",
fileSize: 45000,
contentType: "image/png",
_links: {
downloadLocation: { href: "/api/v3/attachments/12/content" }
}
}
]
}
};
}
throw new Error(`Unexpected path: ${path}`);
}
} as unknown as OpenProjectClient;
const page = await getWikiPage(1, mockClient);
expect(page.id).toBe(1);
expect(page.title).toBe("Project Wiki");
expect(page.project.id).toBe(1);
expect(page.attachments).toHaveLength(1);
expect(page.attachments[0].fileName).toBe("architecture.png");
});
test("searchWikiPages matches discovered pages by title substring", async () => {
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path.startsWith("/api/v3/wiki_page_links")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path === "/api/v3/wiki_pages/1") {
return {
_type: "WikiPage",
id: 1,
title: "System Architecture",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } }
};
}
if (path === "/api/v3/wiki_pages/1/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path === "/api/v3/wiki_pages/2") {
return {
_type: "WikiPage",
id: 2,
title: "Sprint Retrospective",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } }
};
}
if (path === "/api/v3/wiki_pages/2/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
const err = new Error("Not found");
(err as unknown as { statusCode: number }).statusCode = 404;
throw err;
}
} as unknown as OpenProjectClient;
const results = await searchWikiPages({ query: "architecture" }, mockClient);
expect(results).toHaveLength(1);
expect(results[0].title).toBe("System Architecture");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test tests/wikis.test.ts`
Expected: FAIL (`src/services/wikis.ts` does not exist).
- [ ] **Step 3: Implement `src/services/wikis.ts`**
Write `src/services/wikis.ts` with in-memory caching scanner, ID discovery with consecutive 404 cutoff, project resolution, and attachments loading.
- [ ] **Step 4: Run test to verify it passes**
Run: `bun test tests/wikis.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/services/wikis.ts tests/wikis.test.ts
git commit -m "feat: add wikis domain service with caching discovery search"
```
---
### Task 4: MCP Tools: Work Package Activities Tool
**Files:**
- Modify: `src/tools/work-packages.ts`
- Modify: `tests/tools.test.ts`
**Interfaces:**
- Consumes: `listWorkPackageActivities` from `src/services/work-packages.ts`.
- Produces: `listWorkPackageActivitiesTool` registered in `workPackageTools`.
- [ ] **Step 1: Write test for `openproject_list_work_package_activities` in `tests/tools.test.ts`**
Add assertion verifying tool schema, parameters, and `readOnly: true`.
- [ ] **Step 2: Run test to verify failure**
Run: `bun test tests/tools.test.ts`
Expected: FAIL (tool not defined in `workPackageTools`).
- [ ] **Step 3: Define tool in `src/tools/work-packages.ts`**
Export `listWorkPackageActivitiesTool`:
- Name: `openproject_list_work_package_activities`
- Description: "Retrieve timeline activities and comments for a work package. Can optionally filter to only include user comments."
- Parameters: `workPackageId` (`z.number()`), `onlyComments` (`z.boolean().optional().default(false)`).
- `readOnly: true`.
- Add to `workPackageTools` array.
- [ ] **Step 4: Run tests to verify pass**
Run: `bun test tests/tools.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/tools/work-packages.ts tests/tools.test.ts
git commit -m "feat: add openproject_list_work_package_activities MCP tool"
```
---
### Task 5: MCP Tools: Meetings Tools
**Files:**
- Create: `src/tools/meetings.ts`
- Test: `tests/tools.test.ts`
**Interfaces:**
- Consumes: `listMeetings`, `getMeeting`, `searchMeetings` from `src/services/meetings.ts`.
- Produces: `meetingsTools` array containing:
- `openproject_list_meetings`
- `openproject_get_meeting`
- `openproject_search_meetings`
- [ ] **Step 1: Write tests in `tests/tools.test.ts` verifying the three meetings tools**
Assert `meetingsTools` exists, exports the 3 tools, and all are `readOnly: true`.
- [ ] **Step 2: Run test to verify failure**
Run: `bun test tests/tools.test.ts`
Expected: FAIL (`src/tools/meetings.ts` does not exist).
- [ ] **Step 3: Implement `src/tools/meetings.ts`**
Define the 3 tools with Zod input schemas and handlers invoking domain service functions.
- [ ] **Step 4: Run test to verify pass**
Run: `bun test tests/tools.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/tools/meetings.ts tests/tools.test.ts
git commit -m "feat: add meetings MCP tools"
```
---
### Task 6: MCP Tools: Wikis Tools
**Files:**
- Create: `src/tools/wikis.ts`
- Test: `tests/tools.test.ts`
**Interfaces:**
- Consumes: `getWikiPage`, `searchWikiPages`, `listWikiPageLinks` from `src/services/wikis.ts`.
- Produces: `wikisTools` array containing:
- `openproject_get_wiki_page`
- `openproject_search_wiki_pages`
- `openproject_list_wiki_page_links`
- [ ] **Step 1: Write tests in `tests/tools.test.ts` verifying the three wikis tools**
Assert `wikisTools` exists, exports the 3 tools, and all are `readOnly: true`.
- [ ] **Step 2: Run test to verify failure**
Run: `bun test tests/tools.test.ts`
Expected: FAIL (`src/tools/wikis.ts` does not exist).
- [ ] **Step 3: Implement `src/tools/wikis.ts`**
Define the 3 tools with Zod input schemas and handlers invoking domain service functions.
- [ ] **Step 4: Run test to verify pass**
Run: `bun test tests/tools.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/tools/wikis.ts tests/tools.test.ts
git commit -m "feat: add wikis MCP tools"
```
---
### Task 7: Tool Registry & Server Integration
**Files:**
- Modify: `src/tools/index.ts`
- Modify: `src/services/index.ts`
- Modify: `tests/tools.test.ts`
- Modify: `tests/mcp-server.test.ts`
- Modify: `tests/read-only.test.ts`
- Modify: `tests/openapi.test.ts`
- Modify: `tests/http-server.test.ts`
**Interfaces:**
- Consumes: all tools from `meetings.ts`, `wikis.ts`, `work-packages.ts`.
- Produces: `allTools` array containing 18 tools total.
- [ ] **Step 1: Update registry in `src/tools/index.ts` and `src/services/index.ts`**
Import and add `meetingsTools` (3) and `wikisTools` (3) to `allTools`. Re-export all services in `src/services/index.ts`.
- [ ] **Step 2: Update all tool count assertions from 11 to 18**
Update `tests/tools.test.ts`, `tests/mcp-server.test.ts`, `tests/read-only.test.ts`, `tests/openapi.test.ts`, and `tests/http-server.test.ts` where total count `11` was asserted.
- [ ] **Step 3: Run entire test suite**
Run: `bun test`
Expected: PASS all tests across all test suites.
- [ ] **Step 4: Commit**
```bash
git add src/tools/index.ts src/services/index.ts tests/
git commit -m "feat: wire meetings, wikis, and activities tools into server registry (18 tools)"
```
---
### Task 8: Live Container Integration Tests
**Files:**
- Modify: `tests/services.test.ts`
- Modify: `tests/mcp-server.test.ts`
- [ ] **Step 1: Add live container tests in `tests/services.test.ts`**
Add tests for:
- `listMeetings` against live OpenProject container (verifying meetings 2..5).
- `getMeeting` verifying agenda items on meeting 2.
- `searchMeetings` verifying query "Weekly" matches.
- `getWikiPage` querying wiki page 1.
- `searchWikiPages` querying "Wiki".
- `listWorkPackageActivities` querying work package 38.
- [ ] **Step 2: Run live integration tests**
Run: `bun test tests/services.test.ts`
Expected: PASS with real container responses.
- [ ] **Step 3: Commit**
```bash
git add tests/services.test.ts tests/mcp-server.test.ts
git commit -m "test: add live container tests for meetings, wikis, and activities"
```
---
### Task 9: Documentation & Architectural Decision Records
**Files:**
- Modify: `docs/DECISIONS.md`
- Modify: `docs/ARCHITECTURE.md`
- Modify: `docs/TODO.md`
- Modify: `README.md`
- [ ] **Step 1: Record ADR-018 in `docs/DECISIONS.md`**
Document architecture for Meetings, Wikis, and Activities tools and the smart caching discovery strategy for wiki pages.
- [ ] **Step 2: Update `docs/ARCHITECTURE.md`**
Add Meetings and Wikis domains to the architecture guide and update tool listings.
- [ ] **Step 3: Update `docs/TODO.md`**
Mark the new tools as completed and update the roadmap.
- [ ] **Step 4: Update `README.md`**
Update tools catalog table with all 18 tools and descriptions.
- [ ] **Step 5: Run full verification suite**
Run: `bun test`
- [ ] **Step 6: Commit**
```bash
git add docs/DECISIONS.md docs/ARCHITECTURE.md docs/TODO.md README.md
git commit -m "docs: record ADR-018 and update documentation for meetings, wikis, and activities tools"
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,209 @@
# Specification: Docker Packaging & GitHub Actions CI/CD Pipeline
## 1. Overview & Objectives
This specification defines the Docker containerization and continuous integration (CI/CD) pipeline for `openproject-mcp`. The goal is to produce an official, multi-platform Docker container image published to GitHub Container Registry (`ghcr.io`) whenever changes land on `main` or semantic release tags are pushed, while validating code health and container build integrity on every pull request.
### Primary Objectives
1. **Container Packaging**: Provide a lean, secure, multi-stage `Dockerfile` and `.dockerignore` targeting the official `oven/bun:1-slim` runtime.
2. **Multi-Architecture Support**: Build for both `linux/amd64` and `linux/arm64` using Docker Buildx and QEMU.
3. **Least Privilege Security**: Run container processes under the unprivileged `bun` user (UID/GID 1000) and ensure credentials (`.env*`, tokens) are never included in the image layers.
4. **CI/CD Automation**: Create a unified GitHub Actions workflow (`.github/workflows/ci.yml`) that runs type checking (`tsc --noEmit`), test suites (`bun test`), and multi-arch Docker image compilation.
5. **Publishing Strategy**: Publish images to GitHub Container Registry (`ghcr.io/<owner>/<repo>`) with `latest`, commit SHA (`sha-<hash>`), and semantic version tags (`vX.Y.Z`, `X.Y`), while verifying pull requests with build-only checks without pushing.
6. **Documentation**: Update `README.md` with Docker execution instructions and record an Architectural Decision Record in `docs/DECISIONS.md`.
---
## 2. Container Architecture & Packaging
### 2.1 Base Image & Runtime Strategy
- **Base Image**: `oven/bun:1-slim` (Debian Bookworm slim base with Bun v1.x).
- **Rationale**:
- Official Bun Docker image maintained by Oven.
- Includes glibc and essential CA certificates needed for HTTPS communication with OpenProject instances.
- Slim footprint (~80-90 MB) compared to full OS images.
- Provides a built-in unprivileged user `bun` (UID 1000, GID 1000).
### 2.2 Multi-Stage Dockerfile (`Dockerfile`)
```dockerfile
# Stage 1: Dependency resolution and production prune
FROM oven/bun:1-slim AS builder
WORKDIR /app
# Copy dependency manifests
COPY package.json bun.lock ./
# Install production dependencies only with strict lockfile
RUN bun install --frozen-lockfile --production
# Stage 2: Minimal runtime image
FROM oven/bun:1-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy installed production node_modules from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application configuration and source code
COPY package.json tsconfig.json ./
COPY src ./src
# Set ownership and drop root privileges
RUN chown -R bun:bun /app
USER bun
# Standard MCP stdio communication
ENTRYPOINT ["bun", "run", "src/index.ts"]
```
### 2.3 Context Hygiene (`.dockerignore`)
The `.dockerignore` file prevents build context bloat and guarantees sensitive local files are excluded:
```
node_modules
.git
.github
.env*
coverage
tests
docs
scripts
.superpowers
*.log
*.md
docker-compose.yml
```
---
## 3. GitHub Actions CI/CD Pipeline (`.github/workflows/ci.yml`)
### 3.1 Pipeline Triggers & Permissions
- **Triggers**:
- `push`: branches `[main]`, tags `['v*.*.*']`
- `pull_request`: branches `[main]`
- `workflow_dispatch`: manual execution
- **Permissions**:
- `contents: read`
- `packages: write` (for publishing container images to GHCR)
### 3.2 Pipeline Jobs
```mermaid
graph TD
A[Trigger: Push / PR / Tag] --> B[Job 1: test]
B --> C[Setup Bun 1.3.14]
C --> D[bun install --frozen-lockfile]
D --> E[bun run typecheck]
E --> F[bun test]
F --> G{All Tests Passed?}
G -- Yes --> H[Job 2: docker]
G -- No --> Z[Fail Pipeline]
H --> I[Setup QEMU & Buildx]
H --> J[Compute Docker Metadata & Tags]
H --> K{Is PR?}
K -- Yes --> L[Build Multi-Arch (amd64, arm64) with GHA Cache - Push=False]
K -- No --> M[Login to GHCR with GITHUB_TOKEN]
M --> N[Build & Push Multi-Arch (amd64, arm64) to GHCR]
```
#### Job 1: `test`
- **Environment**: `ubuntu-latest`
- **Steps**:
1. `actions/checkout@v4`
2. `oven-sh/setup-bun@v2` with `bun-version: 1.3.14`
3. `bun install --frozen-lockfile`
4. `bun run typecheck` (`tsc --noEmit`)
5. `bun test` (all mock unit and integration tests execute; live container tests gracefully skip if environment variables are unset)
#### Job 2: `docker`
- **Depends on**: `test` (will not run if tests or typecheck fail)
- **Environment**: `ubuntu-latest`
- **Steps**:
1. `actions/checkout@v4`
2. `docker/setup-qemu-action@v3` (enables multi-arch emulation for `linux/amd64` and `linux/arm64`)
3. `docker/setup-buildx-action@v3`
4. `docker/metadata-action@v5`:
- Image base: `ghcr.io/${{ github.repository }}`
- Flavor: `latest=auto`
- Tags:
- `type=ref,event=branch`
- `type=ref,event=pr`
- `type=semver,pattern={{version}}`
- `type=semver,pattern={{major}}.{{minor}}`
- `type=sha,prefix=sha-`
- `type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}`
5. `docker/login-action@v3` (run only if `github.event_name != 'pull_request'`):
- Registry: `ghcr.io`
- Username: `${{ github.actor }}`
- Password: `${{ secrets.GITHUB_TOKEN }}`
6. `docker/build-push-action@v6`:
- Context: `.`
- File: `./Dockerfile`
- Platforms: `linux/amd64,linux/arm64`
- Push: `${{ github.event_name != 'pull_request' }}`
- Tags: `${{ steps.meta.outputs.tags }}`
- Labels: `${{ steps.meta.outputs.labels }}`
- Cache from: `type=gha`
- Cache to: `type=gha,mode=max`
---
## 4. Operational Usage
### 4.1 Running the Docker Image
Users can run the container image via interactive stdio transport:
```bash
docker run -i --rm \
-e OPENPROJECT_BASE_URL="https://openproject.example.com" \
-e OPENPROJECT_API_KEY="your-api-key" \
ghcr.io/<owner>/openproject-mcp:latest
```
Running in read-only mode:
```bash
docker run -i --rm \
-e OPENPROJECT_BASE_URL="https://openproject.example.com" \
-e OPENPROJECT_API_KEY="your-api-key" \
ghcr.io/<owner>/openproject-mcp:latest --read-only
```
### 4.2 MCP Client Configuration Example (Claude Desktop / Cursor)
```json
{
"mcpServers": {
"openproject": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e", "OPENPROJECT_BASE_URL=https://openproject.example.com",
"-e", "OPENPROJECT_API_KEY=your-api-key",
"ghcr.io/<owner>/openproject-mcp:latest"
]
}
}
}
```
---
## 5. Verification & Testing Plan
1. **Local Docker Build**: Run `docker build -t openproject-mcp:test .` to verify multi-stage build finishes with zero errors.
2. **Local Execution & Stdio Verification**:
- Run container with test environment variables:
```bash
docker run -i --rm \
-e OPENPROJECT_BASE_URL="http://localhost:8080" \
-e OPENPROJECT_API_KEY="test-token" \
openproject-mcp:test --read-only
```
- Verify that container starts as non-root user `bun`, logs `[openproject-mcp] Server started (readOnly=true)` to stderr, and keeps stdout clean for JSON-RPC messages.
3. **Workflow Syntax & Linting**:
- Validate `.github/workflows/ci.yml` YAML syntax and structure.
4. **Documentation Check**:
- Record ADR-015 in `docs/DECISIONS.md`.
- Update `README.md` with Docker usage guide.
- Update `docs/TODO.md` tracking the CI/CD pipeline completion.
@@ -0,0 +1,200 @@
# Specification: Hosted Remote MCP Server (HTTP/SSE Transport) & Multi-Tenant Scoping
## 1. Overview & Objectives
Currently, `openproject-mcp` runs as a local subprocess communicating via standard input/output (stdio) transports. Every user must have Docker or Bun installed locally and run their own MCP process.
This specification introduces a **Hosted Remote MCP Server** capability. Organizations can deploy a single hosted instance of `openproject-mcp` (e.g. at `https://mcp.example.com`). Individual users and their AI clients (Claude Desktop, Cursor, web agents) connect directly to the hosted server over the standard Model Context Protocol HTTP/SSE (Server-Sent Events) transport, supplying their own personal OpenProject API keys.
### Primary Objectives
1. **Hosted Multi-Tenant Remote Server**: Provide an HTTP/SSE server implementation based on native `Bun.serve` and `@modelcontextprotocol/sdk`.
2. **Dual-Transport Entrypoint**: Preserve existing local stdio operation by default; activate HTTP/SSE transport when `PORT` (e.g., `PORT=3000`) or `--port` is supplied.
3. **Flexible Credential Scoping**: Extract the user's OpenProject API key from incoming client requests via:
- `Authorization: Bearer <key>`
- `X-OpenProject-Api-Key: <key>`
- URL query parameter `?apiKey=<key>`
4. **Single-Instance Multi-User Isolation**: Target a shared OpenProject base URL (`OPENPROJECT_BASE_URL`) configured on the server, while strictly isolating each client's requests inside `RequestContext` (`AsyncLocalStorage`) using their respective personal API key.
5. **Unauthorized Rejection**: Return clean HTTP 401 Unauthorized responses if a client connects without an API key.
6. **Docker Compose Deployment**: Provide a `docker-compose.server.yml` file and `README.md` guide for hosting the server with health checks.
---
## 2. Architecture & Data Flow
```
+------------------------------------+ +------------------------------------+
| User A (Cursor / Claude Desktop) | | User B (Claude Desktop / Agent) |
| Headers: X-OpenProject-Api-Key: keyA| | URL: /sse?apiKey=keyB |
+-----------------+------------------+ +-----------------+------------------+
| (GET /sse) | (GET /sse)
+-----------------------+-------------------+
|
v
+------------------------------+
| Hosted openproject-mcp Server |
| (Bun.serve on PORT=3000) |
+--------------+---------------+
|
+-----------------+-----------------+
| Session A | Session B
| Client A (key: keyA) | Client B (key: keyB)
| McpServer A (runWithContext) | McpServer B (runWithContext)
+-----------------+-----------------+
|
v
+------------------------------+
| OpenProject 17 REST API v3 |
| (Shared OPENPROJECT_BASE_URL)|
+------------------------------+
```
### 2.1 HTTP Endpoints
1. **`GET /health`**:
- Responds with HTTP 200:
```json
{
"status": "ok",
"mode": "remote-mcp",
"openproject": "https://openproject.example.com",
"readOnly": false
}
```
- Used by load balancers, Kubernetes probes, and Docker health checks.
2. **`GET /sse`**:
- Establishes a persistent Server-Sent Events (SSE) connection with the client.
- Extracts the API key from:
1. `Authorization: Bearer <token>`
2. `X-OpenProject-Api-Key: <token>`
3. Query parameter `?apiKey=<token>`
- If missing: Rejects immediately with HTTP 401 and JSON error message:
```json
{ "error": "Missing OpenProject API key. Provide via Authorization header, X-OpenProject-Api-Key header, or ?apiKey= query parameter." }
```
- If present:
- Generates a cryptographically random `sessionId`.
- Creates an isolated `OpenProjectClient({ baseUrl, apiKey })`.
- Creates an `SSEServerTransport` bound to `/messages?sessionId=${sessionId}`.
- Creates an `McpServer` instance with all tools registered and wrapped with `runWithContext`.
- Stores the session in `activeSessions: Map<string, SessionData>`.
- Streams the initial `endpoint` SSE event to the client.
3. **`POST /messages`**:
- URL contains `?sessionId=<id>`.
- Looks up the session in `activeSessions`.
- If session not found: Returns HTTP 404 Not Found.
- Forwards the JSON-RPC request to the session transport's message handler, ensuring tool execution resolves the session's client via `runWithContext`.
4. **Session Eviction**:
- On SSE connection close or error, `transport.onclose` evicts the session from `activeSessions` to prevent memory leaks.
---
## 3. Configuration & CLI Flags
### Configuration Extension (`src/config/index.ts`)
```typescript
export interface AppConfig {
baseUrl: string;
apiKey?: string; // Optional when running in hosted HTTP mode
readOnly: boolean;
port?: number; // When present, launches HTTP/SSE server instead of stdio
host?: string; // Default: "0.0.0.0"
}
```
- When `port` is defined (`process.env.PORT` or `--port <num>`):
- `apiKey` is optional at server startup because individual connecting users supply their own keys.
- Server starts in HTTP mode.
- When `port` is undefined:
- Server runs in stdio mode (requires `OPENPROJECT_API_KEY` in environment).
---
## 4. Docker Compose Setup (`docker-compose.server.yml`)
A ready-to-run Docker Compose file for deploying the hosted MCP server:
```yaml
version: "3.8"
services:
openproject-mcp:
image: ghcr.io/stereotypicalcat/openproject-mcp:latest
container_name: openproject-mcp-hosted
restart: unless-stopped
ports:
- "3000:3000"
environment:
- OPENPROJECT_BASE_URL=https://openproject.example.com
- PORT=3000
- OPENPROJECT_READ_ONLY=false
healthcheck:
test: ["CMD-SHELL", "bun -e 'fetch(\"http://localhost:3000/health\").then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))'"]
interval: 30s
timeout: 5s
retries: 3
```
---
## 5. Client Configuration Examples
### Cursor
Add to `.cursor/mcp.json` (or Cursor Settings > Features > MCP):
```json
{
"mcpServers": {
"openproject": {
"url": "https://mcp.example.com/sse",
"headers": {
"X-OpenProject-Api-Key": "your-personal-api-key"
}
}
}
}
```
Or via URL query parameter:
```json
{
"mcpServers": {
"openproject": {
"url": "https://mcp.example.com/sse?apiKey=your-personal-api-key"
}
}
}
```
### Claude Desktop
In clients that connect to remote SSE servers via standard proxy or direct URL:
```json
{
"mcpServers": {
"openproject": {
"url": "https://mcp.example.com/sse?apiKey=your-personal-api-key"
}
}
}
```
---
## 6. Verification & Test Plan
1. **Unit & Protocol Tests (`tests/http-server.test.ts`)**:
- `GET /health` returns HTTP 200 with status info.
- `GET /sse` without credentials returns HTTP 401.
- `GET /sse` with `Authorization: Bearer <key>` succeeds and returns SSE stream.
- `GET /sse` with `X-OpenProject-Api-Key: <key>` succeeds.
- `GET /sse` with `?apiKey=<key>` succeeds.
- Concurrent clients with different API keys execute tools with their own respective credentials without bleed.
- Disconnecting client evicts session from memory.
2. **CLI & Entrypoint Tests**:
- `PORT=3000` boots the HTTP server.
- CLI flags `--port 3000` boots the HTTP server.
3. **Docker Compose Validation**:
- Validate `docker-compose.server.yml` configuration and verify container runs in HTTP mode.
4. **Documentation**:
- Record ADR-016 in `docs/DECISIONS.md`.
- Update `README.md` with Hosted Remote Server and Docker Compose deployment instructions.
+155
View File
@@ -0,0 +1,155 @@
# Design Specification: MCP Stdio Server Assembly & Integration
**Date**: 2026-09-06
**Status**: Approved
**Scope**: Phase 1 Task 4 - MCP Stdio Server Assembly & Integration
---
## 1. Executive Summary
This specification defines the assembly, transport binding, request lifecycle management, read-only enforcement, and logging safeguards for the `openproject-mcp` stdio server.
As the final milestone of Phase 1 (Read/Browse Implementation), Task 4 integrates the configuration layer (`src/config/`), client and request context (`src/client/`, `src/context.ts`), and MCP tools (`src/tools/`) into an executable Model Context Protocol server communicating over standard input/output (`StdioServerTransport`) using `@modelcontextprotocol/sdk`.
---
## 2. Architecture & Design Principles
### 2.1 Server Factory Pattern (`src/server.ts`)
To ensure testability and separation from process lifecycle concerns, the server is instantiated via a factory function:
```typescript
export interface OpenProjectMcpServer {
server: McpServer;
client: OpenProjectClient;
start: (transport?: Transport) => Promise<void>;
stop: () => Promise<void>;
}
export function createServer(config: AppConfig): OpenProjectMcpServer;
```
- **Configuration Injection**: Accepts validated `AppConfig` (`baseUrl`, `apiKey`, `readOnly`).
- **Scoped Client Lifecycle**: Instantiates an isolated `OpenProjectClient` for the session.
- **Context Binding**: Automatically wraps each incoming tool execution inside `runWithContext({ client, isReadOnly: config.readOnly }, ...)` so domain services and tools always resolve the active client without global state.
- **Pluggable Transport**: Default to `StdioServerTransport` for production, with support for `InMemoryTransport` in unit and integration tests.
### 2.2 CLI Entrypoint (`src/index.ts`)
The CLI entrypoint:
1. Validates environment variables and CLI arguments (`parseConfig(process.env, process.argv)`).
2. Initializes the server via `createServer(config)`.
3. Binds graceful termination handlers (`SIGINT`, `SIGTERM`).
4. Connects to `StdioServerTransport` and starts listening.
```
+-------------------------------------------------------------+
| MCP Client Host |
| (Claude Desktop, Cursor, Antigravity) |
+-------------------------------------------------------------+
|
stdio (stdin / stdout)
v
+-------------------------------------------------------------+
| src/index.ts / src/server.ts |
| - Parse AppConfig (OPENPROJECT_BASE_URL, API_KEY, ReadOnly)|
| - Instantiate McpServer & OpenProjectClient |
| - StdioServerTransport |
| - Execution Wrapper: runWithContextStorage |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| src/tools/ (10 Tools) |
| - Tool Definitions & Zod Parameter Validation |
| - Error Sanitization (formatToolSuccess / formatToolError) |
| - Read-Only Execution Guard |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| src/services/ & src/client/ |
| - projects, work-packages, queries, metadata |
| - Native fetch to OpenProject API v3 (Basic Auth) |
+-------------------------------------------------------------+
```
### 2.3 Read-Only Mode Enforcement
Per ADR-005 and AGENTS.md requirements:
1. **Tool Manifest Filtering**: When `config.readOnly` is `true`, mutating tools are excluded from registration so LLM clients never receive them in `tools/list`.
2. **Runtime Execution Guard**: In addition to manifest omission, any tool call executed while `isReadOnly` is `true` that attempts a non-read-only action is rejected with error code `SERVER_READ_ONLY`:
```json
{
"content": [
{
"type": "text",
"text": "Error [SERVER_READ_ONLY]: Operation rejected. OpenProject MCP server is running in read-only mode."
}
],
"isError": true
}
```
3. **Phase 1 Verification**: All 10 Phase 1 tools are read-only (`readOnly: true`), ensuring 100% availability in read-only mode while providing the enforcement harness for Phase 2 mutations.
### 2.4 Stdio Transport Integrity & Stderr Logging
In Model Context Protocol stdio transport:
- `stdout` is the communication channel for JSON-RPC messages. Any non-JSON text emitted to `stdout` will corrupt the transport and crash the MCP client.
- All diagnostics, banners, startup messages, and errors must be directed to `stderr`.
- `createServer` and `index.ts` ensure all startup logging uses `console.error`.
---
## 3. Interfaces & Contracts
### 3.1 `src/server.ts`
```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { AppConfig } from "./config";
import { OpenProjectClient } from "./client/api-client";
export interface OpenProjectMcpServer {
server: McpServer;
client: OpenProjectClient;
start: (transport?: Transport) => Promise<void>;
stop: () => Promise<void>;
}
export function createServer(config: AppConfig): OpenProjectMcpServer;
```
### 3.2 Tool Execution Wrapping in `src/tools/common.ts` & `src/tools/index.ts`
Enhance `registerAllTools` and `registerTool` with `wrapExecute`:
```typescript
export interface RegisterToolsOptions {
readOnly?: boolean;
tools?: AnyToolDefinition[];
wrapExecute?: (
fn: () => Promise<McpToolResponse>,
tool: AnyToolDefinition,
args: Record<string, unknown>
) => Promise<McpToolResponse>;
}
```
When `wrapExecute` is supplied:
1. Checks if `isReadOnly && !tool.readOnly`. If so, returns `formatToolError(new OpenProjectError("SERVER_READ_ONLY", "Operation rejected. OpenProject MCP server is running in read-only mode."))`.
2. Executes the tool inside `runWithContext({ client, isReadOnly }, fn)`.
---
## 4. Testing Strategy
1. **End-to-End Server Tests (`tests/mcp-server.test.ts`)**:
- Verify server initialization with `createServer(config)`.
- Connect client using `InMemoryTransport.createLinkedPair()`.
- Verify `client.listTools()` returns all 10 Phase 1 tools.
- Verify calling tools (`openproject_list_projects`, `openproject_get_work_package`) via `client.callTool()` executes successfully against live OpenProject 17 container.
- Verify invalid arguments return `isError: true` without throwing unhandled exceptions.
2. **Read-Only Mode Tests (`tests/read-only.test.ts`)**:
- Verify config parsing with `OPENPROJECT_READ_ONLY=true` and `--read-only`.
- Verify that all Phase 1 tools remain listed and functional.
- Verify that a synthetic mutating tool (`readOnly: false`) is omitted from `client.listTools()`.
- Verify that invoking a mutating tool triggers `SERVER_READ_ONLY` execution guard.
3. **Subprocess Stdio Verification**:
- Spawn `bun run src/index.ts` as a child process and verify startup banner on `stderr`.
+11
View File
@@ -54,6 +54,12 @@ Per ADR-003 and ADR-004, tool handlers do NOT store global client references. In
### 2.3 Read-Only Safety
All Phase 1 tools are read-only (`readOnly: true`). When `OPENPROJECT_READ_ONLY=true` or `--read-only` is active, read-only tools remain accessible while mutating tools (in Phase 2) are filtered out and rejected with `SERVER_READ_ONLY`.
### 2.4 OpenAPI Specification Support
OpenProject REST API v3 publishes a comprehensive, machine-readable OpenAPI 3.0 specification at `/api/v3/openapi.json`.
The MCP server architecture mandates first-class support for OpenAPI:
1. **Dynamic Schema Introspection**: Provide capability to fetch and query OpenProject's OpenAPI 3.0 specification so LLM agents can dynamically discover available endpoints, parameters, allowed filter operations, and custom field constraints.
2. **OpenAPI / JSON Schema Compatibility**: All MCP tool schemas and parameter definitions must strictly conform to OpenAPI 3.0 / JSON Schema specifications to ensure universal interoperability with MCP clients and LLM function-calling engines.
---
## 3. Tool Specifications
@@ -124,6 +130,11 @@ All tool names are prefixed with `openproject_` using `snake_case` in accordance
- `pageSize` (optional integer, 1-100, default: 20): Number of users per page.
- `offset` (optional integer, >= 1, default: 1): Page number to retrieve.
### 3.11 `openproject_get_openapi_spec`
- **Description**: Retrieve OpenProject API v3's OpenAPI 3.0 specification (`/api/v3/openapi.json`) for dynamic schema introspection, endpoint discovery, and entity definitions.
- **Parameters**:
- `path` (optional string): Specific endpoint path or tag to retrieve (e.g. `"/api/v3/work_packages"` or `"Work Packages"`) to conserve LLM context tokens, or omitted to retrieve full metadata.
---
## 4. MCP Response & Error Handling
@@ -0,0 +1,185 @@
# Specification: OpenProject OpenAPI Specification Tool (`openproject_get_openapi_spec`)
## 1. Overview & Objectives
OpenProject REST API v3 provides an instance-specific OpenAPI 3.0 specification at `GET /api/v3/openapi.json`. This dynamic document describes all API endpoints, parameters, request bodies, and schema models supported by the connected OpenProject instance.
However, the raw JSON document is approximately **1.18 MB** (over 220 path endpoints, 60+ tags, and dozens of component schemas). Returning this payload unconditionally to an LLM would consume ~300,000 tokens, degrading agent reasoning or causing token limit exceptions.
### Primary Objectives
1. Provide an MCP tool `openproject_get_openapi_spec` for dynamic endpoint and schema introspection.
2. Implement **Smart Scoping & Filtering** so agents can query:
- **Summary mode** (default): High-level overview of available tags and endpoints without blowing up the context window.
- **Path mode**: Exact or prefix match for a specific endpoint (e.g. `/api/v3/work_packages`).
- **Tag mode**: Operations grouped by functional domain (e.g. `"Work Packages"`, `"Projects"`).
- **Schema mode**: Component models from `components.schemas` (e.g. `"WorkPackageModel"`).
- **Full mode**: Complete raw document when explicitly requested (`full: true`).
3. Implement **In-Memory Caching** keyed by `baseUrl` to avoid re-fetching ~1.18 MB on every tool call, with an optional `refresh: true` cache bypass.
4. Mark the tool as `readOnly: true`, making it available in both read-only and read-write modes.
---
## 2. Architecture & Components
```
MCP Client (Claude / Antigravity / Cursor)
│
▼
McpServer (src/server.ts)
│
▼ wrapExecute (RequestContext)
openproject_get_openapi_spec (src/tools/openapi.ts)
│
▼
getOpenApiSpec (src/services/openapi.ts)
│
┌────────────┴────────────┐
▼ ▼
[In-Memory Cache] OpenProjectClient.get("openapi.json")
(by baseUrl) │
▼
OpenProject 17 API v3
```
### 2.1 Domain Service Layer (`src/services/openapi.ts`)
- **Query Options**:
```typescript
export interface OpenApiQueryOptions {
path?: string;
tag?: string;
schema?: string;
full?: boolean;
refresh?: boolean;
}
```
- **Caching Mechanism**:
- `const openApiCache = new Map<string, unknown>();`
- Cached parsed JSON object keyed by `client.baseUrl`.
- When `options.refresh` is true, the cached entry is evicted and a fresh `GET /api/v3/openapi.json` is performed.
- **Path Normalization**:
- Automatically handles `/api/v3` prefix differences (e.g. `work_packages`, `/work_packages`, and `/api/v3/work_packages` resolve identically).
- Performs case-insensitive matching.
- **Summary Extraction**:
- Generates an overview object containing `title`, `version`, `openapi`, `totalPaths`, `tags` (with endpoint counts), and an indexed list of available paths with query instructions.
### 2.2 MCP Tool Layer (`src/tools/openapi.ts`)
- Tool name: `openproject_get_openapi_spec`
- `readOnly: true`
- Zod schema:
- `path`: `z.string().optional()`
- `tag`: `z.string().optional()`
- `schema`: `z.string().optional()`
- `full`: `z.boolean().optional().default(false)`
- `refresh`: `z.boolean().optional().default(false)`
- Registration via `registerOpenApiTools` and integration in `allTools` (`src/tools/index.ts`).
---
## 3. Tool Parameters & Output Schemas
### 3.1 Tool Arguments
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `path` | string | No | - | Endpoint path to inspect (e.g. `"/api/v3/work_packages"` or `"projects"`). |
| `tag` | string | No | - | Tag / category to filter endpoints by (e.g. `"Work Packages"`, `"Queries"`). |
| `schema` | string | No | - | Component schema model name to inspect (e.g. `"WorkPackageModel"`). |
| `full` | boolean | No | `false` | When `true`, returns the complete raw OpenAPI document. |
| `refresh` | boolean | No | `false` | When `true`, forces cache invalidation and re-fetches from server. |
### 3.2 Success Output Formats
#### Summary Mode (Default when no filters provided):
```json
{
"title": "OpenProject API V3 (Stable)",
"version": "3",
"openapi": "3.0.3",
"totalPaths": 226,
"tags": [
{ "name": "Work Packages", "endpointCount": 8 },
{ "name": "Projects", "endpointCount": 12 },
{ "name": "Queries", "endpointCount": 5 }
],
"availablePaths": [
"/api/v3",
"/api/v3/actions",
"/api/v3/work_packages",
"/api/v3/work_packages/{id}"
],
"instructions": "Call openproject_get_openapi_spec with 'path' (e.g. '/api/v3/work_packages'), 'tag' (e.g. 'Work Packages'), or 'schema' (e.g. 'WorkPackageModel') to inspect specific schemas and operations."
}
```
#### Path Mode (`path: "/api/v3/work_packages"`):
```json
{
"path": "/api/v3/work_packages",
"operations": {
"get": {
"summary": "List work packages",
"operationId": "list_work_packages",
"tags": ["Work Packages"],
"description": "Returns a collection of work packages.",
"parameters": [...]
},
"post": {
"summary": "Create work package",
"operationId": "create_work_package",
"tags": ["Work Packages"],
"parameters": [...],
"requestBody": {...}
}
}
}
```
#### Tag Mode (`tag: "Queries"`):
```json
{
"tag": "Queries",
"totalPaths": 3,
"paths": {
"/api/v3/queries": { ... },
"/api/v3/queries/{id}": { ... },
"/api/v3/queries/{id}/results": { ... }
}
}
```
#### Schema Mode (`schema: "WorkPackageModel"`):
```json
{
"schemaName": "WorkPackageModel",
"schema": {
"type": "object",
"properties": { ... }
}
}
```
---
## 4. Error Handling
- Non-existent path: returns `Error [OPENPROJECT_NOT_FOUND]: OpenAPI path '<path>' not found in specification. Available paths include: [...]`
- Non-existent tag: returns `Error [OPENPROJECT_NOT_FOUND]: OpenAPI tag '<tag>' not found. Available tags include: [...]`
- Non-existent schema: returns `Error [OPENPROJECT_NOT_FOUND]: OpenAPI component schema '<schema>' not found. Available schemas include: [...]`
- API / Connection failures: captured by `OpenProjectError` and serialized as standard MCP `isError: true` responses.
---
## 5. Testing & Verification
1. **Unit & Mock Tests (`tests/openapi.test.ts`)**:
- Summary mode returns expected structure and instructions.
- Path lookup handles exact and normalized matches.
- Tag lookup filters paths properly.
- Schema lookup extracts matching component definition.
- Cache hit avoids duplicate HTTP requests; `refresh: true` triggers re-fetch.
- Validation rejects invalid argument types.
- 404 Not Found returns appropriate error messages for missing paths/tags/schemas.
2. **Live Container Tests (`tests/openapi.test.ts`)**:
- Live query against OpenProject 17 at `http://localhost:8080/api/v3/openapi.json`.
- Verifies live summary, path inspection (`/api/v3/work_packages`), and tag filtering (`"Work Packages"`).
3. **Existing Suite Updates**:
- Update `tests/tools.test.ts` and `tests/read-only.test.ts` tool count assertions from 10 to 11.
@@ -0,0 +1,229 @@
# Specification: Meetings, Wikis, and Work Package Activities MCP Tools
## 1. Overview & Objectives
This specification defines the extension of the `openproject-mcp` server with seven new read-only tools covering three domains:
1. **Meetings**: Listing meetings, detailed inspection of agendas/notes, and deep keyword search across meeting titles and agenda content.
2. **Wikis**: Retrieving wiki pages, smart caching discovery/search across wiki page titles, and listing wiki page links to work packages.
3. **Work Package Activities & Comments**: Browsing history and timeline events with dedicated comments-only filtering.
All 7 tools are strictly read-only (`readOnly: true`), preserving full functionality in read-only environments while expanding the MCP server catalog from 11 to 18 registered tools.
---
## 2. Architectural Design
```
MCP Client (Claude / Antigravity / Cursor)
│
▼
McpServer (src/server.ts)
│
▼ wrapExecute (RequestContext)
┌──────────────────┼──────────────────────────────┐
│ │ │
▼ ▼ ▼
Meetings Tools Wikis Tools Work Package Tools
(src/tools/ (src/tools/ (src/tools/
meetings.ts) wikis.ts) work-packages.ts)
│ │ │
▼ ▼ ▼
Meetings Service Wikis Service Work Packages Service
(src/services/ (src/services/ (src/services/
meetings.ts) wikis.ts) work-packages.ts)
│ │ │
│ ├─► In-Memory Discovery Cache │
│ │ │
└──────────────────┼──────────────────────────────┘
│
▼ OpenProjectClient
OpenProject 17 API v3
```
### 2.1 Meetings Domain (`src/services/meetings.ts` & `src/tools/meetings.ts`)
- **`listMeetings(params, client)`**:
- Accepts `projectId` (numeric ID or string identifier), `time` filter (e.g. `"upcoming"`, `"past"`), `offset`, and `pageSize`.
- Resolves string project identifiers to numeric IDs via `resolveProjectId`.
- Serializes OpenProject filter syntax: `[{"project_id":{"operator":"=","values":[...]}}]` and optional `time` filter.
- Calls `GET /api/v3/meetings`.
- Returns paginated `MeetingSummary` array:
```typescript
export interface MeetingSummary {
id: number;
title: string;
state: string;
startTime: string;
endTime: string;
duration?: string;
project: { id: number; name: string };
location?: string;
author?: { id: number; name: string };
}
```
- **`getMeeting(id, options, client)`**:
- Calls `GET /api/v3/meetings/{id}`.
- If `options.includeAgendaItems` is true (default: true), concurrently calls `GET /api/v3/meetings/{id}/agenda_items` and unpacks each item.
- Returns `MeetingDetail`:
```typescript
export interface AgendaItemOutcome {
id: number;
notes: string;
}
export interface AgendaItem {
id: number;
title: string;
durationInMinutes?: number;
notes?: string;
itemType: string;
position: number;
author?: { id: number; name: string };
presenter?: { id: number; name: string };
section?: { id: number; title: string };
outcomes: AgendaItemOutcome[];
}
export interface MeetingDetail extends MeetingSummary {
template: boolean;
notify: boolean;
participants: Array<{ id: number; name: string }>;
agendaItems?: AgendaItem[];
sections?: Array<{ id: number; title: string; position: number }>;
}
```
- **`searchMeetings(params, client)`**:
- Accepts `query` (required), optional `projectId`, `offset`, and `pageSize`.
- Fetches candidate meetings (scoped to `projectId` if provided).
- Performs case-insensitive matching against meeting `title` and `location`.
- Concurrently fetches agenda items for candidate meetings and inspects agenda item `title` and `notes`.
- Returns `MeetingSearchResult` list:
```typescript
export interface MeetingSearchResult {
meeting: MeetingSummary;
matchType: "title" | "location" | "agenda_item";
matchedAgendaItems?: Array<{
id: number;
title: string;
snippet?: string;
}>;
}
```
### 2.2 Wikis Domain (`src/services/wikis.ts` & `src/tools/wikis.ts`)
- **`getWikiPage(id, client)`**:
- Calls `GET /api/v3/wiki_pages/{id}`.
- Calls `GET /api/v3/wiki_pages/{id}/attachments` to embed attachments.
- Returns `WikiPageDetail`:
```typescript
export interface WikiPageAttachment {
id: number;
fileName: string;
fileSize: number;
contentType: string;
downloadUrl: string;
}
export interface WikiPageDetail {
id: number;
title: string;
project: { id: number; identifier?: string; name?: string };
attachments: WikiPageAttachment[];
}
```
- **`listWikiPageLinks(params, client)`**:
- If `workPackageId` is provided, queries `/api/v3/work_packages/{workPackageId}/wiki_page_links`.
- Otherwise, queries `/api/v3/wiki_page_links`.
- Returns `WikiPageLink` array:
```typescript
export interface WikiPageLink {
id: number;
pageTitle?: string;
pageUrl?: string;
provider?: string;
workPackage?: { id: number; title?: string };
}
```
- **`searchWikiPages(params, client)`**:
- Resolves optional `projectId` (numeric or identifier).
- Maintains an in-memory cache of discovered wiki pages keyed by `client.baseUrl` (`Map<number, WikiPageDetail>`).
- Discovery strategy:
1. Checks `wiki_page_links` to collect referenced wiki pages.
2. Batch-probes `/api/v3/wiki_pages/{id}` across sequential IDs (e.g. IDs 1..50 with consecutive 404 threshold stop).
3. Populates cache with discovered pages.
- Filters cached pages by `projectId` (if specified) and matches `query` against `page.title` (case-insensitive substring).
- Returns matching `WikiPageSummary` array.
### 2.3 Work Package Activities & Comments (`src/services/work-packages.ts`)
- **`listWorkPackageActivities(params, client)`**:
- Calls `GET /api/v3/work_packages/{workPackageId}/activities`.
- Parses each activity:
```typescript
export interface ActivityDetail {
format: string;
raw: string;
html?: string;
}
export interface WorkPackageActivity {
id: number;
version: number;
createdAt: string;
user?: { id: number; name: string };
comment?: string;
details: ActivityDetail[];
isComment: boolean;
}
```
- If `onlyComments: true`, filters out activities where `isComment` is false.
- Returns `PaginatedResult<WorkPackageActivity>`.
---
## 3. Tool Definitions & Zod Schemas
| Tool Name | Parameters | Returns | Description |
|-----------|------------|---------|-------------|
| `openproject_list_meetings` | `projectId` (optional string/number), `time` (optional string), `offset` (optional number), `pageSize` (optional number) | `PaginatedResult<MeetingSummary>` | List and filter meetings visible to the user. |
| `openproject_get_meeting` | `id` (required number), `includeAgendaItems` (optional boolean, default: true) | `MeetingDetail` | Retrieve detailed meeting information including agenda items, sections, and notes. |
| `openproject_search_meetings` | `query` (required string), `projectId` (optional string/number), `offset` (optional number), `pageSize` (optional number) | `PaginatedResult<MeetingSearchResult>` | Deep search across meeting titles, locations, and agenda item notes. |
| `openproject_get_wiki_page` | `id` (required number) | `WikiPageDetail` | Retrieve wiki page metadata, project, and attachments. |
| `openproject_search_wiki_pages` | `query` (optional string), `projectId` (optional string/number), `limit` (optional number, default: 20) | `WikiPageSummary[]` | Discover and search wiki pages matching keywords or project. |
| `openproject_list_wiki_page_links` | `workPackageId` (optional number), `offset` (optional number), `pageSize` (optional number) | `PaginatedResult<WikiPageLink>` | List links connecting work packages to wiki pages. |
| `openproject_list_work_package_activities` | `workPackageId` (required number), `onlyComments` (optional boolean, default: false) | `PaginatedResult<WorkPackageActivity>` | Retrieve timeline activities and comments for a work package. |
All 7 tools: `readOnly: true`.
---
## 4. Error Handling & Edge Cases
1. **Missing Resource (404)**:
- `openproject_get_meeting` and `openproject_get_wiki_page` throw `OpenProjectNotFoundError` with descriptive message when resource does not exist.
2. **Permission Denied (403)**:
- Preserves normalized `OpenProjectForbiddenError` when current API key lacks permission to view meetings or wiki pages in target project.
3. **Empty Agenda Items**:
- Meetings without agenda items return an empty array for `agendaItems: []` without failing.
4. **Consecutive 404s in Wiki Scanner**:
- The wiki scanner halts probing when encountering consecutive 404 responses (e.g. 5 consecutive misses), avoiding excessive HTTP requests.
5. **Project Identifier Resolution**:
- `projectId` can be passed as integer (e.g. `1`) or string identifier (e.g. `"demo-project"`), resolved transparently using `resolveProjectId`.
---
## 5. Testing & Verification
1. **Unit & Service Tests (`tests/services.test.ts`, `tests/meetings.test.ts`, `tests/wikis.test.ts`)**:
- Verify pagination, filters, and schema parsing against mocked HAL responses.
- Verify wiki scanner caching, project filtering, and title matching.
- Verify activities comments-only filtering.
2. **MCP Server Integration (`tests/tools.test.ts`, `tests/mcp-server.test.ts`, `tests/read-only.test.ts`)**:
- Verify registry has 18 tools total.
- Verify tool schemas and execution via MCP Client in `RequestContext`.
- Verify that all 18 tools remain registered under `OPENPROJECT_READ_ONLY=true`.
3. **Remote HTTP/SSE & OpenAPI Introspection (`tests/http-server.test.ts`)**:
- Verify `GET /openapi.json` publishes all 18 tool specifications.
- Verify `POST /api/tools/:toolName` executes the new tools.
4. **Live OpenProject 17 Container Tests**:
- Live meeting retrieval against project 1 (`demo-project`, meetings 2-5).
- Live wiki page inspection against page 1 (`Wiki`).
- Live work package activity inspection against work package 38.
@@ -0,0 +1,520 @@
# Specification: Fuzzy Search by Default Across MCP Search Tools
## 1. Overview & Objectives
Every search surface in `openproject-mcp` currently matches with case-insensitive
exact substring containment. An LLM that asks for `"budget aproval"`, `"approval of
the budget"`, or `"what did we decide about hiring"` gets zero results even when the
information is present, because none of those strings appear verbatim. The model then
reports that nothing exists.
This specification replaces exact substring matching with token-aware fuzzy matching
as the **default** behaviour across all search tools, adds relevance ranking, and
widens the set of content that is actually searched.
### Objectives
1. Tolerate typos, word reordering, partial words, and diacritic differences.
2. Rank results by relevance rather than returning them in arbitrary API order.
3. Search content that is currently ignored: wiki page body text, work package
descriptions and comments, meeting participants and project names.
4. Keep the cost of a single search call bounded and predictable.
5. Preserve an escape hatch (`matchMode: "exact"`) with byte-for-byte legacy behaviour.
6. Add zero runtime dependencies.
### Current Behaviour Being Replaced
| Tool | Matching today | Location |
| --- | --- | --- |
| `openproject_search_meetings` | Exact substring over title, location, agenda item title/notes, outcome notes | `src/services/meetings.ts:369` |
| `openproject_search_wiki_pages` | Exact substring over **title only** | `src/services/wikis.ts:257` |
| `openproject_list_work_packages` (`subject` arg) | Server-side OpenProject `~` filter on **subject only** | `src/client/filter-builder.ts:86` |
| `searchWorkPackages` service helper | Same; **not reachable from any MCP tool** — called only by tests | `src/services/work-packages.ts:79` |
| `openproject_list_work_package_activities` | No search capability | `src/services/work-packages.ts:119` |
---
## 2. Architectural Design
A new pure-computation unit `src/search/` sits beside the client layer. It has no
I/O, no dependency on `OpenProjectClient`, and no module-level state. Domain services
declare *what* to search; the search unit decides *how well* it matches.
```
MCP Client (Claude / Antigravity / Cursor)
│
▼
McpServer (src/server.ts)
│
▼ wrapExecute (RequestContext)
┌──────────────────┼──────────────────┬─────────────────────┐
▼ ▼ ▼ ▼
Meetings Tools Wikis Tools Work Package Tools (matchMode param
│ │ │ on every shape)
▼ ▼ ▼
Meetings Svc Wikis Svc Work Packages Svc
│ │ │
└──────────────────┴────────┬─────────┘
│ FieldSpec<T> declarations
▼
src/search/ (pure, no I/O)
├── tokenize.ts normalize + split
├── score.ts token & field scoring
├── rank.ts generic record ranking
├── snippet.ts match-centred excerpts
└── pipeline.ts two-phase orchestration
│
▼ (pipeline calls back into services)
OpenProjectClient
▼
OpenProject 17 API v3
```
### 2.1 Tokenization (`src/search/tokenize.ts`)
```typescript
export function normalizeText(text: string): string;
export function tokenize(text: string): string[];
```
- `normalizeText` lowercases, applies Unicode NFKD normalization, and strips combining
marks, so `Müller` matches `muller` and `café` matches `cafe`.
- `tokenize` splits normalized text on non-alphanumeric boundaries and discards empty
segments.
- Both query text and field text pass through the identical path. Any divergence
between the two sides silently breaks matching, so there is exactly one
implementation.
### 2.2 Scoring Primitives (`src/search/score.ts`)
```typescript
export function boundedLevenshtein(a: string, b: string, max: number): number;
export function scoreToken(queryToken: string, fieldToken: string): number;
export function scoreField(queryTokens: string[], fieldText: string): FieldScore;
export interface FieldScore {
score: number; // 0..1
matchedOffsets: number[]; // character offsets in fieldText, for snippets
}
```
**Token scoring rules**, applied in order, first match wins:
| Rule | Score | Guard |
| --- | --- | --- |
| Exact token equality | `1.0` | — |
| Field token starts with query token | `0.9` | query token length >= 3 |
| Field token contains query token | `0.75` | query token length >= 4 |
| Edit distance `d` within budget | `1 - (d / queryToken.length) * 0.6` | `d <= 1` for length < 6; `d <= 2` for length >= 6 |
| Otherwise | `0` | — |
The length guards are load-bearing. Without them `cat` -> `dog` is distance 3 and
every short token fuzzily matches every other short token, which produces noise the
LLM cannot distinguish from signal.
`boundedLevenshtein` is a two-row dynamic programming implementation with a diagonal
band and early exit: it abandons a pair as soon as the minimum of the current row
exceeds `max`. The overwhelmingly common case is a non-match, and this makes the
non-match case cheap.
**Field scoring** takes, for each query token, its best score against any field
token, then aggregates:
```
fieldScore = mean(bestScorePerQueryToken) * coverage
coverage = (count of query tokens with bestScore > 0) / (total query tokens)
```
Coverage is what prevents a two-word query from ranking a document that matched only
the common word. A phrase bonus of `+0.15` (result clamped to `1.0`) applies when the
full normalized query appears verbatim in the field, keeping literal matches on top of
merely fuzzy ones.
### 2.3 Record Ranking (`src/search/rank.ts`)
```typescript
export interface FieldSpec<T> {
name: string;
weight: number;
extract: (record: T) => string | string[] | undefined;
}
export interface FieldMatch {
field: string;
score: number;
snippet?: string;
}
export interface Ranked<T> {
record: T;
score: number; // 0..1, weighted across fields
matches: FieldMatch[]; // only fields that scored > 0, descending
}
export interface RankOptions {
minScore?: number; // default 0.35
limit?: number;
matchMode?: "fuzzy" | "exact"; // default "fuzzy"
}
export function rankRecords<T>(
records: T[],
query: string,
fields: FieldSpec<T>[],
opts?: RankOptions
): Ranked<T>[];
```
Record score is the best weighted field score, normalized so the result stays in
`0..1`:
```
recordScore = max over declared fields f of ( fieldScore(f) * weight(f) ) / maxWeight
maxWeight = max weight across all declared fields (not only matching ones)
```
Normalizing by the maximum *declared* weight rather than the best *matching* weight is
deliberate: it means a hit on a low-weight field (say `author.name` at 0.5) yields a
proportionally lower record score instead of being rescaled up to 1.0. Otherwise a
weak incidental match would tie with a perfect title match.
Results sort by score descending; **ties break by ascending
record ID** so that repeated identical searches return identically ordered results. A
model that re-runs a search and sees a reshuffled list will draw conclusions from the
difference.
When `matchMode` is `"exact"`, `rankRecords` bypasses all fuzzy scoring and applies
plain case-insensitive substring containment, scoring `1` or `0`. This reproduces
today's behaviour exactly and lets the existing test suites serve as a regression
harness.
### 2.4 Snippets (`src/search/snippet.ts`)
```typescript
export function extractSnippet(
text: string,
matchedOffsets: number[],
maxLength?: number // default 160
): string;
```
The existing helper (`src/services/meetings.ts:250`) locates the excerpt with
`text.indexOf(query)` and, when that fails, truncates from character 0. Under fuzzy
matching `indexOf` fails on essentially every hit, so snippets would silently degrade
to "first 160 characters" — actively worse than today. The replacement centres the
window on the match offsets that `scoreField` already computed. The old helper is
removed and meetings uses this one.
### 2.5 Two-Phase Pipeline (`src/search/pipeline.ts`)
```typescript
export interface PipelineSpec<TShallow, TDeep> {
fetchCandidates: () => Promise<TShallow[]>;
shallowFields: FieldSpec<TShallow>[];
enrich: (candidate: TShallow) => Promise<TDeep>;
deepFields: FieldSpec<TDeep>[];
enrichLimit?: number; // default 25
concurrency?: number; // default 8
recencyOf?: (candidate: TShallow) => string | number;
}
export interface PipelineResult<TShallow, TDeep> {
ranked: Array<Ranked<TShallow> & { deep?: TDeep; deepMatches?: FieldMatch[] }>;
degraded: boolean;
enrichmentFailures: number;
}
export function searchPipeline<TShallow, TDeep>(
spec: PipelineSpec<TShallow, TDeep>,
query: string,
opts?: RankOptions
): Promise<PipelineResult<TShallow, TDeep>>;
```
**Phase 1** ranks candidates using only data the list endpoint already returned. This
costs no additional API calls.
**Phase 2** enriches a bounded subset and re-ranks. Final record score is
`max(shallowScore, deepScore)` — deliberately not a sum. A dead-on title match must
not be diluted by an unrelated body paragraph.
**Enrichment set selection.** The set is the top `enrichLimit` candidates by shallow
score, **backfilled to `enrichLimit` with the most recently updated candidates** when
fewer than that pass the shallow gate.
`recencyOf` supplies the backfill ordering. When it is omitted, backfill falls back to
the candidate order the API returned, which is stable but arbitrary. Each service
supplies it explicitly: meetings use `startTime` (there is no `updatedAt` on
`MeetingSummary`), work packages use `updatedAt`, and wikis do not use the pipeline at
all.
This backfill exists because of a genuine structural weakness: a record whose only
match lives in deep content scores `0` shallow, so it is never enriched and therefore
cannot be found. Queries like "what did we decide about hiring" are exactly that
shape. The backfill narrows the gap without closing it — see Section 6.1.
**Concurrency.** Enrichment runs through `mapWithConcurrency(items, concurrency, fn)`.
The current meeting search issues `Promise.all` over up to 250 candidates
(`src/services/meetings.ts:480`), firing up to 250 simultaneous agenda-item requests
at the OpenProject instance. Capping the enrichment set at 25 and the in-flight count
at 8 fixes that fan-out as a side effect of the redesign.
---
## 3. Per-Service Integration
### 3.1 Meetings (`src/services/meetings.ts`)
Candidates come from the existing `listMeetings` paging loop (250 cap, unchanged).
| Phase | Field | Weight | Extra API cost |
| --- | --- | --- | --- |
| Shallow | `title` | 3.0 | none |
| Shallow | `location` | 1.0 | none |
| Shallow | `project.name` | 0.5 | none |
| Shallow | `author.name` | 0.5 | none |
| Deep | agenda item `title` | 2.0 | existing agenda fetch |
| Deep | agenda item `notes` | 1.5 | existing agenda fetch |
| Deep | outcome `notes` | 1.5 | existing agenda fetch |
| Deep | `participants[].name` | 1.0 | meeting detail fetch |
`MeetingSummary` already carries `project.name` and `author.name`, so those four
shallow fields are free. Participants exist only on `MeetingDetail` and require the
deep fetch.
`MeetingSearchResult` is extended, not replaced:
```typescript
export interface MeetingSearchResult {
meeting: MeetingSummary;
matchType: "title" | "location" | "agenda_item" | "participant" | "project";
matchedAgendaItems?: Array<{ id: number; title: string; snippet?: string }>;
score: number; // NEW
matchedFields: string[]; // NEW
}
```
### 3.2 Wiki Pages (`src/services/wikis.ts`)
No pipeline is required: the discovery cache already holds page details locally, so
both phases collapse into one in-memory ranking pass.
`normalizeWikiPage` currently discards the page body. The `text` field is present in
the `GET /api/v3/wiki_pages/{id}` response that the service already issues, so
retaining it costs no additional API calls:
```typescript
export interface WikiPageDetail {
id: number;
title: string;
text?: string; // NEW - raw markdown from the formattable `text` field
project: { id: number; identifier?: string; name?: string };
attachments: WikiPageAttachment[];
}
```
Ranking: `title` weight 3.0, `text` weight 1.0. Return type becomes:
```typescript
export type WikiPageSearchResult = WikiPageSummary & {
score: number;
snippet?: string;
matchedFields: string[];
};
```
### 3.3 Work Packages (`src/services/work-packages.ts`)
**A new tool is required.** There is no `openproject_search_work_packages` tool
today: `searchWorkPackages` is a service helper that only the test suite calls,
and `openproject_list_work_packages` invokes `listWorkPackages` directly. Fuzzy
work package search would therefore be unreachable by any LLM. This change
registers `openproject_search_work_packages` as a new read-only tool, taking the
MCP tool count from 18 to 19.
`openproject_list_work_packages` keeps its current server-side `subject ~`
behaviour unchanged. Splitting list (filter, flat results) from search (rank,
scored results) avoids making one tool's response shape depend on which
arguments were supplied.
A naive change to the search path would trade a cheap server-side filter for an
expensive client-side scan. Instead, **union strategy** — two requests issued in
parallel:
- **Query A**: today's server-side `subject ~ query` filter. Cheap, precise,
guarantees that every currently-returned result is still returned.
- **Query B**: a broad candidate window using the caller's other filters (project,
status, type, assignee) **minus** the subject filter, sorted `updatedAt` descending,
capped at 250.
Results are unioned and deduplicated by ID, then ranked: `subject` weight 3.0 shallow;
top 25 enriched with `description` weight 1.0 and activity comments weight 1.0.
Exact matches are therefore guaranteed to survive; fuzzy matching only ever adds.
### 3.4 Work Package Activities
OpenProject v3 exposes activities only per work package
(`/api/v3/work_packages/{id}/activities`); there is no global activity collection. A
standalone `openproject_search_activities` tool would still require a
`workPackageId`, which is of little use to a model that is searching in order to
*find* the work package.
Therefore: **no new tool**. Activity comment text enters the work package deep phase
(3.3), and `openproject_list_work_package_activities` gains optional `query` and
`matchMode` parameters that rank the list it already fetches, client-side, at no extra
API cost.
---
## 4. Tool Definitions & Zod Schemas
Every search shape gains:
```typescript
matchMode: z
.enum(["fuzzy", "exact"])
.optional()
.default("fuzzy")
.describe(
"Matching strategy. 'fuzzy' (default) tolerates typos, word reordering, and " +
"partial words. Use 'exact' only for literal strings you know verbatim."
),
```
Affected shapes: `searchMeetingsShape`, `searchWikiPagesShape`,
`listWorkPackageActivitiesShape` (alongside a new optional `query`), and a new
`searchWorkPackagesShape` backing the new `openproject_search_work_packages`
tool.
`listWorkPackagesShape` is deliberately **not** changed: that tool stays a
server-side filter and does not gain `matchMode`.
Tool `description` strings are rewritten to advertise the capability, since the
description is what actually steers whether and how a model calls the tool. For
example:
> `openproject_search_meetings`: "Search meetings and agenda items. Accepts
> natural-language phrasing; tolerates typos and reordered words. Returns results
> ranked by relevance with matching excerpts."
All affected tools remain `readOnly: true`.
---
## 5. Error Handling & Edge Cases
| Case | Behaviour |
| --- | --- |
| Enrichment fails with 401 / 403 / 429 | Abort the entire search and propagate. Matches the existing wiki service precedent (`src/services/wikis.ts:300`). |
| Enrichment fails otherwise (404, 500, network) | Degrade that one record to its shallow score; set `degraded: true` and increment `enrichmentFailures`. |
| Empty or whitespace-only query, fuzzy mode | Return unranked candidates, consistent with today's optional-`query` wiki behaviour. |
| Query shorter than 3 characters | Fall back to substring containment; edit distance is meaningless at that length. |
| No results above `minScore` | Return an empty result set with `degraded` reported honestly. Never silently widen the threshold. |
| Invalid `matchMode` | Rejected by Zod before any request is issued. |
**The auth-swallowing bug.** `src/services/meetings.ts:427` currently does
`.catch(() => undefined)` on the agenda-item fetch. An expired token therefore
produces "no results" rather than an authentication error, and the model confidently
reports that no such meeting exists. The `degraded` / `enrichmentFailures` envelope
plus auth propagation is what separates "nothing matched" from "I could not read
everything".
**Tenant isolation.** `src/search/` is pure and holds no module-level state, and
`pipeline.ts` keeps all state per-call. Neither reintroduces the cross-tenant exposure
fixed for the wiki cache in commit `7097d1c`.
---
## 6. Known Limitations
These are accepted and explicitly out of scope for this change.
### 6.1 Two-Phase Recall Ceiling
A record whose only match is in deep content is findable only if it lands in the
enrichment set — that is, if it is among the 25 most recently updated candidates when
no shallow match exists. Beyond that window, deep-only matches are missed.
Eliminating this would require either full-corpus enrichment (hundreds of API calls
per search, with MCP client timeout risk) or a persistent content index (a materially
larger change, with cache invalidation and tenant isolation to get right). The
recency backfill is the bounded-cost compromise.
### 6.2 Wiki Discovery Probe Window
`searchWikiPages` builds its cache by probing wiki page IDs 1..50 with a 5-consecutive-
miss cutoff (`src/services/wikis.ts:311`). Pages beyond that window, or past a gap of
five deleted IDs, are invisible to search regardless of ranking quality. Better
ranking over an incomplete corpus is still ranking over an incomplete corpus. This is
a pre-existing defect, tracked separately.
---
## 7. Testing & Verification
Development is test-driven: scoring tests are written before the scorer.
### 7.1 Pure Unit Tests (`tests/search/`)
Table-driven, no mocks, no client. The guard cases are the point:
- `cat` vs `dog` must **not** match (distance 3, below the length gate).
- `budget` vs `budgt` must match (distance 1, length >= 6).
- `Müller` vs `muller` must match (NFKD normalization).
- Query `"budget approval"` must rank a document containing both tokens above one
containing only `"approval"` (coverage).
- A verbatim phrase match must outrank a scattered-token match (phrase bonus).
- `boundedLevenshtein` must early-exit rather than compute full distance beyond `max`.
### 7.2 Regression via Exact Mode
The existing `tests/meetings.test.ts` and `tests/wikis.test.ts` suites re-run with
`matchMode: "exact"` and must pass **unchanged**. This is the proof that the escape
hatch is genuinely byte-for-byte compatible.
### 7.3 Pipeline Tests
Against a fake client, asserting the cost budget actually holds:
- At most 8 enrichment requests in flight at any moment.
- At most 25 enrichment calls per search.
- Recency backfill triggers when fewer than 25 candidates pass the shallow gate.
- A 401 during enrichment aborts and propagates.
- A 500 during enrichment sets `degraded: true` and leaves other results intact.
### 7.4 Recall Suite (`tests/search/recall.test.ts`)
A fixture corpus plus a table of realistic LLM phrasings mapped to expected top hits:
| Query | Expected top result |
| --- | --- |
| `"budget aproval"` | agenda item "Approval of Q3 budget" |
| `"approval of the budget"` | same |
| `"what did we decide about hiring"` | outcome note on the hiring agenda item |
| `"meeting with the design team"` | meeting whose participants include design team members |
| `"muller"` | meeting authored by Müller |
This is the only suite that measures whether the feature does what was asked. The
others measure that it breaks nothing.
### 7.5 Gate
`bun test` and `bun run typecheck` both clean before the work is considered complete.
---
## 8. Documentation Updates
Per AGENTS.md §2.2 and §2.6:
- **`docs/DECISIONS.md`**: new ADR-019 recording the zero-dependency hand-rolled
scorer (and why `fuse.js` was rejected: its Bitap implementation caps patterns at 32
characters and targets short fields, which is the opposite of the wiki-body and
comment matching required here), the fuzzy-by-default contract, and the two-phase
cost model.
- **`docs/ARCHITECTURE.md`**: the new `src/search/` unit and its position in the layering.
- **`README.md`**: tool table updated with `matchMode` and the `score` / `matchedFields`
result fields.
- **`docs/TODO.md`**: milestone status, plus the two Section 6 limitations as tracked
follow-ups.
+7
View File
@@ -145,6 +145,13 @@ export class OpenProjectClient {
this.authHeader = `Basic ${btoa(`apikey:${this.apiKey}`)}`;
}
/**
* Returns a unique cache key scoped to both baseUrl and apiKey for multi-tenant cache isolation.
*/
getCacheKey(): string {
return `${this.baseUrl}#${this.apiKey}`;
}
/**
* Performs an HTTP GET request to OpenProject API v3.
*/
+69 -12
View File
@@ -3,19 +3,53 @@ import { z } from "zod";
/**
* Zod schema for OpenProject MCP server configuration.
*/
export const configSchema = z.object({
baseUrl: z
.string({ required_error: "OPENPROJECT_BASE_URL is required" })
.url("OPENPROJECT_BASE_URL must be a valid URL")
.transform((val) => val.replace(/\/+$/, "")),
apiKey: z
.string({ required_error: "OPENPROJECT_API_KEY is required" })
.min(1, "OPENPROJECT_API_KEY must not be empty"),
readOnly: z.boolean().default(false),
});
export const configSchema = z
.object({
baseUrl: z
.string({ required_error: "OPENPROJECT_BASE_URL is required" })
.url("OPENPROJECT_BASE_URL must be a valid URL")
.transform((val) => val.replace(/\/+$/, "")),
apiKey: z
.string()
.min(1, "OPENPROJECT_API_KEY must not be empty")
.optional(),
readOnly: z.boolean().default(false),
port: z.number().int().min(0).max(65535).optional(),
host: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.port === undefined && (!data.apiKey || data.apiKey.trim().length === 0)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"OPENPROJECT_API_KEY is required in stdio mode (set PORT, HOST_PORT, or --port to run as an HTTP server)",
path: ["apiKey"],
});
}
});
export type AppConfig = z.infer<typeof configSchema>;
/**
* Helper to parse a CLI argument flag that may take a value either as
* `--flag value` or `--flag=value`.
*/
function parseCliArg(argv: string[], flag: string): string | undefined {
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (!arg) continue;
if (arg === flag) {
const nextArg = argv[i + 1];
if (nextArg !== undefined && !nextArg.startsWith("-")) {
return nextArg;
}
} else if (arg.startsWith(`${flag}=`)) {
return arg.slice(flag.length + 1);
}
}
return undefined;
}
/**
* Parses and validates configuration from environment variables and CLI arguments.
*
@@ -23,7 +57,7 @@ export type AppConfig = z.infer<typeof configSchema>;
* @param argv - CLI arguments array (defaults to process.argv)
* @returns Validated AppConfig object
*/
export function parseConfig(
export function loadConfig(
env: Record<string, string | undefined> = process.env,
argv: string[] = process.argv
): AppConfig {
@@ -35,9 +69,32 @@ export function parseConfig(
readOnlyEnv === "1" ||
readOnlyEnv === "yes";
const portArg = parseCliArg(argv, "--port");
const portStr = portArg ?? env.PORT ?? env.HOST_PORT;
let port: number | undefined;
if (portStr !== undefined && portStr.trim() !== "") {
const parsed = parseInt(portStr.trim(), 10);
if (Number.isNaN(parsed)) {
throw new Error(`Invalid port value: ${portStr}`);
}
port = parsed;
}
const hostArg = parseCliArg(argv, "--host");
const hostStr = hostArg ?? env.HOST;
const host = hostStr !== undefined && hostStr.trim() !== "" ? hostStr.trim() : undefined;
const apiKeyRaw = env.OPENPROJECT_API_KEY?.trim();
const apiKey = apiKeyRaw !== undefined && apiKeyRaw.length > 0 ? apiKeyRaw : undefined;
return configSchema.parse({
baseUrl: env.OPENPROJECT_BASE_URL,
apiKey: env.OPENPROJECT_API_KEY,
apiKey,
readOnly: isReadOnly,
port,
host,
});
}
export const parseConfig = loadConfig;
+876
View File
@@ -0,0 +1,876 @@
/**
* Hosted Remote MCP Server implementation over HTTP/SSE.
*
* Provides a multi-tenant HTTP/SSE MCP server using native Bun.serve.
* Incoming SSE connections supply their personal OpenProject API keys
* via Authorization: Bearer, X-OpenProject-Api-Key, or ?apiKey= query parameter.
* Each connection runs in an isolated session with its own OpenProjectClient
* and McpServer instance.
*/
import type { Server } from "bun";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import {
JSONRPCMessageSchema,
type JSONRPCMessage,
type MessageExtraInfo,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import type { AppConfig } from "./config/index.ts";
import { OpenProjectClient } from "./client/api-client.ts";
import { runWithContext } from "./context.ts";
import { registerAllTools, allTools } from "./tools/index.ts";
import { formatToolError } from "./tools/common.ts";
import { OpenProjectError } from "./client/errors.ts";
import { SERVER_NAME, SERVER_VERSION } from "./server.ts";
import { generateOpenApiSpec } from "./openapi-spec.ts";
export interface HttpServerInstance {
server: Server<unknown>;
port: number;
stop(): Promise<void>;
getActiveSessionsCount(): number;
}
/**
* Extracts OpenProject API key from incoming request according to precedence:
* 1. Authorization: Bearer <key>
* 2. X-OpenProject-Api-Key: <key>
* 3. ?apiKey=<key> query parameter
* 4. Server-configured defaultApiKey fallback (single-tenant deployments)
*/
export function extractApiKey(
req: Request,
url: URL,
defaultApiKey?: string
): string | undefined {
const authHeader = req.headers.get("authorization");
if (authHeader) {
const match = authHeader.match(/^Bearer\s+(.+)$/i);
if (match && match[1] && match[1].trim().length > 0) {
return match[1].trim();
}
}
const headerKey = req.headers.get("x-openproject-api-key");
if (headerKey && headerKey.trim().length > 0) {
return headerKey.trim();
}
const queryKey = url.searchParams.get("apiKey");
if (queryKey && queryKey.trim().length > 0) {
return queryKey.trim();
}
if (defaultApiKey && defaultApiKey.trim().length > 0) {
return defaultApiKey.trim();
}
return undefined;
}
/**
* Custom SSE Transport for native Bun Web Streams.
*/
export class BunSseTransport implements Transport {
readonly sessionId: string;
private controller: ReadableStreamDefaultController<Uint8Array> | null = null;
private closed = false;
private readonly encoder = new TextEncoder();
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: <T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void;
constructor(sessionId: string) {
this.sessionId = sessionId;
}
setController(controller: ReadableStreamDefaultController<Uint8Array>): void {
this.controller = controller;
}
async start(): Promise<void> {
if (!this.controller) {
throw new Error("SSE stream controller not initialized");
}
const endpoint = `/messages?sessionId=${this.sessionId}`;
this.sendRawEvent("endpoint", endpoint);
}
private sendRawEvent(event: string, data: string): void {
if (this.closed || !this.controller) {
return;
}
try {
this.controller.enqueue(this.encoder.encode(`event: ${event}\ndata: ${data}\n\n`));
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
this.onerror?.(error);
}
}
async send(message: JSONRPCMessage): Promise<void> {
if (this.closed) {
throw new Error("Transport is closed");
}
this.sendRawEvent("message", JSON.stringify(message));
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
try {
this.controller?.close();
} catch {
// Ignore if already closed or cancelled
}
this.controller = null;
this.onclose?.();
}
handleMessage(message: JSONRPCMessage, extra?: MessageExtraInfo): void {
if (this.closed) {
throw new Error("Session transport is closed");
}
this.onmessage?.(message, extra);
}
}
interface SseSessionRecord {
sessionId: string;
transport: BunSseTransport;
server: McpServer;
client: OpenProjectClient;
cleanup: () => Promise<void>;
}
interface StreamableSessionRecord {
sessionId: string;
transport: WebStandardStreamableHTTPServerTransport;
server: McpServer;
client: OpenProjectClient;
cleanup: () => Promise<void>;
lastActive: number;
}
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, X-OpenProject-Api-Key, Mcp-Session-Id, Last-Event-ID, Mcp-Protocol-Version",
"Access-Control-Expose-Headers": "Mcp-Session-Id, Mcp-Protocol-Version",
};
/**
* Attaches CORS headers to a response if not already set.
*/
function withCors(response: Response): Response {
const headers = new Headers(response.headers);
for (const [key, value] of Object.entries(CORS_HEADERS)) {
if (!headers.has(key)) {
headers.set(key, value);
}
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
/**
* Normalizes an incoming Request for Streamable HTTP compliance:
* - Ensures Accept includes both application/json and text/event-stream (preventing 406 on clients like Open WebUI)
* - Propagates sessionId from query parameter to Mcp-Session-Id header if missing
*/
function normalizeMcpRequest(req: Request): Request {
const headers = new Headers(req.headers);
const accept = headers.get("accept") ?? "";
if (!accept.includes("application/json") || !accept.includes("text/event-stream")) {
headers.set("accept", "application/json, text/event-stream");
}
const sessionId =
headers.get("mcp-session-id") ??
new URL(req.url).searchParams.get("sessionId") ??
new URL(req.url).searchParams.get("mcp-session-id");
if (sessionId && !headers.has("mcp-session-id")) {
headers.set("mcp-session-id", sessionId);
}
return new Request(req, { headers });
}
/**
* Checks if a parsed JSON-RPC payload is or contains an initialize request.
*/
function isInitializeMessage(msg: unknown): boolean {
if (!msg || typeof msg !== "object") return false;
if (Array.isArray(msg)) return msg.some(isInitializeMessage);
return (msg as { method?: unknown }).method === "initialize";
}
/**
* Helper to construct an McpServer instance with all tools registered and execution wrapped in RequestContext.
*/
function createSessionServer(
config: AppConfig,
client: OpenProjectClient
): McpServer {
const sessionServer = new McpServer({
name: SERVER_NAME,
version: SERVER_VERSION,
});
registerAllTools(sessionServer, {
readOnly: config.readOnly,
wrapExecute: async (fn, tool) => {
if (config.readOnly && !tool.readOnly) {
return formatToolError(
new OpenProjectError(
"Operation rejected. OpenProject MCP server is running in read-only mode.",
{ code: "SERVER_READ_ONLY" }
)
);
}
return runWithContext(
{ client, isReadOnly: config.readOnly },
fn
);
},
});
return sessionServer;
}
/**
* Starts the hosted HTTP/SSE MCP server with Bun.serve.
* Supports both classic SSE transport (GET /sse + POST /messages) and
* modern Streamable HTTP transport (POST/GET/DELETE on /sse, /mcp, and /).
*/
export async function startHttpServer(config: AppConfig): Promise<HttpServerInstance> {
const activeSseSessions = new Map<string, SseSessionRecord>();
const activeStreamableSessions = new Map<string, StreamableSessionRecord>();
// Periodically clean up inactive Streamable HTTP sessions (TTL: 1 hour)
const SESSION_TTL_MS = 60 * 60 * 1000;
const cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [, session] of activeStreamableSessions.entries()) {
if (now - session.lastActive > SESSION_TTL_MS) {
session.cleanup().catch(() => {});
}
}
}, 5 * 60 * 1000);
/**
* Handles Streamable HTTP transport requests (POST, GET, DELETE).
*/
async function handleStreamableRequest(req: Request, url: URL): Promise<Response> {
const normReq = normalizeMcpRequest(req);
// 1. DELETE request terminates session
if (req.method === "DELETE") {
const sessionId =
normReq.headers.get("mcp-session-id") ?? url.searchParams.get("sessionId");
if (!sessionId) {
return withCors(
new Response(JSON.stringify({ error: "Missing session ID" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
);
}
const session = activeStreamableSessions.get(sessionId);
if (!session) {
return withCors(
new Response(JSON.stringify({ error: "Session not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
);
}
await session.cleanup();
return withCors(new Response(null, { status: 204 }));
}
// 2. GET request on session (SSE stream for server-to-client notifications)
if (req.method === "GET") {
const sessionId =
normReq.headers.get("mcp-session-id") ?? url.searchParams.get("sessionId");
if (!sessionId) {
return withCors(
new Response(
JSON.stringify({
error:
"Missing Mcp-Session-Id header. To initiate a Streamable HTTP session, send a POST initialize request.",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
);
}
const session = activeStreamableSessions.get(sessionId);
if (!session) {
return withCors(
new Response(JSON.stringify({ error: "Session not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
);
}
session.lastActive = Date.now();
const res = await runWithContext(
{ client: session.client, isReadOnly: config.readOnly },
() => session.transport.handleRequest(normReq)
);
return withCors(res);
}
if (req.method !== "POST") {
return withCors(
new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
})
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return withCors(
new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
);
}
const sessionId =
normReq.headers.get("mcp-session-id") ?? url.searchParams.get("sessionId");
// Existing session message (already authenticated upon initialization)
if (sessionId && !isInitializeMessage(body)) {
const session = activeStreamableSessions.get(sessionId);
if (!session) {
return withCors(
new Response(JSON.stringify({ error: "Session not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
);
}
session.lastActive = Date.now();
const res = await runWithContext(
{ client: session.client, isReadOnly: config.readOnly },
() => session.transport.handleRequest(normReq, { parsedBody: body })
);
return withCors(res);
}
// 3. New session initialization or stateless request requires API key
const apiKey = extractApiKey(req, url, config.apiKey);
if (!apiKey) {
return withCors(
new Response(
JSON.stringify({
error:
"Missing OpenProject API key. Provide via Authorization header, X-OpenProject-Api-Key header, or ?apiKey= query parameter.",
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
)
);
}
// New session initialization
if (isInitializeMessage(body)) {
const newSessionId = crypto.randomUUID();
const sessionClient = new OpenProjectClient({
baseUrl: config.baseUrl,
apiKey,
});
const sessionServer = createSessionServer(config, sessionClient);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => newSessionId,
enableJsonResponse: true,
});
await sessionServer.connect(transport);
let cleanedUp = false;
const cleanup = async () => {
if (cleanedUp) return;
cleanedUp = true;
activeStreamableSessions.delete(newSessionId);
try {
await transport.close();
} catch {}
try {
await sessionServer.close();
} catch {}
};
const record: StreamableSessionRecord = {
sessionId: newSessionId,
transport,
server: sessionServer,
client: sessionClient,
cleanup,
lastActive: Date.now(),
};
activeStreamableSessions.set(newSessionId, record);
const res = await runWithContext(
{ client: sessionClient, isReadOnly: config.readOnly },
() => transport.handleRequest(normReq, { parsedBody: body })
);
return withCors(res);
}
// Stateless fallback request (e.g. tools/list or tools/call without session ID)
const statelessClient = new OpenProjectClient({
baseUrl: config.baseUrl,
apiKey,
});
const statelessServer = createSessionServer(config, statelessClient);
const statelessTransport = new WebStandardStreamableHTTPServerTransport({
enableJsonResponse: true,
});
await statelessServer.connect(statelessTransport);
try {
const res = await runWithContext(
{ client: statelessClient, isReadOnly: config.readOnly },
() => statelessTransport.handleRequest(normReq, { parsedBody: body })
);
return withCors(res);
} finally {
await statelessTransport.close().catch(() => {});
await statelessServer.close().catch(() => {});
}
}
const server = Bun.serve({
port: config.port ?? 3000,
hostname: config.host || "0.0.0.0",
async fetch(req: Request): Promise<Response> {
const url = new URL(req.url);
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: CORS_HEADERS,
});
}
// 1. Health check endpoint
if (url.pathname === "/health") {
if (req.method !== "GET") {
return withCors(
new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
})
);
}
return withCors(
new Response(
JSON.stringify({
status: "ok",
mode: "remote-mcp",
openproject: config.baseUrl,
readOnly: config.readOnly,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
)
);
}
// 2. OpenAPI specification endpoint
if (url.pathname === "/openapi.json" || url.pathname === "/swagger.json") {
if (req.method !== "GET") {
return withCors(
new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
})
);
}
const spec = generateOpenApiSpec(config);
return withCors(
new Response(JSON.stringify(spec, null, 2), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
}
// 3. REST tool execution endpoint (OpenAPI callers)
if (
url.pathname.startsWith("/api/tools/") ||
url.pathname.startsWith("/tools/")
) {
if (req.method !== "POST") {
return withCors(
new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
})
);
}
const toolName = url.pathname.replace(/^\/(api\/)?tools\//, "");
const tool = allTools.find((t) => t.name === toolName);
if (!tool) {
return withCors(
new Response(JSON.stringify({ error: `Tool '${toolName}' not found` }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
);
}
if (config.readOnly && !tool.readOnly) {
return withCors(
new Response(
JSON.stringify({
error:
"Operation rejected. OpenProject MCP server is running in read-only mode.",
code: "SERVER_READ_ONLY",
}),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
);
}
const apiKey = extractApiKey(req, url, config.apiKey);
if (!apiKey) {
return withCors(
new Response(
JSON.stringify({
error:
"Missing OpenProject API key. Provide via Authorization header, X-OpenProject-Api-Key header, or ?apiKey= query parameter.",
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
)
);
}
let body: unknown = {};
const text = await req.text();
if (text.trim().length > 0) {
try {
body = JSON.parse(text);
} catch {
return withCors(
new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
);
}
}
let args: Record<string, unknown> = {};
if (tool.parameters) {
const parseResult = z.object(tool.parameters).safeParse(body);
if (!parseResult.success) {
return withCors(
new Response(
JSON.stringify({
error: "Validation error",
details: parseResult.error.format(),
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
);
}
args = parseResult.data as Record<string, unknown>;
} else if (typeof body === "object" && body !== null) {
args = body as Record<string, unknown>;
}
const client = new OpenProjectClient({ baseUrl: config.baseUrl, apiKey });
const toolResponse = await runWithContext(
{ client, isReadOnly: config.readOnly },
() => tool.execute(args as any)
);
let parsedData: unknown;
try {
if (toolResponse.content?.[0]?.text) {
parsedData = JSON.parse(toolResponse.content[0].text);
}
} catch {}
return withCors(
new Response(
JSON.stringify({
content: toolResponse.content,
isError: toolResponse.isError ?? false,
...(parsedData !== undefined ? { data: parsedData } : {}),
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
)
);
}
// 4. Classic MCP SSE endpoint: GET /sse without Mcp-Session-Id header
if (
url.pathname === "/sse" &&
req.method === "GET" &&
!req.headers.has("mcp-session-id")
) {
const apiKey = extractApiKey(req, url, config.apiKey);
if (!apiKey) {
return withCors(
new Response(
JSON.stringify({
error:
"Missing OpenProject API key. Provide via Authorization header, X-OpenProject-Api-Key header, or ?apiKey= query parameter.",
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
)
);
}
const sessionId = crypto.randomUUID();
const transport = new BunSseTransport(sessionId);
const sessionClient = new OpenProjectClient({
baseUrl: config.baseUrl,
apiKey,
});
const sessionServer = createSessionServer(config, sessionClient);
let cleanedUp = false;
const cleanup = async () => {
if (cleanedUp) return;
cleanedUp = true;
activeSseSessions.delete(sessionId);
try {
await transport.close();
} catch {}
try {
await sessionServer.close();
} catch {}
};
const sessionRecord: SseSessionRecord = {
sessionId,
transport,
server: sessionServer,
client: sessionClient,
cleanup,
};
activeSseSessions.set(sessionId, sessionRecord);
if (req.signal.aborted) {
cleanup().catch(() => {});
} else {
req.signal.addEventListener("abort", () => {
cleanup().catch(() => {});
});
}
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
transport.setController(controller);
try {
await sessionServer.connect(transport);
} catch (err) {
await cleanup();
try {
controller.error(err);
} catch {}
}
},
cancel() {
cleanup().catch(() => {});
},
});
return withCors(
new Response(stream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
},
})
);
}
// 3. Classic MCP JSON-RPC message posting endpoint: POST /messages
if (url.pathname === "/messages") {
if (req.method !== "POST") {
return withCors(
new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
})
);
}
const sessionId = url.searchParams.get("sessionId");
if (!sessionId) {
return withCors(
new Response(
JSON.stringify({ error: "Missing sessionId query parameter" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
);
}
const session = activeSseSessions.get(sessionId);
if (!session) {
return withCors(
new Response(JSON.stringify({ error: "Session not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return withCors(
new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
);
}
if (Array.isArray(body)) {
for (const item of body) {
const parseResult = JSONRPCMessageSchema.safeParse(item);
if (!parseResult.success) {
return withCors(
new Response(
JSON.stringify({
error: "Invalid JSON-RPC message in batch",
details: parseResult.error.format(),
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
);
}
}
await runWithContext(
{ client: session.client, isReadOnly: config.readOnly },
async () => {
for (const item of body) {
session.transport.handleMessage(item as JSONRPCMessage);
}
}
);
} else {
const parseResult = JSONRPCMessageSchema.safeParse(body);
if (!parseResult.success) {
return withCors(
new Response(
JSON.stringify({
error: "Invalid JSON-RPC message",
details: parseResult.error.format(),
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
);
}
await runWithContext(
{ client: session.client, isReadOnly: config.readOnly },
async () => {
session.transport.handleMessage(parseResult.data);
}
);
}
return withCors(
new Response("Accepted", {
status: 202,
headers: { "Content-Type": "text/plain" },
})
);
}
// 4. Streamable HTTP MCP endpoints: /sse (POST/DELETE/GET with session), /mcp, /
const isStreamablePath =
url.pathname === "/sse" ||
url.pathname === "/mcp" ||
url.pathname === "/" ||
url.pathname === "";
if (isStreamablePath) {
return handleStreamableRequest(req, url);
}
// 5. Default Not Found
return withCors(
new Response(JSON.stringify({ error: "Not Found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
);
},
});
return {
server,
port: server.port ?? (config.port ?? 3000),
async stop() {
clearInterval(cleanupInterval);
for (const session of activeSseSessions.values()) {
await session.cleanup();
}
activeSseSessions.clear();
for (const session of activeStreamableSessions.values()) {
await session.cleanup();
}
activeStreamableSessions.clear();
server.stop(true);
},
getActiveSessionsCount() {
return activeSseSessions.size + activeStreamableSessions.size;
},
};
}
+65 -3
View File
@@ -1,8 +1,70 @@
#!/usr/bin/env bun
/**
* openproject-mcp - Model Context Protocol Server for OpenProject
*/
export const SERVER_NAME = "openproject-mcp";
export const SERVER_VERSION = "0.1.0";
import { parseConfig } from "./config";
import { createServer } from "./server";
import { startHttpServer } from "./http-server";
async function main(): Promise<void> {
try {
const config = parseConfig(process.env, process.argv);
let isShuttingDown = false;
if (config.port !== undefined) {
console.error(
`[openproject-mcp] Starting hosted HTTP server on ${config.host || "0.0.0.0"}:${config.port}...`
);
const httpServer = await startHttpServer(config);
const shutdown = async () => {
if (isShuttingDown) return;
isShuttingDown = true;
console.error(
"\n[openproject-mcp] Received termination signal, shutting down HTTP server..."
);
await httpServer.stop();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
} else {
const mcpServer = createServer(config);
const shutdown = async () => {
if (isShuttingDown) return;
isShuttingDown = true;
console.error(
"\n[openproject-mcp] Received termination signal, shutting down..."
);
await mcpServer.stop();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// Start server over stdio transport
await mcpServer.start();
}
} catch (error) {
console.error(
"[openproject-mcp] Fatal startup error:",
error instanceof Error ? error.message : error
);
process.exit(1);
}
}
// Only execute when invoked directly as a script
if (import.meta.main) {
main();
}
export { createServer } from "./server";
export { SERVER_NAME, SERVER_VERSION } from "./server";
export { startHttpServer, type HttpServerInstance } from "./http-server";
console.error(`[${SERVER_NAME}] Initialized MCP companion server stub v${SERVER_VERSION}`);
+163
View File
@@ -0,0 +1,163 @@
/**
* Dynamic OpenAPI 3.1.0 Specification Generator for OpenProject MCP Tools.
*
* Exposes all registered MCP tools as standard REST operations under /api/tools/{toolName},
* allowing Open WebUI and OpenAPI clients to discover and invoke tools via standard HTTP.
*/
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { allTools } from "./tools/index.ts";
import type { AppConfig } from "./config/index.ts";
import { SERVER_VERSION } from "./server.ts";
export interface OpenApiSpec {
openapi: string;
info: {
title: string;
version: string;
description: string;
};
servers: Array<{ url: string; description?: string }>;
paths: Record<string, Record<string, unknown>>;
components?: {
securitySchemes?: Record<string, unknown>;
};
security?: Array<Record<string, unknown[]>>;
}
function determineToolTag(name: string): string {
if (name.includes("project")) return "Projects";
if (name.includes("work_package")) return "Work Packages";
if (name.includes("query")) return "Queries";
if (
name.includes("type") ||
name.includes("status") ||
name.includes("priority") ||
name.includes("user")
) {
return "Metadata";
}
if (name.includes("openapi")) return "OpenAPI";
return "Tools";
}
/**
* Generates an OpenAPI 3.1.0 document from registered MCP tools.
* In read-only mode, mutating tools are excluded from the specification.
*/
export function generateOpenApiSpec(config: AppConfig): OpenApiSpec {
const isReadOnly = config.readOnly ?? false;
const tools = allTools.filter((tool) => !isReadOnly || tool.readOnly);
const paths: Record<string, Record<string, unknown>> = {};
for (const tool of tools) {
const rawShape = tool.parameters ?? {};
const hasParams = Object.keys(rawShape).length > 0;
const schema = z.object(rawShape);
const jsonSchema = zodToJsonSchema(schema, {
target: "openApi3",
$refStrategy: "none",
});
const { $schema: _discard, ...cleanSchema } = jsonSchema as Record<string, unknown>;
const path = `/api/tools/${tool.name}`;
paths[path] = {
post: {
operationId: tool.name,
summary: tool.name.replace(/_/g, " "),
description: tool.description,
tags: [determineToolTag(tool.name)],
requestBody: hasParams
? {
description: `Parameters for ${tool.name}`,
required: true,
content: {
"application/json": {
schema: cleanSchema,
},
},
}
: undefined,
responses: {
"200": {
description: "Successful tool execution",
content: {
"application/json": {
schema: {
type: "object",
properties: {
content: {
type: "array",
items: {
type: "object",
properties: {
type: { type: "string" },
text: { type: "string" },
},
},
},
data: {
description: "Parsed JSON response payload if applicable",
},
isError: { type: "boolean" },
},
},
},
},
},
"400": {
description: "Invalid tool parameters or schema validation error",
},
"401": {
description: "Missing or invalid OpenProject API key",
},
"403": {
description: "Operation rejected (e.g. read-only mode guard)",
},
"404": {
description: "Tool or requested entity not found",
},
"500": {
description: "Internal server or API error",
},
},
},
};
}
return {
openapi: "3.1.0",
info: {
title: "OpenProject MCP Tools API",
version: SERVER_VERSION,
description:
"OpenAPI REST interface for OpenProject MCP tools. Exposes curated OpenProject tools for projects, work packages, queries, metadata, and OpenAPI introspection.",
},
servers: [
{
url: "/",
description: "OpenProject MCP Server",
},
],
components: {
securitySchemes: {
BearerAuth: {
type: "http",
scheme: "bearer",
description: "OpenProject API Key provided as Bearer token",
},
ApiKeyAuth: {
type: "apiKey",
in: "header",
name: "X-OpenProject-Api-Key",
description: "OpenProject API Key provided in header",
},
},
},
security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }],
paths,
};
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Fuzzy search unit barrel export.
*/
export { normalizeText, tokenize, tokenizeWithOffsets, type Token } from "./tokenize.ts";
export { boundedLevenshtein, scoreToken, scoreField, type FieldScore } from "./score.ts";
export { extractSnippet } from "./snippet.ts";
export {
rankRecords,
DEFAULT_MIN_SCORE,
type MatchMode,
type FieldSpec,
type FieldMatch,
type Ranked,
type RankOptions,
} from "./rank.ts";
export {
searchPipeline,
mapWithConcurrency,
isFatalSearchError,
DEFAULT_ENRICH_LIMIT,
DEFAULT_CONCURRENCY,
type PipelineSpec,
type PipelineEntry,
type PipelineResult,
} from "./pipeline.ts";
+241
View File
@@ -0,0 +1,241 @@
/**
* Two-phase search orchestration.
*
* Phase 1 ranks candidates on data the list endpoint already returned, at no
* additional API cost. Phase 2 enriches a bounded subset with deep content
* and re-ranks. Final score is max(shallow, deep) so that a dead-on title
* match is never diluted by an unrelated body.
*/
import { OpenProjectAuthenticationError } from "../client/api-client.ts";
import { rankRecords, type FieldMatch, type FieldSpec, type RankOptions } from "./rank.ts";
export const DEFAULT_ENRICH_LIMIT = 25;
export const DEFAULT_CONCURRENCY = 8;
export interface PipelineSpec<TShallow, TDeep> {
/** Fetches the candidate window. */
fetchCandidates: () => Promise<TShallow[]>;
/** Fields available without any additional API call. */
shallowFields: FieldSpec<TShallow>[];
/** Fetches deep content for one candidate. */
enrich: (candidate: TShallow) => Promise<TDeep>;
/** Fields available only after enrichment. */
deepFields: FieldSpec<TDeep>[];
enrichLimit?: number;
concurrency?: number;
/** Orders the recency backfill. Omitted means "keep API order". */
recencyOf?: (candidate: TShallow) => string | number;
idOf?: (candidate: TShallow) => number;
}
export interface PipelineEntry<TShallow, TDeep> {
record: TShallow;
score: number;
matches: FieldMatch[];
deep?: TDeep;
}
export interface PipelineResult<TShallow, TDeep> {
ranked: PipelineEntry<TShallow, TDeep>[];
/** True when at least one enrichment failed non-fatally. */
degraded: boolean;
enrichmentFailures: number;
}
/**
* True for errors that must abort the whole search rather than degrade one
* record: an expired token must not be reported to the model as "no results".
*/
export function isFatalSearchError(error: unknown): boolean {
if (error instanceof OpenProjectAuthenticationError) {
return true;
}
const statusCode = (error as { statusCode?: number } | null)?.statusCode;
return statusCode === 401 || statusCode === 403 || statusCode === 429;
}
/**
* Maps over items with a bounded number of in-flight operations, preserving
* input order in the results.
*/
export async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length);
let nextIndex = 0;
const workerCount = Math.max(1, Math.min(concurrency, items.length));
const workers = Array.from({ length: workerCount }, async () => {
for (;;) {
const index = nextIndex++;
if (index >= items.length) {
return;
}
results[index] = await fn(items[index]!, index);
}
});
await Promise.all(workers);
return results;
}
function recencyValue<T>(
recencyOf: ((candidate: T) => string | number) | undefined,
candidate: T
): number {
if (!recencyOf) {
return 0;
}
const value = recencyOf(candidate);
if (typeof value === "number") {
return value;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
export async function searchPipeline<TShallow, TDeep>(
spec: PipelineSpec<TShallow, TDeep>,
query: string,
opts?: RankOptions<TShallow>
): Promise<PipelineResult<TShallow, TDeep>> {
const candidates = await spec.fetchCandidates();
const enrichLimit = spec.enrichLimit ?? DEFAULT_ENRICH_LIMIT;
const concurrency = spec.concurrency ?? DEFAULT_CONCURRENCY;
const idOf = spec.idOf ?? ((candidate: TShallow) => (candidate as { id?: number })?.id ?? 0);
// Phase 1: rank on data we already have. No limit here — the limit applies
// to the final merged result, not the intermediate ranking.
const shallowRanked = rankRecords(candidates, query, spec.shallowFields, {
...opts,
idOf,
limit: undefined,
});
const entries = new Map<number, PipelineEntry<TShallow, TDeep>>();
for (const ranked of shallowRanked) {
entries.set(idOf(ranked.record), {
record: ranked.record,
score: ranked.score,
matches: ranked.matches,
});
}
// Enrichment set: top N by shallow score, backfilled by recency. The
// backfill exists because a record whose only match is deep scores zero
// shallowly and would otherwise never be enriched, and so never found.
const selected = shallowRanked.slice(0, enrichLimit).map((ranked) => ranked.record);
if (selected.length < enrichLimit) {
const alreadySelected = new Set(selected.map(idOf));
const backfill = candidates
.filter((candidate) => !alreadySelected.has(idOf(candidate)))
.sort((a, b) => recencyValue(spec.recencyOf, b) - recencyValue(spec.recencyOf, a));
for (const candidate of backfill) {
if (selected.length >= enrichLimit) {
break;
}
selected.push(candidate);
}
}
// Phase 2: bounded enrichment.
//
// The explicit EnrichedCandidate annotation is required, not stylistic:
// without it TypeScript infers the callback's return as a UNION of two
// object literal types ({ deep: Awaited<TDeep> } | { deep: undefined })
// rather than a single type with `deep: TDeep | undefined`. The narrowing
// predicate below is then rejected, because a type predicate's type must be
// assignable to its parameter's type and `Pair` is not assignable to the
// `{ deep: undefined }` arm.
interface EnrichedCandidate {
candidate: TShallow;
deep: TDeep | undefined;
}
let enrichmentFailures = 0;
const enriched: EnrichedCandidate[] = await mapWithConcurrency<TShallow, EnrichedCandidate>(
selected,
concurrency,
async (candidate) => {
try {
return { candidate, deep: await spec.enrich(candidate) };
} catch (error: unknown) {
if (isFatalSearchError(error)) {
throw error;
}
enrichmentFailures++;
return { candidate, deep: undefined };
}
}
);
interface Pair {
candidate: TShallow;
deep: TDeep;
}
const pairs: Pair[] = enriched.filter(
(entry): entry is Pair => entry.deep !== undefined
);
// Attach deep content to every enriched entry, matched or not, so callers
// can reuse it without re-fetching.
for (const pair of pairs) {
const existing = entries.get(idOf(pair.candidate));
if (existing) {
existing.deep = pair.deep;
}
}
const pairFields: FieldSpec<Pair>[] = spec.deepFields.map((field) => ({
name: field.name,
weight: field.weight,
extract: (pair: Pair) => field.extract(pair.deep),
}));
const deepRanked = rankRecords(pairs, query, pairFields, {
...opts,
idOf: (pair: Pair) => idOf(pair.candidate),
limit: undefined,
});
for (const ranked of deepRanked) {
const id = idOf(ranked.record.candidate);
const existing = entries.get(id);
if (existing) {
// max(shallow, deep), never a sum.
if (ranked.score > existing.score) {
existing.score = ranked.score;
}
existing.matches = [...existing.matches, ...ranked.matches].sort(
(a, b) => b.score - a.score
);
existing.deep = ranked.record.deep;
} else {
entries.set(id, {
record: ranked.record.candidate,
score: ranked.score,
matches: ranked.matches,
deep: ranked.record.deep,
});
}
}
const ranked = [...entries.values()].sort(
(a, b) => b.score - a.score || idOf(a.record) - idOf(b.record)
);
const limited =
opts?.limit !== undefined && opts.limit > 0 ? ranked.slice(0, opts.limit) : ranked;
return {
ranked: limited,
degraded: enrichmentFailures > 0,
enrichmentFailures,
};
}
+170
View File
@@ -0,0 +1,170 @@
/**
* Generic relevance ranking over record collections.
*
* Services declare WHAT to search via FieldSpec declarations; this module
* decides HOW WELL each record matches.
*/
import { normalizeText, tokenizeQuery } from "./tokenize.ts";
import { scoreField, type FieldScore } from "./score.ts";
import { extractSnippet } from "./snippet.ts";
export type MatchMode = "fuzzy" | "exact";
export interface FieldSpec<T> {
/** Field name reported back in FieldMatch.field. */
name: string;
/** Relative importance; scores are normalized by the highest declared weight. */
weight: number;
/** Pulls the searchable text out of a record. */
extract: (record: T) => string | string[] | undefined;
}
export interface FieldMatch {
field: string;
score: number;
snippet?: string;
}
export interface Ranked<T> {
record: T;
/** 0..1, the best weighted field score. */
score: number;
/** Only fields that scored above zero, highest first. */
matches: FieldMatch[];
}
export interface RankOptions<T = unknown> {
minScore?: number;
limit?: number;
matchMode?: MatchMode;
/** Used for deterministic tie-breaking. Defaults to reading `record.id`. */
idOf?: (record: T) => number;
}
export const DEFAULT_MIN_SCORE = 0.35;
/** Below this query length, edit distance is meaningless (spec section 5). */
const MIN_FUZZY_QUERY_LENGTH = 3;
function defaultIdOf(record: unknown): number {
return (record as { id?: number })?.id ?? 0;
}
function fieldTexts<T>(record: T, spec: FieldSpec<T>): string[] {
const raw = spec.extract(record);
if (raw === undefined || raw === null) {
return [];
}
if (Array.isArray(raw)) {
return raw.filter((value): value is string => typeof value === "string" && value.length > 0);
}
return raw.length > 0 ? [raw] : [];
}
/**
* Case-insensitive substring containment, reproducing the pre-fuzzy behaviour
* byte for byte.
*/
function scoreExact(query: string, text: string): FieldScore {
const haystack = normalizeText(text);
const needle = normalizeText(query);
if (needle.length === 0) {
return { score: 0, matchedOffsets: [] };
}
const index = haystack.indexOf(needle);
return index === -1 ? { score: 0, matchedOffsets: [] } : { score: 1, matchedOffsets: [index] };
}
/**
* Ranks records against a query using the supplied field declarations.
*
* Record score is the best weighted field score:
* recordScore = max over fields of (fieldScore * weight) / maxDeclaredWeight
*
* Normalizing by the maximum DECLARED weight (not the best matching weight)
* means a hit on a low-weight field yields a proportionally lower record
* score rather than being rescaled up to 1.
*
* The minScore gate is applied to the RAW field score, never to this
* weighted value.
*/
export function rankRecords<T>(
records: T[],
query: string,
fields: FieldSpec<T>[],
opts?: RankOptions<T>
): Ranked<T>[] {
const matchMode = opts?.matchMode ?? "fuzzy";
const minScore = opts?.minScore ?? DEFAULT_MIN_SCORE;
const idOf = opts?.idOf ?? defaultIdOf;
const trimmedQuery = query.trim();
if (trimmedQuery.length === 0) {
return records.map((record) => ({ record, score: 0, matches: [] }));
}
const queryTokens = tokenizeQuery(trimmedQuery);
const useExact =
matchMode === "exact" ||
trimmedQuery.length < MIN_FUZZY_QUERY_LENGTH ||
queryTokens.length === 0;
// Exact mode must not apply minScore: a containment hit on a low-weight
// field legitimately scores below it, and dropping those would break
// backward compatibility.
const threshold = useExact ? Number.EPSILON : minScore;
const maxWeight = fields.reduce((highest, field) => Math.max(highest, field.weight), 0) || 1;
const ranked: Ranked<T>[] = [];
for (const record of records) {
const matches: FieldMatch[] = [];
let bestWeighted = 0;
let bestRaw = 0;
for (const spec of fields) {
let bestScore: FieldScore = { score: 0, matchedOffsets: [] };
let bestText = "";
for (const text of fieldTexts(record, spec)) {
const result = useExact ? scoreExact(trimmedQuery, text) : scoreField(queryTokens, text);
if (result.score > bestScore.score) {
bestScore = result;
bestText = text;
}
}
if (bestScore.score > 0) {
matches.push({
field: spec.name,
score: Number(bestScore.score.toFixed(2)),
snippet: extractSnippet(bestText, bestScore.matchedOffsets) || undefined,
});
if (bestScore.score > bestRaw) {
bestRaw = bestScore.score;
}
const weighted = (bestScore.score * spec.weight) / maxWeight;
if (weighted > bestWeighted) {
bestWeighted = weighted;
}
}
}
// Gate on the RAW score (is this a real match?), order by the weighted
// score (how important is where it matched?). Gating on the weighted
// score would make every low-weight field unreachable — see the note in
// the plan for this task.
if (bestRaw >= threshold) {
matches.sort((a, b) => b.score - a.score);
ranked.push({ record, score: Number(bestWeighted.toFixed(2)), matches });
}
}
ranked.sort((a, b) => b.score - a.score || idOf(a.record) - idOf(b.record));
return opts?.limit !== undefined && opts.limit > 0 ? ranked.slice(0, opts.limit) : ranked;
}
+213
View File
@@ -0,0 +1,213 @@
/**
* Fuzzy scoring primitives.
*/
import { tokenizeWithOffsets } from "./tokenize.ts";
export interface FieldScore {
/** Aggregate field score in the range 0..1. */
score: number;
/** Offsets of matching tokens in the ORIGINAL field text. */
matchedOffsets: number[];
}
/** Multiplier applied to the normalized edit distance when scoring a typo. */
const EDIT_PENALTY = 0.6;
/** Added when the full query appears verbatim in the field. */
const PHRASE_BONUS = 0.15;
/**
* The base score is scaled by this before the phrase bonus is added.
*
* Load-bearing: without it a document matching every query token already
* scores 1.0, the phrase bonus clamps to nothing, and a verbatim phrase
* cannot outrank a scattered one — the bonus becomes dead code in exactly
* the case it exists for. Scaling reserves the top 0.15 for verbatim hits.
*/
const BASE_SCALE = 0.85;
/** Minimum shared prefix length for the morphological-variant rule. */
const MIN_SHARED_PREFIX = 4;
/** Below this length a token only ever matches exactly. */
const MIN_FUZZY_TOKEN_LENGTH = 3;
/** Below this length a token is not eligible for containment matching. */
const MIN_CONTAINS_TOKEN_LENGTH = 4;
/**
* Levenshtein distance with an early exit.
*
* Returns the true distance when it is <= max, otherwise max + 1. The
* overwhelmingly common case is a non-match, so the row-minimum early exit
* is what keeps scoring cheap over long documents.
*/
export function boundedLevenshtein(a: string, b: string, max: number): number {
if (a === b) {
return 0;
}
if (Math.abs(a.length - b.length) > max) {
return max + 1;
}
if (a.length === 0) {
return b.length <= max ? b.length : max + 1;
}
if (b.length === 0) {
return a.length <= max ? a.length : max + 1;
}
let previous: number[] = Array.from({ length: b.length + 1 }, (_, index) => index);
let current: number[] = new Array<number>(b.length + 1).fill(0);
for (let i = 1; i <= a.length; i++) {
current[0] = i;
let rowMinimum = i;
for (let j = 1; j <= b.length; j++) {
const substitutionCost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
const value = Math.min(
previous[j]! + 1,
current[j - 1]! + 1,
previous[j - 1]! + substitutionCost
);
current[j] = value;
if (value < rowMinimum) {
rowMinimum = value;
}
}
if (rowMinimum > max) {
return max + 1;
}
const swap = previous;
previous = current;
current = swap;
}
const distance = previous[b.length]!;
return distance <= max ? distance : max + 1;
}
/**
* Length of the common prefix shared by two tokens.
*/
function sharedPrefixLength(a: string, b: string): number {
const limit = Math.min(a.length, b.length);
let index = 0;
while (index < limit && a.charCodeAt(index) === b.charCodeAt(index)) {
index++;
}
return index;
}
/**
* Scores a single query token against a single field token.
*
* Rules are applied in order and the first match wins. The length guards are
* load-bearing: without them "cat" is edit distance 3 from "dog" and every
* short token fuzzily matches every other short token.
*/
export function scoreToken(queryToken: string, fieldToken: string): number {
if (queryToken === fieldToken) {
return 1;
}
if (queryToken.length < MIN_FUZZY_TOKEN_LENGTH) {
return 0;
}
if (fieldToken.startsWith(queryToken)) {
return 0.9;
}
if (queryToken.length >= MIN_CONTAINS_TOKEN_LENGTH && fieldToken.includes(queryToken)) {
return 0.75;
}
// Morphological variants: "decide"/"decision", "deploy"/"deployment".
// These are 3-4 edits apart, and no edit budget wide enough to match them
// is narrow enough to reject unrelated words. A shared prefix is.
const sharedPrefix = sharedPrefixLength(queryToken, fieldToken);
if (sharedPrefix >= MIN_SHARED_PREFIX) {
const longest = Math.max(queryToken.length, fieldToken.length);
return 0.55 + 0.25 * (sharedPrefix / longest);
}
const maxDistance = queryToken.length < 6 ? 1 : 2;
const distance = boundedLevenshtein(queryToken, fieldToken, maxDistance);
if (distance > maxDistance) {
return 0;
}
return 1 - (distance / queryToken.length) * EDIT_PENALTY;
}
/**
* Scores a set of query tokens against one field's text.
*
* Aggregation is mean(best score per query token) * coverage * BASE_SCALE,
* plus PHRASE_BONUS when the full query appears verbatim. Coverage is the
* fraction of query tokens that matched anything, and is what stops a
* two-word query from ranking a document that matched only the common word.
*
* A field score reaches 1.0 only via the phrase bonus.
*/
export function scoreField(queryTokens: string[], fieldText: string): FieldScore {
const empty: FieldScore = { score: 0, matchedOffsets: [] };
if (queryTokens.length === 0 || !fieldText) {
return empty;
}
const fieldTokens = tokenizeWithOffsets(fieldText);
if (fieldTokens.length === 0) {
return empty;
}
let total = 0;
let matchedCount = 0;
const matchedOffsets: number[] = [];
for (const queryToken of queryTokens) {
let best = 0;
let bestOffset = -1;
for (const fieldToken of fieldTokens) {
const score = scoreToken(queryToken, fieldToken.value);
if (score > best) {
best = score;
bestOffset = fieldToken.offset;
if (best === 1) {
break;
}
}
}
total += best;
if (best > 0) {
matchedCount++;
if (bestOffset >= 0) {
matchedOffsets.push(bestOffset);
}
}
}
if (matchedCount === 0) {
return empty;
}
const coverage = matchedCount / queryTokens.length;
let score = (total / queryTokens.length) * coverage * BASE_SCALE;
// Phrase bonus, compared over joined token values so that punctuation
// between words does not defeat the check.
const phrase = queryTokens.join(" ");
const joinedField = fieldTokens.map((token) => token.value).join(" ");
if (joinedField.includes(phrase)) {
score += PHRASE_BONUS;
}
return {
score: Math.min(1, score),
matchedOffsets: matchedOffsets.sort((a, b) => a - b),
};
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Match-centred excerpt extraction for search results.
*/
const DEFAULT_MAX_LENGTH = 160;
const CONTEXT_BEFORE = 40;
/**
* Extracts an excerpt of `text` centred on the earliest match offset.
*
* `matchedOffsets` are offsets into the ORIGINAL text, as produced by
* `scoreField`. When no offsets are supplied the excerpt falls back to a
* head truncation.
*/
export function extractSnippet(
text: string,
matchedOffsets: number[],
maxLength: number = DEFAULT_MAX_LENGTH
): string {
if (!text) {
return "";
}
if (text.length <= maxLength) {
return text.trim();
}
let start = 0;
if (matchedOffsets.length > 0) {
const earliest = Math.min(...matchedOffsets);
start = Math.max(0, Math.min(earliest - CONTEXT_BEFORE, text.length - maxLength));
}
const end = Math.min(text.length, start + maxLength);
let snippet = text.slice(start, end).trim();
if (start > 0) {
snippet = `...${snippet}`;
}
if (end < text.length) {
snippet = `${snippet}...`;
}
return snippet;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Text normalization and tokenization for fuzzy search.
*
* Query text and field text must travel through an identical path; any
* divergence between the two sides silently breaks matching, so there is
* exactly one implementation of each operation here.
*/
export interface Token {
/** Normalized token value. */
value: string;
/** Character offset of the token in the ORIGINAL (un-normalized) text. */
offset: number;
}
const COMBINING_MARKS = /[̀-ͯ]/g;
const TOKEN_PATTERN = /[\p{L}\p{N}]+/gu;
/**
* Lowercases text and strips diacritics via NFKD decomposition, so that
* "Müller" matches "muller" and "café" matches "cafe".
*/
export function normalizeText(text: string): string {
return text.normalize("NFKD").replace(COMBINING_MARKS, "").toLowerCase();
}
/**
* Splits text into normalized tokens, each carrying its offset in the
* original string.
*
* Token boundaries are found on the ORIGINAL text and normalization is
* applied per token, because NFKD can change string length and would
* otherwise invalidate the offsets.
*/
export function tokenizeWithOffsets(text: string): Token[] {
const tokens: Token[] = [];
if (!text) {
return tokens;
}
for (const match of text.matchAll(TOKEN_PATTERN)) {
const value = normalizeText(match[0]);
if (value.length > 0) {
tokens.push({ value, offset: match.index });
}
}
return tokens;
}
/**
* Splits text into normalized tokens, discarding offsets.
*/
export function tokenize(text: string): string[] {
return tokenizeWithOffsets(text).map((token) => token.value);
}
/**
* English function words carrying no retrieval signal.
*
* LLM queries are phrased as questions ("what did we decide about hiring"),
* and the coverage multiplier in scoreField divides by the query token count.
* Left in, these words drive coverage — and therefore the score — toward zero
* for exactly the natural-language queries this feature exists to serve.
*/
const STOPWORDS = new Set([
"a", "about", "an", "and", "are", "as", "at", "be", "but", "by", "can",
"could", "did", "do", "does", "for", "from", "had", "has", "have", "he",
"her", "his", "how", "i", "if", "in", "is", "it", "its", "me", "my", "of",
"on", "or", "our", "she", "should", "so", "than", "that", "the", "their",
"them", "then", "there", "these", "they", "this", "to", "us", "was", "we",
"were", "what", "when", "where", "which", "who", "why", "will", "with",
"would", "you", "your",
]);
/**
* Tokenizes a QUERY, stripping stopwords.
*
* Stopwords are kept when removing them would leave nothing, so that a query
* such as "the who" still searches for something.
*/
export function tokenizeQuery(query: string): string[] {
const tokens = tokenize(query);
const meaningful = tokens.filter((token) => !STOPWORDS.has(token));
return meaningful.length > 0 ? meaningful : tokens;
}
+67
View File
@@ -0,0 +1,67 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { AppConfig } from "./config";
import { OpenProjectClient } from "./client/api-client";
import { runWithContext } from "./context";
import { registerAllTools } from "./tools";
import { formatToolError } from "./tools/common";
import { OpenProjectError } from "./client/errors";
export interface OpenProjectMcpServer {
server: McpServer;
client: OpenProjectClient;
start: (transport?: Transport) => Promise<void>;
stop: () => Promise<void>;
}
export const SERVER_NAME = "openproject-mcp";
export const SERVER_VERSION = "0.1.0";
/**
* Creates and configures an OpenProject MCP server instance.
*
* @param config - Server configuration including baseUrl, apiKey, and readOnly flag.
* @returns Configured OpenProjectMcpServer instance with lifecycle methods.
*/
export function createServer(config: AppConfig): OpenProjectMcpServer {
const server = new McpServer({
name: SERVER_NAME,
version: SERVER_VERSION,
});
const client = new OpenProjectClient({
baseUrl: config.baseUrl,
apiKey: config.apiKey ?? "",
});
// Register all tools with ambient context execution and read-only guards
registerAllTools(server, {
readOnly: config.readOnly,
wrapExecute: async (fn, tool) => {
if (config.readOnly && !tool.readOnly) {
return formatToolError(
new OpenProjectError(
"Operation rejected. OpenProject MCP server is running in read-only mode.",
{ code: "SERVER_READ_ONLY" }
)
);
}
return runWithContext({ client, isReadOnly: config.readOnly }, fn);
},
});
return {
server,
client,
start: async (transport?: Transport) => {
const activeTransport = transport ?? new StdioServerTransport();
await server.connect(activeTransport);
console.error(`[${SERVER_NAME}] Server started (readOnly=${config.readOnly})`);
},
stop: async () => {
await server.close();
console.error(`[${SERVER_NAME}] Server stopped`);
},
};
}
+4
View File
@@ -7,3 +7,7 @@ export * from "./projects.ts";
export * from "./work-packages.ts";
export * from "./queries.ts";
export * from "./metadata.ts";
export * from "./openapi.ts";
export * from "./meetings.ts";
export * from "./wikis.ts";
export type { PaginatedResult } from "./meetings.ts";
+572
View File
@@ -0,0 +1,572 @@
/**
* Meetings Domain Service.
* Provides functions to list, inspect, and deep-search OpenProject meetings and agenda items.
*/
import type { OpenProjectClient } from "../client/api-client.ts";
import { extractIdFromHref, extractRawText } from "../client/hal-parser.ts";
import type { HalCollection, HalResource } from "../client/types.ts";
import { resolveClient, resolveProjectId } from "./helper.ts";
import { isFatalSearchError, searchPipeline } from "../search/pipeline.ts";
import { rankRecords } from "../search/rank.ts";
import type { FieldSpec, MatchMode } from "../search/rank.ts";
export interface MeetingSummary {
id: number;
title: string;
state: string;
startTime: string;
endTime: string;
duration?: string;
project: { id: number; name: string };
location?: string;
author?: { id: number; name: string };
}
export interface AgendaItemOutcome {
id: number;
notes: string;
}
export interface AgendaItem {
id: number;
title: string;
durationInMinutes?: number;
notes?: string;
itemType: string;
position: number;
author?: { id: number; name: string };
presenter?: { id: number; name: string };
section?: { id: number; title: string };
outcomes: AgendaItemOutcome[];
}
export interface MeetingDetail extends MeetingSummary {
template: boolean;
notify: boolean;
participants: Array<{ id: number; name: string }>;
agendaItems?: AgendaItem[];
sections?: Array<{ id: number; title: string; position: number }>;
}
export interface MeetingSearchResult {
meeting: MeetingSummary;
matchType: "title" | "location" | "agenda_item" | "participant" | "author" | "project";
matchedAgendaItems?: Array<{
id: number;
title: string;
snippet?: string;
}>;
/** Relevance score in the range 0..1. */
score: number;
/** Names of the fields that matched, highest scoring first. */
matchedFields: string[];
}
export interface PaginatedResult<T> {
total: number;
count: number;
pageSize: number;
offset: number;
elements: T[];
items?: T[];
}
/**
* Search results are exposed under `elements` only. `items` is deliberately
* omitted: the tool layer JSON-stringifies this whole object, so carrying the
* same array twice doubles the token cost of every search response.
*/
export interface MeetingSearchPage
extends Omit<PaginatedResult<MeetingSearchResult>, "items"> {
elements: MeetingSearchResult[];
/** True when some deep content could not be read. */
degraded: boolean;
enrichmentFailures: number;
}
export interface SearchMeetingsParams {
query: string;
projectId?: string | number;
offset?: number;
pageSize?: number;
matchMode?: MatchMode;
}
/**
* Normalizes a raw HAL Meeting resource into a token-efficient MeetingSummary.
*/
export function normalizeMeetingSummary(resource: HalResource): MeetingSummary {
const links = (resource._links ?? {}) as Record<string, { href?: string; title?: string } | undefined>;
const selfLink = links.self;
const projectLink = links.project;
const authorLink = links.author;
const embedded = (resource._embedded ?? {}) as Record<string, unknown>;
const embeddedProject = embedded.project as { id?: number; name?: string } | undefined;
const embeddedAuthor = embedded.author as { id?: number; name?: string } | undefined;
const id = resource.id ?? extractIdFromHref(selfLink?.href) ?? 0;
const title = String(resource.title ?? "");
const state = String(resource.state ?? "");
const startTime = String(resource.startTime ?? "");
const endTime = String(resource.endTime ?? "");
const duration = resource.duration ? String(resource.duration) : undefined;
const location = resource.location ? String(resource.location) : undefined;
const projectId = extractIdFromHref(projectLink?.href) ?? embeddedProject?.id ?? 0;
const projectName = projectLink?.title ?? embeddedProject?.name ?? "";
let author: { id: number; name: string } | undefined;
const authorId = extractIdFromHref(authorLink?.href) ?? embeddedAuthor?.id;
const authorName = authorLink?.title ?? embeddedAuthor?.name;
if (authorId !== undefined || authorName !== undefined) {
author = {
id: authorId ?? 0,
name: authorName ?? (authorId ? `User #${authorId}` : ""),
};
}
return {
id,
title,
state,
startTime,
endTime,
duration,
project: { id: projectId, name: projectName },
location,
author,
};
}
/**
* Normalizes an Agenda Item Outcome.
*/
export function normalizeOutcome(outcome: Record<string, unknown>): AgendaItemOutcome {
const links = (outcome._links ?? {}) as Record<string, { href?: string } | undefined>;
const id = (typeof outcome.id === "number" ? outcome.id : extractIdFromHref(links.self?.href)) ?? 0;
const notes = extractRawText(outcome.notes ?? outcome.text ?? outcome.comment ?? "");
return { id, notes };
}
/**
* Normalizes a raw HAL Agenda Item resource into an AgendaItem.
*/
export function normalizeAgendaItem(item: Record<string, unknown>): AgendaItem {
const links = (item._links ?? {}) as Record<string, { href?: string; title?: string } | undefined>;
const embedded = (item._embedded ?? {}) as Record<string, unknown>;
const id = (typeof item.id === "number" ? item.id : extractIdFromHref(links.self?.href)) ?? 0;
const title = String(item.title ?? links.workPackage?.title ?? "");
const itemType = String(item.itemType ?? item.type ?? "simple");
const position = typeof item.position === "number" ? item.position : 0;
const durationInMinutes =
typeof item.durationInMinutes === "number" ? item.durationInMinutes : undefined;
const rawNotes = extractRawText(item.notes);
const notes = rawNotes.length > 0 ? rawNotes : undefined;
let author: { id: number; name: string } | undefined;
const authorLink = links.author;
const authorId = extractIdFromHref(authorLink?.href);
if (authorId !== undefined || authorLink?.title) {
author = {
id: authorId ?? 0,
name: authorLink?.title ?? `User #${authorId}`,
};
}
let presenter: { id: number; name: string } | undefined;
const presenterLink = links.presenter;
const presenterId = extractIdFromHref(presenterLink?.href);
if (presenterId !== undefined || presenterLink?.title) {
presenter = {
id: presenterId ?? 0,
name: presenterLink?.title ?? `User #${presenterId}`,
};
}
let section: { id: number; title: string } | undefined;
const sectionObj = (embedded.section ?? links.section) as
| { id?: number; title?: string; href?: string; _links?: { self?: { href?: string } } }
| undefined;
if (sectionObj) {
const sectionId =
sectionObj.id ?? extractIdFromHref(sectionObj.href ?? sectionObj._links?.self?.href);
if (sectionId !== undefined || sectionObj.title !== undefined) {
section = {
id: sectionId ?? 0,
title: sectionObj.title ?? "",
};
}
}
const rawOutcomes = embedded.outcomes ?? links.outcomes ?? [];
const outcomes: AgendaItemOutcome[] = Array.isArray(rawOutcomes)
? rawOutcomes.map((o) => normalizeOutcome(o as Record<string, unknown>))
: [];
return {
id,
title,
durationInMinutes,
notes,
itemType,
position,
author,
presenter,
section,
outcomes,
};
}
/**
* Normalizes a raw HAL Meeting resource and embedded agenda items into a MeetingDetail.
*/
export function normalizeMeetingDetail(
resource: HalResource,
agendaItems?: AgendaItem[]
): MeetingDetail {
const summary = normalizeMeetingSummary(resource);
const links = (resource._links ?? {}) as Record<string, unknown>;
const embedded = (resource._embedded ?? {}) as Record<string, unknown>;
const template = Boolean(resource.template);
const notify = Boolean(resource.notify);
const rawParticipants = embedded.participants ?? links.participants ?? [];
const participants: Array<{ id: number; name: string }> = [];
if (Array.isArray(rawParticipants)) {
for (const p of rawParticipants) {
const pObj = p as { id?: number; href?: string; title?: string; name?: string; _links?: { self?: { href?: string } } };
const pId = pObj.id ?? extractIdFromHref(pObj.href ?? pObj._links?.self?.href);
const pName = pObj.title ?? pObj.name;
if (pId !== undefined || pName !== undefined) {
participants.push({
id: pId ?? 0,
name: pName ?? (pId ? `User #${pId}` : ""),
});
}
}
}
const rawSections = embedded.sections ?? links.sections ?? [];
let sections: Array<{ id: number; title: string; position: number }> | undefined;
if (Array.isArray(rawSections) && rawSections.length > 0) {
sections = (rawSections as Array<{ id?: number; href?: string; title?: string; position?: number; _links?: { self?: { href?: string } } }>).map(
(s) => ({
id: s.id ?? extractIdFromHref(s.href ?? s._links?.self?.href) ?? 0,
title: s.title ?? "",
position: typeof s.position === "number" ? s.position : 0,
})
);
}
return {
...summary,
template,
notify,
participants,
agendaItems,
sections,
};
}
/**
* Lists meetings visible to the user matching optional project, time, and pagination filters.
*/
export async function listMeetings(
params?: {
projectId?: string | number;
time?: string;
offset?: number;
pageSize?: number;
},
client?: OpenProjectClient
): Promise<PaginatedResult<MeetingSummary>> {
const opClient = resolveClient(client);
const filters: Record<string, { operator: string; values: string[] }>[] = [];
if (params?.projectId !== undefined) {
const resolvedId = await resolveProjectId(params.projectId, opClient);
filters.push({
project_id: {
operator: "=",
values: [String(resolvedId)],
},
});
}
if (params?.time) {
const timeLower = params.time.toLowerCase().trim();
const operator = timeLower === "past" ? "past" : "upcoming";
filters.push({
time: {
operator,
values: [],
},
});
}
const searchParams = new URLSearchParams();
if (params?.pageSize !== undefined) {
searchParams.set("pageSize", String(params.pageSize));
}
if (params?.offset !== undefined) {
searchParams.set("offset", String(params.offset));
}
if (filters.length > 0) {
searchParams.set("filters", JSON.stringify(filters));
}
const queryStr = searchParams.toString();
const path = `/api/v3/meetings${queryStr ? `?${queryStr}` : ""}`;
const response = await opClient.get<HalCollection<HalResource>>(path);
const rawElements = response._embedded?.elements ?? [];
const elements = rawElements.map(normalizeMeetingSummary);
return {
total: response.total ?? elements.length,
count: response.count ?? elements.length,
pageSize: response.pageSize ?? elements.length,
offset: response.offset ?? 1,
elements,
items: elements,
};
}
/**
* Retrieves a single meeting by ID, optionally including its agenda items.
*/
export async function getMeeting(
id: number,
options?: { includeAgendaItems?: boolean },
client?: OpenProjectClient
): Promise<MeetingDetail> {
const opClient = resolveClient(client);
const includeAgenda = options?.includeAgendaItems ?? true;
const meetingPromise = opClient.get<HalResource>(`/api/v3/meetings/${id}`);
if (includeAgenda) {
const [meetingResource, agendaResponse] = await Promise.all([
meetingPromise,
opClient
.get<HalCollection<Record<string, unknown>>>(`/api/v3/meetings/${id}/agenda_items`)
.catch((error: unknown) => {
// An expired token or a rate limit must not be reported to the
// model as "this meeting has no agenda items". Degrade only on
// errors that genuinely mean "agenda unavailable" (404, 500, ...).
if (isFatalSearchError(error)) {
throw error;
}
return undefined;
}),
]);
let agendaItems: AgendaItem[] | undefined;
if (agendaResponse?._embedded?.elements) {
agendaItems = agendaResponse._embedded.elements.map(normalizeAgendaItem);
} else {
agendaItems = [];
}
return normalizeMeetingDetail(meetingResource, agendaItems);
}
const meetingResource = await meetingPromise;
return normalizeMeetingDetail(meetingResource);
}
interface MeetingDeepContent {
id: number;
agendaItems: AgendaItem[];
participants: Array<{ id: number; name: string }>;
}
/**
* Fetches agenda items and participants for one meeting.
*/
async function fetchMeetingDeepContent(
meetingId: number,
client: OpenProjectClient
): Promise<MeetingDeepContent> {
const [agendaResponse, detail] = await Promise.all([
client.get<HalCollection<Record<string, unknown>>>(
`/api/v3/meetings/${meetingId}/agenda_items`
),
client.get<HalResource>(`/api/v3/meetings/${meetingId}`),
]);
const rawItems = agendaResponse?._embedded?.elements ?? [];
const meetingDetail = normalizeMeetingDetail(detail);
return {
id: meetingId,
agendaItems: rawItems.map(normalizeAgendaItem),
participants: meetingDetail.participants,
};
}
/**
* Maps matched field names onto the legacy matchType discriminator, in
* priority order.
*/
function resolveMatchType(matchedFields: string[]): MeetingSearchResult["matchType"] {
if (matchedFields.includes("title")) return "title";
if (matchedFields.includes("location")) return "location";
if (
matchedFields.includes("agenda_item") ||
matchedFields.includes("agenda_notes") ||
matchedFields.includes("outcome_notes")
) {
return "agenda_item";
}
if (matchedFields.includes("participant")) return "participant";
// `author` sits below `participant` and above the `project` fallback: both
// are person matches, and author carries the lower field weight. Without
// this branch an author-only hit falls through and is reported to the model
// as a project-name match.
if (matchedFields.includes("author")) return "author";
return "project";
}
/**
* Ranks a meeting's agenda items against the query directly.
*
* Do NOT try to work out which item matched by searching the meeting-level
* snippet: snippets are trimmed and ellipsized, so the original text is not
* recoverable from them. Ranking the items themselves gives exact per-item
* scores and snippets for free.
*/
function collectMatchedAgendaItems(
deep: MeetingDeepContent | undefined,
query: string,
matchMode: MatchMode
): Array<{ id: number; title: string; snippet?: string }> {
if (!deep || deep.agendaItems.length === 0) {
return [];
}
const fields: FieldSpec<AgendaItem>[] = [
{ name: "title", weight: 2, extract: (item) => item.title },
{ name: "notes", weight: 1.5, extract: (item) => item.notes },
{
name: "outcomes",
weight: 1.5,
extract: (item) => item.outcomes.map((outcome) => outcome.notes),
},
];
return rankRecords(deep.agendaItems, query, fields, { matchMode }).map((entry) => ({
id: entry.record.id,
title: entry.record.title,
snippet: entry.matches[0]?.snippet,
}));
}
/**
* Deep searches meetings across titles, locations, agenda items,
* participants, and project names using fuzzy matching.
*/
export async function searchMeetings(
params: SearchMeetingsParams,
client?: OpenProjectClient
): Promise<MeetingSearchPage> {
const opClient = resolveClient(client);
const matchMode = params.matchMode ?? "fuzzy";
const MAX_CANDIDATES = 250;
const CANDIDATE_BATCH_SIZE = 100;
const fetchCandidates = async (): Promise<MeetingSummary[]> => {
const collected: MeetingSummary[] = [];
let offset = 1;
while (collected.length < MAX_CANDIDATES) {
const page = await listMeetings(
{ projectId: params.projectId, offset, pageSize: CANDIDATE_BATCH_SIZE },
opClient
);
if (page.elements.length === 0) {
break;
}
collected.push(...page.elements);
if (collected.length >= page.total || page.elements.length < CANDIDATE_BATCH_SIZE) {
break;
}
offset += 1;
}
return collected.slice(0, MAX_CANDIDATES);
};
const shallowFields: FieldSpec<MeetingSummary>[] = [
{ name: "title", weight: 3, extract: (m) => m.title },
{ name: "location", weight: 1, extract: (m) => m.location },
{ name: "project", weight: 0.5, extract: (m) => m.project.name },
{ name: "author", weight: 0.5, extract: (m) => m.author?.name },
];
const deepFields: FieldSpec<MeetingDeepContent>[] = [
{ name: "agenda_item", weight: 2, extract: (d) => d.agendaItems.map((i) => i.title) },
{
name: "agenda_notes",
weight: 1.5,
extract: (d) => d.agendaItems.map((i) => i.notes ?? "").filter((n) => n.length > 0),
},
{
name: "outcome_notes",
weight: 1.5,
extract: (d) => d.agendaItems.flatMap((i) => i.outcomes.map((o) => o.notes)),
},
{ name: "participant", weight: 1, extract: (d) => d.participants.map((p) => p.name) },
];
const result = await searchPipeline<MeetingSummary, MeetingDeepContent>(
{
fetchCandidates,
shallowFields,
enrich: (meeting) => fetchMeetingDeepContent(meeting.id, opClient),
deepFields,
recencyOf: (meeting) => meeting.startTime,
idOf: (meeting) => meeting.id,
},
params.query,
{ matchMode }
);
const all: MeetingSearchResult[] = result.ranked.map((entry) => {
const matchedFields = entry.matches.map((match) => match.field);
const matchedAgendaItems = collectMatchedAgendaItems(entry.deep, params.query, matchMode);
return {
meeting: entry.record,
matchType: resolveMatchType(matchedFields),
matchedAgendaItems: matchedAgendaItems.length > 0 ? matchedAgendaItems : undefined,
score: entry.score,
matchedFields,
};
});
// `offset` is a 1-based PAGE number, as the tool schema documents and as the
// rest of this codebase and the OpenProject API treat it — not an item index.
const offset = params.offset ?? 1;
const pageSize = params.pageSize ?? 20;
const startIndex = Math.max(0, offset - 1) * pageSize;
const paged = all.slice(startIndex, startIndex + pageSize);
return {
total: all.length,
count: paged.length,
pageSize,
offset,
elements: paged,
degraded: result.degraded,
enrichmentFailures: result.enrichmentFailures,
};
}
+221
View File
@@ -0,0 +1,221 @@
/**
* OpenAPI 3.0 Introspection Domain Service.
*/
import type { OpenProjectClient } from "../client/api-client.ts";
import { OpenProjectNotFoundError } from "../client/errors.ts";
import { resolveClient } from "./helper.ts";
export interface OpenApiQueryOptions {
path?: string;
tag?: string;
schema?: string;
full?: boolean;
refresh?: boolean;
}
export interface OpenApiSummary {
title: string;
version: string;
openapi: string;
totalPaths: number;
tags: Array<{ name: string; endpointCount: number }>;
availablePaths: string[];
instructions: string;
}
interface RawOpenApiOperation {
tags?: string[];
summary?: string;
operationId?: string;
parameters?: Array<{ name: string; in?: string; [key: string]: unknown }>;
responses?: Record<string, unknown>;
[key: string]: unknown;
}
interface RawOpenApiDoc {
openapi: string;
info: { title: string; version: string; description?: string };
paths: Record<string, Record<string, RawOpenApiOperation>>;
components?: {
schemas?: Record<string, unknown>;
[key: string]: unknown;
};
[key: string]: unknown;
}
// In-memory cache keyed by baseUrl
const openApiCache = new Map<string, RawOpenApiDoc>();
/**
* Clears the in-memory OpenAPI cache.
*/
export function clearOpenApiCache(): void {
openApiCache.clear();
}
/**
* Normalizes an API path for flexible querying.
*/
function normalizePath(path: string): string {
let clean = path.trim();
if (!clean.startsWith("/")) {
clean = `/${clean}`;
}
if (!clean.startsWith("/api/v3")) {
clean = `/api/v3${clean}`;
}
return clean.toLowerCase();
}
/**
* Fetches and filters OpenProject OpenAPI 3.0 specification.
*/
export async function getOpenApiSpec(
options?: OpenApiQueryOptions,
client?: OpenProjectClient
): Promise<unknown> {
const activeClient = resolveClient(client);
const cacheKey = activeClient.getCacheKey?.() ?? activeClient.baseUrl;
if (options?.refresh) {
openApiCache.delete(cacheKey);
}
let doc = openApiCache.get(cacheKey);
if (!doc) {
doc = await activeClient.get<RawOpenApiDoc>("openapi.json");
openApiCache.set(cacheKey, doc);
}
// 1. Full mode
if (options?.full) {
return doc;
}
const allPaths = Object.keys(doc.paths || {});
// 2. Specific path lookup
if (options?.path) {
const target = normalizePath(options.path);
const suffix = target.replace(/^\/api\/v3/, "");
const matchedKey =
allPaths.find((p) => p.toLowerCase() === target) ||
(suffix.length > 0 && suffix !== "/"
? allPaths.find((p) => p.toLowerCase().endsWith(suffix))
: undefined);
if (!matchedKey || !doc.paths[matchedKey]) {
const sample = allPaths.slice(0, 10).join(", ");
throw new OpenProjectNotFoundError(
`OpenAPI path '${options.path}' not found. Available paths include: ${sample}...`,
{ errorIdentifier: "urn:openproject-org:api:v3:errors:NotFound" }
);
}
return {
path: matchedKey,
operations: doc.paths[matchedKey],
};
}
// 3. Tag filtering
if (options?.tag) {
const targetTag = options.tag.trim().toLowerCase();
const matchedPaths: Record<string, unknown> = {};
for (const [pathKey, methods] of Object.entries(doc.paths || {})) {
let pathMatched = false;
for (const op of Object.values(methods)) {
if (
op &&
typeof op === "object" &&
Array.isArray(op.tags) &&
op.tags.some((t: string) => t.toLowerCase() === targetTag)
) {
pathMatched = true;
break;
}
}
if (pathMatched) {
matchedPaths[pathKey] = methods;
}
}
const matchedCount = Object.keys(matchedPaths).length;
if (matchedCount === 0) {
const availableTags = Array.from(
new Set(
Object.values(doc.paths || {}).flatMap((methods) =>
Object.values(methods).flatMap((op) =>
op && typeof op === "object" && Array.isArray(op.tags) ? op.tags : []
)
)
)
).sort();
throw new OpenProjectNotFoundError(
`OpenAPI tag '${options.tag}' not found. Available tags include: ${availableTags.slice(0, 15).join(", ")}...`,
{ errorIdentifier: "urn:openproject-org:api:v3:errors:NotFound" }
);
}
return {
tag: options.tag,
totalPaths: matchedCount,
paths: matchedPaths,
};
}
// 4. Schema model lookup
if (options?.schema) {
const targetSchema = options.schema.trim().toLowerCase();
const schemas = doc.components?.schemas || {};
const matchedKey = Object.keys(schemas).find((s) => s.toLowerCase() === targetSchema);
if (!matchedKey || !schemas[matchedKey]) {
const availableSchemas = Object.keys(schemas).slice(0, 15).join(", ");
throw new OpenProjectNotFoundError(
`OpenAPI schema '${options.schema}' not found in components.schemas. Available schemas include: ${availableSchemas}...`,
{ errorIdentifier: "urn:openproject-org:api:v3:errors:NotFound" }
);
}
return {
schemaName: matchedKey,
schema: schemas[matchedKey],
};
}
// 5. Default: Summary Overview
const tagCounts = new Map<string, number>();
for (const methods of Object.values(doc.paths || {})) {
const seenInPath = new Set<string>();
for (const op of Object.values(methods)) {
if (op && typeof op === "object" && Array.isArray(op.tags)) {
for (const t of op.tags) {
seenInPath.add(t);
}
}
}
for (const t of seenInPath) {
tagCounts.set(t, (tagCounts.get(t) || 0) + 1);
}
}
const sortedTags = Array.from(tagCounts.entries())
.map(([name, endpointCount]) => ({ name, endpointCount }))
.sort((a, b) => a.name.localeCompare(b.name));
const summary: OpenApiSummary = {
title: doc.info?.title || "OpenProject API V3",
version: doc.info?.version || "3",
openapi: doc.openapi || "3.0.0",
totalPaths: allPaths.length,
tags: sortedTags,
availablePaths: allPaths,
instructions:
"Call openproject_get_openapi_spec with 'path' (e.g. '/api/v3/work_packages'), 'tag' (e.g. 'Work Packages'), or 'schema' (e.g. 'WorkPackageModel') to inspect detailed schemas and operations without requesting the full payload.",
};
return summary;
}
+2 -2
View File
@@ -88,9 +88,9 @@ export async function getQueryResults(
return unpackCollection(response, normalizeWorkPackageSummary);
} catch (err) {
if (err instanceof OpenProjectNotFoundError) {
// In OpenProject API v3, query results endpoint is dynamically linked in query._links.results.href
const queryDetail = await opClient.get<HalResource>(`queries/${id}`);
const resultsHref = queryDetail._links?.results?.href;
const resultsLink = queryDetail._links?.results;
const resultsHref = Array.isArray(resultsLink) ? resultsLink?.[0]?.href : resultsLink?.href;
if (typeof resultsHref === "string" && resultsHref.length > 0) {
const response = await opClient.get<HalCollection<HalResource>>(resultsHref, query);
return unpackCollection(response, normalizeWorkPackageSummary);
+447
View File
@@ -0,0 +1,447 @@
/**
* Wikis Domain Service.
* Provides functions to inspect, search, and list OpenProject wiki pages and wiki page links.
*/
import {
OpenProjectAuthenticationError,
type OpenProjectClient,
} from "../client/api-client.ts";
import { extractIdFromHref, extractRawText } from "../client/hal-parser.ts";
import type { HalCollection, HalLink, HalResource } from "../client/types.ts";
import { resolveClient, resolveProjectId } from "./helper.ts";
import { rankRecords, type FieldSpec, type MatchMode } from "../search/rank.ts";
import { isFatalSearchError } from "../search/pipeline.ts";
export interface WikiPageAttachment {
id: number;
fileName: string;
fileSize: number;
contentType: string;
downloadUrl: string;
}
export interface WikiPageDetail {
id: number;
title: string;
/** Raw markdown body from the formattable `text` field. */
text?: string;
project: { id: number; identifier?: string; name?: string };
attachments: WikiPageAttachment[];
}
export interface WikiPageSummary {
id: number;
title: string;
project: { id: number; identifier?: string; name?: string };
attachmentsCount: number;
}
export interface WikiPageLink {
id: number;
pageTitle?: string;
pageUrl?: string;
provider?: string;
workPackage?: { id: number; title?: string };
}
export interface PaginatedResult<T> {
total: number;
count: number;
pageSize: number;
offset: number;
elements: T[];
items?: T[];
}
export interface ListWikiPageLinksParams {
workPackageId?: number;
offset?: number;
pageSize?: number;
}
export interface SearchWikiPagesParams {
query?: string;
projectId?: string | number;
limit?: number;
refreshCache?: boolean;
matchMode?: MatchMode;
}
export type WikiPageSearchResult = WikiPageSummary & {
/** Relevance score in the range 0..1. */
score: number;
snippet?: string;
matchedFields: string[];
};
// In-memory cache for discovered wiki pages keyed by client cache key
const wikiPagesCache = new Map<string, Map<number, WikiPageDetail>>();
const discoveredCacheKeys = new Set<string>();
/**
* Clears the in-memory wiki discovery cache.
*/
export function clearWikiCache(): void {
wikiPagesCache.clear();
discoveredCacheKeys.clear();
}
/**
* Normalizes a raw HAL attachment resource into a WikiPageAttachment.
*/
export function normalizeWikiAttachment(element: HalResource): WikiPageAttachment {
const links = (element._links ?? {}) as Record<string, HalLink | undefined>;
const downloadHref =
(links.downloadLocation as HalLink | undefined)?.href ??
(links.self as HalLink | undefined)?.href ??
"";
return {
id: element.id ?? extractIdFromHref(downloadHref) ?? 0,
fileName: String(element.fileName ?? element.name ?? ""),
fileSize: Number(element.fileSize ?? element.size ?? 0),
contentType: String(element.contentType ?? element.mimeType ?? ""),
downloadUrl: downloadHref,
};
}
/**
* Normalizes a raw HAL WikiPage resource and its attachments into a WikiPageDetail.
*/
export function normalizeWikiPageDetail(
resource: HalResource,
attachments: WikiPageAttachment[] = []
): WikiPageDetail {
const links = (resource._links ?? {}) as Record<string, HalLink | undefined>;
const projectLink = links.project;
const embedded = (resource._embedded ?? {}) as Record<string, unknown>;
const embeddedProject = embedded.project as { id?: number; identifier?: string; name?: string } | undefined;
const projectId = extractIdFromHref(projectLink?.href) ?? embeddedProject?.id ?? 0;
const projectName = projectLink?.title ?? embeddedProject?.name;
const projectIdentifier =
embeddedProject?.identifier ?? (projectLink as { identifier?: string } | undefined)?.identifier;
const project: { id: number; identifier?: string; name?: string } = {
id: projectId,
...(projectIdentifier ? { identifier: projectIdentifier } : {}),
...(projectName ? { name: projectName } : {}),
};
const id = resource.id ?? extractIdFromHref(links.self?.href) ?? 0;
const title = String(resource.title ?? "");
const text = extractRawText(resource.text);
return {
id,
title,
...(text ? { text } : {}),
project,
attachments,
};
}
/**
* Converts a WikiPageDetail into a token-efficient WikiPageSummary.
*/
export function toWikiPageSummary(detail: WikiPageDetail): WikiPageSummary {
return {
id: detail.id,
title: detail.title,
project: detail.project,
attachmentsCount: detail.attachments.length,
};
}
/**
* Normalizes a raw HAL WikiPageLink resource into a clean WikiPageLink.
*/
export function normalizeWikiPageLink(resource: HalResource): WikiPageLink {
const links = (resource._links ?? {}) as Record<string, HalLink | undefined>;
const selfLink = links.self;
const wpLink = links.workPackage;
const wikiPageLink = links.wikiPage ?? links["wiki_page"];
const embedded = (resource._embedded ?? {}) as Record<string, unknown>;
const embeddedWp = embedded.workPackage as { id?: number; subject?: string; title?: string } | undefined;
const id = resource.id ?? extractIdFromHref(selfLink?.href) ?? 0;
const pageTitle =
(typeof resource.pageTitle === "string" ? resource.pageTitle : undefined) ??
(typeof resource.title === "string" ? resource.title : undefined) ??
wikiPageLink?.title;
const pageUrl =
(typeof resource.pageUrl === "string" ? resource.pageUrl : undefined) ??
(typeof resource.url === "string" ? resource.url : undefined) ??
wikiPageLink?.href;
const provider = typeof resource.provider === "string" ? resource.provider : undefined;
const wpId = extractIdFromHref(wpLink?.href) ?? embeddedWp?.id;
const wpTitle = wpLink?.title ?? embeddedWp?.title ?? embeddedWp?.subject;
const workPackage =
wpId !== undefined
? {
id: wpId,
...(wpTitle ? { title: wpTitle } : {}),
}
: undefined;
return {
id,
...(pageTitle ? { pageTitle } : {}),
...(pageUrl ? { pageUrl } : {}),
...(provider ? { provider } : {}),
...(workPackage ? { workPackage } : {}),
};
}
/**
* Retrieves a single wiki page by ID including its attachments.
*/
export async function getWikiPage(
id: number,
client?: OpenProjectClient
): Promise<WikiPageDetail> {
const opClient = resolveClient(client);
const pageResource = await opClient.get<HalResource>(`/api/v3/wiki_pages/${id}`);
let attachments: WikiPageAttachment[] = [];
try {
const attachmentsResponse = await opClient.get<HalCollection<HalResource>>(
`/api/v3/wiki_pages/${id}/attachments`
);
if (attachmentsResponse._embedded?.elements) {
attachments = attachmentsResponse._embedded.elements.map(normalizeWikiAttachment);
}
} catch (error: unknown) {
// A 401/403/429 here is not "this page has no attachments" — it means we
// could not read. Swallowing it caches (and reports) an empty attachment
// list as fact, and during a discovery scan eats the same rate limit once
// per probed page.
if (isFatalSearchError(error)) {
throw error;
}
// Otherwise (404, 500, network) degrade to an empty list.
}
const detail = normalizeWikiPageDetail(pageResource, attachments);
// Update in-memory cache for this client cache key if initialized
const cacheKey = opClient.getCacheKey?.() ?? (opClient.baseUrl || "default");
let cacheMap = wikiPagesCache.get(cacheKey);
if (!cacheMap) {
cacheMap = new Map<number, WikiPageDetail>();
wikiPagesCache.set(cacheKey, cacheMap);
}
cacheMap.set(detail.id, detail);
return detail;
}
/**
* Lists wiki page links, optionally filtered by work package ID.
*/
export async function listWikiPageLinks(
params?: ListWikiPageLinksParams,
client?: OpenProjectClient
): Promise<PaginatedResult<WikiPageLink>> {
const opClient = resolveClient(client);
const path =
params?.workPackageId !== undefined
? `/api/v3/work_packages/${params.workPackageId}/wiki_page_links`
: `/api/v3/wiki_page_links`;
const query: Record<string, string | number | boolean | undefined> = {};
if (params?.offset !== undefined) query.offset = params.offset;
if (params?.pageSize !== undefined) query.pageSize = params.pageSize;
const response = await opClient.get<HalCollection<HalResource>>(path, query);
const items = (response._embedded?.elements ?? []).map(normalizeWikiPageLink);
return {
total: response.total ?? items.length,
count: response.count ?? items.length,
pageSize: response.pageSize ?? items.length,
offset: response.offset ?? 1,
elements: items,
items,
};
}
/**
* Searches wiki pages using in-memory caching and smart discovery scanning.
*/
export async function searchWikiPages(
params?: SearchWikiPagesParams,
client?: OpenProjectClient
): Promise<WikiPageSearchResult[]> {
const opClient = resolveClient(client);
const cacheKey = opClient.getCacheKey?.() ?? (opClient.baseUrl || "default");
let cacheMap = wikiPagesCache.get(cacheKey);
const needsDiscovery = !cacheMap || !discoveredCacheKeys.has(cacheKey) || params?.refreshCache;
if (params?.refreshCache && cacheMap) {
cacheMap.clear();
}
if (!cacheMap) {
cacheMap = new Map<number, WikiPageDetail>();
wikiPagesCache.set(cacheKey, cacheMap);
}
if (needsDiscovery) {
// `cacheMap` is the tenant-wide map that getWikiPage ALSO writes to on
// every single-page fetch, so it is not empty just because discovery has
// not run: one prior openproject_get_wiki_page call on this cache key
// leaves an entry behind. Completion must therefore be judged on growth
// during this scan, not on absolute size — otherwise "get a page, then
// search" (an ordinary LLM tool sequence) lets an all-errors scan inherit
// that entry and mark itself complete.
const cachedBefore = cacheMap.size;
// 1. Check wiki_page_links to collect referenced wiki page IDs
const discoveredIds = new Set<number>();
try {
const linksResponse = await opClient.get<HalCollection<HalResource>>("/api/v3/wiki_page_links", {
pageSize: 100,
});
const elements = linksResponse._embedded?.elements ?? [];
for (const element of elements) {
const links = (element._links ?? {}) as Record<string, HalLink | undefined>;
const wikiPageLink = links.wikiPage ?? links["wiki_page"];
const idFromLink = extractIdFromHref(wikiPageLink?.href);
if (idFromLink !== undefined) {
discoveredIds.add(idFromLink);
}
if (element.pageUrl && typeof element.pageUrl === "string") {
const idFromUrl = extractIdFromHref(element.pageUrl);
if (idFromUrl !== undefined) {
discoveredIds.add(idFromUrl);
}
}
}
} catch (err: unknown) {
if (
err instanceof OpenProjectAuthenticationError ||
(err as { statusCode?: number })?.statusCode === 401 ||
(err as { statusCode?: number })?.statusCode === 403 ||
(err as { statusCode?: number })?.statusCode === 429
) {
throw err;
}
// Ignore errors if wiki_page_links is not supported or accessible (e.g. 404)
}
// 2. Sequential probing of IDs 1..MAX_PROBE_ID with consecutive 404 cutoff
const MAX_PROBE_ID = 50;
const CONSECUTIVE_404_CUTOFF = 5;
let consecutiveMisses = 0;
// True only when the scan stopped because the 404 cutoff said "the wiki
// ends here", as opposed to erroring its way through every probe.
let reachedMissCutoff = false;
for (let id = 1; id <= MAX_PROBE_ID; id++) {
try {
const pageDetail = await getWikiPage(id, opClient);
cacheMap.set(id, pageDetail);
consecutiveMisses = 0;
} catch (err: unknown) {
if (
err instanceof OpenProjectAuthenticationError ||
(err as { statusCode?: number })?.statusCode === 401 ||
(err as { statusCode?: number })?.statusCode === 403 ||
(err as { statusCode?: number })?.statusCode === 429
) {
throw err;
}
if ((err as { statusCode?: number })?.statusCode === 404) {
consecutiveMisses++;
}
if (consecutiveMisses >= CONSECUTIVE_404_CUTOFF) {
reachedMissCutoff = true;
break;
}
}
}
// Also fetch any referenced wiki page IDs discovered from wiki_page_links that weren't probed
for (const id of discoveredIds) {
if (!cacheMap.has(id)) {
try {
const pageDetail = await getWikiPage(id, opClient);
cacheMap.set(id, pageDetail);
} catch (err: unknown) {
if (
err instanceof OpenProjectAuthenticationError ||
(err as { statusCode?: number })?.statusCode === 401 ||
(err as { statusCode?: number })?.statusCode === 403 ||
(err as { statusCode?: number })?.statusCode === 429
) {
throw err;
}
// Ignore missing pages
}
}
}
// Mark discovery complete only when it genuinely completed. If every
// probe failed with a 500 or a network error the cutoff never trips and
// this scan caches nothing — marking that scan "done" would short-circuit
// every later search for the life of the process and return [] forever.
// Either condition on its own is proof of a real scan: a page cached BY
// THIS SCAN means we read the wiki, and the 404 cutoff means we read to
// its end (a genuinely empty wiki is 404s all the way down, and must
// still be marked complete or every search re-probes it).
const discoveryCompleted = cacheMap.size > cachedBefore || reachedMissCutoff;
if (discoveryCompleted) {
discoveredCacheKeys.add(cacheKey);
}
}
// 3. Resolve target project ID if provided
let targetProjectId: number | undefined;
if (params?.projectId !== undefined) {
targetProjectId = await resolveProjectId(params.projectId, opClient);
}
// 4. Scope cached pages to the target project
const allPages = Array.from(cacheMap.values()).filter(
(page) => targetProjectId === undefined || page.project.id === targetProjectId
);
// 5. Rank by relevance
const query = params?.query?.trim() ?? "";
if (query.length === 0) {
const limited =
params?.limit !== undefined && params.limit > 0
? allPages.slice(0, params.limit)
: allPages;
return limited.map((page) => ({
...toWikiPageSummary(page),
score: 0,
matchedFields: [],
}));
}
const fields: FieldSpec<WikiPageDetail>[] = [
{ name: "title", weight: 3, extract: (page) => page.title },
{ name: "text", weight: 1, extract: (page) => page.text },
];
const ranked = rankRecords(allPages, query, fields, {
matchMode: params?.matchMode ?? "fuzzy",
limit: params?.limit,
});
return ranked.map((entry) => ({
...toWikiPageSummary(entry.record),
score: entry.score,
snippet: entry.matches[0]?.snippet,
matchedFields: entry.matches.map((match) => match.field),
}));
}
+292 -8
View File
@@ -18,6 +18,8 @@ import type {
WorkPackageSummary,
} from "../client/types.ts";
import { resolveClient, resolveProjectId } from "./helper.ts";
import { isFatalSearchError, searchPipeline } from "../search/pipeline.ts";
import { rankRecords, type FieldSpec, type MatchMode } from "../search/rank.ts";
export interface ListWorkPackagesParams extends WorkPackageFilterParams {
pageSize?: number;
@@ -29,6 +31,10 @@ export interface SearchWorkPackagesOptions {
projectId?: number | string;
pageSize?: number;
offset?: number;
status?: "open" | "closed" | string | number;
typeId?: number | string;
assigneeId?: number | string;
matchMode?: MatchMode;
}
/**
@@ -73,21 +79,299 @@ export async function getWorkPackage(
return normalizeWorkPackage(response);
}
export interface WorkPackageSearchResult {
workPackage: WorkPackageSummary;
/** Relevance score in the range 0..1. */
score: number;
matchedFields: string[];
snippet?: string;
}
/**
* Convenience helper to search work packages by subject text, optionally within a project.
* Search results are exposed under `elements` only. `items` is deliberately
* omitted: the tool layer JSON-stringifies this whole object, so carrying the
* same array twice doubles the token cost of every search response.
*/
export interface WorkPackageSearchPage
extends Omit<PaginatedResult<WorkPackageSearchResult>, "items"> {
elements: WorkPackageSearchResult[];
degraded: boolean;
enrichmentFailures: number;
}
interface WorkPackageDeepContent {
id: number;
description: string;
comments: string[];
}
const MAX_WORK_PACKAGE_CANDIDATES = 250;
/**
* OpenProject enforces a server-side maximum page size, and every tool shape
* in this repo caps `pageSize` at 100. Asking for 250 in a single request
* therefore risks silently receiving fewer candidates than the code assumes,
* so the candidate window is paged in batches instead.
*/
const CANDIDATE_BATCH_SIZE = 100;
/**
* Fetches the description and comment text for one work package.
*/
async function fetchWorkPackageDeepContent(
workPackageId: number,
client: OpenProjectClient
): Promise<WorkPackageDeepContent> {
const [detail, activities] = await Promise.all([
getWorkPackage(workPackageId, client),
listWorkPackageActivities({ workPackageId, onlyComments: true }, client),
]);
return {
id: workPackageId,
description: detail.description ?? "",
comments: activities
.map((activity) => activity.comment ?? "")
.filter((comment) => comment.length > 0),
};
}
/**
* Searches work packages by subject, description, and comments using fuzzy
* matching.
*
* Candidates come from the union of two parallel requests: the precise
* server-side `subject ~ query` filter (so every result the old
* implementation returned is still returned) and a broad recency-ordered
* window (so fuzzy matching has something to rank). Fuzzy matching therefore
* only ever adds results, never removes them.
*/
export async function searchWorkPackages(
query: string,
options?: SearchWorkPackagesOptions,
client?: OpenProjectClient
): Promise<PaginatedResult<WorkPackageSummary>> {
return listWorkPackages(
): Promise<WorkPackageSearchPage> {
const opClient = resolveClient(client);
const matchMode = options?.matchMode ?? "fuzzy";
const sharedFilters = {
projectId: options?.projectId,
status: options?.status,
typeId: options?.typeId,
assigneeId: options?.assigneeId,
};
// A non-fatal failure on one leg (e.g. a transient 500) must not kill the
// whole search: the other leg's candidates are still worth ranking. A
// fatal failure (401/403/429) must propagate rather than degrade into "0
// candidates" — an expired token must not be reported to the model as "no
// results found".
const listCandidates = async (
params: ListWorkPackagesParams
): Promise<{ items: WorkPackageSummary[]; total: number }> => {
try {
return await listWorkPackages(params, opClient);
} catch (error: unknown) {
if (isFatalSearchError(error)) {
throw error;
}
return { items: [], total: 0 };
}
};
/**
* Accumulates one candidate leg in server-acceptable batches, up to the
* shared 250 cap. Stops once the server says the collection is exhausted
* (`collected.length >= total`) or hands back a short page.
*/
const collectLeg = async (
params: ListWorkPackagesParams
): Promise<WorkPackageSummary[]> => {
const collected: WorkPackageSummary[] = [];
let offset = 1;
while (collected.length < MAX_WORK_PACKAGE_CANDIDATES) {
const page = await listCandidates({
...params,
pageSize: CANDIDATE_BATCH_SIZE,
offset,
});
if (page.items.length === 0) {
break;
}
collected.push(...page.items);
if (collected.length >= page.total || page.items.length < CANDIDATE_BATCH_SIZE) {
break;
}
offset += 1;
}
return collected.slice(0, MAX_WORK_PACKAGE_CANDIDATES);
};
// Both legs run in every match mode. Exact mode must not be restricted to
// the server-side subject filter: the literal strings a caller reaches for
// exact mode with (error codes, ticket refs, commit SHAs) overwhelmingly
// live in descriptions and comments, which only the broad leg can surface.
const fetchCandidates = async (): Promise<WorkPackageSummary[]> => {
const [precise, broad] = await Promise.all([
collectLeg({ ...sharedFilters, subject: query }),
collectLeg({ ...sharedFilters, sortBy: '[["updatedAt","desc"]]' }),
]);
// Precise leg first: on a collision the exact subject match wins the slot,
// so exact matches always survive the 250 cap.
const byId = new Map<number, WorkPackageSummary>();
for (const item of [...precise, ...broad]) {
byId.set(item.id, item);
}
return Array.from(byId.values()).slice(0, MAX_WORK_PACKAGE_CANDIDATES);
};
const shallowFields: FieldSpec<WorkPackageSummary>[] = [
{ name: "subject", weight: 3, extract: (wp) => wp.subject },
];
const deepFields: FieldSpec<WorkPackageDeepContent>[] = [
{ name: "description", weight: 1, extract: (d) => d.description },
{ name: "comments", weight: 1, extract: (d) => d.comments },
];
const result = await searchPipeline<WorkPackageSummary, WorkPackageDeepContent>(
{
subject: query,
projectId: options?.projectId,
pageSize: options?.pageSize,
offset: options?.offset,
fetchCandidates,
shallowFields,
enrich: (workPackage) => fetchWorkPackageDeepContent(workPackage.id, opClient),
deepFields,
recencyOf: (workPackage) => workPackage.updatedAt ?? "",
idOf: (workPackage) => workPackage.id,
},
client
query,
{ matchMode }
);
const all: WorkPackageSearchResult[] = result.ranked.map((entry) => ({
workPackage: entry.record,
score: entry.score,
matchedFields: entry.matches.map((match) => match.field),
snippet: entry.matches[0]?.snippet,
}));
// `offset` is a 1-based PAGE number, as the tool schema documents and as the
// rest of this codebase and the OpenProject API treat it — not an item index.
const offset = options?.offset ?? 1;
const pageSize = options?.pageSize ?? 20;
const startIndex = Math.max(0, offset - 1) * pageSize;
const paged = all.slice(startIndex, startIndex + pageSize);
return {
total: all.length,
count: paged.length,
pageSize,
offset,
elements: paged,
degraded: result.degraded,
enrichmentFailures: result.enrichmentFailures,
};
}
export interface ActivityDetail {
format: string;
raw: string;
html?: string;
}
export interface WorkPackageActivity {
id: number;
version: number;
createdAt: string;
user?: { id: number; name: string };
comment?: string;
details: ActivityDetail[];
isComment: boolean;
}
export interface ListWorkPackageActivitiesOptions {
workPackageId: number;
onlyComments?: boolean;
/** Ranks the fetched activities client-side. No extra API calls. */
query?: string;
matchMode?: MatchMode;
}
/**
* Lists activities (field changes, comments, status updates) for a specific work package.
*/
export async function listWorkPackageActivities(
options: ListWorkPackageActivitiesOptions,
client?: OpenProjectClient
): Promise<WorkPackageActivity[]> {
const opClient = resolveClient(client);
const response = await opClient.get<Record<string, unknown>>(
`/api/v3/work_packages/${options.workPackageId}/activities`
);
const elements = (response._embedded as { elements?: unknown[] })?.elements ?? [];
const activities: WorkPackageActivity[] = [];
for (const item of elements) {
const rawItem = item as Record<string, unknown>;
const commentObj = rawItem.comment as { raw?: string } | undefined;
const commentRaw = typeof commentObj?.raw === "string" ? commentObj.raw.trim() : "";
const isComment = commentRaw.length > 0;
if (options.onlyComments && !isComment) {
continue;
}
const links = (rawItem._links as Record<string, { href?: string; title?: string }>) ?? {};
const userLink = links.user;
let user: { id: number; name: string } | undefined;
if (userLink?.href) {
const match = userLink.href.match(/\/users\/(\d+)/);
if (match) {
user = {
id: Number(match[1]),
name: userLink.title ?? `User #${match[1]}`
};
}
}
const rawDetails = (rawItem.details as Array<{ format?: string; raw?: string; html?: string }>) ?? [];
const details: ActivityDetail[] = rawDetails.map((d) => ({
format: d.format ?? "custom",
raw: d.raw ?? "",
html: d.html,
}));
activities.push({
id: Number(rawItem.id),
version: Number(rawItem.version ?? 1),
createdAt: String(rawItem.createdAt ?? ""),
user,
comment: commentRaw || undefined,
details,
isComment,
});
}
const query = options.query?.trim() ?? "";
if (query.length === 0) {
return activities;
}
const fields: FieldSpec<WorkPackageActivity>[] = [
{ name: "comment", weight: 3, extract: (activity) => activity.comment },
{
name: "details",
weight: 1,
extract: (activity) => activity.details.map((detail) => detail.raw),
},
];
return rankRecords(activities, query, fields, {
matchMode: options.matchMode ?? "fuzzy",
}).map((entry) => entry.record);
}
+38 -4
View File
@@ -65,20 +65,54 @@ export interface ToolDefinition<
execute: (args: TArgs) => Promise<McpToolResponse>;
}
/**
* Options for registering an individual tool on an McpServer instance.
*/
export interface RegisterToolOptions<
TShape extends ZodRawShape = ZodRawShape,
TArgs = Record<string, unknown>,
> {
/**
* Optional wrapper function to intercept tool execution, enabling
* context injection (runWithContext) or execution guards (e.g. read-only checks).
*/
wrapExecute?: (
fn: () => Promise<McpToolResponse>,
tool: ToolDefinition<TShape, TArgs>,
args: TArgs
) => Promise<McpToolResponse>;
}
/**
* Registers a ToolDefinition instance on an McpServer instance.
*
* @param server The McpServer instance to register the tool on.
* @param tool The ToolDefinition describing the tool metadata, parameters, and handler.
* @param options Optional registration options, such as an execution wrapper.
*/
export function registerTool<
TShape extends ZodRawShape = ZodRawShape,
TArgs = Record<string, unknown>,
>(server: McpServer, tool: ToolDefinition<TShape, TArgs>): void {
>(
server: McpServer,
tool: ToolDefinition<TShape, TArgs>,
options?: RegisterToolOptions<TShape, TArgs>
): void {
const executeFn = (args: TArgs) => {
if (options?.wrapExecute) {
return options.wrapExecute(() => tool.execute(args), tool, args);
}
return tool.execute(args);
};
if (tool.parameters) {
server.tool(tool.name, tool.description, tool.parameters, async (args) => {
return tool.execute(args as unknown as TArgs);
server.tool(tool.name, tool.description, tool.parameters as ZodRawShape, async (args) => {
return executeFn(args as unknown as TArgs);
});
} else {
server.tool(tool.name, tool.description, async () => {
return tool.execute({} as unknown as TArgs);
return executeFn({} as unknown as TArgs);
});
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Model Context Protocol (MCP) Tools Registry and Barrel Export.
*/
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
registerTool,
type ToolDefinition,
type RegisterToolOptions,
type McpToolResponse,
} from "./common";
import { projectTools } from "./projects";
import { workPackageTools } from "./work-packages";
import { queryTools } from "./queries";
import { metadataTools } from "./metadata";
import { openApiTools } from "./openapi";
import { meetingsTools, registerMeetingsTools } from "./meetings";
import { wikisTools, registerWikisTools } from "./wikis";
export * from "./common";
export * from "./projects";
export * from "./work-packages";
export * from "./queries";
export * from "./metadata";
export * from "./openapi";
export * from "./meetings";
export * from "./wikis";
export { registerMeetingsTools, registerWikisTools };
export type AnyToolDefinition =
| (typeof projectTools)[number]
| (typeof workPackageTools)[number]
| (typeof queryTools)[number]
| (typeof metadataTools)[number]
| (typeof openApiTools)[number]
| (typeof meetingsTools)[number]
| (typeof wikisTools)[number];
export const allTools: AnyToolDefinition[] = [
...projectTools,
...workPackageTools,
...queryTools,
...metadataTools,
...openApiTools,
...meetingsTools,
...wikisTools,
];
export interface RegisterToolsOptions {
readOnly?: boolean;
tools?: AnyToolDefinition[];
wrapExecute?: (
fn: () => Promise<McpToolResponse>,
tool: AnyToolDefinition,
args: Record<string, unknown>
) => Promise<McpToolResponse>;
}
/**
* Registers all OpenProject tools with the given McpServer instance.
* If options.readOnly is true, tools not marked as readOnly are skipped.
* If options.wrapExecute is provided, tool execution is intercepted through the wrapper.
*/
export function registerAllTools(
server: McpServer,
options?: RegisterToolsOptions
): void {
const isReadOnly = options?.readOnly ?? false;
const toolsToRegister = options?.tools ?? allTools;
for (const tool of toolsToRegister) {
// If server is in read-only mode, skip any non-read-only tools
if (isReadOnly && !tool.readOnly) {
continue;
}
const toolOptions: RegisterToolOptions | undefined = options?.wrapExecute
? {
wrapExecute: (fn, t, args) =>
options.wrapExecute!(
fn,
t as unknown as AnyToolDefinition,
args as Record<string, unknown>
),
}
: undefined;
registerTool(server, tool as unknown as ToolDefinition, toolOptions);
}
}
+198
View File
@@ -0,0 +1,198 @@
/**
* Meeting Tools for OpenProject MCP.
* Implements openproject_list_meetings, openproject_get_meeting, and openproject_search_meetings tools.
*/
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
formatToolSuccess,
formatToolError,
registerTool,
type McpToolResponse,
type ToolDefinition,
} from "./common";
import {
listMeetings,
getMeeting,
searchMeetings,
} from "../services/meetings";
export const listMeetingsShape = {
projectId: z
.union([z.number().int().positive(), z.string().min(1)])
.optional()
.describe("Filter meetings by project ID or identifier"),
time: z
.string()
.optional()
.describe("Filter by meeting time context ('upcoming' or 'past')"),
offset: z
.number()
.int()
.positive()
.optional()
.default(1)
.describe("Page number to retrieve (1-based, default 1)"),
pageSize: z
.number()
.int()
.positive()
.max(100)
.optional()
.default(20)
.describe("Number of items per page (max 100, default 20)"),
};
export type ListMeetingsArgs = z.input<z.ZodObject<typeof listMeetingsShape>>;
/**
* Tool execution handler for openproject_list_meetings.
*/
export async function handleListMeetings(
args: ListMeetingsArgs
): Promise<McpToolResponse> {
try {
const result = await listMeetings(args);
return formatToolSuccess(result);
} catch (error) {
return formatToolError(error);
}
}
export const getMeetingShape = {
id: z
.number()
.int()
.positive()
.describe("Numeric ID of the meeting"),
includeAgendaItems: z
.boolean()
.optional()
.default(true)
.describe("Whether to include agenda items and outcomes (default true)"),
};
export type GetMeetingArgs = z.input<z.ZodObject<typeof getMeetingShape>>;
/**
* Tool execution handler for openproject_get_meeting.
*/
export async function handleGetMeeting(
args: GetMeetingArgs
): Promise<McpToolResponse> {
try {
const result = await getMeeting(args.id, {
includeAgendaItems: args.includeAgendaItems ?? true,
});
return formatToolSuccess(result);
} catch (error) {
return formatToolError(error);
}
}
export const searchMeetingsShape = {
query: z
.string()
.min(1)
.describe(
"Search keywords. Natural-language phrasing works; matched against meeting " +
"titles, locations, project and author names, agenda item titles and notes, " +
"meeting outcomes, and participant names."
),
projectId: z
.union([z.number().int().positive(), z.string().min(1)])
.optional()
.describe("Scope search to a specific project by numeric ID or slug identifier"),
offset: z
.number()
.int()
.positive()
.optional()
.default(1)
.describe("Page number to retrieve (1-based, default 1)"),
pageSize: z
.number()
.int()
.positive()
.max(100)
.optional()
.default(20)
.describe("Number of items per page (max 100, default 20)"),
matchMode: z
.enum(["fuzzy", "exact"])
.optional()
.default("fuzzy")
.describe(
"Matching strategy. 'fuzzy' (default) tolerates typos, word reordering, and " +
"partial words. Use 'exact' only for literal strings you know verbatim."
),
};
export type SearchMeetingsArgs = z.input<z.ZodObject<typeof searchMeetingsShape>>;
/**
* Tool execution handler for openproject_search_meetings.
*/
export async function handleSearchMeetings(
args: SearchMeetingsArgs
): Promise<McpToolResponse> {
try {
const result = await searchMeetings(args);
return formatToolSuccess(result);
} catch (error) {
return formatToolError(error);
}
}
export const listMeetingsTool: ToolDefinition<
typeof listMeetingsShape,
ListMeetingsArgs
> = {
name: "openproject_list_meetings",
description: "List and filter meetings visible to the user.",
parameters: listMeetingsShape,
readOnly: true,
execute: handleListMeetings,
};
export const getMeetingTool: ToolDefinition<
typeof getMeetingShape,
GetMeetingArgs
> = {
name: "openproject_get_meeting",
description:
"Retrieve detailed meeting information including agenda items, sections, notes, and participants.",
parameters: getMeetingShape,
readOnly: true,
execute: handleGetMeeting,
};
export const searchMeetingsTool: ToolDefinition<
typeof searchMeetingsShape,
SearchMeetingsArgs
> = {
name: "openproject_search_meetings",
description:
"Search meetings and agenda items. Accepts natural-language phrasing and " +
"tolerates typos and reordered words. Returns results ranked by relevance " +
"with matching excerpts.",
parameters: searchMeetingsShape,
readOnly: true,
execute: handleSearchMeetings,
};
export const meetingsTools = [
listMeetingsTool,
getMeetingTool,
searchMeetingsTool,
];
/**
* Registers all meetings tools with an McpServer instance.
*/
export function registerMeetingsTools(server: McpServer): void {
registerTool(server, listMeetingsTool);
registerTool(server, getMeetingTool);
registerTool(server, searchMeetingsTool);
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Metadata Tools for OpenProject MCP.
* Implements tools for inspecting OpenProject taxonomies and users:
* - openproject_list_types
* - openproject_list_statuses
* - openproject_list_priorities
* - openproject_list_users
*/
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
formatToolSuccess,
formatToolError,
registerTool,
type McpToolResponse,
type ToolDefinition,
} from "./common";
import {
listTypes,
listStatuses,
listPriorities,
listUsers,
} from "../services/metadata";
export const listTypesShape = {
projectId: z
.union([z.number().int().positive(), z.string().min(1)])
.optional()
.describe("Optional numeric project ID or identifier to scope types"),
};
export type ListTypesArgs = z.infer<z.ZodObject<typeof listTypesShape>>;
/**
* Tool execution handler for openproject_list_types.
*/
export async function handleListTypes(
args: ListTypesArgs = {}
): Promise<McpToolResponse> {
try {
const result = await listTypes(
args.projectId !== undefined ? { projectId: args.projectId } : undefined
);
return formatToolSuccess({
types: result,
});
} catch (error) {
return formatToolError(error);
}
}
export const listStatusesShape = {};
export type ListStatusesArgs = Record<string, unknown>;
/**
* Tool execution handler for openproject_list_statuses.
*/
export async function handleListStatuses(
_args: ListStatusesArgs = {}
): Promise<McpToolResponse> {
try {
const result = await listStatuses();
return formatToolSuccess({
statuses: result,
});
} catch (error) {
return formatToolError(error);
}
}
export const listPrioritiesShape = {};
export type ListPrioritiesArgs = Record<string, unknown>;
/**
* Tool execution handler for openproject_list_priorities.
*/
export async function handleListPriorities(
_args: ListPrioritiesArgs = {}
): Promise<McpToolResponse> {
try {
const result = await listPriorities();
return formatToolSuccess({
priorities: result,
});
} catch (error) {
return formatToolError(error);
}
}
export const listUsersShape = {
pageSize: z
.number()
.int()
.positive()
.max(100)
.optional()
.describe("Number of users to return per page (max 100, default 20)"),
offset: z
.number()
.int()
.positive()
.optional()
.describe("Page number to retrieve (1-based, default 1)"),
status: z
.string()
.optional()
.describe("Filter users by status (e.g. 'active', 'locked', 'invited')"),
};
export type ListUsersArgs = z.infer<z.ZodObject<typeof listUsersShape>>;
/**
* Tool execution handler for openproject_list_users.
*/
export async function handleListUsers(
args: ListUsersArgs = {}
): Promise<McpToolResponse> {
try {
const result = await listUsers(args);
return formatToolSuccess({
users: result.items,
total: result.total,
count: result.count,
pageSize: result.pageSize,
offset: result.offset,
});
} catch (error) {
return formatToolError(error);
}
}
export const listTypesTool: ToolDefinition<
typeof listTypesShape,
ListTypesArgs
> = {
name: "openproject_list_types",
description:
"List all available work package types (Task, Bug, Milestone, Feature, etc.), optionally scoped to a project",
parameters: listTypesShape,
readOnly: true,
execute: handleListTypes,
};
export const listStatusesTool: ToolDefinition<
typeof listStatusesShape,
ListStatusesArgs
> = {
name: "openproject_list_statuses",
description:
"List all available work package statuses (New, In Progress, Closed, etc.)",
parameters: listStatusesShape,
readOnly: true,
execute: handleListStatuses,
};
export const listPrioritiesTool: ToolDefinition<
typeof listPrioritiesShape,
ListPrioritiesArgs
> = {
name: "openproject_list_priorities",
description: "List all priority levels (Low, Normal, High, Immediate)",
parameters: listPrioritiesShape,
readOnly: true,
execute: handleListPriorities,
};
export const listUsersTool: ToolDefinition<
typeof listUsersShape,
ListUsersArgs
> = {
name: "openproject_list_users",
description: "List users in the OpenProject instance with optional pagination",
parameters: listUsersShape,
readOnly: true,
execute: handleListUsers,
};
export const metadataTools = [
listTypesTool,
listStatusesTool,
listPrioritiesTool,
listUsersTool,
];
/**
* Registers all metadata tools with an McpServer instance.
*/
export function registerMetadataTools(server: McpServer): void {
registerTool(server, listTypesTool);
registerTool(server, listStatusesTool);
registerTool(server, listPrioritiesTool);
registerTool(server, listUsersTool);
}
+89
View File
@@ -0,0 +1,89 @@
/**
* OpenAPI Tools for OpenProject MCP.
* Implements openproject_get_openapi_spec tool for dynamic API introspection.
*/
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { getOpenApiSpec } from "../services/openapi";
import {
formatToolSuccess,
formatToolError,
registerTool,
type ToolDefinition,
type RegisterToolOptions,
} from "./common";
export const getOpenApiSpecSchema = {
path: z
.string()
.optional()
.describe(
"Specific endpoint path to inspect (e.g. '/api/v3/work_packages' or 'projects')."
),
tag: z
.string()
.optional()
.describe(
"Tag or category to filter endpoints by (e.g. 'Work Packages', 'Queries', 'Projects')."
),
schema: z
.string()
.optional()
.describe(
"Component schema model name to inspect (e.g. 'WorkPackageModel')."
),
full: z
.boolean()
.optional()
.default(false)
.describe(
"Whether to return the complete raw OpenAPI 3.0 document. Warning: very large (~1.18 MB)."
),
refresh: z
.boolean()
.optional()
.default(false)
.describe(
"Whether to bypass the in-memory cache and fetch a fresh specification from the server."
),
};
export type GetOpenApiSpecArgs = z.infer<
z.ZodObject<typeof getOpenApiSpecSchema>
>;
export const getOpenApiSpecTool: ToolDefinition<
typeof getOpenApiSpecSchema,
GetOpenApiSpecArgs
> = {
name: "openproject_get_openapi_spec",
description:
"Retrieve OpenProject API v3 OpenAPI 3.0 specification (/api/v3/openapi.json) for dynamic endpoint discovery, parameter schemas, and entity models. Supports smart filtering by path, tag, or schema, or returns a token-efficient summary overview when called without parameters.",
readOnly: true,
parameters: getOpenApiSpecSchema,
execute: async (args: GetOpenApiSpecArgs) => {
try {
const data = await getOpenApiSpec(args);
return formatToolSuccess(data);
} catch (error) {
return formatToolError(error);
}
},
};
export const openApiTools: ToolDefinition[] = [
getOpenApiSpecTool as unknown as ToolDefinition,
];
/**
* Registers all OpenAPI tools with an McpServer instance.
*/
export function registerOpenApiTools(
server: McpServer,
options?: RegisterToolOptions
): void {
for (const tool of openApiTools) {
registerTool(server, tool, options);
}
}
+8 -2
View File
@@ -47,7 +47,13 @@ export async function handleListProjects(
try {
let filters: FilterElement[] | undefined;
if (args.filters) {
filters = JSON.parse(args.filters) as FilterElement[];
const parsed = JSON.parse(args.filters);
if (!Array.isArray(parsed)) {
return formatToolError(
new Error("filters parameter must be a JSON array string")
);
}
filters = parsed as FilterElement[];
}
const result = await listProjects({
@@ -71,7 +77,7 @@ export async function handleListProjects(
export const getProjectShape = {
projectId: z
.union([z.number().int().positive(), z.string()])
.union([z.number().int().positive(), z.string().min(1)])
.describe("Numeric project ID or string identifier/slug"),
};
+151
View File
@@ -0,0 +1,151 @@
/**
* Query Tools for OpenProject MCP.
* Implements openproject_list_queries and openproject_get_query tools.
*/
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
formatToolSuccess,
formatToolError,
registerTool,
type McpToolResponse,
type ToolDefinition,
} from "./common";
import { listQueries, getQuery, getQueryResults } from "../services/queries";
export const listQueriesShape = {
projectId: z
.union([z.number().int().positive(), z.string().min(1)])
.optional()
.describe("Optional project ID or identifier to scope saved queries"),
pageSize: z
.number()
.int()
.positive()
.max(100)
.optional()
.describe("Number of queries to return per page (max 100, default 20)"),
offset: z
.number()
.int()
.positive()
.optional()
.describe("Page number to retrieve (1-based, default 1)"),
};
export type ListQueriesArgs = z.infer<z.ZodObject<typeof listQueriesShape>>;
/**
* Tool execution handler for openproject_list_queries.
*/
export async function handleListQueries(
args: ListQueriesArgs
): Promise<McpToolResponse> {
try {
const result = await listQueries({
projectId: args.projectId,
pageSize: args.pageSize,
offset: args.offset,
});
return formatToolSuccess({
queries: result.items,
total: result.total,
count: result.count,
pageSize: result.pageSize,
offset: result.offset,
});
} catch (error) {
return formatToolError(error);
}
}
export const getQueryShape = {
queryId: z
.number()
.int()
.positive()
.describe("The numeric ID of the saved query"),
pageSize: z
.number()
.int()
.positive()
.max(100)
.optional()
.describe(
"Maximum number of result work packages to return (max 100, default 20)"
),
offset: z
.number()
.int()
.positive()
.optional()
.describe("Page number of results to retrieve (1-based, default 1)"),
};
export type GetQueryArgs = z.infer<z.ZodObject<typeof getQueryShape>>;
/**
* Tool execution handler for openproject_get_query.
*/
export async function handleGetQuery(
args: GetQueryArgs
): Promise<McpToolResponse> {
try {
const [query, results] = await Promise.all([
getQuery(args.queryId),
getQueryResults(args.queryId, {
pageSize: args.pageSize,
offset: args.offset,
}),
]);
return formatToolSuccess({
query,
results: {
workPackages: results.items,
total: results.total,
count: results.count,
pageSize: results.pageSize,
offset: results.offset,
},
});
} catch (error) {
return formatToolError(error);
}
}
export const listQueriesTool: ToolDefinition<
typeof listQueriesShape,
ListQueriesArgs
> = {
name: "openproject_list_queries",
description:
"List saved queries (custom views, filter sets) accessible to the user, optionally filtered by project",
parameters: listQueriesShape,
readOnly: true,
execute: handleListQueries,
};
export const getQueryTool: ToolDefinition<
typeof getQueryShape,
GetQueryArgs
> = {
name: "openproject_get_query",
description:
"Retrieve metadata and work package results for a saved query",
parameters: getQueryShape,
readOnly: true,
execute: handleGetQuery,
};
export const queryTools = [listQueriesTool, getQueryTool];
/**
* Registers all query tools with an McpServer instance.
*/
export function registerQueryTools(server: McpServer): void {
registerTool(server, listQueriesTool);
registerTool(server, getQueryTool);
}
+186
View File
@@ -0,0 +1,186 @@
/**
* Wiki Tools for OpenProject MCP.
* Implements openproject_get_wiki_page, openproject_search_wiki_pages, and openproject_list_wiki_page_links tools.
*/
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
formatToolSuccess,
formatToolError,
registerTool,
type McpToolResponse,
type ToolDefinition,
} from "./common";
import {
getWikiPage,
searchWikiPages,
listWikiPageLinks,
} from "../services/wikis";
export const getWikiPageShape = {
id: z
.number()
.int()
.positive()
.describe("Numeric ID of the wiki page"),
};
export type GetWikiPageArgs = z.input<z.ZodObject<typeof getWikiPageShape>>;
/**
* Tool execution handler for openproject_get_wiki_page.
*/
export async function handleGetWikiPage(
args: GetWikiPageArgs
): Promise<McpToolResponse> {
try {
const result = await getWikiPage(args.id);
return formatToolSuccess(result);
} catch (error) {
return formatToolError(error);
}
}
export const searchWikiPagesShape = {
query: z
.string()
.optional()
.describe(
"Search keywords matched against wiki page titles and page body text. " +
"Natural-language phrasing works."
),
projectId: z
.union([z.number().int().positive(), z.string().min(1)])
.optional()
.describe("Project ID or identifier to scope search"),
limit: z
.number()
.int()
.positive()
.max(100)
.optional()
.default(20)
.describe("Maximum results to return"),
refreshCache: z
.boolean()
.optional()
.default(false)
.describe("Force cache refresh"),
matchMode: z
.enum(["fuzzy", "exact"])
.optional()
.default("fuzzy")
.describe(
"Matching strategy. 'fuzzy' (default) tolerates typos, word reordering, and " +
"partial words. Use 'exact' only for literal strings you know verbatim."
),
};
export type SearchWikiPagesArgs = z.input<z.ZodObject<typeof searchWikiPagesShape>>;
/**
* Tool execution handler for openproject_search_wiki_pages.
*/
export async function handleSearchWikiPages(
args: SearchWikiPagesArgs
): Promise<McpToolResponse> {
try {
const result = await searchWikiPages(args);
return formatToolSuccess(result);
} catch (error) {
return formatToolError(error);
}
}
export const listWikiPageLinksShape = {
workPackageId: z
.number()
.int()
.positive()
.optional()
.describe("Work package ID to scope links"),
offset: z
.number()
.int()
.positive()
.optional()
.default(1)
.describe("Page number to retrieve (1-based, default 1)"),
pageSize: z
.number()
.int()
.positive()
.max(100)
.optional()
.default(30)
.describe("Number of items per page (max 100, default 30)"),
};
export type ListWikiPageLinksArgs = z.input<
z.ZodObject<typeof listWikiPageLinksShape>
>;
/**
* Tool execution handler for openproject_list_wiki_page_links.
*/
export async function handleListWikiPageLinks(
args: ListWikiPageLinksArgs
): Promise<McpToolResponse> {
try {
const result = await listWikiPageLinks(args);
return formatToolSuccess(result);
} catch (error) {
return formatToolError(error);
}
}
export const getWikiPageTool: ToolDefinition<
typeof getWikiPageShape,
GetWikiPageArgs
> = {
name: "openproject_get_wiki_page",
description: "Retrieve wiki page metadata, project, and attachments by numeric ID.",
parameters: getWikiPageShape,
readOnly: true,
execute: handleGetWikiPage,
};
export const searchWikiPagesTool: ToolDefinition<
typeof searchWikiPagesShape,
SearchWikiPagesArgs
> = {
name: "openproject_search_wiki_pages",
description:
"Search wiki pages by title and page content. Accepts natural-language " +
"phrasing and tolerates typos. Returns results ranked by relevance.",
parameters: searchWikiPagesShape,
readOnly: true,
execute: handleSearchWikiPages,
};
export const listWikiPageLinksTool: ToolDefinition<
typeof listWikiPageLinksShape,
ListWikiPageLinksArgs
> = {
name: "openproject_list_wiki_page_links",
description: "List links connecting work packages to wiki pages.",
parameters: listWikiPageLinksShape,
readOnly: true,
execute: handleListWikiPageLinks,
};
export const wikisTools = [
getWikiPageTool,
searchWikiPagesTool,
listWikiPageLinksTool,
];
/**
* Registers all wikis tools with an McpServer instance.
*/
export function registerWikisTools(server: McpServer): void {
registerTool(server, getWikiPageTool);
registerTool(server, searchWikiPagesTool);
registerTool(server, listWikiPageLinksTool);
}
+159 -2
View File
@@ -12,7 +12,12 @@ import {
type McpToolResponse,
type ToolDefinition,
} from "./common";
import { listWorkPackages, getWorkPackage } from "../services/work-packages";
import {
listWorkPackages,
getWorkPackage,
listWorkPackageActivities,
searchWorkPackages,
} from "../services/work-packages";
import type { FilterElement } from "../client/types";
export const listWorkPackagesShape = {
@@ -163,7 +168,157 @@ export const getWorkPackageTool: ToolDefinition<
execute: handleGetWorkPackage,
};
export const workPackageTools = [listWorkPackagesTool, getWorkPackageTool];
export const listWorkPackageActivitiesShape = {
workPackageId: z
.number()
.int()
.positive()
.describe("Numeric ID of the work package"),
onlyComments: z
.boolean()
.optional()
.default(false)
.describe(
"When true, filters out property change audits and returns only comments"
),
query: z
.string()
.optional()
.describe(
"Optional search text. FILTERS the activity list down to relevant " +
"entries and ranks them by relevance — activities that do not match " +
"are omitted, so the returned timeline is not the complete history. " +
"Omit this parameter to get the full, unfiltered list. Matched " +
"against comment text and change details; tolerates typos."
),
matchMode: z
.enum(["fuzzy", "exact"])
.optional()
.default("fuzzy")
.describe(
"Matching strategy. 'fuzzy' (default) tolerates typos, word reordering, and " +
"partial words. Use 'exact' only for literal strings you know verbatim."
),
};
export type ListWorkPackageActivitiesArgs = z.infer<
z.ZodObject<typeof listWorkPackageActivitiesShape>
>;
/**
* Tool execution handler for openproject_list_work_package_activities.
*/
export async function handleListWorkPackageActivities(
args: ListWorkPackageActivitiesArgs
): Promise<McpToolResponse> {
try {
const result = await listWorkPackageActivities({
workPackageId: args.workPackageId,
onlyComments: args.onlyComments,
query: args.query,
matchMode: args.matchMode,
});
return formatToolSuccess(result);
} catch (error) {
return formatToolError(error);
}
}
export const listWorkPackageActivitiesTool: ToolDefinition<
typeof listWorkPackageActivitiesShape,
ListWorkPackageActivitiesArgs
> = {
name: "openproject_list_work_package_activities",
description:
"Retrieve timeline activities and comments for a work package. Can optionally filter to only include user comments. Supplying 'query' filters the timeline to relevant entries and ranks them, rather than reordering the full history.",
parameters: listWorkPackageActivitiesShape,
readOnly: true,
execute: handleListWorkPackageActivities,
};
export const searchWorkPackagesShape = {
query: z
.string()
.min(1)
.describe(
"Search text matched against work package subjects, descriptions, and " +
"comments. Natural-language phrasing works and typos are tolerated."
),
projectId: z
.union([z.number().int().positive(), z.string().min(1)])
.optional()
.describe("Scope search to a specific project by numeric ID or slug identifier"),
status: z
.union([z.enum(["open", "closed"]), z.number().int().positive()])
.optional()
.describe("Filter by status: 'open', 'closed', or numeric status ID"),
matchMode: z
.enum(["fuzzy", "exact"])
.optional()
.default("fuzzy")
.describe(
"Matching strategy. 'fuzzy' (default) tolerates typos, word reordering, and " +
"partial words. Use 'exact' only for literal strings you know verbatim."
),
offset: z
.number()
.int()
.positive()
.optional()
.default(1)
.describe("Page number to retrieve (1-based, default 1)"),
pageSize: z
.number()
.int()
.positive()
.max(100)
.optional()
.default(20)
.describe("Number of items per page (max 100, default 20)"),
};
export type SearchWorkPackagesArgs = z.input<z.ZodObject<typeof searchWorkPackagesShape>>;
/**
* Tool execution handler for openproject_search_work_packages.
*/
export async function handleSearchWorkPackages(
args: SearchWorkPackagesArgs
): Promise<McpToolResponse> {
try {
const result = await searchWorkPackages(args.query, {
projectId: args.projectId,
status: args.status,
matchMode: args.matchMode,
offset: args.offset,
pageSize: args.pageSize,
});
return formatToolSuccess(result);
} catch (error) {
return formatToolError(error);
}
}
export const searchWorkPackagesTool: ToolDefinition<
typeof searchWorkPackagesShape,
SearchWorkPackagesArgs
> = {
name: "openproject_search_work_packages",
description:
"Search work packages by subject, description, and comments. Accepts " +
"natural-language phrasing and tolerates typos. Returns results ranked by " +
"relevance with matching excerpts.",
parameters: searchWorkPackagesShape,
readOnly: true,
execute: handleSearchWorkPackages,
};
export const workPackageTools = [
listWorkPackagesTool,
getWorkPackageTool,
listWorkPackageActivitiesTool,
searchWorkPackagesTool,
];
/**
* Registers all work package tools with an McpServer instance.
@@ -171,4 +326,6 @@ export const workPackageTools = [listWorkPackagesTool, getWorkPackageTool];
export function registerWorkPackageTools(server: McpServer): void {
registerTool(server, listWorkPackagesTool);
registerTool(server, getWorkPackageTool);
registerTool(server, listWorkPackageActivitiesTool);
registerTool(server, searchWorkPackagesTool);
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, test, expect } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const ROOT_DIR = resolve(import.meta.dir, "..");
const WORKFLOW_PATH = resolve(ROOT_DIR, ".github/workflows/ci.yml");
describe("GitHub Actions CI Workflow", () => {
test("ci.yml exists and has proper triggers and permissions", () => {
expect(existsSync(WORKFLOW_PATH)).toBe(true);
const content = readFileSync(WORKFLOW_PATH, "utf-8");
// Triggers
expect(content).toContain("pull_request:");
expect(content).toContain("push:");
expect(content).toContain("workflow_dispatch:");
expect(content).toContain("- main");
expect(content).toContain("- 'v*.*.*'");
// Permissions
expect(content).toContain("permissions:");
expect(content).toContain("contents: read");
expect(content).toContain("packages: write");
});
test("ci.yml defines test job with Bun 1.3.14 and strict checks", () => {
const content = readFileSync(WORKFLOW_PATH, "utf-8");
expect(content).toContain("test:");
expect(content).toContain("runs-on: ubuntu-latest");
expect(content).toContain("oven-sh/setup-bun@v2");
expect(content).toContain("bun-version: 1.3.14");
expect(content).toContain("bun install --frozen-lockfile");
expect(content).toContain("bun run typecheck");
expect(content).toContain("bun test");
});
test("ci.yml defines docker job with multi-arch buildx and ghcr publishing", () => {
const content = readFileSync(WORKFLOW_PATH, "utf-8");
expect(content).toContain("docker:");
expect(content).toContain("needs: test");
expect(content).toContain("docker/setup-qemu-action@v3");
expect(content).toContain("docker/setup-buildx-action@v3");
expect(content).toContain("docker/metadata-action@v5");
expect(content).toContain("IMAGE_NAME=ghcr.io/$(echo \"${{ github.repository }}\" | tr '[:upper:]' '[:lower:]')");
expect(content).toContain("images: ${{ env.IMAGE_NAME }}");
expect(content).toContain("docker/login-action@v3");
expect(content).toContain("registry: ghcr.io");
expect(content).toContain("docker/build-push-action@v6");
expect(content).toContain("platforms: linux/amd64,linux/arm64");
expect(content).toContain("push: ${{ github.event_name != 'pull_request' }}");
expect(content).toContain("type=gha");
});
});
+26 -12
View File
@@ -442,6 +442,26 @@ describe("OpenProjectClient (Mocked Unit Tests)", () => {
expect(capturedHeaders?.get("Accept")).toContain("application/hal+json");
});
test("getCacheKey returns unique key scoped to both baseUrl and apiKey", () => {
const clientA = new OpenProjectClient({
baseUrl: "https://openproject.example.com",
apiKey: "user-key-a",
});
const clientB = new OpenProjectClient({
baseUrl: "https://openproject.example.com",
apiKey: "user-key-b",
});
const clientC = new OpenProjectClient({
baseUrl: "https://other.example.com",
apiKey: "user-key-a",
});
expect(clientA.getCacheKey()).toBe("https://openproject.example.com#user-key-a");
expect(clientB.getCacheKey()).toBe("https://openproject.example.com#user-key-b");
expect(clientC.getCacheKey()).toBe("https://other.example.com#user-key-a");
expect(clientA.getCacheKey()).not.toBe(clientB.getCacheKey());
});
test("sanitizes API credentials from error messages", async () => {
const secretKey = "super-secret-key-xyz-999";
const mockFetch = async (): Promise<Response> => {
@@ -531,16 +551,12 @@ describe("OpenProjectClient (Mocked Unit Tests)", () => {
describe("OpenProject Live API Integration (Local Container)", () => {
const liveBaseUrl = process.env.OPENPROJECT_BASE_URL || "http://localhost:8080";
const liveApiKey = process.env.OPENPROJECT_API_KEY;
const runLiveTests = liveApiKey ? test : test.skip;
test("fetches projects and work packages against live instance", async () => {
if (!liveApiKey) {
console.warn("Skipping live container test: OPENPROJECT_API_KEY not set");
return;
}
runLiveTests("fetches projects and work packages against live instance", async () => {
const client = new OpenProjectClient({
baseUrl: liveBaseUrl,
apiKey: liveApiKey,
apiKey: liveApiKey!,
});
// 1. Fetch projects
@@ -582,7 +598,7 @@ describe("OpenProject Live API Integration (Local Container)", () => {
expect(priorities.items.length).toBeGreaterThan(0);
});
test("live 401 authentication failure with invalid key", async () => {
runLiveTests("live 401 authentication failure with invalid key", async () => {
const client = new OpenProjectClient({
baseUrl: liveBaseUrl,
apiKey: "definitely-invalid-key-9999",
@@ -593,12 +609,10 @@ describe("OpenProject Live API Integration (Local Container)", () => {
);
});
test("live 404 not found failure with non-existent project", async () => {
if (!liveApiKey) return;
runLiveTests("live 404 not found failure with non-existent project", async () => {
const client = new OpenProjectClient({
baseUrl: liveBaseUrl,
apiKey: liveApiKey,
apiKey: liveApiKey!,
});
await expect(client.get("/api/v3/projects/9999999")).rejects.toBeInstanceOf(
+167
View File
@@ -0,0 +1,167 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { loadConfig, parseConfig } from "../src/config/index.ts";
describe("Configuration Loader (HTTP & Stdio Support)", () => {
const originalEnv = { ...process.env };
const originalArgv = [...process.argv];
beforeEach(() => {
process.env = { ...originalEnv };
process.argv = [...originalArgv.slice(0, 2)];
});
afterEach(() => {
process.env = originalEnv;
process.argv = originalArgv;
});
test("loads stdio configuration when PORT is unset and OPENPROJECT_API_KEY is provided", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
process.env.OPENPROJECT_API_KEY = "test-key";
delete process.env.PORT;
const config = loadConfig();
expect(config.baseUrl).toBe("https://openproject.example.com");
expect(config.apiKey).toBe("test-key");
expect(config.readOnly).toBe(false);
expect(config.port).toBeUndefined();
});
test("throws error if OPENPROJECT_API_KEY is missing in stdio mode", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
delete process.env.OPENPROJECT_API_KEY;
delete process.env.PORT;
expect(() => loadConfig()).toThrow();
});
test("throws error if OPENPROJECT_API_KEY is whitespace in stdio mode", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
process.env.OPENPROJECT_API_KEY = " ";
delete process.env.PORT;
expect(() => loadConfig()).toThrow();
});
test("throws error if OPENPROJECT_BASE_URL is invalid", () => {
process.env.OPENPROJECT_BASE_URL = "not-a-valid-url";
process.env.OPENPROJECT_API_KEY = "test-key";
delete process.env.PORT;
expect(() => loadConfig()).toThrow();
});
test("normalizes baseUrl by stripping trailing slashes", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com///";
process.env.OPENPROJECT_API_KEY = "test-key";
delete process.env.PORT;
const config = loadConfig();
expect(config.baseUrl).toBe("https://openproject.example.com");
});
test("loads HTTP configuration when PORT environment variable is set without API key", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
delete process.env.OPENPROJECT_API_KEY;
process.env.PORT = "3000";
const config = loadConfig();
expect(config.baseUrl).toBe("https://openproject.example.com");
expect(config.port).toBe(3000);
expect(config.apiKey).toBeUndefined();
});
test("loads HTTP configuration with PORT=0 for dynamic assignment", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
delete process.env.OPENPROJECT_API_KEY;
process.env.PORT = "0";
const config = loadConfig();
expect(config.baseUrl).toBe("https://openproject.example.com");
expect(config.port).toBe(0);
expect(config.apiKey).toBeUndefined();
});
test("loads HTTP configuration when HOST_PORT is set as fallback without PORT", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
delete process.env.OPENPROJECT_API_KEY;
delete process.env.PORT;
process.env.HOST_PORT = "3000";
const config = loadConfig();
expect(config.baseUrl).toBe("https://openproject.example.com");
expect(config.port).toBe(3000);
expect(config.apiKey).toBeUndefined();
});
test("loads HTTP configuration with API key when provided", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
process.env.OPENPROJECT_API_KEY = "test-key";
process.env.PORT = "3000";
const config = loadConfig();
expect(config.baseUrl).toBe("https://openproject.example.com");
expect(config.port).toBe(3000);
expect(config.apiKey).toBe("test-key");
});
test("parses --port and --host CLI arguments", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
delete process.env.OPENPROJECT_API_KEY;
delete process.env.PORT;
process.argv.push("--port", "8080", "--host", "127.0.0.1", "--read-only");
const config = loadConfig();
expect(config.port).toBe(8080);
expect(config.host).toBe("127.0.0.1");
expect(config.readOnly).toBe(true);
});
test("parses --port=value and --host=value CLI arguments", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
delete process.env.OPENPROJECT_API_KEY;
delete process.env.PORT;
process.argv.push("--port=9090", "--host=0.0.0.0");
const config = loadConfig();
expect(config.port).toBe(9090);
expect(config.host).toBe("0.0.0.0");
});
test("CLI arguments take precedence over environment variables", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
process.env.PORT = "3000";
process.env.HOST = "127.0.0.1";
process.argv.push("--port", "4000", "--host", "0.0.0.0");
const config = loadConfig();
expect(config.port).toBe(4000);
expect(config.host).toBe("0.0.0.0");
});
test("throws error if port is non-numeric", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
process.env.PORT = "not-a-port";
expect(() => loadConfig()).toThrow();
});
test("throws error if port is out of range", () => {
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
process.env.PORT = "70000";
expect(() => loadConfig()).toThrow();
});
test("parseConfig alias works identically to loadConfig", () => {
const config = parseConfig(
{
OPENPROJECT_BASE_URL: "https://openproject.example.com",
OPENPROJECT_API_KEY: "alias-key",
},
[]
);
expect(config.baseUrl).toBe("https://openproject.example.com");
expect(config.apiKey).toBe("alias-key");
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, test, expect } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const ROOT_DIR = resolve(import.meta.dir, "..");
describe("Docker Container Packaging", () => {
test("Dockerfile exists and defines multi-stage build with non-root user", () => {
const dockerfilePath = resolve(ROOT_DIR, "Dockerfile");
expect(existsSync(dockerfilePath)).toBe(true);
const content = readFileSync(dockerfilePath, "utf-8");
expect(content).toContain("FROM oven/bun:1-slim AS builder");
expect(content).toContain("FROM oven/bun:1-slim AS runner");
expect(content).toContain("bun install --frozen-lockfile --production");
expect(content).toContain("USER bun");
expect(content).toContain('ENTRYPOINT ["bun", "run", "src/index.ts"]');
});
test(".dockerignore exists and excludes sensitive and unnecessary files", () => {
const ignorePath = resolve(ROOT_DIR, ".dockerignore");
expect(existsSync(ignorePath)).toBe(true);
const content = readFileSync(ignorePath, "utf-8");
expect(content).toContain("node_modules");
expect(content).toContain(".env*");
expect(content).toContain(".git");
expect(content).toContain("tests");
expect(content).toContain("docs");
expect(content).toContain("scripts");
});
test("docker-compose.server.yml exists and defines hosted server configuration", () => {
const composePath = resolve(ROOT_DIR, "docker-compose.server.yml");
expect(existsSync(composePath)).toBe(true);
const content = readFileSync(composePath, "utf-8");
expect(content).toContain("ghcr.io/stereotypicalcat/openproject-mcp:latest");
expect(content).toContain("openproject-mcp-hosted");
expect(content).toContain("PORT=3000");
expect(content).toContain("OPENPROJECT_BASE_URL=");
expect(content).toContain("/health");
});
test("Docker image builds and starts up cleanly in container", async () => {
if (process.env.SKIP_DOCKER_TESTS === "true" || process.env.CI === "true") {
console.log("Skipping live docker build test in CI / SKIP_DOCKER_TESTS environment");
return;
}
// Check if docker daemon is available in test environment
const proc = Bun.spawn(["which", "docker"], { stdout: "pipe", stderr: "pipe" });
const exitCode = await proc.exited;
if (exitCode !== 0) {
console.warn("Docker CLI not available, skipping container build execution test");
return;
}
// Build test image
const buildProc = Bun.spawn(["docker", "build", "-t", "openproject-mcp:test", "."], {
cwd: ROOT_DIR,
stdout: "pipe",
stderr: "pipe",
});
const buildExit = await buildProc.exited;
expect(buildExit).toBe(0);
// Run container with test env and check stderr banner
const runProc = Bun.spawn(
[
"docker",
"run",
"-i",
"--rm",
"-e",
"OPENPROJECT_BASE_URL=http://localhost:8080",
"-e",
"OPENPROJECT_API_KEY=test-token",
"openproject-mcp:test",
"--read-only",
],
{
cwd: ROOT_DIR,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
}
);
// Read stderr with a timeout loop up to 5000ms waiting for the startup banner
let stderrText = "";
const decoder = new TextDecoder();
const reader = runProc.stderr.getReader();
const readLoop = (async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
stderrText += decoder.decode(value, { stream: true });
if (stderrText.includes("[openproject-mcp] Server started (readOnly=true)")) {
break;
}
}
} catch {
// stream closed or cancelled
}
})();
const startTime = Date.now();
while (
Date.now() - startTime < 5000 &&
!stderrText.includes("[openproject-mcp] Server started (readOnly=true)")
) {
await Bun.sleep(50);
}
runProc.kill();
await runProc.exited;
try {
await reader.cancel();
} catch {
// ignore
}
await readLoop;
expect(stderrText).toContain("[openproject-mcp] Server started (readOnly=true)");
}, 60000);
});
+695
View File
@@ -0,0 +1,695 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { startHttpServer, type HttpServerInstance } from "../src/http-server.ts";
describe("Hosted Remote MCP Server (HTTP/SSE)", () => {
let instance: HttpServerInstance;
let serverUrl: string;
beforeAll(async () => {
instance = await startHttpServer({
baseUrl: "https://mock.openproject.example.com",
readOnly: true,
port: 0, // OS assigns random available port
host: "127.0.0.1",
});
serverUrl = `http://127.0.0.1:${instance.port}`;
});
afterAll(async () => {
await instance.stop();
});
test("GET /health returns HTTP 200 with server status", async () => {
const res = await fetch(`${serverUrl}/health`);
expect(res.status).toBe(200);
const data = (await res.json()) as {
status: string;
mode: string;
openproject: string;
readOnly: boolean;
};
expect(data.status).toBe("ok");
expect(data.mode).toBe("remote-mcp");
expect(data.openproject).toBe("https://mock.openproject.example.com");
expect(data.readOnly).toBe(true);
});
test("GET /sse without API key returns HTTP 401 Unauthorized", async () => {
const res = await fetch(`${serverUrl}/sse`);
expect(res.status).toBe(401);
const data = (await res.json()) as { error: string };
expect(data.error).toContain("Missing OpenProject API key");
});
test("GET /sse with X-OpenProject-Api-Key header initiates SSE stream", async () => {
const res = await fetch(`${serverUrl}/sse`, {
headers: {
"X-OpenProject-Api-Key": "test-key-header",
Accept: "text/event-stream",
},
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/event-stream");
const reader = res.body?.getReader();
expect(reader).toBeDefined();
const { value } = await reader!.read();
const text = new TextDecoder().decode(value);
expect(text).toContain("event: endpoint");
expect(text).toContain("/messages?sessionId=");
await reader!.cancel();
});
test("GET /sse with Authorization: Bearer header initiates SSE stream", async () => {
const res = await fetch(`${serverUrl}/sse`, {
headers: {
Authorization: "Bearer test-key-bearer",
Accept: "text/event-stream",
},
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/event-stream");
const reader = res.body?.getReader();
const { value } = await reader!.read();
const text = new TextDecoder().decode(value);
expect(text).toContain("event: endpoint");
await reader!.cancel();
});
test("GET /sse with ?apiKey= query parameter initiates SSE stream", async () => {
const res = await fetch(`${serverUrl}/sse?apiKey=test-key-query`, {
headers: {
Accept: "text/event-stream",
},
});
expect(res.status).toBe(200);
const reader = res.body?.getReader();
const { value } = await reader!.read();
const text = new TextDecoder().decode(value);
expect(text).toContain("event: endpoint");
await reader!.cancel();
});
test("POST /messages with invalid sessionId returns HTTP 404", async () => {
const res = await fetch(`${serverUrl}/messages?sessionId=non-existent-id`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "ping", id: 1 }),
});
expect(res.status).toBe(404);
});
test("POST /messages with missing sessionId returns HTTP 400", async () => {
const res = await fetch(`${serverUrl}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "ping", id: 1 }),
});
expect(res.status).toBe(400);
});
test("POST /messages with invalid JSON returns HTTP 400", async () => {
const sseRes = await fetch(`${serverUrl}/sse?apiKey=test-key-json`, {
headers: { Accept: "text/event-stream" },
});
const reader = sseRes.body?.getReader();
const { value } = await reader!.read();
const text = new TextDecoder().decode(value);
const match = text.match(/\/messages\?sessionId=([a-zA-Z0-9_-]+)/);
expect(match).toBeTruthy();
const sessionId = match![1];
const res = await fetch(`${serverUrl}/messages?sessionId=${sessionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not-json{",
});
expect(res.status).toBe(400);
await reader!.cancel();
});
test("full JSON-RPC flow over SSE and POST /messages", async () => {
const sseRes = await fetch(`${serverUrl}/sse?apiKey=test-session-key`, {
headers: { Accept: "text/event-stream" },
});
expect(sseRes.status).toBe(200);
const reader = sseRes.body?.getReader();
expect(reader).toBeDefined();
// 1. First event is endpoint
const { value: epValue } = await reader!.read();
const epText = new TextDecoder().decode(epValue);
expect(epText).toContain("event: endpoint");
const match = epText.match(/\/messages\?sessionId=([a-zA-Z0-9_-]+)/);
expect(match).toBeTruthy();
const sessionId = match![1];
// 2. Client sends initialize request via POST /messages
const postRes = await fetch(`${serverUrl}/messages?sessionId=${sessionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test-client", version: "1.0.0" },
},
}),
});
expect(postRes.status).toBe(202);
// 3. Receive initialize response via SSE
const { value: msgValue } = await reader!.read();
const msgText = new TextDecoder().decode(msgValue);
expect(msgText).toContain("event: message");
const jsonMatch = msgText.match(/data:\s*(\{.*\})/);
expect(jsonMatch).toBeTruthy();
const rawData = jsonMatch?.[1];
expect(rawData).toBeDefined();
const rpcResponse = JSON.parse(rawData!);
expect(rpcResponse.id).toBe(1);
expect(rpcResponse.result).toBeDefined();
expect(rpcResponse.result.serverInfo.name).toBe("openproject-mcp");
// 4. Client sends tools/list request
const toolsPostRes = await fetch(`${serverUrl}/messages?sessionId=${sessionId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "tools/list",
params: {},
}),
});
expect(toolsPostRes.status).toBe(202);
const { value: toolsValue } = await reader!.read();
const toolsText = new TextDecoder().decode(toolsValue);
expect(toolsText).toContain("event: message");
const toolsMatch = toolsText.match(/data:\s*(\{.*\})/);
expect(toolsMatch).toBeTruthy();
const toolsRawData = toolsMatch?.[1];
expect(toolsRawData).toBeDefined();
const toolsRpcResponse = JSON.parse(toolsRawData!);
expect(toolsRpcResponse.id).toBe(2);
expect(toolsRpcResponse.result.tools).toBeDefined();
expect(toolsRpcResponse.result.tools.length).toBeGreaterThan(0);
await reader!.cancel();
});
test("client disconnect evicts session from active sessions", async () => {
const ac = new AbortController();
const sseRes = await fetch(`${serverUrl}/sse?apiKey=test-evict-key`, {
headers: { Accept: "text/event-stream" },
signal: ac.signal,
});
const reader = sseRes.body?.getReader();
expect(reader).toBeDefined();
const initialCount = instance.getActiveSessionsCount();
expect(initialCount).toBeGreaterThan(0);
// Abort client request to simulate disconnect
ac.abort();
// Small delay to allow abort callback to execute
await new Promise((resolve) => setTimeout(resolve, 50));
expect(instance.getActiveSessionsCount()).toBeLessThan(initialCount);
});
test("OPTIONS /sse and /messages return 204 with CORS headers", async () => {
const res = await fetch(`${serverUrl}/sse`, {
method: "OPTIONS",
});
expect(res.status).toBe(204);
expect(res.headers.get("access-control-allow-origin")).toBe("*");
expect(res.headers.get("access-control-allow-methods")).toContain("GET");
});
test("GET /sse falls back to config.apiKey when configured on server", async () => {
const singleTenantServer = await startHttpServer({
baseUrl: "https://mock.openproject.example.com",
readOnly: true,
port: 0,
host: "127.0.0.1",
apiKey: "server-configured-key",
});
const res = await fetch(`http://127.0.0.1:${singleTenantServer.port}/sse`, {
headers: { Accept: "text/event-stream" },
});
expect(res.status).toBe(200);
const reader = res.body?.getReader();
const { value } = await reader!.read();
const text = new TextDecoder().decode(value);
expect(text).toContain("event: endpoint");
await reader!.cancel();
await singleTenantServer.stop();
});
test("concurrent clients with different API keys maintain isolated sessions", async () => {
// Client A
const resA = await fetch(`${serverUrl}/sse?apiKey=user-a-secret`, {
headers: { Accept: "text/event-stream" },
});
const readerA = resA.body?.getReader();
const { value: valA } = await readerA!.read();
const textA = new TextDecoder().decode(valA);
const matchA = textA.match(/\/messages\?sessionId=([a-zA-Z0-9_-]+)/);
expect(matchA).toBeTruthy();
const sessionIdA = matchA![1];
// Client B
const resB = await fetch(`${serverUrl}/sse?apiKey=user-b-secret`, {
headers: { Accept: "text/event-stream" },
});
const readerB = resB.body?.getReader();
const { value: valB } = await readerB!.read();
const textB = new TextDecoder().decode(valB);
const matchB = textB.match(/\/messages\?sessionId=([a-zA-Z0-9_-]+)/);
expect(matchB).toBeTruthy();
const sessionIdB = matchB![1];
// Session IDs must be distinct
expect(sessionIdA).not.toBe(sessionIdB);
// Both can initialize independently
const initA = await fetch(`${serverUrl}/messages?sessionId=${sessionIdA}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 101,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "client-a", version: "1.0.0" },
},
}),
});
expect(initA.status).toBe(202);
const initB = await fetch(`${serverUrl}/messages?sessionId=${sessionIdB}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 201,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "client-b", version: "1.0.0" },
},
}),
});
expect(initB.status).toBe(202);
// Read response for A
const { value: respValA } = await readerA!.read();
const respTextA = new TextDecoder().decode(respValA);
expect(respTextA).toContain('"id":101');
// Read response for B
const { value: respValB } = await readerB!.read();
const respTextB = new TextDecoder().decode(respValB);
expect(respTextB).toContain('"id":201');
await readerA!.cancel();
await readerB!.cancel();
});
test("POST /sse with initialize message returns HTTP 200 with Mcp-Session-Id and serverInfo (Open WebUI compatibility)", async () => {
const res = await fetch(`${serverUrl}/sse?apiKey=test-streamable-key`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "*/*", // Open WebUI / httpx often sends */* or application/json
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "open-webui", version: "0.5.0" },
},
}),
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
const sessionId = res.headers.get("mcp-session-id");
expect(sessionId).toBeTruthy();
const data = (await res.json()) as {
jsonrpc: string;
id: number;
result: {
protocolVersion: string;
serverInfo: { name: string; version: string };
};
};
expect(data.jsonrpc).toBe("2.0");
expect(data.id).toBe(1);
expect(data.result.serverInfo.name).toBe("openproject-mcp");
// Subsequent tools/list request with Mcp-Session-Id
const toolsRes = await fetch(`${serverUrl}/sse`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Mcp-Session-Id": sessionId!,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "tools/list",
params: {},
}),
});
expect(toolsRes.status).toBe(200);
const toolsData = (await toolsRes.json()) as {
jsonrpc: string;
id: number;
result: { tools: Array<{ name: string }> };
};
expect(toolsData.id).toBe(2);
expect(toolsData.result.tools.length).toBeGreaterThan(0);
expect(
toolsData.result.tools.some((t) => t.name === "openproject_list_projects")
).toBe(true);
// DELETE session to clean up
const deleteRes = await fetch(`${serverUrl}/sse`, {
method: "DELETE",
headers: {
"Mcp-Session-Id": sessionId!,
},
});
expect(deleteRes.status).toBe(204);
// Subsequent request on deleted session returns 404
const afterDeleteRes = await fetch(`${serverUrl}/sse`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Mcp-Session-Id": sessionId!,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 3,
method: "tools/list",
params: {},
}),
});
expect(afterDeleteRes.status).toBe(404);
});
test("Streamable HTTP on /mcp and / root endpoints", async () => {
// 1. /mcp endpoint
const mcpRes = await fetch(`${serverUrl}/mcp`, {
method: "POST",
headers: {
Authorization: "Bearer test-mcp-path-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 10,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test-client", version: "1.0.0" },
},
}),
});
expect(mcpRes.status).toBe(200);
const mcpSessionId = mcpRes.headers.get("mcp-session-id");
expect(mcpSessionId).toBeTruthy();
// 2. / root endpoint
const rootRes = await fetch(`${serverUrl}/`, {
method: "POST",
headers: {
"X-OpenProject-Api-Key": "test-root-path-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 20,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test-client", version: "1.0.0" },
},
}),
});
expect(rootRes.status).toBe(200);
const rootSessionId = rootRes.headers.get("mcp-session-id");
expect(rootSessionId).toBeTruthy();
expect(rootSessionId).not.toBe(mcpSessionId);
});
test("POST /mcp without API key returns HTTP 401 Unauthorized", async () => {
const res = await fetch(`${serverUrl}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test", version: "1.0" },
},
}),
});
expect(res.status).toBe(401);
const data = (await res.json()) as { error: string };
expect(data.error).toContain("Missing OpenProject API key");
});
test("POST /mcp with invalid sessionId returns HTTP 404", async () => {
const res = await fetch(`${serverUrl}/mcp`, {
method: "POST",
headers: {
Authorization: "Bearer test-key",
"Content-Type": "application/json",
"Mcp-Session-Id": "non-existent-uuid",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "tools/list",
params: {},
}),
});
expect(res.status).toBe(404);
});
test("GET /openapi.json and GET /swagger.json return valid OpenAPI 3.1.0 specification", async () => {
// 1. GET /openapi.json
const res = await fetch(`${serverUrl}/openapi.json`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
expect(res.headers.get("access-control-allow-origin")).toBe("*");
const spec = (await res.json()) as {
openapi: string;
info: { title: string; version: string };
paths: Record<string, { post: { operationId: string; description: string } }>;
};
expect(spec.openapi).toBe("3.1.0");
expect(spec.info.title).toContain("OpenProject");
expect(spec.paths["/api/tools/openproject_list_projects"]).toBeDefined();
expect(spec.paths["/api/tools/openproject_list_projects"]?.post.operationId).toBe(
"openproject_list_projects"
);
expect(spec.paths["/api/tools/openproject_get_work_package"]).toBeDefined();
// 2. GET /swagger.json returns identical spec
const swaggerRes = await fetch(`${serverUrl}/swagger.json`);
expect(swaggerRes.status).toBe(200);
const swaggerSpec = (await swaggerRes.json()) as { openapi: string };
expect(swaggerSpec.openapi).toBe("3.1.0");
});
test("POST /api/tools/:toolName executes tool and returns JSON result", async () => {
// 1. Missing API key returns 401
const noAuthRes = await fetch(`${serverUrl}/api/tools/openproject_list_projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pageSize: 10 }),
});
expect(noAuthRes.status).toBe(401);
// 2. Non-existent tool returns 404
const notFoundRes = await fetch(`${serverUrl}/api/tools/non_existent_tool`, {
method: "POST",
headers: {
Authorization: "Bearer test-key",
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
expect(notFoundRes.status).toBe(404);
// 3. Invalid tool arguments return 400
const invalidArgRes = await fetch(`${serverUrl}/api/tools/openproject_get_project`, {
method: "POST",
headers: {
Authorization: "Bearer test-key",
"Content-Type": "application/json",
},
body: JSON.stringify({}), // Missing required projectId
});
expect(invalidArgRes.status).toBe(400);
// 4. Valid invocation executes tool and returns result
const validRes = await fetch(`${serverUrl}/api/tools/openproject_list_projects`, {
method: "POST",
headers: {
Authorization: "Bearer test-key",
"Content-Type": "application/json",
},
body: JSON.stringify({ pageSize: 5 }),
});
expect(validRes.status).toBe(200);
const result = (await validRes.json()) as {
content: Array<{ type: string; text: string }>;
data?: unknown;
};
expect(result.content).toBeDefined();
expect(result.content.length).toBeGreaterThan(0);
expect(result.content[0]?.type).toBe("text");
// 5. Successful data execution with mocked fetch
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
if (String(url).includes("/api/v3/projects")) {
return new Response(
JSON.stringify({
_embedded: {
elements: [
{ id: 1, name: "Test Project", identifier: "test-proj" },
],
},
total: 1,
count: 1,
pageSize: 20,
offset: 1,
}),
{
status: 200,
headers: { "Content-Type": "application/hal+json" },
}
);
}
return originalFetch(url, init);
}) as unknown as typeof fetch;
const mockRes = await fetch(`${serverUrl}/api/tools/openproject_list_projects`, {
method: "POST",
headers: {
Authorization: "Bearer test-key",
"Content-Type": "application/json",
},
body: JSON.stringify({ pageSize: 5 }),
});
expect(mockRes.status).toBe(200);
const mockResult = (await mockRes.json()) as {
isError: boolean;
data?: { projects: Array<{ id: number; identifier: string }> };
};
expect(mockResult.isError).toBe(false);
expect(mockResult.data?.projects[0]?.identifier).toBe("test-proj");
} finally {
globalThis.fetch = originalFetch;
}
});
test("CLI entrypoint (src/index.ts) boots HTTP server when --port is provided", async () => {
const proc = Bun.spawn(
[
"bun",
"src/index.ts",
"--port",
"0",
"--host",
"127.0.0.1",
"--read-only",
],
{
env: {
...process.env,
OPENPROJECT_BASE_URL: "https://mock.openproject.example.com",
},
stderr: "pipe",
stdout: "pipe",
}
);
let stderrOutput = "";
const decoder = new TextDecoder();
const reader = proc.stderr.getReader();
const readPromise = (async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
stderrOutput += decoder.decode(value, { stream: true });
if (stderrOutput.includes("[openproject-mcp] Starting hosted HTTP server on 127.0.0.1:0...")) {
break;
}
}
} catch {
// Stream closed
}
})();
const start = Date.now();
while (
Date.now() - start < 5000 &&
!stderrOutput.includes("[openproject-mcp] Starting hosted HTTP server on 127.0.0.1:0...")
) {
await Bun.sleep(50);
}
expect(stderrOutput).toContain("[openproject-mcp] Starting hosted HTTP server on 127.0.0.1:0...");
proc.kill();
await proc.exited;
try {
await reader.cancel();
} catch {
// ignore
}
await readPromise;
});
});
+348
View File
@@ -0,0 +1,348 @@
import { describe, expect, test, spyOn } from "bun:test";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer, SERVER_NAME, SERVER_VERSION } from "../src/server";
describe("MCP Server Factory", () => {
test("creates server and lists all 19 tools via InMemoryTransport", async () => {
const config = {
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: false,
};
const mcpServer = createServer(config);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
const toolsResult = await client.listTools();
expect(toolsResult.tools).toHaveLength(19);
const names = toolsResult.tools.map((t) => t.name);
expect(names).toContain("openproject_list_projects");
expect(names).toContain("openproject_get_project");
expect(names).toContain("openproject_list_work_packages");
expect(names).toContain("openproject_get_work_package");
expect(names).toContain("openproject_list_work_package_activities");
expect(names).toContain("openproject_search_work_packages");
expect(names).toContain("openproject_list_queries");
expect(names).toContain("openproject_get_query");
expect(names).toContain("openproject_list_types");
expect(names).toContain("openproject_list_statuses");
expect(names).toContain("openproject_list_priorities");
expect(names).toContain("openproject_list_users");
expect(names).toContain("openproject_get_openapi_spec");
expect(names).toContain("openproject_list_meetings");
expect(names).toContain("openproject_get_meeting");
expect(names).toContain("openproject_search_meetings");
expect(names).toContain("openproject_get_wiki_page");
expect(names).toContain("openproject_search_wiki_pages");
expect(names).toContain("openproject_list_wiki_page_links");
await client.close();
await mcpServer.stop();
});
test("server exposes server, client, start, and stop properties with correct constants", () => {
expect(SERVER_NAME).toBe("openproject-mcp");
expect(SERVER_VERSION).toBe("0.1.0");
const config = {
baseUrl: "https://openproject.example.com",
apiKey: "secret-key",
readOnly: true,
};
const mcpServer = createServer(config);
expect(mcpServer.server).toBeDefined();
expect(mcpServer.client).toBeDefined();
expect(typeof mcpServer.start).toBe("function");
expect(typeof mcpServer.stop).toBe("function");
});
test("executes tool through MCP client and injects RequestContext", async () => {
const config = {
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: false,
};
const mcpServer = createServer(config);
// Mock client.get on mcpServer.client to verify ambient context is used
let interceptedPath: string | undefined;
(
mcpServer.client as unknown as {
get: (path: string, query?: Record<string, unknown>) => Promise<unknown>;
}
).get = async (path: string, _query?: Record<string, unknown>) => {
interceptedPath = path;
return {
_embedded: {
elements: [
{
id: 42,
identifier: "ambient-project",
name: "Ambient Test Project",
active: true,
public: true,
description: { raw: "Ambient description" },
createdAt: "2026-09-06T00:00:00Z",
updatedAt: "2026-09-06T00:00:00Z",
},
],
},
total: 1,
count: 1,
pageSize: 20,
offset: 1,
};
};
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
const callResult = (await client.callTool({
name: "openproject_list_projects",
arguments: {},
})) as {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
expect(callResult.isError).toBeFalsy();
expect(interceptedPath).toBe("projects");
const parsed = JSON.parse(callResult.content[0]?.text ?? "{}");
expect(parsed.total).toBe(1);
expect(parsed.projects[0]?.id).toBe(42);
expect(parsed.projects[0]?.identifier).toBe("ambient-project");
await client.close();
await mcpServer.stop();
});
test("read-only mode filters tools and maintains read-only status in context", async () => {
const config = {
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: true,
};
const mcpServer = createServer(config);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
// In read-only mode, only readOnly: true tools should be registered
const toolsResult = await client.listTools();
expect(toolsResult.tools).toHaveLength(19);
await client.close();
await mcpServer.stop();
});
test("start defaults to StdioServerTransport when no transport is provided", async () => {
const config = {
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: false,
};
const mcpServer = createServer(config);
let capturedTransport: unknown;
const connectSpy = spyOn(mcpServer.server, "connect").mockImplementation(
async (transport) => {
capturedTransport = transport;
}
);
await mcpServer.start();
expect(connectSpy).toHaveBeenCalled();
expect(capturedTransport).toBeInstanceOf(StdioServerTransport);
connectSpy.mockRestore();
});
test("start and stop log messages via console.error", async () => {
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
const config = {
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: false,
};
const mcpServer = createServer(config);
const [, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
expect(errorSpy).toHaveBeenCalledWith(
"[openproject-mcp] Server started (readOnly=false)"
);
await mcpServer.stop();
expect(errorSpy).toHaveBeenCalledWith("[openproject-mcp] Server stopped");
errorSpy.mockRestore();
});
});
describe("End-to-End Live Tool Calling over MCP Client", () => {
const baseUrl = process.env.OPENPROJECT_BASE_URL || "http://localhost:8080";
const apiKey = process.env.OPENPROJECT_API_KEY;
const runLiveTests = apiKey ? test : test.skip;
runLiveTests("calls openproject_list_projects and openproject_get_work_package via Client", async () => {
const mcpServer = createServer({ baseUrl, apiKey: apiKey || "skip", readOnly: false });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-runner", version: "1" });
await client.connect(clientTransport);
try {
// Call openproject_list_projects
const projRes = (await client.callTool({
name: "openproject_list_projects",
arguments: { pageSize: 5 },
})) as {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
expect(projRes.isError).toBeFalsy();
const projData = JSON.parse(projRes.content[0]?.text ?? "{}");
expect(projData.projects.length).toBeGreaterThan(0);
// Call openproject_get_work_package
const wpRes = (await client.callTool({
name: "openproject_get_work_package",
arguments: { workPackageId: 38 },
})) as {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
expect(wpRes.isError).toBeFalsy();
const wpData = JSON.parse(wpRes.content[0]?.text ?? "{}");
expect(wpData.id).toBe(38);
} finally {
await client.close();
await mcpServer.stop();
}
});
runLiveTests("tool error propagation returns isError: true without crashing client", async () => {
const mcpServer = createServer({ baseUrl, apiKey: apiKey || "skip", readOnly: false });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-runner", version: "1" });
await client.connect(clientTransport);
try {
const errRes = (await client.callTool({
name: "openproject_get_project",
arguments: { projectId: 999999 },
})) as {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
expect(errRes.isError).toBe(true);
expect(errRes.content[0]?.text).toContain("OPENPROJECT_NOT_FOUND");
} finally {
await client.close();
await mcpServer.stop();
}
});
runLiveTests("calls meetings, wikis, and activities tools via Client", async () => {
const mcpServer = createServer({ baseUrl, apiKey: apiKey || "skip", readOnly: false });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "test-runner", version: "1" });
await client.connect(clientTransport);
try {
// openproject_list_meetings
const listMeetingsRes = (await client.callTool({
name: "openproject_list_meetings",
arguments: { projectId: 1 },
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(listMeetingsRes.isError).toBeFalsy();
const meetingsData = JSON.parse(listMeetingsRes.content[0]?.text ?? "{}");
expect(meetingsData.total).toBeGreaterThanOrEqual(4);
expect(meetingsData.elements.length).toBeGreaterThan(0);
// openproject_get_meeting
const getMeetingRes = (await client.callTool({
name: "openproject_get_meeting",
arguments: { id: 2, includeAgendaItems: true },
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(getMeetingRes.isError).toBeFalsy();
const meetingData = JSON.parse(getMeetingRes.content[0]?.text ?? "{}");
expect(meetingData.id).toBe(2);
expect(meetingData.title).toBe("Weekly");
expect(meetingData.agendaItems.length).toBeGreaterThan(0);
// openproject_search_meetings
const searchMeetingsRes = (await client.callTool({
name: "openproject_search_meetings",
arguments: { query: "Weekly" },
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(searchMeetingsRes.isError).toBeFalsy();
const searchMeetingsData = JSON.parse(searchMeetingsRes.content[0]?.text ?? "{}");
expect(searchMeetingsData.total).toBeGreaterThan(0);
expect(searchMeetingsData.elements.length).toBeGreaterThan(0);
// openproject_get_wiki_page
const getWikiRes = (await client.callTool({
name: "openproject_get_wiki_page",
arguments: { id: 1 },
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(getWikiRes.isError).toBeFalsy();
const wikiData = JSON.parse(getWikiRes.content[0]?.text ?? "{}");
expect(wikiData.id).toBe(1);
expect(wikiData.title).toBe("Wiki");
expect(wikiData.project.id).toBe(1);
// openproject_search_wiki_pages
const searchWikiRes = (await client.callTool({
name: "openproject_search_wiki_pages",
arguments: { query: "Wiki" },
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(searchWikiRes.isError).toBeFalsy();
const searchWikiData = JSON.parse(searchWikiRes.content[0]?.text ?? "[]");
expect(Array.isArray(searchWikiData)).toBe(true);
expect(searchWikiData.length).toBeGreaterThan(0);
expect(
searchWikiData.some(
(w: { id: number; title: string }) => w.id === 1 && w.title === "Wiki"
)
).toBe(true);
// openproject_list_work_package_activities
const activitiesRes = (await client.callTool({
name: "openproject_list_work_package_activities",
arguments: { workPackageId: 38 },
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(activitiesRes.isError).toBeFalsy();
const activitiesData = JSON.parse(activitiesRes.content[0]?.text ?? "[]");
expect(Array.isArray(activitiesData)).toBe(true);
expect(activitiesData.length).toBeGreaterThan(0);
expect(activitiesData[0].id).toBeDefined();
} finally {
await client.close();
await mcpServer.stop();
}
});
});
+725
View File
@@ -0,0 +1,725 @@
import { describe, expect, test } from "bun:test";
import {
OpenProjectAuthenticationError,
OpenProjectClient,
OpenProjectError,
} from "../src/client/api-client.ts";
import { runWithContext, type RequestContext } from "../src/context.ts";
import { listMeetings, getMeeting, searchMeetings } from "../src/services/meetings.ts";
describe("Meetings Service", () => {
test("listMeetings serializes project filter and parses meeting items", async () => {
const mockClient = {
get: async (path: string) => {
expect(path).toContain("/api/v3/meetings");
return {
_type: "Collection",
total: 1,
count: 1,
pageSize: 20,
offset: 1,
_embedded: {
elements: [
{
_type: "Meeting",
id: 2,
title: "Weekly Planning",
state: "open",
startTime: "2026-09-08T00:22:53Z",
endTime: "2026-09-08T01:22:53Z",
duration: "PT1H",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo project" },
author: { href: "/api/v3/users/4", title: "Admin" }
}
}
]
}
};
}
} as unknown as OpenProjectClient;
const res = await listMeetings({ projectId: 1 }, mockClient);
expect(res.total).toBe(1);
expect(res.elements[0]!.title).toBe("Weekly Planning");
expect(res.elements[0]!.project.name).toBe("Demo project");
});
test("getMeeting fetches details and embeds agenda items", async () => {
const mockClient = {
get: async (path: string) => {
if (path === "/api/v3/meetings/2") {
return {
_type: "Meeting",
id: 2,
title: "Weekly Planning",
state: "open",
startTime: "2026-09-08T00:22:53Z",
endTime: "2026-09-08T01:22:53Z",
template: false,
notify: false,
_links: {
project: { href: "/api/v3/projects/1", title: "Demo project" },
participants: [{ href: "/api/v3/users/4", title: "Admin" }]
}
};
}
if (path === "/api/v3/meetings/2/agenda_items") {
return {
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
_type: "MeetingAgendaItem",
id: 10,
title: "Good news",
position: 1,
itemType: "simple",
durationInMinutes: 5,
notes: { raw: "What went well this week?" },
_embedded: { outcomes: [] },
_links: { outcomes: [] }
}
]
}
};
}
throw new Error(`Unexpected path: ${path}`);
}
} as unknown as OpenProjectClient;
const meeting = await getMeeting(2, { includeAgendaItems: true }, mockClient);
expect(meeting.title).toBe("Weekly Planning");
expect(meeting.agendaItems).toHaveLength(1);
expect(meeting.agendaItems![0]!.title).toBe("Good news");
expect(meeting.agendaItems![0]!.notes).toBe("What went well this week?");
});
test("searchMeetings finds matches in agenda notes", async () => {
const mockClient = {
get: async (path: string) => {
if (path.startsWith("/api/v3/meetings?")) {
return {
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
_type: "Meeting",
id: 2,
title: "Weekly Planning",
state: "open",
startTime: "2026-09-08T00:22:53Z",
endTime: "2026-09-08T01:22:53Z",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo project" }
}
}
]
}
};
}
if (path === "/api/v3/meetings/2/agenda_items") {
return {
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
_type: "MeetingAgendaItem",
id: 10,
title: "Sprint Review",
notes: { raw: "Discussed critical architecture improvements." }
}
]
}
};
}
if (path === "/api/v3/meetings/2") {
return {
_type: "Meeting",
id: 2,
title: "Weekly Planning",
state: "open",
startTime: "2026-09-08T00:22:53Z",
endTime: "2026-09-08T01:22:53Z",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo project" }
}
};
}
throw new Error(`Unexpected path: ${path}`);
}
} as unknown as OpenProjectClient;
const res = await searchMeetings({ query: "architecture" }, mockClient);
expect(res.total).toBe(1);
expect(res.elements[0]!.matchType).toBe("agenda_item");
expect(res.elements[0]!.matchedAgendaItems![0]!.snippet).toContain("architecture");
});
test("searchMeetings matches by title and location", async () => {
const mockClient = {
get: async (path: string) => {
if (path.startsWith("/api/v3/meetings?")) {
return {
_type: "Collection",
total: 2,
_embedded: {
elements: [
{
_type: "Meeting",
id: 20,
title: "Sprint Retrospective",
state: "closed",
startTime: "2026-09-01T10:00:00Z",
endTime: "2026-09-01T11:00:00Z",
location: "Room A",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo" }
}
},
{
_type: "Meeting",
id: 21,
title: "Standup",
state: "open",
startTime: "2026-09-02T10:00:00Z",
endTime: "2026-09-02T10:15:00Z",
location: "Virtual Stage",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo" }
}
}
]
}
};
}
if (path.includes("/agenda_items")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
throw new Error(`Unexpected path: ${path}`);
}
} as unknown as OpenProjectClient;
const titleRes = await searchMeetings({ query: "Retrospective" }, mockClient);
expect(titleRes.total).toBe(1);
expect(titleRes.elements[0]!.matchType).toBe("title");
expect(titleRes.elements[0]!.meeting.id).toBe(20);
const locRes = await searchMeetings({ query: "Virtual" }, mockClient);
expect(locRes.total).toBe(1);
expect(locRes.elements[0]!.matchType).toBe("location");
expect(locRes.elements[0]!.meeting.id).toBe(21);
});
test("searchMeetings slices matched elements according to offset and pageSize", async () => {
const mockClient = {
get: async (path: string) => {
if (path.startsWith("/api/v3/meetings?")) {
return {
_type: "Collection",
total: 3,
_embedded: {
elements: [
{
_type: "Meeting",
id: 1,
title: "Arch Review 1",
state: "open",
startTime: "2026-09-01T10:00:00Z",
endTime: "2026-09-01T11:00:00Z",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } }
},
{
_type: "Meeting",
id: 2,
title: "Arch Review 2",
state: "open",
startTime: "2026-09-02T10:00:00Z",
endTime: "2026-09-02T11:00:00Z",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } }
},
{
_type: "Meeting",
id: 3,
title: "Arch Review 3",
state: "open",
startTime: "2026-09-03T10:00:00Z",
endTime: "2026-09-03T11:00:00Z",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } }
}
]
}
};
}
if (path.includes("/agenda_items")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
throw new Error(`Unexpected path: ${path}`);
}
} as unknown as OpenProjectClient;
const res = await searchMeetings({ query: "Arch", offset: 2, pageSize: 1 }, mockClient);
expect(res.total).toBe(3);
expect(res.count).toBe(1);
expect(res.pageSize).toBe(1);
expect(res.offset).toBe(2);
expect(res.elements[0]!.meeting.id).toBe(2);
});
test("searchMeetings iterates candidate pages when total meetings exceed single page size", async () => {
const requestedOffsets: number[] = [];
const mockClient = {
get: async (path: string) => {
if (path.startsWith("/api/v3/meetings?")) {
const url = new URL(`http://dummy${path}`);
const offset = Number(url.searchParams.get("offset") ?? "1");
requestedOffsets.push(offset);
if (offset === 1) {
// Page 1: 100 meetings (the pipeline's candidate batch size), none matching query
const page1Elements = Array.from({ length: 100 }, (_, i) => ({
_type: "Meeting",
id: i + 1,
title: `Daily Routine ${i + 1}`,
state: "open",
startTime: "2026-09-01T10:00:00Z",
endTime: "2026-09-01T10:30:00Z",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
}));
return {
_type: "Collection",
total: 105,
count: 100,
pageSize: 100,
offset: 1,
_embedded: { elements: page1Elements },
};
}
if (offset === 2) {
// Page 2: 5 meetings, meeting #102 has matching title
const page2Elements = Array.from({ length: 5 }, (_, i) => ({
_type: "Meeting",
id: 101 + i,
title: i === 1 ? "Special Architecture Sync" : `Daily Routine ${101 + i}`,
state: "open",
startTime: "2026-09-02T10:00:00Z",
endTime: "2026-09-02T10:30:00Z",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
}));
return {
_type: "Collection",
total: 105,
count: 5,
pageSize: 100,
offset: 2,
_embedded: { elements: page2Elements },
};
}
}
if (path.includes("/agenda_items")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
throw new Error(`Unexpected path: ${path}`);
},
} as unknown as OpenProjectClient;
const res = await searchMeetings({ query: "Architecture" }, mockClient);
expect(requestedOffsets).toEqual([1, 2]);
expect(res.total).toBe(1);
expect(res.elements[0]!.meeting.id).toBe(102);
expect(res.elements[0]!.meeting.title).toBe("Special Architecture Sync");
});
test("listMeetings supports time filter (upcoming and past)", async () => {
let capturedPath = "";
const mockClient = {
get: async (path: string) => {
capturedPath = path;
return {
_type: "Collection",
total: 0,
count: 0,
pageSize: 20,
offset: 1,
_embedded: { elements: [] }
};
}
} as unknown as OpenProjectClient;
await listMeetings({ time: "upcoming" }, mockClient);
expect(decodeURIComponent(capturedPath)).toContain('"time":{"operator":"upcoming","values":[]}');
await listMeetings({ time: "past" }, mockClient);
expect(decodeURIComponent(capturedPath)).toContain('"time":{"operator":"past","values":[]}');
});
test("listMeetings resolves project identifier string to ID", async () => {
let capturedPath = "";
const mockClient = {
get: async (path: string) => {
if (path === "projects/demo-project") {
return { id: 77 };
}
capturedPath = path;
return {
_type: "Collection",
total: 0,
count: 0,
pageSize: 20,
offset: 1,
_embedded: { elements: [] }
};
}
} as unknown as OpenProjectClient;
await listMeetings({ projectId: "demo-project" }, mockClient);
expect(decodeURIComponent(capturedPath)).toContain('"project_id":{"operator":"=","values":["77"]}');
});
test("listMeetings resolves ambient RequestContext client when client is omitted", async () => {
const ambientClient = {
get: async (path: string) => {
expect(path).toContain("/api/v3/meetings");
return {
_type: "Collection",
total: 0,
count: 0,
pageSize: 20,
offset: 1,
_embedded: { elements: [] }
};
}
} as unknown as OpenProjectClient;
const context: RequestContext = {
client: ambientClient,
isReadOnly: false
};
await runWithContext(context, async () => {
const res = await listMeetings();
expect(res.total).toBe(0);
expect(res.elements).toEqual([]);
});
});
test("getMeeting with includeAgendaItems=false does not request agenda items", async () => {
let agendaCalled = false;
const mockClient = {
get: async (path: string) => {
if (path === "/api/v3/meetings/5") {
return {
_type: "Meeting",
id: 5,
title: "Quick Sync",
state: "open",
startTime: "2026-09-08T00:00:00Z",
endTime: "2026-09-08T00:30:00Z",
template: true,
notify: true,
_links: {
project: { href: "/api/v3/projects/1", title: "Demo" }
}
};
}
if (path.includes("/agenda_items")) {
agendaCalled = true;
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
throw new Error(`Unexpected path: ${path}`);
}
} as unknown as OpenProjectClient;
const detail = await getMeeting(5, { includeAgendaItems: false }, mockClient);
expect(detail.id).toBe(5);
expect(detail.template).toBe(true);
expect(detail.notify).toBe(true);
expect(detail.agendaItems).toBeUndefined();
expect(agendaCalled).toBe(false);
});
});
describe("Meetings Fuzzy Search", () => {
function meetingFixtureClient() {
return {
get: async (path: string) => {
if (path.startsWith("/api/v3/meetings?") || path === "/api/v3/meetings") {
return {
_type: "Collection",
total: 2,
count: 2,
pageSize: 50,
offset: 1,
_embedded: {
elements: [
{
_type: "Meeting",
id: 1,
title: "Approval of Q3 budget",
state: "open",
startTime: "2026-09-08T00:00:00Z",
endTime: "2026-09-08T01:00:00Z",
location: "Room A",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo project" },
author: { href: "/api/v3/users/4", title: "Müller" },
},
},
{
_type: "Meeting",
id: 2,
title: "Weekly sync",
state: "open",
startTime: "2026-09-09T00:00:00Z",
endTime: "2026-09-09T01:00:00Z",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo project" },
},
},
],
},
};
}
if (path === "/api/v3/meetings/1/agenda_items") {
return { _type: "Collection", _embedded: { elements: [] } };
}
if (path === "/api/v3/meetings/2/agenda_items") {
return {
_type: "Collection",
_embedded: {
elements: [
{
_type: "MeetingAgendaItem",
id: 20,
title: "Staffing",
notes: { raw: "We agreed on the hiring freeze until January." },
position: 1,
},
],
},
};
}
return { _type: "Collection", _embedded: { elements: [] } };
},
} as unknown as OpenProjectClient;
}
test("finds a meeting despite a typo in the query", async () => {
const result = await searchMeetings({ query: "budgt aproval" }, meetingFixtureClient());
expect(result.elements[0]!.meeting.id).toBe(1);
expect(result.elements[0]!.score).toBeGreaterThan(0);
});
test("finds a meeting with reordered query words", async () => {
const result = await searchMeetings({ query: "budget approval" }, meetingFixtureClient());
expect(result.elements[0]!.meeting.id).toBe(1);
});
test("finds a meeting by agenda item notes only", async () => {
const result = await searchMeetings({ query: "hiring freeze" }, meetingFixtureClient());
expect(result.elements[0]!.meeting.id).toBe(2);
expect(result.elements[0]!.matchType).toBe("agenda_item");
expect(result.elements[0]!.matchedAgendaItems?.[0]!.snippet).toContain("hiring freeze");
});
test("matches an author name with diacritics", async () => {
const result = await searchMeetings({ query: "muller" }, meetingFixtureClient());
expect(result.elements.some((e) => e.meeting.id === 1)).toBe(true);
});
test("exact mode finds nothing for a typo", async () => {
const result = await searchMeetings(
{ query: "budgt aproval", matchMode: "exact" },
meetingFixtureClient()
);
expect(result.elements).toHaveLength(0);
});
test("exact mode still finds a literal substring", async () => {
const result = await searchMeetings(
{ query: "Q3 budget", matchMode: "exact" },
meetingFixtureClient()
);
expect(result.elements[0]!.meeting.id).toBe(1);
});
test("reports degradation rather than silently returning fewer results", async () => {
const client = {
get: async (path: string) => {
if (path.includes("agenda_items")) {
throw new OpenProjectError("server error", { statusCode: 500 });
}
return meetingFixtureClient().get(path);
},
} as unknown as OpenProjectClient;
const result = await searchMeetings({ query: "budget" }, client);
expect(result.degraded).toBe(true);
expect(result.enrichmentFailures).toBeGreaterThan(0);
});
test("propagates an auth failure instead of reporting no results", async () => {
const client = {
get: async (path: string) => {
if (path.includes("agenda_items")) {
throw new OpenProjectAuthenticationError();
}
return meetingFixtureClient().get(path);
},
} as unknown as OpenProjectClient;
await expect(searchMeetings({ query: "budget" }, client)).rejects.toThrow(
OpenProjectAuthenticationError
);
});
});
describe("Meetings Search Regressions", () => {
function meetingResource(
id: number,
title: string,
extra: Record<string, unknown> = {},
links: Record<string, unknown> = {}
) {
return {
_type: "Meeting",
id,
title,
state: "open",
startTime: `2026-09-${String(id).padStart(2, "0")}T10:00:00Z`,
endTime: `2026-09-${String(id).padStart(2, "0")}T11:00:00Z`,
_links: { project: { href: "/api/v3/projects/1", title: "Demo" }, ...links },
...extra,
};
}
function collectionClient(elements: unknown[]) {
return {
get: async (path: string) => {
if (path.startsWith("/api/v3/meetings?") || path === "/api/v3/meetings") {
return {
_type: "Collection",
total: elements.length,
count: elements.length,
pageSize: 100,
offset: 1,
_embedded: { elements },
};
}
if (path.includes("/agenda_items")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
const id = Number(path.match(/meetings\/(\d+)/)?.[1] ?? NaN);
return elements.find((e) => (e as { id: number }).id === id);
},
} as unknown as OpenProjectClient;
}
test("an author-only match is reported as an author match, not a project match", async () => {
const client = collectionClient([
meetingResource(
1,
"Weekly sync",
{},
{ author: { href: "/api/v3/users/7", title: "Ingrid Kowalczyk" } }
),
meetingResource(
2,
"Retrospective",
{},
{ author: { href: "/api/v3/users/8", title: "Sam Okafor" } }
),
]);
const res = await searchMeetings({ query: "Kowalczyk" }, client);
expect(res.elements).toHaveLength(1);
expect(res.elements[0]!.meeting.id).toBe(1);
expect(res.elements[0]!.matchedFields).toContain("author");
// Before the fix this fell through resolveMatchType and reported
// "project", telling the model the meeting matched a project name.
expect(res.elements[0]!.matchType).toBe("author");
});
test("offset is a page number: page 2 does not overlap page 1", async () => {
const elements = Array.from({ length: 12 }, (_, i) =>
meetingResource(i + 1, `Arch Review ${i + 1}`)
);
const client = collectionClient(elements);
const whole = await searchMeetings(
{ query: "Arch Review", offset: 1, pageSize: 100 },
client
);
const page1 = await searchMeetings({ query: "Arch Review", offset: 1, pageSize: 5 }, client);
const page2 = await searchMeetings({ query: "Arch Review", offset: 2, pageSize: 5 }, client);
const allIds = whole.elements.map((e) => e.meeting.id);
const ids1 = page1.elements.map((e) => e.meeting.id);
const ids2 = page2.elements.map((e) => e.meeting.id);
expect(allIds).toHaveLength(12);
expect(ids1).toEqual(allIds.slice(0, 5));
// The bug returned allIds.slice(1, 6) here — four of five items repeated.
expect(ids2).toEqual(allIds.slice(5, 10));
expect(ids1.filter((id) => ids2.includes(id))).toEqual([]);
});
test("does not ship the result array twice", async () => {
const client = collectionClient([meetingResource(1, "Arch Review")]);
const res = await searchMeetings({ query: "Arch Review" }, client);
expect(res.elements).toHaveLength(1);
expect(Object.keys(res)).not.toContain("items");
expect(JSON.parse(JSON.stringify(res))).not.toHaveProperty("items");
});
});
describe("getMeeting error propagation", () => {
function clientFailingAgendaWith(error: unknown) {
return {
get: async (path: string) => {
if (path.includes("/agenda_items")) {
throw error;
}
return {
_type: "Meeting",
id: 5,
title: "Weekly sync",
state: "open",
startTime: "2026-09-05T10:00:00Z",
endTime: "2026-09-05T11:00:00Z",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
};
},
} as unknown as OpenProjectClient;
}
test("propagates an authentication failure from the agenda fetch", async () => {
await expect(
getMeeting(5, { includeAgendaItems: true }, clientFailingAgendaWith(
new OpenProjectAuthenticationError()
))
).rejects.toThrow(OpenProjectAuthenticationError);
});
test("propagates a rate limit from the agenda fetch", async () => {
await expect(
getMeeting(5, { includeAgendaItems: true }, clientFailingAgendaWith(
new OpenProjectError("rate limited", { statusCode: 429 })
))
).rejects.toThrow("rate limited");
});
test("still degrades to an empty agenda on a non-fatal failure", async () => {
const detail = await getMeeting(
5,
{ includeAgendaItems: true },
clientFailingAgendaWith(new OpenProjectError("boom", { statusCode: 500 }))
);
expect(detail.id).toBe(5);
expect(detail.agendaItems).toEqual([]);
});
});
+270
View File
@@ -0,0 +1,270 @@
import { describe, expect, test, beforeEach } from "bun:test";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
getOpenApiSpec,
clearOpenApiCache,
type OpenApiSummary,
} from "../src/services/openapi.ts";
import { OpenProjectClient } from "../src/client/api-client.ts";
import { OpenProjectNotFoundError } from "../src/client/errors.ts";
import { runWithContext } from "../src/context.ts";
import {
getOpenApiSpecTool,
registerOpenApiTools,
} from "../src/tools/openapi.ts";
import { allTools } from "../src/tools/index.ts";
const mockSpec = {
openapi: "3.0.3",
info: { title: "OpenProject API V3 (Test)", version: "3" },
paths: {
"/api/v3/work_packages": {
get: {
summary: "List work packages",
operationId: "list_work_packages",
tags: ["Work Packages"],
parameters: [{ name: "offset", in: "query" }],
responses: { "200": { description: "OK" } },
},
post: {
summary: "Create work package",
operationId: "create_work_package",
tags: ["Work Packages"],
responses: { "201": { description: "Created" } },
},
},
"/api/v3/projects": {
get: {
summary: "List projects",
operationId: "list_projects",
tags: ["Projects"],
responses: { "200": { description: "OK" } },
},
},
},
components: {
schemas: {
WorkPackageModel: {
type: "object",
properties: { id: { type: "integer" }, subject: { type: "string" } },
},
},
},
};
let getCallCount = 0;
const mockClient = {
baseUrl: "http://mock-openproject.local",
get: async (path: string) => {
getCallCount++;
if (path.includes("openapi.json")) {
return mockSpec;
}
throw new Error("Not found");
},
} as unknown as OpenProjectClient;
describe("OpenApi Service", () => {
beforeEach(() => {
clearOpenApiCache();
getCallCount = 0;
});
test("summary mode returns overview and instructions when no filters passed", async () => {
const result = (await getOpenApiSpec({}, mockClient)) as OpenApiSummary;
expect(result.title).toBe("OpenProject API V3 (Test)");
expect(result.version).toBe("3");
expect(result.openapi).toBe("3.0.3");
expect(result.totalPaths).toBe(2);
expect(result.tags).toHaveLength(2);
expect(result.tags[0]).toEqual({ name: "Projects", endpointCount: 1 });
expect(result.tags[1]).toEqual({ name: "Work Packages", endpointCount: 1 });
expect(result.availablePaths).toContain("/api/v3/work_packages");
expect(result.instructions).toContain("Call openproject_get_openapi_spec");
});
test("caches spec in memory and avoids duplicate HTTP GET calls", async () => {
await getOpenApiSpec({}, mockClient);
expect(getCallCount).toBe(1);
await getOpenApiSpec({}, mockClient);
expect(getCallCount).toBe(1); // Served from cache
await getOpenApiSpec({ refresh: true }, mockClient);
expect(getCallCount).toBe(2); // Evicted and re-fetched
});
test("path mode resolves exact and normalized path endpoints", async () => {
const res1 = (await getOpenApiSpec({ path: "/api/v3/work_packages" }, mockClient)) as {
path: string;
operations: Record<string, unknown>;
};
expect(res1.path).toBe("/api/v3/work_packages");
expect(res1.operations.get).toBeDefined();
expect(res1.operations.post).toBeDefined();
// Normalization test without /api/v3 prefix
const res2 = (await getOpenApiSpec({ path: "work_packages" }, mockClient)) as {
path: string;
operations: Record<string, unknown>;
};
expect(res2.path).toBe("/api/v3/work_packages");
});
test("path mode throws OpenProjectNotFoundError when path does not exist", async () => {
expect(getOpenApiSpec({ path: "/api/v3/nonexistent" }, mockClient)).rejects.toThrow(
OpenProjectNotFoundError
);
});
test("tag mode filters paths by functional domain tag", async () => {
const result = (await getOpenApiSpec({ tag: "Work Packages" }, mockClient)) as {
tag: string;
totalPaths: number;
paths: Record<string, unknown>;
};
expect(result.tag).toBe("Work Packages");
expect(result.totalPaths).toBe(1);
expect(result.paths["/api/v3/work_packages"]).toBeDefined();
expect(result.paths["/api/v3/projects"]).toBeUndefined();
});
test("tag mode throws OpenProjectNotFoundError when tag does not exist", async () => {
expect(getOpenApiSpec({ tag: "NonExistentTag" }, mockClient)).rejects.toThrow(
OpenProjectNotFoundError
);
});
test("schema mode extracts component model from components.schemas", async () => {
const result = (await getOpenApiSpec({ schema: "WorkPackageModel" }, mockClient)) as {
schemaName: string;
schema: { type: string; properties: Record<string, unknown> };
};
expect(result.schemaName).toBe("WorkPackageModel");
expect(result.schema.type).toBe("object");
expect(result.schema.properties.subject).toBeDefined();
});
test("schema mode throws OpenProjectNotFoundError when schema does not exist", async () => {
expect(getOpenApiSpec({ schema: "UnknownModel" }, mockClient)).rejects.toThrow(
OpenProjectNotFoundError
);
});
test("resolves ambient client from RequestContext when client is omitted", async () => {
const res = await runWithContext({ client: mockClient, isReadOnly: true }, async () => {
return getOpenApiSpec({});
});
expect((res as OpenApiSummary).title).toBe("OpenProject API V3 (Test)");
});
test("path mode does not falsely match root /api/v3 via empty suffix", async () => {
// If /api/v3 itself is not a path key, requesting it must throw OpenProjectNotFoundError
expect(getOpenApiSpec({ path: "/api/v3" }, mockClient)).rejects.toThrow(
OpenProjectNotFoundError
);
});
});
describe("OpenApi MCP Tool Registration & Execution", () => {
beforeEach(() => {
clearOpenApiCache();
getCallCount = 0;
});
test("getOpenApiSpecTool is marked readOnly: true and exposes proper schema", () => {
expect(getOpenApiSpecTool.name).toBe("openproject_get_openapi_spec");
expect(getOpenApiSpecTool.readOnly).toBe(true);
expect(getOpenApiSpecTool.parameters).toBeDefined();
});
test("executes openproject_get_openapi_spec tool via MCP Client inside RequestContext", async () => {
const server = new McpServer({ name: "openapi-test", version: "1.0.0" });
registerOpenApiTools(server);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "client", version: "1.0.0" });
await client.connect(clientTransport);
const res = (await runWithContext({ client: mockClient, isReadOnly: true }, async () => {
return client.callTool({
name: "openproject_get_openapi_spec",
arguments: { tag: "Work Packages" },
});
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(res.isError).toBeFalsy();
const data = JSON.parse(res.content[0]?.text ?? "{}");
expect(data.tag).toBe("Work Packages");
expect(data.totalPaths).toBe(1);
await client.close();
await server.close();
});
test("executes openproject_get_openapi_spec tool handling error when target not found", async () => {
const server = new McpServer({ name: "openapi-test", version: "1.0.0" });
registerOpenApiTools(server);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "client", version: "1.0.0" });
await client.connect(clientTransport);
const res = (await runWithContext({ client: mockClient, isReadOnly: true }, async () => {
return client.callTool({
name: "openproject_get_openapi_spec",
arguments: { path: "/api/v3/nonexistent" },
});
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
expect(res.isError).toBe(true);
expect(res.content[0]?.text).toContain("OpenAPI path '/api/v3/nonexistent' not found");
await client.close();
await server.close();
});
test("allTools includes openproject_get_openapi_spec (total: 19 tools)", () => {
const toolNames = allTools.map((t) => t.name);
expect(toolNames).toContain("openproject_get_openapi_spec");
expect(allTools).toHaveLength(19);
});
});
describe("Live Container OpenAPI Integration", () => {
const liveBaseUrl = process.env.OPENPROJECT_BASE_URL || "http://localhost:8080";
const liveApiKey = process.env.OPENPROJECT_API_KEY;
const runLiveTests = liveApiKey ? test : test.skip;
const liveClient = new OpenProjectClient({ baseUrl: liveBaseUrl, apiKey: liveApiKey || "skip" });
runLiveTests("live query returns OpenProject API summary with paths and tags", async () => {
const summary = (await getOpenApiSpec({}, liveClient)) as OpenApiSummary;
expect(summary.title).toContain("OpenProject API");
expect(summary.totalPaths).toBeGreaterThan(200);
expect(summary.tags.length).toBeGreaterThan(30);
expect(summary.availablePaths).toContain("/api/v3/work_packages");
});
runLiveTests("live query fetches operation details for /api/v3/work_packages", async () => {
const res = (await getOpenApiSpec({ path: "/api/v3/work_packages" }, liveClient)) as {
path: string;
operations: {
get?: {
parameters?: Array<{ name: string }>;
};
};
};
expect(res.operations.get).toBeDefined();
const paramNames = res.operations.get?.parameters?.map((p) => p.name) ?? [];
expect(paramNames).toContain("offset");
expect(paramNames).toContain("pageSize");
});
});
+266
View File
@@ -0,0 +1,266 @@
import { describe, expect, test } from "bun:test";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createServer } from "../src/server";
import { parseConfig } from "../src/config";
import {
registerTool,
type ToolDefinition,
} from "../src/tools/common";
import { allTools, registerAllTools, type AnyToolDefinition } from "../src/tools";
import { OpenProjectError } from "../src/client/errors";
import { formatToolError } from "../src/tools/common";
import { getRequestContext } from "../src/context";
describe("Read-Only Mode Enforcement", () => {
test("parseConfig parses read-only flags from env and argv", () => {
const configEnv = parseConfig(
{
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
OPENPROJECT_READ_ONLY: "true",
},
[]
);
expect(configEnv.readOnly).toBe(true);
const configArg = parseConfig(
{
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
},
["--read-only"]
);
expect(configArg.readOnly).toBe(true);
const configDefault = parseConfig(
{
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
},
[]
);
expect(configDefault.readOnly).toBe(false);
const configOne = parseConfig(
{
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
OPENPROJECT_READ_ONLY: "1",
},
[]
);
expect(configOne.readOnly).toBe(true);
const configYes = parseConfig(
{
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
OPENPROJECT_READ_ONLY: "yes",
},
[]
);
expect(configYes.readOnly).toBe(true);
const configFalse = parseConfig(
{
OPENPROJECT_BASE_URL: "http://localhost:8080",
OPENPROJECT_API_KEY: "key",
OPENPROJECT_READ_ONLY: "false",
},
[]
);
expect(configFalse.readOnly).toBe(false);
});
test("read-only server lists all 19 tools (all are readOnly: true)", async () => {
const mcpServer = createServer({
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: true,
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "client", version: "1" });
await client.connect(clientTransport);
const toolsResult = await client.listTools();
expect(toolsResult.tools).toHaveLength(19);
await client.close();
await mcpServer.stop();
});
test("read-only mode filters out mutating tools from tools/list", async () => {
const syntheticMutatingTool: ToolDefinition = {
name: "openproject_create_work_package_synthetic",
description: "Synthetic mutating tool",
readOnly: false,
execute: async () => ({ content: [{ type: "text", text: "created" }] }),
};
// Also verify registerAllTools explicitly filters out mutating tools when readOnly is true
const standaloneServer = new McpServer({ name: "test-server", version: "1.0.0" });
registerAllTools(standaloneServer, {
readOnly: true,
tools: [...allTools, syntheticMutatingTool as unknown as AnyToolDefinition],
});
const [cTransport, sTransport] = InMemoryTransport.createLinkedPair();
await standaloneServer.connect(sTransport);
const client2 = new Client({ name: "client-filter-test", version: "1.0.0" });
await client2.connect(cTransport);
const standaloneTools = await client2.listTools();
const standaloneNames = standaloneTools.tools.map((t) => t.name);
expect(standaloneNames).not.toContain("openproject_create_work_package_synthetic");
expect(standaloneTools.tools).toHaveLength(19);
await client2.close();
await standaloneServer.close();
});
test("execution guard returns SERVER_READ_ONLY if mutating tool is called in read-only mode", async () => {
let executionAttempted = false;
const syntheticMutatingTool: ToolDefinition = {
name: "openproject_synthetic_write",
description: "Write tool",
readOnly: false,
execute: async () => {
executionAttempted = true;
return { content: [{ type: "text", text: "written" }] };
},
};
const mcpServer = createServer({
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: true,
});
// Register with mutating tool included
registerTool(mcpServer.server, syntheticMutatingTool, {
wrapExecute: async (fn, tool) => {
if (true && !tool.readOnly) {
return formatToolError(
new OpenProjectError(
"Operation rejected. OpenProject MCP server is running in read-only mode.",
{ code: "SERVER_READ_ONLY" }
)
);
}
return fn();
},
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.start(serverTransport);
const client = new Client({ name: "client", version: "1" });
await client.connect(clientTransport);
const callRes = (await client.callTool({
name: "openproject_synthetic_write",
arguments: {},
})) as {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
expect(callRes.isError).toBe(true);
expect(callRes.content[0]!.text).toContain("SERVER_READ_ONLY");
expect(callRes.content[0]!.text).toContain(
"Operation rejected. OpenProject MCP server is running in read-only mode."
);
expect(executionAttempted).toBe(false);
await client.close();
await mcpServer.stop();
});
test("propagates isReadOnly flag in RequestContext during tool execution", async () => {
let capturedReadOnly: boolean | undefined;
const readOnlyServer = createServer({
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: true,
});
(
readOnlyServer.client as unknown as {
get: (path: string, query?: Record<string, unknown>) => Promise<unknown>;
}
).get = async () => {
const ctx = getRequestContext();
capturedReadOnly = ctx.isReadOnly;
return {
_embedded: { elements: [] },
total: 0,
count: 0,
pageSize: 20,
offset: 1,
};
};
const [clientTransport1, serverTransport1] = InMemoryTransport.createLinkedPair();
await readOnlyServer.start(serverTransport1);
const client1 = new Client({ name: "ro-client", version: "1.0.0" });
await client1.connect(clientTransport1);
await client1.callTool({
name: "openproject_list_projects",
arguments: {},
});
expect(capturedReadOnly).toBe(true);
await client1.close();
await readOnlyServer.stop();
// Verify readOnly: false sets isReadOnly to false
let capturedReadWrite: boolean | undefined;
const readWriteServer = createServer({
baseUrl: "http://localhost:8080",
apiKey: "test-key",
readOnly: false,
});
(
readWriteServer.client as unknown as {
get: (path: string, query?: Record<string, unknown>) => Promise<unknown>;
}
).get = async () => {
const ctx = getRequestContext();
capturedReadWrite = ctx.isReadOnly;
return {
_embedded: { elements: [] },
total: 0,
count: 0,
pageSize: 20,
offset: 1,
};
};
const [clientTransport2, serverTransport2] = InMemoryTransport.createLinkedPair();
await readWriteServer.start(serverTransport2);
const client2 = new Client({ name: "rw-client", version: "1.0.0" });
await client2.connect(clientTransport2);
await client2.callTool({
name: "openproject_list_projects",
arguments: {},
});
expect(capturedReadWrite).toBe(false);
await client2.close();
await readWriteServer.stop();
});
});
+204
View File
@@ -0,0 +1,204 @@
import { describe, expect, test } from "bun:test";
import { mapWithConcurrency, searchPipeline } from "../../src/search/pipeline.ts";
import { OpenProjectAuthenticationError, OpenProjectError } from "../../src/client/api-client.ts";
import type { FieldSpec } from "../../src/search/rank.ts";
interface Candidate {
id: number;
title: string;
updatedAt: string;
}
interface Enriched {
id: number;
notes: string;
}
function makeCandidates(count: number): Candidate[] {
return Array.from({ length: count }, (_, index) => ({
id: index + 1,
title: `Item ${index + 1}`,
updatedAt: new Date(2026, 0, index + 1).toISOString(),
}));
}
const SHALLOW: FieldSpec<Candidate>[] = [{ name: "title", weight: 3, extract: (c) => c.title }];
const DEEP: FieldSpec<Enriched>[] = [{ name: "notes", weight: 2, extract: (e) => e.notes }];
describe("mapWithConcurrency", () => {
test("never exceeds the concurrency limit", async () => {
let inFlight = 0;
let peak = 0;
await mapWithConcurrency(Array.from({ length: 50 }, (_, i) => i), 8, async (item) => {
inFlight++;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 1));
inFlight--;
return item;
});
expect(peak).toBeLessThanOrEqual(8);
});
test("preserves input order in the results", async () => {
const result = await mapWithConcurrency([3, 1, 2], 2, async (item) => {
await new Promise((resolve) => setTimeout(resolve, item));
return item * 10;
});
expect(result).toEqual([30, 10, 20]);
});
test("handles an empty input array", async () => {
expect(await mapWithConcurrency([], 4, async (i) => i)).toEqual([]);
});
});
describe("searchPipeline", () => {
test("caps enrichment at the enrich limit", async () => {
let enrichCalls = 0;
await searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(100),
shallowFields: SHALLOW,
enrich: async (candidate) => {
enrichCalls++;
return { id: candidate.id, notes: "" };
},
deepFields: DEEP,
recencyOf: (candidate) => candidate.updatedAt,
},
"item"
);
expect(enrichCalls).toBe(25);
});
test("backfills the enrichment set by recency when few candidates match shallowly", async () => {
const enriched: number[] = [];
await searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(100),
shallowFields: SHALLOW,
enrich: async (candidate) => {
enriched.push(candidate.id);
return { id: candidate.id, notes: "" };
},
deepFields: DEEP,
recencyOf: (candidate) => candidate.updatedAt,
},
"zzzznomatch"
);
// Nothing matches shallowly, so the whole budget is spent on the most
// recently updated candidates: ids 100 down to 76.
expect(enriched).toHaveLength(25);
expect(enriched[0]).toBe(100);
expect(enriched).toContain(76);
expect(enriched).not.toContain(75);
});
test("finds a record whose only match is in deep content", async () => {
const result = await searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(10),
shallowFields: SHALLOW,
enrich: async (candidate) => ({
id: candidate.id,
notes: candidate.id === 3 ? "We agreed on the hiring freeze" : "routine notes",
}),
deepFields: DEEP,
recencyOf: (candidate) => candidate.updatedAt,
},
"hiring freeze"
);
expect(result.ranked[0]!.record.id).toBe(3);
expect(result.ranked[0]!.deep?.notes).toContain("hiring freeze");
});
test("takes the maximum of shallow and deep scores rather than the sum", async () => {
const result = await searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => [{ id: 1, title: "Budget", updatedAt: "2026-01-01" }],
shallowFields: SHALLOW,
enrich: async () => ({ id: 1, notes: "totally unrelated filler text" }),
deepFields: DEEP,
},
"budget"
);
expect(result.ranked[0]!.score).toBeLessThanOrEqual(1);
expect(result.ranked[0]!.score).toBeGreaterThan(0.9);
});
test("marks the result degraded when an enrichment fails non-fatally", async () => {
const result = await searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(3),
shallowFields: SHALLOW,
enrich: async (candidate) => {
if (candidate.id === 2) {
throw new OpenProjectError("boom", { statusCode: 500 });
}
return { id: candidate.id, notes: "" };
},
deepFields: DEEP,
},
"item"
);
expect(result.degraded).toBe(true);
expect(result.enrichmentFailures).toBe(1);
expect(result.ranked.length).toBeGreaterThan(0);
});
test("propagates authentication errors instead of swallowing them", async () => {
const run = searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(3),
shallowFields: SHALLOW,
enrich: async () => {
throw new OpenProjectAuthenticationError();
},
deepFields: DEEP,
},
"item"
);
await expect(run).rejects.toThrow(OpenProjectAuthenticationError);
});
test("propagates rate limit errors instead of swallowing them", async () => {
const run = searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(3),
shallowFields: SHALLOW,
enrich: async () => {
throw new OpenProjectError("slow down", { statusCode: 429 });
},
deepFields: DEEP,
},
"item"
);
await expect(run).rejects.toThrow("slow down");
});
test("reports no degradation when every enrichment succeeds", async () => {
const result = await searchPipeline<Candidate, Enriched>(
{
fetchCandidates: async () => makeCandidates(3),
shallowFields: SHALLOW,
enrich: async (candidate) => ({ id: candidate.id, notes: "" }),
deepFields: DEEP,
},
"item"
);
expect(result.degraded).toBe(false);
expect(result.enrichmentFailures).toBe(0);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, test } from "bun:test";
import { rankRecords, type FieldSpec } from "../../src/search/rank.ts";
interface Doc {
id: number;
title: string;
body?: string;
tags?: string[];
}
const FIELDS: FieldSpec<Doc>[] = [
{ name: "title", weight: 3, extract: (d) => d.title },
{ name: "body", weight: 1, extract: (d) => d.body },
{ name: "tags", weight: 0.5, extract: (d) => d.tags },
];
const DOCS: Doc[] = [
{ id: 1, title: "Budget approval", body: "Nothing relevant here" },
{ id: 2, title: "Weekly sync", body: "We discussed the budget approval process at length" },
{ id: 3, title: "Retrospective", body: "No overlap", tags: ["budget"] },
{ id: 4, title: "Unrelated", body: "Completely different subject matter" },
];
describe("rankRecords", () => {
test("ranks a title match above a body match", () => {
const results = rankRecords(DOCS, "budget approval", FIELDS);
expect(results[0]!.record.id).toBe(1);
expect(results[1]!.record.id).toBe(2);
});
test("excludes records below the minimum score", () => {
const results = rankRecords(DOCS, "budget approval", FIELDS);
expect(results.some((r) => r.record.id === 4)).toBe(false);
});
test("tolerates typos", () => {
const results = rankRecords(DOCS, "budgt aproval", FIELDS);
expect(results[0]!.record.id).toBe(1);
});
test("tolerates reordered words", () => {
const results = rankRecords(DOCS, "approval budget", FIELDS);
expect(results[0]!.record.id).toBe(1);
});
test("reports which fields matched, with snippets", () => {
const results = rankRecords(DOCS, "budget approval", FIELDS);
const second = results.find((r) => r.record.id === 2)!;
expect(second.matches.map((m) => m.field)).toContain("body");
expect(second.matches[0]!.snippet).toBeDefined();
});
test("searches array-valued fields", () => {
const results = rankRecords(DOCS, "budget", FIELDS);
expect(results.some((r) => r.record.id === 3)).toBe(true);
});
test("breaks ties by ascending record id for determinism", () => {
const tied: Doc[] = [
{ id: 9, title: "Budget" },
{ id: 4, title: "Budget" },
{ id: 7, title: "Budget" },
];
const results = rankRecords(tied, "budget", FIELDS);
expect(results.map((r) => r.record.id)).toEqual([4, 7, 9]);
});
test("applies the limit after sorting", () => {
const results = rankRecords(DOCS, "budget", FIELDS, { limit: 1 });
expect(results).toHaveLength(1);
expect(results[0]!.record.id).toBe(1);
});
test("exact mode matches only literal substrings", () => {
const results = rankRecords(DOCS, "budget approval", FIELDS, { matchMode: "exact" });
expect(results.map((r) => r.record.id).sort()).toEqual([1, 2]);
});
test("exact mode finds no results for a typo", () => {
const results = rankRecords(DOCS, "budgt aproval", FIELDS, { matchMode: "exact" });
expect(results).toHaveLength(0);
});
test("exact mode keeps low-weight field hits that fall under minScore", () => {
const results = rankRecords(DOCS, "budget", FIELDS, { matchMode: "exact" });
expect(results.some((r) => r.record.id === 3)).toBe(true);
});
test("a blank query returns every record unranked", () => {
const results = rankRecords(DOCS, " ", FIELDS);
expect(results).toHaveLength(DOCS.length);
expect(results.every((r) => r.score === 0)).toBe(true);
});
test("a query under three characters falls back to substring containment", () => {
const results = rankRecords(DOCS, "bu", FIELDS);
expect(results.length).toBeGreaterThan(0);
expect(
results.every((r) => {
const haystack = [r.record.title, r.record.body ?? "", ...(r.record.tags ?? [])]
.join(" ")
.toLowerCase();
return haystack.includes("bu");
})
).toBe(true);
});
test("a body-only match is returned despite its low field weight", () => {
const bodyOnly: Doc[] = [{ id: 1, title: "Unrelated", body: "the hiring freeze was agreed" }];
const results = rankRecords(bodyOnly, "hiring freeze", FIELDS);
expect(results).toHaveLength(1);
expect(results[0]!.matches[0]!.field).toBe("body");
});
});
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test";
import { rankRecords, type FieldSpec } from "../../src/search/rank.ts";
interface Doc {
id: number;
title: string;
body: string;
people: string[];
}
const CORPUS: Doc[] = [
{
id: 1,
title: "Approval of Q3 budget",
body: "The finance team presented the quarterly figures and the board signed off.",
people: ["Anna Müller", "Ben Carter"],
},
{
id: 2,
title: "Weekly engineering sync",
body: "After a long discussion about staffing we reached a decision on the hiring freeze.",
people: ["Ben Carter"],
},
{
id: 3,
title: "Design review",
body: "Walked through the new onboarding flow mockups with the design team.",
people: ["Chen Wu", "Dana Ortiz"],
},
{
id: 4,
title: "Infrastructure retrospective",
body: "Root cause was an expired TLS certificate on the edge proxy.",
people: ["Dana Ortiz"],
},
{
id: 5,
title: "Vendor negotiation",
body: "Agreed new payment terms of net 45 with the hosting provider.",
people: ["Anna Müller"],
},
];
const FIELDS: FieldSpec<Doc>[] = [
{ name: "title", weight: 3, extract: (doc) => doc.title },
{ name: "body", weight: 1, extract: (doc) => doc.body },
{ name: "people", weight: 1, extract: (doc) => doc.people },
];
const CASES: Array<{ query: string; expectedId: number; why: string }> = [
{ query: "budget aproval", expectedId: 1, why: "typo in both words" },
{ query: "approval of the budget", expectedId: 1, why: "reordered with filler words" },
{ query: "Q3 budget", expectedId: 1, why: "verbatim phrase" },
{ query: "what did we decide about hiring", expectedId: 2, why: "natural-language body match" },
{ query: "hiring freeze", expectedId: 2, why: "exact body phrase" },
{ query: "staffing discussion", expectedId: 2, why: "paraphrase of body content" },
{ query: "onboarding mockups", expectedId: 3, why: "two body tokens" },
{ query: "design team", expectedId: 3, why: "title and body overlap" },
{ query: "tls certificate expired", expectedId: 4, why: "reordered body tokens" },
{ query: "certificat", expectedId: 4, why: "truncated word" },
{ query: "muller", expectedId: 1, why: "diacritic-insensitive person match; ties with doc 5 and wins on the id tie-break" },
{ query: "payment terms", expectedId: 5, why: "body phrase" },
];
describe("Search recall", () => {
for (const { query, expectedId, why } of CASES) {
test(`"${query}" finds document ${expectedId} (${why})`, () => {
const results = rankRecords(CORPUS, query, FIELDS);
expect(results.length).toBeGreaterThan(0);
expect(results[0]!.record.id).toBe(expectedId);
});
}
test("a query matching nothing returns no results rather than noise", () => {
const results = rankRecords(CORPUS, "quantum chromodynamics", FIELDS);
expect(results).toHaveLength(0);
});
test("results are stable across repeated identical searches", () => {
// Uses "muller" deliberately: docs 1 and 5 share the identical person
// string, so both score exactly 0.33 and the ORDER is decided purely by
// the ascending-id tie-break. A single-result query (e.g. "budget") would
// pass this assertion even if the tie-break were removed entirely, since
// a one-element array cannot be reordered.
const first = rankRecords(CORPUS, "muller", FIELDS).map((r) => r.record.id);
const second = rankRecords(CORPUS, "muller", FIELDS).map((r) => r.record.id);
expect(first).toHaveLength(2);
expect(first).toEqual([1, 5]);
expect(first).toEqual(second);
});
test("every legacy exact query still resolves in exact mode", () => {
const results = rankRecords(CORPUS, "hiring freeze", FIELDS, { matchMode: "exact" });
expect(results[0]!.record.id).toBe(2);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from "bun:test";
import { boundedLevenshtein, scoreField, scoreToken } from "../../src/search/score.ts";
import { tokenize } from "../../src/search/tokenize.ts";
describe("boundedLevenshtein", () => {
test("returns 0 for identical strings", () => {
expect(boundedLevenshtein("budget", "budget", 2)).toBe(0);
});
test("computes small distances within budget", () => {
expect(boundedLevenshtein("budget", "budgt", 2)).toBe(1);
expect(boundedLevenshtein("approval", "aproval", 2)).toBe(1);
});
test("returns max + 1 when the distance exceeds the budget", () => {
expect(boundedLevenshtein("cat", "dog", 1)).toBe(2);
expect(boundedLevenshtein("meeting", "xyz", 2)).toBe(3);
});
test("short-circuits on length difference alone", () => {
expect(boundedLevenshtein("a", "abcdefgh", 2)).toBe(3);
});
});
describe("scoreToken", () => {
test("scores an exact match at 1", () => {
expect(scoreToken("budget", "budget")).toBe(1);
});
test("scores a prefix match at 0.9 when the query token is at least 3 chars", () => {
expect(scoreToken("bud", "budget")).toBe(0.9);
});
test("does not prefix-match tokens shorter than 3 characters", () => {
expect(scoreToken("bu", "budget")).toBe(0);
});
test("scores a containment match at 0.75 when the query token is at least 4 chars", () => {
expect(scoreToken("prov", "approval")).toBe(0.75);
});
test("scores a single-typo match below an exact match but above zero", () => {
const score = scoreToken("budgt", "budget");
expect(score).toBeGreaterThan(0);
expect(score).toBeLessThan(1);
});
test("does NOT match unrelated short tokens", () => {
expect(scoreToken("cat", "dog")).toBe(0);
expect(scoreToken("red", "car")).toBe(0);
});
test("does not fuzzy-match tokens shorter than 3 characters", () => {
expect(scoreToken("ab", "ac")).toBe(0);
});
test("matches morphological variants via a shared prefix", () => {
// Neither token is a true prefix of the other, so these exercise the
// shared-prefix rule rather than being intercepted by the startsWith rule.
expect(scoreToken("decide", "decision")).toBeGreaterThan(0.5);
expect(scoreToken("deployed", "deployment")).toBeGreaterThan(0.5);
});
test("does not shared-prefix-match on fewer than 4 common characters", () => {
// "cars"/"carton" share only "car" (3 < MIN_SHARED_PREFIX) and neither is
// a true prefix of the other, so every rule falls through to zero.
expect(scoreToken("cars", "carton")).toBe(0);
expect(scoreToken("bees", "beetle")).toBe(0);
});
test("ranks a shared-prefix match below a true prefix match", () => {
expect(scoreToken("decide", "decision")).toBeLessThan(scoreToken("deci", "decision"));
});
});
describe("scoreField", () => {
test("scores a full phrase match highest", () => {
const query = tokenize("budget approval");
const both = scoreField(query, "Approval of the budget");
const one = scoreField(query, "Approval of the agenda");
expect(both.score).toBeGreaterThan(one.score);
});
test("coverage penalises documents matching only one query token", () => {
const query = tokenize("hiring decision");
const partial = scoreField(query, "Decision log");
expect(partial.score).toBeLessThan(0.6);
});
test("gives a verbatim phrase a bonus over scattered tokens", () => {
const query = tokenize("budget approval");
const verbatim = scoreField(query, "budget approval meeting");
const scattered = scoreField(query, "approval of the yearly budget");
expect(verbatim.score).toBeGreaterThan(scattered.score);
});
test("tolerates a typo in the query", () => {
const query = tokenize("budgt aproval");
const result = scoreField(query, "Approval of Q3 budget");
expect(result.score).toBeGreaterThan(0.5);
});
test("reports matched offsets that index into the original field text", () => {
const field = "Approval of Q3 budget";
const result = scoreField(tokenize("budget"), field);
expect(result.matchedOffsets.length).toBeGreaterThan(0);
expect(field.slice(result.matchedOffsets[0]!, result.matchedOffsets[0]! + 6)).toBe("budget");
});
test("returns zero for empty input", () => {
expect(scoreField([], "anything").score).toBe(0);
expect(scoreField(tokenize("budget"), "").score).toBe(0);
});
test("an all-tokens match without the phrase tops out below 1.0", () => {
const result = scoreField(tokenize("budget approval"), "approval of the budget");
expect(result.score).toBeCloseTo(0.85, 5);
});
test("only a verbatim phrase reaches 1.0", () => {
const result = scoreField(tokenize("budget approval"), "the budget approval doc");
expect(result.score).toBeCloseTo(1, 5);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test";
import { extractSnippet } from "../../src/search/snippet.ts";
const LONG_TEXT =
"The team met on Tuesday to review the quarterly plan. " +
"After a long discussion about staffing, we reached a decision on the hiring freeze. " +
"The remaining agenda items were deferred to the following week.";
describe("extractSnippet", () => {
test("centres the window on the match rather than the start of the text", () => {
const offset = LONG_TEXT.indexOf("hiring");
const snippet = extractSnippet(LONG_TEXT, [offset]);
expect(snippet).toContain("hiring");
expect(snippet.startsWith("...")).toBe(true);
});
test("adds a leading ellipsis only when the window is not at the start", () => {
const snippet = extractSnippet(LONG_TEXT, [0]);
expect(snippet.startsWith("...")).toBe(false);
});
test("adds a trailing ellipsis when the text continues past the window", () => {
const snippet = extractSnippet(LONG_TEXT, [0]);
expect(snippet.endsWith("...")).toBe(true);
});
test("returns short text whole with no ellipsis", () => {
expect(extractSnippet("Short note", [0])).toBe("Short note");
});
test("falls back to a head truncation when there are no offsets", () => {
const snippet = extractSnippet(LONG_TEXT, []);
expect(snippet.startsWith("The team met")).toBe(true);
expect(snippet.endsWith("...")).toBe(true);
});
test("returns an empty string for empty text", () => {
expect(extractSnippet("", [5])).toBe("");
});
test("uses the earliest offset when several are supplied", () => {
const first = LONG_TEXT.indexOf("staffing");
const later = LONG_TEXT.indexOf("deferred");
const snippet = extractSnippet(LONG_TEXT, [later, first]);
expect(snippet).toContain("staffing");
});
test("respects a custom maxLength", () => {
const snippet = extractSnippet(LONG_TEXT, [0], 20);
expect(snippet.replace(/\.\.\.$/, "").length).toBeLessThanOrEqual(20);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import {
normalizeText,
tokenize,
tokenizeQuery,
tokenizeWithOffsets,
} from "../../src/search/tokenize.ts";
describe("normalizeText", () => {
test("lowercases and strips diacritics", () => {
expect(normalizeText("Müller")).toBe("muller");
expect(normalizeText("Café")).toBe("cafe");
expect(normalizeText("RÉSUMÉ")).toBe("resume");
});
test("leaves plain ascii untouched apart from case", () => {
expect(normalizeText("Budget Approval")).toBe("budget approval");
});
});
describe("tokenize", () => {
test("splits on non-alphanumeric boundaries", () => {
expect(tokenize("Approval of Q3 budget!")).toEqual(["approval", "of", "q3", "budget"]);
});
test("returns an empty array for empty or punctuation-only input", () => {
expect(tokenize("")).toEqual([]);
expect(tokenize("--- ...")).toEqual([]);
});
test("preserves non-latin scripts", () => {
expect(tokenize("Привет мир")).toEqual(["привет", "мир"]);
});
});
describe("tokenizeQuery", () => {
test("strips English stopwords from a natural-language query", () => {
expect(tokenizeQuery("what did we decide about hiring")).toEqual(["decide", "hiring"]);
expect(tokenizeQuery("approval of the budget")).toEqual(["approval", "budget"]);
});
test("keeps stopwords when nothing else would survive", () => {
expect(tokenizeQuery("the who")).toEqual(["the", "who"]);
});
test("leaves a stopword-free query untouched", () => {
expect(tokenizeQuery("budget approval")).toEqual(["budget", "approval"]);
});
});
describe("tokenizeWithOffsets", () => {
test("reports offsets into the original text, not the normalized text", () => {
const tokens = tokenizeWithOffsets("Der Müller Bericht");
expect(tokens.map((t) => t.value)).toEqual(["der", "muller", "bericht"]);
expect(tokens.map((t) => t.offset)).toEqual([0, 4, 11]);
});
test("offsets index back into the source string correctly", () => {
const source = "Approval of Q3 budget";
const tokens = tokenizeWithOffsets(source);
const budget = tokens.find((t) => t.value === "budget")!;
expect(source.slice(budget.offset, budget.offset + 6)).toBe("budget");
});
});
+544 -35
View File
@@ -14,7 +14,11 @@ import {
listUsers,
} from "../src/services/metadata.ts";
import * as domainServices from "../src/services/index.ts";
import { OpenProjectClient } from "../src/client/api-client.ts";
import {
OpenProjectClient,
OpenProjectAuthenticationError,
OpenProjectError,
} from "../src/client/api-client.ts";
import { runWithContext, type RequestContext } from "../src/context.ts";
describe("Domain Services Helper", () => {
@@ -115,9 +119,9 @@ describe("Projects Service", () => {
expect(requestedUrl).toContain("offset=1");
expect(requestedUrl).toContain("sortBy=");
expect(result.total).toBe(1);
expect(result.items[0].id).toBe(4);
expect(result.items[0].identifier).toBe("mcp-test-project");
expect(result.items[0].name).toBe("MCP Test Project");
expect(result.items[0]!.id).toBe(4);
expect(result.items[0]!.identifier).toBe("mcp-test-project");
expect(result.items[0]!.name).toBe("MCP Test Project");
});
test("getProject retrieves project detail by id or identifier", async () => {
@@ -304,10 +308,10 @@ describe("Work Packages Service", () => {
expect(requestedUrl).toContain("filters=");
expect(requestedUrl).toContain("pageSize=25");
expect(result.total).toBe(1);
expect(result.items[0].id).toBe(38);
expect(result.items[0].subject).toBe("Implement MCP Server Core Protocol");
expect(result.items[0].status).toBe("In progress");
expect(result.items[0].type).toBe("Task");
expect(result.items[0]!.id).toBe(38);
expect(result.items[0]!.subject).toBe("Implement MCP Server Core Protocol");
expect(result.items[0]!.status).toBe("In progress");
expect(result.items[0]!.type).toBe("Task");
});
test("getWorkPackage returns full details with parent and children", async () => {
@@ -329,14 +333,17 @@ describe("Work Packages Service", () => {
expect(wp.parent?.id).toBe(30);
expect(wp.parent?.subject).toBe("Epic Parent");
expect(wp.children?.length).toBe(2);
expect(wp.children?.[0].id).toBe(39);
expect(wp.children?.[0]?.id).toBe(39);
expect(wp.lockVersion).toBe(3);
});
test("searchWorkPackages sets subject query filter", async () => {
let requestedUrl = "";
// The union strategy issues several requests in parallel (the precise
// subject filter, a broad recency window, and per-candidate enrichment
// calls), so every requested URL is captured rather than just the last.
const requestedUrls: string[] = [];
const mockFetch = async (input: string | URL | Request) => {
requestedUrl = String(input);
requestedUrls.push(String(input));
return new Response(
JSON.stringify({
_type: "Collection",
@@ -357,11 +364,12 @@ describe("Work Packages Service", () => {
});
await searchWorkPackages("MCP Server", { projectId: 4 }, client);
expect(requestedUrl).toContain("filters=");
// Filter must include substring operator for subject
const decodedUrl = decodeURIComponent(requestedUrl.replace(/\+/g, " "));
expect(decodedUrl).toContain('"subject"');
expect(decodedUrl).toContain("MCP Server");
// Filter must include substring operator for subject on the precise request.
const decodedUrls = requestedUrls.map((url) => decodeURIComponent(url.replace(/\+/g, " ")));
const subjectRequestUrl = decodedUrls.find((url) => url.includes('"subject"'));
expect(subjectRequestUrl).toBeDefined();
expect(subjectRequestUrl).toContain("MCP Server");
});
});
@@ -425,9 +433,9 @@ describe("Queries Service", () => {
expect(requestedUrl).toContain("pageSize=20");
const decodedUrl = decodeURIComponent(requestedUrl);
expect(decodedUrl).toContain('"project"');
expect(result.items[0].id).toBe(30);
expect(result.items[0].name).toBe("MCP Active Tasks");
expect(result.items[0].projectId).toBe(4);
expect(result.items[0]!.id).toBe(30);
expect(result.items[0]!.name).toBe("MCP Active Tasks");
expect(result.items[0]!.projectId).toBe(4);
});
test("getQuery returns normalized details with columns and filters", async () => {
@@ -448,9 +456,9 @@ describe("Queries Service", () => {
expect(query.name).toBe("MCP Active Tasks");
expect(query.columns).toContain("ID");
expect(query.columns).toContain("Subject");
expect(query.sortBy?.[0].attribute).toBe("id");
expect(query.sortBy?.[0].direction).toBe("asc");
expect(query.filters?.[0].field).toBe("Status");
expect(query.sortBy?.[0]?.attribute).toBe("id");
expect(query.sortBy?.[0]?.direction).toBe("asc");
expect(query.filters?.[0]?.field).toBe("Status");
expect(query.resultsHref).toBe("/api/v3/queries/30/results");
});
@@ -493,8 +501,8 @@ describe("Queries Service", () => {
const result = await getQueryResults(30, { pageSize: 10 }, client);
expect(requestedUrl).toContain("/api/v3/queries/30/results");
expect(requestedUrl).toContain("pageSize=10");
expect(result.items[0].id).toBe(38);
expect(result.items[0].subject).toBe("Implement MCP Server Core Protocol");
expect(result.items[0]!.id).toBe(38);
expect(result.items[0]!.subject).toBe("Implement MCP Server Core Protocol");
});
});
@@ -523,9 +531,9 @@ describe("Metadata Service", () => {
const statuses = await listStatuses(client);
expect(statuses.length).toBe(2);
expect(statuses[0].name).toBe("New");
expect(statuses[0].isClosed).toBe(false);
expect(statuses[1].isClosed).toBe(true);
expect(statuses[0]!.name).toBe("New");
expect(statuses[0]!.isClosed).toBe(false);
expect(statuses[1]!.isClosed).toBe(true);
});
test("listTypes scopes to project when projectId is provided", async () => {
@@ -551,7 +559,7 @@ describe("Metadata Service", () => {
const types = await listTypes({ projectId: 4 }, client);
expect(requestedUrl).toContain("/api/v3/projects/4/types");
expect(types[0].name).toBe("Task");
expect(types[0]!.name).toBe("Task");
});
test("listTypes fetches global types when projectId is omitted", async () => {
@@ -578,7 +586,7 @@ describe("Metadata Service", () => {
const types = await listTypes(undefined, client);
expect(requestedUrl).toContain("/api/v3/types");
expect(requestedUrl).not.toContain("/projects/");
expect(types[0].name).toBe("Task");
expect(types[0]!.name).toBe("Task");
});
test("listPriorities retrieves priority items", async () => {
@@ -601,7 +609,7 @@ describe("Metadata Service", () => {
});
const priorities = await listPriorities(client);
expect(priorities[0].name).toBe("High");
expect(priorities[0]!.name).toBe("High");
});
test("listUsers returns paginated UserItem list", async () => {
@@ -631,8 +639,8 @@ describe("Metadata Service", () => {
const users = await listUsers({ pageSize: 25, offset: 1 }, client);
expect(requestedUrl).toContain("/api/v3/users");
expect(users.items[0].login).toBe("admin");
expect(users.items[0].admin).toBe(true);
expect(users.items[0]!.login).toBe("admin");
expect(users.items[0]!.admin).toBe(true);
});
test("src/services/index.ts exports all services properly", () => {
@@ -641,12 +649,19 @@ describe("Metadata Service", () => {
expect(typeof domainServices.getProjectSchema).toBe("function");
expect(typeof domainServices.listWorkPackages).toBe("function");
expect(typeof domainServices.getWorkPackage).toBe("function");
expect(typeof domainServices.listWorkPackageActivities).toBe("function");
expect(typeof domainServices.listQueries).toBe("function");
expect(typeof domainServices.getQuery).toBe("function");
expect(typeof domainServices.listStatuses).toBe("function");
expect(typeof domainServices.listTypes).toBe("function");
expect(typeof domainServices.listPriorities).toBe("function");
expect(typeof domainServices.listUsers).toBe("function");
expect(typeof domainServices.listMeetings).toBe("function");
expect(typeof domainServices.getMeeting).toBe("function");
expect(typeof domainServices.searchMeetings).toBe("function");
expect(typeof domainServices.getWikiPage).toBe("function");
expect(typeof domainServices.searchWikiPages).toBe("function");
expect(typeof domainServices.listWikiPageLinks).toBe("function");
});
});
@@ -673,12 +688,12 @@ describe("Live Container Integration (Domain Services)", () => {
const wps = await domainServices.listWorkPackages({ projectId: "mcp-test-project" }, client);
expect(wps.items.length).toBeGreaterThan(0);
const firstWp = await domainServices.getWorkPackage(wps.items[0].id, client);
expect(firstWp.id).toBe(wps.items[0].id);
const firstWp = await domainServices.getWorkPackage(wps.items[0]!.id, client);
expect(firstWp.id).toBe(wps.items[0]!.id);
expect(firstWp.subject).toBeDefined();
const searchRes = await domainServices.searchWorkPackages("MCP", { projectId: "mcp-test-project" }, client);
expect(searchRes.items.length).toBeGreaterThan(0);
expect(searchRes.elements.length).toBeGreaterThan(0);
});
runLiveTests("live: queries service operations", async () => {
@@ -713,5 +728,499 @@ describe("Live Container Integration (Domain Services)", () => {
const users = await domainServices.listUsers({ pageSize: 5 }, client);
expect(users.items.length).toBeGreaterThan(0);
});
runLiveTests("live: meetings service operations", async () => {
const client = new OpenProjectClient({ baseUrl: liveBaseUrl, apiKey: liveApiKey! });
// list meetings (should return meetings 2..5)
const meetings = await domainServices.listMeetings({ projectId: 1 }, client);
expect(meetings.elements.length).toBeGreaterThanOrEqual(4);
const meetingIds = meetings.elements.map((m) => m.id);
expect(meetingIds).toContain(2);
expect(meetingIds).toContain(3);
expect(meetingIds).toContain(4);
expect(meetingIds).toContain(5);
// get meeting (should return meeting with title "Weekly" and agenda items)
const meeting = await domainServices.getMeeting(2, { includeAgendaItems: true }, client);
expect(meeting.id).toBe(2);
expect(meeting.title).toBe("Weekly");
expect(meeting.agendaItems).toBeDefined();
expect(meeting.agendaItems!.length).toBeGreaterThan(0);
expect(meeting.agendaItems![0]!.title).toBeDefined();
// search meetings (should return results with matching items)
const searchRes = await domainServices.searchMeetings({ query: "Weekly" }, client);
expect(searchRes.elements.length).toBeGreaterThan(0);
const match = searchRes.elements.find((r) => r.meeting.title.includes("Weekly"));
expect(match).toBeDefined();
});
runLiveTests("live: wikis service operations", async () => {
const client = new OpenProjectClient({ baseUrl: liveBaseUrl, apiKey: liveApiKey! });
// get wiki page (should return title "Wiki", project id 1)
const wiki = await domainServices.getWikiPage(1, client);
expect(wiki.id).toBe(1);
expect(wiki.title).toBe("Wiki");
expect(wiki.project.id).toBe(1);
// search wiki pages (should discover and return wiki page 1)
const searchRes = await domainServices.searchWikiPages({ query: "Wiki" }, client);
expect(searchRes.length).toBeGreaterThan(0);
const found = searchRes.find((w) => w.id === 1);
expect(found).toBeDefined();
expect(found!.title).toBe("Wiki");
expect(found!.project.id).toBe(1);
});
runLiveTests("live: activities service operations", async () => {
const client = new OpenProjectClient({ baseUrl: liveBaseUrl, apiKey: liveApiKey! });
// list work package activities (should return activities for WP 38)
const activities = await domainServices.listWorkPackageActivities({ workPackageId: 38 }, client);
expect(activities.length).toBeGreaterThan(0);
expect(activities[0]!.id).toBeDefined();
});
});
describe("Work Package Fuzzy Search", () => {
function workPackageFixtureClient(calls: string[] = []) {
const elements = [
{
_type: "WorkPackage",
id: 1,
subject: "Fix login redirect",
_links: {
type: { title: "Bug" },
status: { title: "New" },
project: { href: "/api/v3/projects/1", title: "Demo project" },
},
updatedAt: "2026-09-10T00:00:00Z",
},
{
_type: "WorkPackage",
id: 2,
subject: "Update dependencies",
_links: {
type: { title: "Task" },
status: { title: "New" },
project: { href: "/api/v3/projects/1", title: "Demo project" },
},
updatedAt: "2026-09-11T00:00:00Z",
},
];
return {
get: async (path: string, query?: Record<string, unknown>) => {
// Record the query alongside the path so assertions can inspect the
// filter payload (the real client encodes filters into the query
// string of the resolved URL rather than the bare path).
calls.push(query ? `${path}?${JSON.stringify(query)}` : path);
if (path.startsWith("work_packages/") || path.startsWith("/api/v3/work_packages/")) {
// Extract the id following "work_packages/" specifically, rather
// than concatenating every digit in the path: the activities path
// (`/api/v3/work_packages/{id}/activities`) contains a stray digit
// in "v3" that a blanket \D+ strip would fold into the id.
const id = Number(path.match(/work_packages\/(\d+)/)?.[1] ?? NaN);
if (path.includes("activities")) {
return {
_type: "Collection",
_embedded: {
elements:
id === 2
? [
{
id: 90,
version: 1,
createdAt: "2026-09-11T00:00:00Z",
comment: { raw: "Blocked by the expired TLS certificate" },
_links: { user: { href: "/api/v3/users/4", title: "Admin" } },
},
]
: [],
},
};
}
const match = elements.find((e) => e.id === id);
return { ...match, description: { raw: id === 1 ? "Session cookie is dropped" : "" } };
}
return {
_type: "Collection",
total: elements.length,
count: elements.length,
pageSize: 100,
offset: 1,
_embedded: { elements },
};
},
} as unknown as OpenProjectClient;
}
test("finds a work package despite a typo in the subject query", async () => {
const result = await searchWorkPackages("login redirct", {}, workPackageFixtureClient());
expect(result.elements[0]!.workPackage.id).toBe(1);
});
test("finds a work package by a comment only", async () => {
const result = await searchWorkPackages("TLS certificate", {}, workPackageFixtureClient());
expect(result.elements[0]!.workPackage.id).toBe(2);
expect(result.elements[0]!.matchedFields).toContain("comments");
});
test("finds a work package by description only", async () => {
const result = await searchWorkPackages("session cookie", {}, workPackageFixtureClient());
expect(result.elements[0]!.workPackage.id).toBe(1);
expect(result.elements[0]!.matchedFields).toContain("description");
});
test("still issues the precise server-side subject filter", async () => {
const calls: string[] = [];
await searchWorkPackages("login", {}, workPackageFixtureClient(calls));
expect(calls.some((path) => path.includes("subject"))).toBe(true);
});
test("exact mode finds nothing for a typo", async () => {
const result = await searchWorkPackages(
"login redirct",
{ matchMode: "exact" },
workPackageFixtureClient()
);
expect(result.elements).toHaveLength(0);
});
test("propagates an auth failure from candidate fetch instead of reporting no results", async () => {
const client = {
get: async () => {
throw new OpenProjectAuthenticationError();
},
} as unknown as OpenProjectClient;
await expect(searchWorkPackages("anything", {}, client)).rejects.toThrow(
OpenProjectAuthenticationError
);
});
test("a non-fatal failure on one candidate leg still returns results from the other", async () => {
let calls = 0;
const client = {
get: async (path: string) => {
calls++;
// Fail the precise (subject-filtered) leg non-fatally; the broad
// leg (no subject filter) should still supply candidates.
if (calls === 1) {
throw new OpenProjectError("boom", { statusCode: 500 });
}
return {
_type: "Collection",
total: 1,
count: 1,
pageSize: 100,
offset: 1,
_embedded: {
elements: [
{
_type: "WorkPackage",
id: 1,
subject: "Fix login redirect",
_links: {
type: { title: "Bug" },
status: { title: "New" },
project: { href: "/api/v3/projects/1", title: "Demo project" },
},
updatedAt: "2026-09-10T00:00:00Z",
},
],
},
};
},
} as unknown as OpenProjectClient;
const result = await searchWorkPackages("login", {}, client);
expect(result.elements[0]!.workPackage.id).toBe(1);
});
});
/**
* These fixtures deliberately HONOUR the server-side `subject ~` filter.
* `workPackageFixtureClient` above returns every element for any collection
* request, which is why the exact-mode candidate starvation bug was invisible
* to the suite: a subject-filtered leg that returns the whole corpus looks
* identical to a broad leg.
*/
describe("Work Package Search Candidate Union", () => {
interface FixtureElement {
_type: string;
id: number;
subject: string;
_links: Record<string, unknown>;
updatedAt: string;
}
function makeElement(id: number, subject: string, updatedAt: string): FixtureElement {
return {
_type: "WorkPackage",
id,
subject,
_links: {
type: { title: "Bug" },
status: { title: "New" },
project: { href: "/api/v3/projects/1", title: "Demo project" },
},
updatedAt,
};
}
/** Reads the `subject ~` value out of the serialized filter payload. */
function subjectFilterOf(query?: Record<string, unknown>): string | undefined {
const raw = query?.filters;
if (typeof raw !== "string") return undefined;
const filters = JSON.parse(raw) as Array<Record<string, { values?: string[] }>>;
for (const filter of filters) {
const subject = filter.subject;
if (subject) return subject.values?.[0];
}
return undefined;
}
/**
* A fixture client that applies the subject filter server-side, the way a
* real OpenProject instance does.
*/
function subjectAwareClient(
elements: FixtureElement[],
deep: Record<number, { description?: string; comment?: string }>,
calls: Array<{ path: string; query?: Record<string, unknown> }> = []
) {
return {
get: async (path: string, query?: Record<string, unknown>) => {
calls.push({ path, query });
if (path.startsWith("work_packages/") || path.startsWith("/api/v3/work_packages/")) {
const id = Number(path.match(/work_packages\/(\d+)/)?.[1] ?? NaN);
if (path.includes("activities")) {
const comment = deep[id]?.comment;
return {
_type: "Collection",
_embedded: {
elements: comment
? [
{
id: id * 10,
version: 1,
createdAt: "2026-09-11T00:00:00Z",
comment: { raw: comment },
_links: { user: { href: "/api/v3/users/4", title: "Admin" } },
},
]
: [],
},
};
}
const match = elements.find((e) => e.id === id);
return { ...match, description: { raw: deep[id]?.description ?? "" } };
}
const subject = subjectFilterOf(query);
const matched =
subject === undefined
? elements
: elements.filter((e) =>
e.subject.toLowerCase().includes(subject.toLowerCase())
);
return {
_type: "Collection",
total: matched.length,
count: matched.length,
pageSize: 100,
offset: 1,
_embedded: { elements: matched },
};
},
} as unknown as OpenProjectClient;
}
const elements = [
makeElement(1, "Fix login redirect", "2026-09-10T00:00:00Z"),
makeElement(2, "Update dependencies", "2026-09-11T00:00:00Z"),
];
const deep = {
1: { description: "Session cookie is dropped" },
2: { comment: "Blocked by error ERR_TLS_EXPIRED on deploy" },
};
test("the subject-aware fixture really does filter (guards the tests below)", async () => {
const result = await searchWorkPackages(
"Update dependencies",
{ matchMode: "exact" },
subjectAwareClient(elements, deep)
);
expect(result.elements.map((e) => e.workPackage.id)).toEqual([2]);
});
test("exact mode finds a comment-only match the subject filter cannot return", async () => {
const result = await searchWorkPackages(
"ERR_TLS_EXPIRED",
{ matchMode: "exact" },
subjectAwareClient(elements, deep)
);
expect(result.elements).toHaveLength(1);
expect(result.elements[0]!.workPackage.id).toBe(2);
expect(result.elements[0]!.matchedFields).toContain("comments");
});
test("exact mode finds a description-only match the subject filter cannot return", async () => {
const result = await searchWorkPackages(
"Session cookie",
{ matchMode: "exact" },
subjectAwareClient(elements, deep)
);
expect(result.elements).toHaveLength(1);
expect(result.elements[0]!.workPackage.id).toBe(1);
expect(result.elements[0]!.matchedFields).toContain("description");
});
test("exact mode still issues both the precise and the broad candidate leg", async () => {
const calls: Array<{ path: string; query?: Record<string, unknown> }> = [];
await searchWorkPackages(
"login",
{ matchMode: "exact" },
subjectAwareClient(elements, deep, calls)
);
const collectionCalls = calls.filter((c) => c.path === "work_packages");
expect(collectionCalls.some((c) => subjectFilterOf(c.query) === "login")).toBe(true);
expect(collectionCalls.some((c) => subjectFilterOf(c.query) === undefined)).toBe(true);
});
test("never requests a page size above the documented 100 maximum", async () => {
const calls: Array<{ path: string; query?: Record<string, unknown> }> = [];
await searchWorkPackages("login", {}, subjectAwareClient(elements, deep, calls));
const pageSizes = calls
.filter((c) => c.path === "work_packages")
.map((c) => Number(c.query?.pageSize));
expect(pageSizes.length).toBeGreaterThan(0);
for (const pageSize of pageSizes) {
expect(pageSize).toBeLessThanOrEqual(100);
}
});
test("pages the candidate window until the server-reported total is collected", async () => {
const requestedOffsets: number[] = [];
// 150 candidates behind a server that hands back 100 per page: a single
// pageSize=250 request would have been capped at 100 by a real instance.
const corpus = Array.from({ length: 150 }, (_, i) =>
makeElement(i + 1, `Candidate ${i + 1}`, "2026-09-10T00:00:00Z")
);
const client = {
get: async (path: string, query?: Record<string, unknown>) => {
if (path.startsWith("work_packages/") || path.startsWith("/api/v3/work_packages/")) {
const id = Number(path.match(/work_packages\/(\d+)/)?.[1] ?? NaN);
if (path.includes("activities")) {
return { _type: "Collection", _embedded: { elements: [] } };
}
return { ...corpus.find((e) => e.id === id), description: { raw: "" } };
}
const pageSize = Number(query?.pageSize ?? 20);
expect(pageSize).toBeLessThanOrEqual(100);
const offset = Number(query?.offset ?? 1);
if (subjectFilterOf(query) === undefined) {
requestedOffsets.push(offset);
}
const start = (offset - 1) * pageSize;
const page = corpus.slice(start, start + pageSize);
return {
_type: "Collection",
total: corpus.length,
count: page.length,
pageSize,
offset,
_embedded: { elements: page },
};
},
} as unknown as OpenProjectClient;
const result = await searchWorkPackages("Candidate 137", {}, client);
expect(requestedOffsets).toEqual([1, 2]);
// Candidate 137 lives on the second page; without paging it is unreachable.
expect(result.elements[0]!.workPackage.id).toBe(137);
});
});
describe("Work Package Search Response Shape", () => {
function pagedFixtureClient(count: number) {
const elements = Array.from({ length: count }, (_, i) => ({
_type: "WorkPackage",
id: i + 1,
subject: `Release checklist item ${i + 1}`,
_links: {
type: { title: "Task" },
status: { title: "New" },
project: { href: "/api/v3/projects/1", title: "Demo project" },
},
updatedAt: "2026-09-10T00:00:00Z",
}));
return {
get: async (path: string) => {
if (path.startsWith("work_packages/") || path.startsWith("/api/v3/work_packages/")) {
const id = Number(path.match(/work_packages\/(\d+)/)?.[1] ?? NaN);
if (path.includes("activities")) {
return { _type: "Collection", _embedded: { elements: [] } };
}
return { ...elements.find((e) => e.id === id), description: { raw: "" } };
}
return {
_type: "Collection",
total: elements.length,
count: elements.length,
pageSize: 100,
offset: 1,
_embedded: { elements },
};
},
} as unknown as OpenProjectClient;
}
test("offset is a page number: page 2 does not overlap page 1", async () => {
const client = pagedFixtureClient(12);
const page1 = await searchWorkPackages(
"Release checklist item",
{ offset: 1, pageSize: 5 },
client
);
const page2 = await searchWorkPackages(
"Release checklist item",
{ offset: 2, pageSize: 5 },
client
);
const whole = await searchWorkPackages(
"Release checklist item",
{ offset: 1, pageSize: 100 },
client
);
const allIds = whole.elements.map((e) => e.workPackage.id);
const ids1 = page1.elements.map((e) => e.workPackage.id);
const ids2 = page2.elements.map((e) => e.workPackage.id);
expect(allIds).toHaveLength(12);
expect(ids1).toEqual(allIds.slice(0, 5));
// The bug returned allIds.slice(1, 6) here — four of five items repeated.
expect(ids2).toEqual(allIds.slice(5, 10));
expect(ids1.filter((id) => ids2.includes(id))).toEqual([]);
});
test("does not ship the result array twice", async () => {
const result = await searchWorkPackages(
"Release checklist item",
{},
pagedFixtureClient(3)
);
expect(result.elements).toHaveLength(3);
expect(Object.keys(result)).not.toContain("items");
expect(JSON.parse(JSON.stringify(result))).not.toHaveProperty("items");
});
});
+1483 -3
View File
File diff suppressed because it is too large Load Diff
+937
View File
@@ -0,0 +1,937 @@
import { describe, expect, test, beforeEach } from "bun:test";
import {
OpenProjectAuthenticationError,
OpenProjectClient,
OpenProjectError,
} from "../src/client/api-client.ts";
import { runWithContext, type RequestContext } from "../src/context.ts";
import {
clearWikiCache,
getWikiPage,
listWikiPageLinks,
searchWikiPages,
} from "../src/services/wikis.ts";
describe("Wikis Service", () => {
beforeEach(() => {
clearWikiCache();
});
test("getWikiPage retrieves page and attaches files", async () => {
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path === "/api/v3/wiki_pages/1") {
return {
_type: "WikiPage",
id: 1,
title: "Project Wiki",
_links: {
project: { href: "/api/v3/projects/1", title: "Demo Project" },
},
};
}
if (path === "/api/v3/wiki_pages/1/attachments") {
return {
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
_type: "Attachment",
id: 12,
fileName: "architecture.png",
fileSize: 45000,
contentType: "image/png",
_links: {
downloadLocation: { href: "/api/v3/attachments/12/content" },
},
},
],
},
};
}
throw new Error(`Unexpected path: ${path}`);
},
} as unknown as OpenProjectClient;
const page = await getWikiPage(1, mockClient);
expect(page.id).toBe(1);
expect(page.title).toBe("Project Wiki");
expect(page.project.id).toBe(1);
expect(page.attachments).toHaveLength(1);
expect(page.attachments[0]!.fileName).toBe("architecture.png");
expect(page.attachments[0]!.fileSize).toBe(45000);
expect(page.attachments[0]!.contentType).toBe("image/png");
expect(page.attachments[0]!.downloadUrl).toBe("/api/v3/attachments/12/content");
});
test("getWikiPage handles missing attachments collection gracefully", async () => {
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path === "/api/v3/wiki_pages/2") {
return {
_type: "WikiPage",
id: 2,
title: "Quick Notes",
_links: {
project: { href: "/api/v3/projects/3", title: "Core App" },
},
};
}
if (path === "/api/v3/wiki_pages/2/attachments") {
const err = new Error("Attachments not found");
(err as unknown as { statusCode: number }).statusCode = 404;
throw err;
}
throw new Error(`Unexpected path: ${path}`);
},
} as unknown as OpenProjectClient;
const page = await getWikiPage(2, mockClient);
expect(page.id).toBe(2);
expect(page.title).toBe("Quick Notes");
expect(page.project.id).toBe(3);
expect(page.attachments).toEqual([]);
});
test("listWikiPageLinks retrieves global wiki page links when workPackageId is omitted", async () => {
let requestedPath = "";
let requestedQuery: Record<string, unknown> | undefined;
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string, query?: Record<string, unknown>) => {
requestedPath = path;
requestedQuery = query;
return {
_type: "Collection",
total: 1,
count: 1,
pageSize: 20,
offset: 1,
_embedded: {
elements: [
{
_type: "WikiPageLink",
id: 10,
pageTitle: "Design Guidelines",
pageUrl: "http://localhost:8080/projects/demo/wiki/design",
provider: "openproject",
_links: {
workPackage: { href: "/api/v3/work_packages/42", title: "Refactor UI" },
wikiPage: { href: "/api/v3/wiki_pages/5", title: "Design Guidelines" },
},
},
],
},
};
},
} as unknown as OpenProjectClient;
const result = await listWikiPageLinks({ offset: 1, pageSize: 20 }, mockClient);
expect(requestedPath).toBe("/api/v3/wiki_page_links");
expect(requestedQuery?.offset).toBe(1);
expect(requestedQuery?.pageSize).toBe(20);
expect(result.total).toBe(1);
expect(result.elements).toHaveLength(1);
expect(result.elements[0]!.id).toBe(10);
expect(result.elements[0]!.pageTitle).toBe("Design Guidelines");
expect(result.elements[0]!.pageUrl).toBe("http://localhost:8080/projects/demo/wiki/design");
expect(result.elements[0]!.workPackage?.id).toBe(42);
expect(result.elements[0]!.workPackage?.title).toBe("Refactor UI");
expect(result.items).toHaveLength(1);
});
test("listWikiPageLinks queries work package specific endpoint when workPackageId is provided", async () => {
let requestedPath = "";
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
requestedPath = path;
return {
_type: "Collection",
total: 0,
count: 0,
pageSize: 10,
offset: 1,
_embedded: {
elements: [],
},
};
},
} as unknown as OpenProjectClient;
const result = await listWikiPageLinks({ workPackageId: 99 }, mockClient);
expect(requestedPath).toBe("/api/v3/work_packages/99/wiki_page_links");
expect(result.total).toBe(0);
expect(result.elements).toEqual([]);
});
test("searchWikiPages matches discovered pages by title substring", async () => {
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path.startsWith("/api/v3/wiki_page_links")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path === "/api/v3/wiki_pages/1") {
return {
_type: "WikiPage",
id: 1,
title: "System Architecture",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
};
}
if (path === "/api/v3/wiki_pages/1/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path === "/api/v3/wiki_pages/2") {
return {
_type: "WikiPage",
id: 2,
title: "Sprint Retrospective",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
};
}
if (path === "/api/v3/wiki_pages/2/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
const err = new Error("Not found");
(err as unknown as { statusCode: number }).statusCode = 404;
throw err;
},
} as unknown as OpenProjectClient;
const results = await searchWikiPages({ query: "architecture" }, mockClient);
expect(results).toHaveLength(1);
expect(results[0]!.title).toBe("System Architecture");
expect(results[0]!.id).toBe(1);
expect(results[0]!.project.id).toBe(1);
expect(results[0]!.attachmentsCount).toBe(0);
});
test("searchWikiPages filters by numeric projectId and string identifier", async () => {
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path.startsWith("/api/v3/wiki_page_links")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path === "/api/v3/wiki_pages/1") {
return {
_type: "WikiPage",
id: 1,
title: "Project One Architecture",
_links: { project: { href: "/api/v3/projects/1", title: "Project 1" } },
};
}
if (path === "/api/v3/wiki_pages/1/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path === "/api/v3/wiki_pages/2") {
return {
_type: "WikiPage",
id: 2,
title: "Project Two Architecture",
_links: { project: { href: "/api/v3/projects/2", title: "Project 2" } },
};
}
if (path === "/api/v3/wiki_pages/2/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path === "projects/proj-two" || path === "/api/v3/projects/proj-two") {
return { id: 2 };
}
const err = new Error("Not found");
(err as unknown as { statusCode: number }).statusCode = 404;
throw err;
},
} as unknown as OpenProjectClient;
// Filter by numeric projectId = 1
const resultsProject1 = await searchWikiPages({ projectId: 1 }, mockClient);
expect(resultsProject1).toHaveLength(1);
expect(resultsProject1[0]!.id).toBe(1);
// Filter by string identifier "proj-two" (resolves to ID 2)
const resultsProject2 = await searchWikiPages({ projectId: "proj-two" }, mockClient);
expect(resultsProject2).toHaveLength(1);
expect(resultsProject2[0]!.id).toBe(2);
});
test("searchWikiPages respects limit parameter", async () => {
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path.startsWith("/api/v3/wiki_page_links")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path === "/api/v3/wiki_pages/1") {
return {
_type: "WikiPage",
id: 1,
title: "Docs Page 1",
_links: { project: { href: "/api/v3/projects/1", title: "Project 1" } },
};
}
if (path === "/api/v3/wiki_pages/1/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path === "/api/v3/wiki_pages/2") {
return {
_type: "WikiPage",
id: 2,
title: "Docs Page 2",
_links: { project: { href: "/api/v3/projects/1", title: "Project 1" } },
};
}
if (path === "/api/v3/wiki_pages/2/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
const err = new Error("Not found");
(err as unknown as { statusCode: number }).statusCode = 404;
throw err;
},
} as unknown as OpenProjectClient;
const results = await searchWikiPages({ query: "Docs", limit: 1 }, mockClient);
expect(results).toHaveLength(1);
expect(results[0]!.id).toBe(1);
});
test("searchWikiPages caches discovered pages and serves subsequent searches without probing", async () => {
let probeCount = 0;
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path.startsWith("/api/v3/wiki_page_links")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path === "/api/v3/wiki_pages/1") {
probeCount++;
return {
_type: "WikiPage",
id: 1,
title: "API Architecture Guide",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
};
}
if (path === "/api/v3/wiki_pages/1/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
const err = new Error("Not found");
(err as unknown as { statusCode: number }).statusCode = 404;
throw err;
},
} as unknown as OpenProjectClient;
// First search: performs discovery
const firstResults = await searchWikiPages({ query: "Architecture" }, mockClient);
expect(firstResults).toHaveLength(1);
expect(probeCount).toBe(1);
// Second search: served from in-memory cache without hitting /api/v3/wiki_pages/1 again
const secondResults = await searchWikiPages({ query: "Guide" }, mockClient);
expect(secondResults).toHaveLength(1);
expect(secondResults[0]!.id).toBe(1);
expect(probeCount).toBe(1);
// Third search with refreshCache: true forces re-discovery
const thirdResults = await searchWikiPages(
{ query: "Guide", refreshCache: true },
mockClient
);
expect(thirdResults).toHaveLength(1);
expect(probeCount).toBe(2);
});
test("resolves ambient RequestContext client when client parameter is omitted", async () => {
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path === "/api/v3/wiki_pages/1") {
return {
_type: "WikiPage",
id: 1,
title: "Context Wiki",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
};
}
if (path === "/api/v3/wiki_pages/1/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path.startsWith("/api/v3/wiki_page_links")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
const err = new Error("Not found");
(err as unknown as { statusCode: number }).statusCode = 404;
throw err;
},
} as unknown as OpenProjectClient;
const context: RequestContext = {
client: mockClient,
isReadOnly: false,
};
await runWithContext(context, async () => {
const page = await getWikiPage(1);
expect(page.title).toBe("Context Wiki");
const searchRes = await searchWikiPages({ query: "Context" });
expect(searchRes).toHaveLength(1);
expect(searchRes[0]!.title).toBe("Context Wiki");
});
});
test("searchWikiPages re-throws 401 authentication errors during discovery instead of swallowing", async () => {
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path.startsWith("/api/v3/wiki_page_links")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
const err = new OpenProjectAuthenticationError("Invalid API key provided");
throw err;
},
} as unknown as OpenProjectClient;
expect(searchWikiPages({}, mockClient)).rejects.toThrow("Invalid API key provided");
});
test("searchWikiPages re-throws 403 and 429 errors during discovery probe", async () => {
const mockForbiddenClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path.startsWith("/api/v3/wiki_page_links")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
const err = new Error("Access denied");
(err as unknown as { statusCode: number }).statusCode = 403;
throw err;
},
} as unknown as OpenProjectClient;
expect(searchWikiPages({}, mockForbiddenClient)).rejects.toThrow("Access denied");
const mockRateLimitClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path.startsWith("/api/v3/wiki_page_links")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
const err = new Error("Too many requests");
(err as unknown as { statusCode: number }).statusCode = 429;
throw err;
},
} as unknown as OpenProjectClient;
expect(searchWikiPages({}, mockRateLimitClient)).rejects.toThrow("Too many requests");
});
test("searchWikiPages with refreshCache evicts stale deleted pages from cache", async () => {
let page2Exists = true;
const mockClient = {
baseUrl: "http://localhost:8080",
get: async (path: string) => {
if (path.startsWith("/api/v3/wiki_page_links")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path === "/api/v3/wiki_pages/1") {
return {
_type: "WikiPage",
id: 1,
title: "First Page",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
};
}
if (path === "/api/v3/wiki_pages/1/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (path === "/api/v3/wiki_pages/2" && page2Exists) {
return {
_type: "WikiPage",
id: 2,
title: "Second Page",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
};
}
if (path === "/api/v3/wiki_pages/2/attachments" && page2Exists) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
const err = new Error("Not found");
(err as unknown as { statusCode: number }).statusCode = 404;
throw err;
},
} as unknown as OpenProjectClient;
const initialResults = await searchWikiPages({}, mockClient);
expect(initialResults).toHaveLength(2);
// Page 2 is subsequently deleted on OpenProject
page2Exists = false;
// Refreshing cache should clear stale page 2 from memory
const refreshedResults = await searchWikiPages({ refreshCache: true }, mockClient);
expect(refreshedResults).toHaveLength(1);
expect(refreshedResults[0]!.id).toBe(1);
});
test("maintains isolated wiki page cache across different clients with same baseUrl but different API keys", async () => {
let clientACalls = 0;
let clientBCalls = 0;
const mockFetchA = async (input: string | URL | Request) => {
clientACalls++;
const url = String(input);
if (url.includes("/api/v3/wiki_pages/1/attachments")) {
return new Response(
JSON.stringify({ _type: "Collection", total: 0, _embedded: { elements: [] } }),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
return new Response(
JSON.stringify({
_type: "WikiPage",
id: 1,
title: "User A Confidential Wiki",
_links: { project: { href: "/api/v3/projects/1", title: "Project A" } },
}),
{ headers: { "Content-Type": "application/hal+json" } }
);
};
const mockFetchB = async (input: string | URL | Request) => {
clientBCalls++;
const url = String(input);
if (url.includes("/api/v3/wiki_pages/1/attachments")) {
return new Response(
JSON.stringify({ _type: "Collection", total: 0, _embedded: { elements: [] } }),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
return new Response(
JSON.stringify({
_type: "WikiPage",
id: 1,
title: "User B Confidential Wiki",
_links: { project: { href: "/api/v3/projects/2", title: "Project B" } },
}),
{ headers: { "Content-Type": "application/hal+json" } }
);
};
const clientA = new OpenProjectClient({
baseUrl: "https://openproject.example.com",
apiKey: "api-key-tenant-a",
fetchFn: mockFetchA,
});
const clientB = new OpenProjectClient({
baseUrl: "https://openproject.example.com",
apiKey: "api-key-tenant-b",
fetchFn: mockFetchB,
});
// Client A fetches wiki page 1 -> cached in client A's bucket
const pageA = await getWikiPage(1, clientA);
expect(pageA.title).toBe("User A Confidential Wiki");
expect(clientACalls).toBe(2);
// Client B fetches wiki page 1 -> must NOT use client A's cache
const pageB = await getWikiPage(1, clientB);
expect(pageB.title).toBe("User B Confidential Wiki");
expect(clientBCalls).toBe(2);
// Repeat fetch with client A -> returns User A data without refetching from server
const pageA2 = await getWikiPage(1, clientA);
expect(pageA2.title).toBe("User A Confidential Wiki");
});
test("maintains isolated wiki search discovery cache across different API keys", async () => {
const mockFetchA = async (input: string | URL | Request) => {
const url = String(input);
if (url.includes("/api/v3/wiki_page_links")) {
return new Response(
JSON.stringify({
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
id: 101,
_links: { wikiPage: { href: "/api/v3/wiki_pages/10" } },
},
],
},
}),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
if (url.includes("/api/v3/wiki_pages/10/attachments")) {
return new Response(
JSON.stringify({ _type: "Collection", total: 0, _embedded: { elements: [] } }),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
if (url.includes("/api/v3/wiki_pages/10")) {
return new Response(
JSON.stringify({
_type: "WikiPage",
id: 10,
title: "Tenant A Page",
_links: { project: { href: "/api/v3/projects/1", title: "Project A" } },
}),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
return new Response(JSON.stringify({ message: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
};
const mockFetchB = async (input: string | URL | Request) => {
const url = String(input);
if (url.includes("/api/v3/wiki_page_links")) {
return new Response(
JSON.stringify({
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
id: 201,
_links: { wikiPage: { href: "/api/v3/wiki_pages/20" } },
},
],
},
}),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
if (url.includes("/api/v3/wiki_pages/20/attachments")) {
return new Response(
JSON.stringify({ _type: "Collection", total: 0, _embedded: { elements: [] } }),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
if (url.includes("/api/v3/wiki_pages/20")) {
return new Response(
JSON.stringify({
_type: "WikiPage",
id: 20,
title: "Tenant B Page",
_links: { project: { href: "/api/v3/projects/2", title: "Project B" } },
}),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
return new Response(JSON.stringify({ message: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
};
const clientA = new OpenProjectClient({
baseUrl: "https://openproject.example.com",
apiKey: "api-key-tenant-a",
fetchFn: mockFetchA,
});
const clientB = new OpenProjectClient({
baseUrl: "https://openproject.example.com",
apiKey: "api-key-tenant-b",
fetchFn: mockFetchB,
});
const searchResultsA = await searchWikiPages({}, clientA);
expect(searchResultsA).toHaveLength(1);
expect(searchResultsA[0]!.title).toBe("Tenant A Page");
const searchResultsB = await searchWikiPages({}, clientB);
expect(searchResultsB).toHaveLength(1);
expect(searchResultsB[0]!.title).toBe("Tenant B Page");
});
});
describe("Wiki Fuzzy Search", () => {
function wikiFixtureClient() {
const pages: Record<string, unknown> = {
"/api/v3/wiki_pages/1": {
_type: "WikiPage",
id: 1,
title: "Onboarding Guide",
text: { raw: "New engineers should request VPN access on their first day." },
_links: { project: { href: "/api/v3/projects/1", title: "Demo project" } },
},
"/api/v3/wiki_pages/2": {
_type: "WikiPage",
id: 2,
title: "Deployment Runbook",
text: { raw: "Roll back with the previous image tag if smoke tests fail." },
_links: { project: { href: "/api/v3/projects/1", title: "Demo project" } },
},
};
return {
getCacheKey: () => `test-${Math.random()}`,
baseUrl: "http://localhost",
get: async (path: string) => {
if (path.startsWith("/api/v3/wiki_page_links")) {
return { _type: "Collection", _embedded: { elements: [] } };
}
if (path.endsWith("/attachments")) {
return { _type: "Collection", _embedded: { elements: [] } };
}
if (pages[path]) {
return pages[path];
}
throw new OpenProjectError("not found", { statusCode: 404 });
},
} as unknown as OpenProjectClient;
}
test("retains page body text when normalizing", async () => {
const page = await getWikiPage(1, wikiFixtureClient());
expect(page.text).toContain("VPN access");
});
test("finds a page by body text, not just title", async () => {
const results = await searchWikiPages({ query: "vpn access" }, wikiFixtureClient());
expect(results[0]!.id).toBe(1);
expect(results[0]!.matchedFields).toContain("text");
});
test("ranks a title match above a body match", async () => {
const results = await searchWikiPages({ query: "deployment" }, wikiFixtureClient());
expect(results[0]!.id).toBe(2);
});
test("tolerates a typo in the query", async () => {
const results = await searchWikiPages({ query: "onboardng" }, wikiFixtureClient());
expect(results[0]!.id).toBe(1);
});
test("returns a snippet centred on the body match", async () => {
const results = await searchWikiPages({ query: "smoke tests" }, wikiFixtureClient());
expect(results[0]!.snippet).toContain("smoke tests");
});
test("exact mode finds nothing for a typo", async () => {
const results = await searchWikiPages(
{ query: "onboardng", matchMode: "exact" },
wikiFixtureClient()
);
expect(results).toHaveLength(0);
});
});
describe("Wiki Error Propagation", () => {
beforeEach(() => {
clearWikiCache();
});
function pageWithFailingAttachments(error: unknown) {
return {
baseUrl: "http://localhost:8080",
getCacheKey: () => "attachments-test",
get: async (path: string) => {
if (path.endsWith("/attachments")) {
throw error;
}
if (path === "/api/v3/wiki_pages/1") {
return {
_type: "WikiPage",
id: 1,
title: "Project Wiki",
_links: { project: { href: "/api/v3/projects/1", title: "Demo Project" } },
};
}
throw new OpenProjectError("not found", { statusCode: 404 });
},
} as unknown as OpenProjectClient;
}
test("getWikiPage propagates a rate limit from the attachments fetch", async () => {
await expect(
getWikiPage(
1,
pageWithFailingAttachments(new OpenProjectError("rate limited", { statusCode: 429 }))
)
).rejects.toThrow("rate limited");
});
test("getWikiPage propagates an authentication failure from the attachments fetch", async () => {
await expect(
getWikiPage(1, pageWithFailingAttachments(new OpenProjectAuthenticationError()))
).rejects.toThrow(OpenProjectAuthenticationError);
});
test("getWikiPage still degrades to an empty attachment list on a non-fatal failure", async () => {
const page = await getWikiPage(
1,
pageWithFailingAttachments(new OpenProjectError("boom", { statusCode: 500 }))
);
expect(page.id).toBe(1);
expect(page.attachments).toEqual([]);
});
test("a discovery scan that errors through every probe is not marked complete", async () => {
// Phase 1: every wiki page probe fails with a 500. Nothing is cached, and
// the 404 cutoff never trips, so discovery did not actually complete.
let failEverything = true;
const client = {
baseUrl: "http://localhost:8080",
getCacheKey: () => "flaky-tenant",
get: async (path: string) => {
if (path === "/api/v3/wiki_page_links") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
if (failEverything) {
throw new OpenProjectError("upstream exploded", { statusCode: 500 });
}
if (path === "/api/v3/wiki_pages/1") {
return {
_type: "WikiPage",
id: 1,
title: "Deployment Runbook",
text: { raw: "How to deploy the service" },
_links: { project: { href: "/api/v3/projects/1", title: "Demo Project" } },
};
}
if (path === "/api/v3/wiki_pages/1/attachments") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
throw new OpenProjectError("not found", { statusCode: 404 });
},
} as unknown as OpenProjectClient;
const first = await searchWikiPages({ query: "deployment" }, client);
expect(first).toEqual([]);
// Phase 2: the instance recovers. Discovery must run again rather than
// short-circuiting on a cache that was wrongly marked complete and
// returning [] for the life of the process.
failEverything = false;
const second = await searchWikiPages({ query: "deployment" }, client);
expect(second.map((page) => page.id)).toEqual([1]);
});
test("a genuinely empty wiki is marked complete and is not re-probed", async () => {
let probes = 0;
const client = {
baseUrl: "http://localhost:8080",
getCacheKey: () => "empty-tenant",
get: async (path: string) => {
if (path === "/api/v3/wiki_page_links") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
probes++;
throw new OpenProjectError("not found", { statusCode: 404 });
},
} as unknown as OpenProjectClient;
expect(await searchWikiPages({ query: "anything" }, client)).toEqual([]);
const afterFirst = probes;
// The 404 cutoff is a real completion signal: five consecutive misses.
expect(afterFirst).toBe(5);
expect(await searchWikiPages({ query: "anything" }, client)).toEqual([]);
expect(probes).toBe(afterFirst);
});
});
describe("Wiki Discovery Completion With A Primed Cache", () => {
beforeEach(() => {
clearWikiCache();
});
const CACHE_KEY = "primed-tenant";
const pages: Record<number, { title: string; text: string }> = {
1: { title: "Onboarding", text: "How to get started" },
2: { title: "Deployment Runbook", text: "How to deploy the service" },
};
/** Serves the fixture wiki. Shares a cache key with the failing client. */
function healthyClient(probedIds: number[] = []) {
return {
baseUrl: "http://localhost:8080",
getCacheKey: () => CACHE_KEY,
get: async (path: string) => {
if (path === "/api/v3/wiki_page_links") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
const id = Number(path.match(/wiki_pages\/(\d+)/)?.[1] ?? NaN);
if (path.endsWith("/attachments")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
probedIds.push(id);
const page = pages[id];
if (!page) {
throw new OpenProjectError("not found", { statusCode: 404 });
}
return {
_type: "WikiPage",
id,
title: page.title,
text: { raw: page.text },
_links: { project: { href: "/api/v3/projects/1", title: "Demo Project" } },
};
},
} as unknown as OpenProjectClient;
}
/** Same tenant, but every wiki page probe fails non-fatally. */
function outageClient(probedIds: number[] = []) {
return {
baseUrl: "http://localhost:8080",
getCacheKey: () => CACHE_KEY,
get: async (path: string) => {
if (path === "/api/v3/wiki_page_links") {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
const id = Number(path.match(/wiki_pages\/(\d+)/)?.[1] ?? NaN);
probedIds.push(id);
throw new OpenProjectError("upstream exploded", { statusCode: 500 });
},
} as unknown as OpenProjectClient;
}
/**
* `getWikiPage` writes into the same tenant-wide cache map that discovery
* populates, so "get a wiki page, then search" — an entirely ordinary LLM
* tool sequence — leaves the map non-empty before the probe loop has issued
* a single request. Judging completion on absolute cache size therefore
* lets an all-errors scan inherit that entry and mark itself complete, and
* wiki search then returns [] for the life of the process.
*/
test("a primed cache does not let an all-errors scan mark discovery complete", async () => {
// 1. Prime the tenant cache with one successful single-page fetch.
const primed = await getWikiPage(1, healthyClient());
expect(primed.title).toBe("Onboarding");
// 2. A discovery scan in which every probe fails non-fatally. It caches
// nothing new and the 404 cutoff never trips, so discovery did NOT
// complete — even though the map is non-empty thanks to step 1.
const outageProbes: number[] = [];
const duringOutage = await searchWikiPages({ query: "deployment" }, outageClient(outageProbes));
expect(outageProbes.length).toBeGreaterThan(0);
expect(duringOutage).toEqual([]);
// 3. The instance recovers. Discovery must run again rather than
// short-circuiting on a cache that was wrongly marked complete.
const recoveryProbes: number[] = [];
const afterRecovery = await searchWikiPages(
{ query: "deployment" },
healthyClient(recoveryProbes)
);
expect(recoveryProbes.length).toBeGreaterThan(0);
expect(afterRecovery.map((page) => page.id)).toEqual([2]);
});
});
+262
View File
@@ -0,0 +1,262 @@
import { describe, expect, test } from "bun:test";
import { OpenProjectClient } from "../src/client/api-client.ts";
import { runWithContext, type RequestContext } from "../src/context.ts";
import { listWorkPackageActivities } from "../src/services/work-packages.ts";
import {
listWorkPackageActivitiesShape,
listWorkPackageActivitiesTool,
} from "../src/tools/work-packages.ts";
describe("Work Package Activities Service", () => {
test("parses activities and separates comments from field changes", async () => {
const mockClient = {
get: async (path: string) => {
expect(path).toBe("/api/v3/work_packages/38/activities");
return {
_type: "Collection",
total: 2,
count: 2,
_embedded: {
elements: [
{
_type: "Activity",
id: 60,
version: 1,
createdAt: "2026-09-05T14:24:56Z",
comment: { raw: "", html: "" },
details: [
{ format: "custom", raw: "Status set to In progress" }
],
_links: {
user: { href: "/api/v3/users/2", title: "Admin User" }
}
},
{
_type: "Activity",
id: 61,
version: 2,
createdAt: "2026-09-05T15:00:00Z",
comment: { raw: "Fixed in commit abc1234", html: "<p>Fixed</p>" },
details: [],
_links: {
user: { href: "/api/v3/users/3", title: "Dev User" }
}
}
]
}
};
}
} as unknown as OpenProjectClient;
const all = await listWorkPackageActivities({ workPackageId: 38 }, mockClient);
expect(all).toHaveLength(2);
expect(all[0]!.isComment).toBe(false);
expect(all[1]!.isComment).toBe(true);
expect(all[1]!.comment).toBe("Fixed in commit abc1234");
expect(all[1]!.user?.name).toBe("Dev User");
const commentsOnly = await listWorkPackageActivities({ workPackageId: 38, onlyComments: true }, mockClient);
expect(commentsOnly).toHaveLength(1);
expect(commentsOnly[0]!.id).toBe(61);
});
test("handles empty activities collection and fallback user formatting", async () => {
const mockClient = {
get: async () => ({
_type: "Collection",
total: 1,
count: 1,
_embedded: {
elements: [
{
id: 99,
version: 1,
createdAt: "2026-09-05T16:00:00Z",
_links: {
user: { href: "/api/v3/users/42" }
}
}
]
}
})
} as unknown as OpenProjectClient;
const res = await listWorkPackageActivities({ workPackageId: 10 }, mockClient);
expect(res).toHaveLength(1);
expect(res[0]!.id).toBe(99);
expect(res[0]!.user).toEqual({ id: 42, name: "User #42" });
expect(res[0]!.comment).toBeUndefined();
expect(res[0]!.isComment).toBe(false);
expect(res[0]!.details).toEqual([]);
});
test("handles missing _embedded or empty elements gracefully", async () => {
const mockClient = {
get: async () => ({
_type: "Collection",
total: 0,
count: 0
})
} as unknown as OpenProjectClient;
const res = await listWorkPackageActivities({ workPackageId: 10 }, mockClient);
expect(res).toEqual([]);
});
test("resolves ambient RequestContext client when client parameter is omitted", async () => {
let calledPath = "";
const ambientClient = {
get: async (path: string) => {
calledPath = path;
return {
_type: "Collection",
total: 1,
count: 1,
_embedded: {
elements: [
{
id: 101,
version: 1,
createdAt: "2026-09-07T00:00:00Z",
comment: { raw: "Ambient resolution test comment" },
details: [],
},
],
},
};
},
} as unknown as OpenProjectClient;
const context: RequestContext = {
client: ambientClient,
isReadOnly: false,
};
await runWithContext(context, async () => {
const res = await listWorkPackageActivities({ workPackageId: 77 });
expect(calledPath).toBe("/api/v3/work_packages/77/activities");
expect(res).toHaveLength(1);
expect(res[0]!.id).toBe(101);
expect(res[0]!.comment).toBe("Ambient resolution test comment");
expect(res[0]!.isComment).toBe(true);
});
});
});
describe("Activity Ranking", () => {
function activitiesClient() {
return {
get: async () => ({
_type: "Collection",
_embedded: {
elements: [
{
id: 1,
version: 1,
createdAt: "2026-09-01T00:00:00Z",
comment: { raw: "Deployment blocked by the expired TLS certificate" },
_links: { user: { href: "/api/v3/users/4", title: "Admin" } },
},
{
id: 2,
version: 2,
createdAt: "2026-09-02T00:00:00Z",
comment: { raw: "Merged the dependency bump" },
_links: { user: { href: "/api/v3/users/5", title: "Dev" } },
},
],
},
}),
} as unknown as OpenProjectClient;
}
test("ranks activities by relevance when a query is supplied", async () => {
const result = await listWorkPackageActivities(
{ workPackageId: 1, query: "TLS certificate" },
activitiesClient()
);
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe(1);
});
test("tolerates a typo in the query", async () => {
const result = await listWorkPackageActivities(
{ workPackageId: 1, query: "certificat" },
activitiesClient()
);
expect(result[0]!.id).toBe(1);
});
test("returns every activity when no query is supplied", async () => {
const result = await listWorkPackageActivities({ workPackageId: 1 }, activitiesClient());
expect(result).toHaveLength(2);
});
test("exact mode finds nothing for a typo", async () => {
// Deliberately not "certificat" (the brief's original value): that string
// is a literal prefix substring of "certificate", so byte-for-byte exact
// containment matching (scoreExact in src/search/rank.ts, "reproducing
// the pre-fuzzy behaviour byte for byte") finds it regardless of typo
// tolerance, making the original fixture unable to exercise "exact mode
// rejects typos" against a correct implementation. "certificatee" (an
// extra trailing letter) is not a substring of "certificate", so it
// exercises the same intent without the collision.
const result = await listWorkPackageActivities(
{ workPackageId: 1, query: "certificatee", matchMode: "exact" },
activitiesClient()
);
expect(result).toHaveLength(0);
});
});
describe("Work Package Activities query documentation", () => {
/**
* `query` does not merely reorder the timeline — rankRecords drops
* everything below DEFAULT_MIN_SCORE. A model told the parameter "ranks"
* will reason over a silently truncated history, so the schema and the tool
* description must both say that it filters.
*/
test("the query parameter is documented as filtering, not only ranking", () => {
const description = listWorkPackageActivitiesShape.query.description ?? "";
expect(description.toLowerCase()).toContain("filter");
expect(description.toLowerCase()).toMatch(/not the complete history|are omitted/);
});
test("the tool description warns that a query narrows the timeline", () => {
expect(listWorkPackageActivitiesTool.description.toLowerCase()).toContain("filters");
});
test("a query really does drop non-matching activities", async () => {
const client = {
get: async () => ({
_type: "Collection",
_embedded: {
elements: [
{
id: 1,
version: 1,
createdAt: "2026-09-10T00:00:00Z",
comment: { raw: "Renewed the expired TLS certificate" },
_links: { user: { href: "/api/v3/users/4", title: "Admin" } },
},
{
id: 2,
version: 2,
createdAt: "2026-09-11T00:00:00Z",
comment: { raw: "Merged the dependency bump" },
_links: { user: { href: "/api/v3/users/5", title: "Dev" } },
},
],
},
}),
} as unknown as OpenProjectClient;
const all = await listWorkPackageActivities({ workPackageId: 1 }, client);
const filtered = await listWorkPackageActivities(
{ workPackageId: 1, query: "TLS certificate" },
client
);
expect(all).toHaveLength(2);
expect(filtered.map((a) => a.id)).toEqual([1]);
});
});