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.
- openproject_mcp/__init__.py +5 -0
- openproject_mcp/__main__.py +127 -0
- openproject_mcp/auth.py +54 -0
- openproject_mcp/client/__init__.py +17 -0
- openproject_mcp/client/cache.py +147 -0
- openproject_mcp/client/errors.py +439 -0
- openproject_mcp/client/filters.py +571 -0
- openproject_mcp/client/hal.py +342 -0
- openproject_mcp/client/http.py +457 -0
- openproject_mcp/client/locking.py +192 -0
- openproject_mcp/client/payloads.py +300 -0
- openproject_mcp/config.py +273 -0
- openproject_mcp/observability.py +127 -0
- openproject_mcp/projections.py +311 -0
- openproject_mcp/prompts.py +972 -0
- openproject_mcp/py.typed +0 -0
- openproject_mcp/resources.py +324 -0
- openproject_mcp/server.py +142 -0
- openproject_mcp/tools/__init__.py +61 -0
- openproject_mcp/tools/_forms.py +172 -0
- openproject_mcp/tools/_shared.py +606 -0
- openproject_mcp/tools/attachments.py +1053 -0
- openproject_mcp/tools/git_activity.py +751 -0
- openproject_mcp/tools/metadata.py +1045 -0
- openproject_mcp/tools/modules_collab.py +1306 -0
- openproject_mcp/tools/news.py +654 -0
- openproject_mcp/tools/notifications.py +546 -0
- openproject_mcp/tools/people.py +1045 -0
- openproject_mcp/tools/projects.py +1363 -0
- openproject_mcp/tools/queries.py +687 -0
- openproject_mcp/tools/reporting.py +1075 -0
- openproject_mcp/tools/time_entries.py +906 -0
- openproject_mcp/tools/versions.py +702 -0
- openproject_mcp/tools/work_packages.py +1539 -0
- openproject_mcp/tools/wp_collaboration.py +1695 -0
- openproject_mcp/version_probe.py +295 -0
- openproject_mcp_server-0.1.0.dist-info/METADATA +499 -0
- openproject_mcp_server-0.1.0.dist-info/RECORD +41 -0
- openproject_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- openproject_mcp_server-0.1.0.dist-info/entry_points.txt +3 -0
- openproject_mcp_server-0.1.0.dist-info/licenses/LICENSE +21 -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())
|
openproject_mcp/auth.py
ADDED
|
@@ -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)))
|