iflow-mcp_sdi2200262-eclass-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.
@@ -0,0 +1,12 @@
1
+ """eClass MCP Server - MCP integration for Open eClass platform."""
2
+
3
+ import asyncio
4
+
5
+ from . import server
6
+
7
+ __all__ = ['main', 'server']
8
+
9
+
10
+ def main() -> None:
11
+ """Main entry point for the package."""
12
+ asyncio.run(server.main())
@@ -0,0 +1,197 @@
1
+ """
2
+ Authentication module for eClass MCP Server.
3
+
4
+ Handles authentication with eClass through UoA's SSO (CAS) system.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING, Optional, Tuple
11
+ from urllib.parse import urlparse
12
+
13
+ import mcp.types as types
14
+ import requests
15
+
16
+ from . import html_parsing
17
+
18
+ if TYPE_CHECKING:
19
+ from .server import SessionState
20
+
21
+ logger = logging.getLogger('eclass_mcp_server.authentication')
22
+
23
+
24
+ def attempt_login(
25
+ session_state: SessionState, username: str, password: str
26
+ ) -> Tuple[bool, Optional[str]]:
27
+ """
28
+ Attempt to log in to eClass using the SSO authentication flow.
29
+
30
+ Returns:
31
+ Tuple of (success, error_message). On success, error_message is None.
32
+ """
33
+ try:
34
+ # Step 1: Visit the eClass login form page
35
+ response = session_state.session.get(session_state.login_form_url)
36
+ response.raise_for_status()
37
+
38
+ # Step 2: Find and follow the SSO login link
39
+ sso_link = html_parsing.extract_sso_link(response.text, session_state.base_url)
40
+ if not sso_link:
41
+ return False, "Could not find SSO login link on the login page"
42
+
43
+ response = session_state.session.get(sso_link)
44
+ response.raise_for_status()
45
+
46
+ # Step 3: Validate SSO redirect and extract CAS form data
47
+ if not _is_valid_sso_redirect(response.url, session_state):
48
+ return False, f"Unexpected redirect to {response.url}"
49
+
50
+ execution, action, error_text = html_parsing.extract_cas_form_data(
51
+ response.text, response.url, session_state.sso_base_url
52
+ )
53
+
54
+ if error_text and ('authenticate' in error_text.lower() or 'credentials' in error_text.lower()):
55
+ return False, f"Authentication error: {error_text}"
56
+ if not execution:
57
+ return False, "Could not find execution parameter on SSO page"
58
+ if not action:
59
+ return False, "Could not find login form on SSO page"
60
+
61
+ # Step 4: Submit credentials to CAS
62
+ login_data = {
63
+ 'username': username,
64
+ 'password': password,
65
+ 'execution': execution,
66
+ '_eventId': 'submit',
67
+ 'geolocation': ''
68
+ }
69
+
70
+ response = session_state.session.post(action, data=login_data)
71
+ response.raise_for_status()
72
+
73
+ # Check for authentication errors in response
74
+ if 'Πόροι Πληροφορικής ΕΚΠΑ' in response.text or \
75
+ 'The credentials you provided cannot be determined to be authentic' in response.text:
76
+ _, _, error_text = html_parsing.extract_cas_form_data(response.text, response.url)
77
+ if error_text:
78
+ return False, f"Authentication error: {error_text}"
79
+ return False, "Authentication failed: Invalid credentials"
80
+
81
+ logger.info("Successfully authenticated with SSO")
82
+
83
+ # Step 5: Verify login by accessing portfolio page
84
+ if session_state.eclass_domain not in response.url:
85
+ return False, f"Unexpected redirect after login: {response.url}"
86
+
87
+ response = session_state.session.get(session_state.portfolio_url)
88
+ response.raise_for_status()
89
+
90
+ if not html_parsing.verify_login_success(response.text):
91
+ return False, "Could not access portfolio page after login"
92
+
93
+ session_state.logged_in = True
94
+ session_state.username = username
95
+ logger.info("Login successful, redirected to eClass portfolio")
96
+ return True, None
97
+
98
+ except requests.RequestException as e:
99
+ logger.error(f"Request error during login: {e}")
100
+ return False, f"Network error during login process: {e}"
101
+ except Exception as e:
102
+ logger.error(f"Login error: {e}")
103
+ return False, f"Error during login process: {e}"
104
+
105
+
106
+ def _is_valid_sso_redirect(response_url: str, session_state: SessionState) -> bool:
107
+ """Check if the redirect is to a valid SSO domain."""
108
+ response_url_domain = urlparse(response_url).netloc
109
+ sso_domain_netloc = urlparse(session_state.sso_base_url).netloc
110
+
111
+ # Valid if: SSO domain in URL, exact domain match, or same domain for local testing
112
+ return (
113
+ session_state.sso_domain in response_url or
114
+ response_url_domain == sso_domain_netloc or
115
+ (session_state.eclass_domain == session_state.sso_domain and
116
+ session_state.eclass_domain in response_url)
117
+ )
118
+
119
+
120
+ def perform_logout(session_state: SessionState) -> Tuple[bool, Optional[str]]:
121
+ """
122
+ Log out from eClass.
123
+
124
+ Returns:
125
+ Tuple of (success, username_or_error).
126
+ On success with prior login: (True, username)
127
+ On success without prior login: (True, None)
128
+ On failure: (False, error_message)
129
+ """
130
+ if not session_state.logged_in:
131
+ return True, None
132
+
133
+ try:
134
+ response = session_state.session.get(session_state.logout_url)
135
+ response.raise_for_status()
136
+
137
+ username = session_state.username
138
+ session_state.reset()
139
+ return True, username
140
+ except Exception as e:
141
+ logger.error(f"Logout error: {e}")
142
+ return False, f"Error during logout: {e}"
143
+
144
+
145
+ def format_login_response(
146
+ success: bool, message: Optional[str], username: Optional[str] = None
147
+ ) -> types.TextContent:
148
+ """Format login response for MCP."""
149
+ if success:
150
+ return types.TextContent(
151
+ type="text",
152
+ text=f"Login successful! You are now logged in as {username}.",
153
+ )
154
+ return types.TextContent(
155
+ type="text",
156
+ text=f"Error: {message}",
157
+ )
158
+
159
+
160
+ def format_logout_response(
161
+ success: bool, username_or_error: Optional[str]
162
+ ) -> types.TextContent:
163
+ """Format logout response for MCP."""
164
+ if success:
165
+ if username_or_error:
166
+ return types.TextContent(
167
+ type="text",
168
+ text=f"Successfully logged out user {username_or_error}.",
169
+ )
170
+ return types.TextContent(
171
+ type="text",
172
+ text="Not logged in, nothing to do.",
173
+ )
174
+ return types.TextContent(
175
+ type="text",
176
+ text=f"Error during logout: {username_or_error}",
177
+ )
178
+
179
+
180
+ def format_authstatus_response(session_state: SessionState) -> types.TextContent:
181
+ """Format authentication status response for MCP."""
182
+ if not session_state.logged_in:
183
+ return types.TextContent(
184
+ type="text",
185
+ text="Status: Not logged in",
186
+ )
187
+
188
+ if session_state.is_session_valid():
189
+ return types.TextContent(
190
+ type="text",
191
+ text=f"Status: Logged in as {session_state.username}\nCourses: {len(session_state.courses)} enrolled",
192
+ )
193
+
194
+ return types.TextContent(
195
+ type="text",
196
+ text="Status: Session expired. Please log in again.",
197
+ )
@@ -0,0 +1,82 @@
1
+ """
2
+ Course management module for eClass MCP Server.
3
+
4
+ Handles retrieval and formatting of course information from eClass.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
11
+
12
+ import mcp.types as types
13
+ import requests
14
+
15
+ from . import html_parsing
16
+
17
+ if TYPE_CHECKING:
18
+ from .server import SessionState
19
+
20
+ logger = logging.getLogger('eclass_mcp_server.course_management')
21
+
22
+
23
+ def get_courses(
24
+ session_state: SessionState
25
+ ) -> Tuple[bool, Optional[str], Optional[List[Dict[str, str]]]]:
26
+ """
27
+ Retrieve a list of enrolled courses from eClass.
28
+
29
+ Returns:
30
+ Tuple of (success, message, courses).
31
+ On success: (True, None, courses_list)
32
+ On empty result: (True, message, [])
33
+ On failure: (False, error_message, None)
34
+ """
35
+ if not session_state.logged_in:
36
+ return False, "Not logged in. Please log in first using the login tool.", None
37
+
38
+ if not session_state.is_session_valid():
39
+ return False, "Session expired. Please log in again.", None
40
+
41
+ try:
42
+ response = session_state.session.get(session_state.portfolio_url)
43
+ response.raise_for_status()
44
+
45
+ courses = html_parsing.extract_courses(response.text, session_state.base_url)
46
+ session_state.courses = courses
47
+
48
+ if not courses:
49
+ return True, "No courses found. You may not be enrolled in any courses.", []
50
+
51
+ logger.info(f"Successfully retrieved {len(courses)} courses")
52
+ return True, None, courses
53
+
54
+ except requests.RequestException as e:
55
+ logger.error(f"Network error getting courses: {e}")
56
+ return False, f"Network error retrieving courses: {e}", None
57
+ except Exception as e:
58
+ logger.error(f"Error getting courses: {e}")
59
+ return False, f"Error retrieving courses: {e}", None
60
+
61
+
62
+ def format_courses_response(
63
+ success: bool, message: Optional[str], courses: Optional[List[Dict[str, str]]]
64
+ ) -> types.TextContent:
65
+ """Format course list response for MCP."""
66
+ if not success:
67
+ return types.TextContent(
68
+ type="text",
69
+ text=f"Error: {message}",
70
+ )
71
+
72
+ if message: # No courses found message
73
+ return types.TextContent(
74
+ type="text",
75
+ text=message,
76
+ )
77
+
78
+ course_list = html_parsing.format_course_list(courses)
79
+ return types.TextContent(
80
+ type="text",
81
+ text=f"Found {len(courses)} courses:\n\n{course_list}",
82
+ )
@@ -0,0 +1,163 @@
1
+ """
2
+ HTML parsing utilities for eClass MCP Server.
3
+
4
+ Uses BeautifulSoup to extract data from eClass and CAS SSO HTML responses.
5
+ """
6
+
7
+ import logging
8
+ from typing import Dict, List, Optional, Tuple
9
+
10
+ from bs4 import BeautifulSoup
11
+
12
+ logger = logging.getLogger('eclass_mcp_server.html_parsing')
13
+
14
+
15
+ def extract_sso_link(html_content: str, base_url: str) -> Optional[str]:
16
+ """
17
+ Extract the SSO login link from the eClass login page.
18
+
19
+ Returns:
20
+ Absolute SSO login URL, or None if not found.
21
+ """
22
+ soup = BeautifulSoup(html_content, 'html.parser')
23
+ sso_link = None
24
+
25
+ # Look for UoA login button
26
+ for link in soup.find_all('a'):
27
+ if 'Είσοδος με λογαριασμό ΕΚΠΑ' in link.text or 'ΕΚΠΑ' in link.text:
28
+ sso_link = link.get('href')
29
+ break
30
+
31
+ # Fallback: look for CAS form action
32
+ if not sso_link:
33
+ for form in soup.find_all('form'):
34
+ if form.get('action') and 'cas.php' in form.get('action'):
35
+ sso_link = form.get('action')
36
+ break
37
+
38
+ if not sso_link:
39
+ logger.warning("Could not find SSO login link on the login page")
40
+ return None
41
+
42
+ # Ensure URL is absolute
43
+ if not sso_link.startswith(('http://', 'https://')):
44
+ sso_link = f"{base_url}/{sso_link.lstrip('/')}"
45
+
46
+ logger.debug(f"Extracted SSO link: {sso_link}")
47
+ return sso_link
48
+
49
+
50
+ def extract_cas_form_data(
51
+ html_content: str,
52
+ current_url: str,
53
+ sso_base_url: str = "https://sso.uoa.gr"
54
+ ) -> Tuple[Optional[str], Optional[str], Optional[str]]:
55
+ """
56
+ Extract execution parameter and form action from CAS login page.
57
+
58
+ Returns:
59
+ Tuple of (execution_value, form_action, error_message).
60
+ On parsing failure, appropriate values will be None.
61
+ """
62
+ soup = BeautifulSoup(html_content, 'html.parser')
63
+
64
+ # Check for error message
65
+ error_msg = soup.find('div', {'id': 'msg'})
66
+ error_text = error_msg.text.strip() if error_msg else None
67
+
68
+ # Find execution token
69
+ execution_input = soup.find('input', {'name': 'execution'})
70
+ if not execution_input:
71
+ logger.warning("Could not find execution parameter on SSO page")
72
+ return None, None, error_text or "Could not find execution parameter on SSO page"
73
+
74
+ execution = execution_input.get('value')
75
+
76
+ # Find login form
77
+ form = soup.find('form', {'id': 'fm1'}) or soup.find('form')
78
+ if not form:
79
+ logger.warning("Could not find login form on SSO page")
80
+ return execution, None, error_text or "Could not find login form on SSO page"
81
+
82
+ action = form.get('action')
83
+ if not action:
84
+ action = current_url
85
+ elif not action.startswith(('http://', 'https://')):
86
+ action = f"{sso_base_url}/{action.lstrip('/')}"
87
+
88
+ return execution, action, error_text
89
+
90
+
91
+ def verify_login_success(html_content: str) -> bool:
92
+ """
93
+ Verify if login was successful by checking for portfolio page content.
94
+ """
95
+ return ('Μαθήματα' in html_content or
96
+ 'portfolio' in html_content.lower() or
97
+ 'course' in html_content.lower())
98
+
99
+
100
+ def extract_courses(html_content: str, base_url: str) -> List[Dict[str, str]]:
101
+ """
102
+ Extract course information from the portfolio page.
103
+
104
+ Returns:
105
+ List of dicts with 'name' and 'url' keys.
106
+ """
107
+ courses = []
108
+ soup = BeautifulSoup(html_content, 'html.parser')
109
+
110
+ # Try specific course selectors
111
+ course_elements = (
112
+ soup.select('.course-title') or
113
+ soup.select('.lesson-title') or
114
+ soup.select('.course-box .title') or
115
+ soup.select('.course-info h4')
116
+ )
117
+
118
+ if course_elements:
119
+ for course_elem in course_elements:
120
+ course_link = course_elem.find('a') or course_elem
121
+ if course_link and course_link.get('href'):
122
+ course_name = course_link.text.strip()
123
+ course_url = course_link.get('href')
124
+ if course_name:
125
+ courses.append({
126
+ 'name': course_name,
127
+ 'url': _make_absolute_url(course_url, base_url)
128
+ })
129
+ else:
130
+ # Fallback: find course links by URL pattern
131
+ for link in soup.find_all('a'):
132
+ href = link.get('href', '')
133
+ if 'courses' in href or 'course.php' in href:
134
+ course_name = link.text.strip()
135
+ if course_name:
136
+ courses.append({
137
+ 'name': course_name,
138
+ 'url': _make_absolute_url(href, base_url)
139
+ })
140
+
141
+ logger.debug(f"Extracted {len(courses)} courses")
142
+ return courses
143
+
144
+
145
+ def _make_absolute_url(url: str, base_url: str) -> str:
146
+ """Convert relative URL to absolute URL."""
147
+ if url.startswith(('http://', 'https://')):
148
+ return url
149
+ return f"{base_url}/{url.lstrip('/')}"
150
+
151
+
152
+ def format_course_list(courses: List[Dict[str, str]]) -> str:
153
+ """
154
+ Format course list for display.
155
+
156
+ Returns:
157
+ Formatted string with numbered courses and URLs.
158
+ """
159
+ lines = []
160
+ for i, course in enumerate(courses, 1):
161
+ lines.append(f"{i}. {course['name']}")
162
+ lines.append(f" URL: {course['url']}")
163
+ return "\n".join(lines)
@@ -0,0 +1,237 @@
1
+ """
2
+ eClass MCP Server - MCP Integration for Open eClass Platform
3
+
4
+ Provides an MCP server for interacting with eClass through UoA's SSO authentication.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import os
10
+ from typing import Any, Dict, List
11
+ from urllib.parse import urlparse
12
+
13
+ import requests
14
+ from dotenv import load_dotenv
15
+ from mcp.server.lowlevel import NotificationOptions, Server
16
+ from mcp.server.models import InitializationOptions
17
+ import mcp.server.stdio
18
+ import mcp.types as types
19
+
20
+ from . import authentication
21
+ from . import course_management
22
+ from . import html_parsing
23
+
24
+ logging.basicConfig(
25
+ level=logging.INFO,
26
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
27
+ )
28
+ logger = logging.getLogger('eclass_mcp_server')
29
+
30
+ server = Server("eclass-mcp")
31
+
32
+
33
+ class SessionState:
34
+ """Maintains authentication state between MCP tool calls."""
35
+
36
+ def __init__(self) -> None:
37
+ # Load .env from project root
38
+ script_dir = os.path.dirname(os.path.abspath(__file__))
39
+ project_root = os.path.dirname(os.path.dirname(script_dir))
40
+ env_path = os.path.join(project_root, '.env')
41
+ load_dotenv(env_path, override=False)
42
+
43
+ self.session = requests.Session()
44
+ self.logged_in = False
45
+
46
+ # Base URL configuration
47
+ self.base_url = os.getenv('ECLASS_URL', 'https://eclass.uoa.gr').rstrip('/')
48
+ self.eclass_domain = urlparse(self.base_url).netloc
49
+
50
+ # SSO configuration
51
+ self.sso_domain = os.getenv('ECLASS_SSO_DOMAIN', 'sso.uoa.gr')
52
+ sso_protocol = os.getenv('ECLASS_SSO_PROTOCOL', 'https')
53
+ self.sso_base_url = f"{sso_protocol}://{self.sso_domain}"
54
+
55
+ # eClass endpoint URLs
56
+ self.login_form_url = f"{self.base_url}/main/login_form.php"
57
+ self.portfolio_url = f"{self.base_url}/main/portfolio.php"
58
+ self.logout_url = f"{self.base_url}/index.php?logout=yes"
59
+
60
+ self.username: str | None = None
61
+ self.courses: List[Dict[str, str]] = []
62
+
63
+ logger.info(f"Initialized eClass session for {self.base_url} (SSO: {self.sso_domain})")
64
+
65
+ def is_session_valid(self) -> bool:
66
+ """Check if the current session is still valid."""
67
+ if not self.logged_in:
68
+ return False
69
+
70
+ try:
71
+ response = self.session.get(self.portfolio_url, allow_redirects=False)
72
+ if response.status_code == 302 and 'login' in response.headers.get('Location', ''):
73
+ self.logged_in = False
74
+ return False
75
+ if response.status_code == 200 and html_parsing.verify_login_success(response.text):
76
+ return True
77
+ self.logged_in = False
78
+ return False
79
+ except Exception:
80
+ self.logged_in = False
81
+ return False
82
+
83
+ def reset(self) -> None:
84
+ """Reset the session state."""
85
+ self.session = requests.Session()
86
+ self.logged_in = False
87
+ self.username = None
88
+ self.courses = []
89
+
90
+
91
+ session_state = SessionState()
92
+
93
+
94
+ @server.list_tools()
95
+ async def handle_list_tools() -> list[types.Tool]:
96
+ """List available eClass tools."""
97
+ return [
98
+ types.Tool(
99
+ name="login",
100
+ description="Log in to eClass using username/password from your .env file through UoA's SSO. Configure ECLASS_USERNAME and ECLASS_PASSWORD in your .env file.",
101
+ inputSchema={
102
+ "type": "object",
103
+ "properties": {
104
+ "random_string": {
105
+ "type": "string",
106
+ "description": "Dummy parameter for no-parameter tools"
107
+ },
108
+ },
109
+ "required": ["random_string"],
110
+ },
111
+ ),
112
+ types.Tool(
113
+ name="get_courses",
114
+ description="Get list of enrolled courses from eClass",
115
+ inputSchema={
116
+ "type": "object",
117
+ "properties": {
118
+ "random_string": {
119
+ "type": "string",
120
+ "description": "Dummy parameter for no-parameter tools"
121
+ },
122
+ },
123
+ "required": ["random_string"],
124
+ },
125
+ ),
126
+ types.Tool(
127
+ name="logout",
128
+ description="Log out from eClass",
129
+ inputSchema={
130
+ "type": "object",
131
+ "properties": {
132
+ "random_string": {
133
+ "type": "string",
134
+ "description": "Dummy parameter for no-parameter tools"
135
+ },
136
+ },
137
+ "required": ["random_string"],
138
+ },
139
+ ),
140
+ types.Tool(
141
+ name="authstatus",
142
+ description="Check authentication status with eClass",
143
+ inputSchema={
144
+ "type": "object",
145
+ "properties": {
146
+ "random_string": {
147
+ "type": "string",
148
+ "description": "Dummy parameter for no-parameter tools"
149
+ },
150
+ },
151
+ "required": ["random_string"],
152
+ },
153
+ ),
154
+ ]
155
+
156
+ @server.call_tool()
157
+ async def handle_call_tool(
158
+ name: str, arguments: Dict[str, Any] | None
159
+ ) -> List[types.TextContent | types.ImageContent | types.EmbeddedResource]:
160
+ """Handle eClass tool execution requests."""
161
+ if name == "login":
162
+ return await handle_login()
163
+ elif name == "get_courses":
164
+ return await handle_get_courses()
165
+ elif name == "logout":
166
+ return await handle_logout()
167
+ elif name == "authstatus":
168
+ return await handle_authstatus()
169
+ else:
170
+ raise ValueError(f"Unknown tool: {name}")
171
+
172
+
173
+ async def handle_login() -> List[types.TextContent]:
174
+ """Handle login to eClass."""
175
+ if session_state.logged_in and session_state.is_session_valid():
176
+ return [
177
+ types.TextContent(
178
+ type="text",
179
+ text=f"Already logged in as {session_state.username}",
180
+ )
181
+ ]
182
+
183
+ if session_state.logged_in and not session_state.is_session_valid():
184
+ session_state.reset()
185
+
186
+ username = os.getenv('ECLASS_USERNAME')
187
+ password = os.getenv('ECLASS_PASSWORD')
188
+
189
+ if not username or not password:
190
+ return [
191
+ types.TextContent(
192
+ type="text",
193
+ text="Error: Username and password must be provided in the .env file. Please set ECLASS_USERNAME and ECLASS_PASSWORD in your .env file.",
194
+ )
195
+ ]
196
+
197
+ logger.info(f"Attempting to log in as {username}")
198
+ success, message = authentication.attempt_login(session_state, username, password)
199
+ return [authentication.format_login_response(success, message, username if success else None)]
200
+
201
+
202
+ async def handle_get_courses() -> List[types.TextContent]:
203
+ """Handle getting the list of enrolled courses."""
204
+ success, message, courses = course_management.get_courses(session_state)
205
+ return [course_management.format_courses_response(success, message, courses)]
206
+
207
+
208
+ async def handle_logout() -> List[types.TextContent]:
209
+ """Handle logout from eClass."""
210
+ success, username_or_error = authentication.perform_logout(session_state)
211
+ return [authentication.format_logout_response(success, username_or_error)]
212
+
213
+
214
+ async def handle_authstatus() -> List[types.TextContent]:
215
+ """Handle checking authentication status."""
216
+ return [authentication.format_authstatus_response(session_state)]
217
+
218
+
219
+ async def main() -> None:
220
+ """Run the MCP server."""
221
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
222
+ await server.run(
223
+ read_stream,
224
+ write_stream,
225
+ InitializationOptions(
226
+ server_name="eclass-mcp",
227
+ server_version="0.1.0",
228
+ capabilities=server.get_capabilities(
229
+ notification_options=NotificationOptions(),
230
+ experimental_capabilities={},
231
+ ),
232
+ ),
233
+ )
234
+
235
+
236
+ if __name__ == "__main__":
237
+ asyncio.run(main())
@@ -0,0 +1 @@
1
+ """Test package for eClass MCP Server."""