feat(mcp): generic MCP tool source with per-node function filtering (#301)

* feat(mcp): generic MCP tool source with per-node function filtering

Adds a Model Context Protocol tool category: connect a customer MCP
server and expose its tools to the agent, with optional per-node
allow-listing of individual MCP functions.

- ToolCategory.MCP enum + alembic migration
- MCP definition validator and collision-safe function-name namespacing
- McpToolSession wrapper: graceful-degrade, per-call open/close lifecycle
- CustomToolManager MCP branch (schemas + proxy handlers)
- Per-node mcp_tool_filters threaded through DTO/graph/engine
- Best-effort discovered_tools catalog cache + POST /tools/{uuid}/mcp/refresh
- UI: MCP create/edit config, tabbed ToolSelector with per-node toggles

* feat: refactor for code standardisation and documentation

---------

Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
Paulo Busato Favarato 2026-05-19 07:40:00 -03:00 committed by GitHub
parent 0097974444
commit 75839f9de5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3028 additions and 137 deletions

View file

@ -205,6 +205,7 @@ function CanvasPreview({
<ToolBadges
toolUuids={data.tool_uuids}
onStaleUuidsDetected={onStaleTools}
mcpToolFilters={data.mcp_tool_filters}
/>
</div>
)}
@ -396,14 +397,22 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
const spec = bySpecName.get(type);
// ── Form state ─────────────────────────────────────────────────────
const [values, setValues] = useState<Record<string, unknown>>(() =>
spec ? seedValues(data, spec) : {},
// mcp_tool_filters is not a spec property, so seedValues won't carry it;
// seed merges it back in alongside the spec-derived values.
const seed = useCallback(
() =>
spec
? { ...seedValues(data, spec), mcp_tool_filters: data.mcp_tool_filters }
: {},
[data, spec],
);
const [values, setValues] = useState<Record<string, unknown>>(seed);
// Re-seed once the spec arrives (initial fetch race).
useEffect(() => {
if (spec && Object.keys(values).length === 0) {
setValues(seedValues(data, spec));
setValues(seed());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spec]);
@ -464,7 +473,11 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
const isDirty = useMemo(() => {
if (!spec) return false;
const baseline = seedValues(data, spec);
return propertyNames.some((n) => values[n] !== baseline[n]);
if (propertyNames.some((n) => values[n] !== baseline[n])) return true;
return (
JSON.stringify(values.mcp_tool_filters ?? {}) !==
JSON.stringify(data.mcp_tool_filters ?? {})
);
}, [values, data, spec, propertyNames]);
const handleSave = async () => {
@ -478,12 +491,12 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
};
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && spec) setValues(seedValues(data, spec));
if (newOpen && spec) setValues(seed());
setOpen(newOpen);
};
useEffect(() => {
if (open && spec) setValues(seedValues(data, spec));
if (open && spec) setValues(seed());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, open]);
@ -562,6 +575,18 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
tools: tools ?? [],
documents: documents ?? [],
recordings: recordings ?? [],
mcpToolFilters:
(values.mcp_tool_filters as
| Record<string, string[]>
| undefined) ?? {},
onMcpToolFiltersChange: (next) =>
setValues((prev) => ({
...prev,
mcp_tool_filters:
Object.keys(next).length > 0
? next
: undefined,
})),
}}
/>
{type === "trigger" && (