diff --git a/README.md b/README.md index 591e8d6..13e1796 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,11 @@ Robots: Nav2, MoveIt2, cameras, sensors ``` rosclaw/ -├── packages/ -│ └── rosbridge-client/ # @rosclaw/rosbridge-client — TypeScript rosbridge WebSocket client ├── extensions/ │ ├── openclaw-plugin/ # @rosclaw/openclaw-plugin — Core OpenClaw extension -│ └── openclaw-canvas/ # @rosclaw/openclaw-canvas — Real-time dashboard (Phase 3) +│ ├── openclaw-canvas/ # @rosclaw/openclaw-canvas — Real-time dashboard (Phase 3) +│ ├── rosclaw-codex-mcp-server/ # MCP server for agent access to ROS2 +│ └── rosclaw-claude-plugin/ # Claude Code plugin wrapper for the MCP server ├── ros2_ws/src/ │ ├── rosclaw_discovery/ # ROS2 capability auto-discovery node │ └── rosclaw_msgs/ # Custom ROS2 message/service definitions @@ -94,9 +94,10 @@ Send a message to your robot: | Package | Description | |---|---| -| [`@rosclaw/rosbridge-client`](packages/rosbridge-client/) | Standalone TypeScript client for the rosbridge WebSocket protocol | | [`@rosclaw/openclaw-plugin`](extensions/openclaw-plugin/) | OpenClaw extension: tools, hooks, skills, commands for ROS2 control | | [`@rosclaw/openclaw-canvas`](extensions/openclaw-canvas/) | Real-time robot dashboard (Phase 3) | +| [`@rosclaw/rosclaw-codex-mcp-server`](extensions/rosclaw-codex-mcp-server/) | MCP stdio server for controlled ROS2 access | +| [`rosclaw-claude-plugin`](extensions/rosclaw-claude-plugin/) | Claude Code plugin that exposes the RosClaw MCP server | | [`rosclaw_discovery`](ros2_ws/src/rosclaw_discovery/) | ROS2 Python node for capability auto-discovery | | [`rosclaw_msgs`](ros2_ws/src/rosclaw_msgs/) | Custom ROS2 message/service definitions | diff --git a/docker/Dockerfile.rosclaw b/docker/Dockerfile.rosclaw index 9ba59c5..16e8898 100644 --- a/docker/Dockerfile.rosclaw +++ b/docker/Dockerfile.rosclaw @@ -7,13 +7,9 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate WORKDIR /app # Copy workspace config -COPY package.json pnpm-workspace.yaml .npmrc tsconfig.base.json ./ +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc tsconfig.base.json ./ # Copy package manifests for dependency resolution -COPY packages/transport/package.json packages/transport/ -COPY packages/rosbridge-client/package.json packages/rosbridge-client/ -COPY packages/transport-local/package.json packages/transport-local/ -COPY packages/transport-webrtc/package.json packages/transport-webrtc/ COPY extensions/openclaw-plugin/package.json extensions/openclaw-plugin/ COPY extensions/openclaw-canvas/package.json extensions/openclaw-canvas/ @@ -21,7 +17,6 @@ COPY extensions/openclaw-canvas/package.json extensions/openclaw-canvas/ RUN pnpm install --frozen-lockfile # Copy source code -COPY packages/ packages/ COPY extensions/ extensions/ # Build all packages @@ -37,4 +32,4 @@ COPY --from=base /app . ENV NODE_ENV=production -CMD ["node", "extensions/openclaw-plugin/dist/index.js"] +CMD ["node", "extensions/openclaw-plugin/dist/standalone.js"] diff --git a/docker/docker-compose.robot.yml b/docker/docker-compose.robot.yml index 478b6e7..165dc6a 100644 --- a/docker/docker-compose.robot.yml +++ b/docker/docker-compose.robot.yml @@ -8,8 +8,8 @@ services: # ROS2 stack (no rosbridge needed — agent talks directly to DDS) ros2: build: - context: . - dockerfile: Dockerfile.ros2 + context: .. + dockerfile: docker/Dockerfile.ros2 image: rosclaw/ros2:latest environment: - ROS_DOMAIN_ID=0 @@ -20,8 +20,8 @@ services: # RosClaw agent node (connects to signaling server, bridges to DDS) rosclaw-agent: build: - context: . - dockerfile: Dockerfile.ros2 + context: .. + dockerfile: docker/Dockerfile.ros2 image: rosclaw/ros2:latest command: ["ros2", "run", "rosclaw_agent", "agent_node"] environment: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6a3c8e0..7935201 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -4,8 +4,8 @@ services: # ROS2 + rosbridge_server + Gazebo simulation ros2: build: - context: . - dockerfile: Dockerfile.ros2 + context: .. + dockerfile: docker/Dockerfile.ros2 image: rosclaw/ros2:latest ports: - "9090:9090" # rosbridge WebSocket diff --git a/docs/codex-mcp-server-plan.md b/docs/codex-mcp-server-plan.md new file mode 100644 index 0000000..55697d1 --- /dev/null +++ b/docs/codex-mcp-server-plan.md @@ -0,0 +1,215 @@ +# Codex MCP Server Plan + +## Goal + +Expose RosClaw to Codex through a local MCP stdio server. Codex should call +controlled ROS tools through MCP instead of running arbitrary `ros2` shell +commands. + +Target flow: + +```text +Codex + -> MCP stdio server + -> RosClaw transport + -> rosbridge / local DDS / WebRTC + -> ROS 2 robot +``` + +The MCP server should be reusable by other MCP-capable agents later, but Codex +is the first target platform. + +## Scope + +Current implementation scope: + +- Add a new MCP server package. +- Reuse existing RosClaw transport code. +- Expose the existing RosClaw ROS tools through MCP. +- Guard write-capable tools with the shared RosClaw safety policy. + +Out of scope for the current implementation: + +- Web dashboard or Canvas integration. +- Replacing the existing OpenClaw plugin. +- Non-rosbridge transports in the Codex MCP server. The MCP server currently + supports `rosbridge` first. + +## Package Location + +Use a Codex-focused package name for the first implementation: + +```text +extensions/rosclaw-codex-mcp-server/ +├── package.json +├── tsconfig.json +└── src/ + └── index.ts +``` + +The server still speaks standard MCP over stdio, so the implementation can be +generalized later if another MCP-capable agent needs the same ROS access. + +## First-Phase Tools + +Expose all existing RosClaw ROS tools: + +| MCP Tool | Purpose | Existing RosClaw equivalent | +|---|---|---| +| `ros2_publish` | Publish a ROS message after safety validation | `ros2_publish` | +| `ros2_list_topics` | Discover ROS topics and message types | `ros2_list_topics` | +| `ros2_subscribe_once` | Read one message from a topic | `ros2_subscribe_once` | +| `ros2_param_get` | Read a ROS parameter | `ros2_param_get` | +| `ros2_param_set` | Set a ROS parameter after safety validation | `ros2_param_set` | +| `ros2_camera_snapshot` | Read one compressed camera frame | `ros2_camera_snapshot` | +| `ros2_service_call` | Call a ROS service after safety validation | `ros2_service_call` | +| `ros2_action_goal` | Send a ROS action goal after safety validation | `ros2_action_goal` | + +Read-only tools can inspect robot state. Write-capable tools are exposed but +must pass RosClaw safety validation before they reach ROS. + +## Code Reuse + +The MCP server should reuse the current plugin internals instead of creating a +parallel ROS client stack: + +```text +extensions/openclaw-plugin/src/transport/ +extensions/openclaw-plugin/src/config.ts +extensions/openclaw-plugin/src/safety/validator.ts +``` + +Shared safety validation: + +- `safety/validator.ts` exports `validateRosToolCall(...)`. +- OpenClaw hooks and Codex MCP tool handlers both call the same policy path. +- Keep the same allowlist, blocklist, readonly, velocity, and workspace rules. + +Suggested shape: + +```ts +validateRosToolCall({ + toolName, + params, + safety, +}): string | null +``` + +Return `null` when allowed, or a human-readable block reason when denied. + +## Transport Configuration + +Default first-phase transport should be `rosbridge`, because it is easiest to +run from Codex without sourcing a ROS workspace: + +```text +ROSCLAW_TRANSPORT_MODE=rosbridge +ROSCLAW_ROSBRIDGE_URL=ws://localhost:9090 +``` + +Optional full config override: + +```bash +ROSCLAW_CONFIG_JSON='{"safety":{"readonlyMode":true}}' +``` + +Environment variables override matching fields in `ROSCLAW_CONFIG_JSON` for +transport and rosbridge connection settings. + +Later phases can support: + +- `local`: direct DDS through `rclnodejs`. +- `webrtc`: remote robot access through the existing RosClaw WebRTC transport. + +## Codex Registration + +Codex should launch the server as a local MCP stdio process. + +Expected command after build: + +```bash +node extensions/rosclaw-codex-mcp-server/dist/index.js +``` + +Example MCP server configuration: + +```json +{ + "mcpServers": { + "rosclaw": { + "command": "node", + "args": ["extensions/rosclaw-codex-mcp-server/dist/index.js"], + "env": { + "ROSCLAW_TRANSPORT_MODE": "rosbridge", + "ROSCLAW_ROSBRIDGE_URL": "ws://localhost:9090" + } + } + } +} +``` + +The exact Codex config file location depends on the Codex runtime being used; +the server command and environment should stay the same. + +## Security Rules + +Safety posture: + +- Do not expose shell access through MCP. +- Do not pass through arbitrary ROS operations. +- Do not bypass RosClaw config parsing. +- Treat camera frames and robot state as potentially sensitive data. +- `readonlyMode` must be enforced. +- `allowedTopics`, `allowedServices`, and `allowedActions` must be enforced. +- `blockedTopics` must override topic allowlists. +- `requireConfirmationFor` must block write targets until a confirmation flow + exists. +- Velocity and workspace checks must run for motion-related commands. + +## Implementation Steps + +1. Create `extensions/rosclaw-codex-mcp-server/`. +2. Add TypeScript build config and package metadata. +3. Add MCP SDK dependency. +4. Implement startup config parsing using `RosClawConfigSchema`. +5. Create and connect the RosClaw transport. +6. Register read-only tools: + `ros2_list_topics`, `ros2_subscribe_once`, `ros2_param_get`, and + `ros2_camera_snapshot`. +7. Validate with Codex against a running rosbridge server. +8. Extract reusable safety validation. +9. Register write-capable tools: + `ros2_publish`, `ros2_param_set`, `ros2_service_call`, and + `ros2_action_goal`. +10. Validate that write-capable tools are blocked or allowed according to + safety config. + +## Validation Plan + +Minimum validation for phase one: + +```bash +pnpm --filter @rosclaw/rosclaw-codex-mcp-server build +pnpm --filter @rosclaw/rosclaw-codex-mcp-server typecheck +``` + +Runtime validation: + +1. Start ROS 2 and rosbridge. +2. Start the MCP server with `ROSCLAW_ROSBRIDGE_URL=ws://localhost:9090`. +3. Register the MCP server in Codex. +4. Ask Codex to list ROS topics. +5. Ask Codex to read one safe topic such as `/odom` or `/battery_state`. + +Validate write tools only with constrained safety config and a simulator or +safe test ROS graph. + +## Open Questions + +- Which exact Codex MCP config location should this repository document? +- Should the MCP server live under `extensions/` or a future `packages/` + workspace? +- Should read-only topic access also use `allowedTopics`, or should allowlists + apply only to write operations? +- Should camera snapshots return raw base64 data, a temporary file path, or a + reduced metadata response for Codex? diff --git a/extensions/openclaw-plugin/openclaw.plugin.json b/extensions/openclaw-plugin/openclaw.plugin.json index 95d15da..0ca0947 100644 --- a/extensions/openclaw-plugin/openclaw.plugin.json +++ b/extensions/openclaw-plugin/openclaw.plugin.json @@ -110,16 +110,69 @@ "maxLinearVelocity": { "type": "number", "description": "Maximum linear velocity (m/s)", + "minimum": 0, "default": 1.0 }, "maxAngularVelocity": { "type": "number", "description": "Maximum angular velocity (rad/s)", + "minimum": 0, "default": 1.5 }, + "readonlyMode": { + "type": "boolean", + "description": "Block ROS write operations while still allowing read-only discovery and subscriptions", + "default": false + }, + "allowedTopics": { + "type": "array", + "description": "Allowed ROS topic patterns for publish/subscribe/camera tools. Supports '*' wildcards; leave empty to allow all topics.", + "items": { "type": "string" }, + "default": [ + "/cmd_vel", + "*/cmd_vel", + "/goal_pose", + "*/goal_pose", + "/odom", + "*/odom", + "/scan", + "*/scan", + "/camera/*", + "*/camera/*", + "/battery_state", + "*/battery_state", + "/diagnostics", + "*/diagnostics", + "/rosclaw/*" + ] + }, + "allowedServices": { + "type": "array", + "description": "Allowed ROS service patterns for service and parameter tools. Supports '*' wildcards; leave empty to allow all services.", + "items": { "type": "string" }, + "default": ["/rosapi/*", "/rosclaw/*", "*/get_parameters"] + }, + "allowedActions": { + "type": "array", + "description": "Allowed ROS action server patterns. Supports '*' wildcards; leave empty to allow all actions.", + "items": { "type": "string" }, + "default": ["/navigate_to_pose", "*/navigate_to_pose"] + }, + "blockedTopics": { + "type": "array", + "description": "Blocked ROS topic patterns. Blocks take precedence over allowedTopics.", + "items": { "type": "string" }, + "default": ["/rosout", "/parameter_events"] + }, + "requireConfirmationFor": { + "type": "array", + "description": "Topic, service, or action patterns that should be blocked until a higher-level confirmation flow is available. Supports bare patterns and kind prefixes like topic:/cmd_vel.", + "items": { "type": "string" }, + "default": [] + }, "workspaceLimits": { "type": "object", - "description": "Workspace boundary limits (meters)", + "description": "Workspace boundary limits for goal or navigation targets (meters)", "properties": { "xMin": { "type": "number", "default": -10 }, "xMax": { "type": "number", "default": 10 }, @@ -152,6 +205,30 @@ "label": "Max Angular Velocity (rad/s)", "advanced": true }, + "safety.readonlyMode": { + "label": "Read-only Mode", + "advanced": true + }, + "safety.allowedTopics": { + "label": "Allowed Topics", + "advanced": true + }, + "safety.allowedServices": { + "label": "Allowed Services", + "advanced": true + }, + "safety.allowedActions": { + "label": "Allowed Actions", + "advanced": true + }, + "safety.blockedTopics": { + "label": "Blocked Topics", + "advanced": true + }, + "safety.requireConfirmationFor": { + "label": "Require Confirmation For", + "advanced": true + }, "safety.workspaceLimits": { "label": "Workspace Boundary Limits", "advanced": true diff --git a/extensions/openclaw-plugin/package.json b/extensions/openclaw-plugin/package.json index 2465dc9..f7f8fae 100644 --- a/extensions/openclaw-plugin/package.json +++ b/extensions/openclaw-plugin/package.json @@ -3,13 +3,42 @@ "version": "0.0.1", "type": "module", "description": "OpenClaw extension for ROS2 robot control via natural language", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./config": { + "import": "./dist/config.js", + "types": "./dist/config.d.ts" + }, + "./safety/validator": { + "import": "./dist/safety/validator.js", + "types": "./dist/safety/validator.d.ts" + }, + "./transport/factory": { + "import": "./dist/transport/factory.js", + "types": "./dist/transport/factory.d.ts" + }, + "./transport/transport": { + "import": "./dist/transport/transport.js", + "types": "./dist/transport/transport.d.ts" + }, + "./transport/types": { + "import": "./dist/transport/types.js", + "types": "./dist/transport/types.d.ts" + } + }, "openclaw": { "extensions": [ "./src/index.ts" ] }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "build": "tsc" }, "dependencies": { "@sinclair/typebox": "^0.34.0", diff --git a/extensions/openclaw-plugin/src/config.ts b/extensions/openclaw-plugin/src/config.ts index 2fe4694..09c88fd 100644 --- a/extensions/openclaw-plugin/src/config.ts +++ b/extensions/openclaw-plugin/src/config.ts @@ -6,6 +6,38 @@ const IceServerSchema = z.object({ credential: z.string().optional(), }); +// DEFAULT_ALLOWED_TOPICS: default topic allowlist for common robot control, state, camera, diagnostics, and rosclaw topics +const DEFAULT_ALLOWED_TOPICS = [ + "/cmd_vel", + "*/cmd_vel", + "/goal_pose", + "*/goal_pose", + "/odom", + "*/odom", + "/scan", + "*/scan", + "/camera/*", + "*/camera/*", + "/battery_state", + "*/battery_state", + "/diagnostics", + "*/diagnostics", + "/rosclaw/*", +]; + +// DEFAULT_ALLOWED_SERVICES: default service allowlist for rosapi, rosclaw, and read-only parameter access +const DEFAULT_ALLOWED_SERVICES = [ + "/rosapi/*", + "/rosclaw/*", + "*/get_parameters", +]; + +// DEFAULT_ALLOWED_ACTIONS: default action allowlist for common navigation action servers +const DEFAULT_ALLOWED_ACTIONS = [ + "/navigate_to_pose", + "*/navigate_to_pose", +]; + export const RosClawConfigSchema = z.object({ transport: z .object({ @@ -48,8 +80,20 @@ export const RosClawConfigSchema = z.object({ safety: z .object({ - maxLinearVelocity: z.number().default(1.0), - maxAngularVelocity: z.number().default(1.5), + maxLinearVelocity: z.number().nonnegative().default(1.0), + maxAngularVelocity: z.number().nonnegative().default(1.5), + // readonlyMode: blocks write operations while still allowing read-only discovery and subscriptions + readonlyMode: z.boolean().default(false), + // allowedTopics: topic allowlist; empty means all topics are allowed, otherwise only matching topics pass + allowedTopics: z.array(z.string()).default(DEFAULT_ALLOWED_TOPICS), + // allowedServices: service allowlist; empty means all services are allowed, otherwise only matching services pass + allowedServices: z.array(z.string()).default(DEFAULT_ALLOWED_SERVICES), + // allowedActions: action allowlist; empty means all actions are allowed, otherwise only matching actions pass + allowedActions: z.array(z.string()).default(DEFAULT_ALLOWED_ACTIONS), + // blockedTopics: topic blocklist that overrides allowedTopics; useful for noisy or internal topics like /rosout + blockedTopics: z.array(z.string()).default(["/rosout", "/parameter_events"]), + // requireConfirmationFor: write targets blocked without confirmation; supports bare patterns or kind prefixes like topic:/cmd_vel + requireConfirmationFor: z.array(z.string()).default([]), workspaceLimits: z .object({ xMin: z.number().default(-10), @@ -57,6 +101,14 @@ export const RosClawConfigSchema = z.object({ yMin: z.number().default(-10), yMax: z.number().default(10), }) + .refine((limits) => limits.xMin <= limits.xMax, { + message: "xMin must be less than or equal to xMax", + path: ["xMin"], + }) + .refine((limits) => limits.yMin <= limits.yMax, { + message: "yMin must be less than or equal to yMax", + path: ["yMin"], + }) .default({}), }) .default({}), diff --git a/extensions/openclaw-plugin/src/safety/validator.ts b/extensions/openclaw-plugin/src/safety/validator.ts index eed8b73..f388a6f 100644 --- a/extensions/openclaw-plugin/src/safety/validator.ts +++ b/extensions/openclaw-plugin/src/safety/validator.ts @@ -1,43 +1,467 @@ import type { OpenClawPluginApi } from "../plugin-api.js"; import type { RosClawConfig } from "../config.js"; +type OperationKind = "topic" | "service" | "action" | "none"; + +interface RosOperation { + kind: OperationKind; + target?: string; + write: boolean; +} + +interface Point2D { + x: number; + y: number; +} + /** * Register the before_tool_call safety validation hook. - * Intercepts tool calls and validates them against safety limits. + * Intercepts ROS tool calls and applies safety policy checks before execution. */ export function registerSafetyHook(api: OpenClawPluginApi, config: RosClawConfig): void { const safety = config.safety; api.on("before_tool_call", async (event, _ctx) => { - if (event.toolName === "ros2_publish") { - const msg = event.params["message"] as Record | undefined; - if (msg) { - // Check velocity limits for Twist messages - const linear = msg["linear"] as Record | undefined; - const angular = msg["angular"] as Record | undefined; - - if (linear) { - const speed = Math.sqrt( - (linear["x"] ?? 0) ** 2 + - (linear["y"] ?? 0) ** 2 + - (linear["z"] ?? 0) ** 2, - ); - if (speed > safety.maxLinearVelocity) { - api.logger.warn(`Blocked: linear velocity ${speed} exceeds limit ${safety.maxLinearVelocity}`); - return { block: true, blockReason: `Linear velocity ${speed.toFixed(2)} m/s exceeds safety limit of ${safety.maxLinearVelocity} m/s` }; - } - } - - if (angular) { - const rate = Math.abs(angular["z"] ?? 0); - if (rate > safety.maxAngularVelocity) { - api.logger.warn(`Blocked: angular velocity ${rate} exceeds limit ${safety.maxAngularVelocity}`); - return { block: true, blockReason: `Angular velocity ${rate.toFixed(2)} rad/s exceeds safety limit of ${safety.maxAngularVelocity} rad/s` }; - } - } - } - } - - // TODO: Add workspace limit checks for navigation goals + const violation = validateRosToolCall(event.toolName, event.params, safety); + if (violation) { + api.logger.warn(`Blocked ${event.toolName}: ${violation}`); + return { block: true, blockReason: violation }; + } }); } + +export function validateRosToolCall( + toolName: string, + params: Record, + safety: RosClawConfig["safety"], +): string | null { + // Map each ROS tool call into a normalized operation so policy checks stay centralized. + const operation = getRosOperation(toolName, params); + if (!operation) { + return null; + } + + // Fail closed on any configured safety violation before the tool reaches ROS. + const policyViolation = validateAccessPolicy(operation, safety); + if (policyViolation) { + return policyViolation; + } + + return validateMotionSafety(toolName, params, safety); +} + +function getRosOperation( + toolName: string, + params: Record, +): RosOperation | null { + switch (toolName) { + case "ros2_publish": + return { + kind: "topic", + target: stringParam(params, "topic"), + write: true, + }; + + case "ros2_subscribe_once": + return { + kind: "topic", + target: stringParam(params, "topic"), + write: false, + }; + + case "ros2_camera_snapshot": + return { + kind: "topic", + // Camera snapshot defaults to the common compressed image topic when none is passed in. + target: stringParam(params, "topic") ?? "/camera/image_raw/compressed", + write: false, + }; + + case "ros2_service_call": + return { + kind: "service", + target: stringParam(params, "service"), + write: true, + }; + + case "ros2_param_get": { + const node = stringParam(params, "node"); + return { + kind: "service", + target: node ? `${node}/get_parameters` : undefined, + write: false, + }; + } + + case "ros2_param_set": { + const node = stringParam(params, "node"); + return { + kind: "service", + target: node ? `${node}/set_parameters` : undefined, + write: true, + }; + } + + case "ros2_action_goal": + return { + kind: "action", + target: stringParam(params, "action"), + write: true, + }; + + case "ros2_cancel_action_goal": + return { + kind: "action", + target: stringParam(params, "action"), + write: true, + }; + + case "ros2_list_topics": + case "ros2_list_services": + case "ros2_list_actions": + case "ros2_list_nodes": + case "ros2_node_info": + case "ros2_interface_show": + case "ros2_message_schema": + case "ros2_service_schema": + case "ros2_action_schema": + case "ros2_validate_tool_call": + case "ros2_transport_status": + return { + kind: "none", + write: false, + }; + + case "ros2_topic_info": + return { + kind: "topic", + target: stringParam(params, "topic"), + write: false, + }; + + case "ros2_service_info": + return { + kind: "service", + target: stringParam(params, "service"), + write: false, + }; + + case "ros2_action_info": + return { + kind: "action", + target: stringParam(params, "action"), + write: false, + }; + + default: + return null; + } +} + +function validateAccessPolicy( + operation: RosOperation, + safety: RosClawConfig["safety"], +): string | null { + if (safety.readonlyMode && operation.write) { + return "ROS write operations are blocked because readonlyMode is enabled"; + } + + if (operation.kind === "none") { + return null; + } + + if (!operation.target) { + return `Missing ROS ${operation.kind} target`; + } + + if ( + operation.kind === "topic" && + matchesAny(operation.target, safety.blockedTopics) + ) { + return `Topic ${operation.target} is blocked by safety policy`; + } + + const allowed = getAllowedPatterns(operation.kind, safety); + // Empty allowlists are intentionally permissive; they mean "allow everything" for that kind. + if (allowed.length > 0 && !matchesAny(operation.target, allowed)) { + return `${capitalize(operation.kind)} ${operation.target} is not in the allowed ${operation.kind} list`; + } + + if ( + operation.write && + matchesPolicyTarget( + operation.kind, + operation.target, + safety.requireConfirmationFor, + ) + ) { + return `${capitalize(operation.kind)} ${operation.target} requires explicit user confirmation before execution`; + } + + return null; +} + +function getAllowedPatterns( + kind: OperationKind, + safety: RosClawConfig["safety"], +): string[] { + switch (kind) { + case "topic": + return safety.allowedTopics; + case "service": + return safety.allowedServices; + case "action": + return safety.allowedActions; + case "none": + // Non-ROS discovery/listing operations are always allowed by policy. + return ["*"]; + } +} + +function matchesAny(value: string, patterns: string[]): boolean { + // Pattern matching supports exact names and "*" wildcards. + return patterns.some((pattern) => matchesPattern(value, pattern.trim())); +} + +function matchesPolicyTarget( + kind: OperationKind, + target: string, + patterns: string[], +): boolean { + return patterns.some((rawPattern) => { + const scoped = parseScopedPattern(rawPattern); + if (!scoped.pattern) { + return false; + } + if (scoped.kind && scoped.kind !== kind) { + return false; + } + return matchesPattern(target, scoped.pattern); + }); +} + +function parseScopedPattern(pattern: string): { + kind?: Exclude; + pattern: string; +} { + const trimmed = pattern.trim(); + const separator = trimmed.indexOf(":"); + if (separator <= 0) { + return { pattern: trimmed }; + } + + const kind = trimmed.slice(0, separator); + if (kind !== "topic" && kind !== "service" && kind !== "action") { + return { pattern: trimmed }; + } + + return { + kind, + pattern: trimmed.slice(separator + 1).trim(), + }; +} + +function matchesPattern(value: string, pattern: string): boolean { + if (pattern === "*") { + return true; + } + if (!pattern.includes("*")) { + return value === pattern; + } + + const regex = new RegExp( + `^${pattern.split("*").map(escapeRegex).join(".*")}$`, + ); + return regex.test(value); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function stringParam( + params: Record, + key: string, +): string | undefined { + const value = params[key]; + // Ignore missing or empty values so downstream policy checks can report the target as missing. + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function validateMotionSafety( + toolName: string, + params: Record, + safety: RosClawConfig["safety"], +): string | null { + if (toolName === "ros2_publish") { + const msg = objectParam(params["message"]); + if (!msg) { + return null; + } + + const twistViolation = validateTwistLimits(msg, safety); + if (twistViolation) { + return twistViolation; + } + } + + const goalPoint = getGoalPoint(toolName, params); + if (!goalPoint) { + return null; + } + + const { xMin, xMax, yMin, yMax } = safety.workspaceLimits; + if ( + goalPoint.x < xMin || + goalPoint.x > xMax || + goalPoint.y < yMin || + goalPoint.y > yMax + ) { + return `Goal (${goalPoint.x.toFixed(2)}, ${goalPoint.y.toFixed(2)}) is outside workspace limits x=[${xMin}, ${xMax}], y=[${yMin}, ${yMax}]`; + } + + return null; +} + +function validateTwistLimits( + msg: Record, + safety: RosClawConfig["safety"], +): string | null { + const linear = objectParam(msg["linear"]); + if (linear) { + const linearVector = vector3Param(linear, "linear"); + if (typeof linearVector === "string") { + return linearVector; + } + + const speed = vectorMagnitude(linearVector); + if (speed > safety.maxLinearVelocity) { + return `Linear velocity ${speed.toFixed(2)} m/s exceeds safety limit of ${safety.maxLinearVelocity} m/s`; + } + } + + const angular = objectParam(msg["angular"]); + if (angular) { + const angularVector = vector3Param(angular, "angular"); + if (typeof angularVector === "string") { + return angularVector; + } + + const rate = vectorMagnitude(angularVector); + if (rate > safety.maxAngularVelocity) { + return `Angular velocity ${rate.toFixed(2)} rad/s exceeds safety limit of ${safety.maxAngularVelocity} rad/s`; + } + } + + return null; +} + +function getGoalPoint( + toolName: string, + params: Record, +): Point2D | null { + if (!shouldCheckWorkspace(toolName, params)) { + return null; + } + + if (toolName === "ros2_publish") { + const msg = objectParam(params["message"]); + return msg ? findPoint2D(msg) : null; + } + + if (toolName === "ros2_action_goal") { + const goal = objectParam(params["goal"]); + return goal ? findPoint2D(goal) : null; + } + + return null; +} + +function shouldCheckWorkspace( + toolName: string, + params: Record, +): boolean { + if (toolName === "ros2_publish") { + const topic = stringParam(params, "topic")?.toLowerCase() ?? ""; + return topic.includes("goal") || topic.includes("pose"); + } + + if (toolName === "ros2_action_goal") { + const action = stringParam(params, "action")?.toLowerCase() ?? ""; + return ( + action.includes("navigate") || + action.includes("move") || + action.includes("goal") || + action.includes("pose") + ); + } + + return false; +} + +function findPoint2D(value: unknown): Point2D | null { + const object = objectParam(value); + if (!object) { + return null; + } + + const x = finiteNumberParam(object["x"]); + const y = finiteNumberParam(object["y"]); + if (x !== undefined && y !== undefined) { + return { x, y }; + } + + for (const key of ["position", "pose", "goal", "target_pose"]) { + const nested = objectParam(object[key]); + if (!nested) { + continue; + } + const point = findPoint2D(nested); + if (point) { + return point; + } + } + + return null; +} + +function vector3Param( + value: Record, + label: string, +): { x: number; y: number; z: number } | string { + const x = finiteNumberParam(value["x"]) ?? 0; + const y = finiteNumberParam(value["y"]) ?? 0; + const z = finiteNumberParam(value["z"]) ?? 0; + + for (const axis of ["x", "y", "z"]) { + const axisValue = value[axis]; + if (axisValue !== undefined && finiteNumberParam(axisValue) === undefined) { + return `Invalid ${label}.${axis} value; expected a finite number`; + } + } + + return { x, y, z }; +} + +function vectorMagnitude(vector: { x: number; y: number; z: number }): number { + return Math.sqrt(vector.x ** 2 + vector.y ** 2 + vector.z ** 2); +} + +function objectParam(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function finiteNumberParam(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} diff --git a/extensions/openclaw-plugin/src/standalone.ts b/extensions/openclaw-plugin/src/standalone.ts new file mode 100644 index 0000000..f09da19 --- /dev/null +++ b/extensions/openclaw-plugin/src/standalone.ts @@ -0,0 +1,85 @@ +import type { OpenClawPluginApi, PluginLogger, PluginService } from "./plugin-api.js"; +import plugin from "./index.js"; + +const logger: PluginLogger = { + info: (msg: string) => console.log(`[rosclaw] INFO ${msg}`), + warn: (msg: string) => console.warn(`[rosclaw] WARN ${msg}`), + error: (msg: string) => console.error(`[rosclaw] ERROR ${msg}`), +}; + +const services: PluginService[] = []; + +const api: OpenClawPluginApi = { + pluginConfig: {}, + + logger, + + registerTool() { + // no-op in standalone: tools require an AI agent + }, + + registerService(service: PluginService) { + services.push(service); + }, + + registerCommand() { + // no-op in standalone: commands require OpenClaw + }, + + on() { + // no-op in standalone: hooks require an agent session + }, +}; + +// Read basic config from environment +const transportMode = process.env.ROSCLAW_TRANSPORT_MODE ?? "rosbridge"; +const rosbridgeUrl = process.env.ROSCLAW_ROSBRIDGE_URL ?? "ws://localhost:9090"; + +api.pluginConfig = { + transport: { mode: transportMode }, + rosbridge: { url: rosbridgeUrl, reconnect: true, reconnectInterval: 3000 }, + local: { domainId: 0 }, + webrtc: { + signalingUrl: process.env.ROSCLAW_SIGNALING_URL ?? "", + apiUrl: process.env.ROSCLAW_API_URL ?? "", + robotId: process.env.ROSCLAW_ROBOT_ID ?? "", + robotKey: process.env.ROSCLAW_ROBOT_KEY ?? "", + }, + robot: { name: "Robot", namespace: "" }, + safety: { + maxLinearVelocity: 1.0, + maxAngularVelocity: 1.5, + workspaceLimits: { xMin: -10, xMax: 10, yMin: -10, yMax: 10 }, + }, +}; + +plugin.register(api); + +// Start all registered services +for (const svc of services) { + svc.start({ + config: api.pluginConfig!, + stateDir: "/tmp/rosclaw", + logger, + }).catch((err) => { + logger.error(`Service ${svc.id} failed to start: ${String(err)}`); + }); +} + +// Keep the process alive +process.on("SIGINT", async () => { + logger.info("Shutting down..."); + for (const svc of services) { + await svc.stop?.({ config: api.pluginConfig!, stateDir: "/tmp/rosclaw", logger }); + logger.info(`Service ${svc.id} stopped`); + } + process.exit(0); +}); + +process.on("SIGTERM", async () => { + logger.info("Shutting down..."); + for (const svc of services) { + await svc.stop?.({ config: api.pluginConfig!, stateDir: "/tmp/rosclaw", logger }); + } + process.exit(0); +}); diff --git a/extensions/openclaw-plugin/src/transport/local/transport.ts b/extensions/openclaw-plugin/src/transport/local/transport.ts index 5ac17c3..383b449 100644 --- a/extensions/openclaw-plugin/src/transport/local/transport.ts +++ b/extensions/openclaw-plugin/src/transport/local/transport.ts @@ -13,6 +13,14 @@ import type { TopicInfo, ServiceInfo, ActionInfo, + NodeInfo, + NodeDetails, + TopicDetails, + ServiceDetails, + ActionDetails, + MessageSchema, + ServiceSchema, + ActionSchema, MessageHandler, } from "../types.js"; import { EntityCache } from "./entities.js"; @@ -309,6 +317,89 @@ export class LocalTransport implements RosTransport { return actions; } + async listNodes(): Promise { + this.ensureConnected(); + return this.getNodeNames().map((name) => ({ name })); + } + + async getNodeInfo(node: string): Promise { + this.ensureConnected(); + const topics = await this.listTopics(); + const services = await this.listServices(); + return { + name: node, + subscribing: topics + .filter((topic) => this.topicHasNode(topic.name, node, "subscriber")) + .map((topic) => topic.name), + publishing: topics + .filter((topic) => this.topicHasNode(topic.name, node, "publisher")) + .map((topic) => topic.name), + services: services + .filter((service) => this.serviceHasNode(service.name, node)) + .map((service) => service.name), + }; + } + + async getTopicInfo(topic: string): Promise { + this.ensureConnected(); + const type = this.resolveTopicType(topic) ?? ""; + const publishers = this.getTopicEndpointNodes(topic, "publisher"); + const subscribers = this.getTopicEndpointNodes(topic, "subscriber"); + const qosProfiles = [ + ...this.getTopicEndpointQos(topic, "publisher"), + ...this.getTopicEndpointQos(topic, "subscriber"), + ]; + return { + name: topic, + type, + publishers, + subscribers, + publisherCount: publishers.length, + subscriberCount: subscribers.length, + qosAvailable: qosProfiles.length > 0, + qosProfiles, + }; + } + + async getServiceInfo(service: string): Promise { + this.ensureConnected(); + const providers = this.getNodeNames().filter((node) => this.serviceHasNode(service, node)); + return { + name: service, + type: this.resolveServiceType(service) ?? "", + providers, + providerCount: providers.length, + }; + } + + async getActionInfo(action: string): Promise { + const actions = await this.listActions(); + const match = actions.find((item) => item.name === action); + return { + name: action, + type: match?.type ?? "", + servers: match ? [match.name] : [], + }; + } + + async getMessageSchema(type: string): Promise { + throw new Error( + `Message schema introspection for ${type} is not supported by LocalTransport yet`, + ); + } + + async getServiceSchema(type: string): Promise { + throw new Error( + `Service schema introspection for ${type} is not supported by LocalTransport yet`, + ); + } + + async getActionSchema(type: string): Promise { + throw new Error( + `Action schema introspection for ${type} is not supported by LocalTransport yet`, + ); + } + // --- Private helpers --- private setStatus(status: ConnectionStatus): void { @@ -348,6 +439,83 @@ export class LocalTransport implements RosTransport { return entry?.types[0]; } + private getNodeNames(): string[] { + const getNodeNames = this.node?.getNodeNames; + if (typeof getNodeNames === "function") { + return getNodeNames.call(this.node) as string[]; + } + + const getNodeNamesAndNamespaces = this.node?.getNodeNamesAndNamespaces; + if (typeof getNodeNamesAndNamespaces === "function") { + const entries = getNodeNamesAndNamespaces.call(this.node) as Array<{ + name?: string; + namespace?: string; + }>; + return entries.map((entry) => qualifyNodeName(entry.namespace, entry.name)); + } + + return []; + } + + private topicHasNode( + topic: string, + node: string, + endpointKind: "publisher" | "subscriber", + ): boolean { + return this.getTopicEndpointNodes(topic, endpointKind).includes(node); + } + + private serviceHasNode(service: string, node: string): boolean { + const getServiceServerInfo = this.node?.getServiceServerInfo; + if (typeof getServiceServerInfo !== "function") { + return false; + } + const endpoints = getServiceServerInfo.call(this.node, service) as Array<{ + nodeName?: string; + nodeNamespace?: string; + }>; + return endpoints + .map((endpoint) => qualifyNodeName(endpoint.nodeNamespace, endpoint.nodeName)) + .includes(node); + } + + private getTopicEndpointNodes( + topic: string, + endpointKind: "publisher" | "subscriber", + ): string[] { + const methodName = + endpointKind === "publisher" ? "getPublishersInfoByTopic" : "getSubscriptionsInfoByTopic"; + const method = this.node?.[methodName]; + if (typeof method !== "function") { + return []; + } + const endpoints = method.call(this.node, topic) as Array<{ + nodeName?: string; + nodeNamespace?: string; + }>; + return Array.from( + new Set( + endpoints.map((endpoint) => qualifyNodeName(endpoint.nodeNamespace, endpoint.nodeName)), + ), + ); + } + + private getTopicEndpointQos( + topic: string, + endpointKind: "publisher" | "subscriber", + ): unknown[] { + const methodName = + endpointKind === "publisher" ? "getPublishersInfoByTopic" : "getSubscriptionsInfoByTopic"; + const method = this.node?.[methodName]; + if (typeof method !== "function") { + return []; + } + const endpoints = method.call(this.node, topic) as Array<{ qosProfile?: unknown }>; + return endpoints + .map((endpoint) => endpoint.qosProfile) + .filter((qosProfile): qosProfile is unknown => qosProfile !== undefined); + } + /** * Wrap rclnodejs callback-based sendRequest in a Promise with timeout. */ @@ -373,3 +541,9 @@ export class LocalTransport implements RosTransport { }); } } + +function qualifyNodeName(namespace: string | undefined, name: string | undefined): string { + const safeName = name ?? ""; + const safeNamespace = namespace && namespace !== "/" ? namespace : ""; + return `${safeNamespace}/${safeName}`.replace(/\/+/g, "/"); +} diff --git a/extensions/openclaw-plugin/src/transport/rosbridge/adapter.ts b/extensions/openclaw-plugin/src/transport/rosbridge/adapter.ts index 0d8e69c..8b61183 100644 --- a/extensions/openclaw-plugin/src/transport/rosbridge/adapter.ts +++ b/extensions/openclaw-plugin/src/transport/rosbridge/adapter.ts @@ -12,6 +12,15 @@ import type { TopicInfo, ServiceInfo, ActionInfo, + NodeInfo, + NodeDetails, + TopicDetails, + ServiceDetails, + ActionDetails, + RosTypeDef, + MessageSchema, + ServiceSchema, + ActionSchema, MessageHandler, } from "../types.js"; import { RosbridgeClient } from "./client.js"; @@ -104,7 +113,7 @@ export class RosbridgeTransport implements RosTransport { this.client, "/rosapi/topics", {}, - "rosapi/srv/Topics", + "rosapi_msgs/srv/Topics", ); const topics = (response.values?.["topics"] as string[]) ?? []; const types = (response.values?.["types"] as string[]) ?? []; @@ -116,16 +125,32 @@ export class RosbridgeTransport implements RosTransport { this.client, "/rosapi/services", {}, - "rosapi/srv/Services", + "rosapi_msgs/srv/Services", ); const services = (response.values?.["services"] as string[]) ?? []; - const types = (response.values?.["types"] as string[]) ?? []; - return services.map((name, i) => ({ name, type: types[i] ?? "" })); + return Promise.all( + services.map(async (name) => ({ name, type: await this.getServiceType(name) })), + ); } async listActions(): Promise { - // rosapi has no built-in action listing. Heuristic: action servers expose - // topics matching */_action/feedback. Extract action names from that pattern. + const response = await callService( + this.client, + "/rosapi/action_servers", + {}, + "rosapi_msgs/srv/GetActionServers", + ); + const actionServers = stringArray(response.values?.["action_servers"]); + if (actionServers.length > 0) { + return Promise.all( + actionServers.map(async (name) => ({ + name, + type: await this.getActionType(name), + })), + ); + } + + // Fallback for rosapi variants that do not expose /rosapi/action_servers. const topics = await this.listTopics(); const actions: ActionInfo[] = []; const feedbackSuffix = "/_action/feedback"; @@ -145,4 +170,205 @@ export class RosbridgeTransport implements RosTransport { return actions; } + + async listNodes(): Promise { + const response = await callService( + this.client, + "/rosapi/nodes", + {}, + "rosapi_msgs/srv/Nodes", + ); + return stringArray(response.values?.["nodes"]).map((name) => ({ name })); + } + + async getNodeInfo(node: string): Promise { + const response = await callService( + this.client, + "/rosapi/node_details", + { node }, + "rosapi_msgs/srv/NodeDetails", + ); + return { + name: node, + subscribing: stringArray(response.values?.["subscribing"]), + publishing: stringArray(response.values?.["publishing"]), + services: stringArray(response.values?.["services"]), + }; + } + + async getTopicInfo(topic: string): Promise { + const [typeResponse, publishersResponse, subscribersResponse] = await Promise.all([ + callService(this.client, "/rosapi/topic_type", { topic }, "rosapi_msgs/srv/TopicType"), + callService(this.client, "/rosapi/publishers", { topic }, "rosapi_msgs/srv/Publishers"), + callService(this.client, "/rosapi/subscribers", { topic }, "rosapi_msgs/srv/Subscribers"), + ]); + const publishers = stringArray(publishersResponse.values?.["publishers"]); + const subscribers = stringArray(subscribersResponse.values?.["subscribers"]); + return { + name: topic, + type: stringValue(typeResponse.values?.["type"]), + publishers, + subscribers, + publisherCount: publishers.length, + subscriberCount: subscribers.length, + qosAvailable: false, + qosProfiles: [], + }; + } + + async getServiceInfo(service: string): Promise { + const [type, nodeResponse] = await Promise.all([ + this.getServiceType(service), + callService( + this.client, + "/rosapi/service_node", + { service }, + "rosapi_msgs/srv/ServiceNode", + ), + ]); + const provider = stringValue(nodeResponse.values?.["node"]); + const providers = provider ? [provider] : []; + return { + name: service, + type, + providers, + providerCount: providers.length, + }; + } + + async getActionInfo(action: string): Promise { + const type = await this.getActionType(action); + return { + name: action, + type, + servers: type ? [action] : [], + }; + } + + async getMessageSchema(type: string): Promise { + const response = await callService( + this.client, + "/rosapi/message_details", + { type }, + "rosapi_msgs/srv/MessageDetails", + ); + return { type, typedefs: typeDefs(response.values?.["typedefs"]) }; + } + + async getServiceSchema(type: string): Promise { + const [requestResponse, responseResponse] = await Promise.all([ + callService( + this.client, + "/rosapi/service_request_details", + { type }, + "rosapi_msgs/srv/ServiceRequestDetails", + ), + callService( + this.client, + "/rosapi/service_response_details", + { type }, + "rosapi_msgs/srv/ServiceResponseDetails", + ), + ]); + return { + type, + request: typeDefs(requestResponse.values?.["typedefs"]), + response: typeDefs(responseResponse.values?.["typedefs"]), + }; + } + + async getActionSchema(type: string): Promise { + const [goalResponse, resultResponse, feedbackResponse] = await Promise.all([ + callService( + this.client, + "/rosapi/action_goal_details", + { type }, + "rosapi_msgs/srv/ActionGoalDetails", + ), + callService( + this.client, + "/rosapi/action_result_details", + { type }, + "rosapi_msgs/srv/ActionResultDetails", + ), + callService( + this.client, + "/rosapi/action_feedback_details", + { type }, + "rosapi_msgs/srv/ActionFeedbackDetails", + ), + ]); + return { + type, + goal: typeDefs(goalResponse.values?.["typedefs"]), + result: typeDefs(resultResponse.values?.["typedefs"]), + feedback: typeDefs(feedbackResponse.values?.["typedefs"]), + }; + } + + private async getActionType(action: string): Promise { + const response = await callService( + this.client, + "/rosapi/action_type", + { action }, + "rosapi_msgs/srv/ActionType", + ); + return stringValue(response.values?.["type"]); + } + + private async getServiceType(service: string): Promise { + const response = await callService( + this.client, + "/rosapi/service_type", + { service }, + "rosapi_msgs/srv/ServiceType", + ); + return stringValue(response.values?.["type"]); + } +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; +} + +function typeDefs(value: unknown): RosTypeDef[] { + return Array.isArray(value) + ? value.filter(isTypeDef).map((item) => ({ + type: item.type, + fieldnames: item.fieldnames, + fieldtypes: item.fieldtypes, + fieldarraylen: item.fieldarraylen, + examples: item.examples, + constnames: item.constnames, + constvalues: item.constvalues, + })) + : []; +} + +function isTypeDef(value: unknown): value is RosTypeDef { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const candidate = value as Record; + return ( + typeof candidate["type"] === "string" && + isStringArray(candidate["fieldnames"]) && + isStringArray(candidate["fieldtypes"]) && + isNumberArray(candidate["fieldarraylen"]) && + isStringArray(candidate["examples"]) && + isStringArray(candidate["constnames"]) && + isStringArray(candidate["constvalues"]) + ); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function isNumberArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((item) => typeof item === "number"); } diff --git a/extensions/openclaw-plugin/src/transport/transport.ts b/extensions/openclaw-plugin/src/transport/transport.ts index 8e1adba..28060e9 100644 --- a/extensions/openclaw-plugin/src/transport/transport.ts +++ b/extensions/openclaw-plugin/src/transport/transport.ts @@ -11,6 +11,14 @@ import type { TopicInfo, ServiceInfo, ActionInfo, + NodeInfo, + NodeDetails, + TopicDetails, + ServiceDetails, + ActionDetails, + MessageSchema, + ServiceSchema, + ActionSchema, MessageHandler, } from "./types.js"; @@ -67,4 +75,28 @@ export interface RosTransport { /** List all available ROS2 action servers. */ listActions(): Promise; + + /** List all available ROS2 nodes. */ + listNodes(): Promise; + + /** Return a node's topic and service graph details. */ + getNodeInfo(node: string): Promise; + + /** Return topic type, publishers, subscribers, and QoS metadata when available. */ + getTopicInfo(topic: string): Promise; + + /** Return service type and provider nodes. */ + getServiceInfo(service: string): Promise; + + /** Return action server type and provider nodes when available. */ + getActionInfo(action: string): Promise; + + /** Return message field schema for a ROS interface type. */ + getMessageSchema(type: string): Promise; + + /** Return request and response field schema for a ROS service type. */ + getServiceSchema(type: string): Promise; + + /** Return goal, result, and feedback field schema for a ROS action type. */ + getActionSchema(type: string): Promise; } diff --git a/extensions/openclaw-plugin/src/transport/types.ts b/extensions/openclaw-plugin/src/transport/types.ts index 9487b7c..43b6dfd 100644 --- a/extensions/openclaw-plugin/src/transport/types.ts +++ b/extensions/openclaw-plugin/src/transport/types.ts @@ -76,6 +76,69 @@ export interface ActionInfo { type: string; } +export interface NodeInfo { + name: string; +} + +export interface NodeDetails { + name: string; + subscribing: string[]; + publishing: string[]; + services: string[]; +} + +export interface TopicDetails { + name: string; + type: string; + publishers: string[]; + subscribers: string[]; + publisherCount: number; + subscriberCount: number; + qosAvailable: boolean; + qosProfiles: unknown[]; +} + +export interface ServiceDetails { + name: string; + type: string; + providers: string[]; + providerCount: number; +} + +export interface ActionDetails { + name: string; + type: string; + servers: string[]; +} + +export interface RosTypeDef { + type: string; + fieldnames: string[]; + fieldtypes: string[]; + fieldarraylen: number[]; + examples: string[]; + constnames: string[]; + constvalues: string[]; +} + +export interface MessageSchema { + type: string; + typedefs: RosTypeDef[]; +} + +export interface ServiceSchema { + type: string; + request: RosTypeDef[]; + response: RosTypeDef[]; +} + +export interface ActionSchema { + type: string; + goal: RosTypeDef[]; + result: RosTypeDef[]; + feedback: RosTypeDef[]; +} + // --- Transport Configuration --- export interface RosbridgeTransportConfig { diff --git a/extensions/openclaw-plugin/src/transport/webrtc/transport.ts b/extensions/openclaw-plugin/src/transport/webrtc/transport.ts index f6d2af4..818e6f8 100644 --- a/extensions/openclaw-plugin/src/transport/webrtc/transport.ts +++ b/extensions/openclaw-plugin/src/transport/webrtc/transport.ts @@ -13,6 +13,15 @@ import type { TopicInfo, ServiceInfo, ActionInfo, + NodeInfo, + NodeDetails, + TopicDetails, + ServiceDetails, + ActionDetails, + RosTypeDef, + MessageSchema, + ServiceSchema, + ActionSchema, MessageHandler, RTCIceServerConfig, } from "../types.js"; @@ -259,7 +268,7 @@ export class WebRTCTransport implements RosTransport { async listTopics(): Promise { const result = await this.callService({ service: "/rosapi/topics", - type: "rosapi/srv/Topics", + type: "rosapi_msgs/srv/Topics", args: {}, }); const topics = (result.values?.["topics"] as string[]) ?? []; @@ -270,12 +279,13 @@ export class WebRTCTransport implements RosTransport { async listServices(): Promise { const result = await this.callService({ service: "/rosapi/services", - type: "rosapi/srv/Services", + type: "rosapi_msgs/srv/Services", args: {}, }); const services = (result.values?.["services"] as string[]) ?? []; - const types = (result.values?.["types"] as string[]) ?? []; - return services.map((name, i) => ({ name, type: types[i] ?? "" })); + return Promise.all( + services.map(async (name) => ({ name, type: await this.getServiceType(name) })), + ); } async listActions(): Promise { @@ -298,12 +308,164 @@ export class WebRTCTransport implements RosTransport { return actions; } + async listNodes(): Promise { + const result = await this.callService({ + service: "/rosapi/nodes", + type: "rosapi_msgs/srv/Nodes", + args: {}, + }); + return stringArray(result.values?.["nodes"]).map((name) => ({ name })); + } + + async getNodeInfo(node: string): Promise { + const result = await this.callService({ + service: "/rosapi/node_details", + type: "rosapi_msgs/srv/NodeDetails", + args: { node }, + }); + return { + name: node, + subscribing: stringArray(result.values?.["subscribing"]), + publishing: stringArray(result.values?.["publishing"]), + services: stringArray(result.values?.["services"]), + }; + } + + async getTopicInfo(topic: string): Promise { + const [typeResult, publishersResult, subscribersResult] = await Promise.all([ + this.callService({ + service: "/rosapi/topic_type", + type: "rosapi_msgs/srv/TopicType", + args: { topic }, + }), + this.callService({ + service: "/rosapi/publishers", + type: "rosapi_msgs/srv/Publishers", + args: { topic }, + }), + this.callService({ + service: "/rosapi/subscribers", + type: "rosapi_msgs/srv/Subscribers", + args: { topic }, + }), + ]); + const publishers = stringArray(publishersResult.values?.["publishers"]); + const subscribers = stringArray(subscribersResult.values?.["subscribers"]); + return { + name: topic, + type: stringValue(typeResult.values?.["type"]), + publishers, + subscribers, + publisherCount: publishers.length, + subscriberCount: subscribers.length, + qosAvailable: false, + qosProfiles: [], + }; + } + + async getServiceInfo(service: string): Promise { + const [type, nodeResult] = await Promise.all([ + this.getServiceType(service), + this.callService({ + service: "/rosapi/service_node", + type: "rosapi_msgs/srv/ServiceNode", + args: { service }, + }), + ]); + const provider = stringValue(nodeResult.values?.["node"]); + const providers = provider ? [provider] : []; + return { + name: service, + type, + providers, + providerCount: providers.length, + }; + } + + async getActionInfo(action: string): Promise { + const result = await this.callService({ + service: "/rosapi/action_type", + type: "rosapi_msgs/srv/ActionType", + args: { action }, + }); + const type = stringValue(result.values?.["type"]); + return { + name: action, + type, + servers: type ? [action] : [], + }; + } + + async getMessageSchema(type: string): Promise { + const result = await this.callService({ + service: "/rosapi/message_details", + type: "rosapi_msgs/srv/MessageDetails", + args: { type }, + }); + return { type, typedefs: typeDefs(result.values?.["typedefs"]) }; + } + + async getServiceSchema(type: string): Promise { + const [requestResult, responseResult] = await Promise.all([ + this.callService({ + service: "/rosapi/service_request_details", + type: "rosapi_msgs/srv/ServiceRequestDetails", + args: { type }, + }), + this.callService({ + service: "/rosapi/service_response_details", + type: "rosapi_msgs/srv/ServiceResponseDetails", + args: { type }, + }), + ]); + return { + type, + request: typeDefs(requestResult.values?.["typedefs"]), + response: typeDefs(responseResult.values?.["typedefs"]), + }; + } + + async getActionSchema(type: string): Promise { + const [goalResult, resultResult, feedbackResult] = await Promise.all([ + this.callService({ + service: "/rosapi/action_goal_details", + type: "rosapi_msgs/srv/ActionGoalDetails", + args: { type }, + }), + this.callService({ + service: "/rosapi/action_result_details", + type: "rosapi_msgs/srv/ActionResultDetails", + args: { type }, + }), + this.callService({ + service: "/rosapi/action_feedback_details", + type: "rosapi_msgs/srv/ActionFeedbackDetails", + args: { type }, + }), + ]); + return { + type, + goal: typeDefs(goalResult.values?.["typedefs"]), + result: typeDefs(resultResult.values?.["typedefs"]), + feedback: typeDefs(feedbackResult.values?.["typedefs"]), + }; + } + // --- Private helpers --- private nextId(prefix = "rosclaw"): string { return `${prefix}_${++this.idCounter}`; } + private async getServiceType(service: string): Promise { + const result = await this.callService({ + service: "/rosapi/service_type", + type: "rosapi_msgs/srv/ServiceType", + args: { service }, + }); + return stringValue(result.values?.["type"]); + } + private setStatus(status: ConnectionStatus): void { this.status = status; for (const handler of this.connectionHandlers) { @@ -514,3 +676,49 @@ export class WebRTCTransport implements RosTransport { this.pendingRequests.clear(); } } + +function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; +} + +function typeDefs(value: unknown): RosTypeDef[] { + return Array.isArray(value) + ? value.filter(isTypeDef).map((item) => ({ + type: item.type, + fieldnames: item.fieldnames, + fieldtypes: item.fieldtypes, + fieldarraylen: item.fieldarraylen, + examples: item.examples, + constnames: item.constnames, + constvalues: item.constvalues, + })) + : []; +} + +function isTypeDef(value: unknown): value is RosTypeDef { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const candidate = value as Record; + return ( + typeof candidate["type"] === "string" && + isStringArray(candidate["fieldnames"]) && + isStringArray(candidate["fieldtypes"]) && + isNumberArray(candidate["fieldarraylen"]) && + isStringArray(candidate["examples"]) && + isStringArray(candidate["constnames"]) && + isStringArray(candidate["constvalues"]) + ); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function isNumberArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((item) => typeof item === "number"); +} diff --git a/extensions/rosclaw-claude-plugin/.claude-plugin/plugin.json b/extensions/rosclaw-claude-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..239f4c1 --- /dev/null +++ b/extensions/rosclaw-claude-plugin/.claude-plugin/plugin.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "rosclaw", + "version": "0.0.1", + "description": "Claude Code plugin for controlled ROS2 access through RosClaw MCP tools.", + "author": { + "name": "PlaiPin" + }, + "repository": "https://github.com/PlaiPin/rosclaw", + "license": "Apache-2.0", + "keywords": ["ros2", "robotics", "mcp", "claude-code"], + "userConfig": { + "rosbridge_url": { + "type": "string", + "title": "rosbridge URL", + "description": "WebSocket URL for rosbridge_server.", + "default": "ws://localhost:9090" + }, + "readonly_mode": { + "type": "boolean", + "title": "Read-only mode", + "description": "Block write-capable ROS operations until disabled intentionally.", + "default": true + } + } +} diff --git a/extensions/rosclaw-claude-plugin/.mcp.json b/extensions/rosclaw-claude-plugin/.mcp.json new file mode 100644 index 0000000..992c63e --- /dev/null +++ b/extensions/rosclaw-claude-plugin/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "rosclaw": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/bin/rosclaw-mcp.mjs"], + "env": { + "ROSCLAW_MCP_SERVER_PATH": "${CLAUDE_PROJECT_DIR}/extensions/rosclaw-codex-mcp-server/dist/index.js", + "ROSCLAW_TRANSPORT_MODE": "rosbridge", + "ROSCLAW_ROSBRIDGE_URL": "${user_config.rosbridge_url}", + "ROSCLAW_CONFIG_JSON": "{\"safety\":{\"readonlyMode\":${user_config.readonly_mode},\"allowedTopics\":[],\"allowedServices\":[],\"allowedActions\":[]}}" + } + } + } +} diff --git a/extensions/rosclaw-claude-plugin/README.md b/extensions/rosclaw-claude-plugin/README.md new file mode 100644 index 0000000..13cd61b --- /dev/null +++ b/extensions/rosclaw-claude-plugin/README.md @@ -0,0 +1,51 @@ +# RosClaw Claude Code Plugin + +This plugin connects Claude Code to RosClaw through the existing MCP stdio server. +It contributes: + +- a Claude Code plugin manifest +- an MCP server entry named `rosclaw` +- a `rosclaw-ops` skill for safe ROS2 inspection and operation workflows + +## Prerequisites + +Build the RosClaw MCP server from the repository root: + +```bash +pnpm install +pnpm --filter @rosclaw/rosclaw-codex-mcp-server build +``` + +Start ROS2 and `rosbridge_server` so the configured URL is reachable. The default +URL is `ws://localhost:9090`. + +## Use Locally + +From the RosClaw repository root: + +```bash +claude --plugin-dir ./extensions/rosclaw-claude-plugin +``` + +Then verify the plugin and MCP server from Claude Code: + +```text +/plugin +/mcp +``` + +By default the plugin enables RosClaw safety `readonlyMode`. Disable it only for +simulators or controlled robot sessions with an explicit safety config. + +## Configuration + +The plugin prompts for: + +- `rosbridge_url`: rosbridge WebSocket URL +- `readonly_mode`: whether write-capable ROS tools are blocked + +For non-standard checkouts, set `ROSCLAW_MCP_SERVER_PATH` to the built server: + +```bash +export ROSCLAW_MCP_SERVER_PATH=/path/to/rosclaw/extensions/rosclaw-codex-mcp-server/dist/index.js +``` diff --git a/extensions/rosclaw-claude-plugin/bin/rosclaw-mcp.mjs b/extensions/rosclaw-claude-plugin/bin/rosclaw-mcp.mjs new file mode 100755 index 0000000..1037e44 --- /dev/null +++ b/extensions/rosclaw-claude-plugin/bin/rosclaw-mcp.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import { access } from "node:fs/promises"; +import { resolve } from "node:path"; +import { spawn } from "node:child_process"; + +const serverPath = resolve( + process.env.ROSCLAW_MCP_SERVER_PATH ?? + "./extensions/rosclaw-codex-mcp-server/dist/index.js", +); + +try { + await access(serverPath); +} catch { + console.error( + [ + `RosClaw MCP server not found at ${serverPath}.`, + "Build it from the RosClaw repo first:", + " pnpm --filter @rosclaw/rosclaw-codex-mcp-server build", + "If Claude was launched outside the RosClaw repo, set ROSCLAW_MCP_SERVER_PATH.", + ].join("\n"), + ); + process.exit(1); +} + +const child = spawn(process.execPath, [serverPath], { + env: process.env, + stdio: "inherit", +}); + +const forwardSignal = (signal) => { + if (!child.killed) { + child.kill(signal); + } +}; + +process.on("SIGINT", () => forwardSignal("SIGINT")); +process.on("SIGTERM", () => forwardSignal("SIGTERM")); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/extensions/rosclaw-claude-plugin/skills/rosclaw-ops/SKILL.md b/extensions/rosclaw-claude-plugin/skills/rosclaw-ops/SKILL.md new file mode 100644 index 0000000..3b5656d --- /dev/null +++ b/extensions/rosclaw-claude-plugin/skills/rosclaw-ops/SKILL.md @@ -0,0 +1,27 @@ +--- +name: rosclaw-ops +description: Use when operating or inspecting ROS2 robots through the RosClaw MCP tools in Claude Code. +--- + +# RosClaw Operations + +Use the RosClaw MCP tools for ROS2 robot access. Prefer read-only discovery before any state-changing operation. + +## Workflow + +1. Check transport health with `ros2_transport_status`. +2. Discover the graph with `ros2_list_topics`, `ros2_list_services`, `ros2_list_actions`, or `ros2_list_nodes`. +3. Inspect exact interfaces before sending structured data: + - Topics: `ros2_topic_info`, then `ros2_message_schema`. + - Services: `ros2_service_info`, then `ros2_service_schema`. + - Actions: `ros2_action_info`, then `ros2_action_schema`. +4. Dry-run safety with `ros2_validate_tool_call` before write-capable calls. +5. Prefer simulator validation before controlling real hardware. + +## Safety Rules + +- Treat `ros2_publish`, `ros2_param_set`, `ros2_service_call`, `ros2_action_goal`, and `ros2_cancel_action_goal` as write-capable. +- Do not bypass RosClaw safety failures. +- Keep velocity commands bounded and short-lived. +- Confirm coordinate frames and workspace limits before navigation goals. +- If schema, frame, or topic semantics are unclear, stop and inspect instead of guessing. diff --git a/extensions/rosclaw-codex-mcp-server/package.json b/extensions/rosclaw-codex-mcp-server/package.json new file mode 100644 index 0000000..ebd661f --- /dev/null +++ b/extensions/rosclaw-codex-mcp-server/package.json @@ -0,0 +1,37 @@ +{ + "name": "@rosclaw/rosclaw-codex-mcp-server", + "version": "0.0.1", + "type": "module", + "description": "MCP stdio server for controlled RosClaw ROS2 access", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "prebuild": "pnpm --filter @rosclaw/rosclaw build", + "build": "tsc", + "pretypecheck": "pnpm --filter @rosclaw/rosclaw build", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "@rosclaw/rosclaw": "workspace:*", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^20.17.0", + "typescript": "^5.7.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/PlaiPin/rosclaw.git", + "directory": "extensions/rosclaw-codex-mcp-server" + }, + "author": "PlaiPin", + "license": "Apache-2.0" +} diff --git a/extensions/rosclaw-codex-mcp-server/src/index.ts b/extensions/rosclaw-codex-mcp-server/src/index.ts new file mode 100644 index 0000000..3f4ecea --- /dev/null +++ b/extensions/rosclaw-codex-mcp-server/src/index.ts @@ -0,0 +1,879 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { parseConfig } from "@rosclaw/rosclaw/config"; +import { validateRosToolCall } from "@rosclaw/rosclaw/safety/validator"; +import { createTransport } from "@rosclaw/rosclaw/transport/factory"; +import type { RosTransport } from "@rosclaw/rosclaw/transport/transport"; +import type { TransportConfig } from "@rosclaw/rosclaw/transport/types"; +import type { RosClawConfig } from "@rosclaw/rosclaw/config"; +import { z } from "zod"; + +let transport: RosTransport | null = null; +const config = readRosClawConfig(); + +const server = new McpServer( + { + name: "rosclaw", + version: "0.0.1", + }, + { + instructions: + "Use this server for controlled ROS2 access through RosClaw. Write-capable ROS tools are guarded by RosClaw safety policy.", + }, +); + +server.registerTool( + "ros2_publish", + { + title: "ROS2 Publish", + description: + "Publish a message to a ROS2 topic after RosClaw safety policy validation.", + inputSchema: z.object({ + topic: z.string().min(1).describe("ROS2 topic name, for example /cmd_vel"), + type: z + .string() + .min(1) + .describe("ROS2 message type, for example geometry_msgs/msg/Twist"), + message: z + .record(z.unknown()) + .describe("Message payload matching the ROS2 message type schema"), + }), + annotations: { + readOnlyHint: false, + destructiveHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_publish", params); + const ros = await getTransport(); + ros.publish({ + topic: params.topic, + type: params.type, + msg: params.message, + }); + + return jsonResult({ + success: true, + topic: params.topic, + type: params.type, + }); + }, +); + +server.registerTool( + "ros2_list_topics", + { + title: "ROS2 List Topics", + description: + "List available ROS2 topics and message types through the configured RosClaw transport.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async () => { + const ros = await getTransport(); + const topics = await ros.listTopics(); + + return jsonResult({ success: true, topics }); + }, +); + +server.registerTool( + "ros2_list_services", + { + title: "ROS2 List Services", + description: + "List available ROS2 services and service types through the configured RosClaw transport.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async () => { + assertAllowed("ros2_list_services", {}); + const ros = await getTransport(); + const services = await ros.listServices(); + + return jsonResult({ success: true, services }); + }, +); + +server.registerTool( + "ros2_list_actions", + { + title: "ROS2 List Actions", + description: + "List available ROS2 action servers and action types through the configured RosClaw transport.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async () => { + assertAllowed("ros2_list_actions", {}); + const ros = await getTransport(); + const actions = await ros.listActions(); + + return jsonResult({ success: true, actions }); + }, +); + +server.registerTool( + "ros2_list_nodes", + { + title: "ROS2 List Nodes", + description: + "List available ROS2 nodes through the configured RosClaw transport.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async () => { + assertAllowed("ros2_list_nodes", {}); + const ros = await getTransport(); + const nodes = await ros.listNodes(); + + return jsonResult({ success: true, nodes }); + }, +); + +server.registerTool( + "ros2_node_info", + { + title: "ROS2 Node Info", + description: + "Return the topics and services used by one ROS2 node without modifying robot state.", + inputSchema: z.object({ + node: z.string().min(1).describe("Fully qualified ROS2 node name"), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_node_info", params); + const ros = await getTransport(); + const node = await ros.getNodeInfo(params.node); + + return jsonResult({ success: true, node }); + }, +); + +server.registerTool( + "ros2_topic_info", + { + title: "ROS2 Topic Info", + description: + "Return a ROS2 topic's type, publishers, subscribers, and QoS metadata when available.", + inputSchema: z.object({ + topic: z.string().min(1).describe("ROS2 topic name, for example /odom"), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_topic_info", params); + const ros = await getTransport(); + const topic = await ros.getTopicInfo(params.topic); + + return jsonResult({ success: true, topic }); + }, +); + +server.registerTool( + "ros2_service_info", + { + title: "ROS2 Service Info", + description: + "Return a ROS2 service's type and provider nodes without modifying robot state.", + inputSchema: z.object({ + service: z.string().min(1).describe("ROS2 service name"), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_service_info", params); + const ros = await getTransport(); + const service = await ros.getServiceInfo(params.service); + + return jsonResult({ success: true, service }); + }, +); + +server.registerTool( + "ros2_action_info", + { + title: "ROS2 Action Info", + description: + "Return a ROS2 action server's type and server metadata without modifying robot state.", + inputSchema: z.object({ + action: z.string().min(1).describe("ROS2 action server name"), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_action_info", params); + const ros = await getTransport(); + const action = await ros.getActionInfo(params.action); + + return jsonResult({ success: true, action }); + }, +); + +server.registerTool( + "ros2_interface_show", + { + title: "ROS2 Interface Show", + description: + "Return the field schema for a ROS2 message, service, or action interface type.", + inputSchema: z.object({ + type: z + .string() + .min(1) + .describe("ROS2 interface type, for example geometry_msgs/msg/Twist"), + kind: z + .enum(["message", "service", "action"]) + .optional() + .describe("Optional interface kind override."), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_interface_show", params); + const ros = await getTransport(); + const schema = await showInterface(ros, params.type, params.kind); + + return jsonResult({ success: true, ...schema }); + }, +); + +server.registerTool( + "ros2_message_schema", + { + title: "ROS2 Message Schema", + description: + "Return the field schema for a ROS2 message type, including nested typedefs.", + inputSchema: z.object({ + type: z + .string() + .min(1) + .describe("ROS2 message type, for example geometry_msgs/msg/Twist"), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_message_schema", params); + const ros = await getTransport(); + const schema = await ros.getMessageSchema(params.type); + + return jsonResult({ success: true, schema }); + }, +); + +server.registerTool( + "ros2_service_schema", + { + title: "ROS2 Service Schema", + description: + "Return request and response field schemas for a ROS2 service type.", + inputSchema: z.object({ + type: z + .string() + .min(1) + .describe("ROS2 service type, for example rcl_interfaces/srv/GetParameters"), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_service_schema", params); + const ros = await getTransport(); + const schema = await ros.getServiceSchema(params.type); + + return jsonResult({ success: true, schema }); + }, +); + +server.registerTool( + "ros2_action_schema", + { + title: "ROS2 Action Schema", + description: + "Return goal, result, and feedback field schemas for a ROS2 action type.", + inputSchema: z.object({ + type: z + .string() + .min(1) + .describe("ROS2 action type, for example nav2_msgs/action/NavigateToPose"), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_action_schema", params); + const ros = await getTransport(); + const schema = await ros.getActionSchema(params.type); + + return jsonResult({ success: true, schema }); + }, +); + +server.registerTool( + "ros2_validate_tool_call", + { + title: "ROS2 Validate Tool Call", + description: + "Dry-run RosClaw safety policy validation for a ROS tool call without executing it.", + inputSchema: z.object({ + toolName: z + .string() + .min(1) + .describe("ROS MCP tool name, for example ros2_publish"), + params: z + .record(z.unknown()) + .default({}) + .describe("Tool parameters to validate."), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async ({ toolName, params }) => { + const violation = validateRosToolCall(toolName, params, config.safety); + + return jsonResult({ + allowed: !violation, + reason: violation ?? null, + }); + }, +); + +server.registerTool( + "ros2_subscribe_once", + { + title: "ROS2 Subscribe Once", + description: + "Subscribe to one ROS2 topic and return the next received message without modifying robot state.", + inputSchema: z.object({ + topic: z.string().min(1).describe("ROS2 topic name, for example /odom"), + type: z + .string() + .min(1) + .optional() + .describe("Optional ROS2 message type, for example nav_msgs/msg/Odometry"), + timeout: z + .number() + .positive() + .optional() + .describe("Timeout in milliseconds. Defaults to 5000."), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + }, + }, + async ({ topic, type, timeout }) => { + assertAllowed("ros2_subscribe_once", { topic, type, timeout }); + const ros = await getTransport(); + const message = await subscribeOnce(ros, topic, type, timeout ?? 5000); + return jsonResult({ success: true, topic, message }); + }, +); + +server.registerTool( + "ros2_param_get", + { + title: "ROS2 Get Parameter", + description: + "Read a ROS2 parameter value through the standard get_parameters service.", + inputSchema: z.object({ + node: z + .string() + .min(1) + .describe("Fully qualified ROS2 node name, for example /controller"), + parameter: z.string().min(1).describe("Parameter name to read"), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + }, + }, + async ({ node, parameter }) => { + assertAllowed("ros2_param_get", { node, parameter }); + const ros = await getTransport(); + const response = await ros.callService({ + service: `${node}/get_parameters`, + type: "rcl_interfaces/srv/GetParameters", + args: { names: [parameter] }, + }); + + return jsonResult({ + success: response.result, + node, + parameter, + value: response.values, + }); + }, +); + +server.registerTool( + "ros2_param_set", + { + title: "ROS2 Set Parameter", + description: + "Set a ROS2 parameter after RosClaw safety policy validation.", + inputSchema: z.object({ + node: z + .string() + .min(1) + .describe("Fully qualified ROS2 node name, for example /controller"), + parameter: z.string().min(1).describe("Parameter name to set"), + value: z.unknown().describe("New parameter value"), + }), + annotations: { + readOnlyHint: false, + destructiveHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_param_set", params); + const ros = await getTransport(); + const response = await ros.callService({ + service: `${params.node}/set_parameters`, + type: "rcl_interfaces/srv/SetParameters", + args: { + parameters: [ + { + name: params.parameter, + value: params.value, + }, + ], + }, + }); + + return jsonResult({ + success: response.result, + node: params.node, + parameter: params.parameter, + response: response.values, + }); + }, +); + +server.registerTool( + "ros2_camera_snapshot", + { + title: "ROS2 Camera Snapshot", + description: + "Read one compressed image frame from a ROS2 camera topic without modifying robot state.", + inputSchema: z.object({ + topic: z + .string() + .min(1) + .optional() + .describe("Compressed image topic. Defaults to /camera/image_raw/compressed."), + timeout: z + .number() + .positive() + .optional() + .describe("Timeout in milliseconds. Defaults to 10000."), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + }, + }, + async ({ topic, timeout }) => { + const cameraTopic = topic ?? "/camera/image_raw/compressed"; + assertAllowed("ros2_camera_snapshot", { topic: cameraTopic, timeout }); + const ros = await getTransport(); + const message = await subscribeOnce( + ros, + cameraTopic, + "sensor_msgs/msg/CompressedImage", + timeout ?? 10000, + ); + + return jsonResult({ + success: true, + topic: cameraTopic, + format: message["format"] ?? "jpeg", + data: message["data"] ?? "", + }); + }, +); + +server.registerTool( + "ros2_transport_status", + { + title: "ROS2 Transport Status", + description: + "Return the current RosClaw transport connection status without modifying robot state.", + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + async () => { + assertAllowed("ros2_transport_status", {}); + const ros = await getTransport(); + + return jsonResult({ + success: true, + mode: config.transport.mode, + status: ros.getStatus(), + }); + }, +); + +server.registerTool( + "ros2_service_call", + { + title: "ROS2 Service Call", + description: + "Call a ROS2 service after RosClaw safety policy validation.", + inputSchema: z.object({ + service: z.string().min(1).describe("ROS2 service name"), + type: z.string().min(1).optional().describe("Optional ROS2 service type"), + args: z + .record(z.unknown()) + .optional() + .describe("Service request arguments"), + }), + annotations: { + readOnlyHint: false, + destructiveHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_service_call", params); + const ros = await getTransport(); + const response = await ros.callService({ + service: params.service, + type: params.type, + args: params.args, + }); + + return jsonResult({ + success: response.result, + service: params.service, + response: response.values, + }); + }, +); + +server.registerTool( + "ros2_cancel_action_goal", + { + title: "ROS2 Cancel Action Goal", + description: + "Cancel an in-progress ROS2 action goal after RosClaw safety policy validation.", + inputSchema: z.object({ + action: z.string().min(1).describe("ROS2 action server name"), + }), + annotations: { + readOnlyHint: false, + destructiveHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_cancel_action_goal", params); + const ros = await getTransport(); + await ros.cancelActionGoal(params.action); + + return jsonResult({ + success: true, + action: params.action, + cancelled: true, + }); + }, +); + +server.registerTool( + "ros2_action_goal", + { + title: "ROS2 Action Goal", + description: + "Send a ROS2 action goal after RosClaw safety policy validation.", + inputSchema: z.object({ + action: z.string().min(1).describe("ROS2 action server name"), + actionType: z.string().min(1).describe("ROS2 action type"), + goal: z.record(z.unknown()).describe("Action goal payload"), + }), + annotations: { + readOnlyHint: false, + destructiveHint: true, + }, + }, + async (params) => { + assertAllowed("ros2_action_goal", params); + const ros = await getTransport(); + const result = await ros.sendActionGoal({ + action: params.action, + actionType: params.actionType, + args: params.goal, + }); + + return jsonResult({ + success: result.result, + action: params.action, + result: result.values, + }); + }, +); + +async function main(): Promise { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("RosClaw MCP server running on stdio"); +} + +process.on("SIGINT", async () => { + await closeTransport(); + await server.close(); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + await closeTransport(); + await server.close(); + process.exit(0); +}); + +main().catch((error: unknown) => { + console.error("Fatal error in RosClaw MCP server:", error); + process.exit(1); +}); + +async function getTransport(): Promise { + if (transport) { + return transport; + } + + const config = readTransportConfig(); + const nextTransport = await createTransport(config); + nextTransport.onConnection((status) => { + console.error(`RosClaw transport status: ${status}`); + }); + await nextTransport.connect(); + transport = nextTransport; + return nextTransport; +} + +async function closeTransport(): Promise { + if (!transport) { + return; + } + await transport.disconnect(); + transport = null; +} + +function readTransportConfig(): TransportConfig { + const mode = config.transport.mode; + + if (mode !== "rosbridge") { + throw new Error( + `Unsupported ROSCLAW_TRANSPORT_MODE for this MCP server phase: ${mode}`, + ); + } + + return { + mode: "rosbridge", + rosbridge: config.rosbridge, + }; +} + +function readRosClawConfig(): RosClawConfig { + const raw = readJsonEnv("ROSCLAW_CONFIG_JSON"); + const rawTransport = objectValue(raw["transport"]); + const rawRosbridge = objectValue(raw["rosbridge"]); + + return parseConfig({ + ...raw, + transport: { + ...rawTransport, + mode: process.env.ROSCLAW_TRANSPORT_MODE ?? rawTransport["mode"], + }, + rosbridge: { + ...rawRosbridge, + url: process.env.ROSCLAW_ROSBRIDGE_URL ?? rawRosbridge["url"], + reconnect: envBoolean( + "ROSCLAW_ROSBRIDGE_RECONNECT", + booleanValue(rawRosbridge["reconnect"], true), + ), + reconnectInterval: envNumber( + "ROSCLAW_ROSBRIDGE_RECONNECT_INTERVAL", + numberValue(rawRosbridge["reconnectInterval"], 3000), + ), + }, + }); +} + +function assertAllowed(toolName: string, params: Record): void { + const violation = validateRosToolCall(toolName, params, config.safety); + if (violation) { + throw new Error(violation); + } +} + +function readJsonEnv(name: string): Record { + const value = process.env[name]; + if (!value) { + return {}; + } + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${name} must be a JSON object`); + } + return parsed as Record; +} + +function objectValue(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function booleanValue(value: unknown, defaultValue: boolean): boolean { + return typeof value === "boolean" ? value : defaultValue; +} + +function numberValue(value: unknown, defaultValue: number): number { + return typeof value === "number" && Number.isFinite(value) + ? value + : defaultValue; +} + +function envBoolean(name: string, defaultValue: boolean): boolean { + const value = process.env[name]; + if (value === undefined) { + return defaultValue; + } + return value === "1" || value.toLowerCase() === "true"; +} + +function envNumber(name: string, defaultValue: number): number { + const value = process.env[name]; + if (value === undefined) { + return defaultValue; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : defaultValue; +} + +function subscribeOnce( + ros: RosTransport, + topic: string, + type: string | undefined, + timeout: number, +): Promise> { + return new Promise((resolve, reject) => { + let settled = false; + const subscription = ros.subscribe({ topic, type }, (message) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + subscription.unsubscribe(); + resolve(message); + }); + + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + subscription.unsubscribe(); + reject(new Error(`Timeout waiting for message on ${topic}`)); + }, timeout); + }); +} + +async function showInterface( + ros: RosTransport, + type: string, + kind?: "message" | "service" | "action", +): Promise> { + const inferredKind = kind ?? inferInterfaceKind(type); + switch (inferredKind) { + case "message": + return { + kind: inferredKind, + schema: await ros.getMessageSchema(type), + }; + case "service": + return { + kind: inferredKind, + schema: await ros.getServiceSchema(type), + }; + case "action": + return { + kind: inferredKind, + schema: await ros.getActionSchema(type), + }; + } +} + +function inferInterfaceKind(type: string): "message" | "service" | "action" { + if (type.includes("/msg/")) { + return "message"; + } + if (type.includes("/srv/")) { + return "service"; + } + if (type.includes("/action/")) { + return "action"; + } + + throw new Error( + `Cannot infer interface kind from ${type}; pass kind as message, service, or action`, + ); +} + +function jsonResult(value: unknown) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(value, null, 2), + }, + ], + }; +} diff --git a/extensions/rosclaw-codex-mcp-server/tsconfig.json b/extensions/rosclaw-codex-mcp-server/tsconfig.json new file mode 100644 index 0000000..792172f --- /dev/null +++ b/extensions/rosclaw-codex-mcp-server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a4350a..a2934c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,43 @@ importers: specifier: ^5.7.0 version: 5.9.3 + extensions/rosclaw-codex-mcp-server: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.0.0 + version: 1.29.0(zod@3.25.76) + '@rosclaw/rosclaw': + specifier: workspace:* + version: link:../openclaw-plugin + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^20.17.0 + version: 20.19.33 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages: + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@rclnodejs/ref-array-di@1.2.2': resolution: {integrity: sha512-BHslJ1wmmaiPQ2rPZrO9Du9Cd/eQjZ+N5T84poFZk9JFJkYatbtXALbjWTgoHWpl6Cvm415NfwsGc2xkjzFPxw==} @@ -60,6 +95,21 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + array-index@1.0.0: resolution: {integrity: sha512-jesyNbBkLQgGZMSwA1FanaFjalb1mZUGxGeUEkSDidzgrbjBGhvizJkaItdhkt8eIHFOJC7nDsrXk+BaehTdRw==} @@ -75,12 +125,56 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + d@1.0.2: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} @@ -110,13 +204,40 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + es5-ext@0.10.64: resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} engines: {node: '>=0.10'} @@ -128,32 +249,110 @@ packages: resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} engines: {node: '>=0.12'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + esniff@2.0.1: resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} engines: {node: '>=0.10'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + event-emitter@0.3.5: resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + foreachasync@3.0.0: resolution: {integrity: sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hono@4.12.19: + resolution: {integrity: sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -163,9 +362,52 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -185,6 +427,10 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} @@ -205,17 +451,60 @@ packages: engines: {node: '>=16'} deprecated: Use your platform's native DOMException instead + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} hasBin: true + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -229,23 +518,73 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -260,12 +599,20 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} @@ -277,12 +624,25 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + walk@2.3.15: resolution: {integrity: sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -298,11 +658,42 @@ packages: utf-8-validate: optional: true + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: + '@hono/node-server@1.19.14(hono@4.12.19)': + dependencies: + hono: 4.12.19 + + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.19) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.19 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@rclnodejs/ref-array-di@1.2.2': dependencies: array-index: 1.0.0 @@ -328,6 +719,22 @@ snapshots: dependencies: '@types/node': 20.19.33 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + array-index@1.0.0: dependencies: debug: 2.6.9 @@ -352,13 +759,60 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + buffer@5.7.1: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + chownr@1.1.4: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + d@1.0.2: dependencies: es5-ext: 0.10.64 @@ -373,7 +827,6 @@ snapshots: debug@4.4.3: dependencies: ms: 2.1.3 - optional: true decompress-response@6.0.0: dependencies: @@ -381,12 +834,32 @@ snapshots: deep-extend@0.6.0: {} + depd@2.0.0: {} + detect-libc@2.1.2: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + es5-ext@0.10.64: dependencies: es6-iterator: 2.0.3 @@ -408,6 +881,8 @@ snapshots: ext: 1.7.0 optional: true + escape-html@1.0.3: {} + esniff@2.0.1: dependencies: d: 1.0.2 @@ -416,40 +891,173 @@ snapshots: type: 2.7.3 optional: true + etag@1.8.1: {} + event-emitter@0.3.5: dependencies: d: 1.0.2 es5-ext: 0.10.64 optional: true + eventsource-parser@3.0.8: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 + expand-template@2.0.3: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + ext@1.7.0: dependencies: type: 2.7.3 optional: true + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.2: {} + file-uri-to-path@1.0.0: optional: true + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + foreachasync@3.0.0: optional: true + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-constants@1.0.0: {} + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + github-from-package@0.0.0: {} + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hono@4.12.19: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} inherits@2.0.4: {} ini@1.3.8: {} + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.3: {} + json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 optional: true + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mimic-response@3.1.0: {} minimist@1.2.8: {} @@ -459,11 +1067,12 @@ snapshots: ms@2.0.0: optional: true - ms@2.1.3: - optional: true + ms@2.1.3: {} napi-build-utils@2.0.0: {} + negotiator@1.0.0: {} + next-tick@1.1.0: optional: true @@ -481,10 +1090,26 @@ snapshots: node-domexception@2.0.2: {} + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + pkce-challenge@5.0.1: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -500,11 +1125,29 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -532,6 +1175,18 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + require-from-string@2.0.2: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -539,8 +1194,71 @@ snapshots: safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} + semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + simple-concat@1.0.1: {} simple-get@4.0.1: @@ -549,6 +1267,8 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + statuses@2.0.2: {} + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -570,6 +1290,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + toidentifier@1.0.1: {} + tslib@2.8.1: optional: true @@ -577,6 +1299,12 @@ snapshots: dependencies: safe-buffer: 5.2.1 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + type@2.7.3: optional: true @@ -584,15 +1312,27 @@ snapshots: undici-types@6.21.0: {} + unpipe@1.0.0: {} + util-deprecate@1.0.2: {} + vary@1.1.2: {} + walk@2.3.15: dependencies: foreachasync: 3.0.0 optional: true + which@2.0.2: + dependencies: + isexe: 2.0.0 + wrappy@1.0.2: {} ws@8.19.0: {} + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {}