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.
- eclass_mcp_server/__init__.py +12 -0
- eclass_mcp_server/authentication.py +197 -0
- eclass_mcp_server/course_management.py +82 -0
- eclass_mcp_server/html_parsing.py +163 -0
- eclass_mcp_server/server.py +237 -0
- eclass_mcp_server/test/__init__.py +1 -0
- eclass_mcp_server/test/run_all_tests.py +185 -0
- eclass_mcp_server/test/test_courses.py +92 -0
- eclass_mcp_server/test/test_login.py +87 -0
- iflow_mcp_sdi2200262_eclass_mcp_server-0.1.0.dist-info/METADATA +158 -0
- iflow_mcp_sdi2200262_eclass_mcp_server-0.1.0.dist-info/RECORD +14 -0
- iflow_mcp_sdi2200262_eclass_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- iflow_mcp_sdi2200262_eclass_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_sdi2200262_eclass_mcp_server-0.1.0.dist-info/licenses/LICENSE +637 -0
|
@@ -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."""
|