Skip to content

Commit f23cc14

Browse files
committed
Expose tool / workflow / blueprint YAML across MCP, UI, and CLI
The "Postman for agent tools" story works only when those tool, workflow, and blueprint definitions are inspectable from wherever you happen to be. Today an MCP agent saw a tool's name and description but had no way to read the YAML; a UI user had to drop down to the filesystem; a CLI user had to `cat .factorly/blueprints/*.yaml`. This adds one shared YAML serializer and exposes it through three surfaces. New shared serializer: - internal/configyaml/render.go — RenderTool(name, ToolConfig) emits the same `tools:\n <name>:\n ...` shape users edit on disk. Works for type: workflow too since workflows are tools. RenderBlueprint(cfgPath, name) returns the raw bytes of .factorly/blueprints/<name>.yaml so comments and key order are preserved. BlueprintsDir(cfgPath) is the shared dir-resolution helper (works whether cfgPath is inside .factorly or alongside it). safeName guards against path traversal. - internal/configyaml/render_test.go — round-trip CLI/REST/workflow configs through RenderTool → yaml.Unmarshal → reflect.DeepEqual; raw- bytes assertion for RenderBlueprint with a comment in the source; the traversal-rejection suite covers .., empty, dot, separators. MCP resources (mark3labs/mcp-go v0.52.0): - internal/server/mcp.go — New() now takes (reg, p, cfg, cfgPath, agentReg...) so it can register resources at construction time. WithResourceCapabilities(false, true) turns on listChanged. After the existing tool loop, registerResources() walks cfg.Tools and the blueprints dir, calling s.AddResource for each: factorly://tools/<name> (every non-workflow tool) factorly://workflows/<name> (every type: workflow tool) factorly://blueprints/<name> (every .yaml in .factorly/blueprints) All resources advertise application/yaml. Handlers close over the live cfg/cfgPath so subsequent reads pick up the current state. - internal/server/mcp.go — RefreshResources(s, cfg, cfgPath) reconciles the registered set against the desired set: DeleteResources for any factorly:// URI no longer needed, then re-AddResource for the rest. mcp-go's DeleteResources auto-emits notifications/resources/list_changed when listChanged is enabled, so connected clients see the new set without reconnecting. - internal/server/mcp_test.go — fixture with one tool, one workflow, and one installed blueprint; assert ListResources returns the three expected URIs; exercise each read handler and confirm body content + MIME; RefreshResources test mutates cfg + writes a new blueprint file and re-asserts the registered set. - cmd/factorly/serve_cmd.go, ui_cmd.go, wrap_cmd.go — updated callers. serve and ui pass live cfg/cfgPath; wrap (zero-config) passes cfg with an empty cfgPath, which short-circuits blueprint discovery. UI reload hook: - internal/ui/server.go — Server gains an OnReload func() field, called at the end of reloadConfig() on success, plus public Config() and CfgPath() accessors so external wiring can read the live state without reaching into private fields. - cmd/factorly/ui_cmd.go — when the embedded MCP endpoint is enabled, srv.OnReload is set to factorlyServer.RefreshResources(mcpSrv, srv.Config(), srv.CfgPath()). Adding a tool through the UI now triggers resources/list_changed on the MCP wire without a restart. UI "View YAML" pages: - New templates/yaml_view.html — breadcrumb back to the source page, a <pre><code> block with the YAML, a Copy button (navigator.clipboard), and a "Download .yaml" link. - internal/ui/server.go — yamlViewArgs + renderYAMLView shared helper. ?download=1 switches to Content-Type: application/yaml with a Content-Disposition: attachment; filename=<name>.yaml; otherwise the HTML wrapper is rendered. - Three handlers, each in the file next to its siblings: handleToolYAML → GET /tools/{name}/yaml handleWorkflowYAML → GET /workflows/{name}/yaml handleBlueprintYAML → GET /blueprints/installed/{name}/yaml The blueprint route uses an "/installed/" segment because Go 1.22's mux can't tell /blueprints/{name}/yaml from /blueprints/browse/{name}. The tool route 404s for type: workflow and vice-versa so breadcrumbs stay accurate. Blueprint handler does an existence check up front so missing names return 404, not 500. - View YAML links added to tool_edit.html, workflow_edit.html, and the blueprints list (next to Uninstall). All three use hx-boost="false" so they navigate normally rather than being captured by the body-level boost. - internal/ui/handlers_test.go — TestHandleToolYAML / TestHandleWorkflowYAML / TestHandleBlueprintYAML cover happy path, 404 for unknown / wrong kind, and the download mode (right Content-Type + Content-Disposition, comment preserved for blueprint raw bytes). CLI subcommands: - cmd/factorly/main.go — `factorly tools show <name>` loads the config, looks up cfg.Tools[name], pipes RenderTool output to stdout. Works for workflows because they share the tools map. Nonzero exit + error on unknown name. - cmd/factorly/blueprint_cmd.go — `factorly blueprint show <name>` reads the installed YAML from disk first (so comments + edits are preserved); falls back to the bundled YAML via blueprints.BundledByName so users can preview a blueprint before installing. Errors clearly when neither is found. - test/integration_test.go — TestToolsShowAndBlueprintShow covers a configured tool, an unknown tool (nonzero exit), an installed blueprint, a bundled-but-not-installed blueprint (fallback), and a missing blueprint name (nonzero exit). Out of scope for this PR: OAuth providers / vault backends as YAML views (needs a secret-redaction story first), MCP resource templates, MCP subscribe.
1 parent ce7664e commit f23cc14

19 files changed

Lines changed: 926 additions & 10 deletions

src/cmd/factorly/blueprint_cmd.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/factorly-dev/factorly/internal/blueprints"
1717
"github.com/factorly-dev/factorly/internal/config"
18+
"github.com/factorly-dev/factorly/internal/configyaml"
1819
)
1920

2021
var (
@@ -73,6 +74,33 @@ var blueprintListCmd = &cobra.Command{
7374
RunE: runBlueprintList,
7475
}
7576

77+
var blueprintShowCmd = &cobra.Command{
78+
Use: "show <name>",
79+
Short: "Print a blueprint's YAML to stdout (installed copy, or bundled if not installed)",
80+
Args: requireArgs(1, "factorly blueprint show <name>"),
81+
RunE: runBlueprintShow,
82+
}
83+
84+
func runBlueprintShow(cmd *cobra.Command, args []string) error {
85+
name := args[0]
86+
cfgPath := resolveCfgPath()
87+
88+
// Prefer the installed copy on disk (preserves comments + local edits).
89+
if data, err := configyaml.RenderBlueprint(cfgPath, name); err == nil {
90+
_, werr := os.Stdout.Write(data)
91+
return werr
92+
}
93+
94+
// Fall back to the bundled YAML so `factorly blueprint show <name>` can
95+
// preview a blueprint before installation.
96+
if bp := blueprints.BundledByName(name); bp != nil {
97+
_, err := os.Stdout.Write([]byte(bp.YAML))
98+
return err
99+
}
100+
101+
return fmt.Errorf("blueprint %q is not installed and is not in the bundled catalog", name)
102+
}
103+
76104
func runBlueprintInstall(cmd *cobra.Command, args []string) error {
77105
source := args[0]
78106
cfgPath := resolveCfgPath()
@@ -293,5 +321,5 @@ func isBundledName(source string) bool {
293321
func init() {
294322
blueprintInstallCmd.Flags().BoolVar(&blueprintInstallDryRun, "dry-run", false, "preview without writing")
295323
blueprintInstallCmd.Flags().BoolVar(&blueprintInstallNoPrompt, "no-prompt", false, "skip interactive vault key prompts")
296-
blueprintCmd.AddCommand(blueprintInstallCmd, blueprintUninstallCmd, blueprintListCmd)
324+
blueprintCmd.AddCommand(blueprintInstallCmd, blueprintUninstallCmd, blueprintListCmd, blueprintShowCmd)
297325
}

src/cmd/factorly/main.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/factorly-dev/factorly/internal"
2020
"github.com/factorly-dev/factorly/internal/builtins"
2121
"github.com/factorly-dev/factorly/internal/config"
22+
"github.com/factorly-dev/factorly/internal/configyaml"
2223
"github.com/factorly-dev/factorly/internal/logger"
2324
"github.com/factorly-dev/factorly/internal/oauth"
2425
"github.com/factorly-dev/factorly/internal/openapi"
@@ -161,6 +162,31 @@ var toolsListCmd = &cobra.Command{
161162
RunE: runToolsList,
162163
}
163164

165+
var toolsShowCmd = &cobra.Command{
166+
Use: "show <name>",
167+
Short: "Print a tool's (or workflow's) YAML definition to stdout",
168+
Args: requireArgs(1, "factorly tools show <name>"),
169+
RunE: runToolsShow,
170+
}
171+
172+
func runToolsShow(cmd *cobra.Command, args []string) error {
173+
name := args[0]
174+
cfg, _, err := loadConfig()
175+
if err != nil {
176+
return err
177+
}
178+
tc, ok := cfg.Tools[name]
179+
if !ok {
180+
return fmt.Errorf("tool %q is not configured", name)
181+
}
182+
out, err := configyaml.RenderTool(name, tc)
183+
if err != nil {
184+
return err
185+
}
186+
_, err = os.Stdout.Write(out)
187+
return err
188+
}
189+
164190
func runToolsList(cmd *cobra.Command, args []string) error {
165191
cfg, reg, err := loadConfig()
166192
if err != nil {
@@ -470,7 +496,7 @@ func init() {
470496
importOpenAPICmd.Flags().StringVarP(&importOpenAPIPrefix, "prefix", "p", "", "tool name prefix (default: from spec title)")
471497
importCmd.AddCommand(importOpenAPICmd)
472498

473-
toolsCmd.AddCommand(toolsListCmd, addCmd, removeCmd, importCmd, recordCmd, statusCmd)
499+
toolsCmd.AddCommand(toolsListCmd, toolsShowCmd, addCmd, removeCmd, importCmd, recordCmd, statusCmd)
474500
utilsCmd.AddCommand(autocompleteCmd)
475501
rootCmd.AddCommand(versionCmd, toolsCmd, callCmd, initCmd, syncCmd, vaultCmd, authCmd, serveCmd, wrapCmd, execCmd, logsCmd, utilsCmd, uiCmd, blueprintCmd)
476502
}

src/cmd/factorly/serve_cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ By default, uses stdio transport. Use --port to start an HTTP server instead.`,
6565
}
6666
}
6767

68-
s := factorlyServer.New(reg, p)
68+
s := factorlyServer.New(reg, p, cfg, resolveCfgPath())
6969

7070
ctx, stop := signal.NotifyContext(context.Background(),
7171
os.Interrupt, syscall.SIGTERM)

src/cmd/factorly/ui_cmd.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,13 @@ func runUI(cmd *cobra.Command, args []string) error {
169169

170170
// Optionally mount MCP endpoint on the UI server's mux
171171
if uiMCP {
172-
mcpSrv := factorlyServer.New(reg, p)
172+
mcpSrv := factorlyServer.New(reg, p, cfg, configPath)
173173
mcpHTTP := mcpserver.NewStreamableHTTPServer(mcpSrv)
174+
// Re-register MCP resources whenever the UI reloads config so the
175+
// list_changed notification fires for connected clients.
176+
srv.OnReload = func() {
177+
factorlyServer.RefreshResources(mcpSrv, srv.Config(), srv.CfgPath())
178+
}
174179

175180
// Resolve MCP token: flag → env var → vault refs
176181
if uiMCPToken == "" {

src/cmd/factorly/wrap_cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ func runWrap(cmd *cobra.Command, args []string) error {
185185
}
186186
}
187187

188-
s := factorlyServer.New(reg, p)
188+
s := factorlyServer.New(reg, p, cfg, "")
189189

190190
ctx, stop := signal.NotifyContext(context.Background(),
191191
os.Interrupt, syscall.SIGTERM)

src/internal/configyaml/render.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright 2026 Jordan Sherer <hi@jordansherer.com>
2+
// SPDX-License-Identifier: gpl
3+
4+
// Package configyaml renders tool and blueprint definitions back to YAML.
5+
// Used by the MCP resources surface, the UI "View YAML" page, and the
6+
// `factorly tools show` / `factorly blueprint show` CLI subcommands.
7+
package configyaml
8+
9+
import (
10+
"fmt"
11+
"os"
12+
"path/filepath"
13+
"strings"
14+
15+
"github.com/factorly-dev/factorly/internal/config"
16+
"gopkg.in/yaml.v3"
17+
)
18+
19+
// RenderTool serializes a single tool (or workflow) config back to YAML in
20+
// the same shape it would appear inside a loose .factorly/<file>.yaml — a
21+
// top-level "tools:" map keyed by name.
22+
func RenderTool(name string, tc config.ToolConfig) ([]byte, error) {
23+
if strings.TrimSpace(name) == "" {
24+
return nil, fmt.Errorf("tool name is empty")
25+
}
26+
doc := map[string]map[string]config.ToolConfig{
27+
"tools": {name: tc},
28+
}
29+
return yaml.Marshal(doc)
30+
}
31+
32+
// RenderBlueprint returns the raw bytes of the installed blueprint file at
33+
// .factorly/blueprints/<name>.yaml. Raw bytes (not a re-marshal) preserve
34+
// comments, key order, and any user edits.
35+
func RenderBlueprint(cfgPath, name string) ([]byte, error) {
36+
safe, err := safeName(name)
37+
if err != nil {
38+
return nil, err
39+
}
40+
path := filepath.Join(BlueprintsDir(cfgPath), safe+".yaml")
41+
data, err := os.ReadFile(path) // #nosec G304 -- path is built from a sanitized name
42+
if err != nil {
43+
if os.IsNotExist(err) {
44+
return nil, fmt.Errorf("blueprint %q is not installed", name)
45+
}
46+
return nil, fmt.Errorf("reading blueprint %q: %w", name, err)
47+
}
48+
return data, nil
49+
}
50+
51+
// BlueprintsDir returns the .factorly/blueprints/ directory for the given
52+
// config path, regardless of whether cfgPath is inside .factorly/ already.
53+
func BlueprintsDir(cfgPath string) string {
54+
cfgDir := filepath.Dir(cfgPath)
55+
if filepath.Base(cfgDir) == ".factorly" {
56+
return filepath.Join(cfgDir, "blueprints")
57+
}
58+
return filepath.Join(cfgDir, ".factorly", "blueprints")
59+
}
60+
61+
// safeName rejects path traversal and separator characters so we never
62+
// resolve a blueprint outside the blueprints directory.
63+
func safeName(name string) (string, error) {
64+
s := strings.TrimSpace(name)
65+
if s == "" || s == "." || s == ".." {
66+
return "", fmt.Errorf("invalid blueprint name: %q", name)
67+
}
68+
if strings.ContainsAny(s, "/\\") || strings.Contains(s, "..") {
69+
return "", fmt.Errorf("invalid blueprint name: %q (contains path separator or traversal)", name)
70+
}
71+
return s, nil
72+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Copyright 2026 Jordan Sherer <hi@jordansherer.com>
2+
// SPDX-License-Identifier: gpl
3+
4+
package configyaml
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"reflect"
10+
"strings"
11+
"testing"
12+
13+
"github.com/factorly-dev/factorly/internal/config"
14+
"gopkg.in/yaml.v3"
15+
)
16+
17+
func TestRenderTool_CLI(t *testing.T) {
18+
tc := config.ToolConfig{
19+
Type: "cli",
20+
Description: "list files in cwd",
21+
Command: "ls",
22+
Args: []string{"-la"},
23+
}
24+
out, err := RenderTool("ls.list", tc)
25+
if err != nil {
26+
t.Fatalf("RenderTool: %v", err)
27+
}
28+
got := string(out)
29+
if !strings.HasPrefix(got, "tools:") {
30+
t.Errorf("expected top-level tools: key, got:\n%s", got)
31+
}
32+
if !strings.Contains(got, "ls.list:") {
33+
t.Errorf("expected tool name as nested key, got:\n%s", got)
34+
}
35+
if !strings.Contains(got, "command: ls") {
36+
t.Errorf("expected command field, got:\n%s", got)
37+
}
38+
39+
// Round-trip back through the loader and assert the nested ToolConfig
40+
// matches the original.
41+
var doc struct {
42+
Tools map[string]config.ToolConfig `yaml:"tools"`
43+
}
44+
if err := yaml.Unmarshal(out, &doc); err != nil {
45+
t.Fatalf("round-trip unmarshal: %v", err)
46+
}
47+
if !reflect.DeepEqual(doc.Tools["ls.list"], tc) {
48+
t.Errorf("round-trip mismatch\nwant: %+v\ngot: %+v", tc, doc.Tools["ls.list"])
49+
}
50+
}
51+
52+
func TestRenderTool_REST(t *testing.T) {
53+
tc := config.ToolConfig{
54+
Type: "rest",
55+
Description: "list issues",
56+
BaseURL: "https://api.linear.app",
57+
Method: "POST",
58+
Path: "/graphql",
59+
Headers: map[string]string{"Content-Type": "application/json"},
60+
}
61+
out, err := RenderTool("linear.issues", tc)
62+
if err != nil {
63+
t.Fatalf("RenderTool: %v", err)
64+
}
65+
var doc struct {
66+
Tools map[string]config.ToolConfig `yaml:"tools"`
67+
}
68+
if err := yaml.Unmarshal(out, &doc); err != nil {
69+
t.Fatalf("round-trip unmarshal: %v", err)
70+
}
71+
if !reflect.DeepEqual(doc.Tools["linear.issues"], tc) {
72+
t.Errorf("round-trip mismatch\nwant: %+v\ngot: %+v", tc, doc.Tools["linear.issues"])
73+
}
74+
}
75+
76+
func TestRenderTool_Workflow(t *testing.T) {
77+
tc := config.ToolConfig{
78+
Type: "workflow",
79+
Description: "morning prep",
80+
Steps: []config.StepConfig{
81+
{Tool: "factorly.fetch", Params: map[string]string{"url": "https://example.com"}, Store: "data"},
82+
},
83+
}
84+
out, err := RenderTool("prep.daily", tc)
85+
if err != nil {
86+
t.Fatalf("RenderTool: %v", err)
87+
}
88+
if !strings.Contains(string(out), "type: workflow") {
89+
t.Errorf("expected type: workflow, got:\n%s", out)
90+
}
91+
92+
var doc struct {
93+
Tools map[string]config.ToolConfig `yaml:"tools"`
94+
}
95+
if err := yaml.Unmarshal(out, &doc); err != nil {
96+
t.Fatalf("round-trip unmarshal: %v", err)
97+
}
98+
if !reflect.DeepEqual(doc.Tools["prep.daily"], tc) {
99+
t.Errorf("round-trip mismatch\nwant: %+v\ngot: %+v", tc, doc.Tools["prep.daily"])
100+
}
101+
}
102+
103+
func TestRenderTool_EmptyName(t *testing.T) {
104+
if _, err := RenderTool("", config.ToolConfig{Type: "cli"}); err == nil {
105+
t.Error("expected error for empty name")
106+
}
107+
if _, err := RenderTool(" ", config.ToolConfig{Type: "cli"}); err == nil {
108+
t.Error("expected error for whitespace-only name")
109+
}
110+
}
111+
112+
func TestRenderBlueprint_RawBytes(t *testing.T) {
113+
dir := t.TempDir()
114+
bpDir := filepath.Join(dir, ".factorly", "blueprints")
115+
if err := os.MkdirAll(bpDir, 0o755); err != nil {
116+
t.Fatal(err)
117+
}
118+
cfgPath := filepath.Join(dir, ".factorly", "factorly.yaml")
119+
120+
// A blueprint file with a comment + custom field ordering — RenderBlueprint
121+
// must return these bytes verbatim, not re-marshal them.
122+
source := []byte(`# my custom comment
123+
name: gmail
124+
version: 1.0.0
125+
tools:
126+
gmail.list:
127+
type: rest
128+
description: list messages
129+
`)
130+
if err := os.WriteFile(filepath.Join(bpDir, "gmail.yaml"), source, 0o644); err != nil {
131+
t.Fatal(err)
132+
}
133+
134+
got, err := RenderBlueprint(cfgPath, "gmail")
135+
if err != nil {
136+
t.Fatalf("RenderBlueprint: %v", err)
137+
}
138+
if string(got) != string(source) {
139+
t.Errorf("blueprint not returned byte-for-byte\nwant:\n%s\ngot:\n%s", source, got)
140+
}
141+
}
142+
143+
func TestRenderBlueprint_NotInstalled(t *testing.T) {
144+
dir := t.TempDir()
145+
cfgPath := filepath.Join(dir, ".factorly", "factorly.yaml")
146+
if _, err := RenderBlueprint(cfgPath, "nope"); err == nil {
147+
t.Error("expected error for missing blueprint")
148+
}
149+
}
150+
151+
func TestRenderBlueprint_RejectsTraversal(t *testing.T) {
152+
dir := t.TempDir()
153+
cfgPath := filepath.Join(dir, ".factorly", "factorly.yaml")
154+
cases := []string{"../etc/passwd", "..", "", ".", "foo/bar", `foo\bar`}
155+
for _, name := range cases {
156+
if _, err := RenderBlueprint(cfgPath, name); err == nil {
157+
t.Errorf("expected rejection for %q", name)
158+
}
159+
}
160+
}
161+
162+
func TestBlueprintsDir(t *testing.T) {
163+
// cfgPath inside .factorly/ → use sibling blueprints/
164+
got := BlueprintsDir("/proj/.factorly/factorly.yaml")
165+
if got != "/proj/.factorly/blueprints" {
166+
t.Errorf("inside .factorly: got %q", got)
167+
}
168+
// cfgPath outside .factorly/ → add .factorly/blueprints/
169+
got = BlueprintsDir("/proj/factorly.yaml")
170+
if got != "/proj/.factorly/blueprints" {
171+
t.Errorf("outside .factorly: got %q", got)
172+
}
173+
}

0 commit comments

Comments
 (0)