flowforge-map 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowforge/__init__.py +0 -0
- flowforge/_classic.html +289 -0
- flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
- flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
- flowforge/_dashboard/favicon.svg +1 -0
- flowforge/_dashboard/index.html +16 -0
- flowforge/_example/trip-planner/.env.example +3 -0
- flowforge/_example/trip-planner/.gitignore +2 -0
- flowforge/_example/trip-planner/.mcp.json +20 -0
- flowforge/_example/trip-planner/README.md +48 -0
- flowforge/_example/trip-planner/config.py +27 -0
- flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
- flowforge/_example/trip-planner/planner.py +113 -0
- flowforge/_example/trip-planner/requirements.txt +3 -0
- flowforge/_example/trip-planner/services/__init__.py +0 -0
- flowforge/_example/trip-planner/services/geo.py +49 -0
- flowforge/_example/trip-planner/services/llm.py +18 -0
- flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
- flowforge/_example/trip-planner/services/weather.py +25 -0
- flowforge/_example/trip-planner/tools/packing_list.py +24 -0
- flowforge/_example/trip-planner/trips/.gitkeep +0 -0
- flowforge/_workflows/diamond_mock.json +71 -0
- flowforge/_workflows/stripe_to_razorpay.json +38 -0
- flowforge/agent/__init__.py +5 -0
- flowforge/agent/server.py +333 -0
- flowforge/api/__init__.py +0 -0
- flowforge/api/agent.py +79 -0
- flowforge/api/checks.py +255 -0
- flowforge/api/connectors.py +289 -0
- flowforge/api/flowmap.py +174 -0
- flowforge/api/runs.py +284 -0
- flowforge/api/scan.py +117 -0
- flowforge/api/security.py +31 -0
- flowforge/api/stats.py +80 -0
- flowforge/checks/__init__.py +13 -0
- flowforge/checks/models.py +41 -0
- flowforge/checks/probes.py +359 -0
- flowforge/cli.py +108 -0
- flowforge/connectors/__init__.py +0 -0
- flowforge/connectors/catalog.py +84 -0
- flowforge/connectors/models.py +149 -0
- flowforge/connectors/presets.py +192 -0
- flowforge/connectors/registry.py +85 -0
- flowforge/connectors/runtime.py +47 -0
- flowforge/connectors/secrets.py +53 -0
- flowforge/example_data.py +247 -0
- flowforge/flow/__init__.py +328 -0
- flowforge/flow/advice.py +132 -0
- flowforge/flow/curly.py +884 -0
- flowforge/flow/index.py +116 -0
- flowforge/flow/order.py +181 -0
- flowforge/flow/reach.py +145 -0
- flowforge/flow/rewrite.py +146 -0
- flowforge/flowmap/__init__.py +1 -0
- flowforge/flowmap/diff.py +145 -0
- flowforge/flowmap/fixture.py +283 -0
- flowforge/flowmap/models.py +258 -0
- flowforge/flowmap/planner.py +131 -0
- flowforge/flowmap/service.py +379 -0
- flowforge/flowmap/validate.py +228 -0
- flowforge/home.py +58 -0
- flowforge/main.py +137 -0
- flowforge/nodes/__init__.py +3 -0
- flowforge/nodes/base.py +74 -0
- flowforge/nodes/check_node.py +50 -0
- flowforge/nodes/http_node.py +161 -0
- flowforge/nodes/llm_node.py +158 -0
- flowforge/nodes/local_node.py +164 -0
- flowforge/nodes/mcp_node.py +243 -0
- flowforge/nodes/mock_node.py +40 -0
- flowforge/scan/__init__.py +6 -0
- flowforge/scan/facts.py +47 -0
- flowforge/scan/files.py +112 -0
- flowforge/scan/manifests.py +65 -0
- flowforge/scan/mcp_config.py +75 -0
- flowforge/scan/models.py +73 -0
- flowforge/scan/patterns.py +86 -0
- flowforge/scan/python_ast.py +246 -0
- flowforge/scan/scanner.py +344 -0
- flowforge/scan/services.py +58 -0
- flowforge/scan/urls.py +60 -0
- flowforge/scheduler/__init__.py +0 -0
- flowforge/scheduler/cache.py +76 -0
- flowforge/scheduler/critical_path.py +65 -0
- flowforge/scheduler/durations.py +35 -0
- flowforge/scheduler/executor.py +390 -0
- flowforge/scheduler/graph.py +108 -0
- flowforge/scheduler/rate_limit.py +36 -0
- flowforge/schema.py +81 -0
- flowforge/security/__init__.py +0 -0
- flowforge/security/finders.py +127 -0
- flowforge/security/findings.py +169 -0
- flowforge/security/logfilter.py +129 -0
- flowforge/spa.py +52 -0
- flowforge/state.py +73 -0
- flowforge/storage.py +481 -0
- flowforge/templating.py +60 -0
- flowforge_map-0.1.0.dist-info/METADATA +335 -0
- flowforge_map-0.1.0.dist-info/RECORD +113 -0
- flowforge_map-0.1.0.dist-info/WHEEL +4 -0
- flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
- flowforge_map-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Local command step (D13). The connector fixes the program and the folder; the step adds arguments.
|
|
2
|
+
|
|
3
|
+
params: args (list[str], appended to the connector's command), stdin? (str),
|
|
4
|
+
subdir? (str, must stay inside the connector's cwd), ok_exit_codes? (default [0])
|
|
5
|
+
output: {"kind": "text", "stdout": str, "stderr": str, "exit_code": int}
|
|
6
|
+
|
|
7
|
+
Safety rules, all enforced here:
|
|
8
|
+
- no shell: the program starts from an argv list; step values are separate arguments,
|
|
9
|
+
never pasted into a command string. .bat/.cmd are refused, because Windows runs them
|
|
10
|
+
through cmd.exe, which would bring shell parsing back
|
|
11
|
+
- subdir is resolved (`..`, symlinks) and must stay inside cwd
|
|
12
|
+
- the environment is explicit: connector env + resolved env_refs + the minimum the OS
|
|
13
|
+
needs to start a program; nothing else is inherited
|
|
14
|
+
- on timeout or cancellation (the executor's wait_for) the process is killed and reaped
|
|
15
|
+
- stdout/stderr are capped at max_output_bytes; the rest is read and discarded
|
|
16
|
+
An exit code outside ok_exit_codes is a permanent error. Never cached across runs by default.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from flowforge.connectors.models import LocalConnector
|
|
29
|
+
from flowforge.connectors.secrets import SecretRefError, resolve_env
|
|
30
|
+
from flowforge.nodes.base import Node, NodeError
|
|
31
|
+
|
|
32
|
+
# What a process needs to start at all; everything else must be configured explicitly.
|
|
33
|
+
_OS_ENV = ("PATH", "SYSTEMROOT", "TEMP", "TMP") if sys.platform == "win32" else ("PATH",)
|
|
34
|
+
_SHELL_SCRIPTS = {".bat", ".cmd"}
|
|
35
|
+
_CHUNK = 64 * 1024
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LocalNode(Node):
|
|
39
|
+
type = "local"
|
|
40
|
+
|
|
41
|
+
def __init__(self, connector: LocalConnector) -> None:
|
|
42
|
+
self.connector = connector
|
|
43
|
+
self.connector_id = connector.id
|
|
44
|
+
self.rate_limit_key = connector.id
|
|
45
|
+
self.fallback = connector.fallback
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_connector(cls, connector: LocalConnector) -> LocalNode:
|
|
49
|
+
return cls(connector)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def _label(self) -> str:
|
|
53
|
+
return f"local '{self.connector.id}'"
|
|
54
|
+
|
|
55
|
+
def _env(self) -> dict[str, str]:
|
|
56
|
+
conn = self.connector.connection
|
|
57
|
+
env = {name: os.environ[name] for name in _OS_ENV if name in os.environ}
|
|
58
|
+
try:
|
|
59
|
+
env.update(resolve_env(conn.env, conn.env_refs))
|
|
60
|
+
except SecretRefError as exc:
|
|
61
|
+
raise NodeError(f"{self._label}: {exc}") from exc
|
|
62
|
+
return env
|
|
63
|
+
|
|
64
|
+
def _workdir(self, subdir: Any) -> Path:
|
|
65
|
+
base = Path(self.connector.connection.cwd).resolve()
|
|
66
|
+
if not base.is_dir():
|
|
67
|
+
raise NodeError(f"{self._label}: working folder {base} does not exist")
|
|
68
|
+
if subdir is None:
|
|
69
|
+
return base
|
|
70
|
+
if not isinstance(subdir, str):
|
|
71
|
+
raise NodeError(f"{self._label}: params.subdir must be a string")
|
|
72
|
+
# reject absolute or drive paths of either platform before joining
|
|
73
|
+
win, posix = PureWindowsPath(subdir), PurePosixPath(subdir)
|
|
74
|
+
if win.is_absolute() or win.drive or win.root or posix.is_absolute():
|
|
75
|
+
raise NodeError(f"{self._label}: subdir '{subdir}' is outside the connector's folder")
|
|
76
|
+
target = (base / subdir).resolve()
|
|
77
|
+
if not target.is_relative_to(base):
|
|
78
|
+
raise NodeError(f"{self._label}: subdir '{subdir}' is outside the connector's folder")
|
|
79
|
+
if not target.is_dir():
|
|
80
|
+
raise NodeError(f"{self._label}: subdir '{subdir}' does not exist")
|
|
81
|
+
return target
|
|
82
|
+
|
|
83
|
+
def _program(self, env: dict[str, str]) -> str:
|
|
84
|
+
name = self.connector.connection.command[0]
|
|
85
|
+
if os.path.dirname(name):
|
|
86
|
+
program = name if Path(name).is_file() else None
|
|
87
|
+
else:
|
|
88
|
+
program = shutil.which(name, path=env.get("PATH", ""))
|
|
89
|
+
if program is None:
|
|
90
|
+
raise NodeError(f"{self._label}: program '{name}' not found")
|
|
91
|
+
if Path(program).suffix.lower() in _SHELL_SCRIPTS:
|
|
92
|
+
raise NodeError(f"{self._label}: '{name}' is a batch script, which Windows runs through cmd.exe "
|
|
93
|
+
"(shell parsing); point the connector at the real program instead")
|
|
94
|
+
return program
|
|
95
|
+
|
|
96
|
+
async def run(self, params: dict[str, Any]) -> Any:
|
|
97
|
+
args = params.get("args", [])
|
|
98
|
+
if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
|
|
99
|
+
raise NodeError(f"{self._label}: params.args must be a list of strings")
|
|
100
|
+
stdin = params.get("stdin")
|
|
101
|
+
if stdin is not None and not isinstance(stdin, str):
|
|
102
|
+
raise NodeError(f"{self._label}: params.stdin must be a string")
|
|
103
|
+
ok_codes = params.get("ok_exit_codes", [0])
|
|
104
|
+
cwd = self._workdir(params.get("subdir"))
|
|
105
|
+
env = self._env()
|
|
106
|
+
program = self._program(env)
|
|
107
|
+
cap = self.connector.connection.max_output_bytes
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
proc = await asyncio.create_subprocess_exec(
|
|
111
|
+
program, *self.connector.connection.command[1:], *args,
|
|
112
|
+
cwd=cwd, env=env,
|
|
113
|
+
stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL,
|
|
114
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
115
|
+
)
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
raise NodeError(f"{self._label}: could not start '{program}': {exc}") from exc
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
(out, out_cut), (err, err_cut), _ = await asyncio.gather(
|
|
121
|
+
_read_capped(proc.stdout, cap), _read_capped(proc.stderr, cap), _feed(proc, stdin)
|
|
122
|
+
)
|
|
123
|
+
code = await proc.wait()
|
|
124
|
+
except BaseException: # timeout/cancellation from the executor, or anything else
|
|
125
|
+
if proc.returncode is None:
|
|
126
|
+
proc.kill()
|
|
127
|
+
await proc.wait()
|
|
128
|
+
raise
|
|
129
|
+
|
|
130
|
+
stdout, stderr = _text(out, out_cut, cap), _text(err, err_cut, cap)
|
|
131
|
+
if code not in ok_codes:
|
|
132
|
+
raise NodeError(f"{self._label}: exit code {code}: {stderr[-500:].strip()}")
|
|
133
|
+
return {"kind": "text", "stdout": stdout, "stderr": stderr, "exit_code": code}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _read_capped(stream: asyncio.StreamReader | None, cap: int) -> tuple[bytes, bool]:
|
|
137
|
+
"""Keep the first `cap` bytes; keep reading (and discarding) so the child never blocks."""
|
|
138
|
+
kept = bytearray()
|
|
139
|
+
truncated = False
|
|
140
|
+
if stream is None:
|
|
141
|
+
return b"", False
|
|
142
|
+
while chunk := await stream.read(_CHUNK):
|
|
143
|
+
room = cap - len(kept)
|
|
144
|
+
if len(chunk) > room:
|
|
145
|
+
truncated = True
|
|
146
|
+
if room > 0:
|
|
147
|
+
kept += chunk[:room]
|
|
148
|
+
return bytes(kept), truncated
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def _feed(proc: asyncio.subprocess.Process, data: str | None) -> None:
|
|
152
|
+
if data is None or proc.stdin is None:
|
|
153
|
+
return
|
|
154
|
+
try:
|
|
155
|
+
proc.stdin.write(data.encode())
|
|
156
|
+
await proc.stdin.drain()
|
|
157
|
+
proc.stdin.close()
|
|
158
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
159
|
+
pass # the program exited without reading all of it
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _text(data: bytes, truncated: bool, cap: int) -> str:
|
|
163
|
+
text = data.decode("utf-8", errors="replace")
|
|
164
|
+
return text + f"\n[output truncated at {cap} bytes]" if truncated else text
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""MCP tool call with the official `mcp` SDK (D9, D12).
|
|
2
|
+
|
|
3
|
+
params: server — a command list for a stdio server (default ["uvx", "mcp-server-fetch"])
|
|
4
|
+
or the name of a server registered with MCPNode(servers={...});
|
|
5
|
+
not allowed on a connector node, whose connector fixes the server
|
|
6
|
+
tool — e.g. "fetch"
|
|
7
|
+
arguments (dict)
|
|
8
|
+
output: {"content": str (text blocks joined), "structured": dict | None,
|
|
9
|
+
"blocks": [...] — only when the result has non-text blocks (D12)}
|
|
10
|
+
|
|
11
|
+
One connection per server is opened on first use and reused for the whole run
|
|
12
|
+
(spawning a process per call is slow). Its tool list is discovered once and every call
|
|
13
|
+
is checked against it — unknown tool, or arguments that fail the tool's JSON Schema —
|
|
14
|
+
before anything is sent (D12). A tool that reports is_error fails the step.
|
|
15
|
+
Not cached across runs unless the step sets cache: true (tools can have side effects).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import base64
|
|
22
|
+
import hashlib
|
|
23
|
+
import mimetypes
|
|
24
|
+
import re
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import jsonschema
|
|
28
|
+
from mcp import Client, MCPError, StdioServerParameters
|
|
29
|
+
|
|
30
|
+
from flowforge.connectors.models import MCPConnector
|
|
31
|
+
from flowforge.connectors.secrets import SecretRefError, resolve_env
|
|
32
|
+
from flowforge.home import flowforge_home
|
|
33
|
+
from flowforge.nodes.base import Node, NodeError, TransientNodeError, current_run_id
|
|
34
|
+
|
|
35
|
+
DEFAULT_SERVER = ["uvx", "mcp-server-fetch"]
|
|
36
|
+
INLINE_LIMIT_BYTES = 64 * 1024 # larger binary blocks go to an artifact file (D12)
|
|
37
|
+
_RUN_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Connection:
|
|
41
|
+
"""Owns one MCP client inside a dedicated task.
|
|
42
|
+
|
|
43
|
+
anyio cancel scopes must be entered and exited by the same task, so the client's
|
|
44
|
+
context manager lives in `_own()` rather than in whichever step happened to open it.
|
|
45
|
+
Steps call tools on the shared client from their own tasks.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, server: Any) -> None:
|
|
49
|
+
self.server = server
|
|
50
|
+
self.client: Client | None = None
|
|
51
|
+
self.tools: list[dict[str, Any]] | None = None # discovered once per connection
|
|
52
|
+
self._ready = asyncio.Event()
|
|
53
|
+
self._closing = asyncio.Event()
|
|
54
|
+
self._error: BaseException | None = None
|
|
55
|
+
self._task = asyncio.create_task(self._own())
|
|
56
|
+
|
|
57
|
+
async def _own(self) -> None:
|
|
58
|
+
try:
|
|
59
|
+
# stdio servers like mcp-server-fetch predate `server/discover`; skip the probe
|
|
60
|
+
mode = "legacy" if isinstance(self.server, StdioServerParameters) else "auto"
|
|
61
|
+
async with Client(self.server, cache=None, mode=mode) as client:
|
|
62
|
+
self.client = client
|
|
63
|
+
self._ready.set()
|
|
64
|
+
await self._closing.wait()
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
self._error = exc
|
|
67
|
+
finally:
|
|
68
|
+
self.client = None
|
|
69
|
+
self._ready.set()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def alive(self) -> bool:
|
|
73
|
+
return not self._task.done()
|
|
74
|
+
|
|
75
|
+
async def wait_ready(self) -> Client:
|
|
76
|
+
await self._ready.wait()
|
|
77
|
+
if self.client is None:
|
|
78
|
+
raise TransientNodeError(f"could not connect to MCP server: {self._error!r}")
|
|
79
|
+
return self.client
|
|
80
|
+
|
|
81
|
+
async def close(self) -> None:
|
|
82
|
+
self._closing.set()
|
|
83
|
+
await self._task
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_call(tools: list[dict[str, Any]], tool: str, arguments: Any) -> str | None:
|
|
87
|
+
"""Why calling `tool` with `arguments` would be wrong, or None if it's fine (D12).
|
|
88
|
+
`arguments=None` checks the tool name only (used when arguments hold templates)."""
|
|
89
|
+
by_name = {t["name"]: t for t in tools}
|
|
90
|
+
if tool not in by_name:
|
|
91
|
+
return f"Unknown tool '{tool}'; this server has: {', '.join(sorted(by_name)) or 'no tools'}"
|
|
92
|
+
if arguments is None:
|
|
93
|
+
return None
|
|
94
|
+
schema = by_name[tool].get("input_schema") or {}
|
|
95
|
+
errors = sorted(jsonschema.Draft202012Validator(schema).iter_errors(arguments), key=lambda e: list(e.path))
|
|
96
|
+
if errors:
|
|
97
|
+
first = errors[0]
|
|
98
|
+
where = "/".join(map(str, first.path))
|
|
99
|
+
at = f" (at '{where}')" if where else ""
|
|
100
|
+
return f"arguments for tool '{tool}' don't match its schema: {first.message}{at}"
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MCPNode(Node):
|
|
105
|
+
type = "mcp"
|
|
106
|
+
|
|
107
|
+
def __init__(self, servers: dict[str, Any] | None = None, default_server: Any = None) -> None:
|
|
108
|
+
self.servers = servers or {}
|
|
109
|
+
self.default_server = default_server # used when params.server is absent; None = DEFAULT_SERVER
|
|
110
|
+
self.connector: MCPConnector | None = None
|
|
111
|
+
self._connections: dict[Any, _Connection] = {}
|
|
112
|
+
self._lock = asyncio.Lock()
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_connector(cls, connector: MCPConnector) -> MCPNode:
|
|
116
|
+
node = cls()
|
|
117
|
+
node.connector = connector
|
|
118
|
+
node.connector_id = connector.id
|
|
119
|
+
node.fallback = connector.fallback
|
|
120
|
+
node.rate_limit_key = connector.id
|
|
121
|
+
return node
|
|
122
|
+
|
|
123
|
+
def _connector_server(self) -> StdioServerParameters:
|
|
124
|
+
"""Server parameters for the connector; secret env values are resolved here, at spawn (D12)."""
|
|
125
|
+
assert self.connector is not None
|
|
126
|
+
conn = self.connector.connection
|
|
127
|
+
try:
|
|
128
|
+
env = resolve_env(conn.env, conn.env_refs)
|
|
129
|
+
except SecretRefError as exc:
|
|
130
|
+
raise NodeError(f"MCP connector '{self.connector.id}': {exc}") from exc
|
|
131
|
+
return StdioServerParameters(command=conn.command, args=list(conn.args), env=env or None)
|
|
132
|
+
|
|
133
|
+
def _resolve_server(self, spec: Any) -> tuple[Any, Any]:
|
|
134
|
+
"""(connection key, what to pass to mcp.Client)."""
|
|
135
|
+
if self.connector is not None:
|
|
136
|
+
if spec is not None:
|
|
137
|
+
raise NodeError(f"connector '{self.connector.id}' fixes the server; remove params.server")
|
|
138
|
+
return ("connector", self.connector.id), self._connector_server()
|
|
139
|
+
if spec is None:
|
|
140
|
+
if self.default_server is None:
|
|
141
|
+
spec = DEFAULT_SERVER
|
|
142
|
+
elif isinstance(self.default_server, list):
|
|
143
|
+
spec = self.default_server
|
|
144
|
+
else:
|
|
145
|
+
return "__default__", self.default_server
|
|
146
|
+
if isinstance(spec, str):
|
|
147
|
+
if spec not in self.servers:
|
|
148
|
+
raise NodeError(f"unknown MCP server '{spec}'")
|
|
149
|
+
return spec, self.servers[spec]
|
|
150
|
+
if not spec or not all(isinstance(part, str) for part in spec):
|
|
151
|
+
raise NodeError("params.server must be a server name or a command list")
|
|
152
|
+
return tuple(spec), StdioServerParameters(command=spec[0], args=list(spec[1:]))
|
|
153
|
+
|
|
154
|
+
async def _connection(self, spec: Any) -> tuple[_Connection, Client]:
|
|
155
|
+
key, server = self._resolve_server(spec)
|
|
156
|
+
async with self._lock:
|
|
157
|
+
conn = self._connections.get(key)
|
|
158
|
+
if conn is None or not conn.alive:
|
|
159
|
+
conn = self._connections[key] = _Connection(server)
|
|
160
|
+
return conn, await conn.wait_ready()
|
|
161
|
+
|
|
162
|
+
async def aclose(self) -> None:
|
|
163
|
+
for conn in self._connections.values():
|
|
164
|
+
await conn.close()
|
|
165
|
+
self._connections.clear()
|
|
166
|
+
|
|
167
|
+
async def list_tools(self, server: Any = None) -> list[dict[str, Any]]:
|
|
168
|
+
"""Every tool the server offers: name, description, JSON input schema (D12)."""
|
|
169
|
+
conn, client = await self._connection(server)
|
|
170
|
+
if conn.tools is None:
|
|
171
|
+
tools: list[dict[str, Any]] = []
|
|
172
|
+
cursor: str | None = None
|
|
173
|
+
try:
|
|
174
|
+
while True:
|
|
175
|
+
page = await client.list_tools(cursor=cursor)
|
|
176
|
+
tools += [{"name": t.name, "description": t.description or "", "input_schema": t.input_schema}
|
|
177
|
+
for t in page.tools]
|
|
178
|
+
if not (cursor := page.next_cursor):
|
|
179
|
+
break
|
|
180
|
+
except MCPError as exc:
|
|
181
|
+
raise NodeError(f"MCP error listing tools: {exc}") from exc
|
|
182
|
+
except (OSError, EOFError) as exc:
|
|
183
|
+
raise TransientNodeError(f"MCP connection lost: {exc}") from exc
|
|
184
|
+
conn.tools = tools
|
|
185
|
+
return conn.tools
|
|
186
|
+
|
|
187
|
+
async def run(self, params: dict[str, Any]) -> Any:
|
|
188
|
+
if "tool" not in params:
|
|
189
|
+
raise NodeError("mcp step needs params.tool")
|
|
190
|
+
server = params.get("server")
|
|
191
|
+
arguments = params.get("arguments", {})
|
|
192
|
+
problem = check_call(await self.list_tools(server), params["tool"], arguments)
|
|
193
|
+
if problem:
|
|
194
|
+
raise NodeError(problem)
|
|
195
|
+
_, client = await self._connection(server)
|
|
196
|
+
try:
|
|
197
|
+
result = await client.call_tool(params["tool"], arguments)
|
|
198
|
+
except MCPError as exc:
|
|
199
|
+
raise NodeError(f"MCP error: {exc}") from exc
|
|
200
|
+
except (OSError, EOFError) as exc:
|
|
201
|
+
raise TransientNodeError(f"MCP connection lost: {exc}") from exc
|
|
202
|
+
|
|
203
|
+
text = "\n".join(block.text for block in result.content if getattr(block, "type", None) == "text")
|
|
204
|
+
if result.is_error:
|
|
205
|
+
raise NodeError(f"MCP tool '{params['tool']}' failed: {text}")
|
|
206
|
+
output: dict[str, Any] = {"content": text, "structured": result.structured_content}
|
|
207
|
+
blocks = [b for b in map(_convert_block, result.content) if b is not None]
|
|
208
|
+
if blocks:
|
|
209
|
+
output["blocks"] = blocks
|
|
210
|
+
return output
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# --- non-text result blocks (D12) ----------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def _convert_block(block: Any) -> dict[str, Any] | None:
|
|
216
|
+
kind = getattr(block, "type", None)
|
|
217
|
+
if kind == "text":
|
|
218
|
+
return None
|
|
219
|
+
if kind in ("image", "audio"):
|
|
220
|
+
return _binary(kind, block.mime_type, base64.b64decode(block.data))
|
|
221
|
+
if kind == "resource_link":
|
|
222
|
+
return {"kind": "resource", "uri": str(block.uri), "name": block.name}
|
|
223
|
+
if kind == "resource":
|
|
224
|
+
res = block.resource
|
|
225
|
+
if getattr(res, "text", None) is not None:
|
|
226
|
+
return {"kind": "text", "uri": str(res.uri), "text": res.text}
|
|
227
|
+
mime = res.mime_type or "application/octet-stream"
|
|
228
|
+
major = mime.split("/", 1)[0]
|
|
229
|
+
return _binary(major if major in ("image", "audio", "video") else "file", mime, base64.b64decode(res.blob))
|
|
230
|
+
return {"kind": "unknown", "type": str(kind)}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _binary(kind: str, mime: str | None, data: bytes) -> dict[str, Any]:
|
|
234
|
+
mime = mime or "application/octet-stream"
|
|
235
|
+
if len(data) <= INLINE_LIMIT_BYTES:
|
|
236
|
+
return {"kind": kind, "mime": mime, "data_b64": base64.b64encode(data).decode()}
|
|
237
|
+
run_id = current_run_id.get()
|
|
238
|
+
folder = flowforge_home() / "artifacts" / (run_id if run_id and _RUN_ID.match(run_id) else "adhoc")
|
|
239
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
path = folder / f"{hashlib.sha256(data).hexdigest()}{mimetypes.guess_extension(mime) or '.bin'}"
|
|
241
|
+
if not path.exists():
|
|
242
|
+
path.write_bytes(data)
|
|
243
|
+
return {"kind": kind, "mime": mime, "path": str(path), "bytes": len(data)}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Sleep-based node for tests and the simulated benchmark track (D6). Never touches the network.
|
|
2
|
+
|
|
3
|
+
params:
|
|
4
|
+
duration_ms: how long to "work"
|
|
5
|
+
output: value to return (default: {"step_done": true})
|
|
6
|
+
fail: "transient" | "permanent" to simulate errors
|
|
7
|
+
fail_times: how many calls fail before succeeding (default: always)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from flowforge.nodes.base import Node, NodeError, TransientNodeError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MockNode(Node):
|
|
19
|
+
type = "mock"
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self.calls = 0
|
|
23
|
+
self._failures: dict[str, int] = {}
|
|
24
|
+
|
|
25
|
+
async def run(self, params: dict[str, Any]) -> Any:
|
|
26
|
+
self.calls += 1
|
|
27
|
+
await asyncio.sleep(params.get("duration_ms", 100) / 1000)
|
|
28
|
+
|
|
29
|
+
fail = params.get("fail")
|
|
30
|
+
if fail:
|
|
31
|
+
key = repr(sorted(params.items()))
|
|
32
|
+
done = self._failures.get(key, 0)
|
|
33
|
+
limit = params.get("fail_times")
|
|
34
|
+
if limit is None or done < limit:
|
|
35
|
+
self._failures[key] = done + 1
|
|
36
|
+
if fail == "transient":
|
|
37
|
+
raise TransientNodeError("simulated transient failure")
|
|
38
|
+
raise NodeError("simulated permanent failure")
|
|
39
|
+
|
|
40
|
+
return params.get("output", {"step_done": True})
|
flowforge/scan/facts.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""What one file shows, before the scanner merges files into dependencies (D21). Internal to `scan/`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class UrlHit:
|
|
10
|
+
"""A URL literal, or a constant's name, used where a URL goes. `confirmed`: a recognised call or key."""
|
|
11
|
+
|
|
12
|
+
line: int
|
|
13
|
+
url: str | None = None # scheme://host[:port]/path, query dropped; None while `name` is unresolved
|
|
14
|
+
name: str | None = None # a module-level constant holding the URL (resolved across files)
|
|
15
|
+
confirmed: bool = False
|
|
16
|
+
call_strings: tuple[str, ...] = () # the enclosing call's string arguments: call("fetch", ...) → via
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Ref:
|
|
21
|
+
"""A value given either as a literal or as the name of a constant (resolved across files)."""
|
|
22
|
+
|
|
23
|
+
literal: str | None = None
|
|
24
|
+
name: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class LLMClient:
|
|
29
|
+
line: int
|
|
30
|
+
sdk: str # the imported package the client class comes from
|
|
31
|
+
base_url: Ref = field(default_factory=Ref)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class FileFacts:
|
|
36
|
+
path: str
|
|
37
|
+
language: str
|
|
38
|
+
imports: list[tuple[str, int]] = field(default_factory=list) # (package, line)
|
|
39
|
+
urls: list[UrlHit] = field(default_factory=list)
|
|
40
|
+
clients: list[LLMClient] = field(default_factory=list)
|
|
41
|
+
models: list[tuple[Ref, int]] = field(default_factory=list) # model= values
|
|
42
|
+
env: list[tuple[str, int]] = field(default_factory=list) # env var names read, never values
|
|
43
|
+
commands: list[tuple[list[str], int]] = field(default_factory=list) # [program] or [interpreter, script]
|
|
44
|
+
stdio: list[tuple[list[str], int]] = field(default_factory=list) # MCP servers started from code: argv
|
|
45
|
+
constants: dict[str, tuple[str, int]] = field(default_factory=dict) # NAME -> (string value, line)
|
|
46
|
+
strings: set[str] = field(default_factory=set) # short string literals: does the code name ".mcp.json"?
|
|
47
|
+
call_strings: set[str] = field(default_factory=set) # string arguments of calls: does it name an MCP server?
|
flowforge/scan/files.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Which files the scan reads (D21, D22): code and known config files, never anything vendored, built or ignored."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from flowforge.scan.manifests import is_manifest
|
|
12
|
+
from flowforge.scan.mcp_config import is_config
|
|
13
|
+
|
|
14
|
+
# never walked into, and not counted: other people's code and build output
|
|
15
|
+
VENDOR_DIRS = frozenset({".git", "node_modules", ".venv", "venv", "build", "dist", ".dart_tool", "__pycache__",
|
|
16
|
+
"vendor"})
|
|
17
|
+
# skipped on purpose, and counted as "ignored": their URLs are fixtures, not calls (D21, D23)
|
|
18
|
+
TEST_DIRS = frozenset({"tests", "test", "__tests__", "fixtures", "mocks", "__mocks__", "fakes"})
|
|
19
|
+
TEST_FILE = re.compile(r"^(?:fake|mock|dummy)_|^test_.*\.py$|_test\.(?:py|dart)$|\.(?:test|spec)\.[^.]+$")
|
|
20
|
+
DOC_SUFFIXES = frozenset({".md", ".txt", ".rst"})
|
|
21
|
+
MAX_BYTES = 1_000_000
|
|
22
|
+
BINARY_PROBE = 8192
|
|
23
|
+
LANGUAGES = {".py": "python", ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
24
|
+
".ts": "typescript", ".tsx": "typescript", ".dart": "dart"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Listing:
|
|
29
|
+
files: list[str] = field(default_factory=list) # paths relative to the root, "/"-separated
|
|
30
|
+
skipped: dict[str, int] = field(default_factory=dict) # why -> how many: ignored, binary, too_big, outside…
|
|
31
|
+
|
|
32
|
+
def skip(self, why: str) -> None:
|
|
33
|
+
self.skipped[why] = self.skipped.get(why, 0) + 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def language(rel: str) -> str | None:
|
|
37
|
+
return LANGUAGES.get(Path(rel).suffix.lower())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def classify(rel: str) -> str | None:
|
|
41
|
+
""""read", "ignored" (code or docs skipped on purpose: tests, fake data, docs, your tools' hidden folders) or None
|
|
42
|
+
(not something the scan looks at, such as images or lock files).
|
|
43
|
+
|
|
44
|
+
Hidden folders (.claude, .cursor, .github …) hold your tools, not your app: only their MCP configs are read.
|
|
45
|
+
"""
|
|
46
|
+
path = Path(rel)
|
|
47
|
+
relevant = language(rel) is not None or is_config(rel) or is_manifest(rel)
|
|
48
|
+
if any(part.startswith(".") for part in path.parent.parts):
|
|
49
|
+
return "read" if is_config(rel) else "ignored" if relevant else None
|
|
50
|
+
if any(part in TEST_DIRS for part in path.parent.parts) or TEST_FILE.search(path.name):
|
|
51
|
+
return "ignored" if relevant else None
|
|
52
|
+
if path.suffix.lower() in DOC_SUFFIXES and not is_manifest(rel):
|
|
53
|
+
return "ignored"
|
|
54
|
+
return "read" if relevant else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _git_files(root: Path) -> list[str] | None:
|
|
58
|
+
"""Files git tracks or doesn't ignore, relative to `root`; None when `root` isn't in a git repo.
|
|
59
|
+
A local command only: `ls-files` never touches the network."""
|
|
60
|
+
try:
|
|
61
|
+
done = subprocess.run(["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
62
|
+
capture_output=True, timeout=20, check=False,
|
|
63
|
+
env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"})
|
|
64
|
+
except (OSError, subprocess.SubprocessError):
|
|
65
|
+
return None
|
|
66
|
+
if done.returncode != 0:
|
|
67
|
+
return None
|
|
68
|
+
return sorted({p for p in done.stdout.decode("utf-8", "replace").split("\0") if p})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _walk(root: Path) -> list[str]:
|
|
72
|
+
found = []
|
|
73
|
+
for folder, dirs, names in os.walk(root):
|
|
74
|
+
dirs[:] = sorted(d for d in dirs if d not in VENDOR_DIRS)
|
|
75
|
+
found += [(Path(folder) / name).relative_to(root).as_posix() for name in sorted(names)]
|
|
76
|
+
return found
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_binary(path: Path) -> bool:
|
|
80
|
+
with path.open("rb") as handle:
|
|
81
|
+
return b"\0" in handle.read(BINARY_PROBE)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def list_files(root: Path) -> Listing:
|
|
85
|
+
out = Listing()
|
|
86
|
+
root = root.resolve()
|
|
87
|
+
candidates = _git_files(root)
|
|
88
|
+
if candidates is None:
|
|
89
|
+
candidates = _walk(root)
|
|
90
|
+
for rel in candidates:
|
|
91
|
+
if any(part in VENDOR_DIRS for part in Path(rel).parent.parts):
|
|
92
|
+
continue
|
|
93
|
+
kind = classify(rel)
|
|
94
|
+
if kind is None:
|
|
95
|
+
continue
|
|
96
|
+
if kind == "ignored":
|
|
97
|
+
out.skip("ignored")
|
|
98
|
+
continue
|
|
99
|
+
path = root / rel
|
|
100
|
+
if path.is_symlink() and not path.resolve().is_relative_to(root):
|
|
101
|
+
out.skip("outside")
|
|
102
|
+
continue
|
|
103
|
+
if not path.is_file():
|
|
104
|
+
continue
|
|
105
|
+
if path.stat().st_size > MAX_BYTES:
|
|
106
|
+
out.skip("too_big")
|
|
107
|
+
continue
|
|
108
|
+
if _is_binary(path):
|
|
109
|
+
out.skip("binary")
|
|
110
|
+
continue
|
|
111
|
+
out.files.append(rel)
|
|
112
|
+
return out
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Installed packages from dependency files (D21): requirements*.txt, pyproject.toml, package.json, pubspec.yaml.
|
|
2
|
+
|
|
3
|
+
Only package names and their line are kept. An installed service SDK the code never imports becomes an
|
|
4
|
+
unconfirmed hint on the map.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import tomllib
|
|
12
|
+
from pathlib import PurePosixPath
|
|
13
|
+
|
|
14
|
+
_REQUIREMENT = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._\-]*)")
|
|
15
|
+
_PUBSPEC_DEP = re.compile(r"^ ([A-Za-z0-9_]+)\s*:")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_manifest(rel: str) -> bool:
|
|
19
|
+
name = PurePosixPath(rel).name
|
|
20
|
+
return name in ("pyproject.toml", "package.json", "pubspec.yaml") or \
|
|
21
|
+
(name.startswith("requirements") and name.endswith(".txt"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _line_of(lines: list[str], package: str) -> int:
|
|
25
|
+
pattern = re.compile(rf"""(^|[\s"'\[]){re.escape(package)}([\s"'=<>~!:\[;,]|$)""")
|
|
26
|
+
return next((i for i, raw in enumerate(lines, 1) if pattern.search(raw)), 1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse(rel: str, text: str) -> list[tuple[str, int]]:
|
|
30
|
+
"""(package, line) for every dependency the file lists."""
|
|
31
|
+
name = PurePosixPath(rel).name
|
|
32
|
+
lines = text.splitlines()
|
|
33
|
+
if name.endswith(".txt"):
|
|
34
|
+
out = []
|
|
35
|
+
for i, raw in enumerate(lines, 1):
|
|
36
|
+
raw = raw.split("#")[0]
|
|
37
|
+
if raw.strip().startswith("-"):
|
|
38
|
+
continue
|
|
39
|
+
if m := _REQUIREMENT.match(raw):
|
|
40
|
+
out.append((m.group(1), i))
|
|
41
|
+
return out
|
|
42
|
+
if name == "pubspec.yaml":
|
|
43
|
+
out, inside = [], False
|
|
44
|
+
for i, raw in enumerate(lines, 1):
|
|
45
|
+
if raw and not raw[0].isspace():
|
|
46
|
+
inside = raw.rstrip().rstrip(":") in ("dependencies", "dev_dependencies")
|
|
47
|
+
elif inside and (m := _PUBSPEC_DEP.match(raw)) and m.group(1) != "flutter":
|
|
48
|
+
out.append((m.group(1), i))
|
|
49
|
+
return out
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(text) if name == "package.json" else tomllib.loads(text)
|
|
52
|
+
except ValueError:
|
|
53
|
+
return []
|
|
54
|
+
names: list[str] = []
|
|
55
|
+
if name == "package.json":
|
|
56
|
+
for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
57
|
+
names += list(data.get(key, {}) or {})
|
|
58
|
+
else:
|
|
59
|
+
project = data.get("project", {})
|
|
60
|
+
specs = list(project.get("dependencies", []))
|
|
61
|
+
for group in project.get("optional-dependencies", {}).values():
|
|
62
|
+
specs += group
|
|
63
|
+
names += [m.group(1) for spec in specs if (m := _REQUIREMENT.match(spec))]
|
|
64
|
+
names += [n for n in data.get("tool", {}).get("poetry", {}).get("dependencies", {}) if n != "python"]
|
|
65
|
+
return [(n, _line_of(lines, n)) for n in dict.fromkeys(names)]
|