topicforge 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.
- topicforge/__init__.py +5 -0
- topicforge/__main__.py +68 -0
- topicforge/adapters/__init__.py +5 -0
- topicforge/adapters/base.py +44 -0
- topicforge/adapters/ros2_live/__init__.py +5 -0
- topicforge/adapters/ros2_live/adapter.py +283 -0
- topicforge/adapters/ros2_mock/__init__.py +5 -0
- topicforge/adapters/ros2_mock/adapter.py +68 -0
- topicforge/adapters/ros2_mock/fixtures.py +172 -0
- topicforge/config/__init__.py +5 -0
- topicforge/config/settings.py +68 -0
- topicforge/models/__init__.py +19 -0
- topicforge/models/schemas.py +158 -0
- topicforge/server/__init__.py +5 -0
- topicforge/server/app.py +43 -0
- topicforge/services/__init__.py +7 -0
- topicforge/services/factory.py +39 -0
- topicforge/services/health.py +32 -0
- topicforge/services/inspector.py +92 -0
- topicforge/tools/__init__.py +5 -0
- topicforge/tools/handlers.py +146 -0
- topicforge-0.1.0.dist-info/METADATA +292 -0
- topicforge-0.1.0.dist-info/RECORD +26 -0
- topicforge-0.1.0.dist-info/WHEEL +4 -0
- topicforge-0.1.0.dist-info/entry_points.txt +2 -0
- topicforge-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Runtime settings, resolved from environment variables.
|
|
2
|
+
|
|
3
|
+
Settings are immutable and constructed once at startup. The `auto` mode is
|
|
4
|
+
resolved against the current environment by `Settings.effective_mode` —
|
|
5
|
+
keeping that decision in one place avoids drift between callers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
Mode = Literal["mock", "live", "auto"]
|
|
16
|
+
ResolvedMode = Literal["mock", "live"]
|
|
17
|
+
|
|
18
|
+
_VALID_MODES: tuple[Mode, ...] = ("mock", "live", "auto")
|
|
19
|
+
_VALID_LOG_LEVELS: tuple[str, ...] = ("DEBUG", "INFO", "WARNING", "ERROR")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class Settings:
|
|
24
|
+
"""Immutable runtime configuration."""
|
|
25
|
+
|
|
26
|
+
mode: Mode
|
|
27
|
+
log_level: str
|
|
28
|
+
ros2_executable: str
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def effective_mode(self) -> ResolvedMode:
|
|
32
|
+
"""Resolve `auto` against the current environment.
|
|
33
|
+
|
|
34
|
+
`live` and `mock` are returned as-is; `auto` becomes `live` when the
|
|
35
|
+
configured ROS2 executable is on PATH, otherwise `mock`. The factory
|
|
36
|
+
in `services/factory.py` is responsible for final fallback if a live
|
|
37
|
+
adapter cannot actually start.
|
|
38
|
+
|
|
39
|
+
Predictive resolution only. Final operational fallback (when the live
|
|
40
|
+
adapter is instantiable but cannot actually start) lives in
|
|
41
|
+
`services/factory.py:build_adapter`.
|
|
42
|
+
"""
|
|
43
|
+
if self.mode == "auto":
|
|
44
|
+
return "live" if shutil.which(self.ros2_executable) else "mock"
|
|
45
|
+
return self.mode
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_settings(env: dict[str, str] | os._Environ[str] | None = None) -> Settings:
|
|
49
|
+
"""Build a Settings from the given environment (defaults to `os.environ`).
|
|
50
|
+
|
|
51
|
+
The `env` parameter is injectable so tests can avoid leaking process
|
|
52
|
+
state and pin behavior deterministically.
|
|
53
|
+
"""
|
|
54
|
+
src = env if env is not None else os.environ
|
|
55
|
+
|
|
56
|
+
raw_mode = src.get("TOPICFORGE_MODE", "auto").strip().lower()
|
|
57
|
+
if raw_mode not in _VALID_MODES:
|
|
58
|
+
raise ValueError(f"Invalid TOPICFORGE_MODE={raw_mode!r}; expected one of {_VALID_MODES}")
|
|
59
|
+
|
|
60
|
+
raw_log = src.get("TOPICFORGE_LOG_LEVEL", "INFO").strip().upper()
|
|
61
|
+
if raw_log not in _VALID_LOG_LEVELS:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"Invalid TOPICFORGE_LOG_LEVEL={raw_log!r}; expected one of {_VALID_LOG_LEVELS}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
ros2_exe = src.get("TOPICFORGE_ROS2_BIN", "ros2").strip() or "ros2"
|
|
67
|
+
|
|
68
|
+
return Settings(mode=raw_mode, log_level=raw_log, ros2_executable=ros2_exe)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Pydantic schemas — the contract between TopicForge and MCP clients."""
|
|
2
|
+
|
|
3
|
+
from topicforge.models.schemas import (
|
|
4
|
+
BagAnalysis,
|
|
5
|
+
BagTopicStats,
|
|
6
|
+
HealthReport,
|
|
7
|
+
MessageSample,
|
|
8
|
+
SampleResult,
|
|
9
|
+
TopicInfo,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BagAnalysis",
|
|
14
|
+
"BagTopicStats",
|
|
15
|
+
"HealthReport",
|
|
16
|
+
"MessageSample",
|
|
17
|
+
"SampleResult",
|
|
18
|
+
"TopicInfo",
|
|
19
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Tool input/output schemas.
|
|
2
|
+
|
|
3
|
+
Models are deliberately small, frozen, and JSON-friendly so MCP clients
|
|
4
|
+
(particularly LLMs) can reason about them without ambiguity. `extra="forbid"`
|
|
5
|
+
keeps adapters honest — an accidental extra key fails fast in tests rather
|
|
6
|
+
than silently propagating to clients.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
|
|
13
|
+
_CONFIG = ConfigDict(extra="forbid", frozen=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TopicInfo(BaseModel):
|
|
17
|
+
"""Description of a single ROS2 topic."""
|
|
18
|
+
|
|
19
|
+
model_config = _CONFIG
|
|
20
|
+
|
|
21
|
+
name: str = Field(description="Fully qualified topic name, e.g. `/cmd_vel`.")
|
|
22
|
+
message_type: str = Field(description="ROS2 message type, e.g. `geometry_msgs/msg/Twist`.")
|
|
23
|
+
publisher_count: int = Field(ge=0, description="Publishers known to the graph.")
|
|
24
|
+
subscriber_count: int = Field(ge=0, description="Subscribers known to the graph.")
|
|
25
|
+
qos_reliability: str | None = Field(
|
|
26
|
+
default=None,
|
|
27
|
+
description="QoS reliability policy if known: `reliable` or `best_effort`.",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MessageSample(BaseModel):
|
|
32
|
+
"""A single sampled message on a topic."""
|
|
33
|
+
|
|
34
|
+
model_config = _CONFIG
|
|
35
|
+
|
|
36
|
+
topic: str = Field(
|
|
37
|
+
description="Fully qualified topic name the sample was taken from, e.g. `/cmd_vel`."
|
|
38
|
+
)
|
|
39
|
+
message_type: str = Field(
|
|
40
|
+
description="ROS2 message type of this sample, e.g. `geometry_msgs/msg/Twist`."
|
|
41
|
+
)
|
|
42
|
+
timestamp_ns: int = Field(
|
|
43
|
+
description=(
|
|
44
|
+
"Receive timestamp in nanoseconds since epoch. **Always 0 in the MVP "
|
|
45
|
+
"live adapter** (which shells out to `ros2 topic echo --once` and has "
|
|
46
|
+
"no access to the original receive time); a future rclpy-backed "
|
|
47
|
+
"adapter will populate this. Mock mode emits monotonically "
|
|
48
|
+
"increasing values for deterministic ordering."
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
payload: dict[str, object] = Field(
|
|
52
|
+
default_factory=dict,
|
|
53
|
+
description=(
|
|
54
|
+
"Structured message payload. **In live mode the MVP parser only "
|
|
55
|
+
"extracts top-level keys**; nested fields and the full YAML text "
|
|
56
|
+
"are preserved verbatim under the reserved `_raw_text` key for the "
|
|
57
|
+
"client to handle. In mock mode the payload is fully structured "
|
|
58
|
+
"and `_raw_text` is absent. Large messages (e.g. images) may be "
|
|
59
|
+
"summarized to keep tool output bounded."
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BagTopicStats(BaseModel):
|
|
65
|
+
"""Per-topic statistics inside a bag analysis result."""
|
|
66
|
+
|
|
67
|
+
model_config = _CONFIG
|
|
68
|
+
|
|
69
|
+
name: str = Field(description="Fully qualified topic name as recorded in the bag.")
|
|
70
|
+
message_type: str = Field(description="ROS2 message type recorded for this topic.")
|
|
71
|
+
message_count: int = Field(
|
|
72
|
+
ge=0, description="Number of messages recorded on this topic across the bag."
|
|
73
|
+
)
|
|
74
|
+
frequency_hz: float | None = Field(
|
|
75
|
+
default=None,
|
|
76
|
+
ge=0,
|
|
77
|
+
description="Average rate (messages / bag duration) when computable, else `null`.",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BagAnalysis(BaseModel):
|
|
82
|
+
"""Structured summary of a ROS2 bag."""
|
|
83
|
+
|
|
84
|
+
model_config = _CONFIG
|
|
85
|
+
|
|
86
|
+
path: str = Field(
|
|
87
|
+
description=(
|
|
88
|
+
"Path to the analyzed bag, as supplied by the caller. May point to a "
|
|
89
|
+
"file (`.mcap`, `.db3`, `.bag`) or to a `rosbag2_*` directory."
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
storage_format: str | None = Field(
|
|
93
|
+
default=None,
|
|
94
|
+
description="`mcap`, `sqlite3`, or other storage identifier when known.",
|
|
95
|
+
)
|
|
96
|
+
duration_seconds: float = Field(
|
|
97
|
+
ge=0,
|
|
98
|
+
description="Total bag duration, in seconds (wall clock between first and last message).",
|
|
99
|
+
)
|
|
100
|
+
message_count: int = Field(
|
|
101
|
+
ge=0, description="Total number of messages across all recorded topics."
|
|
102
|
+
)
|
|
103
|
+
topics: list[BagTopicStats] = Field(
|
|
104
|
+
description="Per-topic statistics for every topic present in the bag."
|
|
105
|
+
)
|
|
106
|
+
anomalies: list[str] = Field(
|
|
107
|
+
default_factory=list,
|
|
108
|
+
description=(
|
|
109
|
+
"Human-readable notes about gaps, clock jumps, or other oddities. "
|
|
110
|
+
"MVP populates this in mock mode; live anomaly detection is roadmap."
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class SampleResult(BaseModel):
|
|
116
|
+
"""Envelope returned by the `sample_messages` tool."""
|
|
117
|
+
|
|
118
|
+
model_config = _CONFIG
|
|
119
|
+
|
|
120
|
+
topic: str = Field(description="Topic the samples were taken from, echoed from the request.")
|
|
121
|
+
count: int = Field(
|
|
122
|
+
ge=0,
|
|
123
|
+
description=(
|
|
124
|
+
"Number of samples actually returned. May be 0 (no publisher active "
|
|
125
|
+
"in live mode, or empty mock fixture), less than the requested count "
|
|
126
|
+
"(topic yielded fewer messages within the timeout), or capped by the "
|
|
127
|
+
"MVP's silent maximum of 50 — request `count > 50` and you will "
|
|
128
|
+
"receive at most 50 without warning."
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
samples: list[MessageSample] = Field(
|
|
132
|
+
description="The sampled messages, ordered as received from the backend."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class HealthReport(BaseModel):
|
|
137
|
+
"""Result of `health_check`. Always succeeds, even when the host is unhealthy."""
|
|
138
|
+
|
|
139
|
+
model_config = _CONFIG
|
|
140
|
+
|
|
141
|
+
mode: str = Field(description="Effective runtime mode: `mock` or `live`.")
|
|
142
|
+
requested_mode: str = Field(description="Mode requested via configuration (may be `auto`).")
|
|
143
|
+
ros2_available: bool = Field(description="Whether a `ros2` CLI is on PATH.")
|
|
144
|
+
ros2_distro: str | None = Field(
|
|
145
|
+
default=None, description="Value of `ROS_DISTRO` if set in the environment."
|
|
146
|
+
)
|
|
147
|
+
server_version: str = Field(
|
|
148
|
+
description="TopicForge server version (matches the PyPI release of the `topicforge` package)."
|
|
149
|
+
)
|
|
150
|
+
max_sample_count: int = Field(
|
|
151
|
+
ge=0,
|
|
152
|
+
description=(
|
|
153
|
+
"Server-side cap on the number of samples returned per "
|
|
154
|
+
"`sample_messages` call. Requests above this limit are silently "
|
|
155
|
+
"clamped; the value is exposed here so a client can size its "
|
|
156
|
+
"requests proactively. Constant in v0.1.0."
|
|
157
|
+
),
|
|
158
|
+
)
|
topicforge/server/app.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""MCP server bootstrap.
|
|
2
|
+
|
|
3
|
+
Wires settings → adapter → services → tools → FastMCP. This module is the
|
|
4
|
+
only place that knows the full dependency graph; everything else stays
|
|
5
|
+
narrowly scoped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from mcp.server.fastmcp import FastMCP
|
|
13
|
+
|
|
14
|
+
from topicforge import __version__
|
|
15
|
+
from topicforge.config import Settings, load_settings
|
|
16
|
+
from topicforge.services import HealthService, Inspector, build_adapter
|
|
17
|
+
from topicforge.tools import register_tools
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_app(settings: Settings | None = None) -> FastMCP:
|
|
23
|
+
"""Construct a fully wired FastMCP application.
|
|
24
|
+
|
|
25
|
+
`settings` is optional so tests can build the app with deterministic
|
|
26
|
+
configuration; production callers (`python -m topicforge`) pass nothing
|
|
27
|
+
and pick up settings from the environment.
|
|
28
|
+
"""
|
|
29
|
+
settings = settings or load_settings()
|
|
30
|
+
adapter = build_adapter(settings)
|
|
31
|
+
inspector = Inspector(adapter)
|
|
32
|
+
health = HealthService(settings)
|
|
33
|
+
|
|
34
|
+
mcp = FastMCP("topicforge")
|
|
35
|
+
register_tools(mcp, inspector, health)
|
|
36
|
+
|
|
37
|
+
log.info(
|
|
38
|
+
"topicforge %s ready (mode=%s, adapter=%s)",
|
|
39
|
+
__version__,
|
|
40
|
+
settings.effective_mode,
|
|
41
|
+
adapter.name,
|
|
42
|
+
)
|
|
43
|
+
return mcp
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Domain services — orchestration between tool handlers and adapters."""
|
|
2
|
+
|
|
3
|
+
from topicforge.services.factory import build_adapter
|
|
4
|
+
from topicforge.services.health import HealthService
|
|
5
|
+
from topicforge.services.inspector import Inspector
|
|
6
|
+
|
|
7
|
+
__all__ = ["HealthService", "Inspector", "build_adapter"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Adapter selection.
|
|
2
|
+
|
|
3
|
+
This is the only place that knows how to map a `Settings` to a concrete
|
|
4
|
+
adapter, and where graceful degradation from `live` → `mock` happens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from topicforge.adapters.base import RosAdapter
|
|
12
|
+
from topicforge.adapters.ros2_live import Ros2CliAdapter
|
|
13
|
+
from topicforge.adapters.ros2_mock import MockAdapter
|
|
14
|
+
from topicforge.config import Settings
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_adapter(settings: Settings) -> RosAdapter:
|
|
20
|
+
"""Return the adapter matching the effective runtime mode.
|
|
21
|
+
|
|
22
|
+
If `live` is requested but the `ros2` CLI is missing, falls back to mock
|
|
23
|
+
and logs a warning. This is intentional: it keeps the MCP server usable
|
|
24
|
+
on a developer laptop without ROS2 installed.
|
|
25
|
+
|
|
26
|
+
Predictive resolution (mode `auto` → live or mock based on PATH) lives in
|
|
27
|
+
`config/settings.py:Settings.effective_mode`.
|
|
28
|
+
"""
|
|
29
|
+
mode = settings.effective_mode
|
|
30
|
+
if mode == "live":
|
|
31
|
+
adapter = Ros2CliAdapter(executable=settings.ros2_executable)
|
|
32
|
+
if not adapter.is_available():
|
|
33
|
+
log.warning(
|
|
34
|
+
"live mode requested but %r not on PATH; falling back to mock",
|
|
35
|
+
settings.ros2_executable,
|
|
36
|
+
)
|
|
37
|
+
return MockAdapter()
|
|
38
|
+
return adapter
|
|
39
|
+
return MockAdapter()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Health service — environment & mode introspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
from topicforge import __version__
|
|
9
|
+
from topicforge.config import Settings
|
|
10
|
+
from topicforge.models import HealthReport
|
|
11
|
+
from topicforge.services.inspector import MAX_SAMPLE_COUNT
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HealthService:
|
|
15
|
+
def __init__(self, settings: Settings) -> None:
|
|
16
|
+
self._settings = settings
|
|
17
|
+
|
|
18
|
+
def report(self) -> HealthReport:
|
|
19
|
+
"""Build a HealthReport for the current environment.
|
|
20
|
+
|
|
21
|
+
Never raises. `health_check` is the tool a user will reach for when
|
|
22
|
+
things look broken, so it must always answer.
|
|
23
|
+
"""
|
|
24
|
+
ros2_path = shutil.which(self._settings.ros2_executable)
|
|
25
|
+
return HealthReport(
|
|
26
|
+
mode=self._settings.effective_mode,
|
|
27
|
+
requested_mode=self._settings.mode,
|
|
28
|
+
ros2_available=ros2_path is not None,
|
|
29
|
+
ros2_distro=os.environ.get("ROS_DISTRO"),
|
|
30
|
+
server_version=__version__,
|
|
31
|
+
max_sample_count=MAX_SAMPLE_COUNT,
|
|
32
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Inspector — the domain layer between MCP tool handlers and adapters.
|
|
2
|
+
|
|
3
|
+
Tools call the Inspector. The Inspector validates inputs, delegates to the
|
|
4
|
+
adapter, and ensures outputs are well-formed regardless of backend.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from topicforge.adapters.base import AdapterError, AdapterName, RosAdapter
|
|
13
|
+
from topicforge.models import BagAnalysis, MessageSample, TopicInfo
|
|
14
|
+
|
|
15
|
+
DEFAULT_SAMPLE_COUNT = 5
|
|
16
|
+
MAX_SAMPLE_COUNT = 50
|
|
17
|
+
|
|
18
|
+
# Strict allowlist mirroring ROS2 topic-name conventions:
|
|
19
|
+
# * must start with `/`
|
|
20
|
+
# * one or more segments separated by single `/`
|
|
21
|
+
# * each segment starts with a letter or underscore, then [A-Za-z0-9_]*
|
|
22
|
+
# This rejects `//`, trailing `/`, digit-leading segments, dashes, dots, and
|
|
23
|
+
# every shell metacharacter before any value reaches the `ros2` CLI.
|
|
24
|
+
_TOPIC_NAME_RE = re.compile(r"^/[A-Za-z_][A-Za-z0-9_]*(?:/[A-Za-z_][A-Za-z0-9_]*)*$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Inspector:
|
|
28
|
+
"""Validation and orchestration layer between MCP tool handlers and ROS adapters.
|
|
29
|
+
|
|
30
|
+
All MCP-level input normalization happens here (topic name format, count clamping,
|
|
31
|
+
path validation) so adapters can assume well-formed inputs. Today some methods are
|
|
32
|
+
thin pass-throughs to the adapter; they remain in this layer to keep the contract
|
|
33
|
+
surface symmetric — every tool goes through the same gate.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, adapter: RosAdapter) -> None:
|
|
37
|
+
self._adapter = adapter
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def backend_name(self) -> AdapterName:
|
|
41
|
+
return self._adapter.name
|
|
42
|
+
|
|
43
|
+
def list_topics(self) -> list[TopicInfo]:
|
|
44
|
+
return self._adapter.list_topics()
|
|
45
|
+
|
|
46
|
+
def get_topic_info(self, topic: str) -> TopicInfo:
|
|
47
|
+
_validate_topic_name(topic)
|
|
48
|
+
return self._adapter.get_topic_info(topic)
|
|
49
|
+
|
|
50
|
+
def sample_messages(self, topic: str, count: int | None = None) -> list[MessageSample]:
|
|
51
|
+
_validate_topic_name(topic)
|
|
52
|
+
n = DEFAULT_SAMPLE_COUNT if count is None else count
|
|
53
|
+
if n < 0:
|
|
54
|
+
raise AdapterError("count must be >= 0")
|
|
55
|
+
return self._adapter.sample_messages(topic, min(n, MAX_SAMPLE_COUNT))
|
|
56
|
+
|
|
57
|
+
def analyze_bag(self, path: str) -> BagAnalysis:
|
|
58
|
+
return self._adapter.analyze_bag(_validate_bag_path(path))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _validate_topic_name(topic: str) -> None:
|
|
62
|
+
if not topic or not topic.strip():
|
|
63
|
+
raise AdapterError("topic must be a non-empty string")
|
|
64
|
+
if not topic.startswith("/"):
|
|
65
|
+
raise AdapterError(f"topic must start with '/' (got {topic!r})")
|
|
66
|
+
if not _TOPIC_NAME_RE.match(topic):
|
|
67
|
+
raise AdapterError(
|
|
68
|
+
f"topic name is malformed (got {topic!r}); each `/`-separated "
|
|
69
|
+
"segment must start with a letter or underscore and contain only "
|
|
70
|
+
"letters, digits, and underscores (no `//`, no trailing `/`)"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _validate_bag_path(path: str) -> str:
|
|
75
|
+
"""Validate and normalize a bag path before it reaches an adapter.
|
|
76
|
+
|
|
77
|
+
Returns the stripped path. Raises `AdapterError` for empty, blank,
|
|
78
|
+
null-byte-containing, or otherwise malformed paths. Does NOT check
|
|
79
|
+
existence or extension — that is the live adapter's responsibility.
|
|
80
|
+
"""
|
|
81
|
+
if not isinstance(path, str):
|
|
82
|
+
raise AdapterError("path must be a string")
|
|
83
|
+
if not path or not path.strip():
|
|
84
|
+
raise AdapterError("path must be a non-empty string")
|
|
85
|
+
clean = path.strip()
|
|
86
|
+
if "\x00" in clean:
|
|
87
|
+
raise AdapterError("path must not contain null bytes")
|
|
88
|
+
try:
|
|
89
|
+
Path(clean)
|
|
90
|
+
except (ValueError, OSError) as exc:
|
|
91
|
+
raise AdapterError(f"path is not a valid filesystem path: {exc}") from exc
|
|
92
|
+
return clean
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""MCP tool handlers.
|
|
2
|
+
|
|
3
|
+
Handlers are deliberately thin: they delegate to services and let FastMCP
|
|
4
|
+
serialize the returned Pydantic models. They never touch ROS2 directly.
|
|
5
|
+
|
|
6
|
+
`AdapterError` (and any other unexpected exception) is allowed to bubble up.
|
|
7
|
+
FastMCP translates it into an MCP-native error response (`isError: true`
|
|
8
|
+
in the JSON-RPC payload), which every compliant MCP client knows how to
|
|
9
|
+
handle. We deliberately do **not** wrap errors in a custom envelope: that
|
|
10
|
+
pattern masks failures as successful tool results and forces the client to
|
|
11
|
+
parse the body to discover something went wrong.
|
|
12
|
+
|
|
13
|
+
Future tools (URDF inspection, bag anomaly detection, dataset export) plug
|
|
14
|
+
in here behind the same shape:
|
|
15
|
+
|
|
16
|
+
@mcp.tool(description="...")
|
|
17
|
+
def my_tool(...) -> MyResult:
|
|
18
|
+
return service.do_thing(...)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Annotated
|
|
24
|
+
|
|
25
|
+
from mcp.server.fastmcp import FastMCP
|
|
26
|
+
from pydantic import Field
|
|
27
|
+
|
|
28
|
+
from topicforge.models import (
|
|
29
|
+
BagAnalysis,
|
|
30
|
+
HealthReport,
|
|
31
|
+
SampleResult,
|
|
32
|
+
TopicInfo,
|
|
33
|
+
)
|
|
34
|
+
from topicforge.services import HealthService, Inspector
|
|
35
|
+
|
|
36
|
+
_TOPIC_PARAM_DESC = (
|
|
37
|
+
"Fully qualified ROS2 topic name starting with `/`, e.g. `/cmd_vel` or "
|
|
38
|
+
"`/camera/image_raw`. Each `/`-separated segment must start with a "
|
|
39
|
+
"letter or underscore and contain only letters, digits, and "
|
|
40
|
+
"underscores; everything else (whitespace, quotes, shell "
|
|
41
|
+
"metacharacters, `//`, trailing `/`) is rejected before reaching the "
|
|
42
|
+
"`ros2` CLI."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_COUNT_PARAM_DESC = (
|
|
46
|
+
"Maximum number of recent messages to return. Defaults to 5; silently "
|
|
47
|
+
"clamped to 50 (the hard cap that keeps tool output bounded — read it "
|
|
48
|
+
"from `health_check.max_sample_count`). Negative values raise an error. "
|
|
49
|
+
"The returned `SampleResult.count` reflects the actual number of "
|
|
50
|
+
"samples produced — it can be lower than the request (empty topic, "
|
|
51
|
+
"timeout, mock fixture shorter than requested)."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_PATH_PARAM_DESC = (
|
|
55
|
+
"Path to a ROS2 bag: a file ending in `.mcap`, `.db3`, or `.bag`, or a "
|
|
56
|
+
"`rosbag2_*` directory. Leading/trailing whitespace is stripped. Null "
|
|
57
|
+
"bytes and otherwise malformed filesystem paths are rejected. Existence "
|
|
58
|
+
"and bag format are validated by the live adapter (mock mode accepts "
|
|
59
|
+
"any well-formed path)."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def register_tools(
|
|
64
|
+
mcp: FastMCP,
|
|
65
|
+
inspector: Inspector,
|
|
66
|
+
health: HealthService,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Register the MVP tool set on `mcp`."""
|
|
69
|
+
|
|
70
|
+
@mcp.tool(
|
|
71
|
+
description=(
|
|
72
|
+
"Report TopicForge's effective runtime mode, whether ROS2 tooling is "
|
|
73
|
+
"available, the server version, and the server-side sample cap. "
|
|
74
|
+
"Always succeeds — call this first when something looks wrong."
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
def health_check() -> HealthReport:
|
|
78
|
+
return health.report()
|
|
79
|
+
|
|
80
|
+
@mcp.tool(
|
|
81
|
+
description=(
|
|
82
|
+
"List all ROS2 topics known to the current graph (or the mock graph "
|
|
83
|
+
"if running in mock mode). Returns name, message type, and "
|
|
84
|
+
"publisher/subscriber counts for each topic."
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
def list_topics() -> list[TopicInfo]:
|
|
88
|
+
return inspector.list_topics()
|
|
89
|
+
|
|
90
|
+
@mcp.tool(
|
|
91
|
+
description=(
|
|
92
|
+
"Return detailed info for a single ROS2 topic. `topic` must be a "
|
|
93
|
+
"fully qualified topic name, e.g. `/cmd_vel`. Raises an MCP error "
|
|
94
|
+
"if the topic name is malformed or the topic is unknown."
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
def get_topic_info(
|
|
98
|
+
topic: Annotated[str, Field(description=_TOPIC_PARAM_DESC)],
|
|
99
|
+
) -> TopicInfo:
|
|
100
|
+
return inspector.get_topic_info(topic)
|
|
101
|
+
|
|
102
|
+
@mcp.tool(
|
|
103
|
+
description=(
|
|
104
|
+
"Return up to `count` recent messages from `topic`. `topic` must be "
|
|
105
|
+
"a fully qualified ROS2 name; see the `topic` parameter description "
|
|
106
|
+
"for the exact accepted shape. `count` defaults to 5 and is silently "
|
|
107
|
+
"clamped to 50 — request more and you receive at most 50 without "
|
|
108
|
+
"warning. Returns a `SampleResult` envelope `{topic, count, samples}` "
|
|
109
|
+
"where `count` is the actual number of samples returned (may be 0). "
|
|
110
|
+
"In live mode the MVP shells out to `ros2 topic echo --once` with a "
|
|
111
|
+
"short timeout, so the result is empty when no publisher is "
|
|
112
|
+
"currently active, and every `samples[i].timestamp_ns` is 0 (the "
|
|
113
|
+
"CLI does not expose receive times). The live parser only extracts "
|
|
114
|
+
"top-level YAML keys into `samples[i].payload`; nested fields and "
|
|
115
|
+
"the verbatim CLI output land under the reserved `_raw_text` key, "
|
|
116
|
+
"so a client can reason over the raw bytes when it needs more than "
|
|
117
|
+
"the flat view. In mock mode returns deterministic samples with "
|
|
118
|
+
"monotonically increasing timestamps for the fictional demo robot "
|
|
119
|
+
"(and no `_raw_text` key, since the payload is already structured)."
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
def sample_messages(
|
|
123
|
+
topic: Annotated[str, Field(description=_TOPIC_PARAM_DESC)],
|
|
124
|
+
count: Annotated[int, Field(description=_COUNT_PARAM_DESC, ge=0)] = 5,
|
|
125
|
+
) -> SampleResult:
|
|
126
|
+
samples = inspector.sample_messages(topic, count)
|
|
127
|
+
return SampleResult(topic=topic, count=len(samples), samples=samples)
|
|
128
|
+
|
|
129
|
+
@mcp.tool(
|
|
130
|
+
description=(
|
|
131
|
+
"Inspect a ROS2 bag at `path` and return a structured summary: "
|
|
132
|
+
"duration, message count, per-topic stats, and any detected "
|
|
133
|
+
"anomalies. Supports `.mcap`, `.db3`, and `.bag` paths via "
|
|
134
|
+
"`ros2 bag info` in live mode. Returns rich fixture data in mock "
|
|
135
|
+
"mode."
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
def analyze_bag(
|
|
139
|
+
path: Annotated[str, Field(description=_PATH_PARAM_DESC, min_length=1)],
|
|
140
|
+
) -> BagAnalysis:
|
|
141
|
+
return inspector.analyze_bag(path)
|
|
142
|
+
|
|
143
|
+
# TODO(roadmap): URDF tools — validate / inspect / generate URDF & xacro.
|
|
144
|
+
# TODO(roadmap): bag anomaly detection — clock jumps, frame drops, TF gaps.
|
|
145
|
+
# TODO(roadmap): dataset export — rosbag → COCO / Hugging Face Datasets.
|
|
146
|
+
# TODO(roadmap): synthetic data pipeline — Blender / Gazebo / Isaac control.
|