feat: add mcp server - #1341
Conversation
|
You can access the deployment of this PR at https://renku-ci-ds-1341.dev.renku.ch |
c3779cc to
294e594
Compare
8c844c4 to
023c281
Compare
…ity fixes - Integration tests against the real Sanic/DB/SpiceDB stack via SanicMCPDependencies - Regression test: launcher_create without launcher_type must not fail (old API compat) - launcher_type=None treated as interactive for backwards compatibility - launcher_type optional in launcher_create, only sent when explicitly set
b48904e to
578da49
Compare
Coverage Report for CI Build 27170576504Coverage decreased (-0.4%) to 85.986%Details
Uncovered Changes
Coverage Regressions18 previously-covered lines in 9 files lost coverage.
Coverage Stats
💛 - Coveralls |
…idation - Remove legacy credential file paths (_creds_candidates) - Remove JWT validation from _load_rnk_token (issuer/expiry without signature verification is security theater; the data API validates) - Replace bare except Exception with specific types (bandit B110/B112) - Add tests for _load_rnk_token and _resolve_token
36141b9 to
93ae613
Compare
| base_url = os.environ.get("RENKU_BASE_URL", "https://renkulab.io").rstrip("/") | ||
| return cls(base_url=base_url) | ||
|
|
||
| async def api( |
There was a problem hiding this comment.
It's weird to have an api() method defined on a dependency manager. It should be defined on a http_client. This should be changed to be something like:
def get_renku_httpx_client() -> httpx.AsyncClient:
"""Returns an async httpx client to use the backend API."""
return httpx.AsyncClient() # with a token injector.There was a problem hiding this comment.
do you mean here a renaming to make it clearer what is what or are you suggesting a deeper refactoring?
There was a problem hiding this comment.
-
No human software engineer would put this
api()here inside the dependency manager. The dependency manager would have a factory method (e.g.get_renku_httpx_client()) which creates a client configured to call the API. -
The code found here is not necessary, you can achieve most of what it does with just:
headers: dict[str, str] = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", "Accept": "application/json", } api_client = httpx.AsyncClient(headers=headers)
There was a problem hiding this comment.
Oh and you can also have:
prefix_url=f"{base_url}/api/data"
httpx.AsyncClient(url=prefix_url, headers=headers)And then you can provide just the path part when calling .get(), etc.
| [tool.poetry.scripts] | ||
| renku-mcp = "renku_data_services.mcp_api.main:main" |
There was a problem hiding this comment.
This is not wrong, but unusual for this repository. Python scripts to start a server are just called as the entrypoint.
Instead of:
ENTRYPOINT ["tini", "-g", "--", "env/bin/renku-mcp"]
we tend to have:
ENTRYPOINT ["tini", "-g", "--", "env/bin/python", "-m", "renku_data_services.data_api.main", <other options>]
There was a problem hiding this comment.
a bit less noise if defined as a script, but sure makes sense to follow the same pattern
| The server discovers your token automatically from the `rnk` CLI token file — no `RENKU_ACCESS_TOKEN` | ||
| needed after `rnk login`. |
There was a problem hiding this comment.
Uuhhh can we delay this until there is no more clear text token file?
There was a problem hiding this comment.
this is only for spinning up the server locally - in practice I expect this to only be used for testing. In production the token is handled by the agent harness via oauth.
| Set RENKU_MCP_ALLOW_ADMIN=1 in the server environment to override. | ||
| """ | ||
|
|
||
| if os.environ.get("RENKU_MCP_ALLOW_ADMIN"): |
There was a problem hiding this comment.
Since it's a security related check, I would do an explicit check against 1 as documented in the function docstring.
| if os.environ.get("RENKU_MCP_ALLOW_ADMIN"): | |
| if os.environ.get("RENKU_MCP_ALLOW_ADMIN") == "1": |
| try: | ||
| ts = datetime.datetime.fromisoformat(wda.replace("Z", "+00:00")).timestamp() | ||
| return ts < time.time() | ||
| except Exception: |
There was a problem hiding this comment.
It's usually frown upon to have such a wide reaching except statement.
Co-authored-by: Samuel Gaist <samuel.gaist@idiap.ch>
| ts = datetime.datetime.fromisoformat(wda.replace("Z", "+00:00")).timestamp() | ||
| return ts < time.time() |
There was a problem hiding this comment.
Weird parsing and comparison to "now".
| project_id: Annotated[str, Field(description="Project ID")], | ||
| ) -> dict[str, Any]: | ||
| """Link an existing data connector to a project.""" | ||
| return await _deps(ctx).api( |
There was a problem hiding this comment.
Why use _deps(ctx).api instead of _api ? _api has require_non_admin is there a good reason for this ?
| project: Annotated[str, Field(description="Project ID or namespace/slug (e.g. 'myuser/my-project')")], | ||
| ) -> dict[str, Any]: | ||
| """Get a Renku project by ID or namespace/slug.""" | ||
| return await _api(ctx, "GET", _project_path(project), _token(ctx)) |
There was a problem hiding this comment.
Is there a good reason to pass _token as the body argument of the _api function ? also _api injects the token in the call. is there any need to pass it there ?
| - Hibernated or paused sessions: warn the user that unsaved work inside those | ||
| sessions will be lost, and ask for explicit confirmation before stopping them. | ||
| """ | ||
| proj = await _api(ctx, "GET", _project_path(project), _token(ctx)) |
There was a problem hiding this comment.
same as above. is there any need to pass _token there ?
|
|
||
| def _project_path(ident: str) -> str: | ||
| """Turn a project ID or namespace/slug into an API path segment.""" | ||
| import urllib.parse |
There was a problem hiding this comment.
why not import this at the top level ?
|
|
||
|
|
||
| # Cache admin status per token so we only call /user once per session/request. | ||
| _admin_cache: dict[str, bool] = {} |
There was a problem hiding this comment.
no ttl for a cache ? don't we risk having this grow too much/become stale ? admittedly there shouldn't be too much admins but still.
| _api(ctx, "GET", f"/sessions/{session_id}/logs"), | ||
| return_exceptions=True, | ||
| ) | ||
| if isinstance(session, BaseException): |
There was a problem hiding this comment.
isn't BaseException a bit broad ?
| if extra_headers: | ||
| headers.update(extra_headers) | ||
|
|
||
| async with httpx.AsyncClient() as client: |
There was a problem hiding this comment.
this is called every API call. is this really what we want here ?
…e jobs); add job_command_override and job_args_override
…ENKU_BASE_URL_PATH
…ution are not silently swallowed
Adds an MCP server. The server runs as a separate container alongside data-services.
/deploy renku=000-add-mcp-server extra-values=mcpServer.enabled=true
AI use disclosure: The skeleton and usability guidelines were developed and written by me; claude code wrote the code, which I reviewed and tested.
Questions about the implementation:
_apiwrapper - I'm not sure this is a good way to do it, my initial instinct was to have a decorator do that job.DependencyManageruse consistently throughout, here the much simplerMCPDependenciesis usedHow to test
Claude:
When you start claude it should automatically try to log you in to the CI deployment.
Pi:
Add this as
.pi/mcp.json:{ "mcpServers": { "renku": { "type": "http", "url": "https://renku-ci-ds-1341.dev.renku.ch/mcp", "oauth": { "clientId": "renku-mcp", "redirectUri": "http://localhost:8484" } } } }Launch Pi and do:
select renku and connect - it should log you in and get your token.
Codex
Add this to
.codex/config.toml:Run
codex mcp login renku.Using the tools
Just prompt something like
Related PR: SwissDataScienceCenter/renku#4473