@forhuman/flowmcp 0.1.0
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.
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/SKILL.md +214 -0
- package/bin/flowmcp +80 -0
- package/cases/flowmcp.md +79 -0
- package/commands/add.sh +46 -0
- package/commands/connect.sh +61 -0
- package/commands/debug.sh +122 -0
- package/commands/inspect.sh +58 -0
- package/commands/install.sh +67 -0
- package/commands/lang.sh +38 -0
- package/commands/list.sh +35 -0
- package/commands/remove.sh +82 -0
- package/commands/rename.sh +104 -0
- package/commands/rotate.sh +33 -0
- package/commands/run-mcp.sh +27 -0
- package/commands/schema.sh +131 -0
- package/commands/secret-set.sh +37 -0
- package/commands/test.sh +131 -0
- package/friction.md +67 -0
- package/lib/audit.sh +17 -0
- package/lib/bootstrap.sh +18 -0
- package/lib/clients.sh +107 -0
- package/lib/common.sh +66 -0
- package/lib/i18n.sh +248 -0
- package/lib/oauth.sh +22 -0
- package/lib/profiles.sh +58 -0
- package/lib/secrets.sh +89 -0
- package/lib/ui.sh +121 -0
- package/package.json +51 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Usage: flowmcp rename <old-org> <new-org> [--dry-run] [--json]
|
|
3
|
+
# Renames an org: moves its profile and saved credentials without any
|
|
4
|
+
# re-authentication, and updates the entry name in any client config where
|
|
5
|
+
# it was already installed (server names are derived from the org name, so
|
|
6
|
+
# a rename has to touch those too, not just our own storage).
|
|
7
|
+
|
|
8
|
+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/bootstrap.sh"
|
|
9
|
+
|
|
10
|
+
old="${1:?Usage: flowmcp rename <old-org> <new-org> [--dry-run]}"
|
|
11
|
+
new="${2:?Usage: flowmcp rename <old-org> <new-org> [--dry-run]}"
|
|
12
|
+
shift 2 || true
|
|
13
|
+
dry_run="" json_flag=""
|
|
14
|
+
while [[ $# -gt 0 ]]; do
|
|
15
|
+
case "$1" in
|
|
16
|
+
--dry-run) dry_run="1"; shift ;;
|
|
17
|
+
--json) json_flag="1"; shift ;;
|
|
18
|
+
*) echo "error: unknown option '$1'" >&2; exit 1 ;;
|
|
19
|
+
esac
|
|
20
|
+
done
|
|
21
|
+
|
|
22
|
+
wfw_valid_org "$new" || {
|
|
23
|
+
wfw_say_err "$(wfw_t msg_invalid_org "$new")"
|
|
24
|
+
exit 1
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
wfw_profile_exists "$old" || {
|
|
28
|
+
wfw_say_err "$(wfw_t msg_org_not_found "$old")"
|
|
29
|
+
exit 1
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if wfw_profile_exists "$new"; then
|
|
33
|
+
wfw_say_err "$(wfw_t msg_rename_new_exists "$new")"
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
auth_method="$(jq -r '.auth_method // "pat"' <<<"$(wfw_profile_read "$old")")"
|
|
38
|
+
|
|
39
|
+
# find which already-installed client configs would need updating, whether
|
|
40
|
+
# this is a real run or a dry-run — the scan itself never writes anything.
|
|
41
|
+
would_update=()
|
|
42
|
+
for spec in "claude-code:user" "claude-code:project" "claude-desktop:user" "cursor:user" "cursor:project"; do
|
|
43
|
+
client="${spec%%:*}"; scope="${spec#*:}"
|
|
44
|
+
path="$(wfw_client_config_path "$client" "$scope" 2>/dev/null || true)"
|
|
45
|
+
[[ -z "$path" || ! -f "$path" ]] && continue
|
|
46
|
+
jq empty "$path" >/dev/null 2>&1 || continue
|
|
47
|
+
has_old="$(jq -r --arg n "webflow-$old" '.mcpServers[$n] // empty' "$path")"
|
|
48
|
+
[[ -z "$has_old" ]] && continue
|
|
49
|
+
would_update+=("$path")
|
|
50
|
+
done
|
|
51
|
+
|
|
52
|
+
if [[ -n "$dry_run" ]]; then
|
|
53
|
+
if wfw_json_mode "$json_flag"; then
|
|
54
|
+
jq -nc --arg old "$old" --arg new "$new" \
|
|
55
|
+
--argjson updated "$(printf '%s\n' "${would_update[@]:-}" | jq -R . | jq -sc 'map(select(length>0))')" \
|
|
56
|
+
'{ok: true, dry_run: true, old: $old, new: $new, would_update: $updated}'
|
|
57
|
+
else
|
|
58
|
+
echo "${WFW_C_DIM}dry-run — would rename '$old' to '$new' (no re-login needed)${WFW_C_RESET}"
|
|
59
|
+
for p in "${would_update[@]:-}"; do
|
|
60
|
+
echo "${WFW_C_DIM}would update: $p (webflow-$old -> webflow-$new)${WFW_C_RESET}"
|
|
61
|
+
done
|
|
62
|
+
fi
|
|
63
|
+
exit 0
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
jq --arg org "$new" '.org = $org' "$(wfw_profile_path "$old")" > "$(wfw_profile_path "$new")"
|
|
67
|
+
rm -f "$(wfw_profile_path "$old")"
|
|
68
|
+
|
|
69
|
+
if [[ "$auth_method" == "mcp-remote" ]]; then
|
|
70
|
+
old_dir="$(wfw_mcp_remote_dir "$old")"
|
|
71
|
+
new_dir="$(wfw_mcp_remote_dir "$new")"
|
|
72
|
+
[[ -d "$old_dir" ]] && mv "$old_dir" "$new_dir"
|
|
73
|
+
else
|
|
74
|
+
wfw_old_token="$(wfw_secret_get "$old" || true)"
|
|
75
|
+
if [[ -n "$wfw_old_token" ]]; then
|
|
76
|
+
wfw_secret_set "$new" wfw_old_token
|
|
77
|
+
wfw_secret_delete "$old"
|
|
78
|
+
fi
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
updated=()
|
|
82
|
+
for path in "${would_update[@]:-}"; do
|
|
83
|
+
server_json="$(wfw_build_server_json "$new" "$auth_method")"
|
|
84
|
+
wfw_client_remove_server "$path" "webflow-$old"
|
|
85
|
+
wfw_client_merge_server "$path" "webflow-$new" "$server_json" "--force"
|
|
86
|
+
updated+=("$path")
|
|
87
|
+
done
|
|
88
|
+
|
|
89
|
+
wfw_audit_log "rename" "$old" "ok" "to=$new"
|
|
90
|
+
|
|
91
|
+
if wfw_json_mode "$json_flag"; then
|
|
92
|
+
jq -nc --arg old "$old" --arg new "$new" \
|
|
93
|
+
--argjson updated "$(printf '%s\n' "${updated[@]:-}" | jq -R . | jq -sc 'map(select(length>0))')" \
|
|
94
|
+
'{ok: true, dry_run: false, old: $old, new: $new, updated: $updated,
|
|
95
|
+
next_steps: (if ($updated | length) > 0 then ["restart any client whose config was just updated"] else [] end)}'
|
|
96
|
+
else
|
|
97
|
+
wfw_say_ok "$(wfw_t msg_rename_ok "$old" "$new")"
|
|
98
|
+
if [[ ${#updated[@]} -gt 0 ]]; then
|
|
99
|
+
for p in "${updated[@]}"; do
|
|
100
|
+
echo "${WFW_C_DIM}updated: $p (webflow-$old -> webflow-$new)${WFW_C_RESET}"
|
|
101
|
+
done
|
|
102
|
+
wfw_say_hint "$(wfw_t msg_rename_hint)"
|
|
103
|
+
fi
|
|
104
|
+
fi
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Usage: flowmcp rotate <org>
|
|
3
|
+
# Same interactive, human-only flow as secret-set — overwrites the existing
|
|
4
|
+
# token for an org that's already registered.
|
|
5
|
+
|
|
6
|
+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/bootstrap.sh"
|
|
7
|
+
|
|
8
|
+
org="${1:?Usage: flowmcp rotate <org>}"
|
|
9
|
+
|
|
10
|
+
wfw_profile_exists "$org" || {
|
|
11
|
+
echo "error: no org '$org' registered" >&2
|
|
12
|
+
exit 1
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if [[ ! -t 0 ]]; then
|
|
16
|
+
wfw_t msg_need_tty_rotate >&2
|
|
17
|
+
wfw_t msg_run_yourself_short >&2
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
wfw_t msg_rotate_enter "$org"
|
|
22
|
+
read -rs wfw_token_input
|
|
23
|
+
echo
|
|
24
|
+
if [[ -z "$wfw_token_input" ]]; then
|
|
25
|
+
echo "error: empty token, aborting" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
wfw_secret_set "$org" wfw_token_input
|
|
30
|
+
wfw_audit_log "rotate" "$org" "ok"
|
|
31
|
+
|
|
32
|
+
wfw_t msg_rotate_done "$org" "$(wfw_secret_backend)"
|
|
33
|
+
wfw_t msg_test_hint_new "$org"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Usage: run-mcp.sh <org>
|
|
3
|
+
#
|
|
4
|
+
# NOT meant to be run by a human or an agent directly. This is what the
|
|
5
|
+
# `command` field in the generated mcpServers entry points to — the MCP
|
|
6
|
+
# client (Claude Code / Claude Desktop / Cursor) launches this at server
|
|
7
|
+
# start time. It looks up the token for <org> from the secret backend,
|
|
8
|
+
# exports it, and execs webflow-mcp-server. The token therefore never
|
|
9
|
+
# needs to appear in any client's config JSON file on disk.
|
|
10
|
+
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
13
|
+
# shellcheck source=../lib/common.sh
|
|
14
|
+
source "$SCRIPT_DIR/../lib/common.sh"
|
|
15
|
+
# shellcheck source=../lib/secrets.sh
|
|
16
|
+
source "$SCRIPT_DIR/../lib/secrets.sh"
|
|
17
|
+
|
|
18
|
+
org="${1:?Usage: run-mcp.sh <org>}"
|
|
19
|
+
|
|
20
|
+
WEBFLOW_TOKEN="$(wfw_secret_get "$org" || true)"
|
|
21
|
+
if [[ -z "$WEBFLOW_TOKEN" ]]; then
|
|
22
|
+
echo "flowmcp: no token stored for org '$org'. Run 'flowmcp secret-set $org'." >&2
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
export WEBFLOW_TOKEN
|
|
26
|
+
|
|
27
|
+
exec npx -y webflow-mcp-server@latest
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Usage: flowmcp schema
|
|
3
|
+
# Machine-readable description of every command: args, flags, and the JSON
|
|
4
|
+
# shape it emits in --json mode. Always JSON, regardless of TTY — an agent
|
|
5
|
+
# should be able to `flowmcp schema` once at the start of a
|
|
6
|
+
# session and know the whole surface without parsing --help text.
|
|
7
|
+
|
|
8
|
+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/bootstrap.sh"
|
|
9
|
+
|
|
10
|
+
jq -nc --arg version "$(wfw_version)" '
|
|
11
|
+
{
|
|
12
|
+
schema_version: 1,
|
|
13
|
+
cli_version: $version,
|
|
14
|
+
commands: [
|
|
15
|
+
{
|
|
16
|
+
name: "connect",
|
|
17
|
+
usage: "connect <org> [--label NAME]",
|
|
18
|
+
mutates: true,
|
|
19
|
+
requires_tty: true,
|
|
20
|
+
description: "Add/reconnect an org via browser OAuth (mcp-remote, no setup needed).",
|
|
21
|
+
output: null
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "add",
|
|
25
|
+
usage: "add <org> [--label NAME]",
|
|
26
|
+
mutates: true,
|
|
27
|
+
requires_tty: false,
|
|
28
|
+
description: "Register org metadata only, no secret.",
|
|
29
|
+
output: null
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "secret-set",
|
|
33
|
+
usage: "secret-set <org>",
|
|
34
|
+
mutates: true,
|
|
35
|
+
requires_tty: true,
|
|
36
|
+
description: "Interactively paste a token. Refuses to run without a real TTY.",
|
|
37
|
+
output: null
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "rotate",
|
|
41
|
+
usage: "rotate <org>",
|
|
42
|
+
mutates: true,
|
|
43
|
+
requires_tty: true,
|
|
44
|
+
description: "Replace a stored token. Refuses to run without a real TTY.",
|
|
45
|
+
output: null
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "list",
|
|
49
|
+
usage: "list [--json]",
|
|
50
|
+
mutates: false,
|
|
51
|
+
requires_tty: false,
|
|
52
|
+
description: "List registered orgs and last test status.",
|
|
53
|
+
output: {shape: "array", item: "profile"}
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "inspect",
|
|
57
|
+
usage: "inspect <org> [--live] [--json]",
|
|
58
|
+
mutates: false,
|
|
59
|
+
requires_tty: false,
|
|
60
|
+
description: "Show one profile in full, plus a connected boolean. --live re-runs test first.",
|
|
61
|
+
output: {shape: "object", item: "profile + connected"}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "test",
|
|
65
|
+
usage: "test <org> [--json]",
|
|
66
|
+
mutates: false,
|
|
67
|
+
requires_tty: false,
|
|
68
|
+
description: "Validate stored credentials (network call for pat orgs, file check for mcp-remote orgs).",
|
|
69
|
+
output: {shape: "object", fields: ["org","auth_method","status","sites_count","scopes","error"]}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "install",
|
|
73
|
+
usage: "install <org> <client> [--scope user|project] [--force] [--dry-run] [--json]",
|
|
74
|
+
mutates: true,
|
|
75
|
+
requires_tty: false,
|
|
76
|
+
destructive: false,
|
|
77
|
+
description: "Merge an mcpServers entry into a client config. --dry-run prints the entry without writing.",
|
|
78
|
+
clients: ["claude-code","claude-desktop","cursor"],
|
|
79
|
+
output: {shape: "object", fields: ["ok","org","client","scope","path","dry_run"]}
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "remove",
|
|
83
|
+
usage: "remove <org> --yes [--from client:scope]... [--dry-run] [--json]",
|
|
84
|
+
mutates: true,
|
|
85
|
+
requires_tty: false,
|
|
86
|
+
destructive: true,
|
|
87
|
+
description: "Delete profile + credentials, optionally strip client entries. Requires --yes, no prompt.",
|
|
88
|
+
output: {shape: "object", fields: ["ok","org","removed_from","dry_run"]}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "rename",
|
|
92
|
+
usage: "rename <old-org> <new-org> [--dry-run] [--json]",
|
|
93
|
+
mutates: true,
|
|
94
|
+
requires_tty: false,
|
|
95
|
+
destructive: false,
|
|
96
|
+
description: "Move a profile + saved session to a new org name, no re-login, updates installed client configs.",
|
|
97
|
+
output: {shape: "object", fields: ["ok","old","new","updated"]}
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "debug",
|
|
101
|
+
usage: "debug <org> [--json]",
|
|
102
|
+
mutates: false,
|
|
103
|
+
requires_tty: false,
|
|
104
|
+
description: "Diagnose profile/credential/network/config issues.",
|
|
105
|
+
output: {shape: "object", fields: ["org","checks"]}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "schema",
|
|
109
|
+
usage: "schema",
|
|
110
|
+
mutates: false,
|
|
111
|
+
requires_tty: false,
|
|
112
|
+
description: "This output.",
|
|
113
|
+
output: {shape: "object", fields: ["schema_version","cli_version","commands"]}
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "lang",
|
|
117
|
+
usage: "lang [en|es] [--json]",
|
|
118
|
+
mutates: true,
|
|
119
|
+
requires_tty: false,
|
|
120
|
+
description: "View or change the saved human-readable output language. Only affects --help/banner text, never JSON.",
|
|
121
|
+
output: {shape: "object", fields: ["lang"]}
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
conventions: {
|
|
125
|
+
json_auto: "list/inspect/test/debug/install/remove/rename print JSON automatically when stdout is not a TTY, no --json flag required",
|
|
126
|
+
next_steps_field: "JSON output includes next_steps: [string] where applicable — suggested commands to run next",
|
|
127
|
+
exit_codes: {"0": "success", "1": "user error or failed check (org not found, gate refused, test failed)"},
|
|
128
|
+
secrets: "no command ever accepts a token as an argument or prints one; secret-set/rotate require a real TTY"
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Usage: flowmcp secret-set <org>
|
|
3
|
+
#
|
|
4
|
+
# INTERACTIVE ONLY. This is the one command in this tool that must be run
|
|
5
|
+
# by a human, in their own terminal, never through an agent's tool-call
|
|
6
|
+
# interface — the token is read with a hidden prompt and never touches
|
|
7
|
+
# argv, stdout, or any log. If stdin isn't a real TTY, this refuses to run.
|
|
8
|
+
|
|
9
|
+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/bootstrap.sh"
|
|
10
|
+
|
|
11
|
+
org="${1:?Usage: flowmcp secret-set <org>}"
|
|
12
|
+
|
|
13
|
+
wfw_profile_exists "$org" || {
|
|
14
|
+
echo "error: no org '$org' — run 'flowmcp add $org' first" >&2
|
|
15
|
+
exit 1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if [[ ! -t 0 ]]; then
|
|
19
|
+
wfw_t msg_need_tty_secret >&2
|
|
20
|
+
wfw_t msg_run_yourself1 >&2
|
|
21
|
+
wfw_t msg_run_yourself2 >&2
|
|
22
|
+
exit 1
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
wfw_t msg_secretset_enter "$org"
|
|
26
|
+
read -rs wfw_token_input
|
|
27
|
+
echo
|
|
28
|
+
if [[ -z "$wfw_token_input" ]]; then
|
|
29
|
+
echo "error: empty token, aborting" >&2
|
|
30
|
+
exit 1
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
wfw_secret_set "$org" wfw_token_input
|
|
34
|
+
wfw_audit_log "secret-set" "$org" "ok"
|
|
35
|
+
|
|
36
|
+
wfw_t msg_secretset_stored "$org" "$(wfw_secret_backend)"
|
|
37
|
+
wfw_t msg_test_hint "$org"
|
package/commands/test.sh
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Usage: flowmcp test <org> [--json]
|
|
3
|
+
# Validates the stored credentials. For "pat" orgs, the token is read into
|
|
4
|
+
# a local variable and used only in an Authorization header — it is never
|
|
5
|
+
# echoed, logged, or included in any error message. For "mcp-remote" orgs
|
|
6
|
+
# we deliberately don't make a live call here (it could trigger mcp-remote
|
|
7
|
+
# to silently pop open a browser if the session needs re-auth) — we just
|
|
8
|
+
# check that a completed session exists on disk.
|
|
9
|
+
# JSON automatically when stdout isn't a real TTY (e.g. an agent's tool call).
|
|
10
|
+
|
|
11
|
+
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/bootstrap.sh"
|
|
12
|
+
|
|
13
|
+
org="${1:?Usage: flowmcp test <org> [--json]}"
|
|
14
|
+
shift || true
|
|
15
|
+
json_flag=""
|
|
16
|
+
[[ "${1:-}" == "--json" ]] && json_flag="1"
|
|
17
|
+
|
|
18
|
+
wfw_profile_exists "$org" || {
|
|
19
|
+
if wfw_json_mode "$json_flag"; then
|
|
20
|
+
jq -nc --arg org "$org" '{org: $org, status: "fail", error: "org not registered"}'
|
|
21
|
+
else
|
|
22
|
+
wfw_say_err "$(wfw_t msg_org_not_found "$org")"
|
|
23
|
+
fi
|
|
24
|
+
exit 1
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
auth_method="$(jq -r '.auth_method // "pat"' <<<"$(wfw_profile_read "$org")")"
|
|
28
|
+
|
|
29
|
+
# wfw_test_emit <status> <hint> — final output in whichever mode was requested.
|
|
30
|
+
wfw_test_emit() {
|
|
31
|
+
local status="$1" hint="${2:-}"
|
|
32
|
+
if wfw_json_mode "$json_flag"; then
|
|
33
|
+
local next_hint="$hint"
|
|
34
|
+
[[ "$status" == "ok" ]] && next_hint="flowmcp install $org <client>"
|
|
35
|
+
jq -nc \
|
|
36
|
+
--arg org "$org" --arg auth "$auth_method" --arg status "$status" \
|
|
37
|
+
--arg error "$err_msg" --argjson scopes "${scopes_json:-[]}" \
|
|
38
|
+
--argjson sites "${sites_count:-null}" --arg next_hint "$next_hint" \
|
|
39
|
+
'{org: $org, auth_method: $auth, status: $status,
|
|
40
|
+
sites_count: $sites, scopes: $scopes,
|
|
41
|
+
error: (if $error == "" then null else $error end),
|
|
42
|
+
next_steps: (if $next_hint == "" then [] else [$next_hint] end)}'
|
|
43
|
+
else
|
|
44
|
+
if [[ "$status" == "ok" ]]; then
|
|
45
|
+
if [[ "$auth_method" == "mcp-remote" ]]; then
|
|
46
|
+
wfw_say_ok "$(wfw_t msg_test_mcpremote_ok "$org")"
|
|
47
|
+
echo "${WFW_C_DIM}$(wfw_t msg_test_note1)"
|
|
48
|
+
echo "$(wfw_t msg_test_note2)"
|
|
49
|
+
echo "$(wfw_t msg_test_note3)${WFW_C_RESET}"
|
|
50
|
+
else
|
|
51
|
+
wfw_say_ok "$(wfw_t msg_test_pat_ok "$org" "$sites_count")"
|
|
52
|
+
[[ "$scopes_json" != "[]" ]] && echo "${WFW_C_DIM}$(wfw_t msg_test_scopes "$(jq -r 'join(", ")' <<<"$scopes_json")")${WFW_C_RESET}"
|
|
53
|
+
fi
|
|
54
|
+
echo
|
|
55
|
+
wfw_say_next "$(wfw_t msg_test_next_intro)"
|
|
56
|
+
echo " - claude-code: flowmcp install $org claude-code"
|
|
57
|
+
echo " - claude-desktop: flowmcp install $org claude-desktop"
|
|
58
|
+
echo " - cursor: flowmcp install $org cursor"
|
|
59
|
+
else
|
|
60
|
+
wfw_say_err "$err_msg"
|
|
61
|
+
[[ -n "$hint" ]] && wfw_say_hint "$hint"
|
|
62
|
+
fi
|
|
63
|
+
fi
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
err_msg="" scopes_json="[]" sites_count="null"
|
|
67
|
+
|
|
68
|
+
if [[ "$auth_method" == "mcp-remote" ]]; then
|
|
69
|
+
if wfw_mcp_remote_connected "$org"; then
|
|
70
|
+
wfw_profile_update_last_test "$org" "ok" "[]" "null" ""
|
|
71
|
+
wfw_audit_log "test" "$org" "ok" "mcp-remote session present"
|
|
72
|
+
wfw_test_emit "ok"
|
|
73
|
+
exit 0
|
|
74
|
+
else
|
|
75
|
+
err_msg="no mcp-remote session found"
|
|
76
|
+
wfw_profile_update_last_test "$org" "fail" "[]" "null" "$err_msg"
|
|
77
|
+
wfw_audit_log "test" "$org" "fail" "$err_msg"
|
|
78
|
+
wfw_test_emit "fail" "run 'flowmcp connect $org' to log in"
|
|
79
|
+
exit 1
|
|
80
|
+
fi
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
wfw_require_curl
|
|
84
|
+
|
|
85
|
+
wfw_token="$(wfw_secret_get "$org" || true)"
|
|
86
|
+
if [[ -z "$wfw_token" ]]; then
|
|
87
|
+
err_msg="no token stored"
|
|
88
|
+
wfw_profile_update_last_test "$org" "fail" "[]" "null" "$err_msg"
|
|
89
|
+
wfw_test_emit "fail" "run 'flowmcp secret-set $org'"
|
|
90
|
+
exit 1
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
body_file="$(mktemp)"
|
|
94
|
+
trap 'rm -f "$body_file"; unset wfw_token' EXIT
|
|
95
|
+
|
|
96
|
+
http_code="$(curl -s -o "$body_file" -w "%{http_code}" \
|
|
97
|
+
-H "Authorization: Bearer $wfw_token" \
|
|
98
|
+
-H "accept: application/json" \
|
|
99
|
+
"https://api.webflow.com/v2/sites")"
|
|
100
|
+
|
|
101
|
+
if [[ "$http_code" == "200" ]]; then
|
|
102
|
+
sites_count="$(jq '.sites | length' "$body_file" 2>/dev/null || echo 0)"
|
|
103
|
+
|
|
104
|
+
auth_body_file="$(mktemp)"
|
|
105
|
+
auth_code="$(curl -s -o "$auth_body_file" -w "%{http_code}" \
|
|
106
|
+
-H "Authorization: Bearer $wfw_token" \
|
|
107
|
+
-H "accept: application/json" \
|
|
108
|
+
"https://api.webflow.com/v2/token/authorized_by")"
|
|
109
|
+
if [[ "$auth_code" == "200" ]] && jq -e '.scope' "$auth_body_file" >/dev/null 2>&1; then
|
|
110
|
+
scopes_json="$(jq -c '.scope | split(" ")' "$auth_body_file")"
|
|
111
|
+
fi
|
|
112
|
+
rm -f "$auth_body_file"
|
|
113
|
+
|
|
114
|
+
wfw_profile_update_last_test "$org" "ok" "$scopes_json" "$sites_count" ""
|
|
115
|
+
wfw_audit_log "test" "$org" "ok" "sites=$sites_count"
|
|
116
|
+
wfw_test_emit "ok"
|
|
117
|
+
else
|
|
118
|
+
err_msg="HTTP $http_code"
|
|
119
|
+
detail="$(jq -r '.message // empty' "$body_file" 2>/dev/null || true)"
|
|
120
|
+
[[ -n "$detail" ]] && err_msg="$err_msg: $detail"
|
|
121
|
+
wfw_profile_update_last_test "$org" "fail" "[]" "null" "$err_msg"
|
|
122
|
+
wfw_audit_log "test" "$org" "fail" "$err_msg"
|
|
123
|
+
hint=""
|
|
124
|
+
case "$http_code" in
|
|
125
|
+
401) hint="token is invalid, revoked, or expired — try 'flowmcp rotate $org'" ;;
|
|
126
|
+
403) hint="token is valid but lacks required scopes for this site/workspace" ;;
|
|
127
|
+
000) hint="no response — check network connectivity / DNS / proxy" ;;
|
|
128
|
+
esac
|
|
129
|
+
wfw_test_emit "fail" "$hint"
|
|
130
|
+
exit 1
|
|
131
|
+
fi
|
package/friction.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# friction.md
|
|
2
|
+
|
|
3
|
+
Written retrospectively (this file should have been opened on day one — see
|
|
4
|
+
the case entry for that admission). Kept going forward for anything that
|
|
5
|
+
turns out wrong for this domain.
|
|
6
|
+
|
|
7
|
+
## Contract origin
|
|
8
|
+
|
|
9
|
+
Mixed. `lib/profiles.sh` / `lib/secrets.sh` / `lib/clients.sh` are a defined
|
|
10
|
+
contract — we own the shape of a "profile" and a "client config entry".
|
|
11
|
+
The OAuth surface (`mcp.webflow.com`'s `.well-known/oauth-authorization-server`,
|
|
12
|
+
DCR at `/oauth/register`, PKCE support) was discovered, not assumed — verified
|
|
13
|
+
live with a real registration + full token exchange before writing `connect`
|
|
14
|
+
against it, rather than guessing at Webflow's OAuth behavior from docs.
|
|
15
|
+
|
|
16
|
+
## Rejected
|
|
17
|
+
|
|
18
|
+
- **Hand-rolled OAuth (PKCE + refresh + local callback server).** Built once,
|
|
19
|
+
worked, then thrown away in favor of shelling out to `mcp-remote`
|
|
20
|
+
(github.com/punkpeye/mcp-remote). Reason: it already solves this exact
|
|
21
|
+
problem (stdio↔remote-HTTP-MCP OAuth proxy) and is what the MCP ecosystem
|
|
22
|
+
actually uses — re-implementing token refresh and SSE proxying ourselves
|
|
23
|
+
was surface area with no payoff. The one thing we still own is per-org
|
|
24
|
+
isolation, via `MCP_REMOTE_CONFIG_DIR`.
|
|
25
|
+
- **Custom OAuth App setup step (client_id/secret the user creates by hand).**
|
|
26
|
+
Assumed necessary at first because that's how Webflow's classic Data API
|
|
27
|
+
OAuth (`api.webflow.com/oauth`) works — no PKCE alternative there. Turned
|
|
28
|
+
out false for the hosted MCP server specifically: `mcp.webflow.com`
|
|
29
|
+
supports Dynamic Client Registration, so `connect` needed zero setup.
|
|
30
|
+
Confirmed by reading the metadata endpoint, not by assumption.
|
|
31
|
+
- **`flowmcp` as a Claude-Code-only tool.** Considered scoping
|
|
32
|
+
install targets to just `claude-code`. Rejected once it became clear the
|
|
33
|
+
actual pain point (Claude Desktop's native Connector supporting only one
|
|
34
|
+
Webflow account) lives in Claude Desktop, not Claude Code — `install`
|
|
35
|
+
needed `claude-desktop` and `cursor` from the start.
|
|
36
|
+
|
|
37
|
+
## What turned out wrong
|
|
38
|
+
|
|
39
|
+
- **Test-org naming.** Used a throwaway name (`aktis-juanpa`) during manual
|
|
40
|
+
testing of `rename` that collided with a real, already-connected client
|
|
41
|
+
org of the same name. `rename`'s client-config scan resolves through the
|
|
42
|
+
real `$HOME` (by design — `install`/`remove`/`rename` are meant to touch
|
|
43
|
+
real, shared, global client config files), so it silently repointed a real
|
|
44
|
+
entry in the user's real Claude Desktop config at a fake session directory.
|
|
45
|
+
Recovered via `wfw_client_merge_server`'s own `.bak` backup, no data lost.
|
|
46
|
+
Fix going forward: test org names must be obviously fake (`zzz-test-*`)
|
|
47
|
+
and functional tests must run under an isolated `WFW_HOME`/`HOME`, not the
|
|
48
|
+
real ones — see the test blocks now embedded in the build for `schema`,
|
|
49
|
+
`--dry-run`, and `next_steps`.
|
|
50
|
+
- **`set -e` swallowing errors on command substitution.** Recurred four
|
|
51
|
+
separate times (`wfw_secret_get` in `test.sh`, `run-mcp.sh`, and the
|
|
52
|
+
`wfw_wait_with_logo`/`wait` pattern in `lib/ui.sh`): assigning the output
|
|
53
|
+
of a command that can fail, without wrapping it in `if`/`|| true`, kills
|
|
54
|
+
the whole script silently before the intended error handling runs. Now a
|
|
55
|
+
standing check whenever a new command script reads a keychain value.
|
|
56
|
+
|
|
57
|
+
## Conventions this build confirms
|
|
58
|
+
|
|
59
|
+
- `--json` automatically on non-TTY stdout, no flag required — implemented
|
|
60
|
+
across every read command (`list`/`inspect`/`test`/`debug`) plus every
|
|
61
|
+
write command that now also supports `--json` (`install`/`remove`/`rename`).
|
|
62
|
+
- `next_steps` in structured output, so an agent isn't left to guess the
|
|
63
|
+
follow-up command from prose it may not even be shown.
|
|
64
|
+
- `schema` command, versioned (`schema_version`), listing every command's
|
|
65
|
+
usage, JSON output shape, and whether it mutates/requires a TTY/is
|
|
66
|
+
destructive — added specifically because the skill flagged it as the
|
|
67
|
+
highest-value, least-adopted convention in its corpus.
|
package/lib/audit.sh
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Append-only audit log. Never write secret values here.
|
|
3
|
+
|
|
4
|
+
wfw_audit_log() {
|
|
5
|
+
local action="$1" org="$2" result="$3" detail="${4:-}"
|
|
6
|
+
local month file
|
|
7
|
+
month="$(date -u +%Y-%m)"
|
|
8
|
+
file="$WFW_AUDIT_DIR/$month.jsonl"
|
|
9
|
+
jq -nc \
|
|
10
|
+
--arg ts "$(wfw_now)" \
|
|
11
|
+
--arg action "$action" \
|
|
12
|
+
--arg org "$org" \
|
|
13
|
+
--arg result "$result" \
|
|
14
|
+
--arg detail "$detail" \
|
|
15
|
+
'{ts: $ts, action: $action, org: $org, result: $result, detail: (if $detail == "" then null else $detail end)}' \
|
|
16
|
+
>> "$file"
|
|
17
|
+
}
|
package/lib/bootstrap.sh
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Idempotent loader: makes every command script work whether it's
|
|
3
|
+
# `source`d by bin/flowmcp (functions already in-process) or
|
|
4
|
+
# executed as its own subprocess (e.g. debug.sh shelling out to test.sh).
|
|
5
|
+
BOOTSTRAP_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
: "${WFW_LIB_DIR:=$BOOTSTRAP_LIB_DIR}"
|
|
7
|
+
: "${WFW_COMMANDS_DIR:=$BOOTSTRAP_LIB_DIR/../commands}"
|
|
8
|
+
|
|
9
|
+
if ! declare -f wfw_profile_exists >/dev/null 2>&1; then
|
|
10
|
+
source "$WFW_LIB_DIR/common.sh"
|
|
11
|
+
source "$WFW_LIB_DIR/secrets.sh"
|
|
12
|
+
source "$WFW_LIB_DIR/profiles.sh"
|
|
13
|
+
source "$WFW_LIB_DIR/audit.sh"
|
|
14
|
+
source "$WFW_LIB_DIR/clients.sh"
|
|
15
|
+
source "$WFW_LIB_DIR/oauth.sh"
|
|
16
|
+
source "$WFW_LIB_DIR/ui.sh"
|
|
17
|
+
source "$WFW_LIB_DIR/i18n.sh"
|
|
18
|
+
fi
|