openproject-mcp-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. openproject_mcp/__init__.py +5 -0
  2. openproject_mcp/__main__.py +127 -0
  3. openproject_mcp/auth.py +54 -0
  4. openproject_mcp/client/__init__.py +17 -0
  5. openproject_mcp/client/cache.py +147 -0
  6. openproject_mcp/client/errors.py +439 -0
  7. openproject_mcp/client/filters.py +571 -0
  8. openproject_mcp/client/hal.py +342 -0
  9. openproject_mcp/client/http.py +457 -0
  10. openproject_mcp/client/locking.py +192 -0
  11. openproject_mcp/client/payloads.py +300 -0
  12. openproject_mcp/config.py +273 -0
  13. openproject_mcp/observability.py +127 -0
  14. openproject_mcp/projections.py +311 -0
  15. openproject_mcp/prompts.py +972 -0
  16. openproject_mcp/py.typed +0 -0
  17. openproject_mcp/resources.py +324 -0
  18. openproject_mcp/server.py +142 -0
  19. openproject_mcp/tools/__init__.py +61 -0
  20. openproject_mcp/tools/_forms.py +172 -0
  21. openproject_mcp/tools/_shared.py +606 -0
  22. openproject_mcp/tools/attachments.py +1053 -0
  23. openproject_mcp/tools/git_activity.py +751 -0
  24. openproject_mcp/tools/metadata.py +1045 -0
  25. openproject_mcp/tools/modules_collab.py +1306 -0
  26. openproject_mcp/tools/news.py +654 -0
  27. openproject_mcp/tools/notifications.py +546 -0
  28. openproject_mcp/tools/people.py +1045 -0
  29. openproject_mcp/tools/projects.py +1363 -0
  30. openproject_mcp/tools/queries.py +687 -0
  31. openproject_mcp/tools/reporting.py +1075 -0
  32. openproject_mcp/tools/time_entries.py +906 -0
  33. openproject_mcp/tools/versions.py +702 -0
  34. openproject_mcp/tools/work_packages.py +1539 -0
  35. openproject_mcp/tools/wp_collaboration.py +1695 -0
  36. openproject_mcp/version_probe.py +295 -0
  37. openproject_mcp_server-0.1.0.dist-info/METADATA +499 -0
  38. openproject_mcp_server-0.1.0.dist-info/RECORD +41 -0
  39. openproject_mcp_server-0.1.0.dist-info/WHEEL +4 -0
  40. openproject_mcp_server-0.1.0.dist-info/entry_points.txt +3 -0
  41. openproject_mcp_server-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """OpenProject MCP server."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,127 @@
1
+ """Console entry point: ``openproject-mcp-server`` / ``openproject-mcp`` (SPEC §3.3, §14).
2
+
3
+ Selects the transport, checks configuration, and starts the server. A missing
4
+ or broken configuration produces a short actionable message on stderr and a
5
+ non-zero exit code — never a traceback.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os.path
12
+ import sys
13
+ from collections.abc import Sequence
14
+
15
+ from pydantic import ValidationError
16
+
17
+ from openproject_mcp import __version__
18
+ from openproject_mcp.config import Settings, check_runtime_config
19
+
20
+ __all__ = ["main"]
21
+
22
+ EXIT_OK = 0
23
+ EXIT_CONFIG_ERROR = 2
24
+ EXIT_INTERRUPTED = 130
25
+
26
+
27
+ def _prog() -> str:
28
+ """The name this process was invoked as, when it is one of our scripts.
29
+
30
+ Both console scripts run this ``main``; ``--version`` and usage text
31
+ should echo whichever the user typed. Anything else (``python -m ...``,
32
+ test harnesses) reports the canonical distribution name.
33
+ """
34
+ invoked = os.path.basename(sys.argv[0]) if sys.argv and sys.argv[0] else ""
35
+ if invoked in ("openproject-mcp", "openproject-mcp-server"):
36
+ return invoked
37
+ return "openproject-mcp-server"
38
+
39
+
40
+ def build_parser() -> argparse.ArgumentParser:
41
+ parser = argparse.ArgumentParser(
42
+ prog=_prog(),
43
+ description=(
44
+ "MCP server for OpenProject. Configuration comes from the environment: "
45
+ "OPENPROJECT_URL and OPENPROJECT_API_KEY are required."
46
+ ),
47
+ )
48
+ parser.add_argument(
49
+ "--transport",
50
+ choices=("stdio", "http"),
51
+ default="stdio",
52
+ help="Transport to serve on (default: stdio).",
53
+ )
54
+ parser.add_argument(
55
+ "--host",
56
+ default=None,
57
+ help="Bind address for --transport http (default: 127.0.0.1).",
58
+ )
59
+ parser.add_argument(
60
+ "--port",
61
+ type=int,
62
+ default=None,
63
+ help="Port for --transport http (default: 8000).",
64
+ )
65
+ parser.add_argument(
66
+ "--check",
67
+ action="store_true",
68
+ help="Validate configuration and exit without starting the server.",
69
+ )
70
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
71
+ return parser
72
+
73
+
74
+ def _fail(message: str, problems: Sequence[str] = ()) -> int:
75
+ print(f"openproject-mcp: {message}", file=sys.stderr)
76
+ for problem in problems:
77
+ print(f" - {problem}", file=sys.stderr)
78
+ print(
79
+ " See https://github.com/kar-thik/openproject-mcp#configuration "
80
+ "for the full variable list.",
81
+ file=sys.stderr,
82
+ )
83
+ return EXIT_CONFIG_ERROR
84
+
85
+
86
+ def main(argv: Sequence[str] | None = None) -> int:
87
+ """CLI entry point. Returns a process exit code; raises nothing."""
88
+ args = build_parser().parse_args(argv)
89
+
90
+ try:
91
+ settings = Settings()
92
+ except ValidationError as exc:
93
+ problems = [
94
+ f"{'.'.join(str(part) for part in error['loc']) or 'config'}: {error['msg']}"
95
+ for error in exc.errors()
96
+ ]
97
+ return _fail("invalid configuration", problems)
98
+
99
+ problems = check_runtime_config(settings, args.transport)
100
+ if problems:
101
+ return _fail("cannot start, configuration is incomplete", problems)
102
+
103
+ if args.check:
104
+ print(f"openproject-mcp: configuration OK ({settings.url})", file=sys.stderr)
105
+ return EXIT_OK
106
+
107
+ # Imported here so `--help` and `--check` stay fast and dependency-light.
108
+ from openproject_mcp.server import build_server
109
+
110
+ server = build_server(settings)
111
+ try:
112
+ if args.transport == "http":
113
+ server.run(
114
+ transport="http",
115
+ host=args.host or settings.http_host,
116
+ port=args.port or settings.http_port,
117
+ show_banner=False,
118
+ )
119
+ else:
120
+ server.run(transport="stdio", show_banner=False)
121
+ except KeyboardInterrupt:
122
+ return EXIT_INTERRUPTED
123
+ return EXIT_OK
124
+
125
+
126
+ if __name__ == "__main__": # pragma: no cover
127
+ sys.exit(main())
@@ -0,0 +1,54 @@
1
+ """Bearer-token verification for the HTTP transport (SPEC §11).
2
+
3
+ ``OPENPROJECT_MCP_AUTH_TOKENS`` holds a static, comma-separated set of bearer
4
+ tokens. When it is configured, :class:`StaticBearerVerifier` is attached to the
5
+ server as its auth provider; FastMCP's HTTP app then rejects every request to
6
+ the MCP endpoint that does not carry ``Authorization: Bearer <token>`` with a
7
+ configured token (401 + ``WWW-Authenticate``). Only the HTTP app consults the
8
+ auth provider — the stdio transport and the in-memory client are untouched.
9
+
10
+ Token comparison is constant-time (:func:`secrets.compare_digest`) and checks
11
+ every configured token so timing reveals nothing about which, if any, matched.
12
+ Token material is never logged, and an empty token set rejects all requests
13
+ rather than leaving the endpoint open.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import secrets
19
+ from collections.abc import Sequence
20
+
21
+ from fastmcp.server.auth.auth import AccessToken, TokenVerifier
22
+
23
+ __all__ = ["StaticBearerVerifier"]
24
+
25
+
26
+ class StaticBearerVerifier(TokenVerifier):
27
+ """Verifies presented bearer tokens against a fixed set of secrets.
28
+
29
+ The presented token is compared against **every** configured token with
30
+ :func:`secrets.compare_digest` — no early exit — so response timing does
31
+ not leak which token (if any) matched. With an empty token set every
32
+ request is rejected: a misconfigured server fails closed.
33
+
34
+ A match is attributed to a non-secret client id derived from the token's
35
+ 1-based position in ``OPENPROJECT_MCP_AUTH_TOKENS`` (for example
36
+ ``openproject-mcp/token-2``), so with one token per client, access logs can
37
+ say which client authenticated without exposing token material.
38
+ """
39
+
40
+ def __init__(self, tokens: Sequence[str]) -> None:
41
+ super().__init__()
42
+ self._tokens = tuple(token.encode("utf-8") for token in tokens)
43
+
44
+ async def verify_token(self, token: str) -> AccessToken | None:
45
+ presented = token.encode("utf-8")
46
+ matched_index = -1
47
+ for index, candidate in enumerate(self._tokens):
48
+ if secrets.compare_digest(presented, candidate) and matched_index < 0:
49
+ matched_index = index
50
+ if matched_index < 0:
51
+ return None
52
+ return AccessToken(
53
+ token=token, client_id=f"openproject-mcp/token-{matched_index + 1}", scopes=[]
54
+ )
@@ -0,0 +1,17 @@
1
+ """OpenProject API client layer (SPEC §4).
2
+
3
+ Import surface for tool modules::
4
+
5
+ from openproject_mcp.client import OpenProjectClient, TTLCache
6
+ from openproject_mcp.client.errors import NotFoundError, ValidationFailedError
7
+ from openproject_mcp.client.filters import make_filter, query_params, status_filter
8
+ from openproject_mcp.client.hal import collection, formattable, ref, self_id
9
+ from openproject_mcp.client.locking import patch_with_lock
10
+ from openproject_mcp.client.payloads import build_write_payload, links_payload
11
+ """
12
+
13
+ from openproject_mcp.client.cache import TTLCache, credential_scope
14
+ from openproject_mcp.client.errors import OpenProjectError
15
+ from openproject_mcp.client.http import OpenProjectClient
16
+
17
+ __all__ = ["OpenProjectClient", "OpenProjectError", "TTLCache", "credential_scope"]
@@ -0,0 +1,147 @@
1
+ """TTL cache for near-static metadata (SPEC §4.6).
2
+
3
+ Cached: API root, ``/configuration``, types, statuses, priorities, roles,
4
+ per-project types/versions/categories, WP schemas, time-entry activities,
5
+ ``users/me``, and version-probe results (1 h). **Never cached:** lockVersions,
6
+ work packages, or anything else a write can invalidate.
7
+
8
+ Entries are scoped per credential so a multi-user HTTP deployment cannot serve
9
+ one principal's metadata to another. The scope key is a hash of the credential,
10
+ never the credential itself.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import hashlib
17
+ import time
18
+ from collections.abc import Awaitable, Callable, Hashable
19
+ from dataclasses import dataclass
20
+ from typing import Any, TypeVar
21
+
22
+ __all__ = ["TTLCache", "credential_scope"]
23
+
24
+ T = TypeVar("T")
25
+
26
+ DEFAULT_SCOPE = "default"
27
+
28
+
29
+ def credential_scope(credential: str | None) -> str:
30
+ """Return a stable, non-reversible cache scope for a credential."""
31
+ if not credential:
32
+ return DEFAULT_SCOPE
33
+ return hashlib.sha256(credential.encode("utf-8")).hexdigest()[:16]
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class _Entry:
38
+ value: Any
39
+ expires_at: float
40
+
41
+
42
+ class TTLCache:
43
+ """A small async-safe TTL cache with per-credential key scoping.
44
+
45
+ Concurrent misses on the same key are collapsed: the first caller computes,
46
+ the rest await the same result, so a burst of tool calls does not stampede
47
+ the metadata endpoints.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ *,
53
+ ttl: float = 300.0,
54
+ max_entries: int = 1024,
55
+ time_fn: Callable[[], float] = time.monotonic,
56
+ ) -> None:
57
+ self.default_ttl = ttl
58
+ self.max_entries = max_entries
59
+ self._time = time_fn
60
+ self._entries: dict[tuple[str, Hashable], _Entry] = {}
61
+ self._locks: dict[tuple[str, Hashable], asyncio.Lock] = {}
62
+
63
+ # --- basic operations ------------------------------------------------
64
+
65
+ def get(self, key: Hashable, *, scope: str = DEFAULT_SCOPE) -> Any | None:
66
+ """Return the live value for ``key``, or ``None`` when missing/expired."""
67
+ full_key = (scope, key)
68
+ entry = self._entries.get(full_key)
69
+ if entry is None:
70
+ return None
71
+ if entry.expires_at <= self._time():
72
+ self._entries.pop(full_key, None)
73
+ return None
74
+ return entry.value
75
+
76
+ def set(
77
+ self,
78
+ key: Hashable,
79
+ value: Any,
80
+ *,
81
+ scope: str = DEFAULT_SCOPE,
82
+ ttl: float | None = None,
83
+ ) -> None:
84
+ """Store a value; ``ttl=0`` stores nothing."""
85
+ effective_ttl = self.default_ttl if ttl is None else ttl
86
+ if effective_ttl <= 0:
87
+ return
88
+ self._evict_if_needed()
89
+ self._entries[(scope, key)] = _Entry(value, self._time() + effective_ttl)
90
+
91
+ def invalidate(self, key: Hashable, *, scope: str = DEFAULT_SCOPE) -> None:
92
+ self._entries.pop((scope, key), None)
93
+
94
+ def invalidate_scope(self, scope: str) -> None:
95
+ for full_key in [k for k in self._entries if k[0] == scope]:
96
+ self._entries.pop(full_key, None)
97
+
98
+ def clear(self) -> None:
99
+ self._entries.clear()
100
+ self._locks.clear()
101
+
102
+ def __len__(self) -> int:
103
+ return len(self._entries)
104
+
105
+ # --- memoization -----------------------------------------------------
106
+
107
+ async def get_or_set(
108
+ self,
109
+ key: Hashable,
110
+ factory: Callable[[], Awaitable[T]],
111
+ *,
112
+ scope: str = DEFAULT_SCOPE,
113
+ ttl: float | None = None,
114
+ refresh: bool = False,
115
+ ) -> T:
116
+ """Return the cached value or compute, store and return it.
117
+
118
+ ``refresh=True`` bypasses the cached value and recomputes — this is the
119
+ ``refresh=true`` parameter the metadata tools expose. A factory that
120
+ raises does not poison the cache.
121
+ """
122
+ full_key = (scope, key)
123
+ if not refresh:
124
+ cached = self.get(key, scope=scope)
125
+ if cached is not None:
126
+ return cached # type: ignore[return-value]
127
+
128
+ lock = self._locks.setdefault(full_key, asyncio.Lock())
129
+ async with lock:
130
+ if not refresh:
131
+ cached = self.get(key, scope=scope)
132
+ if cached is not None:
133
+ return cached # type: ignore[return-value]
134
+ value = await factory()
135
+ self.set(key, value, scope=scope, ttl=ttl)
136
+ return value
137
+
138
+ # --- internals -------------------------------------------------------
139
+
140
+ def _evict_if_needed(self) -> None:
141
+ if len(self._entries) < self.max_entries:
142
+ return
143
+ now = self._time()
144
+ for full_key in [k for k, entry in self._entries.items() if entry.expires_at <= now]:
145
+ self._entries.pop(full_key, None)
146
+ while len(self._entries) >= self.max_entries:
147
+ self._entries.pop(next(iter(self._entries)))