init
This commit is contained in:
20
README.md
20
README.md
@@ -15,22 +15,18 @@
|
||||
| `search_course` | 搜索课程信息 | `search_course("数据结构")` |
|
||||
| `get_course_schedule` | 获取学生课表 | `get_course_schedule("B21010101", "2024-2025-1")` |
|
||||
| `search_library_book` | 搜索图书馆藏书 | `search_library_book("Python", "title")` |
|
||||
| `get_campus_info` | 获取校园信息 | `get_campus_info("contacts")` |
|
||||
|
||||
### 📚 Resources(资源)
|
||||
|
||||
| 资源 URI | 描述 |
|
||||
|----------|------|
|
||||
| `njupt://announcements` | 最新公告列表 |
|
||||
| `njupt://academic-calendar` | 校历信息 |
|
||||
| `njupt://departments` | 学院/部门列表 |
|
||||
| 资源 URI | 描述 |
|
||||
|--------|-----|
|
||||
| `...` | ... |
|
||||
|
||||
### 💬 Prompts(提示词模板)
|
||||
|
||||
| Prompt | 用途 |
|
||||
|--------|------|
|
||||
| `academic_advisor_query` | 学业咨询助手 |
|
||||
| `campus_guide_query` | 校园导航助手 |
|
||||
| Prompt | 用途 |
|
||||
|--------|-----|
|
||||
| `...` | ... |
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -68,13 +64,13 @@ python -m njupt_mcp.server
|
||||
#### 方式二:SSE 模式(网络服务)
|
||||
|
||||
```bash
|
||||
uv run njupt-mcp --transport sse --host 0.0.0.0 --port 8000
|
||||
uv run njupt-mcp --transport sse
|
||||
```
|
||||
|
||||
#### 方式三:streamable-http 模式
|
||||
|
||||
```bash
|
||||
uv run njupt-mcp --transport streamable-http --port 8000
|
||||
uv run njupt-mcp --transport streamable-http
|
||||
```
|
||||
|
||||
## ⚙️ 客户端配置
|
||||
|
||||
@@ -18,9 +18,13 @@ classifiers = [
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
dependencies = [
|
||||
"beautifulsoup4>=4.14.3",
|
||||
"fastapi>=0.135.2",
|
||||
"httpx>=0.27.0",
|
||||
"mcp[cli]>=1.7.0",
|
||||
"pydantic>=2.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
"uvicorn>=0.42.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
32
src/njupt_mcp/resources/course_schedule/B240423-22.html
Normal file
32
src/njupt_mcp/resources/course_schedule/B240423-22.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<table id="Table6" class="blacktab" rules="all" border="1" height="132" width="100%">
|
||||
<tbody><tr>
|
||||
<td colspan="2" rowspan="1" width="2%">时间</td><td align="Center" width="14%">星期一</td><td align="Center" width="14%">星期二</td><td align="Center" width="14%">星期三</td><td align="Center" width="14%">星期四</td><td align="Center" width="14%">星期五</td><td align="Center" width="14%">星期六</td><td align="Center" width="14%">星期日</td>
|
||||
</tr><tr>
|
||||
<td colspan="2">早晨</td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td>
|
||||
</tr><tr>
|
||||
<td rowspan="5" width="1%">上午</td><td width="1%">第1节</td><td align="Center" rowspan="2" width="7%">计算机问题求解:使用算法(混合式)<br>1-17(1,2)<br>毛毅<br>教4-312<br></td><td align="Center" width="7%"> </td><td align="Center" width="7%"> </td><td align="Center" rowspan="2" width="7%">人工智能导论及其Python应用实践<br>1-17(1,2)<br>李平<br>教3-208<br></td><td align="Center" rowspan="2" width="7%">概率论与数理统计<br>1-17单(1,2)<br>王雪红<br>教3-520<br></td><td align="Center" width="7%"> </td><td align="Center" width="7%"> </td>
|
||||
</tr><tr>
|
||||
<td>第2节</td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td>
|
||||
</tr><tr>
|
||||
<td>第3节</td><td align="Center" rowspan="2">计算机系统基础Ⅰ(混合式)<br>1-17(3,4)<br>李维维<br>教4-312<br></td><td align="Center" rowspan="2">概率论与数理统计<br>1-17(3,4)<br>王雪红<br>教3-520<br></td><td align="Center" rowspan="2">操作系统基础(混合式)<br>1-17(3,4)<br>王波(女)<br>教3-511<br></td><td align="Center" rowspan="2">大学英语IV<br>2节/双周<br> <br> <br><br><br>大学英语IV<br>2节/单周<br> <br> <br></td><td align="Center" rowspan="2">计算机系统基础Ⅰ(混合式)<br>2-16双(3,4)<br>李维维<br>教3-512<br><br><br>操作系统基础(混合式)<br>1-17单(3,4)<br>王波(女)<br>教3-201<br></td><td align="Center"> </td><td align="Center"> </td>
|
||||
</tr><tr>
|
||||
<td>第4节</td><td align="Center"> </td><td align="Center"> </td>
|
||||
</tr><tr>
|
||||
<td>第5节</td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td>
|
||||
</tr><tr>
|
||||
<td rowspan="4">下午</td><td>第6节</td><td align="Center" rowspan="2">数据库系统基础<br>1-17(6,7)<br>黄楠<br>教4-102<br><br><br>数据库系统基础<br>1-17(6,7)<br>李博文<br>教4-102<br></td><td align="Center" rowspan="2">人工智能导论及其Python应用实践<br>1-17单(6,7)<br>李平<br>教3-408<br><br><br>大学英语IV<br>2节/双周<br> <br> <br></td><td align="Center"> </td><td align="Center"> </td><td align="Center" rowspan="2">体育 IV<br>2节/周<br> <br> <br></td><td align="Center"> </td><td align="Center"> </td>
|
||||
</tr><tr>
|
||||
<td>第7节</td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td>
|
||||
</tr><tr>
|
||||
<td>第8节</td><td align="Center" rowspan="2">数学建模<br>1-17(8,9)<br>王敏敏<br>教3-102<br></td><td align="Center" rowspan="2">现代管理科学基础<br>1-17(8,9)<br>葛伟<br>教2-304<br></td><td align="Center" rowspan="2">中国文化概论<br>1-17(8,9)<br>葛辉<br>教4-101<br></td><td align="Center" rowspan="2">计算机问题求解:使用算法(混合式)<br>2-16双(8,9)<br>毛毅<br>教3-310<br></td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td>
|
||||
</tr><tr>
|
||||
<td>第9节</td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td>
|
||||
</tr><tr>
|
||||
<td rowspan="3">晚上</td><td>第10节</td><td align="Center" rowspan="3">形势与政策IV<br>6-8(10,11,12)<br>陈骏<br>教4-101<br></td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td>
|
||||
</tr><tr>
|
||||
<td>第11节</td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td>
|
||||
</tr><tr>
|
||||
<td>第12节</td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td><td align="Center"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
1
src/njupt_mcp/resources/types/__init__.py
Normal file
1
src/njupt_mcp/resources/types/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .course import Course, course_dict_serializer
|
||||
34
src/njupt_mcp/resources/types/course.py
Normal file
34
src/njupt_mcp/resources/types/course.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Course:
|
||||
"""
|
||||
Course 是对课程表中的 **某一节课** 的抽象。
|
||||
|
||||
Examples:
|
||||
1-17周,星期一,1-2节,数据结构,是一个 Course 对象;
|
||||
|
||||
1-17周,星期三,3-4节,数据结构,是另一个 Course 对象;
|
||||
|
||||
1-17周中的单周,星期四,3-4节,英语,是一个 Course 对象;
|
||||
|
||||
1-17周中的双周,星期四,3-4节,物理,是另一个 Course 对象。
|
||||
"""
|
||||
name: str
|
||||
weeks: list[int]
|
||||
day: int
|
||||
classes: list[int]
|
||||
teacher: str | None
|
||||
classroom: str | None
|
||||
|
||||
|
||||
def course_dict_serializer(course: Course) -> dict[str, str | list[int] | int | None]:
|
||||
return {
|
||||
"name": course.name,
|
||||
"weeks": course.weeks,
|
||||
"day": course.day,
|
||||
"classes": course.classes,
|
||||
"teacher": course.teacher,
|
||||
"classroom": course.classroom,
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
"""NJUPT MCP Server 主入口
|
||||
|
||||
基于 FastMCP 实现的南京邮电大学 MCP 服务器。
|
||||
基于 FastMCP + FastAPI 实现的南京邮电大学 MCP 服务器。
|
||||
支持 stdio 和 SSE 两种传输方式。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.fastmcp.server import Settings
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from njupt_mcp.resources.types import course_dict_serializer
|
||||
from njupt_mcp.tools import create_course_schedule
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
@@ -21,14 +27,24 @@ logging.basicConfig(
|
||||
logger = logging.getLogger("njupt-mcp")
|
||||
|
||||
|
||||
def findHtml():
|
||||
load_dotenv()
|
||||
html_path = os.environ.get('COURSE_SCHEDULE')
|
||||
if html_path is None:
|
||||
logger.error('未知课表 HTML 的位置,调用错误。')
|
||||
logger.error('回退到芒果帆帆的大二下学期课表~')
|
||||
html_path = './src/njupt_mcp/resources/course_schedule/B240423-22.html'
|
||||
return html_path
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(server: FastMCP) -> AsyncIterator[dict]:
|
||||
"""应用生命周期管理
|
||||
|
||||
|
||||
处理服务器启动和关闭时的资源初始化和清理。
|
||||
"""
|
||||
logger.info("🚀 NJUPT MCP Server 启动中...")
|
||||
|
||||
|
||||
# 初始化共享资源(如数据库连接、HTTP 客户端等)
|
||||
try:
|
||||
# 这里可以初始化一些全局资源
|
||||
@@ -44,166 +60,150 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[dict]:
|
||||
logger.info("✅ 资源清理完成")
|
||||
|
||||
|
||||
# 创建 MCP 服务器实例
|
||||
# lifespan: 应用生命周期管理
|
||||
mcp = FastMCP(
|
||||
"njupt-mcp",
|
||||
lifespan=app_lifespan,
|
||||
# 1. 创建标准的 FastMCP 实例
|
||||
mcp = FastMCP("njupt-mcp", lifespan=app_lifespan)
|
||||
|
||||
# 2. 创建 FastAPI 实例并配置 CORS
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 允许所有来源,方便 Inspector 连接
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 3. 将 FastMCP 的 SSE 应用挂载到 FastAPI
|
||||
# sse_app() 返回 Starlette ASGI 应用,内部路由为 /sse 和 /messages
|
||||
# 挂载到根路径,使 SSE 端点可直接通过 /sse 访问
|
||||
app.mount("/", mcp.sse_app())
|
||||
|
||||
|
||||
# ==================== Tools ====================
|
||||
|
||||
@mcp.tool()
|
||||
async def search_course(keyword: str, limit: int = 10) -> str:
|
||||
"""搜索南京邮电大学课程信息
|
||||
|
||||
根据关键词搜索课程名称、课程代码、开课学院等信息。
|
||||
|
||||
Args:
|
||||
keyword: 搜索关键词(课程名称、课程代码等)
|
||||
limit: 返回结果数量限制,默认 10 条
|
||||
|
||||
@mcp.tool(
|
||||
name="get_course_schedule_json",
|
||||
title="获取课表 JSON",
|
||||
description="以 JSON 格式获取整学期整周全部课表数据",
|
||||
annotations=ToolAnnotations(
|
||||
title="获取课表 JSON",
|
||||
readOnlyHint=True, # 只读取数据,不修改
|
||||
destructiveHint=False, # 非破坏性操作
|
||||
idempotentHint=True, # 幂等:重复调用结果相同
|
||||
openWorldHint=False, # 不依赖外部世界状态
|
||||
),
|
||||
)
|
||||
async def get_course_schedule_json() -> list[dict]:
|
||||
"""以 JSON 格式获取整学期整周全部课表
|
||||
|
||||
Returns:
|
||||
课程信息列表的 JSON 字符串
|
||||
|
||||
Example:
|
||||
search_course("数据结构") -> 返回数据结构相关课程
|
||||
search_course("CS101", limit=5) -> 返回课程代码包含 CS101 的课程
|
||||
课程列表,每个课程包含以下字段:
|
||||
- name: 课程名称
|
||||
- teacher: 授课教师(可能为 None)
|
||||
- classroom: 教室(可能为 None)
|
||||
- weeks: 周数,list[int]
|
||||
- day: 星期几,int
|
||||
- classes: 当日第几节课,list[int]
|
||||
失败时返回空列表
|
||||
"""
|
||||
# TODO: 实现实际的课程搜索逻辑
|
||||
# 这里返回示例数据
|
||||
courses = [
|
||||
{
|
||||
"course_code": "CS2101",
|
||||
"name": f"{keyword}基础",
|
||||
"credit": 3.0,
|
||||
"department": "计算机学院",
|
||||
"description": f"这是一门关于{keyword}的基础课程",
|
||||
},
|
||||
{
|
||||
"course_code": "CS3102",
|
||||
"name": f"高级{keyword}",
|
||||
"credit": 4.0,
|
||||
"department": "计算机学院",
|
||||
"description": f"深入探讨{keyword}的高级主题",
|
||||
},
|
||||
]
|
||||
|
||||
import json
|
||||
return json.dumps(courses[:limit], ensure_ascii=False, indent=2)
|
||||
html_path = findHtml()
|
||||
if html_path is None:
|
||||
return []
|
||||
|
||||
courses = create_course_schedule(html_path)
|
||||
logger.debug('解析得到的课表如下:')
|
||||
logger.debug(courses)
|
||||
|
||||
final_courses = []
|
||||
for course in courses:
|
||||
final_courses.append(course_dict_serializer(course))
|
||||
return final_courses
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_course_schedule(student_id: str, semester: str = "2024-2025-1") -> str:
|
||||
"""获取学生课表信息
|
||||
|
||||
根据学号和学期获取个人课表。
|
||||
|
||||
@mcp.tool(
|
||||
name="get_week_course_schedule_json",
|
||||
title="获取指定周课表",
|
||||
description="以 JSON 格式获取指定教学周的全部课表数据",
|
||||
annotations=ToolAnnotations(
|
||||
title="获取指定周课表",
|
||||
readOnlyHint=True, # 只读取数据,不修改
|
||||
destructiveHint=False, # 非破坏性操作
|
||||
idempotentHint=True, # 幂等:重复调用结果相同
|
||||
openWorldHint=False, # 不依赖外部世界状态
|
||||
),
|
||||
)
|
||||
async def get_week_course_schedule_json(week: int) -> list[dict]:
|
||||
"""以 JSON 格式获取指定教学周的全部课表
|
||||
|
||||
Args:
|
||||
student_id: 学生学号(如 B21010101)
|
||||
semester: 学期代码,格式为 "YYYY-YYYY-S"(如 2024-2025-1)
|
||||
|
||||
week: 教学周数,范围通常为 1-20
|
||||
|
||||
Returns:
|
||||
课表信息的 JSON 字符串
|
||||
|
||||
Example:
|
||||
get_course_schedule("B21010101", "2024-2025-1")
|
||||
指定周的课程列表,每个课程包含以下字段:
|
||||
- name: 课程名称
|
||||
- teacher: 授课教师(可能为 None)
|
||||
- classroom: 教室(可能为 None)
|
||||
- weeks: 周数,list[int]
|
||||
- day: 星期几,int (1-7)
|
||||
- classes: 当日第几节课,list[int]
|
||||
该周无课程或参数错误时返回空列表
|
||||
"""
|
||||
# TODO: 实现实际的课表查询逻辑
|
||||
import json
|
||||
schedule = {
|
||||
"student_id": student_id,
|
||||
"semester": semester,
|
||||
"courses": [
|
||||
{
|
||||
"day": 1,
|
||||
"period": [1, 2],
|
||||
"name": "数据结构",
|
||||
"location": "教1-101",
|
||||
"teacher": "张三教授",
|
||||
},
|
||||
{
|
||||
"day": 3,
|
||||
"period": [3, 4],
|
||||
"name": "计算机网络",
|
||||
"location": "教2-205",
|
||||
"teacher": "李四副教授",
|
||||
},
|
||||
],
|
||||
}
|
||||
return json.dumps(schedule, ensure_ascii=False, indent=2)
|
||||
html_path = findHtml()
|
||||
if html_path is None:
|
||||
return []
|
||||
|
||||
courses = create_course_schedule(html_path)
|
||||
logger.debug('解析得到的课表如下:')
|
||||
logger.debug(courses)
|
||||
|
||||
final_courses = []
|
||||
for course in courses:
|
||||
if week in course.weeks:
|
||||
final_courses.append(course_dict_serializer(course))
|
||||
return final_courses
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_library_book(keyword: str, search_type: str = "title") -> str:
|
||||
"""搜索图书馆藏书
|
||||
|
||||
在南京邮电大学图书馆搜索图书。
|
||||
|
||||
@mcp.tool(
|
||||
name="get_week_day_course_schedule_json",
|
||||
title="获取指定周星期课表",
|
||||
description="以 JSON 格式获取指定教学周和星期的课表数据",
|
||||
annotations=ToolAnnotations(
|
||||
title="获取指定周星期课表",
|
||||
readOnlyHint=True, # 只读取数据,不修改
|
||||
destructiveHint=False, # 非破坏性操作
|
||||
idempotentHint=True, # 幂等:重复调用结果相同
|
||||
openWorldHint=False, # 不依赖外部世界状态
|
||||
),
|
||||
)
|
||||
async def get_week_day_course_schedule_json(week: int, day: int) -> list[dict]:
|
||||
"""以 JSON 格式获取指定教学周和星期的课表
|
||||
|
||||
Args:
|
||||
keyword: 搜索关键词
|
||||
search_type: 搜索类型,可选 "title"(书名), "author"(作者), "isbn"(ISBN)
|
||||
|
||||
Returns:
|
||||
图书信息列表的 JSON 字符串
|
||||
|
||||
Example:
|
||||
search_library_book("Python", "title")
|
||||
search_library_book("鲁迅", "author")
|
||||
"""
|
||||
# TODO: 实现实际的图书馆搜索逻辑
|
||||
import json
|
||||
books = [
|
||||
{
|
||||
"title": f"{keyword}编程实战",
|
||||
"author": "王某某",
|
||||
"publisher": "清华大学出版社",
|
||||
"isbn": "978-7-302-12345-6",
|
||||
"available": True,
|
||||
"location": "计算机科学阅览室",
|
||||
},
|
||||
{
|
||||
"title": f"{keyword}入门指南",
|
||||
"author": "李某某",
|
||||
"publisher": "人民邮电出版社",
|
||||
"isbn": "978-7-115-78901-2",
|
||||
"available": False,
|
||||
"location": "基础科学阅览室",
|
||||
},
|
||||
]
|
||||
return json.dumps(books, ensure_ascii=False, indent=2)
|
||||
week: 教学周数,范围通常为 1-20
|
||||
day: 星期几,1=星期一,2=星期二,...,7=星期日
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_campus_info(info_type: str = "overview") -> str:
|
||||
"""获取校园信息
|
||||
|
||||
获取南京邮电大学的基本信息、办事指南等。
|
||||
|
||||
Args:
|
||||
info_type: 信息类型,可选 "overview"(概况), "map"(地图),
|
||||
"contacts"(联系方式), "calendar"(校历)
|
||||
|
||||
Returns:
|
||||
校园信息的字符串
|
||||
|
||||
Example:
|
||||
get_campus_info("overview") -> 学校概况
|
||||
get_campus_info("contacts") -> 各部门联系方式
|
||||
指定周和星期的课程列表,每个课程包含以下字段:
|
||||
- name: 课程名称
|
||||
- teacher: 授课教师(可能为 None)
|
||||
- classroom: 教室(可能为 None)
|
||||
- weeks: 周数,list[int]
|
||||
- day: 星期几,int (1-7)
|
||||
- classes: 当日第几节课,list[int]
|
||||
该时段无课程或参数错误时返回空列表
|
||||
"""
|
||||
info_data = {
|
||||
"overview": """南京邮电大学(Nanjing University of Posts and Telecommunications,
|
||||
简称 NJUPT)是国家“双一流”建设高校和江苏高水平大学高峰计划 A 类建设高校。
|
||||
学校坐落于历史文化名城南京,现有仙林、三牌楼、锁金村、江宁四个校区。""",
|
||||
"contacts": """教务处:025-85866250
|
||||
学生工作处:025-85866255
|
||||
图书馆:025-85866270
|
||||
信息化建设与管理办公室:025-85866280""",
|
||||
"map": "请访问 https://map.njupt.edu.cn 查看校园地图",
|
||||
"calendar": "2024-2025学年第一学期:2024年9月2日 - 2025年1月12日",
|
||||
}
|
||||
return info_data.get(info_type, info_data["overview"])
|
||||
html_path = findHtml()
|
||||
if html_path is None:
|
||||
return []
|
||||
|
||||
courses = create_course_schedule(html_path)
|
||||
logger.debug('解析得到的课表如下:')
|
||||
logger.debug(courses)
|
||||
|
||||
final_courses = []
|
||||
for course in courses:
|
||||
if (week in course.weeks) and (day == course.day):
|
||||
final_courses.append(course_dict_serializer(course))
|
||||
return final_courses
|
||||
|
||||
|
||||
# ==================== Resources ====================
|
||||
@@ -249,39 +249,6 @@ async def get_academic_calendar() -> str:
|
||||
- 暑假:2025年6月30日起"""
|
||||
|
||||
|
||||
@mcp.resource("njupt://departments")
|
||||
async def get_departments() -> str:
|
||||
"""获取南京邮电大学学院/部门列表
|
||||
|
||||
Returns:
|
||||
学院和部门列表
|
||||
"""
|
||||
return """🏫 南京邮电大学学院设置:
|
||||
|
||||
通信与信息工程学院
|
||||
电子与光学工程学院/柔性电子(未来技术)学院
|
||||
集成电路科学与工程学院(产教融合学院)
|
||||
计算机学院/软件学院/网络空间安全学院
|
||||
自动化学院/人工智能学院
|
||||
材料科学与工程学院
|
||||
化学与生命科学学院
|
||||
物联网学院
|
||||
理学院
|
||||
地理与生物信息学院
|
||||
现代邮政学院
|
||||
传媒与艺术学院
|
||||
管理学院
|
||||
经济学院
|
||||
马克思主义学院
|
||||
社会与人口学院/社会工作学院
|
||||
外国语学院
|
||||
教育科学与技术学院
|
||||
|
||||
📋 主要职能部门:
|
||||
教务处、学生工作处、研究生工作部、科学技术处、
|
||||
人事处、财务处、审计处、保卫处、后勤管理处等"""
|
||||
|
||||
|
||||
# ==================== Prompts ====================
|
||||
|
||||
@mcp.prompt()
|
||||
@@ -295,7 +262,7 @@ def academic_advisor_query(question: str, student_major: str = "") -> str:
|
||||
student_major: 学生专业(可选)
|
||||
"""
|
||||
major_context = f"该学生专业为:{student_major}。" if student_major else ""
|
||||
|
||||
|
||||
return f"""你是南京邮电大学的学业咨询助手,专门帮助学生解决学习和课程相关的问题。
|
||||
|
||||
{major_context}
|
||||
@@ -320,9 +287,9 @@ def campus_guide_query(location: str, query_type: str = "location") -> str:
|
||||
"route": f"请提供前往南京邮电大学'{location}'的详细路线指引。",
|
||||
"facility": f"请介绍南京邮电大学'{location}'的设施情况和开放时间。",
|
||||
}
|
||||
|
||||
|
||||
base_prompt = prompts.get(query_type, prompts["location"])
|
||||
|
||||
|
||||
return f"""你是南京邮电大学校园导航助手,熟悉校园的各个位置和设施。
|
||||
|
||||
{base_prompt}
|
||||
@@ -342,34 +309,41 @@ def main():
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
help="SSE/HTTP 模式下的监听地址 (默认: 127.0.0.1)",
|
||||
help="服务器主机地址 (默认: 127.0.0.1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8000,
|
||||
help="SSE/HTTP 模式下的监听端口 (默认: 8000)",
|
||||
help="服务器端口 (默认: 8000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="启用调试模式",
|
||||
)
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
logger.debug("调试模式已启用")
|
||||
|
||||
|
||||
logger.info(f"启动 NJUPT MCP Server,传输方式: {args.transport}")
|
||||
|
||||
|
||||
if args.transport == "stdio":
|
||||
mcp.run(transport="stdio")
|
||||
elif args.transport == "sse":
|
||||
mcp.run(transport="sse", host=args.host, port=args.port)
|
||||
# 使用 uvicorn 启动 FastAPI 应用(SSE 模式)
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=args.host, port=args.port)
|
||||
elif args.transport == "streamable-http":
|
||||
mcp.run(transport="streamable-http", host=args.host, port=args.port)
|
||||
# 创建独立的 streamable-http 应用
|
||||
import uvicorn
|
||||
|
||||
http_app = mcp.streamable_http_app()
|
||||
uvicorn.run(http_app, host=args.host, port=args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -9,4 +9,4 @@
|
||||
4. 处理异常情况并返回友好的错误信息
|
||||
"""
|
||||
|
||||
# 工具函数将在这里定义,也可以在单独的模块中定义后在此导入
|
||||
from .course import create_course_schedule
|
||||
|
||||
1
src/njupt_mcp/tools/course/__init__.py
Normal file
1
src/njupt_mcp/tools/course/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .create_course_schedule import create_course_schedule
|
||||
201
src/njupt_mcp/tools/course/create_course_schedule.py
Normal file
201
src/njupt_mcp/tools/course/create_course_schedule.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from njupt_mcp.resources.types import Course
|
||||
|
||||
|
||||
# 可能需要配合修改的方法
|
||||
def normalize_course_str(course_str: str) -> str:
|
||||
"""规范化课程字符串,确保 create_course 能正确解析。"""
|
||||
parts = course_str.split("<br>")
|
||||
while parts and parts[0] == "":
|
||||
parts.pop(0)
|
||||
while len(parts) < 4:
|
||||
parts.append(" ")
|
||||
for i in range(2, 4):
|
||||
if parts[i] == "":
|
||||
parts[i] = " "
|
||||
return "<br>".join(parts)
|
||||
|
||||
|
||||
def create_course_schedule(html_path: str) -> list[Course]:
|
||||
"""
|
||||
解析给定 HTML 文件,返回包含数个 Course 对象的列表。
|
||||
Args:
|
||||
html_path: HTML 文件路径。该文件中应该有且只有一个 <table> 标签,其中是课程表数据。
|
||||
|
||||
Returns: list[Course]
|
||||
|
||||
"""
|
||||
with open(html_path, encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
table = soup.find("table")
|
||||
rows = table.find_all("tr")
|
||||
|
||||
courses: list[Course] = []
|
||||
rowspan_map: dict[int, int] = {}
|
||||
|
||||
# 解析第一行表头,建立列索引到星期几的映射
|
||||
# 表头格式:第1列是"时间"(colspan=2),然后是 星期一 到 星期日
|
||||
day_map: dict[int, int] = {} # col_idx -> day (1-7)
|
||||
if rows:
|
||||
header_cells = rows[0].find_all(["td", "th"])
|
||||
col_idx = 0
|
||||
for cell in header_cells:
|
||||
text = cell.get_text(strip=True)
|
||||
colspan = int(cell.get("colspan", 1))
|
||||
|
||||
# 跳过"时间"单元格
|
||||
if text != "时间":
|
||||
# 映射星期几到数字
|
||||
day_mapping = {
|
||||
"星期一": 1, "星期二": 2, "星期三": 3, "星期四": 4,
|
||||
"星期五": 5, "星期六": 6, "星期日": 7, "星期天": 7
|
||||
}
|
||||
day = day_mapping.get(text)
|
||||
if day is not None:
|
||||
for c in range(col_idx, col_idx + colspan):
|
||||
day_map[c] = day
|
||||
|
||||
col_idx += colspan
|
||||
|
||||
for row_idx, row in enumerate(rows):
|
||||
if row_idx == 0:
|
||||
continue
|
||||
|
||||
cells = row.find_all(["td", "th"])
|
||||
col_idx = 0
|
||||
class_start: int | None = None
|
||||
|
||||
for cell in cells:
|
||||
while col_idx in rowspan_map and rowspan_map[col_idx] > 0:
|
||||
rowspan_map[col_idx] -= 1
|
||||
if rowspan_map[col_idx] == 0:
|
||||
del rowspan_map[col_idx]
|
||||
col_idx += 1
|
||||
|
||||
text = cell.get_text(strip=True)
|
||||
colspan = int(cell.get("colspan", 1))
|
||||
rowspan = int(cell.get("rowspan", 1))
|
||||
|
||||
if text.startswith("第") and text.endswith("节"):
|
||||
class_start = int(text[1:-1])
|
||||
if rowspan > 1:
|
||||
for c in range(col_idx, col_idx + colspan):
|
||||
rowspan_map[c] = rowspan - 1
|
||||
col_idx += colspan
|
||||
continue
|
||||
|
||||
if text in ("早晨", "上午", "下午", "晚上"):
|
||||
if rowspan > 1:
|
||||
for c in range(col_idx, col_idx + colspan):
|
||||
rowspan_map[c] = rowspan - 1
|
||||
col_idx += colspan
|
||||
continue
|
||||
|
||||
td_str = str(cell)
|
||||
start = td_str.find(">") + 1
|
||||
end = td_str.rfind("</td>")
|
||||
inner_html = td_str[start:end]
|
||||
|
||||
if " " not in inner_html and inner_html.strip():
|
||||
inner_html = re.sub(r"<br\s*/?>", "<br>", inner_html)
|
||||
course_strs = [
|
||||
s.strip()
|
||||
for s in re.split(r"(?:<br>){2,}", inner_html)
|
||||
if s.strip() and " " not in s
|
||||
]
|
||||
# 获取当前列对应的星期几
|
||||
day = day_map.get(col_idx, 1) # 默认为1(星期一)
|
||||
for course_str in course_strs:
|
||||
course_str = normalize_course_str(course_str)
|
||||
courses.append(create_course(course_str, day, default_classes_start=class_start))
|
||||
|
||||
if rowspan > 1:
|
||||
for c in range(col_idx, col_idx + colspan):
|
||||
rowspan_map[c] = rowspan - 1
|
||||
|
||||
col_idx += colspan
|
||||
|
||||
return courses
|
||||
|
||||
|
||||
def create_course(raw: str, day: int, default_classes_start: int | None = None) -> Course:
|
||||
"""
|
||||
根据从 HTML 中提取出的原字符串解析课程信息
|
||||
Args:
|
||||
raw: 原字符串,以 <br> 作为换行符
|
||||
day: 周内的星期几
|
||||
default_classes_start: 如果没有解析出课程的 classes,则使用此参数。此参数应当从表格的行标题解析
|
||||
|
||||
Returns: Course
|
||||
|
||||
"""
|
||||
|
||||
# 0 1 2 3 4
|
||||
# ['概率论与数理统计', '1-17单(1,2)', '王雪红', '教3-520', '']
|
||||
raw_list = raw.split("<br>")
|
||||
|
||||
# 首先去除列表头部的所有空字符串
|
||||
while True:
|
||||
if raw_list[0] == '':
|
||||
raw_list.pop(0)
|
||||
else:
|
||||
break
|
||||
|
||||
# 对于大部分课程,raw_list[1] 都是形如以下格式
|
||||
# 1-17(3,4)
|
||||
# 1-17单(1,2) *(也可能是双)
|
||||
# 2节/周
|
||||
# 2节/单周 *(也可能是双)
|
||||
raw_time = raw_list[1]
|
||||
weeks = []
|
||||
classes = []
|
||||
single = False # 内部变量
|
||||
double = False # 内部变量
|
||||
# 处理前两种形式
|
||||
if '-' in raw_time:
|
||||
# 也可能是 '1-17单'
|
||||
t = raw_time.split('(') # ['1-17', '3-4)']
|
||||
# 也可能是 '17单'
|
||||
start, end = t[0].split('-') # ['1', '17']
|
||||
if end.endswith('单'):
|
||||
end = end[:-1]
|
||||
single = True
|
||||
elif end.endswith('双'):
|
||||
end = end[:-1]
|
||||
double = True
|
||||
for i in range(int(start), int(end) + 1):
|
||||
if single and i % 2 == 0:
|
||||
continue
|
||||
if double and i % 2 == 1:
|
||||
continue
|
||||
weeks.append(i)
|
||||
raw_classes = t[1].removesuffix(')')
|
||||
classes = [int(i) for i in raw_classes.split(',')]
|
||||
# 处理后两种形式
|
||||
elif '/' in raw_time:
|
||||
# 默认学期 1-16 周
|
||||
if '/单周' in raw_time:
|
||||
single = True
|
||||
elif '/双周' in raw_time:
|
||||
double = True
|
||||
for i in range(1, 17):
|
||||
if single and i % 2 == 0:
|
||||
continue
|
||||
if double and i % 2 == 1:
|
||||
continue
|
||||
weeks.append(i)
|
||||
|
||||
# 获取多少节课
|
||||
_num = int(raw_time.split('节')[0])
|
||||
for i in range(0, _num):
|
||||
classes.append(default_classes_start + i)
|
||||
|
||||
teacher = raw_list[2] if raw_list[2] != ' ' else None
|
||||
classroom = raw_list[3] if raw_list[3] != ' ' else None
|
||||
|
||||
return Course(raw_list[0], weeks, day, classes, teacher, classroom)
|
||||
87
tests/test_tools.py
Normal file
87
tests/test_tools.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""测试工具函数"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from njupt_mcp.resources.types import Course
|
||||
from njupt_mcp.tools.course.create_course_schedule import (
|
||||
create_course,
|
||||
create_course_schedule,
|
||||
)
|
||||
|
||||
TEST_HTML_PATH = Path(__file__).parent.parent / "src" / "njupt_mcp" / "resources" / "course_schedule" / "B240423-22.html"
|
||||
|
||||
|
||||
def test_create_course_basic():
|
||||
"""测试 create_course 解析常规课程字符串"""
|
||||
course = create_course("概率论与数理统计<br>1-17单(1,2)<br>王雪红<br>教3-520<br>")
|
||||
assert course.name == "概率论与数理统计"
|
||||
assert course.weeks == [1, 3, 5, 7, 9, 11, 13, 15, 17]
|
||||
assert course.classes == [1, 2]
|
||||
assert course.teacher == "王雪红"
|
||||
assert course.classroom == "教3-520"
|
||||
|
||||
|
||||
def test_create_course_double_weeks():
|
||||
"""测试 create_course 解析双周课程"""
|
||||
course = create_course("计算机系统基础Ⅰ(混合式)<br>2-16双(3,4)<br>李维维<br>教3-512<br>")
|
||||
assert course.name == "计算机系统基础Ⅰ(混合式)"
|
||||
assert course.weeks == [2, 4, 6, 8, 10, 12, 14, 16]
|
||||
assert course.classes == [3, 4]
|
||||
assert course.teacher == "李维维"
|
||||
assert course.classroom == "教3-512"
|
||||
|
||||
|
||||
def test_create_course_with_default_classes():
|
||||
"""测试 create_course 在需要 default_classes_start 时的解析"""
|
||||
course = create_course("大学英语IV<br>2节/双周<br> <br> <br>", default_classes_start=3)
|
||||
assert course.name == "大学英语IV"
|
||||
assert course.weeks == [2, 4, 6, 8, 10, 12, 14, 16]
|
||||
assert course.classes == [3, 4]
|
||||
assert course.teacher is None
|
||||
assert course.classroom is None
|
||||
|
||||
|
||||
def test_create_course_schedule_from_html():
|
||||
"""测试从 HTML 文件解析课程表"""
|
||||
courses = create_course_schedule(str(TEST_HTML_PATH))
|
||||
|
||||
assert isinstance(courses, list)
|
||||
assert len(courses) == 20
|
||||
assert all(isinstance(c, Course) for c in courses)
|
||||
|
||||
# 验证几个典型课程
|
||||
course_names = [c.name for c in courses]
|
||||
assert "计算机问题求解:使用算法(混合式)" in course_names
|
||||
assert "概率论与数理统计" in course_names
|
||||
assert "形势与政策IV" in course_names
|
||||
|
||||
# 查找并验证特定课程
|
||||
xingshi = next(c for c in courses if c.name == "形势与政策IV")
|
||||
assert xingshi.weeks == [6, 7, 8]
|
||||
assert xingshi.classes == [10, 11, 12]
|
||||
assert xingshi.teacher == "陈骏"
|
||||
assert xingshi.classroom == "教4-101"
|
||||
|
||||
# 验证单周课程
|
||||
gailv = [c for c in courses if c.name == "概率论与数理统计" and c.classes == [1, 2]][0]
|
||||
assert gailv.weeks == [1, 3, 5, 7, 9, 11, 13, 15, 17]
|
||||
|
||||
# 验证合并单元格中的多课程(数据库系统基础出现两次,不同老师)
|
||||
db_courses = [c for c in courses if c.name == "数据库系统基础"]
|
||||
assert len(db_courses) == 2
|
||||
teachers = {c.teacher for c in db_courses}
|
||||
assert teachers == {"黄楠", "李博文"}
|
||||
|
||||
# 验证无教师/教室的课程(大学英语IV、体育 IV)
|
||||
english_courses = [c for c in courses if c.name == "大学英语IV"]
|
||||
assert len(english_courses) == 3
|
||||
for c in english_courses:
|
||||
assert c.teacher is None
|
||||
assert c.classroom is None
|
||||
|
||||
tiyu = [c for c in courses if c.name == "体育 IV"][0]
|
||||
assert tiyu.teacher is None
|
||||
assert tiyu.classroom is None
|
||||
assert tiyu.classes == [6, 7]
|
||||
46
uv.lock
generated
46
uv.lock
generated
@@ -42,6 +42,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beautifulsoup4"
|
||||
version = "4.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "soupsieve" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
@@ -201,6 +214,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.135.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -349,9 +378,13 @@ name = "njupt-mcp"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "mcp", extra = ["cli"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -367,11 +400,15 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
|
||||
{ name = "fastapi", specifier = ">=0.135.2" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.7.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.42.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
@@ -789,6 +826,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.3.4"
|
||||
|
||||
Reference in New Issue
Block a user