Skip to content

Add full Lanis MCP server - #5

Draft
joan-code6 wants to merge 1 commit into
mainfrom
agent/add-lanis-mcp-server
Draft

Add full Lanis MCP server#5
joan-code6 wants to merge 1 commit into
mainfrom
agent/add-lanis-mcp-server

Conversation

@joan-code6

@joan-code6 joan-code6 commented Aug 3, 2026

Copy link
Copy Markdown
Owner

What changed

  • Add a stateful Lanis MCP server covering all 37 user-facing Schulportal Hessen API operations.
  • Preserve API parameter parity while retaining credentials per MCP session and refreshing expired access tokens once.
  • Add explicit tool descriptions, input metadata, safety annotations, and structured output schemas.
  • Support stdio, Streamable HTTP, course-file downloads, DSBmobile, messages, homework actions, semantic search, and private SSO launch links.
  • Add packaging metadata, CLI entry point, setup documentation, focused MCP tests, and the ChatGPT App submission artifact.

Why

Lanis needs an MCP transport that gives assistants the same Schulportal Hessen capabilities as the REST API without requiring users to manually pass an access token to every tool call.

User and developer impact

Users can log in once per MCP session and use the full Lanis feature set through their assistant. Developers get an explicit REST-to-MCP route map, safer tool metadata, and packaging that installs the lanis-mcp command.

Validation

  • ruff check lanis_mcp tests
  • pytest -q — 8 passed
  • FastAPI/MCP route parity — 37/37 operations
  • MCP output schemas — 37/37 tools
  • jq empty chatgpt-app-submission.json
  • Editable install with pip install -e '.[mcp,dev]'

Summary by CodeRabbit

  • New Features

    • Added an MCP server for accessing Schulportal Hessen services, including schools, calendars, timetables, messages, courses, files, homework, and search.
    • Added login, token refresh, logout, session-based credentials, and secure app launch links.
    • Added support for stdio, HTTP, and SSE server transports.
    • Added a lanis-mcp command-line entry point and Python module execution.
    • Added a ChatGPT app submission manifest with tool metadata and test scenarios.
  • Documentation

    • Expanded setup, authentication, configuration, client usage, and deployment documentation.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 483879e2-2699-4f40-8517-4bd25dbe6e94

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (8)
lanis_mcp/client.py (3)

107-112: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

download_file does not require a token, unlike request.

request calls resolve_token and raises a clear ValueError when no token exists. download_file sends the request without the header instead. The caller then receives an opaque 401, and lanis_download_course_file in lanis_mcp/server.py tries a token refresh for a case that never had a token. Call resolve_token here for consistent behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lanis_mcp/client.py` around lines 107 - 112, Update download_file to resolve
the access token through the existing resolve_token method before constructing
the request headers, preserving the optional access_token override and clear
ValueError behavior when no token is available. Use the resolved token for
X-Session-Token instead of silently sending an unauthenticated request.

134-137: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Sanitize the parsed filename.

The parser accepts any value the server sends. A response with filename="../../etc/passwd" returns that string unchanged. A consumer that writes the file to disk then writes outside the target directory. The parser also ignores the RFC 5987 filename* form, so UTF-8 names fall back to "download".

Reduce the value to its base name and reject empty results.

♻️ Proposed fix
         disposition = response.headers.get("content-disposition", "")
         filename = "download"
         if "filename=" in disposition:
-            filename = disposition.split("filename=", 1)[1].strip().strip('"')
+            candidate = disposition.split("filename=", 1)[1].strip().strip('"')
+            candidate = os.path.basename(candidate.replace("\\", "/")).strip()
+            if candidate not in ("", ".", ".."):
+                filename = candidate
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lanis_mcp/client.py` around lines 134 - 137, Update the filename parsing
around the response content-disposition handling to support RFC 5987 filename*
values, sanitize the selected filename to its base name, and fall back to
"download" when sanitization produces an empty result. Preserve the existing
default for missing or unusable filename parameters.

68-73: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Reuse one httpx.AsyncClient instead of creating one per request.

request and download_file each build and close a new AsyncClient. This discards connection pooling and forces a new TCP and TLS handshake for every tool call. The server also constructs a new LanisClient per call (_client() in lanis_mcp/server.py line 161), so no connection is ever reused.

Hold one lazily created client on the instance and close it explicitly, or expose the class as an async context manager.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lanis_mcp/client.py` around lines 68 - 73, Update the LanisClient request and
download_file flows to reuse one lazily initialized httpx.AsyncClient stored on
the instance instead of creating and closing a client per call. Add explicit
async cleanup or async-context-manager support, and update the server’s
_client() lifecycle so the same LanisClient and underlying client are reused
across tool calls and closed when the server shuts down.
tests/test_lanis_mcp_client.py (2)

12-76: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Silence the Ruff S105 and S106 hits with a per-file ignore.

Ruff reports hardcoded-password findings for the test tokens at lines 32, 44, 73, 107, and 109. These are fixtures, not real secrets. Add a per-file ignore in pyproject.toml so the lint stage stays green without weakening the rule for production code.

♻️ Proposed Ruff configuration
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S105", "S106"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_lanis_mcp_client.py` around lines 12 - 76, Add a Ruff per-file
ignore in the existing pyproject.toml lint configuration for tests/** covering
S105 and S106, while leaving these security rules enabled for production code.

Source: Linters/SAST tools


113-154: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the 401 refresh-and-retry path.

The tests cover login, session isolation, and token reuse. They do not cover _api_request in lanis_mcp/server.py lines 242-257, which catches LanisAPIError, refreshes the token once, and retries. That branch carries the highest risk in this PR: a wrong retry can repeat a mutating request.

Extend FakeClient to raise LanisAPIError(401, ...) on the first authenticated call. Then assert that /auth/refresh is called exactly once and that the retry uses the new access token.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_lanis_mcp_client.py` around lines 113 - 154, Extend
test_login_token_is_reused_inside_the_same_mcp_session with a FakeClient 401
response on the first authenticated request, track /auth/refresh calls, and
return a new access token from the refresh response. Assert the refresh endpoint
is called exactly once and the retried request uses the refreshed token, while
preserving the existing login and successful tool-call assertions.
lanis_mcp/server.py (2)

838-844: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Mark the sse transport as legacy, or remove the option.

The two-endpoint HTTP+SSE transport is deprecated. The two-endpoint HTTP+SSE transport has been deprecated since the 2025-03-26 spec revision; Streamable HTTP (a single endpoint, usually /mcp) replaced it. Documentation for the newer transport also states "SSE exists only for backward compatibility and shouldn't be used in new projects."

Keep sse only if an old client requires it, and state that in the argument help text so operators do not select it by default.

♻️ Proposed change
     parser.add_argument(
         "--transport",
         choices=("stdio", "streamable-http", "sse"),
         default="stdio",
+        help=(
+            "Transport to serve. Use stdio for local clients and streamable-http "
+            "for remote clients. sse is deprecated and kept only for legacy clients."
+        ),
     )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lanis_mcp/server.py` around lines 838 - 844, Update the transport argument
definition near mcp.run to remove the deprecated sse option, or retain it only
with help text explicitly marking it as legacy and for backward compatibility.
Keep streamable-http as the preferred HTTP transport and preserve the existing
default of stdio.

643-657: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse one retry helper for the download path.

This block duplicates the 401-refresh flow of _api_request. The two copies will drift. The token is also passed twice, once to _client() and once to download_file().

Extract a helper that takes a callable and applies the single refresh-and-retry policy, then use it here and in _api_request.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lanis_mcp/server.py` around lines 643 - 657, Extract the shared 401
refresh-and-retry logic from _api_request into a helper that accepts the API
callable, and update both _api_request and the download path to use it. In the
helper, reuse refreshed credentials for the retry and avoid passing the access
token redundantly to both _client() and download_file().
requirements.txt (1)

14-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the MCP extra optional in the API-server requirements path.

README.md presents requirements.txt as the API-server install, but these lines install MCP dependencies for every API-server environment. Move them to an MCP-specific requirements file, or document this file as a full-stack installation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@requirements.txt` around lines 14 - 15, Remove the MCP dependency entries
httpx and mcp from the API-server requirements represented by requirements.txt,
and place them in an MCP-specific requirements file or otherwise make the MCP
installation optional. Keep requirements.txt limited to dependencies needed for
the API server, unless README.md is updated to clearly describe it as a
full-stack installation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lanis_mcp/client.py`:
- Around line 119-132: Update the download logic in the client method containing
the shown response handling to stream the response body instead of using the
fully buffered client.get result. Accumulate chunks only while max_bytes
permits, abort as soon as the limit is exceeded, and preserve HTTP error
handling; then construct the result from the collected content, content_type,
and disposition.
- Around line 64-73: Update the request flow around the AsyncClient in the
client method to prevent X-Session-Token from being forwarded to redirect
targets whose host differs from base_url. Either implement manual redirect
handling that removes the header before cross-host requests, or configure
equivalent host-aware redirect behavior while preserving authenticated same-host
redirects and existing request functionality.

In `@lanis_mcp/server.py`:
- Around line 211-218: Add an asyncio.Lock field to SessionCredentials and
serialize the refresh request and credential updates under it, rechecking or
preserving the current token state for concurrent callers. In the refresh
handling around new_access_token, update credentials.refresh_token from result’s
refresh_token when it is present, falling back to the request token only when
absent; keep access-token validation unchanged.
- Around line 102-135: Update CredentialStore to avoid sharing the process-wide
_fallback for non-stdio transports: add an allow_shared_fallback configuration,
pass it from main() as true only for stdio and false for other transports, and
have get() return a new empty SessionCredentials when session resolution fails
and sharing is disabled. Preserve the existing _fallback behavior for stdio.

In `@pyproject.toml`:
- Around line 35-37: Align package discovery with the documented skill link by
removing skills* from the exclude list so skills/use-lanis-api is included in
the distribution, or update the README.md and generated PKG-INFO links to use an
absolute repository or hosted-documentation URL instead.
- Around line 21-27: Make the lanis-mcp entry point conditional on the mcp
optional dependency instead of exposing it from the unconditional
project.scripts metadata; place it in the dependency group that installs both
mcp and httpx, or otherwise ensure the entry point cannot load without those
dependencies and reports a clear unavailable-dependency error.

In `@README.md`:
- Line 14: Correct the spelling errors in the changed README text: replace
“straigth” with “straight” and “theire” with “their” at both referenced
occurrences, without changing the surrounding wording.
- Around line 95-98: Update the README section describing the default
`https://lanis-backend.joancode.dev` backend to identify its operator, disclose
what account and school data it retains, and explain how users can self-host or
force a local backend. If remote processing is not explicitly opt-in, change the
documented default to local execution and clearly describe how to opt into the
hosted service.
- Around line 100-101: Update the MCP installation instructions in README.md to
include a PyPI-compatible command that installs the published sph-client package
with its mcp extra, while retaining the existing editable/source installation
command if it serves local development.

---

Nitpick comments:
In `@lanis_mcp/client.py`:
- Around line 107-112: Update download_file to resolve the access token through
the existing resolve_token method before constructing the request headers,
preserving the optional access_token override and clear ValueError behavior when
no token is available. Use the resolved token for X-Session-Token instead of
silently sending an unauthenticated request.
- Around line 134-137: Update the filename parsing around the response
content-disposition handling to support RFC 5987 filename* values, sanitize the
selected filename to its base name, and fall back to "download" when
sanitization produces an empty result. Preserve the existing default for missing
or unusable filename parameters.
- Around line 68-73: Update the LanisClient request and download_file flows to
reuse one lazily initialized httpx.AsyncClient stored on the instance instead of
creating and closing a client per call. Add explicit async cleanup or
async-context-manager support, and update the server’s _client() lifecycle so
the same LanisClient and underlying client are reused across tool calls and
closed when the server shuts down.

In `@lanis_mcp/server.py`:
- Around line 838-844: Update the transport argument definition near mcp.run to
remove the deprecated sse option, or retain it only with help text explicitly
marking it as legacy and for backward compatibility. Keep streamable-http as the
preferred HTTP transport and preserve the existing default of stdio.
- Around line 643-657: Extract the shared 401 refresh-and-retry logic from
_api_request into a helper that accepts the API callable, and update both
_api_request and the download path to use it. In the helper, reuse refreshed
credentials for the retry and avoid passing the access token redundantly to both
_client() and download_file().

In `@requirements.txt`:
- Around line 14-15: Remove the MCP dependency entries httpx and mcp from the
API-server requirements represented by requirements.txt, and place them in an
MCP-specific requirements file or otherwise make the MCP installation optional.
Keep requirements.txt limited to dependencies needed for the API server, unless
README.md is updated to clearly describe it as a full-stack installation.

In `@tests/test_lanis_mcp_client.py`:
- Around line 12-76: Add a Ruff per-file ignore in the existing pyproject.toml
lint configuration for tests/** covering S105 and S106, while leaving these
security rules enabled for production code.
- Around line 113-154: Extend
test_login_token_is_reused_inside_the_same_mcp_session with a FakeClient 401
response on the first authenticated request, track /auth/refresh calls, and
return a new access token from the refresh response. Assert the refresh endpoint
is called exactly once and the retried request uses the refreshed token, while
preserving the existing login and successful tool-call assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ecdc340-30fe-43d9-9f1b-07c264cc3273

📥 Commits

Reviewing files that changed from the base of the PR and between d2dc048 and d5b62ca.

📒 Files selected for processing (14)
  • README.md
  • chatgpt-app-submission.json
  • lanis_mcp/__init__.py
  • lanis_mcp/__main__.py
  • lanis_mcp/client.py
  • lanis_mcp/server.py
  • pyproject.toml
  • requirements.txt
  • sph_client.egg-info/PKG-INFO
  • sph_client.egg-info/SOURCES.txt
  • sph_client.egg-info/entry_points.txt
  • sph_client.egg-info/requires.txt
  • sph_client.egg-info/top_level.txt
  • tests/test_lanis_mcp_client.py

Comment thread lanis_mcp/client.py
Comment on lines +64 to +73
headers: dict[str, str] = {"Accept": "application/json"}
if authenticated:
headers["X-Session-Token"] = self.resolve_token(access_token)

async with httpx.AsyncClient(
base_url=self.base_url,
timeout=self.timeout,
follow_redirects=True,
transport=self.transport,
) as client:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

httpx 0.27 redirect custom headers stripped cross-origin Authorization

💡 Result:

In httpx (including version 0.27 and later), when follow_redirects is enabled, the library automatically strips the Authorization header (along with the Cookie header) when a redirect involves a cross-origin transition [1][2][3]. This is a deliberate security feature designed to prevent the accidental leakage of sensitive credentials to a different host, scheme, or port [2][4][5]. Key behaviors include: - Cross-Origin Stripping: If the redirect target has a different origin (host, scheme, or port) than the original request, the Authorization header is removed from the subsequent redirected request [2][3]. - Same-Origin Persistence: If the redirect remains within the same origin, the Authorization header is preserved [6][4]. - Custom Headers: Httpx only explicitly strips standard sensitive headers like Authorization and Cookie [5][3]. Any custom authentication headers (e.g., X-API-Key, Authorization-Token) are not automatically stripped by httpx during cross-origin redirects, which may lead to credential exposure if those custom headers are used [5]. If you need to maintain Authorization headers across cross-origin redirects, you must handle the redirection manually by setting follow_redirects=False and performing the subsequent requests explicitly using the same client, which ensures you can control the header headers applied to each step of the redirect chain [1]. For security, it is highly recommended to only follow redirects to trusted domains if you are handling sensitive authentication information [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Tracked files matching client.py:\n'
fd -a 'client\.py$' . | sed 's#^\./##'

printf '\nlanis_mcp/client.py excerpt:\n'
if [ -f lanis_mcp/client.py ]; then
  nl -ba lanis_mcp/client.py | sed -n '1,140p'
else
  fd -a 'client\.py$' .
fi

printf '\nSearch for X-Session-Token and base_url handling:\n'
rg -n "X-Session-Token|base_url|resolve_token|LANIS_API_BASE_URL|follow_redirects" -S .

Repository: joan-code6/lanis_api

Length of output: 351


🌐 Web query:

httpx follow_redirects behavior preserves custom headers Authorization stripped source

💡 Result:

In HTTPX, the Authorization header is automatically stripped when a request follows a redirect to a different origin [1][2][3]. This is a security measure implemented to prevent the accidental leakage of sensitive credentials across different domains [4][2]. Specifically, the library performs the following check during a redirect: 1. Same-Origin Check: If the redirect target is on the same origin as the original request, the Authorization header is preserved [1][5]. 2. Cross-Origin Check: If the redirect is to a different origin, the Authorization header is removed [1][3]. There is a notable exception: if the redirect is an "upgrade" from an HTTP request to an HTTPS request (specifically when using default ports 80 and 443), the Authorization header is preserved even if the domain changes [1][6]. If you require the Authorization header to be sent across different origins during a redirect, you must disable automatic redirects (set follow_redirects=False) and manually handle the redirected request yourself [4]. By manually following the redirect with the same client instance, you can ensure your headers are included in the subsequent request [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'lanis_mcp/client.py lines 1-160:\n'
awk '{printf "%6d: %s\n", NR, $0}' lanis_mcp/client.py | sed -n '1,160p'

printf '\nRelevant searches:\n'
rg -n "X-Session-Token|base_url|resolve_token|LANIS_API_BASE_URL|follow_redirects|AsyncClient|transport" lanis_mcp/client.py tests/test_lanis_mcp_client.py 2>/dev/null || true

Repository: joan-code6/lanis_api

Length of output: 8445


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Dependency/version references:\n'
git ls-files | rg '(^|/)(pyproject\.toml|requirements.*\.txt|poetry\.lock|uv\.lock|Pipfile\.lock|.*requirements.*\.txt)$' || true

for f in $(git ls-files | rg '(^|/)(pyproject\.toml|requirements.*\.txt|poetry\.lock|uv\.lock|Pipfile\.lock|.*requirements.*\.txt)$' 2>/dev/null); do
  printf '\n--- %s ---\n' "$f"
  rg -n "httpx" "$f" || true
done

printf '\nInspect test redirect/auth coverage:\n'
awk '{printf "%6d: %s\n", NR, $0}' tests/test_lanis_mcp_client.py | sed -n '1,120p'

printf '\nInspect dependency constraints:\n'
rg -n "httpx" pyproject.toml requirements.txt requirements*.txt poetry.lock uv.lock 2>/dev/null || true

Repository: joan-code6/lanis_api

Length of output: 5441


Send X-Session-Token only to redirects where the host matches base_url.

httpx strips Authorization on cross-origin redirects, but X-Session-Token is a custom credential header and is forwarded when the redirect target has a different host. This path runs with follow_redirects=True, and base_url can come from LANIS_API_BASE_URL, so a redirect can send the session token to an untrusted origin. Use manual redirect handling, or drop the token for cross-host redirects.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lanis_mcp/client.py` around lines 64 - 73, Update the request flow around the
AsyncClient in the client method to prevent X-Session-Token from being forwarded
to redirect targets whose host differs from base_url. Either implement manual
redirect handling that removes the header before cross-host requests, or
configure equivalent host-aware redirect behavior while preserving authenticated
same-host redirects and existing request functionality.

Comment thread lanis_mcp/client.py
Comment on lines +119 to +132
response = await client.get(path, headers=headers)

if response.is_error:
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text[:1000]
raise LanisAPIError(response.status_code, "GET", path, str(detail))
if max_bytes is not None and max_bytes < 1:
raise ValueError("max_bytes must be positive or null")
if max_bytes is not None and len(response.content) > max_bytes:
raise ValueError(
f"File is {len(response.content)} bytes, exceeding max_bytes={max_bytes}."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

max_bytes does not limit memory use because the body is fully buffered first.

client.get reads the complete response into memory. The size check at line 129 runs after that. A large course file therefore causes the same memory pressure with or without max_bytes, and the base64 encoding at line 144 adds about one third more. Callers cannot bound the download.

Stream the response and stop as soon as the limit is exceeded.

🛡️ Proposed fix using streaming with an early abort
-        path = f"/meinunterricht/file/{quote(file_hash, safe='')}"
-        async with httpx.AsyncClient(
-            base_url=self.base_url,
-            timeout=self.timeout,
-            follow_redirects=True,
-            transport=self.transport,
-        ) as client:
-            response = await client.get(path, headers=headers)
-
-        if response.is_error:
-            try:
-                detail = response.json().get("detail", response.text)
-            except ValueError:
-                detail = response.text[:1000]
-            raise LanisAPIError(response.status_code, "GET", path, str(detail))
-        if max_bytes is not None and max_bytes < 1:
-            raise ValueError("max_bytes must be positive or null")
-        if max_bytes is not None and len(response.content) > max_bytes:
-            raise ValueError(
-                f"File is {len(response.content)} bytes, exceeding max_bytes={max_bytes}."
-            )
+        if max_bytes is not None and max_bytes < 1:
+            raise ValueError("max_bytes must be positive or null")
+
+        path = f"/meinunterricht/file/{quote(file_hash, safe='')}"
+        async with httpx.AsyncClient(
+            base_url=self.base_url,
+            timeout=self.timeout,
+            follow_redirects=True,
+            transport=self.transport,
+        ) as client:
+            async with client.stream("GET", path, headers=headers) as response:
+                if response.is_error:
+                    await response.aread()
+                    try:
+                        detail = response.json().get("detail", response.text)
+                    except ValueError:
+                        detail = response.text[:1000]
+                    raise LanisAPIError(
+                        response.status_code, "GET", path, str(detail)
+                    )
+                chunks: list[bytes] = []
+                size = 0
+                async for chunk in response.aiter_bytes():
+                    size += len(chunk)
+                    if max_bytes is not None and size > max_bytes:
+                        raise ValueError(
+                            f"File exceeds max_bytes={max_bytes}."
+                        )
+                    chunks.append(chunk)
+                content = b"".join(chunks)
+                disposition = response.headers.get("content-disposition", "")
+                content_type = response.headers.get(
+                    "content-type", "application/octet-stream"
+                )

Then build the result from content, content_type, and disposition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lanis_mcp/client.py` around lines 119 - 132, Update the download logic in the
client method containing the shown response handling to stream the response body
instead of using the fully buffered client.get result. Accumulate chunks only
while max_bytes permits, abort as soon as the limit is exceeded, and preserve
HTTP error handling; then construct the result from the collected content,
content_type, and disposition.

Comment thread lanis_mcp/server.py
Comment on lines +102 to +135
class CredentialStore:
"""Keep login state isolated between stateful MCP client sessions."""

def __init__(self) -> None:
self._sessions: WeakKeyDictionary[object, SessionCredentials] = (
WeakKeyDictionary()
)
self._fallback = self._new_credentials()

@staticmethod
def _new_credentials() -> SessionCredentials:
return SessionCredentials(
access_token=os.getenv("LANIS_ACCESS_TOKEN"),
refresh_token=os.getenv("LANIS_REFRESH_TOKEN"),
)

def get(self, ctx: Context) -> SessionCredentials:
try:
session = ctx.session
credentials = self._sessions.get(session)
if credentials is None:
credentials = self._new_credentials()
self._sessions[session] = credentials
return credentials
except (TypeError, ValueError):
return self._fallback

def clear(self, ctx: Context) -> None:
credentials = self.get(ctx)
credentials.access_token = None
credentials.refresh_token = None


_credentials = CredentialStore()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not share one fallback credential object across sessions.

_fallback is a single process-wide SessionCredentials. get() returns it whenever ctx.session access raises TypeError or ValueError. Under the streamable-http transport, one process serves many users. If session resolution fails, user A's access token and refresh token are then used for user B's request.

Restrict the process-wide fallback to stdio, and return empty credentials for HTTP transports.

🔒 Proposed change
 class CredentialStore:
     """Keep login state isolated between stateful MCP client sessions."""
 
-    def __init__(self) -> None:
+    def __init__(self, *, allow_shared_fallback: bool = True) -> None:
         self._sessions: WeakKeyDictionary[object, SessionCredentials] = (
             WeakKeyDictionary()
         )
-        self._fallback = self._new_credentials()
+        self._allow_shared_fallback = allow_shared_fallback
+        self._fallback = self._new_credentials()
@@
-        except (TypeError, ValueError):
-            return self._fallback
+        except (TypeError, ValueError):
+            if self._allow_shared_fallback:
+                return self._fallback
+            return SessionCredentials()

Set allow_shared_fallback=False in main() when the selected transport is not stdio.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lanis_mcp/server.py` around lines 102 - 135, Update CredentialStore to avoid
sharing the process-wide _fallback for non-stdio transports: add an
allow_shared_fallback configuration, pass it from main() as true only for stdio
and false for other transports, and have get() return a new empty
SessionCredentials when session resolution fails and sharing is disabled.
Preserve the existing _fallback behavior for stdio.

Comment thread lanis_mcp/server.py
Comment on lines +211 to +218
new_access_token = result.get("access_token")
if not isinstance(new_access_token, str) or not new_access_token:
raise TypeError(
"The LANIS API refresh response did not contain an access token"
)
credentials.access_token = new_access_token
credentials.refresh_token = token
return result

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Serialize refresh calls and keep a rotated refresh token.

Two concerns exist in this block:

  1. Concurrent tool calls in one session can both receive 401 and both call POST /auth/refresh with the same refresh token. If the API rotates refresh tokens, the second call fails and the session loses authentication.
  2. Line 217 re-stores the request token. If the response contains a new refresh_token, that value is discarded.

Add an asyncio.Lock to SessionCredentials and refresh under that lock. Store the response refresh_token when the API returns one.

♻️ Proposed change for the token handling
     credentials.access_token = new_access_token
-    credentials.refresh_token = token
+    rotated = result.get("refresh_token")
+    credentials.refresh_token = rotated if isinstance(rotated, str) and rotated else token
     return result
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lanis_mcp/server.py` around lines 211 - 218, Add an asyncio.Lock field to
SessionCredentials and serialize the refresh request and credential updates
under it, rechecking or preserving the current token state for concurrent
callers. In the refresh handling around new_access_token, update
credentials.refresh_token from result’s refresh_token when it is present,
falling back to the request token only when absent; keep access-token validation
unchanged.

Comment thread pyproject.toml
Comment on lines +21 to +27
[project.scripts]
lanis-mcp = "lanis_mcp.server:main"

[project.optional-dependencies]
mcp = [
"httpx>=0.27.0",
"mcp>=1.27,<2",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -nP '^\s*(from|import)\s+(mcp|httpx)\b' lanis_mcp
rg -n 'lanis-mcp|optional-dependencies' pyproject.toml

Repository: joan-code6/lanis_api

Length of output: 400


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | sed -n '1,120p'

echo "== lanis_mcp/server.py outline =="
ast-grep outline lanis_mcp/server.py || true

echo "== lanis_mcp/server.py =="
cat -n lanis_mcp/server.py

echo "== pyproject relevant =="
cat -n pyproject.toml | sed -n '1,80p'

echo "== behavioral parse/import probe =="
python3 - <<'PY'
from pathlib import Path
import ast

for path in ["lanis_mcp/server.py", "lanis_mcp/client.py"]:
    text = Path(path).read_text()
    tree = ast.parse(text)
    imports = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                imports.append((node.lineno, "import", alias.name))
        elif isinstance(node, ast.ImportFrom):
            module = node.module or ""
            for alias in node.names:
                imports.append((node.lineno, "from", module, alias.name))

    print(f"== {path} ==")
    for item in imports:
        print(item)
    print("module-level mcp/httpx imports:", [item for item in imports if any(part in {"mcp","httpx"} for part in item)])
PY

Repository: joan-code6/lanis_api

Length of output: 45684


Fix the unconditional lanis-mcp entry point.

lanis-mcp = "lanis_mcp.server:main" is created for every pip install sph-client, but mcp and httpx are only in the optional mcp extra. lanis_mcp.server imports mcp at module load, and lanis_mcp.client imports httpx at module load, so a base install cannot load the CLI without installing the extra. Keep the CLI in metadata that requires mcp, or use lazy imports with a clear unavailable-dependency error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyproject.toml` around lines 21 - 27, Make the lanis-mcp entry point
conditional on the mcp optional dependency instead of exposing it from the
unconditional project.scripts metadata; place it in the dependency group that
installs both mcp and httpx, or otherwise ensure the entry point cannot load
without those dependencies and reports a clear unavailable-dependency error.

Comment thread pyproject.toml
Comment on lines +35 to +37
[tool.setuptools.packages.find]
include = ["api*", "lanis_mcp*", "schulportal_hessen*", "sph_client*"]
exclude = ["docs*", "scripts*", "skills*", "tests*", "tui*"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the package exclusion aligned with the documented skill link.

skills* is excluded from package discovery, but README.md and generated PKG-INFO link to skills/use-lanis-api. sph_client.egg-info/SOURCES.txt contains no corresponding skill file. Include the skill in the distribution or use an absolute repository or hosted-documentation link.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyproject.toml` around lines 35 - 37, Align package discovery with the
documented skill link by removing skills* from the exclude list so
skills/use-lanis-api is included in the distribution, or update the README.md
and generated PKG-INFO links to use an absolute repository or
hosted-documentation URL instead.

Comment thread README.md
I provide a hosted server of this for anyone to use at
[https://lanis-backend.joancode.dev/](https://lanis-backend.joancode.dev/)
If you wanna jump straigth in and not read too much code you can just have a look at the [api documentation](https://lanis-backend.joancode.dev/documentation) where you can see all endpoints and how to use them.
If you wanna jump straigth in and not read too much code you can just have a look at the [api documentation](https://lanis-backend.joancode.dev/documentation) where you can see all endpoints and how to use them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the spelling errors in the changed README text.

Use straight instead of straigth, and their instead of theire.

Proposed wording fix
-... jump straigth in ...
+... jump straight in ...

-... names to theire ids ...
+... names to their IDs ...

Also applies to: 39-39

🧰 Tools
🪛 LanguageTool

[grammar] ~14-~14: Ensure spelling is correct
Context: ...ackend.joancode.dev/) If you wanna jump straigth in and not read too much code you can j...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[locale-violation] ~14-~14: In American English, “take a look” is more commonly used.
Context: ...and not read too much code you can just have a look at the [api documentation](https://lani...

(HAVE_A_LOOK)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 14, Correct the spelling errors in the changed README
text: replace “straigth” with “straight” and “theire” with “their” at both
referenced occurrences, without changing the surrounding wording.

Source: Linters/SAST tools

Comment thread README.md
Comment on lines +95 to +98
Lanis lets an AI assistant interact with the user's Schulportal Hessen account through typed MCP
tools. Users do not need to know the LANIS REST API or pass an access token to every tool. The
server covers every user-facing REST operation and defaults to
`https://lanis-backend.joancode.dev`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Document the hosted-server trust boundary before using it as the default.

The documentation makes a hosted deployment the default and describes login, access-token, and refresh-token handling. A local lanis-mcp process can therefore send account and school data to that hosted service. State who operates the service, what data it retains, and how users can self-host or force a local backend. Change the default if remote processing is not an explicit opt-in.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 95 - 98, Update the README section describing the
default `https://lanis-backend.joancode.dev` backend to identify its operator,
disclose what account and school data it retains, and explain how users can
self-host or force a local backend. If remote processing is not explicitly
opt-in, change the documented default to local execution and clearly describe
how to opt into the hosted service.

Comment thread README.md
Comment on lines +100 to +101
```bash
pip install -e '.[mcp]'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files named README.md:\n'
fd -a '^README\.md$' . | sed 's#^\./##'

printf '\nREADME lines 80-115:\n'
READMES=$(fd '^README\.md$' .)
if [ -n "$READMES" ]; then
  for f in $READMES; do
    echo "--- $f"
    sed -n '80,115p' "$f" | nl -ba -v80
  done
fi

printf '\nPackage metadata candidates:\n'
fd '(^setup\.py$|setup\.cfg|pyproject\.toml|PKG-INFO$|pyproject\.toml|.*egg-info/.*PKG-INFO)' . | sed 's#^\./##' | sort

printf '\nSearch sph-client/mcp/pip install -e:\n'
rg -n "pip install -e|sph-client|mcp|Install|installation" pyproject.toml setup.py setup.cfg README.md . 2>/dev/null | head -n 200

Repository: joan-code6/lanis_api

Length of output: 322


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'README.md lines 80-115:\n'
sed -n '80,115p' README.md | awk '{printf "%5d: %s\n", NR+79, $0}'

printf '\nPackage metadata candidates:\n'
find . \( -name 'pyproject.toml' -o -name 'setup.py' -o -name 'setup.cfg' -o -name 'PKG-INFO' \) -print | sort

printf '\nRelevant package metadata:\n'
for f in $(find . \( -name 'pyproject.toml' -o -name 'setup.py' -o -name 'setup.cfg' -o -name 'PKG-INFO' \) -print | sort); do
  echo "--- $f"
  rg -n "name|version|dependencies|optional-dependencies|mcp|requires" "$f" || true
done

printf '\nSearch sph-client/mcp/pip install -e:\n'
rg -n "pip install -e|sph-client|mcp|Install|installation" README.md pyproject.toml setup.cfg setup.py . 2>/dev/null | head -n 200

Repository: joan-code6/lanis_api

Length of output: 11603


Provide a PyPI installation command for the MCP extra.

sph-client declares the mcp optional dependency, but the only MCP install command is editable/source-only. PyPI users also get this pip install -e '.[mcp]', which cannot run from PyPI.

Proposed installation documentation
+# From PyPI
+python -m pip install 'sph-client[mcp]'
+
+# From a source checkout
 python -m pip install -e '.[mcp]'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 100 - 101, Update the MCP installation instructions
in README.md to include a PyPI-compatible command that installs the published
sph-client package with its mcp extra, while retaining the existing
editable/source installation command if it serves local development.

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.

1 participant