petfishframework 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.
Files changed (51) hide show
  1. petfishframework/__init__.py +33 -0
  2. petfishframework/config.py +142 -0
  3. petfishframework/core/__init__.py +91 -0
  4. petfishframework/core/agent.py +207 -0
  5. petfishframework/core/compiled.py +89 -0
  6. petfishframework/core/contracts.py +190 -0
  7. petfishframework/core/conversation.py +51 -0
  8. petfishframework/core/environment.py +260 -0
  9. petfishframework/core/events.py +79 -0
  10. petfishframework/core/session.py +182 -0
  11. petfishframework/core/structured.py +202 -0
  12. petfishframework/core/types.py +192 -0
  13. petfishframework/mcp/__init__.py +19 -0
  14. petfishframework/mcp/client.py +96 -0
  15. petfishframework/mcp/server.py +14 -0
  16. petfishframework/mcp/stdio_transport.py +218 -0
  17. petfishframework/mcp/wrapper.py +43 -0
  18. petfishframework/models/__init__.py +14 -0
  19. petfishframework/models/anthropic.py +194 -0
  20. petfishframework/models/fake.py +202 -0
  21. petfishframework/models/openai.py +178 -0
  22. petfishframework/observability/__init__.py +10 -0
  23. petfishframework/observability/sinks.py +23 -0
  24. petfishframework/permissions/__init__.py +32 -0
  25. petfishframework/permissions/model.py +110 -0
  26. petfishframework/reasoning/__init__.py +13 -0
  27. petfishframework/reasoning/lats.py +222 -0
  28. petfishframework/reasoning/llm_plus_p.py +202 -0
  29. petfishframework/reasoning/react.py +176 -0
  30. petfishframework/reliability/__init__.py +78 -0
  31. petfishframework/reliability/cost.py +50 -0
  32. petfishframework/reliability/cost_report.py +101 -0
  33. petfishframework/reliability/pass_at_k.py +232 -0
  34. petfishframework/reliability/replay.py +190 -0
  35. petfishframework/reliability/retry.py +224 -0
  36. petfishframework/reliability/timeout.py +55 -0
  37. petfishframework/retrieval/__init__.py +12 -0
  38. petfishframework/retrieval/adaptive.py +137 -0
  39. petfishframework/retrieval/crag.py +135 -0
  40. petfishframework/retrieval/memory_store.py +72 -0
  41. petfishframework/tools/__init__.py +12 -0
  42. petfishframework/tools/agent_tool.py +47 -0
  43. petfishframework/tools/base.py +75 -0
  44. petfishframework/tools/calculator.py +84 -0
  45. petfishframework/tools/path_planner.py +90 -0
  46. petfishframework/tools/registry.py +158 -0
  47. petfishframework/tools/word_sorter.py +41 -0
  48. petfishframework-0.1.0.dist-info/METADATA +151 -0
  49. petfishframework-0.1.0.dist-info/RECORD +51 -0
  50. petfishframework-0.1.0.dist-info/WHEEL +4 -0
  51. petfishframework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,182 @@
1
+ """Session — event-sourced run loop (decision 1 + decision 4)."""
2
+ from __future__ import annotations
3
+
4
+ import uuid
5
+ from dataclasses import dataclass, field
6
+
7
+ from petfishframework.permissions.model import PermissionPolicy
8
+
9
+ from .compiled import CompiledContext, EvidenceBundle, MemorySlice, OutputContract, TaskSpec
10
+ from .contracts import (
11
+ MemoryView,
12
+ ModelAdapter,
13
+ ReasoningStrategy,
14
+ Retriever,
15
+ RunContext,
16
+ Tool,
17
+ )
18
+ from .conversation import ConversationStore
19
+ from .environment import RuntimeEnvironment
20
+ from .events import EventEmitter
21
+ from .types import Budget, Message, Result, Role, Task, Usage
22
+
23
+
24
+ @dataclass
25
+ class Session:
26
+ """A single execution of an Agent.
27
+
28
+ Session owns the RuntimeEnvironment, RunContext, and EventEmitter. It is
29
+ the Process abstraction in the Agent:Session :: program:process analogy.
30
+ """
31
+
32
+ model: ModelAdapter
33
+ reasoning: ReasoningStrategy
34
+ tools: tuple[Tool, ...]
35
+ retriever: Retriever | None
36
+ policy: PermissionPolicy
37
+ task: Task
38
+ budget: Budget | None
39
+ events: EventEmitter
40
+ session_id: str = field(default_factory=lambda: uuid.uuid4().hex[:16])
41
+ _env: RuntimeEnvironment | None = field(default=None, repr=False)
42
+ conversation_id: str | None = None
43
+ conversation_store: ConversationStore | None = None
44
+
45
+ def run(self) -> Result:
46
+ """Execute the session and return a Result."""
47
+ ctx = self._prepare_run()
48
+ result = self.reasoning.run(ctx)
49
+ return self._finalize_run(result)
50
+
51
+ async def run_async(self) -> Result:
52
+ """Execute the session asynchronously and return a Result."""
53
+ ctx = self._prepare_run()
54
+ run_async = getattr(self.reasoning, "run_async", None)
55
+ if run_async is None:
56
+ raise RuntimeError(
57
+ f"Strategy {self.reasoning.name} does not support async. "
58
+ "Implement run_async()."
59
+ )
60
+ result = await run_async(ctx)
61
+ return self._finalize_run(result)
62
+
63
+ def _prepare_run(self) -> RunContext:
64
+ """Build the RuntimeEnvironment and RunContext; shared by sync/async paths."""
65
+ compiled = CompiledContext(
66
+ task_spec=TaskSpec(task_type="generic"),
67
+ memory_slice=MemorySlice(),
68
+ evidence_bundle=EvidenceBundle(),
69
+ output_contract=OutputContract(),
70
+ )
71
+
72
+ budget = self.budget if self.budget is not None else Budget()
73
+ self._env = RuntimeEnvironment(
74
+ model=self.model,
75
+ _tools=self.tools,
76
+ retriever=self.retriever,
77
+ budget=budget,
78
+ events=self.events,
79
+ policy=self.policy,
80
+ session_id=self.session_id,
81
+ )
82
+
83
+ conversation_history = self._load_conversation_history()
84
+ ctx = RunContext(
85
+ task=self.task,
86
+ env=self._env,
87
+ budget=budget,
88
+ memory=MemoryView(),
89
+ events=self.events,
90
+ compiled=compiled,
91
+ conversation_history=conversation_history,
92
+ )
93
+
94
+ self.events.emit(
95
+ "session.start",
96
+ {
97
+ "session_id": self.session_id,
98
+ "task": self.task.prompt,
99
+ "reasoning": self.reasoning.name,
100
+ },
101
+ )
102
+ return ctx
103
+
104
+ def _load_conversation_history(self) -> tuple[Message, ...]:
105
+ """Load persisted conversation history when a store and id are configured."""
106
+ if self.conversation_store is None or self.conversation_id is None:
107
+ return ()
108
+
109
+ history = tuple(self.conversation_store.load(self.conversation_id))
110
+ self.events.emit(
111
+ "conversation.load",
112
+ {
113
+ "conversation_id": self.conversation_id,
114
+ "message_count": len(history),
115
+ },
116
+ )
117
+ return history
118
+
119
+ def _finalize_run(self, result: Result) -> Result:
120
+ """Attach usage, persist conversation, and emit session.end."""
121
+ usage = self._env.usage() if self._env is not None else Usage()
122
+ result = Result(
123
+ answer=result.answer,
124
+ trajectory=result.trajectory,
125
+ usage=usage,
126
+ session_id=self.session_id,
127
+ )
128
+
129
+ self._save_conversation_history(result)
130
+
131
+ self.events.emit(
132
+ "session.end",
133
+ {
134
+ "session_id": self.session_id,
135
+ "answer": result.answer,
136
+ "usage": {
137
+ "input_tokens": usage.input_tokens,
138
+ "output_tokens": usage.output_tokens,
139
+ "total_tokens": usage.total_tokens,
140
+ "cost_usd": usage.cost_usd,
141
+ "elapsed_s": usage.elapsed_s,
142
+ },
143
+ },
144
+ )
145
+ return result
146
+
147
+ def _save_conversation_history(self, result: Result) -> None:
148
+ """Persist previous history plus the current user/assistant turn."""
149
+ if self.conversation_store is None or self.conversation_id is None:
150
+ return
151
+
152
+ previous = list(self.conversation_store.load(self.conversation_id))
153
+ current_turn = [
154
+ Message(role=Role.USER, content=self.task.prompt),
155
+ Message(role=Role.ASSISTANT, content=result.answer),
156
+ ]
157
+ new_messages = previous + current_turn
158
+ self.conversation_store.save(self.conversation_id, new_messages)
159
+ self.events.emit(
160
+ "conversation.save",
161
+ {
162
+ "conversation_id": self.conversation_id,
163
+ "message_count": len(new_messages),
164
+ },
165
+ )
166
+
167
+ def replay(self) -> tuple:
168
+ """Return the recorded event log (AUDIT replay mode).
169
+
170
+ RESUME and RERUN replay modes are TODO (open question 3).
171
+ """
172
+ return self.events.events
173
+
174
+ def checkpoint(self) -> None:
175
+ """Emit a checkpoint event capturing current state."""
176
+ self.events.emit(
177
+ "checkpoint",
178
+ {
179
+ "session_id": self.session_id,
180
+ "step_count": len(self.events.events),
181
+ },
182
+ )
@@ -0,0 +1,202 @@
1
+ """Structured output parsing — stdlib-only (json + dataclasses).
2
+
3
+ Thin-core extension that turns a model's JSON response into a typed dataclass
4
+ without pulling in Pydantic or any external dependency.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from dataclasses import dataclass, fields
11
+ from typing import Any, ClassVar, Generic, Protocol, TypeVar, get_type_hints
12
+
13
+
14
+ class DataclassInstance(Protocol):
15
+ """Protocol matching dataclass instances recognized by stdlib fields()."""
16
+
17
+ __dataclass_fields__: ClassVar[dict[str, Any]]
18
+
19
+
20
+ T = TypeVar("T", bound=DataclassInstance)
21
+
22
+ # Match a fenced JSON code block: ```json ... ``` or ``` ... ```
23
+ _CODE_BLOCK_RE = re.compile(
24
+ r"```(?:json)?\s*(.*?)\s*```",
25
+ re.DOTALL | re.IGNORECASE,
26
+ )
27
+
28
+ # Match the first JSON object ({...}) or array ([...]) in free text.
29
+ _JSON_OBJECT_RE = re.compile(r"\{[\s\S]*?\}")
30
+ _JSON_ARRAY_RE = re.compile(r"\[[\s\S]*?\]")
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class StructuredResult(Generic[T]):
35
+ """The outcome of a structured agent run.
36
+
37
+ Always carries the raw answer, the parsed object (when possible), an
38
+ optional parse error message, and the originating session id.
39
+ """
40
+
41
+ answer: str
42
+ data: T | None
43
+ parse_error: str | None
44
+ session_id: str
45
+
46
+
47
+ def extract_json_from_content(content: str) -> str | None:
48
+ """Extract a JSON string from direct JSON, fenced code blocks, or embedded text.
49
+
50
+ Returns the first plausible JSON object or array found, or None if none
51
+ is detected.
52
+ """
53
+ if not content or not content.strip():
54
+ return None
55
+
56
+ text = content.strip()
57
+
58
+ # 1. Direct JSON.
59
+ try:
60
+ json.loads(text)
61
+ return text
62
+ except json.JSONDecodeError:
63
+ pass
64
+
65
+ # 2. Fenced markdown code block.
66
+ code_match = _CODE_BLOCK_RE.search(text)
67
+ if code_match:
68
+ candidate = code_match.group(1).strip()
69
+ try:
70
+ json.loads(candidate)
71
+ return candidate
72
+ except json.JSONDecodeError:
73
+ pass
74
+
75
+ # 3. Embedded object or array.
76
+ object_match = _JSON_OBJECT_RE.search(text)
77
+ if object_match:
78
+ candidate = object_match.group(0)
79
+ try:
80
+ json.loads(candidate)
81
+ return candidate
82
+ except json.JSONDecodeError:
83
+ pass
84
+
85
+ array_match = _JSON_ARRAY_RE.search(text)
86
+ if array_match:
87
+ candidate = array_match.group(0)
88
+ try:
89
+ json.loads(candidate)
90
+ return candidate
91
+ except json.JSONDecodeError:
92
+ pass
93
+
94
+ return None
95
+
96
+
97
+ def parse_json(content: str) -> Any:
98
+ """Parse JSON from model response content.
99
+
100
+ Handles direct JSON, fenced code blocks, and JSON embedded in prose.
101
+ Returns the parsed value (dict, list, or primitive). Raises ValueError
102
+ when no valid JSON can be extracted.
103
+ """
104
+ candidate = extract_json_from_content(content)
105
+ if candidate is None:
106
+ raise ValueError("No valid JSON found in content.")
107
+ try:
108
+ return json.loads(candidate)
109
+ except json.JSONDecodeError as exc:
110
+ raise ValueError(f"Invalid JSON: {exc}") from exc
111
+
112
+
113
+ def _is_dataclass_type(output_type: type) -> bool:
114
+ """True if output_type looks like a dataclass class."""
115
+ return isinstance(output_type, type) and hasattr(output_type, "__dataclass_fields__")
116
+
117
+
118
+ def parse_structured(content: str, output_type: type[T]) -> T:
119
+ """Parse JSON from content and instantiate output_type.
120
+
121
+ output_type must be a dataclass. Unknown fields in the JSON payload are
122
+ ignored. Raises ValueError if the JSON is invalid or instantiation fails.
123
+ """
124
+ if not _is_dataclass_type(output_type):
125
+ raise ValueError(f"output_type must be a dataclass, got {output_type!r}")
126
+
127
+ try:
128
+ parsed = parse_json(content)
129
+ except ValueError as exc:
130
+ raise ValueError(f"Could not parse structured output: {exc}") from exc
131
+
132
+ if not isinstance(parsed, dict):
133
+ raise ValueError(f"Expected a JSON object for dataclass {output_type.__name__}, got {type(parsed).__name__}")
134
+
135
+ dataclass_fields = {f.name for f in fields(output_type)}
136
+ filtered = {k: v for k, v in parsed.items() if k in dataclass_fields}
137
+
138
+ # Coerce simple JSON types toward the declared field type when safe.
139
+ type_hints = get_type_hints(output_type)
140
+ coerced: dict[str, Any] = {}
141
+ for key, value in filtered.items():
142
+ field_type = type_hints.get(key)
143
+ try:
144
+ coerced[key] = _coerce_value(value, field_type)
145
+ except (TypeError, ValueError) as exc:
146
+ raise ValueError(f"Field '{key}' could not be coerced to {field_type}: {exc}") from exc
147
+
148
+ try:
149
+ return output_type(**coerced)
150
+ except (TypeError, ValueError) as exc:
151
+ raise ValueError(f"Could not instantiate {output_type.__name__}: {exc}") from exc
152
+
153
+
154
+ def _coerce_value(value: Any, field_type: Any) -> Any:
155
+ """Best-effort stdlib coercion for JSON primitives to common dataclass field types."""
156
+ if field_type is None:
157
+ return value
158
+
159
+ # Strip None from Optional[X] to inspect the inner type.
160
+ origin = getattr(field_type, "__origin__", None)
161
+ args = getattr(field_type, "__args__", ())
162
+
163
+ # Optional[X] == Union[X, None]
164
+ if origin is type | None or origin is None.__class__:
165
+ # typing.Optional[X]
166
+ non_none_args = [a for a in args if a is not type(None)]
167
+ if len(non_none_args) == 1:
168
+ field_type = non_none_args[0]
169
+ origin = getattr(field_type, "__origin__", None)
170
+ args = getattr(field_type, "__args__", ())
171
+
172
+ # Direct identity matches.
173
+ if field_type is Any:
174
+ return value
175
+
176
+ if isinstance(value, field_type):
177
+ return value
178
+
179
+ # Union types (other than Optional handled above) — try each arg.
180
+ if origin is type | None:
181
+ for candidate in args:
182
+ if candidate is type(None) and value is None:
183
+ return None
184
+ try:
185
+ return _coerce_value(value, candidate)
186
+ except (TypeError, ValueError):
187
+ continue
188
+ raise TypeError(f"Value {value!r} does not match union {field_type}")
189
+
190
+ # Common JSON-friendly scalar coercions.
191
+ if field_type is int:
192
+ return int(value)
193
+ if field_type is float:
194
+ return float(value)
195
+ if field_type is str:
196
+ return str(value)
197
+ if field_type is bool:
198
+ return bool(value)
199
+
200
+ # Generic containers — keep as parsed lists/dicts; dataclass construction
201
+ # will validate element types via runtime checks on its own.
202
+ return value
@@ -0,0 +1,192 @@
1
+ """Core types — value objects shared across the framework.
2
+
3
+ This module has ZERO dependencies on concrete implementations.
4
+ Everything here is pure data (dataclasses / enums) that other modules
5
+ build upon. Keeping it dependency-free is what makes the thin-core
6
+ principle (decision 5) hold.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Any
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Task & Result
16
+ # ---------------------------------------------------------------------------
17
+
18
+ @dataclass(frozen=True)
19
+ class Task:
20
+ """The user's request to the agent."""
21
+
22
+ prompt: str
23
+ metadata: dict[str, Any] = field(default_factory=dict)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Step:
28
+ """One step in a reasoning trajectory."""
29
+
30
+ thought: str | None = None
31
+ tool_name: str | None = None
32
+ tool_args: dict[str, Any] | None = None
33
+ observation: str | None = None
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Trajectory:
38
+ """The full sequence of steps an agent took."""
39
+
40
+ steps: tuple[Step, ...] = ()
41
+
42
+ def append(self, step: Step) -> "Trajectory":
43
+ return Trajectory(steps=self.steps + (step,))
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Result:
48
+ """The final output of an agent run."""
49
+
50
+ answer: str
51
+ trajectory: Trajectory = field(default_factory=Trajectory)
52
+ usage: "Usage" = field(default_factory=lambda: Usage())
53
+ session_id: str = ""
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Budget & Usage (reliability/cost — decision 4)
58
+ # ---------------------------------------------------------------------------
59
+
60
+ @dataclass(frozen=True)
61
+ class Usage:
62
+ """Accumulated resource consumption."""
63
+
64
+ input_tokens: int = 0
65
+ output_tokens: int = 0
66
+ total_tokens: int = 0
67
+ cost_usd: float = 0.0
68
+ elapsed_s: float = 0.0
69
+
70
+ def add(self, other: "Usage") -> "Usage":
71
+ return Usage(
72
+ input_tokens=self.input_tokens + other.input_tokens,
73
+ output_tokens=self.output_tokens + other.output_tokens,
74
+ total_tokens=self.total_tokens + other.total_tokens,
75
+ cost_usd=self.cost_usd + other.cost_usd,
76
+ elapsed_s=self.elapsed_s + other.elapsed_s,
77
+ )
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Budget:
82
+ """Hard limits for a single session run.
83
+
84
+ Exceeding any limit raises BudgetExceeded (hard enforcement — decision 4).
85
+ A None field means unlimited for that dimension.
86
+ """
87
+
88
+ max_tokens: int | None = None
89
+ max_cost_usd: float | None = None
90
+ max_steps: int | None = None
91
+ max_tool_calls: int | None = None
92
+
93
+
94
+ class BudgetExceeded(Exception):
95
+ """Raised when a session exceeds its budget (hard enforcement)."""
96
+
97
+ def __init__(self, dimension: str, limit: Any, actual: Any):
98
+ self.dimension = dimension
99
+ self.limit = limit
100
+ self.actual = actual
101
+ super().__init__(f"Budget exceeded: {dimension} limit={limit} actual={actual}")
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Messaging (model interaction)
106
+ # ---------------------------------------------------------------------------
107
+
108
+ class Role(Enum):
109
+ SYSTEM = "system"
110
+ USER = "user"
111
+ ASSISTANT = "assistant"
112
+ TOOL = "tool"
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class Message:
117
+ """A single chat message."""
118
+
119
+ role: Role
120
+ content: str = ""
121
+ tool_calls: tuple["ToolCall", ...] = ()
122
+ tool_call_id: str | None = None # for role=TOOL responses
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class ToolCall:
127
+ """A tool invocation requested by the model."""
128
+
129
+ id: str
130
+ name: str
131
+ arguments: dict[str, Any]
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class ModelRequest:
136
+ """A request to a language model."""
137
+
138
+ messages: tuple[Message, ...]
139
+ tools: tuple[str, ...] = () # tool names available (for function-calling)
140
+ tool_schemas: tuple[dict[str, Any], ...] = () # full tool definitions: {name, description, input_schema}
141
+ temperature: float = 0.0
142
+ max_tokens: int | None = None
143
+ metadata: dict[str, Any] = field(default_factory=dict)
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class ModelResponse:
148
+ """A response from a language model."""
149
+
150
+ content: str = ""
151
+ tool_calls: tuple[ToolCall, ...] = ()
152
+ usage: Usage = field(default_factory=Usage)
153
+ finish_reason: str = "stop"
154
+ raw: Any = None # provider-specific raw response
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Tools
159
+ # ---------------------------------------------------------------------------
160
+
161
+ @dataclass(frozen=True)
162
+ class ToolRef:
163
+ """A reference to a tool by name."""
164
+
165
+ name: str
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class ToolResult:
170
+ """The outcome of a tool invocation."""
171
+
172
+ value: Any = None
173
+ error: str | None = None
174
+ masked: bool = False # if MASK decision effect applied
175
+
176
+ @property
177
+ def is_error(self) -> bool:
178
+ return self.error is not None
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Retrieval
183
+ # ---------------------------------------------------------------------------
184
+
185
+ @dataclass(frozen=True)
186
+ class Snippet:
187
+ """A single retrieval result."""
188
+
189
+ content: str
190
+ source: str = ""
191
+ score: float = 0.0
192
+ metadata: dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,19 @@
1
+ """MCP integration module — the only module that knows the MCP wire protocol.
2
+
3
+ This package adapts MCP-shaped tools into the framework's canonical Tool
4
+ contract (decision 2). Core and strategies see only `Tool`; all MCP-specific
5
+ concepts live here.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .client import MCPClient, MCPToolSpec, connect_stdio
10
+ from .server import serve_as_mcp
11
+ from .wrapper import MCPToolWrapper
12
+
13
+ __all__ = [
14
+ "MCPClient",
15
+ "MCPToolSpec",
16
+ "MCPToolWrapper",
17
+ "connect_stdio",
18
+ "serve_as_mcp",
19
+ ]
@@ -0,0 +1,96 @@
1
+ """MCP client — tool discovery and invocation.
2
+
3
+ The in-process client is constructed from ``MCPToolSpec`` dictionaries. The
4
+ ``connect_stdio`` function builds a client from a real MCP server subprocess
5
+ speaking JSON-RPC over stdio.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any, Callable
11
+
12
+ from .wrapper import MCPToolWrapper
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class MCPToolSpec:
17
+ """Static description of an MCP tool callable from Python code.
18
+
19
+ This is the in-process equivalent of an MCP ``tools/list`` entry plus its
20
+ invocation handler. It is used to bootstrap ``MCPClient`` in tests and in
21
+ environments where the server is co-located.
22
+ """
23
+
24
+ name: str
25
+ description: str
26
+ input_schema: dict[str, Any]
27
+ call_fn: Callable[[dict[str, Any]], Any]
28
+
29
+
30
+ class MCPClient:
31
+ """Client for MCP tool discovery and invocation.
32
+
33
+ Constructed from a dictionary of tool specs. Clients created by
34
+ ``connect_stdio`` additionally hold a reference to the live stdio transport
35
+ so the subprocess stays alive as long as the client is in use.
36
+ """
37
+
38
+ def __init__(self, tools: dict[str, MCPToolSpec]) -> None:
39
+ self._tools: dict[str, MCPToolSpec] = dict(tools)
40
+
41
+ def discover_tools(self) -> list[MCPToolWrapper]:
42
+ """Return each registered MCP tool wrapped as a framework ``Tool``."""
43
+ return [
44
+ MCPToolWrapper(
45
+ name=spec.name,
46
+ description=spec.description,
47
+ input_schema=spec.input_schema,
48
+ call_fn=spec.call_fn,
49
+ )
50
+ for spec in self._tools.values()
51
+ ]
52
+
53
+ def call_tool(self, name: str, args: dict[str, Any]) -> Any:
54
+ """Invoke a registered tool by name and return its raw value."""
55
+ spec = self._tools[name]
56
+ return spec.call_fn(args)
57
+
58
+
59
+ def connect_stdio(
60
+ command: str,
61
+ args: list[str],
62
+ env: dict[str, str] | None = None,
63
+ ) -> MCPClient:
64
+ """Connect to an MCP server via stdio and discover its tools.
65
+
66
+ This function spawns ``command`` with ``args`` as a subprocess, performs
67
+ the MCP initialization handshake, lists the available tools, and returns an
68
+ ``MCPClient`` populated with ``MCPToolSpec`` instances that forward calls to
69
+ the subprocess via JSON-RPC.
70
+
71
+ The underlying ``StdioMCPClient`` is attached to the returned
72
+ ``MCPClient`` as ``_transport`` so it stays alive for the lifetime of the
73
+ client. Callers may use the returned client as a context manager to ensure
74
+ the subprocess is terminated on exit.
75
+ """
76
+ from .stdio_transport import StdioMCPClient
77
+
78
+ transport = StdioMCPClient(command, args, env=env)
79
+ transport.initialize()
80
+
81
+ tool_defs = transport.list_tools()
82
+ tools: dict[str, MCPToolSpec] = {}
83
+ for tool_def in tool_defs:
84
+ name = tool_def["name"]
85
+ tools[name] = MCPToolSpec(
86
+ name=name,
87
+ description=tool_def.get("description", ""),
88
+ input_schema=tool_def.get("inputSchema", {}),
89
+ call_fn=lambda arguments, tool_name=name: transport.call_tool(
90
+ tool_name, arguments
91
+ ),
92
+ )
93
+
94
+ client = MCPClient(tools=tools)
95
+ object.__setattr__(client, "_transport", transport)
96
+ return client
@@ -0,0 +1,14 @@
1
+ """MCP server helper — expose framework tools as an MCP server (Phase 4)."""
2
+ from __future__ import annotations
3
+
4
+ from petfishframework.core.contracts import Tool
5
+
6
+
7
+ def serve_as_mcp(tools: list[Tool]) -> None: # noqa: ARG001
8
+ """Expose framework tools as an MCP server.
9
+
10
+ STUB — Phase 4 work. This function documents the symmetrical direction:
11
+ just as ``mcp/`` consumes external tools into the framework's ``Tool``
12
+ contract, the framework can also export its own tools as an MCP server.
13
+ """
14
+ raise NotImplementedError("MCP server mode is Phase 4.")