课程摘要
本节承接弹性常数提取,学习把ABACUS计算整理为计划、提交、查询和收集四类工具,理解MCP在工具发现与调用中的作用。通过可运行的离线Python示例,练习参数校验、依赖清单、计划校验值与结构化结果,辨析计划、批准和实际执行的区别,并讨论重复提交、超时与结果验收。课堂不提交真实作业,为后续DFT结果解析与自动化工作流建立清晰边界。
一、从"会算一个常数",走向"管好一批作业"
上一节,我们通过应变---应力关系提取了立方晶体的三个独立弹性常数:
\ C_{11},\\qquad C_{12},\\qquad C_{44} \\
但真实研究中的应力数据,不是凭空出现的。
参考结构、正负应变构型、不同的收敛设置,都可能对应一批计算任务。任务多起来后,问题往往不只是"公式是否正确",还包括:
- 哪些计算已经准备好?
- 哪些输入文件还缺失?
- 哪些任务正在排队?
- 哪些任务失败了?
- 收集到的结果是否属于本次计算?
现在,假设你对一个仿真助手说:
请为这个应变构型准备一次ABACUS计算,先让我检查计划,不要直接提交。
一个可靠的系统,应该先返回清楚的计算计划,而不是立即执行一条命令。
本节就从这张"计算申请单"开始。
本节学习目标
完成学习后,你应当能够:
- 区分MCP接口、作业执行与科学验收的职责;
- 设计计划、提交、查询和收集四类工具;
- 运行离线规划程序,理解每项输出;
- 解释计划校验值能做什么、不能做什么;
- 避免超时后盲目重复提交计算。
本节不运行ABACUS、不连接超算,也不建立完整MCP Server。 配套代码实现离线规划核心,并生成接口消息样例。
二、把计算自动化理解为"实验室预约"
想象你要使用一台实验设备。
填写预约单,不等于预约已经批准;预约批准,不等于设备已经启动;设备停止,也不等于实验数据合格。
计算自动化同样需要分清这些步骤。
本课程把它们整理为四类工具:
| 工具 | 主要职责 | 典型返回 | 本节实现情况 |
|---|---|---|---|
plan_calculation |
校验请求,生成计划 | 参数、依赖清单、校验值 | 离线实现 |
submit_calculation |
提交已批准的计划 | 作业标识与提交记录 | 仅设计 |
get_status |
查询已有作业 | 排队、运行、失败等状态 | 仅设计 |
collect_results |
收集指定作业结果 | 原始证据、解析结果与检查记录 | 仅设计 |
这些名称是本课程设计的业务接口,不是MCP规定的固定名称。
为什么不只做一个"全部自动完成"的工具?
因为拆开之后,每一步的权限和责任更清楚。
例如:
- 用户可以允许生成计划,但不允许占用集群资源;
- 查询状态不应该顺便提交一个新作业;
- 收集文件不应该自动修改计算参数;
- 修改已批准计划,应当重新审核。
好的接口不仅告诉系统"可以做什么",还要让它知道"当前不应该做什么"。
三、MCP究竟处在哪一层?

图1:概念架构。本节只实现规划核心与离线消息样例,不代表各层已经连通。
可以把整个系统理解为三个职责层。
3.1 接口层:描述工具与调用方式
本课固定参考MCP的2025-06-18规范。客户端可以通过 tools/list 发现工具,通过 tools/call 调用工具;工具描述包含名称与输入模式,结果可以包含文本和结构化内容。这里引用的是明确的学习版本,不代表当前最新规范。MCP工具规范
简单来说,接口层解决的是:
有哪些工具?每个工具需要哪些参数?调用后返回什么?
3.2 执行层:真正管理计算
真正执行ABACUS,还需要知道:
- 软件安装在哪里;
- 使用哪种运行环境;
- 输入文件是否齐全;
- 采用什么并行方式;
- 在哪个目录运行;
- 本地进程或集群作业怎样追踪。
这些工作由作业适配器负责,不能依靠一个工具描述自动完成。
3.3 验收层:判断结果能否使用
即使作业结束,也需要继续检查:
- 是否读取了本次作业的输出;
- SCF是否满足收敛要求;
- 应力单位与符号是否正确;
- 数值精度是否达到目标;
- 目标物性是否通过检查。
因此:
MCP相通,不等于计算已经执行;作业结束,不等于科学结果可信。
完整MCP连接还需要初始化与能力协商。本节没有实现连接生命周期或传输层,因此不能把课堂脚本称为完整服务器。MCP连接生命周期
四、先设计一张简单的"计算申请单"
为了突出接口逻辑,本节只保留四个输入字段:
{
"case_id": "elastic_demo_001",
"basis_type": "pw",
"ecutwfc_Ry": 60,
"kmesh": [8, 8, 8]
}
4.1 四个字段分别是什么意思?
| 字段 | 含义 | 本节检查规则 |
|---|---|---|
case_id |
教学案例标识 | 只允许规定长度的字母、数字、下划线与连字符 |
basis_type |
基组类型 | 仅允许 pw 或 lcao |
ecutwfc_Ry |
以Ry为单位的截断能字段 | 正且有限的数值,拒绝布尔值 |
kmesh |
三个方向的k点网格 | 三个正整数 |
这里的60 Ry和8×8×8,只是接口示例,不是某种材料的推荐参数,也没有通过应力收敛研究。
本节脚本对k点采用严格的Python整数检查。JSON Schema中的整数定义与某些语言运行时的类型判断并不完全相同,真实服务需要统一数值规范,不能把手写教学校验器等同于完整的Schema验证器。
4.2 为什么要拒绝未知字段?
假设调用者额外加入:
{
"shell_command": "abacus"
}
程序会拒绝这个请求。
因为我们设计的是"生成规定格式的计算计划",不是提供任意命令执行入口。
同样,真实系统中的结构、赝势、轨道和工作目录,宜通过服务端管理的资源标识解析,而不是接受模型随意给出的路径、URL或命令。
限制 case_id 的字符,只能减少误用,不能代替文件系统沙箱。本节不会根据这个标识访问实际计算目录。
4.3 四个字段是否足够启动计算?
不够。
还缺少:
- 真实结构与元素;
- 赝势及轨道匹配;
- 交换关联设置;
- SCF设置;
- 应变与内部坐标弛豫策略;
- 应力输出要求;
- 运行环境和资源限制。
因此:
输入结构检查通过,只代表可以生成教学计划,不代表已经形成完整ABACUS输入。
实际参数与文件配置应按安装版本核对。本课延续参考ABACUS v3.8.5文档,并不将其作为最新版本声明。ABACUS输入参数文档
五、列出依赖,不等于依赖已经检查通过
本节对PW计划列出四类依赖:
INPUT
STRU
KPT
pseudopotentials
对LCAO计划增加:
numerical_orbitals
这是本教学配置采用显式KPT文件时的简化清单,不代表所有ABACUS模式都必须采用完全相同的文件组合。
更重要的是:
本节只列清单,没有读取或验证这些资源。
因此计划中明确写入:
{
"status": "PLANNED_ONLY",
"dependency_status": "NOT_CHECKED",
"execution_allowed": false,
"submitted": false,
"ABACUS_run": false
}
逐项理解:
PLANNED_ONLY:目前只是计划;NOT_CHECKED:依赖尚未检查;execution_allowed=false:本示例不允许执行;submitted=false:没有提交;ABACUS_run=false:没有运行ABACUS。
这些字段说明课堂边界,但不是一个已部署的权限系统。
本节不会执行计算,是因为代码根本没有提交或进程执行逻辑,而不是因为模型"自觉遵守"了一个布尔字段。
六、计划校验值:防止"审核的是A,执行的却是B"
假设你审核的计划使用:
kmesh = [8, 8, 8]
提交前却被改成:
kmesh = [10, 10, 10]
计算成本与数值条件都可能变化,不能继续沿用原批准。
本节通过计划校验值识别内容变化:
- 规范化输入;
- 加入规划器版本与依赖类别;
- 按固定顺序序列化;
- 计算SHA-256。
相同的规范化内容得到相同校验值,修改k点网格会改变它。
程序还把截断能统一成浮点表示,因此60与60.0不会产生无意义的差别。
校验值不是批准凭证
必须区分:
| 标识 | 用途 |
|---|---|
case_id |
关联案例 |
plan_sha256 |
识别计划内容 |
job_id |
标识真实执行系统中的作业 |
idempotency_key |
关联同一次提交意图的重复请求 |
这些标识不能互相代替。
尤其需要记住:
内容校验值不是数字签名,不是用户批准,也不是物理正确性的证明。
本节校验值只覆盖教学参数、规划器版本和依赖类别,没有覆盖真实结构、赝势或轨道文件内容。
真实系统还应绑定文件内容、执行环境、资源预算等,并保存可信的批准记录。内容变化后,应重新审核。
七、运行本节完整程序
7.1 代码放在哪里?
lesson63_job_tools.py
"""Offline ABACUS job planning lesson; not an MCP server and never submits jobs."""
import argparse
import hashlib
import json
import math
import re
from copy import deepcopy
from pathlib import Path
VERSION = "lesson63-offline-v1"
TASK = dict(case_id="elastic_demo_001", basis_type="pw",
ecutwfc_Ry=60, kmesh=[8, 8, 8])
TOOL = {
"name": "plan_calculation",
"description": "Validate a teaching task and return a plan only; never execute ABACUS.",
"inputSchema": {
"type": "object",
"properties": {
"case_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,40}$"},
"basis_type": {"type": "string", "enum": ["pw", "lcao"]},
"ecutwfc_Ry": {"type": "number", "exclusiveMinimum": 0},
"kmesh": {"type": "array", "minItems": 3, "maxItems": 3,
"items": {"type": "integer", "minimum": 1}},
},
"required": ["case_id", "basis_type", "ecutwfc_Ry", "kmesh"],
"additionalProperties": False,
},
"annotations": {"readOnlyHint": True},
}
def job_plan(task):
if type(task) is not dict or set(task) != set(TASK):
raise ValueError("invalid task fields")
case = task["case_id"]
if type(case) is not str or re.fullmatch(r"[A-Za-z0-9_-]{1,40}", case) is None:
raise ValueError("invalid case_id")
if type(task["basis_type"]) is not str or task["basis_type"] not in ("pw", "lcao"):
raise ValueError("invalid basis_type")
cutoff = task["ecutwfc_Ry"]
if type(cutoff) not in (int, float):
raise ValueError("invalid cutoff")
try:
cutoff = float(cutoff)
except OverflowError:
raise ValueError("invalid cutoff") from None
if not math.isfinite(cutoff) or cutoff <= 0:
raise ValueError("invalid cutoff")
mesh = task["kmesh"]
if type(mesh) is not list or len(mesh) != 3 or any(type(v) is not int or v <= 0 for v in mesh):
raise ValueError("invalid kmesh")
normalized = dict(case_id=case, basis_type=task["basis_type"],
ecutwfc_Ry=cutoff, kmesh=mesh.copy())
assets = ["INPUT", "STRU", "KPT", "pseudopotentials"]
if normalized["basis_type"] == "lcao":
assets.append("numerical_orbitals")
manifest = dict(planner_version=VERSION, task=normalized, required_assets=assets)
canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":"), allow_nan=False)
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
return dict(status="PLANNED_ONLY", plan_id="plan_"+digest[:12],
plan_sha256=digest, manifest=manifest,
dependency_status="NOT_CHECKED", execution_allowed=False,
submitted=False, ABACUS_run=False)
def tool_result(task):
plan = job_plan(task)
return dict(content=[dict(type="text", text=json.dumps(plan, sort_keys=True))],
structuredContent=plan, isError=False)
def demo():
pw = job_plan(TASK)
lcao = job_plan(dict(TASK, basis_type="lcao"))
failures = {}
for name, task in {
"INVALID_KMESH": dict(TASK, kmesh=[8, 0, 8]),
"UNKNOWN_FIELD": dict(TASK, shell_command="abacus"),
}.items():
try:
job_plan(task)
except ValueError as exc:
failures[name] = dict(status="REJECTED", reason=str(exc))
else:
raise AssertionError("Bad input accepted")
result = tool_result(TASK)
messages = dict(
note="OFFLINE_FIXTURES_NOT_A_SESSION",
protocol_reference="2025-06-18",
list_request=dict(jsonrpc="2.0", id=1, method="tools/list"),
list_response=dict(jsonrpc="2.0", id=1, result=dict(tools=[deepcopy(TOOL)])),
call_request=dict(jsonrpc="2.0", id=2, method="tools/call",
params=dict(name="plan_calculation", arguments=deepcopy(TASK))),
call_response=dict(jsonrpc="2.0", id=2, result=result),
)
return dict(mode="OFFLINE_PLANNING", MCP_connected=False, ABACUS_run=False,
pw=pw, lcao=lcao, rejected_examples=failures,
same_normalized_task_same_hash=pw["plan_sha256"] == job_plan(dict(TASK, ecutwfc_Ry=60.0))["plan_sha256"],
changed_kmesh_changes_hash=pw["plan_sha256"] != job_plan(dict(TASK, kmesh=[10, 10, 10]))["plan_sha256"],
hash_is_approval=False, messages=messages)
def self_test():
d = demo()
assert d["pw"]["status"] == "PLANNED_ONLY"
assert len(d["pw"]["manifest"]["required_assets"]) == 4
assert len(d["lcao"]["manifest"]["required_assets"]) == 5
assert d["same_normalized_task_same_hash"] and d["changed_kmesh_changes_hash"]
assert not d["pw"]["execution_allowed"] and not d["pw"]["submitted"]
assert not d["MCP_connected"] and not d["ABACUS_run"]
assert json.loads(d["messages"]["call_response"]["result"]["content"][0]["text"]) == d["pw"]
invalid = [
None, [], {}, dict(TASK, case_id="../outside"), dict(TASK, case_id=4),
dict(TASK, basis_type=[]), dict(TASK, ecutwfc_Ry=True),
dict(TASK, ecutwfc_Ry=0), dict(TASK, ecutwfc_Ry=float("nan")),
dict(TASK, ecutwfc_Ry=float("inf")), dict(TASK, ecutwfc_Ry=10**1000),
dict(TASK, kmesh="888"), dict(TASK, kmesh=[8, True, 8]),
dict(TASK, kmesh=[8, 8.0, 8]), dict(TASK, kmesh=[8, 8]),
dict(TASK, shell_command="abacus"),
]
for task in invalid:
try:
job_plan(task)
except ValueError:
pass
else:
raise AssertionError("Invalid input accepted")
original = deepcopy(TASK)
planned = job_plan(original)
original["kmesh"][0] = 99
assert planned["manifest"]["task"]["kmesh"] == [8, 8, 8]
print("SELF_TEST=PASS (validation, normalization, hashes, isolation and no execution)")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--self-test", action="store_true")
parser.add_argument("--out", type=Path, default=Path("lesson63_run"))
args = parser.parse_args()
if args.self_test:
self_test()
return
if args.out.exists():
parser.error("Output directory exists; choose a new --out path")
d = demo()
print("MODE=OFFLINE_PLANNING; MCP_connected=False; ABACUS_run=False")
print("EXPOSED_TOOL=plan_calculation")
for name in ("pw", "lcao"):
plan = d[name]
print(f"{name.upper()}={plan['status']}; dependencies={len(plan['manifest']['required_assets'])}; execution_allowed={plan['execution_allowed']}")
for name, outcome in d["rejected_examples"].items():
print(f"{name}={outcome['status']}")
print(f"SAME_NORMALIZED_TASK_SAME_HASH={d['same_normalized_task_same_hash']}")
print(f"CHANGED_KMESH_CHANGES_HASH={d['changed_kmesh_changes_hash']}")
print("HASH_IS_APPROVAL=False")
print("plan_sha256="+d["pw"]["plan_sha256"])
args.out.mkdir(parents=True, exist_ok=False)
for name, data in (("report.json", d), ("mcp_messages.json", d["messages"])):
(args.out/name).write_text(json.dumps(data, indent=2, allow_nan=False), encoding="utf-8")
print(f"SAVED={args.out/'report.json'}")
print(f"SAVED={args.out/'mcp_messages.json'}")
if __name__ == "__main__":
main()
这是独立脚本,不需要插入第62节代码。
主程序只使用Python标准库,不要求安装ABACUS、MCP SDK或第三方库。
7.2 先运行自测
在脚本所在目录打开终端:
python lesson63_job_tools.py --self-test
预期输出:
SELF_TEST=PASS (validation, normalization, hashes, isolation and no execution)
自测检查正常计划、依赖差异、非法输入、校验值变化,以及输入列表修改不会反向篡改已生成计划等情况。
7.3 生成计划与消息样例
运行:
python lesson63_job_tools.py --out lesson63_run
预期输出:
MODE=OFFLINE_PLANNING; MCP_connected=False; ABACUS_run=False
EXPOSED_TOOL=plan_calculation
PW=PLANNED_ONLY; dependencies=4; execution_allowed=False
LCAO=PLANNED_ONLY; dependencies=5; execution_allowed=False
INVALID_KMESH=REJECTED
UNKNOWN_FIELD=REJECTED
SAME_NORMALIZED_TASK_SAME_HASH=True
CHANGED_KMESH_CHANGES_HASH=True
HASH_IS_APPROVAL=False
plan_sha256=82167e8761d7230a9b48728363ba732635355e29fe08cccbbbb987a051f60559
SAVED=lesson63_run\report.json
SAVED=lesson63_run\mcp_messages.json
路径分隔符可能随操作系统变化。输出目录已存在时,程序会拒绝覆盖;再次运行请换一个目录名。
7.4 怎样解释这些输出?
- PW与LCAO都只生成计划,没有进入执行;
- LCAO比PW多一类数值轨道依赖;
- 非法k点与未知字段被拒绝;
- 等价规范化参数得到相同校验值;
- 改变k点网格会改变校验值;
- 校验值没有被当作批准。
EXPOSED_TOOL 在这里指离线清单中声明的工具名称,不表示网络服务已经上线。
生成的两个文件分别是:
report.json:计划、拒绝案例和边界标志;mcp_messages.json:工具发现与调用的离线消息样例。
消息样例明确标记为:
OFFLINE_FIXTURES_NOT_A_SESSION
它不是一次真实MCP连接的通信记录。
八、把普通函数映射为MCP工具接口
本节的核心函数:
job_plan(task)
本质上是普通Python函数。
要成为可发现的工具,需要描述名称、用途和输入模式。例如:
{
"name": "plan_calculation",
"description": "Validate a teaching task and return a plan only; never execute ABACUS.",
"inputSchema": {
"type": "object",
"properties": {
"case_id": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{1,40}$"
},
"basis_type": {
"type": "string",
"enum": ["pw", "lcao"]
},
"ecutwfc_Ry": {
"type": "number",
"exclusiveMinimum": 0
},
"kmesh": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": {
"type": "integer",
"minimum": 1
}
}
},
"required": [
"case_id",
"basis_type",
"ecutwfc_Ry",
"kmesh"
],
"additionalProperties": false
}
}
一个调用请求样例为:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "plan_calculation",
"arguments": {
"case_id": "elastic_demo_001",
"basis_type": "pw",
"ecutwfc_Ry": 60,
"kmesh": [8, 8, 8]
}
}
}
其中的 id=2 用于请求与响应关联,不是ABACUS作业的job_id。
本节程序将计划放入 structuredContent,同时保留相同内容的文本JSON。完整返回样例已保存到配套文件。
输入模式只是契约的一部分。真实服务仍需服务端校验与访问控制;readOnlyHint 等注解也不能代替权限机制。MCP工具与安全要求
错误应该怎样处理?
课堂中的非法输入直接触发Python ValueError,在演练中记作 REJECTED。这不是完整MCP错误映射器。
真实服务需要区分请求层错误与工具业务错误。
例如:
- "成功生成一份尚未提交的计划"可以是成功;
- "调用了不存在的工具"不是相同情况;
- "工具执行过程中发现资源不可用"也需要明确返回原因。
不要把所有问题都包装成一个没有解释的"成功"。
九、作业状态:查询超时,不代表计算停止

图2:建议业务状态,不是MCP规定的状态枚举。本节程序实际只返回规划状态。
真实系统宜分别记录两条线。
执行状态:
计划 → 批准 → 提交 → 排队(如有)→ 运行 → 结束、失败或取消。
科学验收:
待检查 → 记录验收 → 数值检查 → 目标物性检查。
本地任务可能没有排队阶段。失败或取消的任务,也应保留诊断证据。
9.1 为什么不能超时后立即重新提交?
假设服务端已经向集群提交作业,但返回响应时网络中断。
客户端看到了超时,集群却可能已经开始计算。
如果立即重提,可能产生两个作业。
正确做法是:
- 保留原提交意图标识与可用的job_id;
- 将当前认识记录为"状态未知";
- 查询持久化提交记录和调度器;
- 确认是否已有作业,再决定下一步。
如果连job_id都没有收到,更不能凭空编造一个;需要通过服务端提交记录核对。
9.2 幂等性不只是使用相同字符串
真实系统可通过 idempotency_key 关联同一次提交意图的重复请求。
但要避免重复执行,还需要持久化记录、并发约束,以及对"调度器已接受、服务端记录尚未完成"等中断情况的处理。
本节没有实现真实提交器或作业数据库。
计划内容相同,不代表系统已经实现了"只提交一次"。
9.3 请求取消,不等于集群作业已经取消
客户端停止等待、取消一个请求,与集群作业真正停止,是不同层面的动作。
取消计算需要明确的执行操作、相应权限及后续状态确认,不能仅凭"取消请求已发送"就宣称资源已经释放。
十、连接真实执行前的确认清单
在真实提交之前,至少确认以下事项:
| 类别 | 应确认的内容 |
|---|---|
| 输入 | 结构、应变路径、赝势、轨道及必要参数 |
| 数值 | 基组、k点、SCF、弛豫与应力输出设置 |
| 环境 | 软件版本、可执行文件、依赖与并行方式 |
| 资源 | 核数、内存、时限与队列 |
| 权限 | 谁可提交、查询、取消,批准范围是什么 |
| 一致性 | 批准内容与实际执行内容一致 |
| 追踪 | 提交意图、真实job_id、时间与状态来源 |
| 结果 | 原始证据、解析器版本及验收记录 |
凭据应留在受控服务端,不放入提示词、任务描述或课程截图。
日志是待分析的数据,不应被当作新的授权指令执行。
安全执行也不是简单修改一个命令调用选项就全部完成。可执行文件、参数、环境、目录和资源边界,都需要明确控制。
十一、课堂练习与参考答案
练习1:k点中有一个零
将:
[8, 8, 8]
改为:
[8, 0, 8]
应该发生什么?
答案: 在计划阶段拒绝,不应进入后续提交。
练习2:从PW改为LCAO
依赖清单发生什么变化?
答案: 增加 numerical_orbitals,依赖类别从4变为5。
但这不能证明轨道文件已经存在,也不能证明它适配该材料。
练习3:相同计划是否必然对应同一个作业?
答案: 不是。计划校验值识别内容,job_id标识真实执行。本节没有提交,因此没有真实job_id。
练习4:修改k点后,原批准能否继续使用?
答案: 应重新审核绑定内容。本节可以识别教学计划变化,但没有实现批准系统。
练习5:调用成功,为什么仍然是PLANNED_ONLY?
答案: 因为工具的任务是"生成计划"。计划生成成功,不等于计算提交成功。
练习6:让模型加入approved=true,就能执行吗?
答案: 不能。调用参数自称已批准,不构成可信授权。批准需要来自受控流程,并绑定用户、内容和权限范围。
十二、本节总结与下一节安排
本节将"运行一次计算"拆成了可解释的步骤:
- 计划表达并检查计算意图;
- 批准授予限定范围的执行权限;
- 提交产生可核对的作业记录;
- 查询不应产生新的计算;
- 收集结果不能跳过来源、数值和物理检查。
本节最重要的一句话是:
工具调用成功,不代表计算已经执行;计算执行完成,也不代表科学结论已经通过验证。
