mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: refactor node spec and add mcp tools (#244)
* refactor: carve out extraction panel * refactor: create spec versions for node types * refactor: create a GenericNode and remove custom nodes * feat: add python and typescript sdk * add dograh sdk * fix: fetch draft workflow definition over published one * fix: fix routes of SDKs to use code gen * chore: remove doclink dependency to reduce image size * chore: format files * chore: bump pipecat * feat: let mcp fetch archived workflows on demand * chore: fix tests * feat: add sdk documentation * chore: change banner and add badge
This commit is contained in:
parent
0a61ef295f
commit
00a1a22b74
162 changed files with 14355 additions and 3554 deletions
3
api/mcp_server/__init__.py
Normal file
3
api/mcp_server/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from api.mcp_server.server import mcp
|
||||
|
||||
__all__ = ["mcp"]
|
||||
46
api/mcp_server/auth.py
Normal file
46
api/mcp_server/auth.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from fastapi import HTTPException
|
||||
from fastmcp.server.dependencies import get_http_headers
|
||||
from opentelemetry import trace
|
||||
|
||||
from api.db.models import UserModel
|
||||
from api.services.auth.depends import _handle_api_key_auth
|
||||
|
||||
|
||||
async def authenticate_mcp_request() -> UserModel:
|
||||
"""Resolve the authenticated Dograh user for an MCP tool invocation.
|
||||
|
||||
Accepts either `X-API-Key: <key>` or `Authorization: Bearer <key>`,
|
||||
reusing the API-key flow from `api.services.auth.depends`.
|
||||
|
||||
Tags the currently-active OTel span with the resolved organization
|
||||
and user identifiers. `_OrgRoutingExporter` reads `dograh.org_id`
|
||||
at export time to dispatch the span to the right Langfuse project;
|
||||
the `langfuse.user.id` / `langfuse.session.id` attributes make the
|
||||
span filterable in the Langfuse UI.
|
||||
"""
|
||||
headers = get_http_headers()
|
||||
api_key = headers.get("x-api-key")
|
||||
if not api_key:
|
||||
auth = headers.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
api_key = auth.split(" ", 1)[1].strip()
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing API key — send X-API-Key or Authorization: Bearer <key>",
|
||||
)
|
||||
user = await _handle_api_key_auth(api_key)
|
||||
|
||||
span = trace.get_current_span()
|
||||
if span.is_recording():
|
||||
org_id = user.selected_organization_id
|
||||
# Intentionally NOT `dograh.org_id` — that attribute triggers the
|
||||
# per-org Langfuse routing for pipeline spans, and MCP traffic
|
||||
# should land in the default (developer-facing) project only.
|
||||
# Exposed under `mcp.org_id` for Langfuse UI filtering without
|
||||
# affecting the router.
|
||||
span.set_attribute("mcp.org_id", str(org_id))
|
||||
span.set_attribute("mcp.user_id", str(user.id))
|
||||
span.set_attribute("langfuse.user.id", str(user.id))
|
||||
|
||||
return user
|
||||
124
api/mcp_server/instructions.py
Normal file
124
api/mcp_server/instructions.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Top-level orchestration guide surfaced to every MCP session.
|
||||
|
||||
Sent to the client via `FastMCP(instructions=...)` — the client bakes
|
||||
this into its system prompt, so every LLM session sees it before the
|
||||
first tool call. Prefer procedural orchestration here (call order, error
|
||||
handling, hard constraints). Design-level per-field guidance belongs in
|
||||
each `PropertySpec.llm_hint`; it flows out through `get_node_type` and
|
||||
doesn't need to be repeated here.
|
||||
|
||||
Extend based on real LLM failures — every bullet below ideally maps to a
|
||||
mistake the system has seen at least once.
|
||||
"""
|
||||
|
||||
DOGRAH_MCP_INSTRUCTIONS = """\
|
||||
You build and edit Dograh voice-AI workflows by emitting TypeScript that
|
||||
uses the `@dograh/sdk` package. Workflows are stored as JSON; this server
|
||||
projects them to TypeScript for editing and parses them back on save.
|
||||
|
||||
## Call order
|
||||
|
||||
1. `list_workflows` — locate the target workflow.
|
||||
2. `get_workflow_code(workflow_id)` — fetch the current source (draft if
|
||||
one exists, otherwise published).
|
||||
3. (optional) `list_node_types` / `get_node_type(name)` — consult before
|
||||
adding or editing a node type whose fields aren't already visible in
|
||||
the current code.
|
||||
4. Mutate the code in place. Preserve existing nodes, edges, and variable
|
||||
names unless the task requires removing or renaming them.
|
||||
5. `save_workflow(workflow_id, code)` — persist as a new draft. The
|
||||
published version is untouched.
|
||||
|
||||
## Allowed source shape
|
||||
|
||||
The parser is AST-only and rejects anything outside this grammar. At the
|
||||
top level, only three statement forms are accepted:
|
||||
|
||||
import ... from "..."; // any import
|
||||
const <var> = <initializer>; // bindings (see below)
|
||||
wf.edge(<src>, <tgt>, { label, condition }); // bare edge calls
|
||||
|
||||
`<initializer>` is one of:
|
||||
new Workflow({ name: "..." })
|
||||
wf.addTyped(<factory>({ ...fields }) [, { position: [x, y] }])
|
||||
wf.add({ type: "<nodeType>", ...fields [, position: [x, y]] })
|
||||
|
||||
No functions, arrow fns, loops, conditionals, ternaries, spreads,
|
||||
destructuring, template interpolation, `export`, or `.map`/`.forEach`.
|
||||
Data-position values must be plain literals (strings, numbers, booleans,
|
||||
null, arrays/objects of same). A single `new Workflow(...)` per file —
|
||||
the `name` you pass there is the workflow's display name and is applied
|
||||
on save (renames propagate immediately; definition changes go to draft).
|
||||
|
||||
## Adding edges — explicit syntax
|
||||
|
||||
wf.edge(source, target, { label: "...", condition: "..." });
|
||||
|
||||
Rules:
|
||||
- `source` and `target` are the **bare variable identifiers** bound by
|
||||
`wf.addTyped(...)` / `wf.add(...)` — not strings, not `.id`, not inline
|
||||
factories. Both must be declared earlier in the file.
|
||||
- `label` is a short tag (≤4 words) shown in call logs to identify the
|
||||
branch: `"qualified"`, `"wrap up"`, `"retry"`.
|
||||
- `condition` is a full natural-language predicate the runtime evaluates
|
||||
against the live conversation: `"caller confirmed interest in a demo"`,
|
||||
not `"interested"`. Condition clarity determines routing accuracy.
|
||||
- Both fields are required and must be non-empty strings.
|
||||
- Edges are directional; emit one `wf.edge(...)` per outgoing branch.
|
||||
- Place all edges after all node bindings; group by source node.
|
||||
|
||||
Example:
|
||||
|
||||
const greet = wf.addTyped(startCall({ name: "Greet", prompt: "Hi!" }));
|
||||
const done = wf.addTyped(endCall({ name: "Done", prompt: "Bye." }));
|
||||
wf.edge(greet, done, {
|
||||
label: "wrap up",
|
||||
condition: "user acknowledged the greeting and is ready to end"
|
||||
});
|
||||
|
||||
## Hard graph constraints
|
||||
|
||||
- Exactly one `startCall` node per workflow; no incoming edges.
|
||||
- `endCall` nodes have no outgoing edges.
|
||||
- `globalNode` has no incoming or outgoing edges; its prompt is prepended
|
||||
to every other node's prompt at runtime when that node sets
|
||||
`add_global_prompt=true`.
|
||||
- Every non-global node must be reachable from `startCall`.
|
||||
|
||||
## Iterating on errors
|
||||
|
||||
`save_workflow` returns one of:
|
||||
- `parse_error` Disallowed construct (see grammar above) or
|
||||
malformed TypeScript.
|
||||
- `validation_error` Node data failed spec validation (unknown field,
|
||||
missing required, wrong type, bad `options` value).
|
||||
- `graph_validation` Structural rule broken (missing startCall,
|
||||
unreachable node, edge to/from wrong node type).
|
||||
- `bridge_error` Internal — retry once, then surface to the user.
|
||||
|
||||
Every error carries `line` and `column`. Fix at that location and
|
||||
resubmit the **complete source** — this tool does not accept patches.
|
||||
|
||||
## Field conventions
|
||||
|
||||
- `data.name` is the canonical identifier. Pick a descriptive name
|
||||
(`"Qualify Budget"`, not `"Node1"`) — the generated code uses it as
|
||||
the variable name and call logs reference it.
|
||||
- Reference fields take UUIDs, not human names:
|
||||
`tool_refs`, `document_refs` → from `list_tools`, `list_documents`
|
||||
`credential_ref` → from `list_credentials`
|
||||
`recording_ref` → from `list_recordings`
|
||||
- `mention_textarea` fields (prompts, greetings, etc.) accept
|
||||
`{{template_variables}}` — values resolved at runtime from
|
||||
`pre_call_fetch`, caller context, or earlier extraction passes.
|
||||
|
||||
## Style
|
||||
|
||||
- Prefer `wf.addTyped(factory({ ... }))` over `wf.add({ type, ... })`.
|
||||
- Only include fields whose values differ from the spec default — the
|
||||
parser re-applies defaults on save, so extras are noise.
|
||||
- Omit `position`; the server reconciles positions against the previous
|
||||
saved workflow and lays out new nodes automatically.
|
||||
- Add nodes in call-flow order (start → intermediate → end) so the
|
||||
generated code reads top-to-bottom, with all edges after all nodes.
|
||||
"""
|
||||
11
api/mcp_server/server.py
Normal file
11
api/mcp_server/server.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from fastmcp import FastMCP
|
||||
|
||||
from api.mcp_server.instructions import DOGRAH_MCP_INSTRUCTIONS
|
||||
|
||||
mcp = FastMCP("dograh", instructions=DOGRAH_MCP_INSTRUCTIONS)
|
||||
|
||||
from api.mcp_server.tools import catalog as _catalog # noqa: E402, F401
|
||||
from api.mcp_server.tools import get_workflow_code as _get_workflow_code # noqa: E402, F401
|
||||
from api.mcp_server.tools import node_types as _node_types # noqa: E402, F401
|
||||
from api.mcp_server.tools import save_workflow as _save_workflow # noqa: E402, F401
|
||||
from api.mcp_server.tools import workflows as _workflows # noqa: E402, F401
|
||||
0
api/mcp_server/tools/__init__.py
Normal file
0
api/mcp_server/tools/__init__.py
Normal file
113
api/mcp_server/tools/catalog.py
Normal file
113
api/mcp_server/tools/catalog.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""MCP discovery tools for the reference catalogs.
|
||||
|
||||
Node properties of type `tool_refs`, `document_refs`, `recording_ref`, and
|
||||
`credential_ref` carry UUIDs that resolve against these catalogs. LLMs must
|
||||
list the catalog before populating those fields with real UUIDs.
|
||||
"""
|
||||
|
||||
from api.db import db_client
|
||||
from api.mcp_server.auth import authenticate_mcp_request
|
||||
from api.mcp_server.server import mcp
|
||||
from api.mcp_server.tracing import traced_tool
|
||||
|
||||
|
||||
@mcp.tool
|
||||
@traced_tool
|
||||
async def list_tools(status: str | None = "active") -> list[dict]:
|
||||
"""List tools the agent can invoke during a call.
|
||||
|
||||
Returns each tool's `tool_uuid` (use this in node `tool_uuids` properties),
|
||||
`name`, `description`, and `category`. Pass `status=None` to include
|
||||
archived tools.
|
||||
"""
|
||||
user = await authenticate_mcp_request()
|
||||
tools = await db_client.get_tools_for_organization(
|
||||
organization_id=user.selected_organization_id,
|
||||
status=status,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"tool_uuid": t.tool_uuid,
|
||||
"name": t.name,
|
||||
"description": t.description or "",
|
||||
"category": t.category,
|
||||
}
|
||||
for t in tools
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool
|
||||
@traced_tool
|
||||
async def list_documents() -> list[dict]:
|
||||
"""List knowledge-base documents the agent can reference during a call.
|
||||
|
||||
Returns each document's `document_uuid` (use this in node
|
||||
`document_uuids` properties), `filename`, and `processing_status`.
|
||||
"""
|
||||
user = await authenticate_mcp_request()
|
||||
documents = await db_client.get_documents_for_organization(
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"document_uuid": d.document_uuid,
|
||||
"filename": d.filename,
|
||||
"processing_status": d.processing_status,
|
||||
"total_chunks": d.total_chunks,
|
||||
}
|
||||
for d in documents
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool
|
||||
@traced_tool
|
||||
async def list_credentials() -> list[dict]:
|
||||
"""List external credentials available for webhook auth and pre-call fetch.
|
||||
|
||||
Returns each credential's `credential_uuid` (use this in node
|
||||
`credential_uuid` / `pre_call_fetch_credential_uuid` properties), `name`,
|
||||
`description`, and `credential_type`.
|
||||
"""
|
||||
user = await authenticate_mcp_request()
|
||||
credentials = await db_client.get_credentials_for_organization(
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"credential_uuid": c.credential_uuid,
|
||||
"name": c.name,
|
||||
"description": c.description or "",
|
||||
"credential_type": c.credential_type,
|
||||
}
|
||||
for c in credentials
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool
|
||||
@traced_tool
|
||||
async def list_recordings(workflow_id: int | None = None) -> list[dict]:
|
||||
"""List pre-recorded audio files available for greetings and edge
|
||||
transition speech.
|
||||
|
||||
Returns each recording's `recording_id` (use this in
|
||||
`greeting_recording_id` / `transition_speech_recording_id` properties),
|
||||
`transcript`, and TTS metadata. Pass `workflow_id` to filter to one
|
||||
workflow's recordings.
|
||||
"""
|
||||
user = await authenticate_mcp_request()
|
||||
recordings = await db_client.get_recordings(
|
||||
organization_id=user.selected_organization_id,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"recording_id": r.recording_id,
|
||||
"workflow_id": r.workflow_id,
|
||||
"transcript": r.transcript,
|
||||
"tts_provider": r.tts_provider,
|
||||
"tts_model": r.tts_model,
|
||||
"tts_voice_id": r.tts_voice_id,
|
||||
}
|
||||
for r in recordings
|
||||
]
|
||||
71
api/mcp_server/tools/get_workflow_code.py
Normal file
71
api/mcp_server/tools/get_workflow_code.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""MCP tool that returns a workflow as SDK TypeScript code.
|
||||
|
||||
Companion to `save_workflow`: the LLM calls `get_workflow_code` to see
|
||||
the current state of a workflow as editable code, mutates it, and calls
|
||||
`save_workflow` with the new code. Storage stays JSON; the TS form is
|
||||
an ephemeral projection for the LLM edit loop.
|
||||
|
||||
Selection priority: latest draft → latest published → legacy
|
||||
`workflow.workflow_definition`. That matches the UI's "whichever is the
|
||||
working copy" behavior so the LLM sees what a human editor would see.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.db import db_client
|
||||
from api.mcp_server.auth import authenticate_mcp_request
|
||||
from api.mcp_server.server import mcp
|
||||
from api.mcp_server.tracing import traced_tool
|
||||
from api.mcp_server.ts_bridge import TsBridgeError, generate_code
|
||||
|
||||
|
||||
@mcp.tool
|
||||
@traced_tool
|
||||
async def get_workflow_code(workflow_id: int) -> dict[str, Any]:
|
||||
"""Return the workflow as SDK TypeScript code the LLM can edit.
|
||||
|
||||
Output shape:
|
||||
{"code": "<TS source>", "workflow_id": int, "version": "draft" | "published" | "legacy"}
|
||||
|
||||
The LLM edits `code`, then calls `save_workflow(workflow_id, code)`.
|
||||
"""
|
||||
user = await authenticate_mcp_request()
|
||||
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail=f"Workflow {workflow_id} not found")
|
||||
|
||||
# Draft wins over published — editing a draft is the normal flow.
|
||||
# `current_definition` (is_current=True) is the published row, so we
|
||||
# fetch the draft explicitly. If the latest draft was just published,
|
||||
# no draft row exists and we fall through to `released_definition`.
|
||||
draft = await db_client.get_draft_version(workflow_id)
|
||||
released = workflow.released_definition
|
||||
|
||||
if draft is not None and draft.workflow_json:
|
||||
payload = draft.workflow_json
|
||||
source = "draft"
|
||||
elif released is not None and released.workflow_json:
|
||||
payload = released.workflow_json
|
||||
source = "published"
|
||||
else:
|
||||
payload = workflow.workflow_definition or {}
|
||||
source = "legacy"
|
||||
|
||||
try:
|
||||
code = await generate_code(payload, workflow_name=workflow.name or "")
|
||||
except TsBridgeError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to generate code: {e}")
|
||||
|
||||
return {
|
||||
"workflow_id": workflow_id,
|
||||
"name": workflow.name or "",
|
||||
"version": source,
|
||||
"code": code,
|
||||
}
|
||||
57
api/mcp_server/tools/node_types.py
Normal file
57
api/mcp_server/tools/node_types.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""MCP discovery tools for node specifications.
|
||||
|
||||
LLMs call these tools first to learn the available node-type catalog and
|
||||
each node's property schema before composing or modifying a workflow.
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.mcp_server.auth import authenticate_mcp_request
|
||||
from api.mcp_server.server import mcp
|
||||
from api.mcp_server.tracing import traced_tool
|
||||
from api.services.workflow.node_specs import SPEC_VERSION, all_specs, get_spec
|
||||
|
||||
|
||||
@mcp.tool
|
||||
@traced_tool
|
||||
async def list_node_types() -> dict:
|
||||
"""List every available node type with a brief summary.
|
||||
|
||||
Use this first to discover what nodes exist, then call `get_node_type`
|
||||
for the full schema of any node you intend to use.
|
||||
|
||||
Returns:
|
||||
A dict with `spec_version` (pin against this in any generated workflow
|
||||
code) and `node_types` (list of {name, display_name, description,
|
||||
category}).
|
||||
"""
|
||||
await authenticate_mcp_request()
|
||||
return {
|
||||
"spec_version": SPEC_VERSION,
|
||||
"node_types": [
|
||||
{
|
||||
"name": spec.name,
|
||||
"display_name": spec.display_name,
|
||||
"description": spec.description,
|
||||
"category": spec.category.value,
|
||||
}
|
||||
for spec in all_specs()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool
|
||||
@traced_tool
|
||||
async def get_node_type(name: str) -> dict:
|
||||
"""Fetch the full schema for a node type, including every property's
|
||||
type, default, conditional visibility rules, and LLM-readable
|
||||
description, plus worked examples.
|
||||
|
||||
Use the property `description` and the `examples` list to understand
|
||||
semantics — types alone are not enough.
|
||||
"""
|
||||
await authenticate_mcp_request()
|
||||
spec = get_spec(name)
|
||||
if spec is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown node type: {name!r}")
|
||||
return spec.model_dump(mode="json")
|
||||
168
api/mcp_server/tools/save_workflow.py
Normal file
168
api/mcp_server/tools/save_workflow.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""MCP tool that accepts LLM-authored SDK TypeScript and saves it as a draft.
|
||||
|
||||
Execution flow:
|
||||
1. Parse via the Node TS validator — AST-only, never executes the code.
|
||||
Returns either a workflow JSON or per-location parse/validate errors.
|
||||
2. Pydantic validation via `ReactFlowDTO.model_validate` (defence in
|
||||
depth; the parser is already spec-driven, but the DTO layer is the
|
||||
authoritative wire-format gate).
|
||||
3. Graph validation via `WorkflowGraph`.
|
||||
4. Save as a new draft via `db_client.save_workflow_draft` — the
|
||||
published version stays intact, so edits are rollback-safe.
|
||||
|
||||
Error codes surfaced to the LLM:
|
||||
parse_error — TS parse failed or a disallowed construct was used
|
||||
validation_error — node data failed spec validation (unknown field,
|
||||
missing required, wrong type, option out of range)
|
||||
schema_validation — ReactFlowDTO Pydantic rejection (rare; parser bug)
|
||||
graph_validation — semantic graph rule broken (e.g. no start node)
|
||||
bridge_error — Node subprocess failed before returning JSON
|
||||
|
||||
All LLM-facing errors include file:line:column where available so the
|
||||
LLM can correct its code directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from api.db import db_client
|
||||
from api.mcp_server.auth import authenticate_mcp_request
|
||||
from api.mcp_server.server import mcp
|
||||
from api.mcp_server.tracing import traced_tool
|
||||
from api.mcp_server.ts_bridge import TsBridgeError, parse_code
|
||||
from api.services.workflow.dto import ReactFlowDTO
|
||||
from api.services.workflow.layout import reconcile_positions
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
|
||||
|
||||
async def _previous_workflow_json(workflow: Any) -> dict[str, Any] | None:
|
||||
"""Same selection priority as `get_workflow_code` — the version the
|
||||
LLM saw is the version we reconcile against.
|
||||
|
||||
`current_definition` (is_current=True) is the published row, so the
|
||||
draft must be fetched explicitly. If no draft exists (e.g. the last
|
||||
draft was just published), fall through to `released_definition`.
|
||||
"""
|
||||
draft = await db_client.get_draft_version(workflow.id)
|
||||
if draft is not None and draft.workflow_json:
|
||||
return draft.workflow_json
|
||||
released = workflow.released_definition
|
||||
if released is not None and released.workflow_json:
|
||||
return released.workflow_json
|
||||
return workflow.workflow_definition or None
|
||||
|
||||
|
||||
def _error_result(code: str, message: str, **extra: Any) -> dict[str, Any]:
|
||||
return {"saved": False, "error_code": code, "error": message, **extra}
|
||||
|
||||
|
||||
def _format_errors(errors: list[dict[str, Any]]) -> str:
|
||||
parts: list[str] = []
|
||||
for e in errors:
|
||||
loc = ""
|
||||
line = e.get("line")
|
||||
col = e.get("column")
|
||||
if line is not None:
|
||||
loc = f" (line {line}" + (f", col {col}" if col is not None else "") + ")"
|
||||
parts.append(f"{e.get('message', '')}{loc}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
@traced_tool
|
||||
async def save_workflow(workflow_id: int, code: str) -> dict[str, Any]:
|
||||
"""Parse SDK TypeScript and save the resulting workflow as a draft.
|
||||
|
||||
`code` is TypeScript source using `@dograh/sdk`. Fetch the current
|
||||
code first via `get_workflow_code(workflow_id)`, edit it, then pass
|
||||
the full updated source here.
|
||||
|
||||
Example code:
|
||||
import { Workflow } from "@dograh/sdk";
|
||||
import { startCall, endCall } from "@dograh/sdk/typed";
|
||||
|
||||
const wf = new Workflow({ name: "lead_qualification" });
|
||||
const greeting = wf.addTyped(startCall({ name: "Greeting", prompt: "Hi!" }));
|
||||
const done = wf.addTyped(endCall({ name: "Done", prompt: "Bye." }));
|
||||
wf.edge(greeting, done, { label: "done", condition: "conversation complete" });
|
||||
|
||||
On success the draft version is saved; the published version is
|
||||
untouched.
|
||||
"""
|
||||
user = await authenticate_mcp_request()
|
||||
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail=f"Workflow {workflow_id} not found")
|
||||
|
||||
# 1. Parse + spec-validate via the Node TS validator.
|
||||
try:
|
||||
parsed = await parse_code(code)
|
||||
except TsBridgeError as e:
|
||||
logger.warning(f"ts_bridge failure: {e}")
|
||||
return _error_result("bridge_error", str(e))
|
||||
|
||||
if not parsed.get("ok"):
|
||||
stage = parsed.get("stage", "parse")
|
||||
errs = parsed.get("errors") or []
|
||||
code_key = "parse_error" if stage == "parse" else "validation_error"
|
||||
return _error_result(code_key, _format_errors(errs), errors=errs)
|
||||
|
||||
payload = parsed["workflow"]
|
||||
new_name = (parsed.get("workflowName") or "").strip()
|
||||
|
||||
# 1b. Reconcile node positions against the previously-stored workflow.
|
||||
# The parser drops positions by design (LLMs don't place nodes well);
|
||||
# here we fill them back in from what was there before, and pick
|
||||
# approximate placements for newly-introduced nodes.
|
||||
payload = reconcile_positions(payload, await _previous_workflow_json(workflow))
|
||||
|
||||
# 2. Pydantic shape check (defence in depth — parser is spec-driven).
|
||||
try:
|
||||
dto = ReactFlowDTO.model_validate(payload)
|
||||
except PydanticValidationError as e:
|
||||
return _error_result("schema_validation", str(e))
|
||||
|
||||
# 3. Graph-level semantic validation (start-node count, edge shape).
|
||||
try:
|
||||
WorkflowGraph(dto)
|
||||
except (ValueError, Exception) as e: # WorkflowGraph raises ValueError
|
||||
return _error_result("graph_validation", str(e))
|
||||
|
||||
# 4a. If the `new Workflow({ name })` in the edited source differs from
|
||||
# the stored name, rename the workflow. Name is a workflow-level field
|
||||
# (not versioned), so this takes effect immediately.
|
||||
name_changed = bool(new_name) and new_name != workflow.name
|
||||
if name_changed:
|
||||
await db_client.update_workflow(
|
||||
workflow_id=workflow_id,
|
||||
name=new_name,
|
||||
workflow_definition=None,
|
||||
template_context_variables=None,
|
||||
workflow_configurations=None,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
|
||||
# 4b. Save as a new draft (existing published version stays intact).
|
||||
draft = await db_client.save_workflow_draft(
|
||||
workflow_id=workflow_id,
|
||||
workflow_definition=payload,
|
||||
)
|
||||
|
||||
return {
|
||||
"saved": True,
|
||||
"workflow_id": workflow_id,
|
||||
"version_number": draft.version_number,
|
||||
"status": draft.status,
|
||||
"node_count": len(payload["nodes"]),
|
||||
"edge_count": len(payload["edges"]),
|
||||
"name": new_name or workflow.name,
|
||||
"renamed": name_changed,
|
||||
}
|
||||
53
api/mcp_server/tools/workflows.py
Normal file
53
api/mcp_server/tools/workflows.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from fastapi import HTTPException
|
||||
|
||||
from api.db import db_client
|
||||
from api.mcp_server.auth import authenticate_mcp_request
|
||||
from api.mcp_server.server import mcp
|
||||
from api.mcp_server.tracing import traced_tool
|
||||
|
||||
|
||||
@mcp.tool
|
||||
@traced_tool
|
||||
async def list_workflows(status: str | None = "active") -> list[dict]:
|
||||
"""List agents (workflows) in the caller's organization.
|
||||
|
||||
Returns id, name, status, and created_at for each agent. Use
|
||||
`get_workflow` to fetch a single agent's full definition. Defaults
|
||||
to active agents; pass `status="archived"` to list archived agents,
|
||||
or `status=None` to list all.
|
||||
"""
|
||||
user = await authenticate_mcp_request()
|
||||
workflows = await db_client.get_all_workflows_for_listing(
|
||||
organization_id=user.selected_organization_id,
|
||||
status=status,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": w.id,
|
||||
"name": w.name,
|
||||
"status": w.status,
|
||||
"created_at": w.created_at.isoformat() if w.created_at else None,
|
||||
}
|
||||
for w in workflows
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool
|
||||
@traced_tool
|
||||
async def get_workflow(workflow_id: int) -> dict:
|
||||
"""Fetch a single agent by id, including its current published definition."""
|
||||
user = await authenticate_mcp_request()
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail=f"Workflow {workflow_id} not found")
|
||||
|
||||
current = workflow.current_definition
|
||||
return {
|
||||
"id": workflow.id,
|
||||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"definition": current.workflow_json if current else None,
|
||||
"version_number": current.version_number if current else None,
|
||||
}
|
||||
87
api/mcp_server/tracing.py
Normal file
87
api/mcp_server/tracing.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""OTel tracing for MCP tool invocations.
|
||||
|
||||
The project-wide tracing setup in
|
||||
`api/services/pipecat/tracing_config.py` already routes spans to
|
||||
per-organization Langfuse projects based on the `dograh.org_id` span
|
||||
attribute. This module plugs MCP tool calls into that pipeline:
|
||||
|
||||
@mcp.tool
|
||||
@traced_tool
|
||||
async def my_tool(...): ...
|
||||
|
||||
Each decorated invocation produces one span named `mcp.<tool_name>` with
|
||||
Langfuse-rendered input/output. Organization and user attributes are
|
||||
stamped separately by `authenticate_mcp_request` when it runs inside
|
||||
the tool body — the decorator's span is the `current_span` at that
|
||||
point, so the attributes land on the right span and the router export
|
||||
dispatches to the correct Langfuse project.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import wraps
|
||||
from typing import Any, Awaitable, Callable, TypeVar
|
||||
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.context import Context
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
R = TypeVar("R")
|
||||
|
||||
_TRACER = trace.get_tracer("dograh.mcp")
|
||||
# Langfuse truncates long payloads anyway; cap here to keep span size
|
||||
# bounded. Tune up if you find tool outputs consistently clipped.
|
||||
_MAX_ATTR_LEN = 8000
|
||||
|
||||
|
||||
def _safe_json(value: Any) -> str:
|
||||
try:
|
||||
return json.dumps(value, default=str, ensure_ascii=False)
|
||||
except Exception: # noqa: BLE001
|
||||
return str(value)
|
||||
|
||||
|
||||
def traced_tool(fn: Callable[..., Awaitable[R]]) -> Callable[..., Awaitable[R]]:
|
||||
"""Wrap an MCP tool so each invocation produces a span.
|
||||
|
||||
Captures tool name, input kwargs, output, and exceptions. Stacks
|
||||
below `@mcp.tool` so FastMCP sees the wrapped function when
|
||||
introspecting the tool schema (`functools.wraps` preserves the
|
||||
signature the framework reads).
|
||||
"""
|
||||
|
||||
@wraps(fn)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> R:
|
||||
# Each MCP tool call is its own root trace. Passing an empty
|
||||
# `Context()` severs the inherited parent so the span doesn't
|
||||
# graft onto whatever other trace happens to be active (e.g.
|
||||
# the FastAPI request span, or a client-propagated context).
|
||||
# One trace per tool invocation makes Langfuse diffing and
|
||||
# per-org filtering clean.
|
||||
with _TRACER.start_as_current_span(
|
||||
f"mcp.{fn.__name__}",
|
||||
context=Context(),
|
||||
) as span:
|
||||
span.set_attribute("mcp.tool.name", fn.__name__)
|
||||
# Explicit trace-name override so the Langfuse UI shows
|
||||
# `mcp.<tool>` at the top of the trace instead of whatever
|
||||
# the framework happens to name the root span.
|
||||
span.set_attribute("langfuse.trace.name", f"mcp.{fn.__name__}")
|
||||
span.set_attribute(
|
||||
"langfuse.observation.input",
|
||||
_safe_json(kwargs)[:_MAX_ATTR_LEN],
|
||||
)
|
||||
try:
|
||||
result = await fn(*args, **kwargs)
|
||||
except Exception as e:
|
||||
span.record_exception(e)
|
||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
||||
raise
|
||||
span.set_attribute(
|
||||
"langfuse.observation.output",
|
||||
_safe_json(result)[:_MAX_ATTR_LEN],
|
||||
)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
93
api/mcp_server/ts_bridge.py
Normal file
93
api/mcp_server/ts_bridge.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Python-side bridge to the Node TS validator.
|
||||
|
||||
Spawns `node api/mcp_server/ts_validator/src/index.ts` as a short-lived
|
||||
subprocess per call, streams a JSON request on stdin, reads a JSON
|
||||
response from stdout. The validator never executes LLM code — it either
|
||||
emits TypeScript from a workflow JSON (`generate`) or parses LLM-authored
|
||||
TS back into a workflow JSON via AST walking (`parse`).
|
||||
|
||||
The subprocess startup cost is ~100-200ms per call. Fine for MCP tool
|
||||
rates; if it ever matters, the validator can be promoted to a long-lived
|
||||
worker over a unix socket without changing this interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from api.services.workflow.node_specs import all_specs
|
||||
|
||||
_VALIDATOR_ENTRY = Path(__file__).resolve().parent / "ts_validator" / "src" / "index.ts"
|
||||
|
||||
|
||||
class TsBridgeError(Exception):
|
||||
"""The Node subprocess failed before producing a JSON response."""
|
||||
|
||||
|
||||
def _specs_payload() -> list[dict[str, Any]]:
|
||||
return [s.model_dump(mode="json") for s in all_specs()]
|
||||
|
||||
|
||||
async def _invoke(request: dict[str, Any]) -> dict[str, Any]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"node",
|
||||
str(_VALIDATOR_ENTRY),
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate(json.dumps(request).encode("utf-8"))
|
||||
if proc.returncode != 0 and not stdout:
|
||||
raise TsBridgeError(
|
||||
f"ts_validator exited {proc.returncode}: "
|
||||
f"{stderr.decode('utf-8', errors='replace')}"
|
||||
)
|
||||
try:
|
||||
return json.loads(stdout.decode("utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise TsBridgeError(
|
||||
f"ts_validator emitted non-JSON: {stdout!r} (stderr: {stderr!r})"
|
||||
) from e
|
||||
|
||||
|
||||
async def generate_code(workflow: dict[str, Any], *, workflow_name: str = "") -> str:
|
||||
"""Emit SDK TypeScript source from a workflow JSON payload.
|
||||
|
||||
Raises `TsBridgeError` if the validator can't produce code (unknown
|
||||
node type, dangling edge reference, etc.) — these are bugs at the
|
||||
caller layer, not user input, so we fail loudly.
|
||||
"""
|
||||
result = await _invoke(
|
||||
{
|
||||
"command": "generate",
|
||||
"workflow": workflow,
|
||||
"specs": _specs_payload(),
|
||||
"workflowName": workflow_name,
|
||||
}
|
||||
)
|
||||
if not result.get("ok"):
|
||||
errs = result.get("errors") or [{"message": "unknown failure"}]
|
||||
raise TsBridgeError(
|
||||
"generate_code failed: " + "; ".join(e.get("message", "") for e in errs)
|
||||
)
|
||||
return result["code"]
|
||||
|
||||
|
||||
async def parse_code(code: str) -> dict[str, Any]:
|
||||
"""Parse LLM-authored TS back into a workflow JSON.
|
||||
|
||||
Returns the raw validator response — `{"ok": True, "workflow": {...}}`
|
||||
on success, `{"ok": False, "stage": "parse" | "validate", "errors": [...]}`
|
||||
on author-side failure. Author-side failures are surfaced to the LLM
|
||||
verbatim so it can iterate; callers should not re-wrap them.
|
||||
"""
|
||||
return await _invoke(
|
||||
{
|
||||
"command": "parse",
|
||||
"code": code,
|
||||
"specs": _specs_payload(),
|
||||
}
|
||||
)
|
||||
1
api/mcp_server/ts_validator/.gitignore
vendored
Normal file
1
api/mcp_server/ts_validator/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
31
api/mcp_server/ts_validator/package-lock.json
generated
Normal file
31
api/mcp_server/ts_validator/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "dograh-ts-validator",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dograh-ts-validator",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
api/mcp_server/ts_validator/package.json
Normal file
13
api/mcp_server/ts_validator/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "dograh-ts-validator",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"description": "Node helper invoked by the Python MCP server. Converts workflow JSON to SDK TypeScript code (generate) and parses LLM-authored TS back into a validated workflow JSON (parse). Runs as a short-lived subprocess over stdin/stdout.",
|
||||
"dependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.6"
|
||||
}
|
||||
}
|
||||
304
api/mcp_server/ts_validator/src/generate.ts
Normal file
304
api/mcp_server/ts_validator/src/generate.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
// JSON → TypeScript source. Emits flat code the LLM can read and edit:
|
||||
// imports, a `Workflow` construction, one `addTyped` per node, one `edge`
|
||||
// per edge. Variable names are derived from `data.name` (falling back to
|
||||
// the node id) and deduplicated so the AST round-trips back through
|
||||
// `parse.ts` into the same workflow JSON.
|
||||
|
||||
import type {
|
||||
GenerateResult,
|
||||
NodeSpec,
|
||||
PropertySpec,
|
||||
WireWorkflow,
|
||||
} from "./types.ts";
|
||||
|
||||
export function generateCode(
|
||||
workflow: WireWorkflow,
|
||||
specs: NodeSpec[],
|
||||
opts: { workflowName?: string } = {},
|
||||
): GenerateResult {
|
||||
const specByName = new Map(specs.map((s) => [s.name, s]));
|
||||
|
||||
// Catch unknown node types up-front — otherwise we'd emit an import
|
||||
// line for a factory that doesn't exist.
|
||||
for (const n of workflow.nodes) {
|
||||
if (!specByName.has(n.type)) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
message: `Unknown node type in workflow: "${n.type}"`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const factoryNames = [
|
||||
...new Set(workflow.nodes.map((n) => n.type)),
|
||||
].sort();
|
||||
const nodeVarById = new Map<string, string>();
|
||||
const usedNames = new Set<string>();
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`import { Workflow } from "@dograh/sdk";`);
|
||||
if (factoryNames.length > 0) {
|
||||
lines.push(
|
||||
`import { ${factoryNames.join(", ")} } from "@dograh/sdk/typed";`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
const wfName = opts.workflowName ?? "";
|
||||
lines.push(
|
||||
`const wf = new Workflow(${renderObject({ name: wfName }, 0)});`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
const varName = pickVarName(node, usedNames);
|
||||
nodeVarById.set(node.id, varName);
|
||||
|
||||
const spec = specByName.get(node.type)!;
|
||||
// Strip legacy/UI-state fields the spec doesn't know about
|
||||
// (e.g. `invalid`, `selected`, `dragging`, `is_start`,
|
||||
// `validationMessage`). They accumulated in stored workflow
|
||||
// data before the parser enforced spec validation, and are
|
||||
// pure noise from the LLM's perspective — dropping them keeps
|
||||
// the editing surface clean and avoids a pointless save-time
|
||||
// rejection round-trip.
|
||||
const knownOnly = stripUnknown(node.data, spec);
|
||||
const data = stripDefaults(knownOnly, spec);
|
||||
const factoryArg = renderObject(data, 0);
|
||||
|
||||
// Positions are intentionally omitted — LLMs don't place nodes
|
||||
// sensibly, so we let a downstream auto-layout pass (future
|
||||
// enhancement) assign coordinates on save. Existing positions
|
||||
// in the DB are preserved by `parse.ts` defaulting to {0,0}
|
||||
// and the save path leaving pre-existing node positions alone.
|
||||
lines.push(
|
||||
`const ${varName} = wf.addTyped(${node.type}(${factoryArg}));`,
|
||||
);
|
||||
}
|
||||
|
||||
if (workflow.edges.length > 0) {
|
||||
lines.push("");
|
||||
}
|
||||
for (const edge of workflow.edges) {
|
||||
const src = nodeVarById.get(edge.source);
|
||||
const tgt = nodeVarById.get(edge.target);
|
||||
if (!src || !tgt) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
`Edge ${edge.id} references unknown node ` +
|
||||
`(source=${edge.source}, target=${edge.target}).`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const cleanedEdge = pickEdgeFields(edge.data);
|
||||
const edgeOpts = renderObject(cleanedEdge, 0);
|
||||
lines.push(`wf.edge(${src}, ${tgt}, ${edgeOpts});`);
|
||||
}
|
||||
|
||||
return { ok: true, code: lines.join("\n") + "\n" };
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function pickVarName(
|
||||
node: { id: string; data: Record<string, unknown> },
|
||||
used: Set<string>,
|
||||
): string {
|
||||
const seed =
|
||||
typeof node.data["name"] === "string" && node.data["name"].trim()
|
||||
? (node.data["name"] as string)
|
||||
: `node_${node.id}`;
|
||||
const base = sanitizeIdentifier(seed);
|
||||
let candidate = base;
|
||||
let i = 2;
|
||||
while (used.has(candidate) || RESERVED.has(candidate)) {
|
||||
candidate = `${base}_${i++}`;
|
||||
}
|
||||
used.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function sanitizeIdentifier(raw: string): string {
|
||||
const cleaned = raw
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toLowerCase();
|
||||
if (!cleaned) return "node";
|
||||
if (/^[0-9]/.test(cleaned)) return `n_${cleaned}`;
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
const RESERVED = new Set([
|
||||
"wf",
|
||||
"const",
|
||||
"let",
|
||||
"var",
|
||||
"new",
|
||||
"function",
|
||||
"class",
|
||||
"import",
|
||||
"export",
|
||||
"return",
|
||||
"if",
|
||||
"else",
|
||||
"for",
|
||||
"while",
|
||||
"do",
|
||||
"switch",
|
||||
"case",
|
||||
"break",
|
||||
"continue",
|
||||
"default",
|
||||
"throw",
|
||||
"try",
|
||||
"catch",
|
||||
"finally",
|
||||
"await",
|
||||
"async",
|
||||
"true",
|
||||
"false",
|
||||
"null",
|
||||
"undefined",
|
||||
"this",
|
||||
"super",
|
||||
"in",
|
||||
"of",
|
||||
"typeof",
|
||||
"instanceof",
|
||||
"delete",
|
||||
"void",
|
||||
"yield",
|
||||
"Workflow",
|
||||
]);
|
||||
|
||||
// Drop keys not declared in the spec. Handles nested `fixed_collection`
|
||||
// rows by recursing through sub-property specs. Anything that isn't in
|
||||
// the spec is legacy/UI state and should never reach the LLM.
|
||||
function stripUnknown(
|
||||
data: Record<string, unknown>,
|
||||
spec: NodeSpec,
|
||||
): Record<string, unknown> {
|
||||
const known = new Map<string, PropertySpec>();
|
||||
for (const p of spec.properties ?? []) known.set(p.name, p);
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
const prop = known.get(k);
|
||||
if (!prop) continue; // drop unknown
|
||||
if (prop.type === "fixed_collection" && Array.isArray(v)) {
|
||||
const rowSpec: NodeSpec = {
|
||||
name: prop.name,
|
||||
properties: prop.properties ?? [],
|
||||
};
|
||||
out[k] = v.map((row) =>
|
||||
row && typeof row === "object" && !Array.isArray(row)
|
||||
? stripUnknown(row as Record<string, unknown>, rowSpec)
|
||||
: row,
|
||||
);
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Edge schema is fixed (no NodeSpec for edges). Mirrors the allowed
|
||||
// fields on `Workflow.edge(...)` in both SDKs.
|
||||
const KNOWN_EDGE_FIELDS = new Set([
|
||||
"label",
|
||||
"condition",
|
||||
"transition_speech",
|
||||
"transition_speech_type",
|
||||
"transition_speech_recording_id",
|
||||
]);
|
||||
|
||||
function pickEdgeFields(
|
||||
data: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (KNOWN_EDGE_FIELDS.has(k)) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Drop keys whose value equals the spec default — keeps emitted code tight.
|
||||
function stripDefaults(
|
||||
data: Record<string, unknown>,
|
||||
spec: NodeSpec,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
const defaults = new Map<string, unknown>();
|
||||
for (const prop of spec.properties ?? []) {
|
||||
if (prop.default !== undefined && prop.default !== null) {
|
||||
defaults.set(prop.name, prop.default);
|
||||
}
|
||||
}
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (defaults.has(k) && deepEqual(defaults.get(k), v)) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (a === null || b === null) return false;
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((el, i) => deepEqual(el, b[i]));
|
||||
}
|
||||
if (typeof a === "object" && typeof b === "object") {
|
||||
const ak = Object.keys(a as object).sort();
|
||||
const bk = Object.keys(b as object).sort();
|
||||
if (ak.length !== bk.length) return false;
|
||||
if (ak.some((k, i) => k !== bk[i])) return false;
|
||||
return ak.every((k) =>
|
||||
deepEqual(
|
||||
(a as Record<string, unknown>)[k],
|
||||
(b as Record<string, unknown>)[k],
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Object renderer biased for readability — strings use single-line JSON,
|
||||
// nested objects/arrays indent one level per depth.
|
||||
function renderObject(obj: Record<string, unknown>, depth: number): string {
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) return "{}";
|
||||
const pad = " ".repeat(depth + 1);
|
||||
const closingPad = " ".repeat(depth);
|
||||
const parts = keys.map((k) => {
|
||||
const v = renderValue(obj[k], depth + 1);
|
||||
return `${pad}${k}: ${v}`;
|
||||
});
|
||||
return `{\n${parts.join(",\n")},\n${closingPad}}`;
|
||||
}
|
||||
|
||||
function renderValue(v: unknown, depth: number): string {
|
||||
if (v === null || v === undefined) return "null";
|
||||
if (typeof v === "string") return JSON.stringify(v);
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
if (Array.isArray(v)) {
|
||||
if (v.length === 0) return "[]";
|
||||
const pad = " ".repeat(depth + 1);
|
||||
const closingPad = " ".repeat(depth);
|
||||
const items = v.map((el) => `${pad}${renderValue(el, depth + 1)}`);
|
||||
return `[\n${items.join(",\n")},\n${closingPad}]`;
|
||||
}
|
||||
if (typeof v === "object") {
|
||||
return renderObject(v as Record<string, unknown>, depth);
|
||||
}
|
||||
return JSON.stringify(v);
|
||||
}
|
||||
74
api/mcp_server/ts_validator/src/index.ts
Normal file
74
api/mcp_server/ts_validator/src/index.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Stdin/stdout dispatch. Reads a single JSON request, routes to
|
||||
// generate or parse, writes a single JSON response. Exits 0 on request
|
||||
// success (including validation failures — those are in the JSON), and
|
||||
// exits 1 only on internal errors (bad input JSON, unhandled exception).
|
||||
|
||||
import { generateCode } from "./generate.ts";
|
||||
import { parseCode } from "./parse.ts";
|
||||
import type { NodeSpec, WireWorkflow } from "./types.ts";
|
||||
|
||||
interface GenerateRequest {
|
||||
command: "generate";
|
||||
workflow: WireWorkflow;
|
||||
specs: NodeSpec[];
|
||||
workflowName?: string;
|
||||
}
|
||||
|
||||
interface ParseRequest {
|
||||
command: "parse";
|
||||
code: string;
|
||||
specs: NodeSpec[];
|
||||
}
|
||||
|
||||
type Request = GenerateRequest | ParseRequest;
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf-8");
|
||||
}
|
||||
|
||||
function writeResult(payload: unknown): void {
|
||||
process.stdout.write(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const input = await readStdin();
|
||||
let req: Request;
|
||||
try {
|
||||
req = JSON.parse(input) as Request;
|
||||
} catch (e) {
|
||||
writeResult({
|
||||
ok: false,
|
||||
stage: "internal",
|
||||
errors: [{ message: `Invalid JSON on stdin: ${(e as Error).message}` }],
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (req.command === "generate") {
|
||||
writeResult(generateCode(req.workflow, req.specs, { workflowName: req.workflowName }));
|
||||
return;
|
||||
}
|
||||
if (req.command === "parse") {
|
||||
writeResult(parseCode(req.code, req.specs));
|
||||
return;
|
||||
}
|
||||
writeResult({
|
||||
ok: false,
|
||||
stage: "internal",
|
||||
errors: [{ message: `Unknown command: ${(req as { command?: unknown }).command}` }],
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
writeResult({
|
||||
ok: false,
|
||||
stage: "internal",
|
||||
errors: [{ message: (err as Error).stack ?? String(err) }],
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
612
api/mcp_server/ts_validator/src/parse.ts
Normal file
612
api/mcp_server/ts_validator/src/parse.ts
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
// TypeScript → workflow JSON.
|
||||
//
|
||||
// Parses LLM-authored SDK code with the TypeScript compiler, walks the
|
||||
// AST statement by statement, and builds up a workflow JSON from the
|
||||
// recognized SDK patterns:
|
||||
//
|
||||
// const wf = new Workflow({ name: "..." });
|
||||
// const X = wf.addTyped(startCall({ ...fields }));
|
||||
// const Y = wf.add({ type: "endCall", ...fields });
|
||||
// wf.edge(X, Y, { label: "...", condition: "..." });
|
||||
//
|
||||
// No code is executed. Any top-level statement that doesn't match one
|
||||
// of the recognized shapes is a parse error with a file:line:col pointer
|
||||
// so the LLM can iterate. Node data is validated against the spec
|
||||
// catalog before returning.
|
||||
|
||||
import ts from "typescript";
|
||||
|
||||
import type {
|
||||
NodeSpec,
|
||||
ParseErrorItem,
|
||||
ParseResult,
|
||||
PropertySpec,
|
||||
WireEdge,
|
||||
WireNode,
|
||||
} from "./types.ts";
|
||||
|
||||
export function parseCode(code: string, specs: NodeSpec[]): ParseResult {
|
||||
const specByName = new Map(specs.map((s) => [s.name, s]));
|
||||
const sourceFile = ts.createSourceFile(
|
||||
"workflow.ts",
|
||||
code,
|
||||
ts.ScriptTarget.ESNext,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
const errors: ParseErrorItem[] = [];
|
||||
const nodes: WireNode[] = [];
|
||||
const edges: WireEdge[] = [];
|
||||
const nodeRefs = new Map<string, WireNode>();
|
||||
let workflowVar: string | null = null;
|
||||
let workflowName = "";
|
||||
let nextId = 1;
|
||||
|
||||
const addError = (node: ts.Node, message: string): void => {
|
||||
const pos = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
||||
errors.push({
|
||||
message,
|
||||
line: pos.line + 1,
|
||||
column: pos.character + 1,
|
||||
});
|
||||
};
|
||||
|
||||
for (const stmt of sourceFile.statements) {
|
||||
if (ts.isImportDeclaration(stmt)) continue; // imports are harmless
|
||||
if (
|
||||
ts.isExportAssignment(stmt) ||
|
||||
stmt.kind === ts.SyntaxKind.EmptyStatement
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// `const X = ...;` or `wf.edge(...);`
|
||||
if (ts.isVariableStatement(stmt)) {
|
||||
handleVariableStatement(stmt);
|
||||
continue;
|
||||
}
|
||||
if (ts.isExpressionStatement(stmt)) {
|
||||
handleExpressionStatement(stmt);
|
||||
continue;
|
||||
}
|
||||
addError(
|
||||
stmt,
|
||||
`Only imports, \`const X = ...\` bindings, and \`wf.edge(...)\` calls are allowed at the top level. Found: ${ts.SyntaxKind[stmt.kind]}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function handleVariableStatement(stmt: ts.VariableStatement): void {
|
||||
const modifiers = ts.getModifiers(stmt);
|
||||
if (modifiers && modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
|
||||
addError(stmt, "`export` is not allowed on workflow bindings.");
|
||||
return;
|
||||
}
|
||||
if ((stmt.declarationList.flags & ts.NodeFlags.Const) === 0) {
|
||||
addError(stmt, "Use `const` for all bindings.");
|
||||
return;
|
||||
}
|
||||
for (const decl of stmt.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(decl.name)) {
|
||||
addError(decl, "Destructuring is not allowed — use a single identifier.");
|
||||
continue;
|
||||
}
|
||||
if (!decl.initializer) {
|
||||
addError(decl, "Bindings must have an initializer.");
|
||||
continue;
|
||||
}
|
||||
const varName = decl.name.text;
|
||||
handleBinding(varName, decl.initializer, decl);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBinding(
|
||||
varName: string,
|
||||
initializer: ts.Expression,
|
||||
origin: ts.Node,
|
||||
): void {
|
||||
const expr = unwrapAwait(initializer);
|
||||
|
||||
// `const wf = new Workflow({ name: "..." })`
|
||||
if (ts.isNewExpression(expr)) {
|
||||
if (!ts.isIdentifier(expr.expression) || expr.expression.text !== "Workflow") {
|
||||
addError(origin, "Only `new Workflow(...)` is supported for object construction.");
|
||||
return;
|
||||
}
|
||||
if (workflowVar) {
|
||||
addError(origin, `A Workflow is already bound (as \`${workflowVar}\`). Only one Workflow is allowed.`);
|
||||
return;
|
||||
}
|
||||
const args = expr.arguments ?? ts.factory.createNodeArray();
|
||||
if (args.length > 0) {
|
||||
const val = literalToJs(args[0]!, addError);
|
||||
if (
|
||||
val &&
|
||||
typeof val === "object" &&
|
||||
!Array.isArray(val) &&
|
||||
typeof (val as Record<string, unknown>)["name"] === "string"
|
||||
) {
|
||||
workflowName = (val as Record<string, unknown>)["name"] as string;
|
||||
}
|
||||
}
|
||||
workflowVar = varName;
|
||||
return;
|
||||
}
|
||||
|
||||
// `const X = wf.addTyped(factory({...}))` or `wf.add({ type: "...", ... })`
|
||||
if (ts.isCallExpression(expr)) {
|
||||
const call = expr;
|
||||
const callee = call.expression;
|
||||
|
||||
// Must be `wf.XYZ(...)` — property access off the workflow var
|
||||
if (
|
||||
!ts.isPropertyAccessExpression(callee) ||
|
||||
!ts.isIdentifier(callee.expression) ||
|
||||
(workflowVar !== null && callee.expression.text !== workflowVar)
|
||||
) {
|
||||
addError(
|
||||
origin,
|
||||
`Expected \`${workflowVar ?? "wf"}.addTyped(...)\` or \`${workflowVar ?? "wf"}.add(...)\`.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!workflowVar) {
|
||||
addError(origin, "Workflow must be constructed before adding nodes.");
|
||||
return;
|
||||
}
|
||||
|
||||
const method = callee.name.text;
|
||||
if (method === "addTyped") {
|
||||
handleAddTyped(varName, call, origin);
|
||||
} else if (method === "add") {
|
||||
handleAddGeneric(varName, call, origin);
|
||||
} else {
|
||||
addError(
|
||||
origin,
|
||||
`Unsupported method \`${method}\`. Use \`addTyped\` or \`add\`.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
addError(
|
||||
origin,
|
||||
"Only `new Workflow(...)`, `wf.addTyped(...)`, and `wf.add(...)` are allowed as binding initializers.",
|
||||
);
|
||||
}
|
||||
|
||||
function handleAddTyped(
|
||||
varName: string,
|
||||
call: ts.CallExpression,
|
||||
origin: ts.Node,
|
||||
): void {
|
||||
if (call.arguments.length < 1 || call.arguments.length > 2) {
|
||||
addError(origin, "`addTyped` takes 1 or 2 arguments.");
|
||||
return;
|
||||
}
|
||||
const inner = call.arguments[0]!;
|
||||
if (!ts.isCallExpression(inner) || !ts.isIdentifier(inner.expression)) {
|
||||
addError(
|
||||
origin,
|
||||
"`addTyped` must be called with a factory invocation, e.g. `addTyped(startCall({ ... }))`.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const factoryName = inner.expression.text;
|
||||
if (!specByName.has(factoryName)) {
|
||||
addError(
|
||||
origin,
|
||||
`Unknown node type: \`${factoryName}\`. Check the list of registered node types.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const factoryArgs = inner.arguments;
|
||||
let data: Record<string, unknown> = {};
|
||||
if (factoryArgs.length > 0) {
|
||||
const parsed = literalToJs(factoryArgs[0]!, addError);
|
||||
if (parsed !== undefined) {
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
addError(inner, "Factory argument must be an object literal.");
|
||||
return;
|
||||
}
|
||||
data = parsed as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
// Optional position arg
|
||||
const position = extractPositionArg(call.arguments[1], addError);
|
||||
bindNode(varName, factoryName, data, position, origin);
|
||||
}
|
||||
|
||||
function handleAddGeneric(
|
||||
varName: string,
|
||||
call: ts.CallExpression,
|
||||
origin: ts.Node,
|
||||
): void {
|
||||
if (call.arguments.length !== 1) {
|
||||
addError(origin, "`add` takes exactly 1 object argument.");
|
||||
return;
|
||||
}
|
||||
const parsed = literalToJs(call.arguments[0]!, addError);
|
||||
if (parsed === undefined) return;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
addError(origin, "`add` argument must be an object literal.");
|
||||
return;
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const typeValue = obj["type"];
|
||||
if (typeof typeValue !== "string") {
|
||||
addError(origin, "`add({ type, ... })` requires a string `type` field.");
|
||||
return;
|
||||
}
|
||||
if (!specByName.has(typeValue)) {
|
||||
addError(origin, `Unknown node type: \`${typeValue}\`.`);
|
||||
return;
|
||||
}
|
||||
let position: { x: number; y: number } | undefined;
|
||||
if (obj["position"] !== undefined) {
|
||||
const p = obj["position"];
|
||||
if (
|
||||
Array.isArray(p) &&
|
||||
p.length === 2 &&
|
||||
typeof p[0] === "number" &&
|
||||
typeof p[1] === "number"
|
||||
) {
|
||||
position = { x: p[0], y: p[1] };
|
||||
} else {
|
||||
addError(
|
||||
origin,
|
||||
"`position` must be a [x, y] tuple of numbers.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { type: _ignored, position: _ignored2, ...rest } = obj;
|
||||
bindNode(varName, typeValue, rest, position, origin);
|
||||
}
|
||||
|
||||
function bindNode(
|
||||
varName: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
position: { x: number; y: number } | undefined,
|
||||
origin: ts.Node,
|
||||
): void {
|
||||
if (nodeRefs.has(varName)) {
|
||||
addError(origin, `Variable \`${varName}\` is already bound.`);
|
||||
return;
|
||||
}
|
||||
const node: WireNode = {
|
||||
id: String(nextId++),
|
||||
type,
|
||||
position: position ?? { x: 0, y: 0 },
|
||||
data,
|
||||
};
|
||||
nodes.push(node);
|
||||
nodeRefs.set(varName, node);
|
||||
}
|
||||
|
||||
function handleExpressionStatement(stmt: ts.ExpressionStatement): void {
|
||||
const expr = unwrapAwait(stmt.expression);
|
||||
if (!ts.isCallExpression(expr)) {
|
||||
addError(stmt, "Only `wf.edge(...)` calls are allowed as bare statements.");
|
||||
return;
|
||||
}
|
||||
const callee = expr.expression;
|
||||
if (
|
||||
!ts.isPropertyAccessExpression(callee) ||
|
||||
!ts.isIdentifier(callee.expression) ||
|
||||
(workflowVar !== null && callee.expression.text !== workflowVar) ||
|
||||
callee.name.text !== "edge"
|
||||
) {
|
||||
addError(stmt, "Only `wf.edge(source, target, opts)` is allowed as a bare statement.");
|
||||
return;
|
||||
}
|
||||
if (expr.arguments.length !== 3) {
|
||||
addError(stmt, "`edge` takes exactly 3 arguments: (source, target, opts).");
|
||||
return;
|
||||
}
|
||||
const [srcArg, tgtArg, optsArg] = expr.arguments;
|
||||
if (!ts.isIdentifier(srcArg!) || !ts.isIdentifier(tgtArg!)) {
|
||||
addError(stmt, "`edge` source and target must be variable identifiers bound by `addTyped`/`add`.");
|
||||
return;
|
||||
}
|
||||
const src = nodeRefs.get(srcArg.text);
|
||||
const tgt = nodeRefs.get(tgtArg.text);
|
||||
if (!src) {
|
||||
addError(srcArg, `Unknown node variable: \`${srcArg.text}\`.`);
|
||||
return;
|
||||
}
|
||||
if (!tgt) {
|
||||
addError(tgtArg, `Unknown node variable: \`${tgtArg.text}\`.`);
|
||||
return;
|
||||
}
|
||||
const opts = literalToJs(optsArg!, addError);
|
||||
if (opts === undefined) return;
|
||||
if (typeof opts !== "object" || opts === null || Array.isArray(opts)) {
|
||||
addError(stmt, "`edge` options must be an object literal.");
|
||||
return;
|
||||
}
|
||||
const optsObj = opts as Record<string, unknown>;
|
||||
if (typeof optsObj["label"] !== "string" || (optsObj["label"] as string).trim() === "") {
|
||||
addError(stmt, "`edge` requires a non-empty `label` string.");
|
||||
return;
|
||||
}
|
||||
if (typeof optsObj["condition"] !== "string" || (optsObj["condition"] as string).trim() === "") {
|
||||
addError(stmt, "`edge` requires a non-empty `condition` string.");
|
||||
return;
|
||||
}
|
||||
edges.push({
|
||||
id: `${src.id}-${tgt.id}`,
|
||||
source: src.id,
|
||||
target: tgt.id,
|
||||
data: optsObj,
|
||||
});
|
||||
}
|
||||
|
||||
// ── terminate early on parse errors ──────────────────────────────
|
||||
if (errors.length > 0) {
|
||||
return { ok: false, stage: "parse", errors };
|
||||
}
|
||||
|
||||
if (!workflowVar) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "parse",
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
"No Workflow construction found. Expected `const wf = new Workflow({ name: \"...\" });`.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ── spec-driven node validation ─────────────────────────────────
|
||||
const validationErrors: ParseErrorItem[] = [];
|
||||
for (const node of nodes) {
|
||||
const spec = specByName.get(node.type)!;
|
||||
const validated = validateNodeData(
|
||||
spec,
|
||||
node.data,
|
||||
(msg) => validationErrors.push({ message: `[${node.type}] ${msg}` }),
|
||||
);
|
||||
if (validated !== null) node.data = validated;
|
||||
}
|
||||
if (validationErrors.length > 0) {
|
||||
return { ok: false, stage: "validate", errors: validationErrors };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
workflow: {
|
||||
nodes,
|
||||
edges,
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
workflowName,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function unwrapAwait(expr: ts.Expression): ts.Expression {
|
||||
return ts.isAwaitExpression(expr) ? expr.expression : expr;
|
||||
}
|
||||
|
||||
function extractPositionArg(
|
||||
arg: ts.Expression | undefined,
|
||||
addError: (n: ts.Node, m: string) => void,
|
||||
): { x: number; y: number } | undefined {
|
||||
if (!arg) return undefined;
|
||||
const parsed = literalToJs(arg, addError);
|
||||
if (parsed === undefined || parsed === null) return undefined;
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
Array.isArray((parsed as Record<string, unknown>)["position"])
|
||||
) {
|
||||
const p = (parsed as Record<string, unknown>)["position"] as unknown[];
|
||||
if (p.length === 2 && typeof p[0] === "number" && typeof p[1] === "number") {
|
||||
return { x: p[0], y: p[1] };
|
||||
}
|
||||
}
|
||||
addError(arg, "Optional second arg must be `{ position: [x, y] }`.");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Convert an expression to a plain JS value. Accepts: string, number,
|
||||
// boolean, null, undefined (→ undefined), array/object literals of the
|
||||
// same. Rejects any expression with runtime semantics (identifiers other
|
||||
// than `true/false/null/undefined`, function calls, arrow fns, etc.).
|
||||
function literalToJs(
|
||||
expr: ts.Expression,
|
||||
addError: (n: ts.Node, m: string) => void,
|
||||
): unknown {
|
||||
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
|
||||
return expr.text;
|
||||
}
|
||||
if (ts.isNumericLiteral(expr)) return Number(expr.text);
|
||||
if (expr.kind === ts.SyntaxKind.TrueKeyword) return true;
|
||||
if (expr.kind === ts.SyntaxKind.FalseKeyword) return false;
|
||||
if (expr.kind === ts.SyntaxKind.NullKeyword) return null;
|
||||
if (ts.isIdentifier(expr) && expr.text === "undefined") return undefined;
|
||||
if (ts.isPrefixUnaryExpression(expr)) {
|
||||
if (expr.operator === ts.SyntaxKind.MinusToken) {
|
||||
const inner = literalToJs(expr.operand, addError);
|
||||
if (typeof inner === "number") return -inner;
|
||||
}
|
||||
if (expr.operator === ts.SyntaxKind.PlusToken) {
|
||||
const inner = literalToJs(expr.operand, addError);
|
||||
if (typeof inner === "number") return inner;
|
||||
}
|
||||
addError(expr, "Unsupported unary operator; only numeric negation is allowed.");
|
||||
return undefined;
|
||||
}
|
||||
if (ts.isArrayLiteralExpression(expr)) {
|
||||
const out: unknown[] = [];
|
||||
for (const el of expr.elements) {
|
||||
if (el.kind === ts.SyntaxKind.OmittedExpression) {
|
||||
addError(el, "Sparse arrays are not allowed.");
|
||||
return undefined;
|
||||
}
|
||||
if (ts.isSpreadElement(el)) {
|
||||
addError(el, "Spread elements are not allowed in array literals.");
|
||||
return undefined;
|
||||
}
|
||||
const v = literalToJs(el, addError);
|
||||
if (v === undefined && el.kind !== ts.SyntaxKind.Identifier) {
|
||||
return undefined;
|
||||
}
|
||||
out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (ts.isObjectLiteralExpression(expr)) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const prop of expr.properties) {
|
||||
if (!ts.isPropertyAssignment(prop)) {
|
||||
addError(prop, "Only plain `key: value` properties are allowed (no methods, shorthand, spread, or computed keys).");
|
||||
return undefined;
|
||||
}
|
||||
let key: string;
|
||||
if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) {
|
||||
key = prop.name.text;
|
||||
} else {
|
||||
addError(prop.name, "Property keys must be identifiers or string literals.");
|
||||
return undefined;
|
||||
}
|
||||
const val = literalToJs(prop.initializer, addError);
|
||||
if (val === undefined && prop.initializer.kind !== ts.SyntaxKind.Identifier) {
|
||||
// treat explicit `undefined` as omission
|
||||
continue;
|
||||
}
|
||||
out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (ts.isTemplateExpression(expr)) {
|
||||
addError(expr, "Template literals with interpolation are not allowed — use plain strings.");
|
||||
return undefined;
|
||||
}
|
||||
addError(expr, `Unsupported expression (${ts.SyntaxKind[expr.kind]}). Only literals are allowed in data positions.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Spec-driven validation, mirrors the shape of
|
||||
// `sdk/python/src/dograh_sdk/_validation.py` but lightweight — applies
|
||||
// defaults for missing optionals, catches unknown keys, enforces `options`
|
||||
// membership, and type-shapes the scalar and `fixed_collection` cases.
|
||||
function validateNodeData(
|
||||
spec: NodeSpec,
|
||||
data: Record<string, unknown>,
|
||||
addError: (message: string) => void,
|
||||
): Record<string, unknown> | null {
|
||||
const out: Record<string, unknown> = {};
|
||||
const known = new Map<string, PropertySpec>();
|
||||
for (const p of spec.properties ?? []) known.set(p.name, p);
|
||||
|
||||
for (const key of Object.keys(data)) {
|
||||
if (!known.has(key)) {
|
||||
addError(`Unknown field: \`${key}\`.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, prop] of known) {
|
||||
if (key in data) {
|
||||
out[key] = data[key];
|
||||
} else if (prop.default !== undefined && prop.default !== null) {
|
||||
out[key] = prop.default;
|
||||
} else if (prop.required) {
|
||||
addError(`Missing required field: \`${key}\`.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, prop] of known) {
|
||||
if (!(key in out)) continue;
|
||||
const value = out[key];
|
||||
const err = checkPropertyShape(prop, value);
|
||||
if (err) {
|
||||
addError(`Field \`${key}\`: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkPropertyShape(prop: PropertySpec, value: unknown): string | null {
|
||||
switch (prop.type) {
|
||||
case "string":
|
||||
case "mention_textarea":
|
||||
case "url":
|
||||
case "recording_ref":
|
||||
case "credential_ref":
|
||||
if (typeof value !== "string") return `expected string, got ${jsTypeOf(value)}.`;
|
||||
return null;
|
||||
case "number":
|
||||
if (typeof value !== "number") return `expected number, got ${jsTypeOf(value)}.`;
|
||||
return null;
|
||||
case "boolean":
|
||||
if (typeof value !== "boolean") return `expected boolean, got ${jsTypeOf(value)}.`;
|
||||
return null;
|
||||
case "tool_refs":
|
||||
case "document_refs":
|
||||
case "multi_options":
|
||||
if (!Array.isArray(value)) return `expected array, got ${jsTypeOf(value)}.`;
|
||||
for (const el of value) {
|
||||
if (prop.type === "multi_options") {
|
||||
if (!isInOptions(prop, el)) {
|
||||
return `value \`${JSON.stringify(el)}\` is not in the allowed options.`;
|
||||
}
|
||||
} else if (typeof el !== "string") {
|
||||
return `array elements must be strings.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case "options":
|
||||
if (!isInOptions(prop, value)) {
|
||||
return `value \`${JSON.stringify(value)}\` is not in the allowed options.`;
|
||||
}
|
||||
return null;
|
||||
case "json":
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return `expected JSON object, got ${jsTypeOf(value)}.`;
|
||||
}
|
||||
return null;
|
||||
case "fixed_collection":
|
||||
if (!Array.isArray(value)) return `expected array of rows, got ${jsTypeOf(value)}.`;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const row = value[i];
|
||||
if (typeof row !== "object" || row === null || Array.isArray(row)) {
|
||||
return `row ${i}: expected object, got ${jsTypeOf(row)}.`;
|
||||
}
|
||||
for (const sub of prop.properties ?? []) {
|
||||
const subVal = (row as Record<string, unknown>)[sub.name];
|
||||
if (subVal === undefined) {
|
||||
if (sub.required && (sub.default === undefined || sub.default === null)) {
|
||||
return `row ${i}: missing required field \`${sub.name}\`.`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const subErr = checkPropertyShape(sub, subVal);
|
||||
if (subErr) return `row ${i}, \`${sub.name}\`: ${subErr}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null; // Unknown types pass — forward compat.
|
||||
}
|
||||
}
|
||||
|
||||
function isInOptions(prop: PropertySpec, value: unknown): boolean {
|
||||
if (!prop.options) return true;
|
||||
return prop.options.some((o) => o.value === value);
|
||||
}
|
||||
|
||||
function jsTypeOf(v: unknown): string {
|
||||
if (v === null) return "null";
|
||||
if (Array.isArray(v)) return "array";
|
||||
return typeof v;
|
||||
}
|
||||
57
api/mcp_server/ts_validator/src/types.ts
Normal file
57
api/mcp_server/ts_validator/src/types.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Shared shapes used by both generate and parse. Mirror the `ReactFlowDTO`
|
||||
// wire format on the Python side (`api/services/workflow/dto.py`) and the
|
||||
// node-spec JSON served by `/api/v1/node-types` / dumped by
|
||||
// `python -m api.services.workflow.node_specs`.
|
||||
|
||||
export interface PropertyOption {
|
||||
value: string | number | boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PropertySpec {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
options?: PropertyOption[];
|
||||
properties?: PropertySpec[];
|
||||
}
|
||||
|
||||
export interface NodeSpec {
|
||||
name: string;
|
||||
properties: PropertySpec[];
|
||||
}
|
||||
|
||||
export interface WireNode {
|
||||
id: string;
|
||||
type: string;
|
||||
position: { x: number; y: number };
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WireEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WireWorkflow {
|
||||
nodes: WireNode[];
|
||||
edges: WireEdge[];
|
||||
viewport: { x: number; y: number; zoom: number };
|
||||
}
|
||||
|
||||
export interface ParseErrorItem {
|
||||
message: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
export type GenerateResult =
|
||||
| { ok: true; code: string }
|
||||
| { ok: false; errors: ParseErrorItem[] };
|
||||
|
||||
export type ParseResult =
|
||||
| { ok: true; workflow: WireWorkflow; workflowName: string }
|
||||
| { ok: false; stage: "parse" | "validate"; errors: ParseErrorItem[] };
|
||||
14
api/mcp_server/ts_validator/tsconfig.json
Normal file
14
api/mcp_server/ts_validator/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue