fix(extension): 完成MCP命令目标并收紧Schema边界
This commit is contained in:
@@ -11,6 +11,7 @@ from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
|
||||
|
||||
from app.contracts import ToolCall, ToolDefinition, ToolResult
|
||||
from app.schema_security import reject_external_schema_references
|
||||
|
||||
ToolExecutor = Callable[[BaseModel, "ToolExecutionContext"], Any | Awaitable[Any]]
|
||||
|
||||
@@ -54,6 +55,7 @@ class ToolRegistry:
|
||||
arguments_model: type[BaseModel],
|
||||
executor: ToolExecutor,
|
||||
) -> None:
|
||||
reject_external_schema_references(definition.parameters)
|
||||
with self._lock:
|
||||
if definition.name in self._tools:
|
||||
raise ValueError(f"Tool already registered: {definition.name}")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
@@ -17,7 +18,7 @@ from typing import Any, Awaitable, Callable, Literal
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import SchemaError, ValidationError as JsonSchemaValidationError
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.config import get_settings
|
||||
from app.contracts import (
|
||||
@@ -34,6 +35,10 @@ from app.contracts import (
|
||||
)
|
||||
from app.extensions.errors import ExtensionError
|
||||
from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore
|
||||
from app.schema_security import (
|
||||
SchemaReferenceError,
|
||||
reject_external_schema_references,
|
||||
)
|
||||
|
||||
_CONTRIBUTION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
||||
_SETTING_KEY = re.compile(r"^[a-z][a-z0-9._-]{0,127}$")
|
||||
@@ -74,9 +79,16 @@ class PluginCommandSpec(BaseModel):
|
||||
)
|
||||
permission: str | None = None
|
||||
secrets: list[str] = Field(default_factory=list)
|
||||
handler: Literal["echo", "uppercase_selection"]
|
||||
handler: Literal["echo", "uppercase_selection"] | None = None
|
||||
mcp_tool: str | None = None
|
||||
timeout_seconds: int = Field(default=30, ge=1, le=120)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_execution_target(self) -> "PluginCommandSpec":
|
||||
if (self.handler is None) == (self.mcp_tool is None):
|
||||
raise ValueError("Command must declare exactly one handler or mcp_tool target.")
|
||||
return self
|
||||
|
||||
|
||||
CommandExecutor = Callable[
|
||||
[dict[str, Any], dict[str, Any]],
|
||||
@@ -689,11 +701,13 @@ def validate_command_spec(plugin_id: str, spec: PluginCommandSpec) -> None:
|
||||
if spec.parameters.get("type", "object") != "object":
|
||||
raise ExtensionError("PLUGIN_COMMAND_INVALID", "Command parameters must be an object schema.")
|
||||
try:
|
||||
reject_external_schema_references(spec.parameters)
|
||||
Draft202012Validator.check_schema(spec.parameters)
|
||||
except SchemaError as exc:
|
||||
except (SchemaReferenceError, SchemaError) as exc:
|
||||
message = exc.message if isinstance(exc, SchemaError) else str(exc)
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
f"Plugin command parameters contain invalid JSON Schema: {exc.message}",
|
||||
f"Plugin command parameters contain invalid JSON Schema: {message}",
|
||||
) from exc
|
||||
|
||||
|
||||
@@ -746,7 +760,8 @@ def _secret_field(
|
||||
|
||||
|
||||
def _secret_reference(plugin_id: str, key: str) -> str:
|
||||
return f"plugin.{plugin_id}.{key}"
|
||||
digest = hashlib.sha256(f"{plugin_id}\0{key}".encode("utf-8")).hexdigest()
|
||||
return f"plugin.{digest}"
|
||||
|
||||
|
||||
def _settings_schema_error(plugin_id: str, message: str) -> ExtensionError:
|
||||
|
||||
@@ -29,6 +29,10 @@ from app.contracts import (
|
||||
PluginHostStatus,
|
||||
ToolDefinition,
|
||||
)
|
||||
from app.schema_security import (
|
||||
SchemaReferenceError,
|
||||
reject_external_schema_references,
|
||||
)
|
||||
|
||||
MCP_PROTOCOL_VERSION = "2025-11-25"
|
||||
SUPPORTED_PROTOCOL_VERSIONS = {
|
||||
@@ -676,11 +680,13 @@ class McpBridge:
|
||||
f"MCP tool inputSchema must be an object schema: {remote_name}",
|
||||
)
|
||||
try:
|
||||
reject_external_schema_references(schema)
|
||||
Draft202012Validator.check_schema(schema)
|
||||
except SchemaError as exc:
|
||||
except (SchemaReferenceError, SchemaError) as exc:
|
||||
message = exc.message if isinstance(exc, SchemaError) else str(exc)
|
||||
raise McpBridgeError(
|
||||
"MCP_TOOL_SCHEMA_INVALID",
|
||||
f"Invalid MCP tool schema for {remote_name}: {exc.message}",
|
||||
f"Invalid MCP tool schema for {remote_name}: {message}",
|
||||
) from exc
|
||||
metadata = raw.get("_meta")
|
||||
permission = (
|
||||
|
||||
@@ -5,13 +5,14 @@ import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import yaml
|
||||
from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import SchemaError
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model
|
||||
|
||||
from app.agent.tools import ToolExecutionContext, ToolRegistry
|
||||
from app.agent.tools import ToolExecutionContext, ToolExecutionError, ToolRegistry
|
||||
from app.agent.permissions import KNOWN_PERMISSIONS
|
||||
from app.contracts import (
|
||||
ModelCapability,
|
||||
@@ -45,6 +46,10 @@ from app.extensions.contributions import (
|
||||
from app.extensions.errors import ExtensionError
|
||||
from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool
|
||||
from app.providers.credentials import EncryptedCredentialStore
|
||||
from app.schema_security import (
|
||||
SchemaReferenceError,
|
||||
reject_external_schema_references,
|
||||
)
|
||||
|
||||
_EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
||||
|
||||
@@ -410,6 +415,22 @@ class PluginRuntime:
|
||||
"Commands using Secret settings require the secrets.use permission.",
|
||||
details={"command_id": spec.command_id},
|
||||
)
|
||||
if spec.mcp_tool is not None:
|
||||
_validate_id("MCP command target", spec.mcp_tool)
|
||||
if manifest.backend.type != "mcp" or not spec.mcp_tool.startswith(
|
||||
f"{manifest.plugin_id}."
|
||||
):
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"MCP Command target must use the current Plugin namespace.",
|
||||
details={"command_id": spec.command_id},
|
||||
)
|
||||
if spec.mcp_tool in manifest.contributes.tools:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_INVALID",
|
||||
"MCP Command target cannot also be exposed as an Agent Tool.",
|
||||
details={"command_id": spec.command_id},
|
||||
)
|
||||
|
||||
record = _PluginRecord(
|
||||
plugin=Plugin(
|
||||
@@ -492,14 +513,21 @@ class PluginRuntime:
|
||||
discovered = self._start_mcp(record)
|
||||
actual = {item.definition.name for item in discovered}
|
||||
declared = set(declared_tools)
|
||||
if actual != declared:
|
||||
command_targets = {
|
||||
spec.mcp_tool for spec in record.commands if spec.mcp_tool is not None
|
||||
}
|
||||
expected = declared | command_targets
|
||||
if actual != expected:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_CONTRIBUTION_INVALID",
|
||||
"Discovered MCP tools must exactly match Plugin contributions.",
|
||||
details={"declared": sorted(declared), "actual": sorted(actual)},
|
||||
"Discovered MCP tools must exactly match Tool and Command targets.",
|
||||
details={"declared": sorted(expected), "actual": sorted(actual)},
|
||||
)
|
||||
for item in discovered:
|
||||
self._register_mcp_tool(record, item)
|
||||
if item.definition.name in declared:
|
||||
self._register_mcp_tool(record, item)
|
||||
else:
|
||||
record.mcp_remote_names[item.definition.name] = item.remote_name
|
||||
else:
|
||||
for spec in record.tools:
|
||||
arguments_model = _arguments_model(spec)
|
||||
@@ -549,6 +577,7 @@ class PluginRuntime:
|
||||
if _record.settings_definition is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
def resolve_secret(key: str) -> str | None:
|
||||
if key not in _spec.secrets:
|
||||
raise ExtensionError(
|
||||
@@ -569,11 +598,62 @@ class PluginRuntime:
|
||||
)
|
||||
if _record.settings_definition is None:
|
||||
return None
|
||||
return self.settings.resolve_secret(
|
||||
value = self.settings.resolve_secret(
|
||||
_record.plugin.manifest.plugin_id,
|
||||
_record.settings_definition,
|
||||
key,
|
||||
)
|
||||
field = next(
|
||||
item
|
||||
for item in _record.settings_definition.fields
|
||||
if item.key == key
|
||||
)
|
||||
if field.required and value is None:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_SECRET_REQUIRED",
|
||||
"A required Plugin Secret has not been configured.",
|
||||
status_code=409,
|
||||
details={"command_id": _spec.command_id, "key": key},
|
||||
)
|
||||
return value
|
||||
|
||||
if _spec.mcp_tool is not None:
|
||||
remote_name = _record.mcp_remote_names[_spec.mcp_tool]
|
||||
secret_values = {
|
||||
key: value
|
||||
for key in _spec.secrets
|
||||
if (value := resolve_secret(key)) is not None
|
||||
}
|
||||
try:
|
||||
effect = await self.mcp.call_tool(
|
||||
_record.plugin.manifest.plugin_id,
|
||||
remote_name,
|
||||
{
|
||||
"_notesagent": {
|
||||
"command_id": _spec.command_id,
|
||||
"arguments": arguments,
|
||||
"context": context,
|
||||
"secrets": secret_values,
|
||||
}
|
||||
},
|
||||
request_id=f"command:{uuid4().hex}",
|
||||
)
|
||||
except ToolExecutionError as exc:
|
||||
raise ExtensionError(
|
||||
exc.code,
|
||||
"MCP Command target execution failed.",
|
||||
status_code=502,
|
||||
details={"command_id": _spec.command_id},
|
||||
) from exc
|
||||
try:
|
||||
return PluginCommandEffect.model_validate(effect)
|
||||
except ValidationError as exc:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_COMMAND_RESULT_INVALID",
|
||||
"MCP Command target returned an invalid effect.",
|
||||
status_code=502,
|
||||
details={"command_id": _spec.command_id},
|
||||
) from exc
|
||||
|
||||
return await self.host.execute_command(
|
||||
_spec.handler,
|
||||
@@ -963,11 +1043,13 @@ def _arguments_model_from_schema(
|
||||
def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
|
||||
schema = spec.parameters or {"type": "object", "properties": {}}
|
||||
try:
|
||||
reject_external_schema_references(schema)
|
||||
Draft202012Validator.check_schema(schema)
|
||||
except SchemaError as exc:
|
||||
except (SchemaReferenceError, SchemaError) as exc:
|
||||
message = exc.message if isinstance(exc, SchemaError) else str(exc)
|
||||
raise ExtensionError(
|
||||
"PLUGIN_TOOL_SCHEMA_INVALID",
|
||||
f"Invalid JSON Schema for tool {spec.name}: {exc.message}",
|
||||
f"Invalid JSON Schema for tool {spec.name}: {message}",
|
||||
details={"tool": spec.name},
|
||||
) from exc
|
||||
if schema.get("type", "object") != "object" or not isinstance(
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""共享 JSON Schema 安全约束。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
class SchemaReferenceError(ValueError):
|
||||
"""Schema 引用不符合宿主的离线、文档内解析约束。"""
|
||||
|
||||
|
||||
class ExternalSchemaReferenceError(SchemaReferenceError):
|
||||
def __init__(self, keyword: str, reference: Any) -> None:
|
||||
super().__init__(f"External JSON Schema reference is not allowed: {reference!r}")
|
||||
self.keyword = keyword
|
||||
self.reference = reference
|
||||
|
||||
|
||||
class UnresolvableLocalSchemaReferenceError(SchemaReferenceError):
|
||||
def __init__(self, reference: str) -> None:
|
||||
super().__init__(f"Local JSON Schema reference cannot be resolved: {reference!r}")
|
||||
self.reference = reference
|
||||
|
||||
|
||||
def reject_external_schema_references(schema: Any) -> None:
|
||||
"""只允许可解析的文档内 Fragment,禁止文件和网络检索。"""
|
||||
|
||||
pending = [schema]
|
||||
local_references: list[str] = []
|
||||
anchors: set[str] = set()
|
||||
while pending:
|
||||
value = pending.pop()
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
if key in {"$ref", "$dynamicRef"}:
|
||||
if not isinstance(child, str) or not child.startswith("#"):
|
||||
raise ExternalSchemaReferenceError(key, child)
|
||||
local_references.append(child)
|
||||
elif key in {"$anchor", "$dynamicAnchor"} and isinstance(child, str):
|
||||
anchors.add(child)
|
||||
pending.append(child)
|
||||
elif isinstance(value, list):
|
||||
pending.extend(value)
|
||||
|
||||
for reference in local_references:
|
||||
if not _local_reference_exists(schema, reference, anchors):
|
||||
raise UnresolvableLocalSchemaReferenceError(reference)
|
||||
|
||||
|
||||
def _local_reference_exists(schema: Any, reference: str, anchors: set[str]) -> bool:
|
||||
fragment = unquote(reference[1:])
|
||||
if not fragment:
|
||||
return True
|
||||
if not fragment.startswith("/"):
|
||||
return fragment in anchors
|
||||
|
||||
current = schema
|
||||
for encoded_segment in fragment[1:].split("/"):
|
||||
segment = encoded_segment.replace("~1", "/").replace("~0", "~")
|
||||
if isinstance(current, dict) and segment in current:
|
||||
current = current[segment]
|
||||
elif isinstance(current, list) and segment.isdecimal():
|
||||
index = int(segment)
|
||||
if index >= len(current):
|
||||
return False
|
||||
current = current[index]
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
Reference in New Issue
Block a user