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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ The split is intentional:

- `duckflow/core.py`: extraction, filtering, stitching, and Mermaid rendering logic
- `duckflow/cli.py`: installable CLI entry points
- `duckflow/server.py`: MCP server exposing duckflow tools via FastMCP
- `scripts/extract_duckflow.py`: CLI for normalized JSON output
- `scripts/generate_duckflow_mermaid.py`: CLI for Mermaid flowchart output
- `tests/test_duckflow_tools.py`: parser and graph-behavior tests
- `tests/test_mcp_server.py`: MCP server tool tests
- `templates/agent-setup/`: agent instruction templates for Copilot, Codex, Claude Code, and Cline
- `install.sh`: interactive install script for all four AI tool clients
- `docs/skill-outline.md`: draft user-level skill outline
- `docs/prompt-structure.md`: draft `/duckflow` prompt structure

Expand Down Expand Up @@ -81,7 +85,7 @@ The extractor and Mermaid generator build edges from local facts only:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e .[dev]
pip install -e ".[dev,mcp]"
```

## Commands
Expand Down Expand Up @@ -119,5 +123,58 @@ python scripts/generate_duckflow_mermaid.py --repo-root . --match summary
Restrict scanning to explicit globs:

```bash
python scripts/extract_duckflow.py --repo-root . --include "src/**/*.py" --include "web/**/*.ts"
duckflow-extract --repo-root . --include "src/**/*.py" --include "web/**/*.ts"
```

## MCP server

`duckflow-mcp` exposes duckflow tools to AI agents over the Model Context
Protocol using [FastMCP](https://github.com/jlowin/fastmcp).

### Running the server directly

```bash
duckflow-mcp
```

or with `uvx` from this checkout:

```bash
uvx --from . duckflow-mcp
```

### Available tools

| Tool | Description |
|------|-------------|
| `duckflow_extract` | Extract annotations from a repository path |
| `duckflow_extract_text` | Extract annotations from source text already in the conversation |
| `duckflow_stitch` | Build a stitched call/data-flow graph for a repository |
| `duckflow_mermaid` | Render a Mermaid flowchart for a repository |
| `duckflow_mermaid_text` | Render a Mermaid flowchart from source text |

All tools accept an optional `match` argument to filter entries by substring.
`duckflow_extract` also accepts `stitched=true` to return a graph with nodes
and edges rather than a flat list.

### Agent setup

Run the interactive install script to wire `duckflow-mcp` into GitHub Copilot,
Codex, Claude Code, and/or Cline:

```bash
bash install.sh
```

The script prompts you to choose which clients to configure and whether to
install instructions at user level or inside a target project directory. It
writes the MCP server entry into each client's config file and merges the
duckflow workflow instructions into the appropriate agent instruction file
(`copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`, or `.clinerules`).

To install from the published GitHub URL instead of a local checkout, replace
the `--from` path in the generated config with:

```
git+https://github.com/warnes-innovations/duckflow
```
207 changes: 207 additions & 0 deletions duckflow/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
# Copyright (C) 2026 Gregory R. Warnes
# SPDX-License-Identifier: AGPL-3.0-or-later

"""Duckflow MCP Server — tools for data-flow annotation extraction and rendering.

Uses FastMCP for concise tool registration.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Optional

from mcp.server.fastmcp import FastMCP

from duckflow.core import (
extract_duckflow_entries,
extract_duckflow_entries_from_text,
filter_entries,
render_mermaid,
stitch_duckflow,
)

mcp = FastMCP(
"duckflow-mcp",
instructions=(
"Duckflow tools for extracting, stitching, and rendering "
"comment-based data-flow annotations."
),
)

_TOOL_EXCEPTIONS = (OSError, ValueError, json.JSONDecodeError)


# ---------------------------------------------------------------------------
# Tool: duckflow_extract
# ---------------------------------------------------------------------------


@mcp.tool()
def duckflow_extract(
repo_root: str,
include: Optional[list[str]] = None,
match: Optional[str] = None,
stitched: bool = False,
) -> str:
"""Extract duckflow annotations from source files beneath repo_root.

Args:
repo_root: Absolute path to the repository root to scan.
include: Optional list of glob patterns to restrict which source files
are scanned (e.g. ["src/**/*.py", "web/**/*.ts"]).
Defaults to all supported source-file extensions.
match: Optional substring filter applied across entry ids, kinds,
statuses, paths, notes, and tokens. Only matching entries
are returned.
stitched: When True, return a stitched graph object with "nodes" and
"edges" keys instead of a flat list of entries.
"""
try:
root = Path(repo_root)
if not root.exists():
return f"ERROR: repo_root does not exist: {repo_root}"
entries = extract_duckflow_entries(root, include=include)
entries = filter_entries(entries, match)
if stitched:
return json.dumps(stitch_duckflow(entries), indent=2)
return json.dumps([e.to_dict() for e in entries], indent=2)
except _TOOL_EXCEPTIONS as exc:
return f"ERROR: {exc}"


# ---------------------------------------------------------------------------
# Tool: duckflow_extract_text
# ---------------------------------------------------------------------------


@mcp.tool()
def duckflow_extract_text(
text: str,
path: str = "<inline>",
match: Optional[str] = None,
) -> str:
"""Extract duckflow annotations directly from a string of source text.

Useful when the source code is already loaded in the conversation rather
than residing on disk.

Args:
text: Source text to scan for duckflow annotations.
path: Logical file path to use in error messages and entry metadata
(default: "<inline>").
match: Optional substring filter.
"""
try:
entries = extract_duckflow_entries_from_text(text, Path(path))
entries = filter_entries(entries, match)
return json.dumps([e.to_dict() for e in entries], indent=2)
except _TOOL_EXCEPTIONS as exc:
return f"ERROR: {exc}"


# ---------------------------------------------------------------------------
# Tool: duckflow_stitch
# ---------------------------------------------------------------------------


@mcp.tool()
def duckflow_stitch(
repo_root: str,
include: Optional[list[str]] = None,
match: Optional[str] = None,
) -> str:
"""Build a stitched duckflow graph from all annotations in repo_root.

The graph contains "nodes" (all matching entries) and "edges" stitched
from call / data relationships between them.

Args:
repo_root: Absolute path to the repository root to scan.
include: Optional list of glob patterns (see duckflow_extract).
match: Optional substring filter applied before stitching.
"""
try:
root = Path(repo_root)
if not root.exists():
return f"ERROR: repo_root does not exist: {repo_root}"
entries = extract_duckflow_entries(root, include=include)
entries = filter_entries(entries, match)
return json.dumps(stitch_duckflow(entries), indent=2)
except _TOOL_EXCEPTIONS as exc:
return f"ERROR: {exc}"


# ---------------------------------------------------------------------------
# Tool: duckflow_mermaid
# ---------------------------------------------------------------------------


@mcp.tool()
def duckflow_mermaid(
repo_root: str,
include: Optional[list[str]] = None,
match: Optional[str] = None,
) -> str:
"""Render a Mermaid flowchart for all duckflow annotations in repo_root.

Returns the raw Mermaid markup as a string.

Args:
repo_root: Absolute path to the repository root to scan.
include: Optional list of glob patterns (see duckflow_extract).
match: Optional substring filter to restrict which entries appear
in the rendered diagram.
"""
try:
root = Path(repo_root)
if not root.exists():
return f"ERROR: repo_root does not exist: {repo_root}"
entries = extract_duckflow_entries(root, include=include)
entries = filter_entries(entries, match)
graph = stitch_duckflow(entries)
return render_mermaid(graph)
except _TOOL_EXCEPTIONS as exc:
return f"ERROR: {exc}"


# ---------------------------------------------------------------------------
# Tool: duckflow_mermaid_text
# ---------------------------------------------------------------------------


@mcp.tool()
def duckflow_mermaid_text(
text: str,
path: str = "<inline>",
match: Optional[str] = None,
) -> str:
"""Render a Mermaid flowchart directly from a string of source text.

Args:
text: Source text to scan for duckflow annotations.
path: Logical file path used in node labels (default: "<inline>").
match: Optional substring filter.
"""
try:
entries = extract_duckflow_entries_from_text(text, Path(path))
entries = filter_entries(entries, match)
graph = stitch_duckflow(entries)
return render_mermaid(graph)
except _TOOL_EXCEPTIONS as exc:
return f"ERROR: {exc}"


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------


def main() -> None:
mcp.run()


if __name__ == "__main__":
main()
Loading
Loading