Skip to content

feat: add mcp server - #1341

Open
rokroskar wants to merge 24 commits into
mainfrom
000-add-mcp-server
Open

feat: add mcp server#1341
rokroskar wants to merge 24 commits into
mainfrom
000-add-mcp-server

Conversation

@rokroskar

@rokroskar rokroskar commented Jun 3, 2026

Copy link
Copy Markdown
Member

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:

  • there is a safety mechanism to prevent all admin users from using the mcp server. It's implemented as a part of the _api wrapper - I'm not sure this is a good way to do it, my initial instinct was to have a decorator do that job.
  • I don't know if you would like to have DependencyManager use consistently throughout, here the much simpler MCPDependencies is used

How to test

Claude:

$ claude mcp add --transport http --client-id renku-mcp --callback-port 8484 renku https://renku-ci-ds-1341.dev.renku.ch/mcp

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:

> /mcp-auth

select renku and connect - it should log you in and get your token.

Codex

Add this to .codex/config.toml:

mcp_oauth_callback_port = 8484
mcp_oauth_callback_url = "http://localhost:8484/callback"

[mcp_servers.renku]
url = "https://renku-ci-ds-1341.dev.renku.ch/mcp"
enabled = true

[mcp_servers.renku.oauth]
client_id = "renku-mcp"

Run codex mcp login renku.

Using the tools

Just prompt something like

Make me a renku project with this repository: https://github.com/rokroskar/renku-demo-air-quality-analysis and a session launcher.

Related PR: SwissDataScienceCenter/renku#4473

@RenkuBot

RenkuBot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

You can access the deployment of this PR at https://renku-ci-ds-1341.dev.renku.ch

@rokroskar
rokroskar force-pushed the 000-add-mcp-server branch 2 times, most recently from c3779cc to 294e594 Compare June 3, 2026 22:07
Comment thread projects/renku_mcp_server/pyproject.toml Outdated
@rokroskar
rokroskar marked this pull request as ready for review June 5, 2026 11:46
@rokroskar
rokroskar requested review from a team, SalimKayal and sgaist as code owners June 5, 2026 11:46
@rokroskar
rokroskar force-pushed the 000-add-mcp-server branch from 8c844c4 to 023c281 Compare June 5, 2026 13:57
…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
@rokroskar
rokroskar force-pushed the 000-add-mcp-server branch from b48904e to 578da49 Compare June 8, 2026 15:26
@coveralls

coveralls commented Jun 8, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 27170576504

Coverage decreased (-0.4%) to 85.986%

Details

  • Coverage decreased (-0.4%) from the base build.
  • Patch coverage: 203 uncovered changes across 3 files (322 of 525 lines covered, 61.33%).
  • 18 coverage regressions across 9 files.

Uncovered Changes

File Changed Covered %
bases/renku_data_services/mcp_api/server.py 385 229 59.48%
bases/renku_data_services/mcp_api/main.py 111 65 58.56%
bases/renku_data_services/mcp_api/dependencies.py 29 28 96.55%

Coverage Regressions

18 previously-covered lines in 9 files lost coverage.

File Lines Losing Coverage Coverage
components/renku_data_services/notifications/core.py 9 30.95%
components/renku_data_services/crc/core.py 2 79.64%
components/renku_data_services/base_api/error_handler.py 1 90.72%
components/renku_data_services/users/db.py 1 90.36%
components/renku_data_services/connected_services/db.py 1 71.98%
components/renku_data_services/base_api/pagination.py 1 88.16%
components/renku_data_services/data_connectors/blueprints.py 1 81.68%
components/renku_data_services/session/models.py 1 94.19%
components/renku_data_services/resource_usage/core.py 1 53.33%

Coverage Stats

Coverage Status
Relevant Lines: 31718
Covered Lines: 27273
Line Coverage: 85.99%
Coverage Strength: 1.49 hits per line

💛 - 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
@rokroskar
rokroskar force-pushed the 000-add-mcp-server branch from 36141b9 to 93ae613 Compare June 8, 2026 22:18
base_url = os.environ.get("RENKU_BASE_URL", "https://renkulab.io").rstrip("/")
return cls(base_url=base_url)

async def api(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you mean here a renaming to make it clearer what is what or are you suggesting a deeper refactoring?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. 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.

  2. 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +25 to +26
[tool.poetry.scripts]
renku-mcp = "renku_data_services.mcp_api.main:main"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a bit less noise if defined as a script, but sure makes sense to follow the same pattern

Comment on lines +86 to +87
The server discovers your token automatically from the `rnk` CLI token file — no `RENKU_ACCESS_TOKEN`
needed after `rnk login`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uuhhh can we delay this until there is no more clear text token file?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread bases/renku_data_services/mcp_api/main.py Outdated
Comment thread bases/renku_data_services/mcp_api/main.py
Set RENKU_MCP_ALLOW_ADMIN=1 in the server environment to override.
"""

if os.environ.get("RENKU_MCP_ALLOW_ADMIN"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it's a security related check, I would do an explicit check against 1 as documented in the function docstring.

Suggested change
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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's usually frown upon to have such a wide reaching except statement.

Co-authored-by: Samuel Gaist <samuel.gaist@idiap.ch>
Comment on lines +97 to +98
ts = datetime.datetime.fromisoformat(wda.replace("Z", "+00:00")).timestamp()
return ts < time.time()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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] = {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't BaseException a bit broad ?

if extra_headers:
headers.update(extra_headers)

async with httpx.AsyncClient() as client:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is called every API call. is this really what we want here ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants