whetkit 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.
@@ -0,0 +1,73 @@
1
+ """The overlay: presents a curated tool set while delegating every call to
2
+ the untouched origin server.
3
+
4
+ Two ways to use it:
5
+
6
+ - :class:`CuratedMCPClient` — in-process overlay used by the eval runner for
7
+ before/after comparisons.
8
+ - :func:`serve_overlay` — a real stdio MCP server (``whetkit overlay``) so
9
+ any MCP client (Claude Code, an IDE, another agent) can talk to the
10
+ curated view. The origin server is never modified; stop the proxy and
11
+ nothing remains.
12
+ """
13
+
14
+ import mcp.types as types
15
+ from mcp.server.lowlevel import Server
16
+ from mcp.server.stdio import stdio_server
17
+
18
+ from whetkit.curation.plan import CurationPlan
19
+ from whetkit.mcp import MCPClient, ServerSpec
20
+
21
+
22
+ class UnknownCuratedTool(Exception):
23
+ pass
24
+
25
+
26
+ class CuratedMCPClient(MCPClient):
27
+ """MCPClient that shows plan-transformed tools and un-maps names on call."""
28
+
29
+ def __init__(self, spec: ServerSpec, plan: CurationPlan):
30
+ super().__init__(spec)
31
+ self.plan = plan
32
+ self._name_map: dict[str, str] | None = None
33
+
34
+ async def _mapping(self) -> dict[str, str]:
35
+ if self._name_map is None:
36
+ origin_tools = await super().list_tools()
37
+ self._name_map = self.plan.presented_to_original({t.name for t in origin_tools})
38
+ return self._name_map
39
+
40
+ async def list_tools(self) -> list[types.Tool]:
41
+ tools = await super().list_tools()
42
+ self._name_map = self.plan.presented_to_original({t.name for t in tools})
43
+ return self.plan.transform_tools(tools)
44
+
45
+ async def call_tool(self, name: str, arguments: dict) -> types.CallToolResult:
46
+ mapping = await self._mapping()
47
+ if name not in mapping:
48
+ raise UnknownCuratedTool(f"tool {name!r} is not part of the curated tool set")
49
+ return await super().call_tool(mapping[name], arguments)
50
+
51
+
52
+ def build_overlay_server(client: CuratedMCPClient, name: str = "whetkit-overlay") -> Server:
53
+ server = Server(name)
54
+
55
+ @server.list_tools()
56
+ async def _list_tools() -> list[types.Tool]:
57
+ return await client.list_tools()
58
+
59
+ @server.call_tool()
60
+ async def _call_tool(tool_name: str, arguments: dict) -> types.CallToolResult:
61
+ # Pass the origin's result through verbatim (content, structured
62
+ # content, and error flag) — the overlay transforms metadata only.
63
+ return await client.call_tool(tool_name, arguments)
64
+
65
+ return server
66
+
67
+
68
+ async def serve_overlay(origin: ServerSpec, plan: CurationPlan) -> None:
69
+ """Run the overlay as a stdio MCP server until the client disconnects."""
70
+ async with CuratedMCPClient(origin, plan) as client:
71
+ server = build_overlay_server(client)
72
+ async with stdio_server() as (read, write):
73
+ await server.run(read, write, server.create_initialization_options())
@@ -0,0 +1,109 @@
1
+ """Curation plans: a declarative, reversible description of tool-set changes.
2
+
3
+ A plan never touches the origin server. It only says how the overlay should
4
+ *present* the origin's tools: hide (prune), rename, or rewrite descriptions.
5
+ Merging duplicate tools is expressed as hiding the redundant copies and
6
+ renaming/redescribing the canonical one — call behavior is always delegated
7
+ 1:1 to an existing origin tool, which is what keeps the overlay fully
8
+ reversible (delete the plan and nothing remains).
9
+
10
+ Plans serialize to YAML so they can be reviewed, versioned, and hand-edited.
11
+ """
12
+
13
+ import re
14
+ from pathlib import Path
15
+
16
+ import mcp.types as types
17
+ import yaml
18
+ from pydantic import BaseModel, Field
19
+
20
+ _NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
21
+
22
+
23
+ class ToolOverride(BaseModel):
24
+ """How the overlay presents one origin tool."""
25
+
26
+ original_name: str
27
+ new_name: str | None = None
28
+ new_description: str | None = None
29
+ hidden: bool = False
30
+ reason: str = ""
31
+
32
+ @property
33
+ def presented_name(self) -> str:
34
+ return self.new_name or self.original_name
35
+
36
+
37
+ class CurationPlan(BaseModel):
38
+ server: str = ""
39
+ notes: str = ""
40
+ overrides: list[ToolOverride] = Field(default_factory=list)
41
+
42
+ def override_for(self, original_name: str) -> ToolOverride | None:
43
+ return next((o for o in self.overrides if o.original_name == original_name), None)
44
+
45
+ def validate_against(self, origin_tool_names: set[str]) -> list[str]:
46
+ """Return every problem that makes the plan unsafe to apply."""
47
+ problems: list[str] = []
48
+ seen_originals: set[str] = set()
49
+ for override in self.overrides:
50
+ if override.original_name not in origin_tool_names:
51
+ problems.append(f"override targets unknown tool {override.original_name!r}")
52
+ if override.original_name in seen_originals:
53
+ problems.append(f"duplicate override for {override.original_name!r}")
54
+ seen_originals.add(override.original_name)
55
+ if override.new_name is not None and not _NAME_RE.match(override.new_name):
56
+ problems.append(f"invalid new name {override.new_name!r}")
57
+
58
+ presented = [
59
+ self.override_for(name).presented_name if self.override_for(name) else name
60
+ for name in sorted(origin_tool_names)
61
+ if not (self.override_for(name) and self.override_for(name).hidden)
62
+ ]
63
+ duplicates = {name for name in presented if presented.count(name) > 1}
64
+ problems.extend(f"presented tool name collision: {name!r}" for name in sorted(duplicates))
65
+ return problems
66
+
67
+ def presented_to_original(self, origin_tool_names: set[str]) -> dict[str, str]:
68
+ """Map every name the agent sees to the origin tool it delegates to."""
69
+ mapping: dict[str, str] = {}
70
+ for name in origin_tool_names:
71
+ override = self.override_for(name)
72
+ if override and override.hidden:
73
+ continue
74
+ mapping[override.presented_name if override else name] = name
75
+ return mapping
76
+
77
+ def transform_tools(self, tools: list[types.Tool]) -> list[types.Tool]:
78
+ """Present the origin's tool list through this plan."""
79
+ presented: list[types.Tool] = []
80
+ for tool in tools:
81
+ override = self.override_for(tool.name)
82
+ if override is None:
83
+ presented.append(tool)
84
+ continue
85
+ if override.hidden:
86
+ continue
87
+ presented.append(
88
+ tool.model_copy(
89
+ update={
90
+ "name": override.presented_name,
91
+ "description": (
92
+ override.new_description
93
+ if override.new_description is not None
94
+ else tool.description
95
+ ),
96
+ }
97
+ )
98
+ )
99
+ return presented
100
+
101
+
102
+ def save_plan(plan: CurationPlan, path: str | Path) -> None:
103
+ path = Path(path)
104
+ path.parent.mkdir(parents=True, exist_ok=True)
105
+ path.write_text(yaml.safe_dump(plan.model_dump(exclude_defaults=True), sort_keys=False))
106
+
107
+
108
+ def load_plan(path: str | Path) -> CurationPlan:
109
+ return CurationPlan.model_validate(yaml.safe_load(Path(path).read_text()))
@@ -0,0 +1,5 @@
1
+ """Eval datasets: task schema, loading, and validation."""
2
+
3
+ from whetkit.datasets.tasks import TaskSpec, load_task_file, load_tasks
4
+
5
+ __all__ = ["TaskSpec", "load_task_file", "load_tasks"]
@@ -0,0 +1,118 @@
1
+ """Task schema and YAML loader.
2
+
3
+ A task file is YAML holding either a single task mapping or a list of task
4
+ mappings. The format is documented in docs/task-format.md; the source of
5
+ truth for validation is :class:`TaskSpec`.
6
+ """
7
+
8
+ import re
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+ from pydantic import BaseModel, Field, field_validator
13
+
14
+ _ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
15
+
16
+
17
+ class TaskSpec(BaseModel):
18
+ """One eval task: a user request plus what a correct agent run looks like."""
19
+
20
+ id: str
21
+ prompt: str = Field(min_length=1)
22
+ server: str = Field(min_length=1, description="MCP server: URL, directory, or file path")
23
+ expected_tools: list[str | list[str]] = Field(
24
+ min_length=1,
25
+ description=(
26
+ "Tool calls a correct run makes. Each entry is one expected call; "
27
+ "an entry may list acceptable alternatives for that call."
28
+ ),
29
+ )
30
+ ordered: bool = Field(
31
+ default=False,
32
+ description="If true, expected calls must happen in the listed order.",
33
+ )
34
+ success_criteria: str = Field(
35
+ min_length=1,
36
+ description="Natural-language rubric the LLM judge grades the final answer against.",
37
+ )
38
+ tags: list[str] = []
39
+
40
+ @field_validator("id")
41
+ @classmethod
42
+ def _valid_id(cls, v: str) -> str:
43
+ if not _ID_RE.match(v):
44
+ raise ValueError(
45
+ f"task id {v!r} must be lowercase alphanumeric with '-' or '_' separators"
46
+ )
47
+ return v
48
+
49
+ @field_validator("expected_tools")
50
+ @classmethod
51
+ def _non_empty_slots(cls, v: list[str | list[str]]) -> list[str | list[str]]:
52
+ for slot in v:
53
+ if isinstance(slot, list) and not slot:
54
+ raise ValueError("an expected_tools alternatives list may not be empty")
55
+ return v
56
+
57
+ @property
58
+ def expected_tool_slots(self) -> list[list[str]]:
59
+ """expected_tools normalized so every slot is a list of alternatives."""
60
+ return [[s] if isinstance(s, str) else list(s) for s in self.expected_tools]
61
+
62
+ def resolve_server(self, base_dir: Path | None = None) -> str:
63
+ """Resolve a relative ``server`` path against the task file's directory."""
64
+ if self.server.startswith(("http://", "https://")):
65
+ return self.server
66
+ path = Path(self.server)
67
+ if not path.is_absolute() and base_dir is not None:
68
+ candidate = (base_dir / path).resolve()
69
+ if candidate.exists():
70
+ return str(candidate)
71
+ return self.server
72
+
73
+
74
+ def load_task_file(path: Path) -> list[TaskSpec]:
75
+ """Load one YAML file containing a task mapping or a list of them."""
76
+ data = yaml.safe_load(path.read_text())
77
+ if data is None:
78
+ raise ValueError(f"{path}: file is empty")
79
+ raw_tasks = data if isinstance(data, list) else [data]
80
+ tasks = []
81
+ for i, raw in enumerate(raw_tasks):
82
+ if not isinstance(raw, dict):
83
+ raise ValueError(f"{path}: entry {i} is not a mapping")
84
+ try:
85
+ task = TaskSpec.model_validate(raw)
86
+ except Exception as exc:
87
+ raise ValueError(f"{path}: entry {i} is invalid: {exc}") from exc
88
+ task = task.model_copy(update={"server": task.resolve_server(path.parent)})
89
+ tasks.append(task)
90
+ return tasks
91
+
92
+
93
+ def load_tasks(path: str | Path) -> list[TaskSpec]:
94
+ """Load tasks from a YAML file or every ``*.yaml``/``*.yml`` in a directory.
95
+
96
+ Raises ValueError on validation failures or duplicate task ids.
97
+ """
98
+ root = Path(path)
99
+ if root.is_dir():
100
+ files = sorted(p for p in root.iterdir() if p.suffix in (".yaml", ".yml"))
101
+ if not files:
102
+ raise ValueError(f"{root}: no .yaml/.yml task files found")
103
+ elif root.is_file():
104
+ files = [root]
105
+ else:
106
+ raise ValueError(f"{root}: no such file or directory")
107
+
108
+ tasks: list[TaskSpec] = []
109
+ seen: dict[str, Path] = {}
110
+ for file in files:
111
+ for task in load_task_file(file):
112
+ if task.id in seen:
113
+ raise ValueError(
114
+ f"duplicate task id {task.id!r} in {file} (first seen in {seen[task.id]})"
115
+ )
116
+ seen[task.id] = file
117
+ tasks.append(task)
118
+ return tasks
@@ -0,0 +1,24 @@
1
+ """Provider-abstracted LLM layer (Anthropic + OpenAI)."""
2
+
3
+ from whetkit.llm.base import (
4
+ ChatMessage,
5
+ LLMProvider,
6
+ LLMTurn,
7
+ ToolCall,
8
+ ToolDef,
9
+ ToolResult,
10
+ Usage,
11
+ )
12
+ from whetkit.llm.registry import get_provider, parse_model
13
+
14
+ __all__ = [
15
+ "ChatMessage",
16
+ "LLMProvider",
17
+ "LLMTurn",
18
+ "ToolCall",
19
+ "ToolDef",
20
+ "ToolResult",
21
+ "Usage",
22
+ "get_provider",
23
+ "parse_model",
24
+ ]
@@ -0,0 +1,90 @@
1
+ """Anthropic provider (Messages API, anthropic SDK).
2
+
3
+ Reads the API key from ``ANTHROPIC_API_KEY``.
4
+ """
5
+
6
+ import json
7
+ from typing import Any
8
+
9
+ from anthropic import AsyncAnthropic
10
+
11
+ from whetkit.llm.base import ChatMessage, LLMProvider, LLMTurn, ToolCall, ToolDef, Usage
12
+
13
+
14
+ def _to_anthropic_messages(messages: list[ChatMessage]) -> list[dict[str, Any]]:
15
+ out: list[dict[str, Any]] = []
16
+ for msg in messages:
17
+ blocks: list[dict[str, Any]] = []
18
+ if msg.role == "assistant":
19
+ if msg.content:
20
+ blocks.append({"type": "text", "text": msg.content})
21
+ for call in msg.tool_calls:
22
+ blocks.append(
23
+ {"type": "tool_use", "id": call.id, "name": call.name, "input": call.arguments}
24
+ )
25
+ else:
26
+ for result in msg.tool_results:
27
+ blocks.append(
28
+ {
29
+ "type": "tool_result",
30
+ "tool_use_id": result.call_id,
31
+ "content": result.content,
32
+ "is_error": result.is_error,
33
+ }
34
+ )
35
+ if msg.content:
36
+ blocks.append({"type": "text", "text": msg.content})
37
+ out.append({"role": msg.role, "content": blocks})
38
+ return out
39
+
40
+
41
+ class AnthropicProvider(LLMProvider):
42
+ name = "anthropic"
43
+
44
+ def __init__(self, client: AsyncAnthropic | None = None):
45
+ self._client = client or AsyncAnthropic()
46
+
47
+ async def complete(
48
+ self,
49
+ *,
50
+ model: str,
51
+ system: str | None,
52
+ messages: list[ChatMessage],
53
+ tools: list[ToolDef],
54
+ max_tokens: int = 1024,
55
+ ) -> LLMTurn:
56
+ kwargs: dict[str, Any] = {}
57
+ if system:
58
+ kwargs["system"] = system
59
+ if tools:
60
+ kwargs["tools"] = [
61
+ {"name": t.name, "description": t.description, "input_schema": t.input_schema}
62
+ for t in tools
63
+ ]
64
+ response = await self._client.messages.create(
65
+ model=model,
66
+ max_tokens=max_tokens,
67
+ messages=_to_anthropic_messages(messages),
68
+ **kwargs,
69
+ )
70
+
71
+ text_parts: list[str] = []
72
+ tool_calls: list[ToolCall] = []
73
+ for block in response.content:
74
+ if block.type == "text":
75
+ text_parts.append(block.text)
76
+ elif block.type == "tool_use":
77
+ arguments = block.input
78
+ if isinstance(arguments, str): # defensive: some models emit JSON strings
79
+ arguments = json.loads(arguments or "{}")
80
+ tool_calls.append(ToolCall(id=block.id, name=block.name, arguments=arguments))
81
+
82
+ return LLMTurn(
83
+ text="\n".join(text_parts) or None,
84
+ tool_calls=tool_calls,
85
+ usage=Usage(
86
+ input_tokens=response.usage.input_tokens,
87
+ output_tokens=response.usage.output_tokens,
88
+ ),
89
+ stop_reason=response.stop_reason,
90
+ )
whetkit/llm/base.py ADDED
@@ -0,0 +1,88 @@
1
+ """Provider-neutral chat/tool-use types and the provider interface.
2
+
3
+ The runner speaks only these types; each provider module translates to and
4
+ from its SDK's wire format. Adding a provider means implementing
5
+ :class:`LLMProvider` and registering it in ``registry.py``.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import Any, Literal
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+
14
+ class ToolDef(BaseModel):
15
+ """A tool offered to the model."""
16
+
17
+ name: str
18
+ description: str = ""
19
+ input_schema: dict[str, Any] = Field(default_factory=lambda: {"type": "object"})
20
+
21
+
22
+ class ToolCall(BaseModel):
23
+ """A tool invocation requested by the model."""
24
+
25
+ id: str
26
+ name: str
27
+ arguments: dict[str, Any] = {}
28
+
29
+
30
+ class ToolResult(BaseModel):
31
+ """The outcome of executing a ToolCall, fed back to the model."""
32
+
33
+ call_id: str
34
+ content: str
35
+ is_error: bool = False
36
+
37
+
38
+ class ChatMessage(BaseModel):
39
+ """One conversation message in provider-neutral form.
40
+
41
+ - role="user": ``content`` holds user text and/or ``tool_results`` holds
42
+ results for the assistant's previous tool calls.
43
+ - role="assistant": ``content`` holds assistant text and ``tool_calls``
44
+ holds any tool invocations it requested.
45
+ """
46
+
47
+ role: Literal["user", "assistant"]
48
+ content: str | None = None
49
+ tool_calls: list[ToolCall] = []
50
+ tool_results: list[ToolResult] = []
51
+
52
+
53
+ class Usage(BaseModel):
54
+ input_tokens: int = 0
55
+ output_tokens: int = 0
56
+
57
+ def __add__(self, other: "Usage") -> "Usage":
58
+ return Usage(
59
+ input_tokens=self.input_tokens + other.input_tokens,
60
+ output_tokens=self.output_tokens + other.output_tokens,
61
+ )
62
+
63
+
64
+ class LLMTurn(BaseModel):
65
+ """One assistant completion."""
66
+
67
+ text: str | None = None
68
+ tool_calls: list[ToolCall] = []
69
+ usage: Usage = Usage()
70
+ stop_reason: str | None = None
71
+
72
+
73
+ class LLMProvider(ABC):
74
+ """One chat completion with tool use. Implementations must be stateless
75
+ across calls: the full conversation is passed in every time."""
76
+
77
+ name: str
78
+
79
+ @abstractmethod
80
+ async def complete(
81
+ self,
82
+ *,
83
+ model: str,
84
+ system: str | None,
85
+ messages: list[ChatMessage],
86
+ tools: list[ToolDef],
87
+ max_tokens: int = 1024,
88
+ ) -> LLMTurn: ...
@@ -0,0 +1,100 @@
1
+ """OpenAI provider (Chat Completions API, openai SDK).
2
+
3
+ Chat Completions (rather than the Responses API) keeps the tool-use loop
4
+ symmetric with other providers; both live behind :class:`LLMProvider`, so
5
+ swapping APIs is contained here. Reads the API key from ``OPENAI_API_KEY``.
6
+ """
7
+
8
+ import json
9
+ from typing import Any
10
+
11
+ from openai import AsyncOpenAI
12
+
13
+ from whetkit.llm.base import ChatMessage, LLMProvider, LLMTurn, ToolCall, ToolDef, Usage
14
+
15
+
16
+ def _to_openai_messages(system: str | None, messages: list[ChatMessage]) -> list[dict[str, Any]]:
17
+ out: list[dict[str, Any]] = []
18
+ if system:
19
+ out.append({"role": "system", "content": system})
20
+ for msg in messages:
21
+ if msg.role == "assistant":
22
+ entry: dict[str, Any] = {"role": "assistant", "content": msg.content}
23
+ if msg.tool_calls:
24
+ entry["tool_calls"] = [
25
+ {
26
+ "id": call.id,
27
+ "type": "function",
28
+ "function": {
29
+ "name": call.name,
30
+ "arguments": json.dumps(call.arguments),
31
+ },
32
+ }
33
+ for call in msg.tool_calls
34
+ ]
35
+ out.append(entry)
36
+ else:
37
+ for result in msg.tool_results:
38
+ out.append(
39
+ {"role": "tool", "tool_call_id": result.call_id, "content": result.content}
40
+ )
41
+ if msg.content:
42
+ out.append({"role": "user", "content": msg.content})
43
+ return out
44
+
45
+
46
+ class OpenAIProvider(LLMProvider):
47
+ name = "openai"
48
+
49
+ def __init__(self, client: AsyncOpenAI | None = None):
50
+ self._client = client or AsyncOpenAI()
51
+
52
+ async def complete(
53
+ self,
54
+ *,
55
+ model: str,
56
+ system: str | None,
57
+ messages: list[ChatMessage],
58
+ tools: list[ToolDef],
59
+ max_tokens: int = 1024,
60
+ ) -> LLMTurn:
61
+ kwargs: dict[str, Any] = {}
62
+ if tools:
63
+ kwargs["tools"] = [
64
+ {
65
+ "type": "function",
66
+ "function": {
67
+ "name": t.name,
68
+ "description": t.description,
69
+ "parameters": t.input_schema,
70
+ },
71
+ }
72
+ for t in tools
73
+ ]
74
+ response = await self._client.chat.completions.create(
75
+ model=model,
76
+ max_completion_tokens=max_tokens,
77
+ messages=_to_openai_messages(system, messages),
78
+ **kwargs,
79
+ )
80
+
81
+ choice = response.choices[0]
82
+ tool_calls = [
83
+ ToolCall(
84
+ id=call.id,
85
+ name=call.function.name,
86
+ arguments=json.loads(call.function.arguments or "{}"),
87
+ )
88
+ for call in (choice.message.tool_calls or [])
89
+ if call.type == "function"
90
+ ]
91
+ usage = response.usage
92
+ return LLMTurn(
93
+ text=choice.message.content or None,
94
+ tool_calls=tool_calls,
95
+ usage=Usage(
96
+ input_tokens=usage.prompt_tokens if usage else 0,
97
+ output_tokens=usage.completion_tokens if usage else 0,
98
+ ),
99
+ stop_reason=choice.finish_reason,
100
+ )
@@ -0,0 +1,37 @@
1
+ """Model-string parsing and provider lookup.
2
+
3
+ Models are addressed as ``provider:model_id`` (e.g. ``anthropic:claude-sonnet-5``,
4
+ ``openai:gpt-5.2``). A bare model id defaults to the Anthropic provider.
5
+ """
6
+
7
+ from functools import cache
8
+
9
+ from whetkit.llm.base import LLMProvider
10
+
11
+ DEFAULT_PROVIDER = "anthropic"
12
+
13
+
14
+ def parse_model(model: str) -> tuple[str, str]:
15
+ """Split ``provider:model_id`` -> (provider_name, model_id)."""
16
+ if ":" in model:
17
+ provider_name, model_id = model.split(":", 1)
18
+ else:
19
+ provider_name, model_id = DEFAULT_PROVIDER, model
20
+ if not model_id:
21
+ raise ValueError(f"invalid model string {model!r}")
22
+ return provider_name, model_id
23
+
24
+
25
+ @cache
26
+ def get_provider(provider_name: str) -> LLMProvider:
27
+ """Instantiate (and cache) a provider by name. Imports lazily so one
28
+ missing SDK/key never blocks the other provider."""
29
+ if provider_name == "anthropic":
30
+ from whetkit.llm.anthropic_provider import AnthropicProvider
31
+
32
+ return AnthropicProvider()
33
+ if provider_name == "openai":
34
+ from whetkit.llm.openai_provider import OpenAIProvider
35
+
36
+ return OpenAIProvider()
37
+ raise ValueError(f"unknown provider {provider_name!r} (expected 'anthropic' or 'openai')")
@@ -0,0 +1,17 @@
1
+ """MCP connectivity: transports, client, and tool introspection."""
2
+
3
+ from whetkit.mcp.client import MCPClient
4
+ from whetkit.mcp.introspect import ServerInventory, ToolInfo, inspect_server
5
+ from whetkit.mcp.transport import HttpMode, HttpSpec, ServerSpec, StdioSpec, resolve_server_spec
6
+
7
+ __all__ = [
8
+ "HttpMode",
9
+ "HttpSpec",
10
+ "MCPClient",
11
+ "ServerInventory",
12
+ "ServerSpec",
13
+ "StdioSpec",
14
+ "ToolInfo",
15
+ "inspect_server",
16
+ "resolve_server_spec",
17
+ ]