工具系统:定义、注册与安全执行
30 秒导读: 模型只会"说话",本章讲 PraisonAI 怎么给它装上"手脚"——把一个 Python 函数变成模型能点名调用的工具,并让每一次真实调用都穿过一条受控管线(类型纠正、审批、熔断、重试、给外部内容打隔离标记)。MCP 服务器也被当成一批"外部工具"接进这条同样的管线。
本章在货架里的位置:上一章 Agent 单体与 chat 主循环 讲"模型什么时候决定要调工具";本章接手"决定之后,这次调用如何安全落地"。沙箱级隔离执行属于 第 06 章 范畴,本章不展开。
1. 这是什么(零基础也能懂)
一句话定义。 工具(tool)= 一个带名字、带参数说明的函数,模型可以在回话里说"我要调 get_weather(city='东京')",框架负责把这句话真正执行掉,再把结果喂回模型。
解决什么问题 / 给谁用。 大语言模型本身不能查数据库、不能读文件、不能发 HTTP 请求——它只能生成文本。工具系统就是那层"翻译 + 执法":
- 把开发者写的普通函数,翻译成模型看得懂的调用说明(JSON schema)。
- 模型说要调用时,把它给的参数校正、审批、执行,再把返回值安全地送回去。
给谁用:任何想让 agent "干实事"(而不只是聊天)的开发者。
它能做什么。
- 用一个
@tool装饰器把函数变工具,自动从函数签名生成参数 schema。 - 用注册表统一管理工具、支持第三方插件自动发现。
- 执行时做:参数类型纠正、危险工具审批、故障熔断、失败重试退避、外部内容防注入包裹。
- 把 MCP(Model Context Protocol,一个让 agent 连外部工具服务器的开放协议)服务器整批挂成工具。
用起来什么样。 最小例子——定义一个工具,交给 agent:
# 示意,基于 README 的最小用法
from praisonaiagents import Agent, tool
@tool # 一个装饰器就够
def get_stock_price(company: str) -> str:
"""查询某公司股价。""" # docstring 变成给模型看的描述
return f"{company} 现价 100 USD"
agent = Agent(instructions="你是助手", tools=[get_stock_price])
agent.start("特斯拉股价多少?") # 模型自己决定调 get_stock_price(company="Tesla")
一句话直觉。 把工具系统想成餐厅后厨的"传菜 + 食品安全"制度:模型是点菜的客人(只会说菜名),工具定义是菜单(告诉客人有什么、怎么点),执行管线是后厨流水线(核对订单、检查过敏原、出问题重做),MCP 是外包的中央厨房(不在本店做,但也走同一套上菜流程)。
2. 顶层全景(它大概怎么转)
工具系统天然分成两个时相,别把它们混在一起看:
| 时相 | 什么时候发生 | 干什么 |
|---|---|---|
| 装载期(定义与注册) | agent 构造、模块导入时 | 把函数变成工具对象、生成 schema、登记进注册表 |
| 运行期(执行管线) | 模型返回一个 tool_call 之后 | 把"调用这句话"安全地落到真实函数上,拿回结果 |
怎么读下面这张图: 上半是装载期(左→右一次性完成),下半是运行期(模型触发后走的循环)。
装载期 ── 一次性 ────────────────────────────────────────────
@tool / BaseTool / MCP(...)
│ 从函数签名 + 类型注解生成 JSON schema
▼
FunctionTool / BaseTool 对象 ──register──► ToolRegistry(全局注册表)
│ │
└───────────► 汇总成 OpenAI 工具定义 ◄──────┘
│
▼ 作为 tools=[...] 传给 LLM
运行期 ── 每次工具调用 ──────────────────────────────────────
LLM 回一个 tool_call {name, arguments}
│
▼
execute_tool ──► 执行管线(类型纠正→审批→熔断→重试→包裹)──► 结果字符串
│ │
└──────────────── 结果回喂进对话,进入下一轮 ◄──────────┘
部件一句话职责:
| 部件 | 干什么 | 文件(clone 根相对) |
|---|---|---|
@tool / FunctionTool | 把函数包成工具、自动生成 schema | src/praisonai-agents/praisonaiagents/tools/decorator.py |
BaseTool / ToolResult | 工具基类与结果封装(含多模态) | .../tools/base.py |
| schema 生成 | 类型注解 → JSON schema | .../tools/schema.py |
ToolRegistry | 注册、查找、插件发现、信任级 | .../tools/registry.py |
执行管线 ToolExecutionMixin | 审批/熔断/重试/纠参/包裹 | .../agent/tool_execution.py |
| 安全护栏 | 路径/URL/信任/白名单 | .../tools/path_safety.py、url_safety.py、trust.py、.../allowed_tools_filter.py |
| 工具检索 | 工具太多时的渐进式披露 | .../tools/tool_search.py |
MCP | 把外部 MCP 服务器挂成工具 | .../mcp/mcp.py |
3. 核心原理(逐个机制,由浅入深)
3.1 从函数到 schema:定义与注册
要解决的小问题。 模型不认识 Python 函数,它只认识一段 JSON:"有个工具叫 X,吃这些参数、每个参数什么类型、哪些必填"。谁来把 def get_stock_price(company: str) 翻成这段 JSON?
思路。 用 inspect 读函数签名 + typing 读类型注解,逐个参数转成 JSON schema 片段。核心翻译器是 annotation_to_json_schema,它认得 Optional、Union、Literal、Enum、List、Dict 这些复杂类型,而不是一律塌成 "string"(.../tools/schema.py:23 annotation_to_json_schema)。
三种定义工具的方式(条条大路,最后都变成一个"有 name/description/get_schema() 的对象"):
| 方式 | 怎么写 | 生成 schema 的入口 |
|---|---|---|
| 装饰器 | @tool 包一个函数 | FunctionTool._generate_schema_from_func(decorator.py:103) |
| 子类 | 继承 BaseTool、实现 run() | BaseTool._generate_parameters_schema(base.py:202) |
| 裸函数 | 直接把函数放进 tools=[...] | Agent._generate_tool_definition(agent/agent.py:403) |
前两种最后都调到同一个共享助手 build_parameters_schema(schema.py:156)——它遍历签名,跳过 self/cls 和被标记为"注入"的参数,对每个参数调 annotation_to_json_schema,再用 get_parameter_requirements(schema.py:123)判定必填(无默认值且非 Optional 才必填)。
原理演示(把"签名 → schema"的核心想法演出来):
# 示意,非源码:build_parameters_schema 的骨架
def build_parameters_schema(sig, hints):
schema = {"type": "object", "properties": {}, "required": []}
for name, _ in sig.parameters.items():
if name in {"self", "cls"}:
continue # 跳过实例参数
ptype = hints.get(name, Any)
schema["properties"][name] = annotation_to_json_schema(ptype) # 类型 → schema
if has_no_default_and_not_optional(sig, name):
schema["required"].append(name) # 无默认值 = 必填
return schema
真实实现要点。
@tool装饰器同时支持@tool和@tool(name=..., retry_policy=...)两种写法,内部统一走decorator(fn)(decorator.py:181tool)。- 装饰时立刻做两道校验并把工具登记进全局注册表:
validate()+validate_schema_roundtrip()(decorator.py:237-238),再registry.register(...)(decorator.py:252)。validate_schema_roundtrip(base.py:337)会把 schema JSON 序列化再反序列化,确保它真能发给 LLM 且结构不变——这是"定义期就把错误抓出来"的设计。 ToolResult(base.py:32)不止能装文本,还能装多模态内容(图片/文件 part),让截图、图表这类工具的产物在下一轮变成模型可见的图像消息;multimodal_content/image_part是它的便捷工厂(base.py:100)。
注册表长什么样。 ToolRegistry(registry.py:81)是个线程安全(RLock)的单例,内部用 Dict[str, ToolEntry] 存工具,每个 ToolEntry 记着工具本体、动态 schema 覆盖函数、以及信任级(trust_level)。关键方法:
| 方法 | 作用 | 位置 |
|---|---|---|
register | 登记工具(重名默认跳过) | registry.py:100 |
get | 按名取工具,找不到才触发插件发现 | registry.py:183 |
get_tool_definitions | 汇总所有可用工具的 OpenAI schema(每次发给 LLM 前调) | registry.py:221 |
discover_plugins | 从 entry_points 自动发现第三方工具 | registry.py:361 |
get_trust_level | 查工具信任级(审批时要用) | registry.py:433 |
外部包只要在 pyproject.toml 里声明 [project.entry-points."praisonaiagents.tools"],就能被 discover_plugins 自动装进来(registry.py:361)——这是"工具即插件"的机制。
3.2 Injected:给工具偷偷塞进 agent 状态
要解决的小问题。 有些工具需要知道"当前是哪个 session、哪个 agent、上一条用户消息",但你不想让模型看到、更不想让模型来填这些参数。
思路。 用类型标记 Injected[T]。被这样标注的参数:①不出现在给模型的 schema 里(生成 schema 时被 skip_predicate 跳过,见 decorator.py:121);②运行时由框架自动填入当前 AgentState。
# 示意,基于 injected.py 的用法
from praisonaiagents import tool
from praisonaiagents.tools import Injected
@tool
def whoami(query: str, state: Injected[dict]) -> str:
# state 由框架注入,模型既看不到也填不了
return f"session={state.get('session_id')}"
真实实现。 执行前,execute_tool 先组一个 AgentState(装 agent_id/run_id/session_id/上一条用户消息/记忆句柄等,tool_execution.py:436),再用 with_injection_context(state) 把它压进一个 ContextVar(injected.py:95);FunctionTool.run 真正调用前,inject_state_into_kwargs 从 ContextVar 取状态填进 kwargs(decorator.py:125-129、injected.py:185)。用 ContextVar 而非全局变量,是为了多 agent 并发时互不串状态。
3.3 执行管线:一次调用的受控之旅
这是本章工程含量最高的部分。入口是 execute_tool(function_name, arguments, tool_call_id)(tool_execution.py:412),它把这次调用送进一条分层管线;下面拆成"外层护送"和"内层落地"两张图看。
外层护送(在 _execute_tool_with_context,tool_execution.py:449):
execute_tool
│ 组 AgentState、开注入上下文
▼
① 发 TOOL_CALL_START 事件(trace / 流式)
② steering 检查:高优先级用户插话可中断本次调用
③ BEFORE_TOOL 钩子:可【拦截】或【改写参数】
④ loop guard:防工具死循环(WARN / BLOCK / HALT)
⑤ 参数校验器(可选):不合规直接拒
│
▼ 外层重试循环(attempt = 1 .. max_retry_limit+1)
⑥ 可选超时:放进线程池,超时判 retryable
└──► 内层落地(见下图)──► 结果 / 错误
│
▼ 拿到结果后
⑦ wrap_if_external:外部工具结果打隔离标记(防注入)
⑧ 截断:超长输出压缩,防撑爆上下文
⑨ AFTER_TOOL 钩子
内层落地(_execute_tool_with_circuit_breaker → _impl → _execute_tool_impl):
_execute_tool_with_circuit_breaker (1342)
│ 取重试策略(工具级 > agent级 > 默认)
▼ 内层重试循环(attempt = 0 .. max_attempts-1)
_execute_tool_with_circuit_breaker_impl (1446)
│ 为该工具取/建熔断器
▼
breaker.call(_tool_wrapper)
│
▼
_execute_tool_impl (1513)
├─ 审批 _check_tool_approval_sync ──拒─► 返回 approval_denied
├─ MCP 工具?在 MCP 实例里找并调
├─ 在 agent.tools 列表里找(BaseTool / FunctionTool / 裸函数)
├─ 没找到 → 查全局注册表(插件)
└─ _cast_arguments 纠正参数类型 ──► 调 func.run(**args) / func(**args)
为什么有两层重试? 这是刻意设计,不是重复。内层(_execute_tool_with_circuit_breaker,tool_execution.py:1358)按 RetryPolicy.retry_on 只重试特定错误类型(超时/限流/连接错误);外层(_execute_tool_with_context,tool_execution.py:581)是兜底。代码在 tool_execution.py:634-643 明确解释了它们如何避免"叠加空转":内层已经重试过并耗尽的错误类型(如 timeout),外层不再当 retryable 重跑,只有内层不重试的类型(如 unknown)才交给外层再走一遍。
参数纠正 _cast_arguments(tool_execution.py:356)。 LLM 给的参数经常有毛病,这一步专治:
- kwarg 名带尾随
=、空格 → 清洗(raw_name.strip().rstrip('='))。 - 大小写对不上 → 大小写不敏感模糊匹配到真实参数名。
- 类型串了:字符串
"3"传给int参数 →int(float("3"));"true"传给bool→ 按('true','1','yes','on')判定。
参数纠正的容错映射:
| LLM 给的 | 目标类型 | 纠正为 |
|---|---|---|
"42" / 42.0 | int | int(float(x)) |
"3.14" / 3 | float | float(x) |
"yes" / "on" / "1" | bool | True |
"City="(脏名) | —— | 清成 "City" 再匹配 |
重试退避。 延迟由 BackoffPolicy.delay(外层,tool_execution.py:28)和 RetryPolicy.get_delay_ms(内层,tools/retry.py:51)算:都是指数退避 + 抖动 + 封顶(initial * factor^attempt,加随机抖动,min(delay, max_delay))。抖动是为了避免多个失败调用同时重试造成"惊群"。
错误分类 _classify_error_type(tool_execution.py:1890)。 决定"这个错该不该重试"的关键——按错误消息/异常类型的关键词归类:
| 归类 | 触发关键词 | 默认是否重试 |
|---|---|---|
timeout | "timeout" / "timed out" | 是 |
rate_limit | "rate" 且 "limit" | 是 |
connection_error | "connection" / "network" | 是 |
unknown | 其它 | 否(内层),交外层兜一次 |
默认可重试集合是 {"timeout", "rate_limit", "connection_error"}(tools/retry.py:28)。
3.4 安全护栏:四道闸
管线里散布着四类护栏,各防一种事。一张表看全:
| 护栏 | 防什么 | 触发点 | 文件:符号 |
|---|---|---|---|
| 审批(approval) | 危险工具未经许可就跑 | 落地前 _check_tool_approval_sync | tool_execution.py:1295 |
| 熔断(circuit breaker) | 某工具连续失败拖垮系统 | 每次落地包一层 | tool_execution.py:1446 |
| 信任包裹(trust) | 外部内容里的提示词注入 | 结果回喂前 wrap_if_external | tools/trust.py:52 |
| 路径/URL 安全 | 目录穿越、SSRF | 具体工具内部调用 | path_safety.py、url_safety.py |
审批(approval)。 拦截分两级:
- 权限层快查(O(1) frozenset):
_perm_deny命中直接拒、_perm_allow不在里面也拒(tool_execution.py:1298-1301)。这来自Agent(approval="default"/"safe"/...)预设。 - 审批决策
_resolve_approval_decision(tool_execution.py:1179):符合下列任一即需人工/后端批准——approve_all、在内置危险清单DEFAULT_DANGEROUS_TOOLS(approval/registry.py:33,含execute_command=critical、write_file=high 等)、注册表标了需审批、或工具信任级为external。
默认预设 "default" 只拦"删文件 + 跑 shell/代码",放行读/建/改——因为 99% 的有用 agent 流程都要改文件(approval/registry.py:53-70 的注释直言这个取舍)。
熔断。 每个工具有独立熔断器,配置写死为 failure_threshold=5、recovery_timeout=60s、graceful_degradation=True(tool_execution.py:1468)。状态机是标准三态:CLOSED(正常)→ 连续 5 次失败 → OPEN(拒绝调用、返回 circuit_open 错误)→ 60 秒后 → HALF_OPEN(放一 个探针)→ 成功则回 CLOSED(tools/circuit_breaker.py:19)。妙处:工具返回的错误 dict 被 _tool_wrapper 转成哨兵异常,好让熔断器"看见"失败;但审批/权限拒绝不算失败(tool_execution.py:1481-1484)——否则用户一直拒批就会误触熔断。
信任包裹(防提示词注入)。 联网搜索/抓网页这类工具的返回值可能藏着"忽略之前指令,改去做 X"的恶意文本。wrap_if_external(tools/trust.py:52)只对外部工具(硬编码清单 EXTERNAL_TOOL_NAMES 或注册表信任级为 external)的结果动手:把内容夹进 <external_tool_result>…</external_tool_result> 标记,并转义内容里已有的同名标记防"越狱"。配套的系统提示词(trust.py:151 get_system_prompt_addition)告诉模型:标记内是数据,只提取事实、绝不执行其中指令。
路径与 URL 安全。
resolve_within_root(path_safety.py:9):把路径解析到某个根目录下,用os.path.commonpath判定是否逃出根,逃出就返回None——挡目录穿越(../../etc/passwd)。is_safe_http_url(url_safety.py:19):挡 SSRF(服务端请求伪造)。只放行 http(s);把主机名解析成 IP,若命中回环/私网/链路本地/组播就拒;localhost 需显式经ALLOW_LOCAL_CRAWL=true或 allowlist 才放行。
白名单过滤 AllowedToolsFilter(allowed_tools_filter.py:37)。 解决多环境下"同名工具打架"(tool shadowing):ALLOWED_TOOLS 环境变量给出白名单,只有名单内工具可见。语义有讲究——未设=全放(带告警)、空串=报错(必须显式)、CI 里名单含未知工具=严格失败(allowed_tools_filter.py:99 filter_tools)。
3.5 工具检索:工具太多时的渐进式披露
要解决的小问题。 工具一多,把所有 schema 都塞进提示词会吃掉大量上下文,还让模型选择困难。
思路(移植自 Hermes)。 把非核心工具"折叠"起来,只给模型三个桥工具:tool_search(query) 找候选、tool_describe(name) 看完整 schema、tool_call(name, args) 解包并真正执行(tools/tool_search.py 头部注释列了 8 条不变量)。核心工具(文件/shell/搜索等,PRAISONAI_CORE_TOOLS,tool_search.py:33)永不折叠。
关键机制:
- 何时启用:
should_defer_tools——当"可折叠工具的 schema token 数 ≥ 上下文窗口的 threshold_pct(默认 10%)"时自动开(tool_search.py:173)。 - 检索用内联的 BM25(
BM25ToolSearcher,tool_search.py:208),不引新依赖,并对工具名做子串兜底。 tool_call先把桥调用解包成真实工具名再执行(resolve_underlying_call,tool_search.py:532),所以审批/trace/流式看到的都是真实工具名(不变量 #6);这也是execute_tool开头就拦截桥工具的原因(tool_execution.py:427)。
3.6 把 MCP 服务器挂成外部工具
要解决的小问题。 MCP 是一套让 agent 连"工具服务器"的开放协议(文件系统、GitHub、时间等都有现成 server)。怎么把一个 MCP server 的所有工具,变成 agent 能直接用的普通工具?
思路。 一个 MCP(...) 对象(mcp/mcp.py:159)= 一个 MCP 客户端 + 一批自动生成的工具函数。它可迭代(__iter__,mcp/mcp.py:604),所以能直接 Agent(tools=MCP("npx ..."))。
传输自动识别。 构造时按字符串判定用哪种传输(mcp/mcp.py:263 起、独立函数 get_transport_type,mcp/mcp_transport.py:115):
| 目标字符串 | 传输 | 客户端 |
|---|---|---|
ws:// / wss:// | WebSocket | WebSocketMCPClient |
http(s)://…/sse | SSE(旧式) | SSEMCPClient |
其它 http(s):// | Streamable HTTP | HTTPStreamMCPClient |
命令字符串(如 npx …) | stdio | MCPToolRunner(独立线程) |
stdio 是怎么跑的。 MCPToolRunner(mcp/mcp.py:25)是个守护线程,内部跑一个 asyncio 事件循环,initialize 后 list_tools 拉到工具清单,然后从队列里收"调用请求"逐个 session.call_tool。用独立线程 + 队列,是为了把 MCP 的异步世界桥接到同步的工具调用世界。
动态造函数(最巧的一处)。 stdio 工具没有 Python 签名,_create_tool_wrapper(mcp/mcp.py:403)读 MCP 工具的 inputSchema,把 JSON 类型映射回 Python 类型(string→str、integer→int…),用 inspect.Parameter 手工拼出一个函数签名贴到 wrapper 上(mcp/mcp.py:465-487)。这样 3.1 节那套"从签名生成 schema"的机制就能无差别地处理 MCP 工具。
执行路径。 运行期在 _execute_tool_impl 里,先检测 self.tools 是不是 MCP 实例、或列表里含 MCP 实例,是就按传输类型在对应客户端里找同名工具并调用(tool_execution.py:1527-1581)——即 MCP 工具和本地工具共用同一条执行管线,一样过审批/熔断/包裹。
MCP 侧安全。 mcp/mcp_security.py 提供服务端护栏:Origin 校验防 DNS 重绑定(is_valid_origin :30、is_potential_dns_rebinding :110)、默认只绑 localhost(SecurityConfig :215)、Bearer/Basic 鉴权头、密码学安全的 session id(generate_secure_session_id :184)。
4. 内置工具族(只列不逐个讲)
tools/ 目录自带一大批开箱工具,风格统一(纯函数 + docstring),需要哪族按文件去看:
| 工具族 | 文件 | 大致能力 |
|---|---|---|
| 文件读写 | tools/file_tools.py、edit_tools.py | 读/写/编辑/打补丁 |
| Shell / 系统 | tools/shell_tools.py | 执行命令、进程管理 |
| Python 执行 | tools/python_tools.py | 跑 Python 代码 |
| Web 搜索 | tools/web_search.py、duckduckgo_tools.py、tavily_tools.py、exa_tools.py、searxng_tools.py | 各家搜索 |
| 网页抓取 | tools/crawl4ai_tools.py、spider_tools.py、web_crawl_tools.py | 爬取/抓取 |
| 协作类 | tools/github_tools.py、jira_tools.py、email_tools.py、messaging_tools.py | 对接外部服务 |
| agent 内务 | tools/memory.py、todo_tools.py、schedule_tools.py、clarify.py、subagent_tool.py | 记忆/待办/调度/澄清/子 agent |
这些大多被 3.4 的护栏点过名(如 web_search 属外部工具、execute_command/write_file 属危险工具)。
5. 巧妙之处(可借鉴的技术)
- 定义期就 round-trip 校验 schema。 装饰工具那一刻就把 schema JSON 序列化再反序列化比对,把"发不出去的 schema"在开发时抓死,而非运行时炸(
base.py:337validate_schema_roundtrip)。 - 参数容错前置。 认定"LLM 一定会把参数写脏",专门用
_cast_arguments洗 kwarg 名、纠类型(tool_execution.py:356),把脆弱性挡在真实函数之 外。 - 两层重试明确分工、避免空转。 内层按错误类型精准重试,外层兜底,并用注释级说明协调二者(
tool_execution.py:634-643),这是很多重试实现踩过的坑。 - 外部内容打隔离标记 + 系统提示配套。 用
<external_tool_result>围栏 + 转义 + 提示词三件套一起防提示词注入,而不是指望模型自觉(tools/trust.py:52、:151)。 - 给无签名的 MCP 工具手工造签名。 让协议来的工具复用同一套 schema/执行机制,避免为 MCP 单开一条分支(
mcp/mcp.py:403)。 - 注入用 ContextVar 而非全局。 多 agent 并发时状态不串台(
injected.py:81-95)。
6. 边界与局限(诚实)
- 多 MCP server 的工具名前缀防撞尚未实现。
load_mcp_tools的prefix_tools参数和mcp.py里的前缀逻辑都留着TODO,多 server 同名工具会撞(mcp/loader.py:79-88)。 - 裸函数解析会回落到
globals()/__main__。 老路径_generate_tool_definition(agent/agent.py:432-440)会去全局和__main__找函数;但新执行路径_execute_tool_impl刻意不回落 globals,找不到就拒(tool_execution.py:1609-1612),两条路径的解析范围不一致。 - 危险工具清单是硬编码的启发式。
DEFAULT_DANGEROUS_TOOLS靠名字匹配(approval/registry.py:33);自定义的危险工具若不主动登记信任级/审批要求,不会被自动拦。 - 错误分类靠关键词。
_classify_error_type用消息里的 "timeout"/"connection" 等子串判类型(tool_execution.py:1890),措辞不含这些词的错误会被归为unknown、少一轮重试。 - 熔断参数写死。
failure_threshold=5/recovery_timeout=60s在_impl里硬编码(tool_execution.py:1468),不随工具区分。
7. 横向对比(同货架兄弟)
- 工具主循环在何处触发调用、消息如何回喂,见 01-agent-chat-loop.md。
- 工具执行依赖的 LLM 层(如何把工具 schema 发给不同 provider、tool_call 如何解析),见 03-llm-layer.md。
- 多 agent 里工具与 Task/编排的关系,见 04-multi-agent-orchestration.md。
- 沙箱级隔离执行(比本章审批/熔断更强的进程隔离),属 06-memory-knowledge-reliability.md 的可靠性外围。
- 总览与阅读顺序见 index.md。