Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions packages/ai/src/ai/modules/mcp/doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ like every other module and mounted at:
/mcp
```

This module exposes a static, 27-tool RocketRide authoring/execution surface served
This module exposes a static, 29-tool RocketRide authoring/execution surface served
over HTTP from inside the running engine process — no separate process or transport
bridge required. It supersedes the earlier 2-tool port, which exposed a dynamic
per-pipeline `{filepath}` tool plus a `RocketRide_Document_Processor` convenience
Expand Down Expand Up @@ -104,7 +104,7 @@ on every `CacheableResult` this module returns:
Config key `mcp_dev_no_auth` (bool, in the module `config` dict passed to `initModule`)
is the config-driven equivalent of `MCP_DEV_NO_AUTH=1`; either one enables the bypass.

## The 27 tools
## The 29 tools

Dispatch is registry-based: `tooling.ToolRegistry` holds `{name -> (description,
inputSchema, handler)}`; `tools/__init__.register_all(registry)` populates one shared
Expand All @@ -120,18 +120,20 @@ All tools are static and typed (fixed name + JSON Schema) — there is no dynami
per-pipeline tool generation and no `filepath`-shaped catch-all tool of the kind the
legacy 2-tool port used.

The 27 tools are organized into 8 groups (plus 2 resources), matching
The 29 tools are organized into 8 groups (plus 2 resources), matching
`claude/tasks/http-mcp-tools-port/final-tool-surface.md` minus the Query group
(see History: the 3 convenience query tools were removed pending their cloud DB
backend), plus the Run log (DVR) group and `list_integrations` added below.

**Introspection (5)** — `tools/introspection.py` plus `tools/integrations.py`, read-only/static-analysis, no task tokens:
**Introspection (7)** — `tools/introspection.py` plus `tools/scaffold.py` and `tools/integrations.py`, read-only/static-analysis, no task tokens:

| Tool | Purpose | Key args |
| --- | --- | --- |
| `list_components` | List pipeline components ready to use *now* — zero-config components plus integrations whose credentials are configured. Configured entries carry a `wiring` block of `${VAR}` placeholders; a `note` counts integrations omitted for needing setup. Call `list_integrations` for those. | none |
| `describe_component` | Full metadata/config schema for one component; catalog nodes also get a `credentials` block (same readiness vocabulary as `list_integrations`). | `name` (required) |
| `validate_pipeline` | Validate a pipeline against the engine's own rules (engine-authoritative, zero client-side drift). | `pipeline` |
| `resolve_config` | What a component config resolves to at load, after the engine applies profile and default merging. Reports keys the resolver discarded, which a schema cannot express. | `provider` (required), `config` |
| `scaffold_node` | Emit a local node skeleton that loads on the first try, with the manifest keys and file layout the engine requires. Returns files to write. | `name` (required), `lane_in`, `lane_out`, `class_type` |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `validate_pipeline` | Validate a pipeline against the engine's own rules (engine-authoritative, zero client-side drift), plus a check that every component names a provider the engine has a service for, which the engine's pipeline path does not cover. | `pipeline` |
| `describe_pipeline` | Statically describe a pipeline's source and components (id, provider, title, classType, inputs); synthesized client-side, no backing SDK method. | `pipeline` |
| `list_integrations` | Credential readiness for catalog integrations this engine has a matching node for. Bare call: terse per-integration rows (`name`/`title`/`status`/`missing_count`). With `name`: full field detail, `missing`, `candidates`, the caller's own variable names (`caller_variables`), and either `setup` (not configured) or `wiring` (configured). | `name` (optional) |

Expand Down Expand Up @@ -356,7 +358,7 @@ prompt templates from the earlier port were removed along with their tests.
## The `EngineClient` seam

`engine.py` defines one `Protocol`, `EngineClient`, with the methods needed
by the 27-tool surface (task lifecycle, services/validation, store/templates/store
by the 29-tool surface (task lifecycle, services/validation, store/templates/store
metadata/signed URLs, full deployment lifecycle, `rrext_log` chapters/read/traces/
trace — see the `Protocol` definition in `engine.py` for exact signatures). All
tool/resource code depends only on this interface — never on a concrete client — so
Expand Down
4 changes: 4 additions & 0 deletions packages/ai/src/ai/modules/mcp/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ async def send(
async def get_services(self) -> Dict[str, Any]: ...
async def get_service(self, name: str) -> Optional[Dict[str, Any]]: ...
async def validate(self, pipeline: dict, source: Optional[str] = None) -> Dict[str, Any]: ...
async def resolve_config(self, provider: str, config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: ...
async def use(self, **kwargs: Any) -> Dict[str, Any]: ...
async def terminate(self, token: str) -> None: ...
async def send_files(self, files: List[Any], token: str) -> Any: ...
Expand Down Expand Up @@ -201,6 +202,9 @@ async def get_service(self, name: str) -> Optional[Dict[str, Any]]:
async def validate(self, pipeline: dict, source: Optional[str] = None) -> Dict[str, Any]:
return await self._guarded(lambda: self._client.validate(pipeline, source=source))

async def resolve_config(self, provider: str, config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return await self._guarded(lambda: self._client.resolve_config(provider, config))

async def use(self, **kwargs: Any) -> Dict[str, Any]:
return await self._guarded(lambda: self._client.use(**kwargs))

Expand Down
5 changes: 4 additions & 1 deletion packages/ai/src/ai/modules/mcp/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from . import integrations
from . import introspection
from . import logs
from . import scaffold
from . import visibility


Expand All @@ -27,7 +28,8 @@ def register_all(registry: ToolRegistry) -> None:
`save_template`, `load_template`, `deploy_add`, `deploy_list`,
`deploy_status`, `deploy_remove`, `deploy_update`), the visibility tools
(`monitor`, `list_running_pipelines`), the DVR run-log tools
(`log_chapters`, `log_read`, `log_traces`, `log_trace`), and the
(`log_chapters`, `log_read`, `log_traces`, `log_trace`), the node
scaffolding tool (`scaffold_node`), and the
integration-discovery tool (`list_integrations`) -- registered last so
it always trails the surface it discovers.
"""
Expand All @@ -36,4 +38,5 @@ def register_all(registry: ToolRegistry) -> None:
capability.register(registry)
visibility.register(registry)
logs.register(registry)
scaffold.register(registry)
integrations.register(registry)
87 changes: 85 additions & 2 deletions packages/ai/src/ai/modules/mcp/tools/introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,41 @@ async def _describe_component(client, tasks, args: Dict[str, Any]) -> dict:
return result


async def _unknown_provider_errors(client, pipeline: Dict[str, Any]):
"""Report components naming a provider the engine has no service for.

The engine validates a whole pipeline structurally. Its provider lookup lives
in the single-component path (`validate_pipeline.cpp`), which the pipeline path
never reaches, so a typed provider name validates clean and fails at run time.
The names come from the engine's own catalog, so this adds no client-side rule.

Args:
client: The engine client.
pipeline: The pipeline body, already unwrapped from its envelope.

Returns:
A ``(errors, engine_error)`` pair; ``engine_error`` is set only when the
catalog could not be read.
"""
components = [c for c in (pipeline.get('components') or []) if isinstance(c, dict)]
if not components:
return [], None

services, err = await engine_call(client.get_services(), 'validate_pipeline')
if err:
return [], err

catalog = (services or {}).get('services') or {}
if not catalog:
return [], None

return [
{'component': c.get('id'), 'message': f'unknown provider {c.get("provider")!r}'}
for c in components
if c.get('provider') and c['provider'] not in catalog
], None


async def _validate_pipeline(client, tasks, args: Dict[str, Any]) -> dict:
pipeline = load_pipeline(args) # raises ValueError -> normalized by the dispatch layer
# The engine requires the {'pipeline': {...}} envelope and treats a missing
Expand All @@ -113,11 +148,45 @@ async def _validate_pipeline(client, tasks, args: Dict[str, Any]) -> dict:
if err:
return err
result = validated or {}
errors = result.get('errors') or []
errors = list(result.get('errors') or [])
warnings = result.get('warnings') or []

unknown, err = await _unknown_provider_errors(client, payload['pipeline'])
if err:
return err
errors.extend(unknown)
return {'ok': not errors, 'errors': errors, 'warnings': warnings}


async def _resolve_config(client, tasks, args: Dict[str, Any]) -> dict:
provider = args.get('provider')
if not provider:
return _bad('provider is required', 'pick a provider from list_components')

# Default only an absent config: `or {}` would coerce [] and skip this check.
config = args.get('config')
if config is None:
config = {}
if not isinstance(config, dict):
return _bad('config must be an object', "pass the component's config block, or omit it for defaults")

resolved, err = await engine_call(client.resolve_config(provider, config), 'resolve_config')
if err:
return err

result = resolved or {}
# Surface the discard rather than leaving it to be inferred from an absence:
# a key written beside 'profile' instead of inside it never reaches the node.
dropped = result.get('dropped') or []
out = {'ok': True, **result}
if dropped:
out['hint'] = (
f'{len(dropped)} config key(s) were discarded because a profile is set: {", ".join(dropped)}. '
f'Move them inside the "{result.get("profile")}" object to take effect.'
)
return out


async def _describe_pipeline(client, tasks, args: Dict[str, Any]) -> dict:
pipeline = load_pipeline(args) # raises ValueError -> normalized by the dispatch layer

Expand Down Expand Up @@ -161,7 +230,7 @@ async def _describe_pipeline(client, tasks, args: Dict[str, Any]) -> dict:


def register(registry: ToolRegistry) -> None:
"""Register the 4 authoring/introspection tools against ``registry``."""
"""Register the authoring/introspection tools against ``registry``."""
registry.register(
'list_components',
'List RocketRide components ready to use now (zero-config plus integrations you have configured). '
Expand All @@ -181,6 +250,20 @@ def register(registry: ToolRegistry) -> None:
},
)(_describe_component)

registry.register(
'resolve_config',
'Show what a component config resolves to at load: the engine applies profile and default '
'merging, so the .pipe rarely says what the node receives. Reports discarded keys.',
{
'type': 'object',
'properties': {
'provider': {'type': 'string', 'description': 'Component provider, e.g. llm_openai'},
'config': {'type': 'object', 'description': "The component's config block; omit for defaults"},
},
'required': ['provider'],
},
)(_resolve_config)

registry.register(
'validate_pipeline',
"Validate a pipeline against the engine's own rules (zero client-side rules -- zero drift).",
Expand Down
Loading
Loading